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
+10 -3
View File
@@ -175,6 +175,13 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
Single file, validated with `PersistedConfigSchema`. Single file, validated with `PersistedConfigSchema`.
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
fields and their removal/default semantics; session handlers and the CLI only relay the structured
result. Normal config patches persist only the requested fields, so launch overrides and resolved
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
``` ```
{ {
version: 1, version: 1,
@@ -278,9 +285,9 @@ Environment variables override `config.json`:
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` | | `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias | | `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the `PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Run `paseo reload`
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon. after changing `config.json`. Environment changes require a daemon restart; the launch environment
For a desktop-managed daemon, fully quit and reopen Paseo Desktop. remains authoritative during reload.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. `agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
+2 -2
View File
@@ -266,8 +266,8 @@ the daemon-global Git process limits in `$PASEO_HOME/config.json`:
} }
``` ```
Restart the daemon with `paseo daemon restart`. If Paseo Desktop manages the daemon, fully quit and Reload the daemon with `paseo reload`. Environment-variable overrides still require a restart because
reopen the desktop app. Lower values reduce machine pressure but make Git-backed workspace state and the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults, Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
semantics, and environment-variable overrides. semantics, and environment-variable overrides.
+12
View File
@@ -10,6 +10,18 @@ describe("canonical CLI surface", () => {
expect(help).not.toContain("worktree"); expect(help).not.toContain("worktree");
}); });
it("offers identical top-level and daemon config reload commands", () => {
const cli = createCli();
const reload = cli.commands.find((command) => command.name() === "reload");
const daemon = cli.commands.find((command) => command.name() === "daemon");
const nestedReload = daemon?.commands.find((command) => command.name() === "reload");
expect(reload?.helpInformation()).toContain("--host <host>");
expect(reload?.helpInformation()).toContain("--json");
expect(nestedReload?.helpInformation()).toContain("--host <host>");
expect(nestedReload?.helpInformation()).toContain("--json");
});
it("names explicit workspace creation without exposing older syntax", () => { it("names explicit workspace creation without exposing older syntax", () => {
const run = createCli().commands.find((command) => command.name() === "run"); const run = createCli().commands.find((command) => command.name() === "run");
const help = run?.helpInformation(); const help = run?.helpInformation();
+5
View File
@@ -16,6 +16,7 @@ import { createHooksCommand } from "./commands/hooks.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js"; import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js";
import { runDaemonReloadCommand } from "./commands/daemon/reload.js";
import { addLsOptions, runLsCommand } from "./commands/agent/ls.js"; import { addLsOptions, runLsCommand } from "./commands/agent/ls.js";
import { addRunOptions, runRunCommand } from "./commands/agent/run.js"; import { addRunOptions, runRunCommand } from "./commands/agent/run.js";
import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js"; import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js";
@@ -125,6 +126,10 @@ export function createCli(): Command {
.option("--home <path>", "Paseo home directory (default: ~/.paseo)") .option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runDaemonStatusCommand)); .action(withOutput(runDaemonStatusCommand));
addJsonAndDaemonHostOptions(
program.command("reload").description('Reload daemon config (alias for "paseo daemon reload")'),
).action(withOutput(runDaemonReloadCommand));
addJsonOption( addJsonOption(
program program
.command("restart") .command("restart")
+6 -1
View File
@@ -5,8 +5,9 @@ import { runStopCommand } from "./stop.js";
import { runRestartCommand } from "./restart.js"; import { runRestartCommand } from "./restart.js";
import { runSetPasswordCommand } from "./set-password.js"; import { runSetPasswordCommand } from "./set-password.js";
import { pairCommand } from "./pair.js"; import { pairCommand } from "./pair.js";
import { runDaemonReloadCommand } from "./reload.js";
import { withOutput } from "../../output/index.js"; import { withOutput } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js"; import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js";
function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined {
if (typeof hostnames === "string") return hostnames; if (typeof hostnames === "string") return hostnames;
@@ -20,6 +21,10 @@ export function createDaemonCommand(): Command {
daemon.addCommand(startCommand()); daemon.addCommand(startCommand());
daemon.addCommand(pairCommand()); daemon.addCommand(pairCommand());
addJsonAndDaemonHostOptions(
daemon.command("reload").description("Reload config.json without restarting the daemon"),
).action(withOutput(runDaemonReloadCommand));
addJsonOption(daemon.command("status").description("Show local daemon status")) addJsonOption(daemon.command("status").description("Show local daemon status"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)") .option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runStatusCommand)); .action(withOutput(runStatusCommand));
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import { render } from "../../output/render.js";
import { daemonReloadSchema, type DaemonReloadResult } from "./reload.js";
function result(data: DaemonReloadResult) {
return { type: "single" as const, data, schema: daemonReloadSchema };
}
describe("daemon reload output", () => {
test("human output reports restart and override warnings", () => {
expect(
render(
result({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: ["app.baseUrl"],
}),
),
).toBe(
[
"Configuration reloaded.",
"",
"Warning: These changes require a daemon restart:",
" daemon.listen",
"",
"Run: paseo daemon restart",
"",
"Warning: These settings are controlled by daemon launch overrides:",
" app.baseUrl",
].join("\n"),
);
});
test("human output omits restart guidance for a live-only reload", () => {
const output = render(
result({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
}),
);
expect(output).toBe("Configuration reloaded.");
expect(output).not.toContain("restart");
});
test("structured output preserves the daemon result", () => {
const data = {
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: [],
};
expect(render(result(data), { format: "json" })).toBe(JSON.stringify(data, null, 2));
expect(render(result(data), { format: "yaml" })).toContain(
"appliedPaths:\n - daemon.browserTools.enabled",
);
});
});
@@ -0,0 +1,56 @@
import type { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
export interface DaemonReloadResult {
appliedPaths: string[];
restartRequiredPaths: string[];
overrideControlledPaths: string[];
}
export const daemonReloadSchema: OutputSchema<DaemonReloadResult> = {
idField: () => "daemon-config",
columns: [],
renderHuman(result) {
if (result.type !== "single") return "";
const lines = ["Configuration reloaded."];
if (result.data.restartRequiredPaths.length > 0) {
lines.push(
"",
"Warning: These changes require a daemon restart:",
...result.data.restartRequiredPaths.map((path) => ` ${path}`),
"",
"Run: paseo daemon restart",
);
}
if (result.data.overrideControlledPaths.length > 0) {
lines.push(
"",
"Warning: These settings are controlled by daemon launch overrides:",
...result.data.overrideControlledPaths.map((path) => ` ${path}`),
);
}
return lines.join("\n");
},
};
export async function runDaemonReloadCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<DaemonReloadResult>> {
const client = await connectToDaemon({ host: options.host });
try {
const payload = await client.reloadDaemonConfig();
return {
type: "single",
data: {
appliedPaths: payload.appliedPaths,
restartRequiredPaths: payload.restartRequiredPaths,
overrideControlledPaths: payload.overrideControlledPaths,
},
schema: daemonReloadSchema,
};
} finally {
await client.close();
}
}
+39
View File
@@ -23,6 +23,7 @@ import { createRequire } from "node:module";
import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { dirname, join } from "path"; import { dirname, join } from "path";
import YAML from "yaml";
import { runLocalPaseo } from "./helpers/local-cli.ts"; import { runLocalPaseo } from "./helpers/local-cli.ts";
console.log("=== Daemon Commands ===\n"); console.log("=== Daemon Commands ===\n");
@@ -276,6 +277,44 @@ try {
assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state"); assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state");
assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer"); assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer");
const reloadConfig = JSON.parse(await readFile(configPath, "utf-8"));
reloadConfig.daemon = {
...reloadConfig.daemon,
listen: process.platform === "win32" ? `${listen}-changed` : `${listen}.changed`,
browserTools: { enabled: true },
};
await writeFile(configPath, `${JSON.stringify(reloadConfig, null, 2)}\n`, "utf-8");
const nestedReload = await daemonCommand(["reload", "--host", listen, "--json"]);
assert.strictEqual(nestedReload.exitCode, 0, nestedReload.stderr);
assert.deepStrictEqual(JSON.parse(nestedReload.stdout), {
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.listen"],
});
reloadConfig.daemon.browserTools.enabled = false;
await writeFile(configPath, `${JSON.stringify(reloadConfig, null, 2)}\n`, "utf-8");
const aliasReload = await runLocalPaseo(["reload", "--host", listen, "--json"], {
PASEO_HOME: paseoHome,
});
assert.strictEqual(aliasReload.exitCode, 0, aliasReload.stderr);
assert.deepStrictEqual(JSON.parse(aliasReload.stdout), {
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
const yamlReload = await daemonCommand(["reload", "--host", listen, "--format", "yaml"]);
assert.strictEqual(yamlReload.exitCode, 0, yamlReload.stderr);
assert.deepStrictEqual(YAML.parse(yamlReload.stdout), {
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
});
const humanReload = await daemonCommand(["reload", "--host", listen]);
assert.match(humanReload.stdout, /Configuration reloaded\./);
const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-")); const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-"));
try { try {
await writeFile( await writeFile(
+59
View File
@@ -1439,6 +1439,65 @@ test("honors explicit getDaemonPairingOffer timeout below the session RPC defaul
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)"); await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
}); });
test("gates config reload on the daemon capability", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
await expect(client.reloadDaemonConfig("reload-old-host")).rejects.toThrow(
"Update the host to reload daemon configuration.",
);
expect(mock.sent).toEqual([]);
});
test("sends and parses daemon config reload", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { daemonConfigReload: true } });
await connectPromise;
const response = client.reloadDaemonConfig("reload-new-host");
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "daemon.config.reload.request",
requestId: "reload-new-host",
});
mock.triggerMessage(
wrapSessionMessage({
type: "daemon.config.reload.response",
payload: {
requestId: "reload-new-host",
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: [],
},
}),
);
await expect(response).resolves.toEqual({
requestId: "reload-new-host",
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: [],
});
});
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => { test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
useHeartbeatClock(); useHeartbeatClock();
const logger = createMockLogger(); const logger = createMockLogger();
+16
View File
@@ -84,6 +84,7 @@ import type {
ProviderUsageListResponseMessage, ProviderUsageListResponseMessage,
DaemonGetStatusResponse, DaemonGetStatusResponse,
DaemonGetPairingOfferResponse, DaemonGetPairingOfferResponse,
DaemonConfigReloadResponse,
DiagnosticsResponse, DiagnosticsResponse,
AgentRewindResponseMessage, AgentRewindResponseMessage,
ListTerminalsResponse, ListTerminalsResponse,
@@ -4552,6 +4553,14 @@ export class DaemonClient {
}); });
} }
async reloadDaemonConfig(requestId?: string): Promise<DaemonConfigReloadResponse["payload"]> {
this.requireDaemonConfigReloadSupport();
return this.sendNamespacedCorrelatedSessionRequest({
requestId,
message: { type: "daemon.config.reload.request" },
});
}
async connectHub(hubUrl: string, token: string, requestId?: string) { async connectHub(hubUrl: string, token: string, requestId?: string) {
this.requireHubRelationshipSupport(); this.requireHubRelationshipSupport();
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
@@ -5333,6 +5342,13 @@ export class DaemonClient {
} }
} }
private requireDaemonConfigReloadSupport(): void {
// COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14.
if (this.lastServerInfoMessage?.features?.daemonConfigReload !== true) {
throw new Error("Update the host to reload daemon configuration.");
}
}
private resolveTransportUrlForAttempt(): string { private resolveTransportUrlForAttempt(): string {
return this.config.url; return this.config.url;
} }
+40 -1
View File
@@ -194,9 +194,26 @@ export const MutableDaemonConfigSchema = z
relay: MutableRelayConfigSchema.optional(), relay: MutableRelayConfigSchema.optional(),
mcp: z mcp: z
.object({ .object({
enabled: z.boolean().optional(),
injectIntoAgents: z.boolean(), injectIntoAgents: z.boolean(),
}) })
.passthrough(), .passthrough(),
hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
cors: z
.object({
allowedOrigins: z.array(z.string()),
})
.passthrough()
.optional(),
trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(),
git: z
.object({
maxProcessesPerSecond: z.number().int().positive(),
maxProcessConcurrency: z.number().int().positive(),
})
.optional(),
app: z.object({ baseUrl: z.string() }).optional(),
catalogRefreshTimeoutMs: z.number().int().positive().optional(),
browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }), browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }),
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }),
@@ -213,7 +230,7 @@ export const MutableDaemonConfigSchema = z
export const MutableDaemonConfigPatchSchema = z export const MutableDaemonConfigPatchSchema = z
.object({ .object({
relay: MutableRelayConfigSchema.partial().optional(), relay: MutableRelayConfigSchema.partial().optional(),
mcp: MutableDaemonConfigSchema.shape.mcp.partial().optional(), mcp: z.object({ injectIntoAgents: z.boolean().optional() }).passthrough().optional(),
browserTools: MutableBrowserToolsConfigSchema.partial().optional(), browserTools: MutableBrowserToolsConfigSchema.partial().optional(),
providers: z providers: z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
@@ -1300,6 +1317,11 @@ export const DaemonGetPairingOfferRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const DaemonConfigReloadRequestSchema = z.object({
type: z.literal("daemon.config.reload.request"),
requestId: z.string(),
});
export const HubManagementDaemonConnectRequestSchema = z.object({ export const HubManagementDaemonConnectRequestSchema = z.object({
type: z.literal("hub.management.daemon.connect.request"), type: z.literal("hub.management.daemon.connect.request"),
requestId: z.string(), requestId: z.string(),
@@ -2826,6 +2848,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WaitForFinishRequestSchema, WaitForFinishRequestSchema,
DaemonGetStatusRequestSchema, DaemonGetStatusRequestSchema,
DaemonGetPairingOfferRequestSchema, DaemonGetPairingOfferRequestSchema,
DaemonConfigReloadRequestSchema,
HubManagementDaemonConnectRequestSchema, HubManagementDaemonConnectRequestSchema,
HubManagementDaemonGetStatusRequestSchema, HubManagementDaemonGetStatusRequestSchema,
HubManagementDaemonDisconnectRequestSchema, HubManagementDaemonDisconnectRequestSchema,
@@ -3157,6 +3180,8 @@ export const ServerInfoStatusPayloadSchema = z
forgeSearch: z.boolean().optional(), forgeSearch: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(), daemonStatusRpc: z.boolean().optional(),
// COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14.
daemonConfigReload: z.boolean().optional(),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
relayConfig: z.boolean().optional(), relayConfig: z.boolean().optional(),
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
@@ -4336,6 +4361,18 @@ export const DaemonGetPairingOfferResponseSchema = z.object({
.passthrough(), .passthrough(),
}); });
export const DaemonConfigReloadResponseSchema = z.object({
type: z.literal("daemon.config.reload.response"),
payload: z
.object({
requestId: z.string(),
appliedPaths: z.array(z.string()),
restartRequiredPaths: z.array(z.string()),
overrideControlledPaths: z.array(z.string()),
})
.passthrough(),
});
export const DiagnosticsResponseSchema = z.object({ export const DiagnosticsResponseSchema = z.object({
type: z.literal("diagnostics.response"), type: z.literal("diagnostics.response"),
payload: z payload: z
@@ -5932,6 +5969,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetVoiceModeResponseMessageSchema, SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema, DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema, DaemonGetPairingOfferResponseSchema,
DaemonConfigReloadResponseSchema,
HubManagementDaemonConnectResponseSchema, HubManagementDaemonConnectResponseSchema,
HubManagementDaemonGetStatusResponseSchema, HubManagementDaemonGetStatusResponseSchema,
HubManagementDaemonDisconnectResponseSchema, HubManagementDaemonDisconnectResponseSchema,
@@ -6179,6 +6217,7 @@ export type ListProviderFeaturesResponseMessage = z.infer<
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>; export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>; export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>; export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>;
export type DaemonConfigReloadResponse = z.infer<typeof DaemonConfigReloadResponseSchema>;
export type DiagnosticsResponse = z.infer<typeof DiagnosticsResponseSchema>; export type DiagnosticsResponse = z.infer<typeof DiagnosticsResponseSchema>;
export type GetProvidersSnapshotResponseMessage = z.infer< export type GetProvidersSnapshotResponseMessage = z.infer<
typeof GetProvidersSnapshotResponseMessageSchema typeof GetProvidersSnapshotResponseMessageSchema
@@ -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 () => { test("defaults provider refreshes to a two-minute deadline", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const manager = new ProviderSnapshotManager({ const manager = new ProviderSnapshotManager({
@@ -1405,9 +1433,131 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
manager.destroy(); 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", () => { 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", () => { test("on/off attaches and detaches change listeners", () => {
const manager = new ProviderSnapshotManager({ const manager = new ProviderSnapshotManager({
logger: createTestLogger(), logger: createTestLogger(),
@@ -85,15 +85,9 @@ function omitProviderOverrides(
overrides: Record<string, ProviderOverride> | undefined, overrides: Record<string, ProviderOverride> | undefined,
providers: readonly string[], providers: readonly string[],
): Record<string, ProviderOverride> | undefined { ): Record<string, ProviderOverride> | undefined {
if (!overrides || providers.length === 0) { if (!overrides || providers.length === 0) return overrides;
return overrides;
}
const nextOverrides = { ...overrides }; const nextOverrides = { ...overrides };
for (const provider of providers) { for (const provider of providers) delete nextOverrides[provider];
delete nextOverrides[provider];
}
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
} }
@@ -129,6 +123,13 @@ interface ProviderSnapshotReadOptions {
interface ApplyMutableProviderConfigOptions { interface ApplyMutableProviderConfigOptions {
removeProviders?: readonly string[]; removeProviders?: readonly string[];
replace?: boolean;
}
export interface StagedMutableProviderConfig {
agentManagerState: AgentManagerProviderState;
publish(): void;
rollback(): void;
} }
interface ProviderSnapshotProviderOptions { interface ProviderSnapshotProviderOptions {
@@ -185,6 +186,16 @@ interface ProviderLoad {
promise: Promise<void>; 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 }; type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string };
interface ProviderSnapshotTarget { interface ProviderSnapshotTarget {
@@ -197,8 +208,8 @@ export class ProviderSnapshotManager {
private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>(); private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>();
private readonly events = new EventEmitter(); private readonly events = new EventEmitter();
private destroyed = false; private destroyed = false;
private readonly refreshTimeoutMs: number; private refreshTimeoutMs: number;
private readonly diagnosticTimeoutMs: number; private diagnosticTimeoutMs: number;
private readonly logger: Logger; private readonly logger: Logger;
private readonly workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">; private readonly workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
private readonly managedProcesses?: ManagedProcessRegistry; private readonly managedProcesses?: ManagedProcessRegistry;
@@ -209,6 +220,7 @@ export class ProviderSnapshotManager {
private baseProviderOverrides: Record<string, ProviderOverride> | undefined; private baseProviderOverrides: Record<string, ProviderOverride> | undefined;
private providerRegistry: Record<AgentProvider, ProviderDefinition>; private providerRegistry: Record<AgentProvider, ProviderDefinition>;
private providerClients: Record<AgentProvider, AgentClient>; private providerClients: Record<AgentProvider, AgentClient>;
private readonly ownedClients = new Set<AgentClient>();
constructor(options: ProviderSnapshotManagerOptions) { constructor(options: ProviderSnapshotManagerOptions) {
this.logger = options.logger; this.logger = options.logger;
@@ -226,6 +238,7 @@ export class ProviderSnapshotManager {
); );
this.providerRegistry = this.buildRegistry(); this.providerRegistry = this.buildRegistry();
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>; this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
for (const client of Object.values(this.providerClients)) this.ownedClients.add(client);
} }
getSnapshot(cwd?: string): ProviderSnapshotEntry[] { getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
@@ -317,6 +330,7 @@ export class ProviderSnapshotManager {
} }
const client = definition.createClient(this.logger); const client = definition.createClient(this.logger);
this.providerClients[provider] = client; this.providerClients[provider] = client;
this.ownedClients.add(client);
return client; return client;
} }
@@ -456,24 +470,97 @@ export class ProviderSnapshotManager {
mutableProviders: MutableDaemonConfig["providers"] | undefined, mutableProviders: MutableDaemonConfig["providers"] | undefined,
options: ApplyMutableProviderConfigOptions = {}, options: ApplyMutableProviderConfigOptions = {},
): AgentManagerProviderState { ): AgentManagerProviderState {
this.baseProviderOverrides = omitProviderOverrides( const staged = this.stageMutableProviderConfig(mutableProviders, options);
this.baseProviderOverrides, try {
options.removeProviders ?? [], staged.publish();
); return staged.agentManagerState;
this.providerOverrides = applyMutableProviderConfigToOverrides( } catch (error) {
this.baseProviderOverrides, staged.rollback();
mutableProviders, throw error;
);
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);
} }
}
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 { on(event: "change", listener: ProviderSnapshotChangeListener): this {
@@ -490,11 +577,8 @@ export class ProviderSnapshotManager {
// Materialize a client per enabled provider so provider-owned resources // Materialize a client per enabled provider so provider-owned resources
// (background processes, sockets, etc.) get a chance to release even when // (background processes, sockets, etc.) get a chance to release even when
// a given provider hasn't been touched yet during this daemon's lifetime. // a given provider hasn't been touched yet during this daemon's lifetime.
const state = this.getAgentManagerProviderState(); this.getAgentManagerProviderState();
const clients = Object.values(state.clients).filter( await shutdownAgentClients(this.ownedClients, this.logger);
(client): client is AgentClient => client !== undefined,
);
await shutdownAgentClients(clients, this.logger);
} }
destroy(): void { destroy(): void {
@@ -665,18 +749,22 @@ export class ProviderSnapshotManager {
defaultModeId: definition?.defaultModeId ?? null, defaultModeId: definition?.defaultModeId ?? null,
}; };
if (!definition?.enabled || !current || current.status === "loading") { if (!definition?.enabled) {
entries.set(provider, { entries.set(provider, {
...metadata, ...metadata,
status: "unavailable", status: "unavailable",
enabled: definition?.enabled ?? true, enabled: false,
}); });
continue; continue;
} }
entries.set(provider, { entries.set(provider, {
...current,
...metadata, ...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 { WebSocket } from "ws";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { AgentManagerShuttingDownError } from "./agent/agent-manager.js"; import { AgentManagerShuttingDownError } from "./agent/agent-manager.js";
import { hashDaemonPassword } from "./auth.js"; import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.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 { DaemonClient } from "./test-utils/daemon-client.js";
import { isPlatform } from "../test-utils/platform.js"; import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.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 { import type {
HubEnrollment, HubEnrollment,
HubEnrollmentResult, 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) => { return new Promise((resolve, reject) => {
const req = http.get( 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) => { (res) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("data", (chunk: Buffer) => chunks.push(chunk));
@@ -775,8 +971,16 @@ async function beginDaemonShutdownWithAgentClosing(): Promise<BlockedDaemonShutd
}; };
} }
function probeWebSocketConnection(url: string): Promise<WebSocketProbeResult> { function probeWebSocketConnection(
const ws = new WebSocket(url); 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) => { return new Promise((resolve) => {
ws.once("open", () => { ws.once("open", () => {
ws.close(); ws.close();
+94 -36
View File
@@ -147,6 +147,7 @@ import {
import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { ScheduleService } from "./schedule/service.js"; import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.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 { BrowserToolsBroker } from "./browser-tools/broker.js";
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
@@ -448,6 +449,13 @@ export interface PaseoDaemonConfig {
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
pushNotificationSender?: PushNotificationSender; pushNotificationSender?: PushNotificationSender;
managedProcesses?: ManagedProcessRegistry; managedProcesses?: ManagedProcessRegistry;
configReload?: {
env: NodeJS.ProcessEnv;
cli?: CliConfigOverrides;
overrideControlledPaths: string[];
relayEnabledFallback: boolean;
startupPersisted: PersistedConfig;
};
} }
export interface PaseoDaemon { export interface PaseoDaemon {
@@ -516,22 +524,22 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
} }
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
const providers: MutableDaemonConfig["providers"] = Object.fromEntries( const providers = config.providerOverrides ?? {};
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 initialConfig: MutableDaemonConfig = { const initialConfig: MutableDaemonConfig = {
relay: { enabled: config.relayEnabled ?? true }, 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 }, browserTools: { enabled: config.browserToolsEnabled ?? false },
providers, providers,
metadataGeneration: { metadataGeneration: {
@@ -565,12 +573,24 @@ export async function createPaseoDaemon(
const bootstrapStart = performance.now(); const bootstrapStart = performance.now();
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url); const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url);
const daemonConfigStore = new DaemonConfigStore( const initialMutableConfig = createInitialMutableDaemonConfig(config);
config.paseoHome, const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, {
createInitialMutableDaemonConfig(config), relayEnabledMutable: config.relayEnabledMutable ?? true,
logger, startupPersisted: config.configReload?.startupPersisted,
{ relayEnabledMutable: config.relayEnabledMutable ?? true }, 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 browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({}); const browserToolsBroker = new BrowserToolsBroker({});
const pluginRuntime = new PluginService(logger, daemonConfigStore); const pluginRuntime = new PluginService(logger, daemonConfigStore);
@@ -606,6 +626,9 @@ export async function createPaseoDaemon(
const app = express(); const app = express();
app.set("trust proxy", resolveExpressTrustProxySetting(config)); app.set("trust proxy", resolveExpressTrustProxySetting(config));
daemonConfigStore.onFieldChange("trustedProxies", (value) => {
app.set("trust proxy", value ?? ["loopback"]);
});
let boundListenTarget: ListenTarget | null = null; let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const terminalManager = createConfiguredTerminalManager({ const terminalManager = createConfiguredTerminalManager({
@@ -622,7 +645,14 @@ export async function createPaseoDaemon(
}); });
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore(); const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
const workspaceSetupRuntime = new WorkspaceSetupRuntime(); 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 wsServer: VoiceAssistantWebSocketServer | null = null;
let serviceProxyListenTarget: ListenTarget | null = null; let serviceProxyListenTarget: ListenTarget | null = null;
const scriptHealthMonitor = new ScriptHealthMonitor({ const scriptHealthMonitor = new ScriptHealthMonitor({
@@ -668,8 +698,7 @@ export async function createPaseoDaemon(
} }
// CORS - allow same-origin + configured origins // CORS - allow same-origin + configured origins
const allowedOrigins = new Set([ const fixedAllowedOrigins = [
...config.corsAllowedOrigins,
// Packaged desktop renderers use the custom paseo:// protocol scheme. // Packaged desktop renderers use the custom paseo:// protocol scheme.
"paseo://app", "paseo://app",
// For TCP, add localhost variants // For TCP, add localhost variants
@@ -680,7 +709,14 @@ export async function createPaseoDaemon(
`http://127.0.0.1:${listenTarget.port}`, `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) => { app.use((req, res, next) => {
const origin = req.headers.origin; const origin = req.headers.origin;
@@ -840,6 +876,17 @@ export async function createPaseoDaemon(
isDev: config.isDev === true, isDev: config.isDev === true,
extraClients: config.agentClients, 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 initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const agentManager = new AgentManager({ const agentManager = new AgentManager({
clients: initialAgentManagerState.clients, clients: initialAgentManagerState.clients,
@@ -1309,9 +1356,9 @@ export async function createPaseoDaemon(
agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog); agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog);
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false); agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
const mcpEnabled = config.mcpEnabled ?? true; let mcpEnabled = config.mcpEnabled ?? true;
let agentMcpBaseUrl: string | null = null; let agentMcpBaseUrl: string | null = null;
if (mcpEnabled) { {
const agentMcpRoute = "/mcp/agents"; const agentMcpRoute = "/mcp/agents";
const createAgentMcpSession = async (callerAgentId?: string) => { const createAgentMcpSession = async (callerAgentId?: string) => {
@@ -1344,6 +1391,10 @@ export async function createPaseoDaemon(
req: express.Request, req: express.Request,
res: express.Response, res: express.Response,
): Promise<void> => { ): 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 // This route is exempt from the global daemon-password middleware, so it
// authenticates here using the injected capability token (or a valid // authenticates here using the injected capability token (or a valid
// daemon password). Without this, a password-protected daemon would be // daemon password). Without this, a password-protected daemon would be
@@ -1425,9 +1476,7 @@ export async function createPaseoDaemon(
app.post(agentMcpRoute, handleAgentMcpRequest); app.post(agentMcpRoute, handleAgentMcpRequest);
app.get(agentMcpRoute, handleAgentMcpRequest); app.get(agentMcpRoute, handleAgentMcpRequest);
app.delete(agentMcpRoute, handleAgentMcpRequest); app.delete(agentMcpRoute, handleAgentMcpRequest);
logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app"); logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted");
} else {
logger.info("Agent MCP HTTP endpoint disabled");
} }
const speechService = createSpeechService({ const speechService = createSpeechService({
@@ -1469,13 +1518,20 @@ export async function createPaseoDaemon(
mainStarted = true; mainStarted = true;
const logAndResolve = async () => { const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer); boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null; const mcpBaseUrl = createAgentMcpBaseUrl(boundListenTarget);
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl; agentMcpBaseUrl =
!mcpEnabled || config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
agentManager.setMcpBaseUrl(agentMcpBaseUrl); 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) => { daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null); agentManager.setMcpBaseUrl(mcpEnabled && value ? mcpBaseUrl : null);
agentManager.setPaseoToolsEnabled(value !== false); agentManager.setPaseoToolsEnabled(mcpEnabled && value !== false);
}); });
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => { daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : ""); agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
@@ -1520,8 +1576,8 @@ export async function createPaseoDaemon(
daemonConfigStore, daemonConfigStore,
mcpBaseUrl, mcpBaseUrl,
{ {
allowedOrigins, getAllowedOrigins: () => allowedOrigins,
hostnames: configuredHostnames, getHostnames: () => configuredHostnames,
daemonStatusRpc: dependencies.serverFeatureOverrides?.daemonStatusRpc, daemonStatusRpc: dependencies.serverFeatureOverrides?.daemonStatusRpc,
relayConfig: dependencies.serverFeatureOverrides?.relayConfig, relayConfig: dependencies.serverFeatureOverrides?.relayConfig,
}, },
@@ -1557,7 +1613,9 @@ export async function createPaseoDaemon(
{ {
listen: formatListenTarget(boundListenTarget ?? listenTarget), listen: formatListenTarget(boundListenTarget ?? listenTarget),
worktreesRoot: config.worktreesRoot, worktreesRoot: config.worktreesRoot,
appBaseUrl: config.appBaseUrl, get appBaseUrl() {
return appBaseUrl;
},
desktopManaged: config.desktopManaged === true, desktopManaged: config.desktopManaged === true,
getRelayConfig: () => getRelayConfig: () =>
relayRuntime?.getConfig() ?? { relayRuntime?.getConfig() ?? {
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { loadConfig } from "./config.js"; import { loadConfig, resolveConfigFromPersisted } from "./config.js";
const roots: string[] = []; const roots: string[] = [];
@@ -36,6 +36,39 @@ describe("daemon relay config", () => {
expect(config.relayEnabledMutable).toBe(true); 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 () => { test("marks environment relay overrides immutable", async () => {
const home = await createPaseoHome({ const home = await createPaseoHome({
version: 1, version: 1,
+114 -1
View File
@@ -4,7 +4,8 @@ import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, test } from "vitest"; 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[] = []; const roots: string[] = [];
@@ -39,6 +40,118 @@ describe("server config", () => {
expect(config.providerCatalogRefreshTimeoutMs).toBe(180_000); 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", () => { test("resolves bundled web UI path from source-tree modules", () => {
const root = path.parse(process.cwd()).root; const root = path.parse(process.cwd()).root;
expect( 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 { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { hashDaemonPassword } from "./auth.js"; import { hashDaemonPassword } from "./auth.js";
import { resolveSpeechConfig } from "./speech/speech-config-resolver.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 { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js"; import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js";
@@ -112,22 +113,86 @@ type TrustedProxiesConfig = true | string[];
function resolveLogConfigFromEnv( function resolveLogConfigFromEnv(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>, persisted: PersistedConfig,
): PersistedConfig["log"] { ): PersistedConfig["log"] {
const envLogLevel = LogLevelSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_LEVEL)); const level = parseLogLevelEnv(env.PASEO_LOG_LEVEL ?? env.PASEO_LOG);
const envLogFormat = LogFormatSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_FORMAT)); 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;
} }
return { return {
...persisted.log, ...persisted.log,
...(envLogLevel.success ? { level: envLogLevel.data } : {}), ...(level !== undefined ? { level } : {}),
...(envLogFormat.success ? { format: envLogFormat.data } : {}), ...(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 const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()]) .union([z.string(), z.null(), z.undefined()])
.transform((value): string | null => .transform((value): string | null =>
@@ -191,9 +256,10 @@ function extractAgentProviderSettings(
interface ResolveRelayInput { interface ResolveRelayInput {
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
persisted: ReturnType<typeof loadPersistedConfig>; persisted: PersistedConfig;
cliRelayEnabled: boolean | undefined; cliRelayEnabled: boolean | undefined;
cliRelayUseTls: boolean | undefined; cliRelayUseTls: boolean | undefined;
enabledFallback: boolean;
} }
interface ResolvedRelay { interface ResolvedRelay {
@@ -223,10 +289,13 @@ function resolveTlsFromEnv(
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED); const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED);
// COMPAT(relayOptInDefault): configs created before v0.2.6 may omit this field. // COMPAT(relayOptInDefault): daemons whose startup config omitted this field
// Preserve their relay-on behavior until 2027-01-31; new homes materialize false. // retain relay-on removal semantics until 2027-01-31. Modern homes use false.
const enabled = const enabled =
input.cliRelayEnabled ?? environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? true; input.cliRelayEnabled ??
environmentEnabled ??
input.persisted.daemon?.relay?.enabled ??
input.enabledFallback;
const endpoint = const endpoint =
input.env.PASEO_RELAY_ENDPOINT ?? input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ?? input.persisted.daemon?.relay?.endpoint ??
@@ -473,17 +542,24 @@ function resolveStaticLoadConfigSettings(
}; };
} }
export function loadConfig( interface ResolveConfigFromPersistedOptions {
paseoHome: string, env?: NodeJS.ProcessEnv;
options?: { cli?: CliConfigOverrides;
env?: NodeJS.ProcessEnv; relayEnabledFallback?: boolean;
cli?: CliConfigOverrides; }
},
): PaseoDaemonConfig {
const env = options?.env ?? process.env;
const persisted = loadPersistedConfig(paseoHome);
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 { const {
mcpEnabled, mcpEnabled,
mcpInjectIntoAgents, mcpInjectIntoAgents,
@@ -495,16 +571,17 @@ export function loadConfig(
hostnames, hostnames,
trustedProxies, trustedProxies,
appBaseUrl, appBaseUrl,
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted); } = resolveStaticLoadConfigSettings(env, cli, persisted);
const relay = resolveRelayConfig({ const relay = resolveRelayConfig({
env, env,
persisted, persisted,
cliRelayEnabled: options?.cli?.relayEnabled, cliRelayEnabled: cli?.relayEnabled,
cliRelayUseTls: options?.cli?.relayUseTls, cliRelayUseTls: cli?.relayUseTls,
enabledFallback: relayEnabledFallback,
}); });
const serviceProxy = resolveServiceProxyConfig(env, persisted); const serviceProxy = resolveServiceProxyConfig(env, persisted);
const webUi = resolveWebUiConfig(paseoHome, env, options?.cli, persisted); const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted);
const { openai, speech } = resolveSpeechConfig({ const { openai, speech } = resolveSpeechConfig({
paseoHome, paseoHome,
@@ -517,6 +594,8 @@ export function loadConfig(
persisted.agents?.providers as Record<string, unknown> | undefined, persisted.agents?.providers as Record<string, unknown> | undefined,
); );
const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, speech.providers);
return { return {
listen, listen,
paseoHome, paseoHome,
@@ -561,5 +640,209 @@ export function loadConfig(
metadataGeneration: persisted.agents?.metadataGeneration, metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides, providerOverrides,
log: resolveLogConfigFromEnv(env, persisted), 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 { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import { loadPersistedConfig } from "./persisted-config.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", () => { describe("applyMutableProviderConfigToOverrides", () => {
test("merges mutable provider fields onto provider overrides", () => { test("merges mutable provider fields onto provider overrides", () => {
@@ -181,6 +214,38 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); 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", () => { test("rejects relay patches when a launch override owns the setting", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome); tempDirs.push(paseoHome);
@@ -237,6 +302,46 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); 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", () => { test("patch persists provider enabled flags into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome); 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 MutableDaemonConfigPatch = import("@getpaseo/protocol/messages").MutableDaemonConfigPatch;
type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOverride; 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 { interface LoggerLike {
child(bindings: Record<string, unknown>): LoggerLike; child(bindings: Record<string, unknown>): LoggerLike;
info(...args: unknown[]): void; info(...args: unknown[]): void;
@@ -24,7 +40,26 @@ export interface DaemonConfigChangeDetails {
removedProviders: readonly string[]; 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 ConfigListener = (config: MutableDaemonConfig, details: DaemonConfigChangeDetails) => void;
type ConfigApplyRollback = () => void;
type ConfigApplyListener = (
config: MutableDaemonConfig,
previous: MutableDaemonConfig,
details: DaemonConfigChangeDetails,
) => ConfigApplyRollback;
type FieldChangeHandler = (value: unknown) => void; type FieldChangeHandler = (value: unknown) => void;
interface AppliedFieldChange { interface AppliedFieldChange {
@@ -121,17 +156,6 @@ function omitProvidersFromOverrides(
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; 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 { function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path return path
.split(".") .split(".")
@@ -142,6 +166,115 @@ function isEqualValue(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b); 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( export function applyMutableProviderConfigToOverrides(
baseOverrides: Record<string, ProviderOverride> | undefined, baseOverrides: Record<string, ProviderOverride> | undefined,
mutableProviders: MutableDaemonConfig["providers"] | undefined, mutableProviders: MutableDaemonConfig["providers"] | undefined,
@@ -166,14 +299,22 @@ export class DaemonConfigStore {
private readonly paseoHome: string; private readonly paseoHome: string;
private readonly logger: LoggerLike | undefined; private readonly logger: LoggerLike | undefined;
private readonly changeListeners = new Set<ConfigListener>(); private readonly changeListeners = new Set<ConfigListener>();
private readonly applyListeners = new Set<ConfigApplyListener>();
private readonly fieldChangeHandlers = new Map<string, Set<FieldChangeHandler>>(); private readonly fieldChangeHandlers = new Map<string, Set<FieldChangeHandler>>();
private readonly relayEnabledMutable: boolean; private readonly relayEnabledMutable: boolean;
private readonly reloadSource: DaemonConfigReloadSource | undefined;
private readonly startupPersisted: PersistedConfig;
private lastKnownPersisted: PersistedConfig;
constructor( constructor(
paseoHome: string, paseoHome: string,
initial: MutableDaemonConfig, initial: MutableDaemonConfig,
logger?: LoggerLike, logger?: LoggerLike,
options: { relayEnabledMutable?: boolean } = {}, options: {
relayEnabledMutable?: boolean;
reloadSource?: DaemonConfigReloadSource;
startupPersisted?: PersistedConfig;
} = {},
) { ) {
this.paseoHome = paseoHome; this.paseoHome = paseoHome;
this.logger = getLogger(logger); this.logger = getLogger(logger);
@@ -182,6 +323,9 @@ export class DaemonConfigStore {
relay: initial.relay ?? { enabled: true }, relay: initial.relay ?? { enabled: true },
}); });
this.relayEnabledMutable = options.relayEnabledMutable ?? 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 { public get(): MutableDaemonConfig {
@@ -189,7 +333,7 @@ export class DaemonConfigStore {
} }
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial); const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) { if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
throw new Error( throw new Error(
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", "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); const configChanged = !isEqualValue(this.current, next);
if (!configChanged && removedProviders.length === 0) { if (!configChanged && removedProviders.length === 0) {
return this.current; return this.current;
} }
const persistedBeforePatch = this.persistConfig(next, removedProviders); const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
if (!configChanged) return this.current; 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 previous = this.current;
const appliedFieldChanges: AppliedFieldChange[] = []; const appliedFieldChanges: AppliedFieldChange[] = [];
const applyRollbacks: ConfigApplyRollback[] = [];
this.current = next; this.current = next;
try { try {
for (const path of changedFieldPaths) { for (const path of changedFieldPaths) {
@@ -234,21 +457,44 @@ export class DaemonConfigStore {
handler(value); handler(value);
} }
} }
for (const listener of this.applyListeners) {
applyRollbacks.push(listener(next, previous, changeDetails));
}
} catch (error) { } catch (error) {
this.current = previous; 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()) { 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; throw error;
} }
const changeDetails: DaemonConfigChangeDetails = { removedProviders };
for (const listener of this.changeListeners) { 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 { 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( private persistConfig(
config: MutableDaemonConfig, patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
removeProviders: readonly string[], removeProviders: readonly string[],
): PersistedConfig { ): { previous: PersistedConfig; knownNext: PersistedConfig } {
const persisted = loadPersistedConfig(this.paseoHome, this.logger); const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const nextPersisted = mergeMutableConfigIntoPersistedConfig({ const merge = (source: PersistedConfig) =>
persisted, mergeMutablePatchIntoPersistedConfig({
mutable: config, persisted: source,
removeProviders, patch,
persistRelayEnabled: this.relayEnabledMutable, removeProviders,
}); persistRelayEnabled: this.relayEnabledMutable,
});
const nextPersisted = merge(persisted);
const knownNext = merge(this.lastKnownPersisted);
savePersistedConfig(this.paseoHome, nextPersisted, this.logger); savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
return persisted; return { previous: persisted, knownNext };
} }
} }
function mergeMutableConfigIntoPersistedConfig(params: { function mergeMutablePatchIntoPersistedConfig(params: {
persisted: PersistedConfig; persisted: PersistedConfig;
mutable: MutableDaemonConfig; patch: Omit<SupportedMutableConfigPatch, "removeProviders">;
removeProviders: readonly string[]; removeProviders: readonly string[];
persistRelayEnabled: boolean; persistRelayEnabled: boolean;
}): PersistedConfig { }): PersistedConfig {
const { persisted, mutable, removeProviders, persistRelayEnabled } = params; const { persisted, patch, removeProviders, persistRelayEnabled } = params;
if (!mutable.relay) { const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled);
throw new Error("Mutable daemon config is missing relay state"); 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( const persistedProviderOverrides = omitProvidersFromOverrides(
persisted.agents?.providers as Record<string, ProviderOverride> | undefined, persistedAgents?.providers as Record<string, ProviderOverride> | undefined,
removeProviders, removeProviders,
); );
const providerOverrides = applyMutableProviderConfigToOverrides( const providerOverrides = applyMutableProviderConfigToOverrides(
persistedProviderOverrides, persistedProviderOverrides,
mutable.providers, patch.providers,
); );
const persistedAgents = omitProvidersFromPersistedAgents(persisted.agents); if (providerOverrides) next["providers"] = providerOverrides;
const persistedMetadataGeneration = { else delete next["providers"];
providers: metadataGenerationProviders,
};
const shouldPersistMetadataGeneration =
metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined;
let nextAgents = persistedAgents as PersistedConfig["agents"]; if (patch.metadataGeneration?.providers !== undefined) {
if (providerOverrides && Object.keys(providerOverrides).length > 0) { next["metadataGeneration"] = { providers: patch.metadataGeneration.providers };
nextAgents = { } else if (removeProviders.length > 0 && persistedAgents?.metadataGeneration?.providers) {
...persistedAgents, const removed = new Set(removeProviders);
providers: providerOverrides, next["metadataGeneration"] = {
...(shouldPersistMetadataGeneration providers: persistedAgents.metadataGeneration.providers.filter(
? { metadataGeneration: persistedMetadataGeneration } (entry) => !removed.has(entry.provider),
: {}), ),
} as PersistedConfig["agents"]; };
} else if (shouldPersistMetadataGeneration) {
nextAgents = {
...persistedAgents,
metadataGeneration: persistedMetadataGeneration,
} as PersistedConfig["agents"];
} }
return { return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined;
...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;
} }
function readBrowserToolsEnabled(mutable: MutableDaemonConfig): boolean { function mergeMutableDaemonPatch(
const browserTools = mutable.browserTools; persistedDaemon: PersistedConfig["daemon"],
if (!isRecord(browserTools)) { patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
return false; 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; if (patch.mcp?.injectIntoAgents !== undefined) {
} next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents };
function readMetadataGenerationProviders(
mutable: MutableDaemonConfig,
): Array<{ provider: string; model?: string; thinkingOptionId?: string }> {
const metadataGeneration = mutable.metadataGeneration;
if (!isRecord(metadataGeneration)) {
return [];
} }
const providers = metadataGeneration["providers"]; if (patch.browserTools?.enabled !== undefined) {
if (!Array.isArray(providers)) { next.browserTools = { ...next.browserTools, enabled: patch.browserTools.enabled };
return [];
} }
return providers.flatMap((entry) => { if (patch.autoArchiveAfterMerge !== undefined) {
if (!isRecord(entry) || typeof entry["provider"] !== "string") { next.autoArchiveAfterMerge = patch.autoArchiveAfterMerge;
return []; }
} if (patch.enableTerminalAgentHooks !== undefined) {
return [ next.enableTerminalAgentHooks = patch.enableTerminalAgentHooks;
{ }
provider: entry["provider"], if (patch.appendSystemPrompt !== undefined) next.appendSystemPrompt = patch.appendSystemPrompt;
...(typeof entry["model"] === "string" ? { model: entry["model"] } : {}), if (patch.terminalProfiles !== undefined) next.terminalProfiles = patch.terminalProfiles;
...(typeof entry["thinkingOptionId"] === "string" if (patch.agentProfiles !== undefined) next.agentProfiles = patch.agentProfiles;
? { thinkingOptionId: entry["thinkingOptionId"] } return Object.keys(next).length > 0 ? next : undefined;
: {}),
},
];
});
} }
@@ -84,28 +84,50 @@ function bootstrapFromEnvironment(): BootstrapResult {
} }
function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void { 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")) { if (process.argv.includes("--relay")) {
config.relayEnabled = true; config.relayEnabled = true;
config.relayEnabledMutable = false; config.relayEnabledMutable = false;
cli.relayEnabled = true;
override("daemon.relay.enabled");
} }
if (process.argv.includes("--no-relay")) { if (process.argv.includes("--no-relay")) {
config.relayEnabled = false; config.relayEnabled = false;
config.relayEnabledMutable = false; config.relayEnabledMutable = false;
cli.relayEnabled = false;
override("daemon.relay.enabled");
} }
if (process.argv.includes("--relay-use-tls")) { if (process.argv.includes("--relay-use-tls")) {
config.relayUseTls = true; config.relayUseTls = true;
cli.relayUseTls = true;
override("daemon.relay.useTls");
} }
if (process.argv.includes("--no-mcp")) { if (process.argv.includes("--no-mcp")) {
config.mcpEnabled = false; config.mcpEnabled = false;
cli.mcpEnabled = false;
override("daemon.mcp.enabled");
} }
if (process.argv.includes("--no-inject-mcp")) { if (process.argv.includes("--no-inject-mcp")) {
config.mcpInjectIntoAgents = false; config.mcpInjectIntoAgents = false;
cli.mcpInjectIntoAgents = false;
override("daemon.mcp.injectIntoAgents");
} }
if (process.argv.includes("--web-ui")) { if (process.argv.includes("--web-ui")) {
config.webUi = { ...(config.webUi ?? { distDir: null }), enabled: true }; config.webUi = { ...(config.webUi ?? { distDir: null }), enabled: true };
cli.webUiEnabled = true;
override("features.webUi.enabled");
} }
if (process.argv.includes("--no-web-ui")) { if (process.argv.includes("--no-web-ui")) {
config.webUi = { ...(config.webUi ?? { distDir: null }), enabled: false }; 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(), listWorkspaces: () => this.workspaceRegistry.list(),
logger: this.sessionLogger, logger: this.sessionLogger,
hubRelationships: options.hubRelationships, hubRelationships: options.hubRelationships,
reloadConfig: () => daemonConfigStore.reload(),
}); });
this.hubExecutionController = options.hubExecutionAgents this.hubExecutionController = options.hubExecutionAgents
? new HubExecutionController({ ? new HubExecutionController({
@@ -2177,6 +2178,9 @@ export class Session {
return this.daemonSession.handleGetStatusRequest(msg); return this.daemonSession.handleGetStatusRequest(msg);
case "daemon.get_pairing_offer.request": case "daemon.get_pairing_offer.request":
return this.daemonSession.handleGetPairingOfferRequest(msg); 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.connect.request":
case "hub.management.daemon.get_status.request": case "hub.management.daemon.get_status.request":
case "hub.management.daemon.disconnect.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 { ProviderAvailability } from "../../agent/agent-manager.js";
import type { HubRelationshipManagement } from "../../hub/relationship-controller.js"; import type { HubRelationshipManagement } from "../../hub/relationship-controller.js";
import type { SessionOutboundMessage } from "../../messages.js"; import type { SessionOutboundMessage } from "../../messages.js";
import type { DaemonConfigReloadResult } from "../../daemon-config-store.js";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@@ -42,6 +43,7 @@ function makeSubsystem(overrides: {
listProviderAvailability?: () => Promise<ProviderAvailability[]>; listProviderAvailability?: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
hubRelationships?: HubRelationshipManagement; hubRelationships?: HubRelationshipManagement;
reloadConfig?: () => DaemonConfigReloadResult;
}) { }) {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = []; const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
@@ -63,12 +65,70 @@ function makeSubsystem(overrides: {
listProviderAvailability: overrides.listProviderAvailability ?? (async () => []), listProviderAvailability: overrides.listProviderAvailability ?? (async () => []),
getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics, getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics,
hubRelationships: overrides.hubRelationships, hubRelationships: overrides.hubRelationships,
reloadConfig:
overrides.reloadConfig ??
(() => ({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
})),
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
}); });
return { subsystem, emitted, paseoHome, restartIntents }; return { subsystem, emitted, paseoHome, restartIntents };
} }
describe("DaemonSession", () => { 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 () => { test("Hub relationship command failures return correlated RPC errors", async () => {
const { subsystem, emitted } = makeSubsystem({ const { subsystem, emitted } = makeSubsystem({
hubRelationships: { hubRelationships: {
@@ -11,6 +11,7 @@ import { DaemonSelfUpdateSessionController } from "./daemon-self-update-session-
import type { ManagedAgent } from "../../agent/agent-manager.js"; import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
import type { HubRelationshipManagement } from "../../hub/relationship-controller.js"; import type { HubRelationshipManagement } from "../../hub/relationship-controller.js";
import type { DaemonConfigReloadResult } from "../../daemon-config-store.js";
export interface DaemonRuntimeConfig { export interface DaemonRuntimeConfig {
listen: string | null; listen: string | null;
@@ -50,6 +51,7 @@ export interface DaemonSessionOptions {
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
logger: pino.Logger; logger: pino.Logger;
hubRelationships?: HubRelationshipManagement; hubRelationships?: HubRelationshipManagement;
reloadConfig: () => DaemonConfigReloadResult;
} }
/** /**
@@ -74,6 +76,7 @@ export class DaemonSession {
private readonly logger: pino.Logger; private readonly logger: pino.Logger;
private readonly selfUpdate: DaemonSelfUpdateSessionController; private readonly selfUpdate: DaemonSelfUpdateSessionController;
private readonly hubRelationships: HubRelationshipManagement | null; private readonly hubRelationships: HubRelationshipManagement | null;
private readonly reloadConfig: () => DaemonConfigReloadResult;
constructor(options: DaemonSessionOptions) { constructor(options: DaemonSessionOptions) {
this.host = options.host; this.host = options.host;
@@ -89,6 +92,7 @@ export class DaemonSession {
this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null);
this.logger = options.logger; this.logger = options.logger;
this.hubRelationships = options.hubRelationships ?? null; this.hubRelationships = options.hubRelationships ?? null;
this.reloadConfig = options.reloadConfig;
this.selfUpdate = new DaemonSelfUpdateSessionController({ this.selfUpdate = new DaemonSelfUpdateSessionController({
clientId: this.clientId, clientId: this.clientId,
daemonVersion: this.daemonVersion ?? null, 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( async handleDiagnosticsRequest(
msg: Extract<SessionInboundMessage, { type: "diagnostics.request" }>, msg: Extract<SessionInboundMessage, { type: "diagnostics.request" }>,
): Promise<void> { ): Promise<void> {
@@ -282,6 +282,7 @@ function createVoiceAssistantWebSocketServer(params: {
}), }),
}; };
const daemonConfigStore = { const daemonConfigStore = {
onApply: () => () => {},
onChange: () => () => {}, onChange: () => () => {},
}; };
@@ -100,6 +100,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
...agentManagerOverrides, ...agentManagerOverrides,
}; };
const daemonConfigStore = { const daemonConfigStore = {
onApply: vi.fn(() => () => {}),
onChange: vi.fn(() => () => {}), onChange: vi.fn(() => () => {}),
}; };
@@ -223,6 +223,7 @@ function createServer(options?: {
}) { }) {
const speechReadiness = options?.speechReadiness ?? null; const speechReadiness = options?.speechReadiness ?? null;
const daemonConfigStore = { const daemonConfigStore = {
onApply: vi.fn(() => () => {}),
onChange: vi.fn(() => () => {}), onChange: vi.fn(() => () => {}),
}; };
const logger = options?.logger ?? createLogger(); const logger = options?.logger ?? createLogger();
@@ -131,6 +131,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
})), })),
}; };
const daemonConfigStore = { const daemonConfigStore = {
onApply: vi.fn(() => () => {}),
onChange: 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 { HubExecutionAgents } from "./hub/daemon-executions.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { attachMutableProviderConfigOwner } from "./agent/mutable-provider-config-owner.js";
import type { import type {
WorkspaceGitRuntimeSnapshot, WorkspaceGitRuntimeSnapshot,
WorkspaceGitService, WorkspaceGitService,
@@ -137,8 +138,10 @@ interface WebSocketConnectionIdentity {
} }
interface WebSocketServerConfig { interface WebSocketServerConfig {
allowedOrigins: Set<string>; allowedOrigins?: Set<string>;
hostnames?: HostnamesConfig; hostnames?: HostnamesConfig;
getAllowedOrigins?: () => Set<string>;
getHostnames?: () => HostnamesConfig | undefined;
daemonStatusRpc?: boolean; daemonStatusRpc?: boolean;
relayConfig?: boolean; relayConfig?: boolean;
} }
@@ -686,14 +689,18 @@ export class VoiceAssistantWebSocketServer {
this.speech?.onReadinessChange((snapshot) => { this.speech?.onReadinessChange((snapshot) => {
this.publishSpeechReadiness(snapshot); this.publishSpeechReadiness(snapshot);
}) ?? null; }) ?? null;
this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config, details) => { const unsubscribeProviderConfig = attachMutableProviderConfigOwner({
const nextAgentManagerState = this.providerSnapshotManager.applyMutableProviderConfig( store: this.daemonConfigStore,
config.providers, providerSnapshotManager: this.providerSnapshotManager,
{ removeProviders: details.removedProviders }, updateProviderRegistry: (state) => this.agentManager.updateProviderRegistry(state),
); });
this.agentManager.updateProviderRegistry(nextAgentManagerState); const unsubscribeChange = this.daemonConfigStore.onChange((config) => {
this.broadcastDaemonConfigChanged(config); this.broadcastDaemonConfigChanged(config);
}); });
this.unsubscribeDaemonConfigChange = () => {
unsubscribeProviderConfig();
unsubscribeChange();
};
const pushLogger = this.logger.child({ module: "push" }); const pushLogger = this.logger.child({ module: "push" });
this.pushNotifications = createPushNotifications({ this.pushNotifications = createPushNotifications({
@@ -776,14 +783,18 @@ export class VoiceAssistantWebSocketServer {
wsConfig: WebSocketServerConfig, wsConfig: WebSocketServerConfig,
auth: DaemonAuthConfig | undefined, auth: DaemonAuthConfig | undefined,
): WebSocketServer { ): WebSocketServer {
const { allowedOrigins, hostnames } = wsConfig;
const password = auth?.password; const password = auth?.password;
const wss = new WebSocketServer({ const wss = new WebSocketServer({
server, server,
path: "/ws", path: "/ws",
handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password), handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password),
verifyClient: ({ req }, callback) => { 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) => { wss.on("connection", (ws, request) => {
@@ -1563,6 +1574,8 @@ export class VoiceAssistantWebSocketServer {
forgeSearch: true, forgeSearch: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), ...(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. // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
...(this.advertiseRelayConfig ? { relayConfig: true } : {}), ...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
+4
View File
@@ -202,9 +202,13 @@ Detaching is an explicit lifecycle action, not a creation flag. The agent keeps
paseo daemon start # Start the daemon paseo daemon start # Start the daemon
paseo daemon start --web-ui # Start and serve the bundled web UI paseo daemon start --web-ui # Start and serve the bundled web UI
paseo daemon status # Check status paseo daemon status # Check status
paseo reload # Reload config.json (top-level alias)
paseo daemon reload # Reload config.json
paseo daemon stop # Stop the daemon paseo daemon stop # Stop the daemon
``` ```
Reload validates the whole file, applies runtime-safe changes, and reports `appliedPaths`, `restartRequiredPaths`, and `overrideControlledPaths`. Human output prints `paseo daemon restart` only when a changed setting needs it. Use `--json` or `--format yaml` for the structured result, and `--host` to reload a remote daemon's own configuration file. An older host that does not support reload returns an update-host error.
Use `PASEO_HOME` to run multiple isolated daemon instances. Use `PASEO_HOME` to run multiple isolated daemon instances.
## Hub ## Hub
+23 -1
View File
@@ -49,6 +49,28 @@ Minimal example that configures listening address, hostnames, and MCP:
`daemon.hostnames` is the primary field. The old `daemon.allowedHosts` name still works as a deprecated alias for backward compatibility. `daemon.hostnames` is the primary field. The old `daemon.allowedHosts` name still works as a deprecated alias for backward compatibility.
## Apply changes
After saving `config.json`, reload it:
```bash
paseo reload
```
The daemon validates the complete file before applying anything. It applies runtime-safe changes and lists any settings that still need a restart. If it reports restart-required paths, run:
```bash
paseo daemon restart
```
Runtime-safe settings include relay enablement, MCP settings, browser tools, hostnames, CORS origins, trusted proxies, Git process limits, agent and terminal profiles, provider definitions, metadata generation, the app base URL, and provider catalog timeout. Removing one of these settings applies its omitted-field behavior; removing a provider removes it from future launches.
New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon whose config already omitted this field when it started keeps the legacy relay-enabled behavior for compatibility. Set `daemon.relay.enabled` explicitly when editing an older config.
Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths.
Environment variables and daemon start flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Remove the override and restart the daemon if you want the file value to become authoritative.
## Agent providers ## Agent providers
Agent providers, both the first-class ones Paseo ships with and custom entries you add under `agents.providers`, are documented on their own page. Agent providers, both the first-class ones Paseo ships with and custom entries you add under `agents.providers`, are documented on their own page.
@@ -144,7 +166,7 @@ The easiest way to set a password is with the CLI:
paseo daemon set-password paseo daemon set-password
``` ```
This prompts for a password, writes the bcrypt hash to `config.json`, and tells you to restart the daemon. This prompts for a password, writes the bcrypt hash to `config.json`, and tells you to restart the daemon. Authentication is a startup setting, so `paseo reload` will also report it as restart-required.
Alternatively, set the `PASEO_PASSWORD` environment variable (plaintext, hashed automatically at startup): Alternatively, set the `PASEO_PASSWORD` environment variable (plaintext, hashed automatically at startup):
+1 -1
View File
@@ -91,4 +91,4 @@ If the host was already paired through the relay, Paseo adds the direct connecti
- **Connection timed out:** Check that Tailscale is connected on both devices and that you used the daemon machine's Tailscale IP. - **Connection timed out:** Check that Tailscale is connected on both devices and that you used the daemon machine's Tailscale IP.
- **Connection refused:** Run `paseo daemon status` and confirm the daemon is running on the configured IP and port. - **Connection refused:** Run `paseo daemon status` and confirm the daemon is running on the configured IP and port.
- **Config change has no effect:** Restart the daemon after editing `config.json`. - **Config change has no effect:** Run `paseo reload`. `daemon.listen` is a startup setting, so restart when the command reports it.
+2
View File
@@ -16,6 +16,8 @@ Everything beyond the [supported providers](/docs/supported-providers) lives und
- **Add ACP agents**, Gemini CLI, Hermes, or any agent speaking the Agent Client Protocol over stdio. - **Add ACP agents**, Gemini CLI, Hermes, or any agent speaking the Agent Client Protocol over stdio.
- **Disable** a provider you don't use. - **Disable** a provider you don't use.
Run `paseo reload` after editing the file. Provider changes apply to future launches without restarting the daemon.
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`). Every custom entry needs `extends` (a first-class provider ID or `"acp"`) and a `label`. Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`). Every custom entry needs `extends` (a first-class provider ID or `"acp"`) and a `label`.
The examples below are a quick tour. The full, up-to-date reference is on GitHub: [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md). The examples below are a quick tour. The full, up-to-date reference is on GitHub: [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
+6 -4
View File
@@ -53,7 +53,7 @@ If you'd rather pin it directly, set the binary path in `~/.paseo/config.json`:
} }
``` ```
`command` is `[binary, ...args]` and fully replaces the default launch command for that provider. Find the real path with `which -a claude`. `type -a claude` also tells you if `claude` is only a shell alias or function, those won't work, Paseo runs the binary directly, so use the path it points to. Restart the daemon after editing (see [below](#i-changed-configjson-but-nothing-happened)). `command` is `[binary, ...args]` and fully replaces the default launch command for that provider. Find the real path with `which -a claude`. `type -a claude` also tells you if `claude` is only a shell alias or function, those won't work, Paseo runs the binary directly, so use the path it points to. Reload the configuration after editing (see [below](#i-changed-configjson-but-nothing-happened)).
For alternative endpoints, multiple profiles, custom binaries, and ACP agents, see [Custom providers](/docs/custom-providers). For per-agent install links, see [Supported providers](/docs/supported-providers). For alternative endpoints, multiple profiles, custom binaries, and ACP agents, see [Custom providers](/docs/custom-providers). For per-agent install links, see [Supported providers](/docs/supported-providers).
@@ -84,13 +84,15 @@ Desktop app log location:
## I changed config.json but nothing happened ## I changed config.json but nothing happened
`config.json` is read when the daemon starts. Restart it after editing: Reload the file after editing:
```bash ```bash
paseo daemon restart paseo reload
``` ```
Or in the app, open **Settings → your host → Overview** and use **Restart daemon**. Running agents keep going, and clients reconnect automatically. Paseo applies runtime-safe settings and names any paths that require a restart. Invalid JSON or a schema error applies nothing; fix the reported error and run the command again. If a launch environment variable or flag owns a changed setting, reload reports it separately.
Run `paseo daemon restart` only when reload requests it. In the app, open **Settings → your host → Overview** and use **Restart daemon**. Running agents keep going, and clients reconnect automatically.
## Still stuck? ## Still stuck?