Reload daemon configuration without restarting (#3365)

* feat(daemon): reload configuration without restarting

Keep config validation, reloadability classification, override handling, and atomic runtime application inside the daemon so local and remote clients share one authoritative workflow.

* fix(daemon): preserve reload resource ownership

Keep every materialized provider client under daemon-lifetime ownership and classify speech overrides from resolved providers. Restore the complete WebSocket config-store test contract exposed by CI.
This commit is contained in:
Mohamed Boudra
2026-08-14 22:46:32 +02:00
committed by GitHub
parent 75bbca4a1b
commit 4748aad103
36 changed files with 2590 additions and 246 deletions
@@ -0,0 +1,321 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { DaemonConfigStore } from "../daemon-config-store.js";
import type { PersistedConfig } from "../persisted-config.js";
import type {
AgentClient,
AgentMode,
AgentModelDefinition,
FetchCatalogOptions,
ProviderSnapshotEntry,
} from "./agent-sdk-types.js";
import { attachMutableProviderConfigOwner } from "./mutable-provider-config-owner.js";
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
const tempDirs: string[] = [];
const CONTROLLED_PROVIDERS = {
claude: { enabled: false },
codex: { enabled: true },
copilot: { enabled: false },
omp: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
} as const;
interface Catalog {
models: AgentModelDefinition[];
modes: AgentMode[];
}
function controlledCodexClient(fetchCatalog: (options: FetchCatalogOptions) => Promise<Catalog>) {
return {
provider: "codex",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
async createSession() {
throw new Error("not implemented");
},
async resumeSession() {
throw new Error("not implemented");
},
isAvailable: async () => true,
fetchCatalog,
} satisfies AgentClient;
}
function catalog(modelId: string): Catalog {
return {
models: [{ provider: "codex", id: modelId, label: modelId }],
modes: [],
};
}
function recordCodexEvent(events: string[]): (entries: ProviderSnapshotEntry[]) => void {
return (entries) => {
const codex = entries.find((entry) => entry.provider === "codex");
events.push(`${codex?.status}:${codex?.models?.[0]?.id ?? "none"}`);
};
}
function getCodexSnapshot(manager: ProviderSnapshotManager, cwd: string) {
return manager.getSnapshot(cwd).find((entry) => entry.provider === "codex");
}
function mutableConfig(persisted: PersistedConfig): MutableDaemonConfig {
return {
relay: { enabled: false },
mcp: { enabled: true, injectIntoAgents: false },
browserTools: { enabled: false },
providers: persisted.agents?.providers ?? CONTROLLED_PROVIDERS,
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
cors: { allowedOrigins: persisted.daemon?.cors?.allowedOrigins ?? [] },
trustedProxies: ["loopback"],
git: {
maxProcessesPerSecond: persisted.daemon?.git?.maxProcessesPerSecond ?? 64,
maxProcessConcurrency: persisted.daemon?.git?.maxProcessConcurrency ?? 8,
},
app: { baseUrl: persisted.app?.baseUrl ?? "https://app.paseo.sh" },
};
}
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) rmSync(tempDir, { recursive: true, force: true });
});
describe("mutable provider config owner", () => {
test("publishes provider changes after commit and emits nothing on rollback", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 }));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
});
const cwd = path.resolve("/tmp/provider-config-owner");
manager.getSnapshot(cwd);
const events: string[] = [];
manager.on("change", (_entries, changedCwd) => events.push(changedCwd));
let agentManagerState = manager.getAgentManagerProviderState();
const unsubscribe = attachMutableProviderConfigOwner({
store,
providerSnapshotManager: manager,
updateProviderRegistry: (state) => {
agentManagerState = state;
},
});
const unsubscribeTimingCheck = store.onApply(() => {
expect(events).toEqual([]);
return () => undefined;
});
try {
store.patch({ providers: { codex: { enabled: false } } });
expect(events).toEqual([cwd]);
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
events.length = 0;
const unsubscribeFailure = store.onApply(() => {
throw new Error("later owner failed");
});
expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow(
"later owner failed",
);
unsubscribeFailure();
expect(events).toEqual([]);
expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({
enabled: false,
});
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
} finally {
unsubscribeTimingCheck();
unsubscribe();
manager.destroy();
}
});
test("does no provider work for unrelated CORS, Git, and app URL reloads", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const initial: PersistedConfig = {
version: 1,
daemon: {
cors: { allowedOrigins: ["https://before.example.test"] },
git: { maxProcessesPerSecond: 64, maxProcessConcurrency: 8 },
},
app: { baseUrl: "https://before.example.test" },
agents: { providers: { codex: { enabled: true } } },
};
const configPath = path.join(paseoHome, "config.json");
writeFileSync(configPath, `${JSON.stringify(initial, null, 2)}\n`, "utf-8");
const store = new DaemonConfigStore(paseoHome, mutableConfig(initial), undefined, {
startupPersisted: initial,
reloadSource: {
resolve: (persisted) => ({
mutable: mutableConfig(persisted),
overrideControlledPaths: [],
}),
},
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
});
manager.getSnapshot("/tmp/provider-config-owner");
const events: string[] = [];
manager.on("change", (_entries, cwd) => events.push(cwd));
let registryUpdates = 0;
const unsubscribe = attachMutableProviderConfigOwner({
store,
providerSnapshotManager: manager,
updateProviderRegistry: () => {
registryUpdates += 1;
},
});
try {
const reloaded: PersistedConfig = {
...initial,
daemon: {
...initial.daemon,
cors: { allowedOrigins: ["https://after.example.test"] },
git: { maxProcessesPerSecond: 5, maxProcessConcurrency: 1 },
},
app: { baseUrl: "https://after.example.test" },
};
writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8");
expect(store.reload().appliedPaths).toEqual([
"app.baseUrl",
"daemon.cors.allowedOrigins",
"daemon.git.maxProcessConcurrency",
"daemon.git.maxProcessesPerSecond",
]);
expect(events).toEqual([]);
expect(registryUpdates).toBe(0);
expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({
enabled: true,
});
} finally {
unsubscribe();
manager.destroy();
}
});
test("replaces an in-flight catalog after provider config commits", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 }));
const catalogResolvers: Array<(value: Catalog) => void> = [];
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: {
codex: controlledCodexClient(
() => new Promise((resolve) => catalogResolvers.push(resolve)),
),
},
});
const cwd = path.resolve("/tmp/provider-config-commit");
const events: string[] = [];
manager.on("change", recordCodexEvent(events));
const unsubscribe = attachMutableProviderConfigOwner({
store,
providerSnapshotManager: manager,
updateProviderRegistry: () => undefined,
});
try {
const originalRead = manager.getProvider({ cwd, provider: "codex", wait: true });
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
expect(events).toEqual(["loading:none"]);
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2));
catalogResolvers[0]?.(catalog("stale-model"));
await originalRead;
expect(events).toEqual(["loading:none"]);
catalogResolvers[1]?.(catalog("current-model"));
await vi.waitFor(() =>
expect(getCodexSnapshot(manager, cwd)).toMatchObject({
status: "ready",
models: [{ id: "current-model" }],
}),
);
expect(events).toEqual(["loading:none", "ready:current-model"]);
} finally {
unsubscribe();
manager.destroy();
}
});
test("lets the original in-flight catalog publish after provider config rolls back", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 }));
const catalogResolvers: Array<(value: Catalog) => void> = [];
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: {
codex: controlledCodexClient(
() => new Promise((resolve) => catalogResolvers.push(resolve)),
),
},
});
const cwd = path.resolve("/tmp/provider-config-rollback");
const events: string[] = [];
manager.on("change", recordCodexEvent(events));
const unsubscribe = attachMutableProviderConfigOwner({
store,
providerSnapshotManager: manager,
updateProviderRegistry: () => undefined,
});
const unsubscribeFailure = store.onApply(() => {
throw new Error("later owner failed");
});
try {
const originalRead = manager.getProvider({ cwd, provider: "codex", wait: true });
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
expect(() =>
store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }),
).toThrow("later owner failed");
expect(events).toEqual([]);
catalogResolvers[0]?.(catalog("original-model"));
await expect(originalRead).resolves.toMatchObject({
status: "ready",
models: [{ id: "original-model" }],
});
expect(getCodexSnapshot(manager, cwd)).toMatchObject({
status: "ready",
models: [{ id: "original-model" }],
});
expect(events.length).toBeGreaterThan(0);
expect(new Set(events)).toEqual(new Set(["ready:original-model"]));
expect(catalogResolvers).toHaveLength(1);
} finally {
unsubscribeFailure();
unsubscribe();
manager.destroy();
}
});
});
@@ -0,0 +1,49 @@
import equal from "fast-deep-equal";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import type {
AgentManagerProviderState,
ProviderSnapshotManager,
} from "./provider-snapshot-manager.js";
export function attachMutableProviderConfigOwner(options: {
store: DaemonConfigStore;
providerSnapshotManager: ProviderSnapshotManager;
updateProviderRegistry: (state: AgentManagerProviderState) => void;
}): () => void {
let publishPendingProviderChange: (() => void) | null = null;
const unsubscribeApply = options.store.onApply((config, previous, details) => {
if (equal(config.providers, previous.providers)) return () => undefined;
const previousAgentManagerState =
options.providerSnapshotManager.getAgentManagerProviderState();
const staged = options.providerSnapshotManager.stageMutableProviderConfig(config.providers, {
removeProviders: details.removedProviders,
replace: true,
});
try {
options.updateProviderRegistry(staged.agentManagerState);
} catch (error) {
staged.rollback();
throw error;
}
publishPendingProviderChange = staged.publish;
return () => {
publishPendingProviderChange = null;
staged.rollback();
options.updateProviderRegistry(previousAgentManagerState);
};
});
const unsubscribeChange = options.store.onChange(() => {
const publish = publishPendingProviderChange;
publishPendingProviderChange = null;
publish?.();
});
return () => {
unsubscribeApply();
unsubscribeChange();
};
}
@@ -442,6 +442,34 @@ describe("ProviderSnapshotManager public surface", () => {
}
});
test("setRefreshTimeoutMs changes the deadline for future refreshes", async () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 60_000,
providerOverrides: {
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
},
extraClients: {
codex: createExtraClient("codex", { isAvailable: vi.fn(waitUntilAborted) }),
},
});
manager.setRefreshTimeoutMs(1);
try {
await expect(
manager.getProvider({ cwd: "/tmp/project", provider: "codex", wait: true }),
).resolves.toMatchObject({
status: "error",
error: "Timed out refreshing Codex after 1ms; pending: availability",
});
} finally {
manager.destroy();
}
});
test("defaults provider refreshes to a two-minute deadline", async () => {
vi.useFakeTimers();
const manager = new ProviderSnapshotManager({
@@ -1405,9 +1433,131 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
manager.destroy();
}
});
test("stages provider state without events, then publishes or rolls back", () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
claude: { enabled: false },
codex: { enabled: true },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
},
});
try {
const cwd = resolve("/tmp/project");
manager.getSnapshot(cwd);
const events: string[] = [];
manager.on("change", (_entries, changedCwd) => events.push(changedCwd));
const rolledBack = manager.stageMutableProviderConfig(
{ codex: { enabled: false } },
{ replace: true },
);
expect(events).toEqual([]);
expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({
enabled: false,
});
rolledBack.rollback();
expect(events).toEqual([]);
expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({
enabled: true,
});
const committed = manager.stageMutableProviderConfig(
{ codex: { enabled: false } },
{ replace: true },
);
expect(events).toEqual([]);
committed.publish();
expect(events).toEqual([cwd]);
} finally {
manager.destroy();
}
});
test("restores provider state when applying a live config fails", () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
claude: { enabled: false },
codex: { enabled: true },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
},
});
manager.getSnapshot("/tmp/project");
manager.on("change", () => {
throw new Error("snapshot consumer failed");
});
try {
expect(() =>
manager.applyMutableProviderConfig({ codex: { enabled: false } }, { replace: true }),
).toThrow("snapshot consumer failed");
expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({
enabled: true,
});
} finally {
manager.destroy();
}
});
});
describe("ProviderSnapshotManager lifecycle", () => {
test("owns every materialized client generation until daemon shutdown", async () => {
const providerConfig = (label: string) => ({
claude: { enabled: false },
codex: { enabled: true, label },
copilot: { enabled: false },
omp: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: providerConfig("Initial"),
});
const shutdowns: Array<ReturnType<typeof vi.fn>> = [];
const trackShutdown = (client: AgentClient | undefined): AgentClient => {
if (!client) throw new Error("Expected materialized Codex client");
const shutdown = vi.fn(async () => undefined);
client.shutdown = shutdown;
shutdowns.push(shutdown);
return client;
};
const initialClient = trackShutdown(manager.getAgentManagerProviderState().clients.codex);
const published = manager.stageMutableProviderConfig(providerConfig("Published"), {
replace: true,
});
const publishedClient = trackShutdown(published.agentManagerState.clients.codex);
published.publish();
const rolledBack = manager.stageMutableProviderConfig(providerConfig("Rolled back"), {
replace: true,
});
trackShutdown(rolledBack.agentManagerState.clients.codex);
rolledBack.rollback();
expect(manager.getAgentManagerProviderState().clients.codex).toBe(publishedClient);
const newest = manager.stageMutableProviderConfig(providerConfig("Newest"), { replace: true });
const newestClient = trackShutdown(newest.agentManagerState.clients.codex);
newest.publish();
expect(initialClient).not.toBe(publishedClient);
expect(manager.getAgentManagerProviderState().clients.codex).toBe(newestClient);
for (const shutdown of shutdowns) expect(shutdown).not.toHaveBeenCalled();
await manager.shutdown();
for (const shutdown of shutdowns) expect(shutdown).toHaveBeenCalledTimes(1);
manager.destroy();
});
test("on/off attaches and detaches change listeners", () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
@@ -85,15 +85,9 @@ function omitProviderOverrides(
overrides: Record<string, ProviderOverride> | undefined,
providers: readonly string[],
): Record<string, ProviderOverride> | undefined {
if (!overrides || providers.length === 0) {
return overrides;
}
if (!overrides || providers.length === 0) return overrides;
const nextOverrides = { ...overrides };
for (const provider of providers) {
delete nextOverrides[provider];
}
for (const provider of providers) delete nextOverrides[provider];
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
}
@@ -129,6 +123,13 @@ interface ProviderSnapshotReadOptions {
interface ApplyMutableProviderConfigOptions {
removeProviders?: readonly string[];
replace?: boolean;
}
export interface StagedMutableProviderConfig {
agentManagerState: AgentManagerProviderState;
publish(): void;
rollback(): void;
}
interface ProviderSnapshotProviderOptions {
@@ -185,6 +186,16 @@ interface ProviderLoad {
promise: Promise<void>;
}
interface MutableProviderState {
baseProviderOverrides: Record<string, ProviderOverride> | undefined;
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined;
providerOverrides: Record<string, ProviderOverride> | undefined;
providerRegistry: Record<AgentProvider, ProviderDefinition>;
providerClients: Record<AgentProvider, AgentClient>;
snapshots: Map<string, Map<AgentProvider, ProviderSnapshotEntry>>;
providerLoads: Map<string, Map<AgentProvider, ProviderLoad>>;
}
type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string };
interface ProviderSnapshotTarget {
@@ -197,8 +208,8 @@ export class ProviderSnapshotManager {
private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>();
private readonly events = new EventEmitter();
private destroyed = false;
private readonly refreshTimeoutMs: number;
private readonly diagnosticTimeoutMs: number;
private refreshTimeoutMs: number;
private diagnosticTimeoutMs: number;
private readonly logger: Logger;
private readonly workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
private readonly managedProcesses?: ManagedProcessRegistry;
@@ -209,6 +220,7 @@ export class ProviderSnapshotManager {
private baseProviderOverrides: Record<string, ProviderOverride> | undefined;
private providerRegistry: Record<AgentProvider, ProviderDefinition>;
private providerClients: Record<AgentProvider, AgentClient>;
private readonly ownedClients = new Set<AgentClient>();
constructor(options: ProviderSnapshotManagerOptions) {
this.logger = options.logger;
@@ -226,6 +238,7 @@ export class ProviderSnapshotManager {
);
this.providerRegistry = this.buildRegistry();
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
for (const client of Object.values(this.providerClients)) this.ownedClients.add(client);
}
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
@@ -317,6 +330,7 @@ export class ProviderSnapshotManager {
}
const client = definition.createClient(this.logger);
this.providerClients[provider] = client;
this.ownedClients.add(client);
return client;
}
@@ -456,24 +470,97 @@ export class ProviderSnapshotManager {
mutableProviders: MutableDaemonConfig["providers"] | undefined,
options: ApplyMutableProviderConfigOptions = {},
): AgentManagerProviderState {
this.baseProviderOverrides = omitProviderOverrides(
this.baseProviderOverrides,
options.removeProviders ?? [],
);
this.providerOverrides = applyMutableProviderConfigToOverrides(
this.baseProviderOverrides,
mutableProviders,
);
this.providerRegistry = this.buildRegistry();
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
for (const cwd of this.snapshots.keys()) {
this.providerLoads.delete(cwd);
this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd));
this.emitChange(cwd);
const staged = this.stageMutableProviderConfig(mutableProviders, options);
try {
staged.publish();
return staged.agentManagerState;
} catch (error) {
staged.rollback();
throw error;
}
}
return this.getAgentManagerProviderState();
stageMutableProviderConfig(
mutableProviders: MutableDaemonConfig["providers"] | undefined,
options: ApplyMutableProviderConfigOptions = {},
): StagedMutableProviderConfig {
const previous = this.captureMutableProviderState();
const snapshotCwds = Array.from(this.snapshots.keys());
try {
if (options.replace) {
this.baseProviderOverrides = undefined;
this.runtimeSettings = undefined;
} else {
this.baseProviderOverrides = omitProviderOverrides(
this.baseProviderOverrides,
options.removeProviders ?? [],
);
}
this.providerOverrides = applyMutableProviderConfigToOverrides(
this.baseProviderOverrides,
mutableProviders,
);
// The mutable config is the complete provider source after startup. Keeping
// startup-derived runtime settings here would retain removed command/env fields.
if (options.replace) this.runtimeSettings = undefined;
this.providerRegistry = this.buildRegistry();
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
for (const cwd of this.snapshots.keys()) {
this.providerLoads.delete(cwd);
this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd));
}
return {
agentManagerState: this.getAgentManagerProviderState(),
publish: () => {
for (const cwd of snapshotCwds) {
this.emitChange(cwd);
const target =
cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY
? createGlobalSnapshotTarget()
: createWorkspaceSnapshotTarget(cwd);
const providers = this.resolveProvidersToWarm(cwd);
if (providers.length > 0) void this.warmUp(target, providers);
}
},
rollback: () => this.restoreMutableProviderState(previous),
};
} catch (error) {
this.restoreMutableProviderState(previous);
throw error;
}
}
private captureMutableProviderState(): MutableProviderState {
return {
baseProviderOverrides: this.baseProviderOverrides,
runtimeSettings: this.runtimeSettings,
providerOverrides: this.providerOverrides,
providerRegistry: this.providerRegistry,
providerClients: this.providerClients,
// Preserve the inner map identities: in-flight refreshes close over them.
// Staging replaces active maps instead of mutating these originals.
snapshots: new Map(this.snapshots),
providerLoads: new Map(this.providerLoads),
};
}
private restoreMutableProviderState(previous: MutableProviderState): void {
this.baseProviderOverrides = previous.baseProviderOverrides;
this.runtimeSettings = previous.runtimeSettings;
this.providerOverrides = previous.providerOverrides;
this.providerRegistry = previous.providerRegistry;
this.providerClients = previous.providerClients;
this.snapshots.clear();
for (const [cwd, entries] of previous.snapshots) this.snapshots.set(cwd, entries);
this.providerLoads.clear();
for (const [cwd, loads] of previous.providerLoads) this.providerLoads.set(cwd, loads);
}
setRefreshTimeoutMs(refreshTimeoutMs: number | undefined): void {
this.refreshTimeoutMs = resolveRefreshTimeoutMs(refreshTimeoutMs);
this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs(undefined, this.refreshTimeoutMs);
}
on(event: "change", listener: ProviderSnapshotChangeListener): this {
@@ -490,11 +577,8 @@ export class ProviderSnapshotManager {
// Materialize a client per enabled provider so provider-owned resources
// (background processes, sockets, etc.) get a chance to release even when
// a given provider hasn't been touched yet during this daemon's lifetime.
const state = this.getAgentManagerProviderState();
const clients = Object.values(state.clients).filter(
(client): client is AgentClient => client !== undefined,
);
await shutdownAgentClients(clients, this.logger);
this.getAgentManagerProviderState();
await shutdownAgentClients(this.ownedClients, this.logger);
}
destroy(): void {
@@ -665,18 +749,22 @@ export class ProviderSnapshotManager {
defaultModeId: definition?.defaultModeId ?? null,
};
if (!definition?.enabled || !current || current.status === "loading") {
if (!definition?.enabled) {
entries.set(provider, {
...metadata,
status: "unavailable",
enabled: definition?.enabled ?? true,
enabled: false,
});
continue;
}
entries.set(provider, {
...current,
...metadata,
status: "loading",
enabled: true,
models: current?.models,
modes: current?.modes,
fetchedAt: current?.fetchedAt,
});
}
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { AgentManagerShuttingDownError } from "./agent/agent-manager.js";
import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
@@ -15,6 +16,11 @@ import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
import {
configureGitProcessPolicy,
snapshotGitCommandRuntimeMetrics,
} from "../utils/run-git-command.js";
import { DEFAULT_GIT_PROCESS_POLICY } from "../utils/git-process-scheduler.js";
import type {
HubEnrollment,
HubEnrollmentResult,
@@ -83,10 +89,200 @@ describe("paseo daemon bootstrap", () => {
}
});
function httpGetWithHost(port: number, host: string, requestPath: string): Promise<Response> {
test("reload applies live HTTP, MCP, Git, provider, relay, and app policies", async () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-config-reload-runtime-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-config-reload-agent-"));
await mkdir(paseoHome, { recursive: true });
const configPath = path.join(paseoHome, "config.json");
const initialPersisted = {
version: 1 as const,
daemon: {
listen: "127.0.0.1:0",
hostnames: ["127.0.0.1", "before.example.test"],
cors: { allowedOrigins: ["https://before.example.test"] },
trustedProxies: [],
mcp: { enabled: true, injectIntoAgents: false },
git: { maxProcessesPerSecond: 64, maxProcessConcurrency: 8 },
relay: {
enabled: false,
endpoint: "127.0.0.1:9",
publicEndpoint: "127.0.0.1:9",
useTls: false,
publicUseTls: false,
},
},
app: { baseUrl: "https://before.example.test" },
};
await writeFile(configPath, `${JSON.stringify(initialPersisted, null, 2)}\n`, "utf-8");
const config = loadConfig(paseoHome, { env: {} });
config.staticDir = staticDir;
config.agentClients = createTestAgentClients();
config.agentStoragePath = path.join(paseoHome, "agents");
config.isDev = true;
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
let client: DaemonClient | null = null;
let proxyUpstream: http.Server | null = null;
try {
await daemon.start();
const target = daemon.getListenTarget();
if (!target || target.type !== "tcp") throw new Error("Expected a TCP listener");
client = new DaemonClient({
url: `ws://127.0.0.1:${target.port}/ws`,
appVersion: "0.4.0",
});
await client.connect();
proxyUpstream = http.createServer((req, res) => {
res.end(String(req.headers["x-forwarded-proto"] ?? "missing"));
});
await new Promise<void>((resolve) => proxyUpstream?.listen(0, "127.0.0.1", resolve));
const proxyAddress = proxyUpstream.address();
if (!proxyAddress || typeof proxyAddress === "string") {
throw new Error("Expected proxy upstream TCP address");
}
const proxyRoute = daemon.serviceProxy.registerWorkspaceService({
workspaceId: "workspace-config-reload",
projectSlug: "reload",
branchName: "main",
scriptName: "proxy",
port: proxyAddress.port,
});
const proxyHost = `${proxyRoute.hostname}:${target.port}`;
expect(
(await httpGetWithHost(target.port, "before.example.test", "/api/health")).status,
).toBe(200);
expect((await httpGetWithHost(target.port, "after.example.test", "/api/health")).status).toBe(
403,
);
const beforeCors = await fetch(`http://127.0.0.1:${target.port}/api/health`, {
headers: { Origin: "https://before.example.test" },
});
expect(beforeCors.headers.get("access-control-allow-origin")).toBe(
"https://before.example.test",
);
const beforeMcp = await fetch(`http://127.0.0.1:${target.port}/mcp/agents`, {
method: "POST",
});
expect(beforeMcp.status).toBe(406);
const beforeProxyReload = await httpGetWithHost(target.port, proxyHost, "/", {
"x-forwarded-proto": "https",
});
expect(await beforeProxyReload.text()).toBe("http");
const reloadedPersisted = {
...initialPersisted,
daemon: {
...initialPersisted.daemon,
hostnames: ["127.0.0.1", "after.example.test"],
cors: { allowedOrigins: ["https://after.example.test"] },
trustedProxies: true as const,
mcp: { enabled: false, injectIntoAgents: false },
git: { maxProcessesPerSecond: 5, maxProcessConcurrency: 1 },
relay: { ...initialPersisted.daemon.relay, enabled: true },
},
app: { baseUrl: "https://after.example.test" },
agents: {
catalogRefreshTimeoutMs: 5_000,
providers: { codex: { enabled: false } },
},
};
await writeFile(configPath, `${JSON.stringify(reloadedPersisted, null, 2)}\n`, "utf-8");
const result = await client.reloadDaemonConfig("runtime-policies");
expect(result).toEqual({
requestId: "runtime-policies",
appliedPaths: [
"agents.catalogRefreshTimeoutMs",
"agents.providers",
"app.baseUrl",
"daemon.cors.allowedOrigins",
"daemon.git.maxProcessConcurrency",
"daemon.git.maxProcessesPerSecond",
"daemon.hostnames",
"daemon.mcp.enabled",
"daemon.relay.enabled",
"daemon.trustedProxies",
],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
expect(
(await httpGetWithHost(target.port, "before.example.test", "/api/health")).status,
).toBe(403);
expect((await httpGetWithHost(target.port, "after.example.test", "/api/health")).status).toBe(
200,
);
const afterCors = await fetch(`http://127.0.0.1:${target.port}/api/health`, {
headers: { Origin: "https://after.example.test" },
});
expect(afterCors.headers.get("access-control-allow-origin")).toBe(
"https://after.example.test",
);
const afterProxyReload = await httpGetWithHost(target.port, proxyHost, "/", {
"x-forwarded-proto": "https",
});
expect(await afterProxyReload.text()).toBe("https");
await expect(
probeWebSocketConnection(`ws://127.0.0.1:${target.port}/ws`, {
host: "after.example.test",
origin: "https://after.example.test",
}),
).resolves.toEqual({ status: "connected" });
await expect(
probeWebSocketConnection(`ws://127.0.0.1:${target.port}/ws`, {
host: "after.example.test",
origin: "https://before.example.test",
}),
).resolves.toEqual({ status: "rejected", statusCode: 403 });
expect(
(
await fetch(`http://127.0.0.1:${target.port}/mcp/agents`, {
method: "POST",
})
).status,
).toBe(404);
expect(snapshotGitCommandRuntimeMetrics()).toMatchObject({
concurrencyLimit: 1,
maxProcessesPerSecond: 5,
});
await expect(
daemon.agentManager.createAgent({ provider: "codex", cwd: agentCwd }, undefined, {
workspaceId: undefined,
}),
).rejects.toThrow(/disabled/i);
expect((await client.getDaemonStatus()).relay?.enabled).toBe(true);
expect((await client.getDaemonPairingOffer()).url).toContain(
"https://after.example.test/#offer=",
);
} finally {
configureGitProcessPolicy(DEFAULT_GIT_PROCESS_POLICY);
await client?.close().catch(() => undefined);
await daemon.stop().catch(() => undefined);
if (proxyUpstream) {
await new Promise<void>((resolve) => proxyUpstream?.close(() => resolve()));
}
await Promise.all([
rm(paseoHomeRoot, { recursive: true, force: true }),
rm(staticDir, { recursive: true, force: true }),
rm(agentCwd, { recursive: true, force: true }),
]);
}
});
function httpGetWithHost(
port: number,
host: string,
requestPath: string,
headers: Record<string, string> = {},
): Promise<Response> {
return new Promise((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host } },
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host, ...headers } },
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
@@ -775,8 +971,16 @@ async function beginDaemonShutdownWithAgentClosing(): Promise<BlockedDaemonShutd
};
}
function probeWebSocketConnection(url: string): Promise<WebSocketProbeResult> {
const ws = new WebSocket(url);
function probeWebSocketConnection(
url: string,
headers?: { host?: string; origin?: string },
): Promise<WebSocketProbeResult> {
const ws = new WebSocket(url, {
headers: {
...(headers?.host ? { Host: headers.host } : {}),
...(headers?.origin ? { Origin: headers.origin } : {}),
},
});
return new Promise((resolve) => {
ws.once("open", () => {
ws.close();
+94 -36
View File
@@ -147,6 +147,7 @@ import {
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js";
import { BrowserToolsBroker } from "./browser-tools/broker.js";
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
@@ -448,6 +449,13 @@ export interface PaseoDaemonConfig {
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pushNotificationSender?: PushNotificationSender;
managedProcesses?: ManagedProcessRegistry;
configReload?: {
env: NodeJS.ProcessEnv;
cli?: CliConfigOverrides;
overrideControlledPaths: string[];
relayEnabledFallback: boolean;
startupPersisted: PersistedConfig;
};
}
export interface PaseoDaemon {
@@ -516,22 +524,22 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
}
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
const providers: MutableDaemonConfig["providers"] = Object.fromEntries(
Object.entries(config.providerOverrides ?? {}).map(([providerId, override]) => {
const providerConfig: MutableDaemonConfig["providers"][string] = {};
if (override.enabled !== undefined) {
providerConfig.enabled = override.enabled;
}
if (override.additionalModels) {
providerConfig.additionalModels = override.additionalModels;
}
return [providerId, providerConfig];
}),
);
const providers = config.providerOverrides ?? {};
const initialConfig: MutableDaemonConfig = {
relay: { enabled: config.relayEnabled ?? true },
mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true },
mcp: {
enabled: config.mcpEnabled ?? true,
injectIntoAgents: config.mcpInjectIntoAgents ?? true,
},
...(config.hostnames !== undefined ? { hostnames: config.hostnames } : {}),
cors: { allowedOrigins: config.corsAllowedOrigins },
trustedProxies: config.trustedProxies ?? ["loopback"],
git: config.git ?? resolveGitProcessPolicy({ env: process.env }),
app: { baseUrl: config.appBaseUrl ?? "https://app.paseo.sh" },
...(config.providerCatalogRefreshTimeoutMs !== undefined
? { catalogRefreshTimeoutMs: config.providerCatalogRefreshTimeoutMs }
: {}),
browserTools: { enabled: config.browserToolsEnabled ?? false },
providers,
metadataGeneration: {
@@ -565,12 +573,24 @@ export async function createPaseoDaemon(
const bootstrapStart = performance.now();
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url);
const daemonConfigStore = new DaemonConfigStore(
config.paseoHome,
createInitialMutableDaemonConfig(config),
logger,
{ relayEnabledMutable: config.relayEnabledMutable ?? true },
);
const initialMutableConfig = createInitialMutableDaemonConfig(config);
const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, {
relayEnabledMutable: config.relayEnabledMutable ?? true,
startupPersisted: config.configReload?.startupPersisted,
reloadSource: {
resolve: (persisted) => {
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
env: config.configReload?.env ?? process.env,
cli: config.configReload?.cli,
relayEnabledFallback: config.configReload?.relayEnabledFallback,
});
return {
mutable: createInitialMutableDaemonConfig(reloaded),
overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [],
};
},
},
});
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({});
const pluginRuntime = new PluginService(logger, daemonConfigStore);
@@ -606,6 +626,9 @@ export async function createPaseoDaemon(
const app = express();
app.set("trust proxy", resolveExpressTrustProxySetting(config));
daemonConfigStore.onFieldChange("trustedProxies", (value) => {
app.set("trust proxy", value ?? ["loopback"]);
});
let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const terminalManager = createConfiguredTerminalManager({
@@ -622,7 +645,14 @@ export async function createPaseoDaemon(
});
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const workspaceSetupRuntime = new WorkspaceSetupRuntime();
const configuredHostnames = config.hostnames ?? config.allowedHosts;
let configuredHostnames = config.hostnames ?? config.allowedHosts;
let appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
daemonConfigStore.onFieldChange("hostnames", (value) => {
configuredHostnames = value as HostnamesConfig | undefined;
});
daemonConfigStore.onFieldChange("app.baseUrl", (value) => {
appBaseUrl = typeof value === "string" ? value : "https://app.paseo.sh";
});
let wsServer: VoiceAssistantWebSocketServer | null = null;
let serviceProxyListenTarget: ListenTarget | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({
@@ -668,8 +698,7 @@ export async function createPaseoDaemon(
}
// CORS - allow same-origin + configured origins
const allowedOrigins = new Set([
...config.corsAllowedOrigins,
const fixedAllowedOrigins = [
// Packaged desktop renderers use the custom paseo:// protocol scheme.
"paseo://app",
// For TCP, add localhost variants
@@ -680,7 +709,14 @@ export async function createPaseoDaemon(
`http://127.0.0.1:${listenTarget.port}`,
]
: []),
]);
];
const allowedOrigins = new Set([...config.corsAllowedOrigins, ...fixedAllowedOrigins]);
daemonConfigStore.onFieldChange("cors.allowedOrigins", (value) => {
allowedOrigins.clear();
for (const origin of [...((value as string[] | undefined) ?? []), ...fixedAllowedOrigins]) {
allowedOrigins.add(origin);
}
});
app.use((req, res, next) => {
const origin = req.headers.origin;
@@ -840,6 +876,17 @@ export async function createPaseoDaemon(
isDev: config.isDev === true,
extraClients: config.agentClients,
});
daemonConfigStore.onFieldChange("catalogRefreshTimeoutMs", (value) => {
providerSnapshotManager.setRefreshTimeoutMs(typeof value === "number" ? value : undefined);
});
daemonConfigStore.onFieldChange("git.maxProcessesPerSecond", () => {
const git = daemonConfigStore.get().git;
if (git) configureGitProcessPolicy(git);
});
daemonConfigStore.onFieldChange("git.maxProcessConcurrency", () => {
const git = daemonConfigStore.get().git;
if (git) configureGitProcessPolicy(git);
});
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const agentManager = new AgentManager({
clients: initialAgentManagerState.clients,
@@ -1309,9 +1356,9 @@ export async function createPaseoDaemon(
agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog);
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
const mcpEnabled = config.mcpEnabled ?? true;
let mcpEnabled = config.mcpEnabled ?? true;
let agentMcpBaseUrl: string | null = null;
if (mcpEnabled) {
{
const agentMcpRoute = "/mcp/agents";
const createAgentMcpSession = async (callerAgentId?: string) => {
@@ -1344,6 +1391,10 @@ export async function createPaseoDaemon(
req: express.Request,
res: express.Response,
): Promise<void> => {
if (!mcpEnabled) {
res.status(404).json({ error: "Agent MCP endpoint disabled" });
return;
}
// This route is exempt from the global daemon-password middleware, so it
// authenticates here using the injected capability token (or a valid
// daemon password). Without this, a password-protected daemon would be
@@ -1425,9 +1476,7 @@ export async function createPaseoDaemon(
app.post(agentMcpRoute, handleAgentMcpRequest);
app.get(agentMcpRoute, handleAgentMcpRequest);
app.delete(agentMcpRoute, handleAgentMcpRequest);
logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app");
} else {
logger.info("Agent MCP HTTP endpoint disabled");
logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted");
}
const speechService = createSpeechService({
@@ -1469,13 +1518,20 @@ export async function createPaseoDaemon(
mainStarted = true;
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
const mcpBaseUrl = createAgentMcpBaseUrl(boundListenTarget);
agentMcpBaseUrl =
!mcpEnabled || config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
agentManager.setPaseoToolsEnabled(mcpEnabled && config.mcpInjectIntoAgents !== false);
daemonConfigStore.onFieldChange("mcp.enabled", (value) => {
mcpEnabled = value !== false;
const inject = daemonConfigStore.get().mcp.injectIntoAgents !== false;
agentManager.setMcpBaseUrl(mcpEnabled && inject ? mcpBaseUrl : null);
agentManager.setPaseoToolsEnabled(mcpEnabled && inject);
});
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
agentManager.setPaseoToolsEnabled(value !== false);
agentManager.setMcpBaseUrl(mcpEnabled && value ? mcpBaseUrl : null);
agentManager.setPaseoToolsEnabled(mcpEnabled && value !== false);
});
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
@@ -1520,8 +1576,8 @@ export async function createPaseoDaemon(
daemonConfigStore,
mcpBaseUrl,
{
allowedOrigins,
hostnames: configuredHostnames,
getAllowedOrigins: () => allowedOrigins,
getHostnames: () => configuredHostnames,
daemonStatusRpc: dependencies.serverFeatureOverrides?.daemonStatusRpc,
relayConfig: dependencies.serverFeatureOverrides?.relayConfig,
},
@@ -1557,7 +1613,9 @@ export async function createPaseoDaemon(
{
listen: formatListenTarget(boundListenTarget ?? listenTarget),
worktreesRoot: config.worktreesRoot,
appBaseUrl: config.appBaseUrl,
get appBaseUrl() {
return appBaseUrl;
},
desktopManaged: config.desktopManaged === true,
getRelayConfig: () =>
relayRuntime?.getConfig() ?? {
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { loadConfig } from "./config.js";
import { loadConfig, resolveConfigFromPersisted } from "./config.js";
const roots: string[] = [];
@@ -36,6 +36,39 @@ describe("daemon relay config", () => {
expect(config.relayEnabledMutable).toBe(true);
});
test("removing enabled from a modern config keeps relay disabled", async () => {
const home = await createPaseoHome({
version: 1,
daemon: { relay: { enabled: false } },
});
const startup = loadConfig(home, { env: {} });
const reloaded = resolveConfigFromPersisted(
home,
{ version: 1, daemon: { relay: {} } },
{
env: startup.configReload?.env,
relayEnabledFallback: startup.configReload?.relayEnabledFallback,
},
);
expect(reloaded.relayEnabled).toBe(false);
});
test("legacy configs retain relay-on compatibility when enabled remains absent", async () => {
const home = await createPaseoHome({ version: 1, daemon: { relay: {} } });
const startup = loadConfig(home, { env: {} });
const reloaded = resolveConfigFromPersisted(
home,
{ version: 1, daemon: { relay: {} } },
{
env: startup.configReload?.env,
relayEnabledFallback: startup.configReload?.relayEnabledFallback,
},
);
expect(reloaded.relayEnabled).toBe(true);
});
test("marks environment relay overrides immutable", async () => {
const home = await createPaseoHome({
version: 1,
+114 -1
View File
@@ -4,7 +4,8 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import { loadConfig, resolveBundledWebUiDistDir } from "./config.js";
import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js";
import { loadPersistedConfig } from "./persisted-config.js";
const roots: string[] = [];
@@ -39,6 +40,118 @@ describe("server config", () => {
expect(config.providerCatalogRefreshTimeoutMs).toBe(180_000);
});
test("resolves reload state from the supplied validated snapshot", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-snapshot-"));
roots.push(paseoHome);
const snapshot = loadPersistedConfig(paseoHome);
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({
...snapshot,
daemon: { ...snapshot.daemon, browserTools: { enabled: true } },
}),
);
expect(resolveConfigFromPersisted(paseoHome, snapshot, { env: {} }).browserToolsEnabled).toBe(
false,
);
expect(loadConfig(paseoHome, { env: {} }).browserToolsEnabled).toBe(true);
});
test("records mutable and startup launch overrides by persisted leaf", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-overrides-"));
roots.push(paseoHome);
const config = loadConfig(paseoHome, {
env: {
PASEO_LISTEN: "127.0.0.1:7000",
PASEO_PASSWORD: "secret",
PASEO_RELAY_ENDPOINT: "relay.example.test:443",
PASEO_TRUSTED_PROXIES: "true",
PASEO_WEB_UI_ENABLED: "true",
PASEO_LOG_FILE_PATH: "custom.log",
PASEO_VOICE_LLM_PROVIDER: "codex",
},
cli: { relayUseTls: false },
});
expect(config.configReload?.overrideControlledPaths).toEqual([
"daemon.auth.password",
"daemon.listen",
"daemon.relay.endpoint",
"daemon.relay.useTls",
"daemon.trustedProxies",
"features.voiceMode.llm.provider",
"features.webUi.enabled",
"log.file.path",
]);
expect(config.listen).toBe("127.0.0.1:7000");
expect(config.trustedProxies).toBe(true);
expect(config.log?.file?.path).toBe("custom.log");
expect(config.voiceLlmProvider).toBe("codex");
});
test.each([
{
name: "local speech providers",
providers: { dictation: "local", voiceStt: "local", voiceTts: "local" },
expected: [
"features.dictation.stt.model",
"features.voiceMode.stt.model",
"features.voiceMode.tts.model",
],
},
{
name: "OpenAI speech providers",
providers: { dictation: "openai", voiceStt: "openai", voiceTts: "openai" },
expected: [
"features.dictation.stt.confidenceThreshold",
"features.dictation.stt.model",
"features.voiceMode.stt.model",
"features.voiceMode.tts.model",
"features.voiceMode.tts.voice",
],
},
{
name: "mixed local and OpenAI speech providers",
providers: { dictation: "local", voiceStt: "openai", voiceTts: "local" },
expected: [
"features.dictation.stt.confidenceThreshold",
"features.dictation.stt.model",
"features.voiceMode.stt.model",
"features.voiceMode.tts.model",
],
},
])("classifies speech overrides for $name", ({ providers, expected }) => {
const config = resolveConfigFromPersisted(
"/tmp/paseo-speech-override-classification",
{
version: 1,
features: {
dictation: { enabled: true, stt: { provider: providers.dictation } },
voiceMode: {
enabled: true,
stt: { provider: providers.voiceStt },
tts: { provider: providers.voiceTts },
},
},
},
{
env: {
OPENAI_API_KEY: "test-api-key",
PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8",
PASEO_VOICE_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8",
PASEO_VOICE_LOCAL_TTS_MODEL: "kokoro-en-v0_19",
STT_CONFIDENCE_THRESHOLD: "0.5",
STT_MODEL: "whisper-1",
TTS_MODEL: "tts-1",
TTS_VOICE: "alloy",
},
},
);
expect(config.configReload?.overrideControlledPaths).toEqual(expected);
});
test("resolves bundled web UI path from source-tree modules", () => {
const root = path.parse(process.cwd()).root;
expect(
+307 -24
View File
@@ -21,6 +21,7 @@ import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { hashDaemonPassword } from "./auth.js";
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js";
@@ -112,22 +113,86 @@ type TrustedProxiesConfig = true | string[];
function resolveLogConfigFromEnv(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
persisted: PersistedConfig,
): PersistedConfig["log"] {
const envLogLevel = LogLevelSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_LEVEL));
const envLogFormat = LogFormatSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_FORMAT));
const level = parseLogLevelEnv(env.PASEO_LOG_LEVEL ?? env.PASEO_LOG);
const format = parseLogFormatEnv(env.PASEO_LOG_FORMAT);
const console = resolveConsoleLogConfigFromEnv(env, persisted.log?.console);
const file = resolveFileLogConfigFromEnv(env, persisted.log?.file);
if (!envLogLevel.success && !envLogFormat.success) {
if (level === undefined && format === undefined && !console && !file) {
return persisted.log;
}
return {
...persisted.log,
...(envLogLevel.success ? { level: envLogLevel.data } : {}),
...(envLogFormat.success ? { format: envLogFormat.data } : {}),
...(level !== undefined ? { level } : {}),
...(format !== undefined ? { format } : {}),
...(console ? { console } : {}),
...(file ? { file } : {}),
};
}
function resolveConsoleLogConfigFromEnv(
env: NodeJS.ProcessEnv,
persisted: NonNullable<PersistedConfig["log"]>["console"],
): NonNullable<PersistedConfig["log"]>["console"] {
const level = parseLogLevelEnv(env.PASEO_LOG_CONSOLE_LEVEL);
const format = parseLogFormatEnv(env.PASEO_LOG_CONSOLE_FORMAT);
if (level === undefined && format === undefined) return undefined;
return {
...persisted,
...(level !== undefined ? { level } : {}),
...(format !== undefined ? { format } : {}),
};
}
function resolveFileLogConfigFromEnv(
env: NodeJS.ProcessEnv,
persisted: NonNullable<PersistedConfig["log"]>["file"],
): NonNullable<PersistedConfig["log"]>["file"] {
const level = parseLogLevelEnv(env.PASEO_LOG_FILE_LEVEL);
const filePath = nonEmptyEnv(env.PASEO_LOG_FILE_PATH);
const maxSize = nonEmptyEnv(env.PASEO_LOG_FILE_ROTATE_SIZE);
const maxFiles = parsePositiveIntegerEnv(env.PASEO_LOG_FILE_ROTATE_COUNT);
const hasRotateOverride = maxSize !== undefined || maxFiles !== undefined;
if (level === undefined && filePath === undefined && !hasRotateOverride) return undefined;
return {
...persisted,
...(level !== undefined ? { level } : {}),
...(filePath !== undefined ? { path: filePath } : {}),
...(hasRotateOverride
? {
rotate: {
...persisted?.rotate,
...(maxSize !== undefined ? { maxSize } : {}),
...(maxFiles !== undefined ? { maxFiles } : {}),
},
}
: {}),
};
}
function parseLogLevelEnv(value: string | undefined): z.infer<typeof LogLevelSchema> | undefined {
const parsed = LogLevelSchema.safeParse(normalizeLogEnv(value));
return parsed.success ? parsed.data : undefined;
}
function parseLogFormatEnv(value: string | undefined): z.infer<typeof LogFormatSchema> | undefined {
const parsed = LogFormatSchema.safeParse(normalizeLogEnv(value));
return parsed.success ? parsed.data : undefined;
}
function nonEmptyEnv(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function parsePositiveIntegerEnv(value: string | undefined): number | undefined {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((value): string | null =>
@@ -191,9 +256,10 @@ function extractAgentProviderSettings(
interface ResolveRelayInput {
env: NodeJS.ProcessEnv;
persisted: ReturnType<typeof loadPersistedConfig>;
persisted: PersistedConfig;
cliRelayEnabled: boolean | undefined;
cliRelayUseTls: boolean | undefined;
enabledFallback: boolean;
}
interface ResolvedRelay {
@@ -223,10 +289,13 @@ function resolveTlsFromEnv(
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED);
// COMPAT(relayOptInDefault): configs created before v0.2.6 may omit this field.
// Preserve their relay-on behavior until 2027-01-31; new homes materialize false.
// COMPAT(relayOptInDefault): daemons whose startup config omitted this field
// retain relay-on removal semantics until 2027-01-31. Modern homes use false.
const enabled =
input.cliRelayEnabled ?? environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? true;
input.cliRelayEnabled ??
environmentEnabled ??
input.persisted.daemon?.relay?.enabled ??
input.enabledFallback;
const endpoint =
input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ??
@@ -473,17 +542,24 @@ function resolveStaticLoadConfigSettings(
};
}
export function loadConfig(
paseoHome: string,
options?: {
env?: NodeJS.ProcessEnv;
cli?: CliConfigOverrides;
},
): PaseoDaemonConfig {
const env = options?.env ?? process.env;
const persisted = loadPersistedConfig(paseoHome);
interface ResolveConfigFromPersistedOptions {
env?: NodeJS.ProcessEnv;
cli?: CliConfigOverrides;
relayEnabledFallback?: boolean;
}
const listen = resolveListenAddress(env, options?.cli, persisted);
export function resolveConfigFromPersisted(
paseoHome: string,
persisted: PersistedConfig,
options?: ResolveConfigFromPersistedOptions,
): PaseoDaemonConfig {
const resolvedOptions = options ?? {};
const env = resolvedOptions.env ?? process.env;
const cli = resolvedOptions.cli;
const relayEnabledFallback =
resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
const listen = resolveListenAddress(env, cli, persisted);
const {
mcpEnabled,
mcpInjectIntoAgents,
@@ -495,16 +571,17 @@ export function loadConfig(
hostnames,
trustedProxies,
appBaseUrl,
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
} = resolveStaticLoadConfigSettings(env, cli, persisted);
const relay = resolveRelayConfig({
env,
persisted,
cliRelayEnabled: options?.cli?.relayEnabled,
cliRelayUseTls: options?.cli?.relayUseTls,
cliRelayEnabled: cli?.relayEnabled,
cliRelayUseTls: cli?.relayUseTls,
enabledFallback: relayEnabledFallback,
});
const serviceProxy = resolveServiceProxyConfig(env, persisted);
const webUi = resolveWebUiConfig(paseoHome, env, options?.cli, persisted);
const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted);
const { openai, speech } = resolveSpeechConfig({
paseoHome,
@@ -517,6 +594,8 @@ export function loadConfig(
persisted.agents?.providers as Record<string, unknown> | undefined,
);
const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, speech.providers);
return {
listen,
paseoHome,
@@ -561,5 +640,209 @@ export function loadConfig(
metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides,
log: resolveLogConfigFromEnv(env, persisted),
configReload: {
env: { ...env },
cli: cli ? { ...cli } : undefined,
overrideControlledPaths,
relayEnabledFallback,
startupPersisted: persisted,
},
};
}
export function loadConfig(
paseoHome: string,
options?: Omit<ResolveConfigFromPersistedOptions, "relayEnabledFallback">,
): PaseoDaemonConfig {
const persisted = loadPersistedConfig(paseoHome);
return resolveConfigFromPersisted(paseoHome, persisted, options);
}
function parsePositiveGitOverride(value: string | undefined): boolean {
if (value === undefined) return false;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0;
}
function resolveOverrideControlledPaths(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
speechProviders: RequestedSpeechProviders,
): string[] {
return Array.from(
new Set([
...resolveDaemonOverrideControlledPaths(env, cli),
...resolveLogOverrideControlledPaths(env),
...resolveSpeechOverrideControlledPaths(env, speechProviders),
]),
).sort();
}
function resolveDaemonOverrideControlledPaths(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
): string[] {
return [
...resolveCoreDaemonOverridePaths(env, cli),
...resolveRelayOverridePaths(env, cli),
...resolveServiceAndWebUiOverridePaths(env, cli),
];
}
function resolveCoreDaemonOverridePaths(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
): string[] {
const paths: string[] = [];
if (cli?.listen !== undefined || env.PASEO_LISTEN !== undefined) {
paths.push("daemon.listen");
}
if (cli?.mcpEnabled !== undefined) paths.push("daemon.mcp.enabled");
if (cli?.mcpInjectIntoAgents !== undefined) paths.push("daemon.mcp.injectIntoAgents");
// Hostname sources append instead of replacing one another, so a launch value
// does not prevent a persisted hostname edit from taking effect.
if (parseTrustedProxiesEnv(env.PASEO_TRUSTED_PROXIES) !== undefined) {
paths.push("daemon.trustedProxies");
}
if (parsePositiveGitOverride(env.PASEO_GIT_MAX_PROCESSES_PER_SECOND)) {
paths.push("daemon.git.maxProcessesPerSecond");
}
if (
parsePositiveGitOverride(env.PASEO_GIT_MAX_PROCESS_CONCURRENCY ?? env.PASEO_GIT_CONCURRENCY)
) {
paths.push("daemon.git.maxProcessConcurrency");
}
if (env.PASEO_APP_BASE_URL !== undefined) paths.push("app.baseUrl");
if (env.PASEO_PASSWORD?.trim()) paths.push("daemon.auth.password");
return paths;
}
function resolveRelayOverridePaths(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
): string[] {
const paths: string[] = [];
if (cli?.relayEnabled !== undefined || parseBooleanEnv(env.PASEO_RELAY_ENABLED) !== undefined) {
paths.push("daemon.relay.enabled");
}
if (env.PASEO_RELAY_ENDPOINT !== undefined) paths.push("daemon.relay.endpoint");
if (env.PASEO_RELAY_PUBLIC_ENDPOINT !== undefined) {
paths.push("daemon.relay.publicEndpoint");
}
if (cli?.relayUseTls !== undefined || env.PASEO_RELAY_USE_TLS !== undefined) {
paths.push("daemon.relay.useTls");
}
if (env.PASEO_RELAY_PUBLIC_USE_TLS !== undefined) {
paths.push("daemon.relay.publicUseTls");
}
return paths;
}
function resolveServiceAndWebUiOverridePaths(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
): string[] {
const paths: string[] = [];
const serviceProxyEnabled = parseBooleanEnv(env.PASEO_SERVICE_PROXY_ENABLED);
if (serviceProxyEnabled !== undefined) paths.push("daemon.serviceProxy.enabled");
if (env.PASEO_SERVICE_PROXY_LISTEN !== undefined || serviceProxyEnabled === false) {
paths.push("daemon.serviceProxy.listen");
}
if (env.PASEO_SERVICE_PROXY_PUBLIC_BASE_URL !== undefined || serviceProxyEnabled === false) {
paths.push("daemon.serviceProxy.publicBaseUrl");
}
if (cli?.webUiEnabled !== undefined || parseBooleanEnv(env.PASEO_WEB_UI_ENABLED) !== undefined) {
paths.push("features.webUi.enabled");
}
if (env.PASEO_WEB_UI_DIST_DIR !== undefined) paths.push("features.webUi.distDir");
return paths;
}
function resolveLogOverrideControlledPaths(env: NodeJS.ProcessEnv): string[] {
const paths: string[] = [];
if (parseLogLevelEnv(env.PASEO_LOG_LEVEL ?? env.PASEO_LOG) !== undefined) {
paths.push("log.level");
}
if (parseLogFormatEnv(env.PASEO_LOG_FORMAT) !== undefined) paths.push("log.format");
if (parseLogLevelEnv(env.PASEO_LOG_CONSOLE_LEVEL) !== undefined) {
paths.push("log.console.level");
}
if (parseLogFormatEnv(env.PASEO_LOG_CONSOLE_FORMAT) !== undefined) {
paths.push("log.console.format");
}
if (parseLogLevelEnv(env.PASEO_LOG_FILE_LEVEL) !== undefined) paths.push("log.file.level");
if (nonEmptyEnv(env.PASEO_LOG_FILE_PATH) !== undefined) paths.push("log.file.path");
if (nonEmptyEnv(env.PASEO_LOG_FILE_ROTATE_SIZE) !== undefined) {
paths.push("log.file.rotate.maxSize");
}
if (parsePositiveIntegerEnv(env.PASEO_LOG_FILE_ROTATE_COUNT) !== undefined) {
paths.push("log.file.rotate.maxFiles");
}
return paths;
}
function isEnabledSpeechProvider(
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders],
expected: "local" | "openai",
): boolean {
return provider.enabled !== false && provider.provider === expected;
}
function resolveSpeechOverrideControlledPaths(
env: NodeJS.ProcessEnv,
providers: RequestedSpeechProviders,
): string[] {
const paths: string[] = [];
const add = (envName: string, ...configPaths: string[]) => {
if (env[envName] !== undefined) paths.push(...configPaths);
};
add("PASEO_DICTATION_ENABLED", "features.dictation.enabled");
add("PASEO_DICTATION_STT_PROVIDER", "features.dictation.stt.provider");
if (
env.PASEO_DICTATION_LOCAL_STT_MODEL !== undefined &&
isEnabledSpeechProvider(providers.dictationStt, "local")
) {
paths.push("features.dictation.stt.model");
}
add("PASEO_DICTATION_LANGUAGE", "features.dictation.stt.language");
add("PASEO_VOICE_MODE_ENABLED", "features.voiceMode.enabled");
add("PASEO_VOICE_LLM_PROVIDER", "features.voiceMode.llm.provider");
add("PASEO_VOICE_STT_PROVIDER", "features.voiceMode.stt.provider");
if (
env.PASEO_VOICE_LOCAL_STT_MODEL !== undefined &&
isEnabledSpeechProvider(providers.voiceStt, "local")
) {
paths.push("features.voiceMode.stt.model");
}
add("PASEO_VOICE_LANGUAGE", "features.voiceMode.stt.language");
add("PASEO_VOICE_TURN_DETECTION_PROVIDER", "features.voiceMode.turnDetection.provider");
add("PASEO_VOICE_TTS_PROVIDER", "features.voiceMode.tts.provider");
if (
env.PASEO_VOICE_LOCAL_TTS_MODEL !== undefined &&
isEnabledSpeechProvider(providers.voiceTts, "local")
) {
paths.push("features.voiceMode.tts.model");
}
add("PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", "features.voiceMode.tts.speakerId");
add("PASEO_VOICE_LOCAL_TTS_SPEED", "features.voiceMode.tts.speed");
add("PASEO_LOCAL_MODELS_DIR", "providers.local.modelsDir");
const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai");
const openAiVoiceStt = isEnabledSpeechProvider(providers.voiceStt, "openai");
if (env.STT_CONFIDENCE_THRESHOLD !== undefined && (openAiDictationStt || openAiVoiceStt)) {
paths.push("features.dictation.stt.confidenceThreshold");
}
if (env.STT_MODEL !== undefined) {
if (openAiDictationStt) paths.push("features.dictation.stt.model");
if (openAiVoiceStt) paths.push("features.voiceMode.stt.model");
}
if (isEnabledSpeechProvider(providers.voiceTts, "openai")) {
add("TTS_MODEL", "features.voiceMode.tts.model");
add("TTS_VOICE", "features.voiceMode.tts.voice");
}
if (env.PASEO_DICTATION_LANGUAGE !== undefined && env.PASEO_VOICE_LANGUAGE === undefined) {
paths.push("features.voiceMode.stt.language");
}
return paths;
}
@@ -5,6 +5,39 @@ import { afterEach, describe, expect, test } from "vitest";
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import { loadPersistedConfig } from "./persisted-config.js";
import type { PersistedConfig } from "./persisted-config.js";
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
function reloadableConfig(
persisted: PersistedConfig,
options: { relayEnabledFallback?: boolean } = {},
): MutableDaemonConfig {
const daemon = persisted.daemon ?? {};
const relay = daemon.relay ?? {};
const git = daemon.git ?? {};
const agents = persisted.agents ?? {};
return {
relay: {
enabled: relay.enabled ?? options.relayEnabledFallback ?? true,
},
mcp: { enabled: true, injectIntoAgents: false },
browserTools: { enabled: daemon.browserTools?.enabled ?? false },
providers: (agents.providers ?? {}) as MutableDaemonConfig["providers"],
metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] },
autoArchiveAfterMerge: daemon.autoArchiveAfterMerge ?? false,
enableTerminalAgentHooks: daemon.enableTerminalAgentHooks ?? false,
appendSystemPrompt: daemon.appendSystemPrompt ?? "",
terminalProfiles: daemon.terminalProfiles,
agentProfiles: daemon.agentProfiles,
cors: { allowedOrigins: [] },
trustedProxies: ["loopback"],
git: {
maxProcessesPerSecond: git.maxProcessesPerSecond ?? 64,
maxProcessConcurrency: git.maxProcessConcurrency ?? 8,
},
app: { baseUrl: "https://app.paseo.sh" },
};
}
describe("applyMutableProviderConfigToOverrides", () => {
test("merges mutable provider fields onto provider overrides", () => {
@@ -181,6 +214,38 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("rolls back live owners when a later transactional owner fails", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
let browserToolsEnabled = false;
store.onApply((next, previous) => {
browserToolsEnabled = next.browserTools.enabled;
return () => {
browserToolsEnabled = previous.browserTools.enabled;
};
});
store.onApply(() => {
throw new Error("Provider refresh failed");
});
expect(() => store.patch({ browserTools: { enabled: true } })).toThrow(
"Provider refresh failed",
);
expect(browserToolsEnabled).toBe(false);
expect(store.get().browserTools.enabled).toBe(false);
expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined();
});
test("rejects relay patches when a launch override owns the setting", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -237,6 +302,46 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("unrelated patches persist only requested file intent", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const before = loadPersistedConfig(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
relay: { enabled: true },
mcp: { enabled: false, injectIntoAgents: false },
hostnames: ["launch.example.test"],
cors: { allowedOrigins: ["https://launch.example.test"] },
trustedProxies: true,
git: { maxProcessesPerSecond: 7, maxProcessConcurrency: 2 },
app: { baseUrl: "https://launch.example.test" },
catalogRefreshTimeoutMs: 9_000,
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
{ relayEnabledMutable: false },
);
store.patch({
appendSystemPrompt: "Only this field",
// Reload-only runtime state is accepted as unknown wire data for forward
// compatibility but is not part of the patch capability.
hostnames: ["attempted-patch.example.test"],
} as Parameters<typeof store.patch>[0]);
expect(store.get().hostnames).toEqual(["launch.example.test"]);
expect(loadPersistedConfig(paseoHome)).toEqual({
...before,
daemon: { ...before.daemon, appendSystemPrompt: "Only this field" },
});
});
test("patch persists provider enabled flags into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -764,3 +869,258 @@ describe("DaemonConfigStore", () => {
});
});
});
describe("DaemonConfigStore reload", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
});
function createReloadableStore(
options: {
overrideControlledPaths?: string[];
initialPersisted?: PersistedConfig;
} = {},
) {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-reload-"));
tempDirs.push(paseoHome);
if (options.initialPersisted) {
writeFileSync(
path.join(paseoHome, "config.json"),
`${JSON.stringify(options.initialPersisted, null, 2)}\n`,
);
}
const persisted = loadPersistedConfig(paseoHome);
const relayEnabledFallback = persisted.daemon?.relay?.enabled === undefined;
const initialMutable = reloadableConfig(persisted, { relayEnabledFallback });
const store = new DaemonConfigStore(paseoHome, initialMutable, undefined, {
reloadSource: {
resolve: (nextPersisted) => {
const mutable = reloadableConfig(nextPersisted, { relayEnabledFallback });
if (options.overrideControlledPaths?.includes("daemon.relay.enabled")) {
mutable.relay = initialMutable.relay;
}
return {
mutable,
overrideControlledPaths: options.overrideControlledPaths ?? [],
};
},
},
});
return { paseoHome, store, persisted };
}
function writeConfig(paseoHome: string, config: unknown): void {
writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
}
test("applies mutable edits and reports startup-only edits", () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
daemon: {
...persisted.daemon,
listen: "127.0.0.1:7777",
browserTools: { enabled: true },
git: { maxProcessesPerSecond: 12, maxProcessConcurrency: 3 },
},
});
expect(store.reload()).toEqual({
appliedPaths: [
"daemon.browserTools.enabled",
"daemon.git.maxProcessConcurrency",
"daemon.git.maxProcessesPerSecond",
],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: [],
});
expect(store.get().browserTools.enabled).toBe(true);
expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 });
});
test("classifies every leaf when a parent subtree is added", () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: { version: 1 },
});
writeConfig(paseoHome, {
version: 1,
daemon: {
relay: {
enabled: false,
endpoint: "relay.example.test:443",
useTls: true,
},
},
});
expect(store.reload()).toEqual({
appliedPaths: ["daemon.relay.enabled"],
restartRequiredPaths: ["daemon.relay.endpoint", "daemon.relay.useTls"],
overrideControlledPaths: [],
});
});
test("classifies every leaf when the daemon subtree is removed", () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: {
version: 1,
daemon: {
listen: "127.0.0.1:7777",
browserTools: { enabled: true },
relay: {
enabled: false,
endpoint: "relay.example.test:443",
useTls: true,
},
serviceProxy: {
listen: "127.0.0.1:7788",
publicBaseUrl: "https://services.example.test",
},
},
},
});
writeConfig(paseoHome, { version: 1 });
expect(store.reload()).toEqual({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [
"daemon.listen",
"daemon.relay.endpoint",
"daemon.relay.useTls",
"daemon.serviceProxy.listen",
"daemon.serviceProxy.publicBaseUrl",
],
overrideControlledPaths: [],
});
expect(store.get().relay?.enabled).toBe(false);
});
test("keeps overridden leaves separate from restart-required siblings", () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: { version: 1 },
overrideControlledPaths: ["daemon.relay.enabled"],
});
writeConfig(paseoHome, {
version: 1,
daemon: {
relay: { enabled: false, endpoint: "relay.example.test:443" },
},
});
expect(store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: ["daemon.relay.endpoint"],
overrideControlledPaths: ["daemon.relay.enabled"],
});
});
test("invalid JSON and invalid schema apply nothing", () => {
const { paseoHome, store } = createReloadableStore();
writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n");
expect(() => store.reload()).toThrow("Invalid JSON");
expect(store.get().browserTools.enabled).toBe(false);
writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } });
expect(() => store.reload()).toThrow("Invalid config");
expect(store.get().browserTools.enabled).toBe(false);
});
test("removing providers and optional profiles clears live state", () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
daemon: {
...persisted.daemon,
terminalProfiles: [{ id: "shell", name: "Shell", command: "bash" }],
agentProfiles: [{ id: "review", name: "Review", provider: "codex" }],
},
agents: {
providers: {
gemini: { extends: "acp", label: "Gemini", command: ["gemini", "--acp"] },
},
},
});
store.reload();
writeConfig(paseoHome, persisted);
const result = store.reload();
expect(result.appliedPaths).toEqual([
"agents.providers",
"daemon.agentProfiles",
"daemon.terminalProfiles",
]);
expect(store.get().providers).toEqual({});
expect(store.get().terminalProfiles).toBeUndefined();
expect(store.get().agentProfiles).toBeUndefined();
});
test("reports a launch-controlled edit without changing live state", () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
});
const initialRelay = store.get().relay?.enabled;
writeConfig(paseoHome, {
...persisted,
daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } },
});
expect(store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.relay.enabled"],
});
expect(store.get().relay?.enabled).toBe(initialRelay);
});
test("an unrelated patch does not mark a manual override-owned edit as applied", () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
});
writeConfig(paseoHome, {
...persisted,
daemon: { ...persisted.daemon, relay: { enabled: true } },
});
store.patch({ appendSystemPrompt: "patched elsewhere" });
expect(store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.relay.enabled"],
});
});
test("reports startup-only launch overrides instead of restart warnings", () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
});
writeConfig(paseoHome, {
...persisted,
daemon: {
...persisted.daemon,
listen: "127.0.0.1:7777",
relay: {
...persisted.daemon?.relay,
endpoint: "relay.example.test:443",
},
},
});
expect(store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
});
});
test("a no-op reload returns empty path lists", () => {
const { store } = createReloadableStore();
expect(store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
});
});
+355 -124
View File
@@ -15,6 +15,22 @@ type MutableDaemonConfig = import("@getpaseo/protocol/messages").MutableDaemonCo
type MutableDaemonConfigPatch = import("@getpaseo/protocol/messages").MutableDaemonConfigPatch;
type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOverride;
interface SupportedMutableConfigPatch {
relay?: { enabled?: boolean };
mcp?: { injectIntoAgents?: boolean };
browserTools?: { enabled?: boolean };
providers?: MutableDaemonConfig["providers"];
removeProviders?: string[];
metadataGeneration?: MutableDaemonConfig["metadataGeneration"];
autoArchiveAfterMerge?: boolean;
enableTerminalAgentHooks?: boolean;
appendSystemPrompt?: string;
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
agentProfiles?: MutableDaemonConfig["agentProfiles"];
pluginsEnabled?: boolean;
plugins?: MutableDaemonConfig["plugins"];
}
interface LoggerLike {
child(bindings: Record<string, unknown>): LoggerLike;
info(...args: unknown[]): void;
@@ -24,7 +40,26 @@ export interface DaemonConfigChangeDetails {
removedProviders: readonly string[];
}
export interface DaemonConfigReloadResult {
appliedPaths: string[];
restartRequiredPaths: string[];
overrideControlledPaths: string[];
}
export interface DaemonConfigReloadSource {
resolve(persisted: PersistedConfig): {
mutable: MutableDaemonConfig;
overrideControlledPaths: readonly string[];
};
}
type ConfigListener = (config: MutableDaemonConfig, details: DaemonConfigChangeDetails) => void;
type ConfigApplyRollback = () => void;
type ConfigApplyListener = (
config: MutableDaemonConfig,
previous: MutableDaemonConfig,
details: DaemonConfigChangeDetails,
) => ConfigApplyRollback;
type FieldChangeHandler = (value: unknown) => void;
interface AppliedFieldChange {
@@ -121,17 +156,6 @@ function omitProvidersFromOverrides(
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
}
function omitProvidersFromPersistedAgents(
agents: PersistedConfig["agents"],
): Record<string, unknown> | undefined {
if (!agents) {
return undefined;
}
const { providers: _providers, ...rest } = agents as Record<string, unknown>;
return Object.keys(rest).length > 0 ? rest : undefined;
}
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path
.split(".")
@@ -142,6 +166,115 @@ function isEqualValue(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
const RELOADABLE_PATHS = [
"daemon.relay.enabled",
"daemon.mcp.enabled",
"daemon.mcp.injectIntoAgents",
"daemon.browserTools.enabled",
"daemon.hostnames",
"daemon.cors.allowedOrigins",
"daemon.trustedProxies",
"daemon.git.maxProcessesPerSecond",
"daemon.git.maxProcessConcurrency",
"daemon.autoArchiveAfterMerge",
"daemon.enableTerminalAgentHooks",
"daemon.appendSystemPrompt",
"daemon.terminalProfiles",
"daemon.agentProfiles",
"app.baseUrl",
"agents.providers",
"agents.catalogRefreshTimeoutMs",
"agents.metadataGeneration",
"pluginsEnabled",
] as const;
const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
["daemon.relay.enabled", "relay.enabled"],
["daemon.mcp.enabled", "mcp.enabled"],
["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"],
["daemon.browserTools.enabled", "browserTools.enabled"],
["daemon.hostnames", "hostnames"],
["daemon.cors.allowedOrigins", "cors.allowedOrigins"],
["daemon.trustedProxies", "trustedProxies"],
["daemon.git.maxProcessesPerSecond", "git.maxProcessesPerSecond"],
["daemon.git.maxProcessConcurrency", "git.maxProcessConcurrency"],
["daemon.autoArchiveAfterMerge", "autoArchiveAfterMerge"],
["daemon.enableTerminalAgentHooks", "enableTerminalAgentHooks"],
["daemon.appendSystemPrompt", "appendSystemPrompt"],
["daemon.terminalProfiles", "terminalProfiles"],
["daemon.agentProfiles", "agentProfiles"],
["app.baseUrl", "app.baseUrl"],
["agents.providers", "providers"],
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
["agents.metadataGeneration", "metadataGeneration"],
["pluginsEnabled", "pluginsEnabled"],
]);
function pathBelongsTo(path: string, owner: string): boolean {
return path === owner || path.startsWith(`${owner}.`);
}
function diffPaths(previous: unknown, next: unknown, prefix = ""): string[] {
if (isEqualValue(previous, next)) return [];
if (!isRecord(previous) || !isRecord(next)) {
if (isRecord(previous)) return leafPaths(previous, prefix);
if (isRecord(next)) return leafPaths(next, prefix);
return prefix ? [prefix] : [];
}
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
return Array.from(keys).flatMap((key) => {
const path = prefix ? `${prefix}.${key}` : key;
return diffPaths(previous[key], next[key], path);
});
}
function leafPaths(record: Record<string, unknown>, prefix: string): string[] {
return Object.entries(record).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return isRecord(value) ? leafPaths(value, path) : [path];
});
}
function compactOwnedPaths(paths: readonly string[], owners: readonly string[]): string[] {
const compacted = new Set<string>();
for (const path of paths) {
const owner = owners.find((candidate) => pathBelongsTo(path, candidate));
compacted.add(owner ?? path);
}
return Array.from(compacted).sort();
}
function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch {
return {
...(patch.relay?.enabled !== undefined ? { relay: { enabled: patch.relay.enabled } } : {}),
...(patch.mcp?.injectIntoAgents !== undefined
? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } }
: {}),
...(patch.browserTools?.enabled !== undefined
? { browserTools: { enabled: patch.browserTools.enabled } }
: {}),
...(patch.providers !== undefined ? { providers: patch.providers } : {}),
...(patch.removeProviders !== undefined ? { removeProviders: patch.removeProviders } : {}),
...(patch.metadataGeneration?.providers !== undefined
? { metadataGeneration: { providers: patch.metadataGeneration.providers } }
: {}),
...(patch.autoArchiveAfterMerge !== undefined
? { autoArchiveAfterMerge: patch.autoArchiveAfterMerge }
: {}),
...(patch.enableTerminalAgentHooks !== undefined
? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks }
: {}),
...(patch.appendSystemPrompt !== undefined
? { appendSystemPrompt: patch.appendSystemPrompt }
: {}),
...(patch.terminalProfiles !== undefined ? { terminalProfiles: patch.terminalProfiles } : {}),
...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}),
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
};
}
export function applyMutableProviderConfigToOverrides(
baseOverrides: Record<string, ProviderOverride> | undefined,
mutableProviders: MutableDaemonConfig["providers"] | undefined,
@@ -166,14 +299,22 @@ export class DaemonConfigStore {
private readonly paseoHome: string;
private readonly logger: LoggerLike | undefined;
private readonly changeListeners = new Set<ConfigListener>();
private readonly applyListeners = new Set<ConfigApplyListener>();
private readonly fieldChangeHandlers = new Map<string, Set<FieldChangeHandler>>();
private readonly relayEnabledMutable: boolean;
private readonly reloadSource: DaemonConfigReloadSource | undefined;
private readonly startupPersisted: PersistedConfig;
private lastKnownPersisted: PersistedConfig;
constructor(
paseoHome: string,
initial: MutableDaemonConfig,
logger?: LoggerLike,
options: { relayEnabledMutable?: boolean } = {},
options: {
relayEnabledMutable?: boolean;
reloadSource?: DaemonConfigReloadSource;
startupPersisted?: PersistedConfig;
} = {},
) {
this.paseoHome = paseoHome;
this.logger = getLogger(logger);
@@ -182,6 +323,9 @@ export class DaemonConfigStore {
relay: initial.relay ?? { enabled: true },
});
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
this.reloadSource = options.reloadSource;
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
this.lastKnownPersisted = this.startupPersisted;
}
public get(): MutableDaemonConfig {
@@ -189,7 +333,7 @@ export class DaemonConfigStore {
}
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
throw new Error(
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
@@ -206,20 +350,99 @@ export class DaemonConfigStore {
),
);
const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path));
});
const configChanged = !isEqualValue(this.current, next);
if (!configChanged && removedProviders.length === 0) {
return this.current;
}
const persistedBeforePatch = this.persistConfig(next, removedProviders);
if (!configChanged) return this.current;
const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
configPatch,
removedProviders,
);
if (!configChanged) {
this.lastKnownPersisted = knownNext;
return this.current;
}
try {
this.applyReplacement(next, { removedProviders });
this.lastKnownPersisted = knownNext;
} catch (error) {
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
throw error;
}
return this.current;
}
public reload(): DaemonConfigReloadResult {
if (!this.reloadSource) {
throw new Error("Daemon config reload is unavailable for this daemon instance");
}
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const resolved = this.reloadSource.resolve(persisted);
// Plugin source changes require the plugin lifecycle operation or a daemon
// restart. The global switch is independently reloadable.
const desired = MutableDaemonConfigSchema.parse({
...resolved.mutable,
plugins: this.current.plugins,
});
const changedSinceLastApply = diffPaths(this.lastKnownPersisted, persisted);
const overrideControlledPaths = compactOwnedPaths(
changedSinceLastApply.filter((path) =>
resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner)),
),
resolved.overrideControlledPaths,
);
const appliedPaths = RELOADABLE_PATHS.filter((persistedPath) => {
if (resolved.overrideControlledPaths.some((owner) => pathBelongsTo(persistedPath, owner))) {
return false;
}
const mutablePath = PERSISTED_TO_MUTABLE_PATH.get(persistedPath);
return (
mutablePath !== undefined &&
!isEqualValue(
getValueAtPath(this.current, mutablePath),
getValueAtPath(desired, mutablePath),
)
);
});
const restartRequiredPaths = compactOwnedPaths(
diffPaths(this.startupPersisted, persisted).filter((path) => {
if (path === "$schema" || path === "version") return false;
if (RELOADABLE_PATHS.some((owner) => pathBelongsTo(path, owner))) return false;
return !resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner));
}),
[],
);
const removedProviders = Object.keys(this.current.providers).filter(
(provider) => !(provider in desired.providers),
);
this.applyReplacement(desired, { removedProviders });
this.lastKnownPersisted = persisted;
return {
appliedPaths: [...appliedPaths].sort(),
restartRequiredPaths,
overrideControlledPaths,
};
}
private applyReplacement(
next: MutableDaemonConfig,
changeDetails: DaemonConfigChangeDetails,
): void {
const changedFieldPaths = Array.from(this.fieldChangeHandlers.keys()).filter((path) => {
return !isEqualValue(getValueAtPath(this.current, path), getValueAtPath(next, path));
});
if (isEqualValue(this.current, next) && changeDetails.removedProviders.length === 0) return;
const previous = this.current;
const appliedFieldChanges: AppliedFieldChange[] = [];
const applyRollbacks: ConfigApplyRollback[] = [];
this.current = next;
try {
for (const path of changedFieldPaths) {
@@ -234,21 +457,44 @@ export class DaemonConfigStore {
handler(value);
}
}
for (const listener of this.applyListeners) {
applyRollbacks.push(listener(next, previous, changeDetails));
}
} catch (error) {
this.current = previous;
const rollbackErrors: unknown[] = [];
for (const rollback of applyRollbacks.toReversed()) {
try {
rollback();
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
for (const change of appliedFieldChanges.toReversed()) {
change.handler(change.previousValue);
try {
change.handler(change.previousValue);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length > 0) {
const rollbackFailure = new Error(
"Daemon config apply failed and one or more live owners could not roll back",
{ cause: error },
);
Object.assign(rollbackFailure, { rollbackErrors });
throw rollbackFailure;
}
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
throw error;
}
const changeDetails: DaemonConfigChangeDetails = { removedProviders };
for (const listener of this.changeListeners) {
listener(next, changeDetails);
try {
listener(next, changeDetails);
} catch (error) {
this.logger?.info({ error }, "Daemon config change notification failed");
}
}
return next;
}
public onFieldChange(path: string, handler: FieldChangeHandler): () => void {
@@ -275,130 +521,115 @@ export class DaemonConfigStore {
};
}
public onApply(listener: ConfigApplyListener): () => void {
// A live owner must either throw before changing its state or return a
// rollback that restores the previous config. Notifications belong in
// onChange so they run only after every live owner commits.
this.applyListeners.add(listener);
return () => {
this.applyListeners.delete(listener);
};
}
private persistConfig(
config: MutableDaemonConfig,
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
removeProviders: readonly string[],
): PersistedConfig {
): { previous: PersistedConfig; knownNext: PersistedConfig } {
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const nextPersisted = mergeMutableConfigIntoPersistedConfig({
persisted,
mutable: config,
removeProviders,
persistRelayEnabled: this.relayEnabledMutable,
});
const merge = (source: PersistedConfig) =>
mergeMutablePatchIntoPersistedConfig({
persisted: source,
patch,
removeProviders,
persistRelayEnabled: this.relayEnabledMutable,
});
const nextPersisted = merge(persisted);
const knownNext = merge(this.lastKnownPersisted);
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
return persisted;
return { previous: persisted, knownNext };
}
}
function mergeMutableConfigIntoPersistedConfig(params: {
function mergeMutablePatchIntoPersistedConfig(params: {
persisted: PersistedConfig;
mutable: MutableDaemonConfig;
patch: Omit<SupportedMutableConfigPatch, "removeProviders">;
removeProviders: readonly string[];
persistRelayEnabled: boolean;
}): PersistedConfig {
const { persisted, mutable, removeProviders, persistRelayEnabled } = params;
if (!mutable.relay) {
throw new Error("Mutable daemon config is missing relay state");
const { persisted, patch, removeProviders, persistRelayEnabled } = params;
const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled);
const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders);
return {
...persisted,
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
...(daemon ? { daemon } : { daemon: undefined }),
...(agents ? { agents } : { agents: undefined }),
} as PersistedConfig;
}
function mergeMutableAgentPatch(
persistedAgents: PersistedConfig["agents"],
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
removeProviders: readonly string[],
): PersistedConfig["agents"] {
if (
patch.providers === undefined &&
patch.metadataGeneration === undefined &&
removeProviders.length === 0
) {
return persistedAgents;
}
const browserToolsEnabled = readBrowserToolsEnabled(mutable);
const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
const next = { ...persistedAgents } as Record<string, unknown>;
const persistedProviderOverrides = omitProvidersFromOverrides(
persisted.agents?.providers as Record<string, ProviderOverride> | undefined,
persistedAgents?.providers as Record<string, ProviderOverride> | undefined,
removeProviders,
);
const providerOverrides = applyMutableProviderConfigToOverrides(
persistedProviderOverrides,
mutable.providers,
patch.providers,
);
const persistedAgents = omitProvidersFromPersistedAgents(persisted.agents);
const persistedMetadataGeneration = {
providers: metadataGenerationProviders,
};
const shouldPersistMetadataGeneration =
metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined;
if (providerOverrides) next["providers"] = providerOverrides;
else delete next["providers"];
let nextAgents = persistedAgents as PersistedConfig["agents"];
if (providerOverrides && Object.keys(providerOverrides).length > 0) {
nextAgents = {
...persistedAgents,
providers: providerOverrides,
...(shouldPersistMetadataGeneration
? { metadataGeneration: persistedMetadataGeneration }
: {}),
} as PersistedConfig["agents"];
} else if (shouldPersistMetadataGeneration) {
nextAgents = {
...persistedAgents,
metadataGeneration: persistedMetadataGeneration,
} as PersistedConfig["agents"];
if (patch.metadataGeneration?.providers !== undefined) {
next["metadataGeneration"] = { providers: patch.metadataGeneration.providers };
} else if (removeProviders.length > 0 && persistedAgents?.metadataGeneration?.providers) {
const removed = new Set(removeProviders);
next["metadataGeneration"] = {
providers: persistedAgents.metadataGeneration.providers.filter(
(entry) => !removed.has(entry.provider),
),
};
}
return {
...persisted,
pluginsEnabled: mutable.pluginsEnabled,
plugins: mutable.plugins,
daemon: {
...persisted.daemon,
...(persistRelayEnabled
? {
relay: {
...persisted.daemon?.relay,
enabled: mutable.relay.enabled,
},
}
: {}),
mcp: {
...persisted.daemon?.mcp,
injectIntoAgents: mutable.mcp.injectIntoAgents,
},
browserTools: {
...persisted.daemon?.browserTools,
enabled: browserToolsEnabled,
},
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
enableTerminalAgentHooks: mutable.enableTerminalAgentHooks,
appendSystemPrompt: mutable.appendSystemPrompt,
...(mutable.terminalProfiles !== undefined
? { terminalProfiles: mutable.terminalProfiles }
: {}),
...(mutable.agentProfiles !== undefined ? { agentProfiles: mutable.agentProfiles } : {}),
},
agents: nextAgents,
} as PersistedConfig;
return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined;
}
function readBrowserToolsEnabled(mutable: MutableDaemonConfig): boolean {
const browserTools = mutable.browserTools;
if (!isRecord(browserTools)) {
return false;
function mergeMutableDaemonPatch(
persistedDaemon: PersistedConfig["daemon"],
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
persistRelayEnabled: boolean,
): PersistedConfig["daemon"] {
const next = { ...persistedDaemon } as NonNullable<PersistedConfig["daemon"]>;
if (persistRelayEnabled && patch.relay?.enabled !== undefined) {
next.relay = { ...next.relay, enabled: patch.relay.enabled };
}
return browserTools["enabled"] === true;
}
function readMetadataGenerationProviders(
mutable: MutableDaemonConfig,
): Array<{ provider: string; model?: string; thinkingOptionId?: string }> {
const metadataGeneration = mutable.metadataGeneration;
if (!isRecord(metadataGeneration)) {
return [];
if (patch.mcp?.injectIntoAgents !== undefined) {
next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents };
}
const providers = metadataGeneration["providers"];
if (!Array.isArray(providers)) {
return [];
if (patch.browserTools?.enabled !== undefined) {
next.browserTools = { ...next.browserTools, enabled: patch.browserTools.enabled };
}
return providers.flatMap((entry) => {
if (!isRecord(entry) || typeof entry["provider"] !== "string") {
return [];
}
return [
{
provider: entry["provider"],
...(typeof entry["model"] === "string" ? { model: entry["model"] } : {}),
...(typeof entry["thinkingOptionId"] === "string"
? { thinkingOptionId: entry["thinkingOptionId"] }
: {}),
},
];
});
if (patch.autoArchiveAfterMerge !== undefined) {
next.autoArchiveAfterMerge = patch.autoArchiveAfterMerge;
}
if (patch.enableTerminalAgentHooks !== undefined) {
next.enableTerminalAgentHooks = patch.enableTerminalAgentHooks;
}
if (patch.appendSystemPrompt !== undefined) next.appendSystemPrompt = patch.appendSystemPrompt;
if (patch.terminalProfiles !== undefined) next.terminalProfiles = patch.terminalProfiles;
if (patch.agentProfiles !== undefined) next.agentProfiles = patch.agentProfiles;
return Object.keys(next).length > 0 ? next : undefined;
}
@@ -84,28 +84,50 @@ function bootstrapFromEnvironment(): BootstrapResult {
}
function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
const configReload = config.configReload;
if (!configReload) throw new Error("Loaded daemon config is missing reload metadata");
const cli = (configReload.cli ??= {});
const override = (configPath: string) => {
if (!configReload.overrideControlledPaths.includes(configPath)) {
configReload.overrideControlledPaths.push(configPath);
}
};
if (process.argv.includes("--relay")) {
config.relayEnabled = true;
config.relayEnabledMutable = false;
cli.relayEnabled = true;
override("daemon.relay.enabled");
}
if (process.argv.includes("--no-relay")) {
config.relayEnabled = false;
config.relayEnabledMutable = false;
cli.relayEnabled = false;
override("daemon.relay.enabled");
}
if (process.argv.includes("--relay-use-tls")) {
config.relayUseTls = true;
cli.relayUseTls = true;
override("daemon.relay.useTls");
}
if (process.argv.includes("--no-mcp")) {
config.mcpEnabled = false;
cli.mcpEnabled = false;
override("daemon.mcp.enabled");
}
if (process.argv.includes("--no-inject-mcp")) {
config.mcpInjectIntoAgents = false;
cli.mcpInjectIntoAgents = false;
override("daemon.mcp.injectIntoAgents");
}
if (process.argv.includes("--web-ui")) {
config.webUi = { ...(config.webUi ?? { distDir: null }), enabled: true };
cli.webUiEnabled = true;
override("features.webUi.enabled");
}
if (process.argv.includes("--no-web-ui")) {
config.webUi = { ...(config.webUi ?? { distDir: null }), enabled: false };
cli.webUiEnabled = false;
override("features.webUi.enabled");
}
}
+4
View File
@@ -924,6 +924,7 @@ export class Session {
listWorkspaces: () => this.workspaceRegistry.list(),
logger: this.sessionLogger,
hubRelationships: options.hubRelationships,
reloadConfig: () => daemonConfigStore.reload(),
});
this.hubExecutionController = options.hubExecutionAgents
? new HubExecutionController({
@@ -2177,6 +2178,9 @@ export class Session {
return this.daemonSession.handleGetStatusRequest(msg);
case "daemon.get_pairing_offer.request":
return this.daemonSession.handleGetPairingOfferRequest(msg);
case "daemon.config.reload.request":
this.daemonSession.handleConfigReloadRequest(msg);
return undefined;
case "hub.management.daemon.connect.request":
case "hub.management.daemon.get_status.request":
case "hub.management.daemon.disconnect.request":
@@ -12,6 +12,7 @@ import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./diagnostics.js"
import type { ProviderAvailability } from "../../agent/agent-manager.js";
import type { HubRelationshipManagement } from "../../hub/relationship-controller.js";
import type { SessionOutboundMessage } from "../../messages.js";
import type { DaemonConfigReloadResult } from "../../daemon-config-store.js";
const tempDirs: string[] = [];
@@ -42,6 +43,7 @@ function makeSubsystem(overrides: {
listProviderAvailability?: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
hubRelationships?: HubRelationshipManagement;
reloadConfig?: () => DaemonConfigReloadResult;
}) {
const emitted: SessionOutboundMessage[] = [];
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
@@ -63,12 +65,70 @@ function makeSubsystem(overrides: {
listProviderAvailability: overrides.listProviderAvailability ?? (async () => []),
getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics,
hubRelationships: overrides.hubRelationships,
reloadConfig:
overrides.reloadConfig ??
(() => ({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
})),
logger: pino({ level: "silent" }),
});
return { subsystem, emitted, paseoHome, restartIntents };
}
describe("DaemonSession", () => {
test("config reload returns the daemon-owned classification", () => {
const { subsystem, emitted } = makeSubsystem({
reloadConfig: () => ({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: ["app.baseUrl"],
}),
});
subsystem.handleConfigReloadRequest({
type: "daemon.config.reload.request",
requestId: "reload-1",
});
expect(emitted).toEqual([
{
type: "daemon.config.reload.response",
payload: {
requestId: "reload-1",
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: ["app.baseUrl"],
},
},
]);
});
test("config reload failures return a correlated RPC error", () => {
const { subsystem, emitted } = makeSubsystem({
reloadConfig: () => {
throw new Error("Invalid config");
},
});
subsystem.handleConfigReloadRequest({
type: "daemon.config.reload.request",
requestId: "reload-2",
});
expect(emitted).toEqual([
{
type: "rpc_error",
payload: {
requestId: "reload-2",
requestType: "daemon.config.reload.request",
error: "Invalid config",
code: "handler_error",
},
},
]);
});
test("Hub relationship command failures return correlated RPC errors", async () => {
const { subsystem, emitted } = makeSubsystem({
hubRelationships: {
@@ -11,6 +11,7 @@ import { DaemonSelfUpdateSessionController } from "./daemon-self-update-session-
import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
import type { HubRelationshipManagement } from "../../hub/relationship-controller.js";
import type { DaemonConfigReloadResult } from "../../daemon-config-store.js";
export interface DaemonRuntimeConfig {
listen: string | null;
@@ -50,6 +51,7 @@ export interface DaemonSessionOptions {
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
logger: pino.Logger;
hubRelationships?: HubRelationshipManagement;
reloadConfig: () => DaemonConfigReloadResult;
}
/**
@@ -74,6 +76,7 @@ export class DaemonSession {
private readonly logger: pino.Logger;
private readonly selfUpdate: DaemonSelfUpdateSessionController;
private readonly hubRelationships: HubRelationshipManagement | null;
private readonly reloadConfig: () => DaemonConfigReloadResult;
constructor(options: DaemonSessionOptions) {
this.host = options.host;
@@ -89,6 +92,7 @@ export class DaemonSession {
this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null);
this.logger = options.logger;
this.hubRelationships = options.hubRelationships ?? null;
this.reloadConfig = options.reloadConfig;
this.selfUpdate = new DaemonSelfUpdateSessionController({
clientId: this.clientId,
daemonVersion: this.daemonVersion ?? null,
@@ -230,6 +234,28 @@ export class DaemonSession {
}
}
handleConfigReloadRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.config.reload.request" }>,
): void {
try {
this.host.emit({
type: "daemon.config.reload.response",
payload: { requestId: msg.requestId, ...this.reloadConfig() },
});
} catch (error) {
this.logger.error({ err: error }, "Failed to reload daemon config");
this.host.emit({
type: "rpc_error",
payload: {
requestId: msg.requestId,
requestType: msg.type,
error: error instanceof Error ? error.message : String(error),
code: "handler_error",
},
});
}
}
async handleDiagnosticsRequest(
msg: Extract<SessionInboundMessage, { type: "diagnostics.request" }>,
): Promise<void> {
@@ -282,6 +282,7 @@ function createVoiceAssistantWebSocketServer(params: {
}),
};
const daemonConfigStore = {
onApply: () => () => {},
onChange: () => () => {},
};
@@ -100,6 +100,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
...agentManagerOverrides,
};
const daemonConfigStore = {
onApply: vi.fn(() => () => {}),
onChange: vi.fn(() => () => {}),
};
@@ -223,6 +223,7 @@ function createServer(options?: {
}) {
const speechReadiness = options?.speechReadiness ?? null;
const daemonConfigStore = {
onApply: vi.fn(() => () => {}),
onChange: vi.fn(() => () => {}),
};
const logger = options?.logger ?? createLogger();
@@ -131,6 +131,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
})),
};
const daemonConfigStore = {
onApply: vi.fn(() => () => {}),
onChange: vi.fn(() => () => {}),
};
+22 -9
View File
@@ -41,6 +41,7 @@ import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
import type { HubExecutionAgents } from "./hub/daemon-executions.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { attachMutableProviderConfigOwner } from "./agent/mutable-provider-config-owner.js";
import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
@@ -137,8 +138,10 @@ interface WebSocketConnectionIdentity {
}
interface WebSocketServerConfig {
allowedOrigins: Set<string>;
allowedOrigins?: Set<string>;
hostnames?: HostnamesConfig;
getAllowedOrigins?: () => Set<string>;
getHostnames?: () => HostnamesConfig | undefined;
daemonStatusRpc?: boolean;
relayConfig?: boolean;
}
@@ -686,14 +689,18 @@ export class VoiceAssistantWebSocketServer {
this.speech?.onReadinessChange((snapshot) => {
this.publishSpeechReadiness(snapshot);
}) ?? null;
this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config, details) => {
const nextAgentManagerState = this.providerSnapshotManager.applyMutableProviderConfig(
config.providers,
{ removeProviders: details.removedProviders },
);
this.agentManager.updateProviderRegistry(nextAgentManagerState);
const unsubscribeProviderConfig = attachMutableProviderConfigOwner({
store: this.daemonConfigStore,
providerSnapshotManager: this.providerSnapshotManager,
updateProviderRegistry: (state) => this.agentManager.updateProviderRegistry(state),
});
const unsubscribeChange = this.daemonConfigStore.onChange((config) => {
this.broadcastDaemonConfigChanged(config);
});
this.unsubscribeDaemonConfigChange = () => {
unsubscribeProviderConfig();
unsubscribeChange();
};
const pushLogger = this.logger.child({ module: "push" });
this.pushNotifications = createPushNotifications({
@@ -776,14 +783,18 @@ export class VoiceAssistantWebSocketServer {
wsConfig: WebSocketServerConfig,
auth: DaemonAuthConfig | undefined,
): WebSocketServer {
const { allowedOrigins, hostnames } = wsConfig;
const password = auth?.password;
const wss = new WebSocketServer({
server,
path: "/ws",
handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password),
verifyClient: ({ req }, callback) => {
this.verifyWsUpgrade(req, allowedOrigins, hostnames, callback);
this.verifyWsUpgrade(
req,
wsConfig.getAllowedOrigins?.() ?? wsConfig.allowedOrigins ?? new Set(),
wsConfig.getHostnames?.() ?? wsConfig.hostnames,
callback,
);
},
});
wss.on("connection", (ws, request) => {
@@ -1563,6 +1574,8 @@ export class VoiceAssistantWebSocketServer {
forgeSearch: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}),
// COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14.
daemonConfigReload: true,
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.