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