fix(server): keep password-protected config reload responsive

Resolve authentication only during startup. Runtime config writes and reloads share an async queue so durable state and live owners stay ordered, including overlapping plugin changes and rollback.
This commit is contained in:
Mohamed Boudra
2026-10-26 16:57:41 +01:00
parent e3bbed3c73
commit 4d1665699c
21 changed files with 704 additions and 314 deletions
@@ -99,16 +99,17 @@ afterEach(() => {
});
describe("mutable provider config owner", () => {
test("publishes provider changes after commit and emits nothing on rollback", () => {
test("publishes provider changes after commit and emits nothing on rollback", async () => {
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 } },
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
});
const cwd = path.resolve("/tmp/provider-config-owner");
manager.getSnapshot(cwd);
await manager.refreshSnapshotForCwd({ cwd });
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let agentManagerState = manager.getAgentManagerProviderState();
@@ -125,7 +126,7 @@ describe("mutable provider config owner", () => {
});
try {
store.patch({ providers: { codex: { enabled: false } } });
await store.patch({ providers: { codex: { enabled: false } } });
expect(events).toEqual([cwd]);
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
@@ -133,7 +134,7 @@ describe("mutable provider config owner", () => {
const unsubscribeFailure = store.onApply(() => {
throw new Error("later owner failed");
});
expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow(
await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow(
"later owner failed",
);
unsubscribeFailure();
@@ -150,7 +151,7 @@ describe("mutable provider config owner", () => {
}
});
test("does no provider work for unrelated CORS, Git, and app URL reloads", () => {
test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const initial: PersistedConfig = {
@@ -174,9 +175,10 @@ describe("mutable provider config owner", () => {
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
});
manager.getSnapshot("/tmp/provider-config-owner");
await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" });
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let registryUpdates = 0;
@@ -200,7 +202,7 @@ describe("mutable provider config owner", () => {
};
writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8");
expect(store.reload().appliedPaths).toEqual([
expect((await store.reload()).appliedPaths).toEqual([
"app.baseUrl",
"daemon.cors.allowedOrigins",
"daemon.git.maxProcessConcurrency",
@@ -245,7 +247,7 @@ describe("mutable provider config owner", () => {
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
expect(events).toEqual([]);
store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
expect(events).toEqual(["loading:none"]);
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2));
@@ -311,9 +313,9 @@ describe("mutable provider config owner", () => {
expect(events).toEqual([]);
const before = manager.getSnapshot(cwd);
expect(() =>
await expect(
store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }),
).toThrow("later owner failed");
).rejects.toThrow("later owner failed");
expect(events).toEqual([]);
expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]);
expect(observedDuringApply[0]!.snapshot).toBe(before);
+6 -6
View File
@@ -141,11 +141,11 @@ function liveCleanupHost() {
test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => {
const host = liveCleanupHost();
const other = liveCleanupHost();
host.store.patch({ idleRuntimeCleanup: false });
await host.store.patch({ idleRuntimeCleanup: false });
expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false);
expect((await host.sweep()).collected).toEqual([]);
expect((await other.sweep()).collected).toHaveLength(1);
host.store.patch({ idleRuntimeCleanup: true });
await host.store.patch({ idleRuntimeCleanup: true });
host.protect(true);
expect((await host.sweep()).collected).toEqual([]);
host.protect(false);
@@ -160,7 +160,7 @@ test("bootstrap's config-file reload binding disables and enables eligible colle
path.join(host.home, "config.json"),
JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }),
);
expect(host.store.reload()).toEqual({
expect(await host.store.reload()).toEqual({
appliedPaths: ["agents.idleRuntimeCleanup"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -173,12 +173,12 @@ test.each([false, true])(
"failed config apply restores prior idle cleanup behavior (previous: %s)",
async (previous) => {
const host = liveCleanupHost();
host.store.patch({ idleRuntimeCleanup: previous });
await host.store.patch({ idleRuntimeCleanup: previous });
const persistedBefore = loadPersistedConfig(host.home);
const refuse = host.store.onApply(() => {
throw new Error("Another live owner refused");
});
expect(() => host.store.patch({ idleRuntimeCleanup: !previous })).toThrow(
await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow(
"Another live owner refused",
);
expect(host.store.get().idleRuntimeCleanup).toBe(previous);
@@ -191,6 +191,6 @@ test.each([false, true])(
test("disposing bootstrap's binding stops configuration delivery", async () => {
const host = liveCleanupHost();
host.unsubscribe();
host.store.patch({ idleRuntimeCleanup: false });
await host.store.patch({ idleRuntimeCleanup: false });
expect((await host.sweep()).collected).toHaveLength(1);
});
+11 -7
View File
@@ -161,7 +161,11 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
import { createOrchestrationSkills } from "./orchestration-skills/index.js";
import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js";
import {
resolveReloadableConfig,
type ResolvedDaemonSettings,
type CliConfigOverrides,
} from "./config.js";
import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js";
import { BrowserToolsBroker } from "./browser-tools/broker.js";
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
@@ -196,7 +200,7 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
import { type PersistedConfig } from "./persisted-config.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js";
@@ -551,7 +555,7 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
return config.trustedProxies ?? ["loopback"];
}
function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] {
function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] {
return {
enabled: config.relayEnabled ?? true,
requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false,
@@ -562,7 +566,7 @@ function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv {
return config.configReload?.env ?? process.env;
}
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig {
const providers = config.providerOverrides ?? {};
const initialConfig: MutableDaemonConfig = {
@@ -638,14 +642,14 @@ export async function createPaseoDaemon(
}),
reloadSource: {
resolve: (persisted) => {
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
const reloaded = resolveReloadableConfig(persisted, {
env: resolveReloadEnv(config),
cli: config.configReload?.cli,
relayEnabledFallback: config.configReload?.relayEnabledFallback,
});
return {
mutable: createInitialMutableDaemonConfig(reloaded),
overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [],
overrideControlledPaths: reloaded.overrideControlledPaths,
};
},
},
@@ -1536,7 +1540,7 @@ export async function createPaseoDaemon(
spawnWorkspaceScript,
assertAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId),
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
getGlobalServicePorts: () => daemonConfigStore.readServicePorts(),
}),
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
+30 -1
View File
@@ -4,7 +4,12 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js";
import {
loadConfig,
resolveBundledWebUiDistDir,
resolveConfigFromPersisted,
resolveReloadableConfig,
} from "./config.js";
import { loadPersistedConfig } from "./persisted-config.js";
const roots: string[] = [];
@@ -14,6 +19,30 @@ describe("server config", () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("resolves reloadable settings without resolving the startup password", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-reload-"));
roots.push(paseoHome);
const env = { PASEO_PASSWORD: "synthetic-audit-password" };
const persisted = loadPersistedConfig(paseoHome);
const startup = resolveConfigFromPersisted(paseoHome, persisted, { env });
const started = performance.now();
const timer = new Promise<number>((resolve) =>
setTimeout(() => resolve(performance.now() - started), 0),
);
const reloaded = resolveReloadableConfig(
{
...persisted,
daemon: { ...persisted.daemon, browserTools: { enabled: true } },
},
{ env },
);
const gap = await timer;
expect(reloaded).not.toHaveProperty("auth");
expect(reloaded.browserToolsEnabled).toBe(true);
expect(startup.auth?.password).toBeTypeOf("string");
expect(gap).toBeLessThan(50);
});
test("records when the daemon is managed by Paseo Desktop", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
roots.push(paseoHome);
+71 -60
View File
@@ -568,31 +568,43 @@ interface ResolveConfigFromPersistedOptions {
relayEnabledFallback?: boolean;
}
// Startup values, including authentication, never enter runtime settings resolution.
export type ResolvedDaemonSettings = Pick<
PaseoDaemonConfig,
| "corsAllowedOrigins"
| "hostnames"
| "trustedProxies"
| "mcpEnabled"
| "mcpInjectIntoAgents"
| "browserToolsEnabled"
| "git"
| "autoArchiveAfterMerge"
| "enableTerminalAgentHooks"
| "appendSystemPrompt"
| "terminalProfiles"
| "agentProfiles"
| "skillSelection"
| "pluginsEnabled"
| "plugins"
| "relayEnabled"
| "relayRequireProtectedSecurity"
| "appBaseUrl"
| "idleRuntimeCleanup"
| "providerCatalogRefreshTimeoutMs"
| "metadataGeneration"
| "providerOverrides"
>;
export function resolveConfigFromPersisted(
paseoHome: string,
persisted: PersistedConfig,
options?: ResolveConfigFromPersistedOptions,
): PaseoDaemonConfig {
const resolvedOptions = options ?? {};
const env = configurationEnvironment(resolvedOptions.env ?? process.env);
const cli = resolvedOptions.cli;
const env = configurationEnvironment(options?.env ?? process.env);
const cli = options?.cli;
const relayEnabledFallback =
resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
const listen = resolveListenAddress(env, cli, persisted);
const {
mcpEnabled,
mcpInjectIntoAgents,
browserToolsEnabled,
autoArchiveAfterMerge,
appendSystemPrompt,
terminalProfiles,
agentProfiles,
hostnames,
trustedProxies,
appBaseUrl,
} = resolveStaticLoadConfigSettings(env, cli, persisted);
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
const { overrideControlledPaths, ...settings } = resolveReloadableConfig(persisted, options);
const relay = resolveRelayConfig({
env,
persisted,
@@ -600,42 +612,14 @@ export function resolveConfigFromPersisted(
cliRelayUseTls: cli?.relayUseTls,
enabledFallback: relayEnabledFallback,
});
const serviceProxy = resolveServiceProxyConfig(env, persisted);
const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted);
const { openai, speech } = resolveSpeechConfig({
paseoHome,
env,
persisted,
});
const { openai, speech } = resolveSpeechConfig({ paseoHome, env, persisted });
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
const providerOverrides = extractProviderOverrides(
persisted.agents?.providers as Record<string, unknown> | undefined,
);
const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, persisted);
return {
listen,
...settings,
listen: resolveListenAddress(env, cli, persisted),
paseoHome,
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
hostnames,
trustedProxies,
mcpEnabled,
mcpInjectIntoAgents,
browserToolsEnabled,
git: resolveGitProcessConfig(env, persisted),
autoArchiveAfterMerge,
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
appendSystemPrompt,
terminalProfiles,
agentProfiles,
skillSelection: persisted.agents?.skills?.selection,
pluginsEnabled: persisted.pluginsEnabled ?? false,
plugins: persisted.plugins,
pluginRegistries: persisted.pluginRegistries,
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
mcpDebug: env.MCP_DEBUG === "1",
@@ -643,27 +627,20 @@ export function resolveConfigFromPersisted(
agentStoragePath: path.join(paseoHome, "agents"),
staticDir: "public",
agentClients: {},
relayEnabled: relay.enabled,
relayEnabledMutable: relay.enabledMutable,
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
relayPublicUseTls: relay.publicUseTls,
serviceProxy,
webUi,
appBaseUrl,
serviceProxy: resolveServiceProxyConfig(env, persisted),
webUi: resolveWebUiConfig(paseoHome, env, cli, persisted),
auth: resolveAuthConfig(env, persisted),
openai,
speech,
voiceLlmProvider: voiceLlm.provider,
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides,
agentProviderSettings: extractAgentProviderSettings(settings.providerOverrides),
log: resolveLogConfigFromEnv(env, persisted),
configReload: {
env: { ...env },
@@ -675,6 +652,40 @@ export function resolveConfigFromPersisted(
};
}
export function resolveReloadableConfig(
persisted: PersistedConfig,
options?: ResolveConfigFromPersistedOptions,
): ResolvedDaemonSettings & { overrideControlledPaths: string[] } {
const env = configurationEnvironment(options?.env ?? process.env);
const cli = options?.cli;
const relay = resolveRelayConfig({
env,
persisted,
cliRelayEnabled: cli?.relayEnabled,
cliRelayUseTls: cli?.relayUseTls,
enabledFallback:
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined,
});
return {
...resolveStaticLoadConfigSettings(env, cli, persisted),
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
git: resolveGitProcessConfig(env, persisted),
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
skillSelection: persisted.agents?.skills?.selection,
pluginsEnabled: persisted.pluginsEnabled ?? false,
plugins: persisted.plugins,
relayEnabled: relay.enabled,
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides: extractProviderOverrides(
persisted.agents?.providers as Record<string, unknown> | undefined,
),
overrideControlledPaths: resolveOverrideControlledPaths(env, cli, persisted),
};
}
export function loadConfig(
paseoHome: string,
options?: Omit<ResolveConfigFromPersistedOptions, "relayEnabledFallback">,
@@ -101,7 +101,7 @@ describe("DaemonConfigStore", () => {
}
});
test("patch persists relay state and emits its field change", () => {
test("patch persists relay state and emits its field change", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -117,13 +117,13 @@ describe("DaemonConfigStore", () => {
const changes: unknown[] = [];
store.onFieldChange("relay.enabled", (value) => changes.push(value));
store.patch({ relay: { enabled: true } });
await store.patch({ relay: { enabled: true } });
expect(changes).toEqual([true]);
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("patch persists the relay protected security requirement independently of relay enablement", () => {
test("patch persists the relay protected security requirement independently of relay enablement", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
@@ -145,14 +145,14 @@ describe("DaemonConfigStore", () => {
const changes: unknown[] = [];
store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value));
store.patch({ relay: { requireProtectedSecurity: true } });
await store.patch({ relay: { requireProtectedSecurity: true } });
expect(changes).toEqual([true]);
expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true });
expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true);
});
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
test("patch round-trips agent profiles through the strictly-parsed persisted config", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -166,7 +166,7 @@ describe("DaemonConfigStore", () => {
appendSystemPrompt: "",
});
store.patch({
await store.patch({
agentProfiles: [
{
id: "profile_ui",
@@ -198,7 +198,7 @@ describe("DaemonConfigStore", () => {
expect(store.get().agentProfiles).toHaveLength(1);
});
test("patch replaces the whole agent profile list rather than merging entries", () => {
test("patch replaces the whole agent profile list rather than merging entries", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -216,13 +216,13 @@ describe("DaemonConfigStore", () => {
],
});
store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
await store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]);
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1);
});
test("rolls back config when a field transition fails", () => {
test("rolls back config when a field transition fails", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -241,14 +241,14 @@ describe("DaemonConfigStore", () => {
}
});
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
"Relay transport failed to start",
);
expect(store.get().relay?.enabled).toBe(false);
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("rolls back live owners when a later transactional owner fails", () => {
test("rolls back live owners when a later transactional owner fails", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
@@ -272,7 +272,7 @@ describe("DaemonConfigStore", () => {
throw new Error("Provider refresh failed");
});
expect(() => store.patch({ browserTools: { enabled: true } })).toThrow(
await expect(store.patch({ browserTools: { enabled: true } })).rejects.toThrow(
"Provider refresh failed",
);
expect(browserToolsEnabled).toBe(false);
@@ -280,7 +280,7 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined();
});
test("rejects relay patches when a launch override owns the setting", () => {
test("rejects relay patches when a launch override owns the setting", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
@@ -299,12 +299,12 @@ describe("DaemonConfigStore", () => {
{ relayEnabledMutable: false },
);
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
"Relay is controlled by a daemon launch override",
);
});
test("unrelated patches do not persist a one-launch relay override", () => {
test("unrelated patches do not persist a one-launch relay override", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const persisted = loadPersistedConfig(paseoHome);
@@ -331,12 +331,12 @@ describe("DaemonConfigStore", () => {
{ relayEnabledMutable: false },
);
store.patch({ browserTools: { enabled: true } });
await store.patch({ browserTools: { enabled: true } });
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
});
test("unrelated patches persist only requested file intent", () => {
test("unrelated patches persist only requested file intent", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const before = loadPersistedConfig(paseoHome);
@@ -362,7 +362,7 @@ describe("DaemonConfigStore", () => {
{ relayEnabledMutable: false },
);
store.patch({
await 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.
@@ -376,7 +376,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists provider enabled flags into config.json", () => {
test("patch persists provider enabled flags into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -417,7 +417,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
providers: {
gemini: { enabled: false },
},
@@ -432,7 +432,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists provider Paseo-tool policy without changing availability", () => {
test("patch persists provider Paseo-tool policy without changing availability", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
writeFileSync(
@@ -449,14 +449,14 @@ describe("DaemonConfigStore", () => {
appendSystemPrompt: "",
});
store.patch({
await store.patch({
providers: {
claude: {
paseoTools: { enabled: true, disabledTools: ["list_agents"] },
},
},
});
store.patch({
await store.patch({
providers: {
claude: {
paseoTools: { disabledTools: ["create_agent"] },
@@ -474,7 +474,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch removes provider entries from config.json", () => {
test("patch removes provider entries from config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -519,7 +519,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
const next = store.patch({ removeProviders: ["gemini"] });
const next = await store.patch({ removeProviders: ["gemini"] });
expect(next.providers.gemini).toBeUndefined();
expect(next.providers.claude).toEqual({ enabled: false });
@@ -528,7 +528,7 @@ describe("DaemonConfigStore", () => {
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
});
test("patch removes the providers object when the last provider is deleted", () => {
test("patch removes the providers object when the last provider is deleted", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -567,13 +567,13 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({ removeProviders: ["gemini"] });
await store.patch({ removeProviders: ["gemini"] });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.agents?.providers).toBeUndefined();
});
test("patch removes deleted providers from metadata generation", () => {
test("patch removes deleted providers from metadata generation", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -629,7 +629,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
const next = store.patch({ removeProviders: ["gemini"] });
const next = await store.patch({ removeProviders: ["gemini"] });
expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]);
const persisted = loadPersistedConfig(paseoHome);
@@ -638,7 +638,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists provider removal when in-memory config is already clean", () => {
test("patch persists provider removal when in-memory config is already clean", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -680,7 +680,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
const next = store.patch({ removeProviders: ["gemini"] });
const next = await store.patch({ removeProviders: ["gemini"] });
expect(next.providers.gemini).toBeUndefined();
const persisted = loadPersistedConfig(paseoHome);
@@ -688,7 +688,7 @@ describe("DaemonConfigStore", () => {
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
});
test("patch persists append system prompt into config.json", () => {
test("patch persists append system prompt into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -706,7 +706,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
@@ -714,7 +714,7 @@ describe("DaemonConfigStore", () => {
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists browser tools opt-in into config.json", () => {
test("patch persists browser tools opt-in into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -731,13 +731,13 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({ browserTools: { enabled: true } });
await store.patch({ browserTools: { enabled: true } });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.browserTools).toEqual({ enabled: true });
});
test("patch persists provider additional models into config.json", () => {
test("patch persists provider additional models into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -755,7 +755,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
providers: {
claude: {
additionalModels: [
@@ -779,7 +779,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists daemon append system prompt into config.json", () => {
test("patch persists daemon append system prompt into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -797,7 +797,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
@@ -805,7 +805,7 @@ describe("DaemonConfigStore", () => {
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists enable terminal agent hooks into config.json", () => {
test("patch persists enable terminal agent hooks into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -822,13 +822,13 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({ enableTerminalAgentHooks: true });
await store.patch({ enableTerminalAgentHooks: true });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true);
});
test("patch persists metadata generation providers into config.json", () => {
test("patch persists metadata generation providers into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -846,7 +846,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
metadataGeneration: {
providers: [
{ provider: "claude", model: "haiku" },
@@ -864,7 +864,7 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists clearing metadata generation providers into config.json", () => {
test("patch persists clearing metadata generation providers into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -899,13 +899,13 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({ metadataGeneration: { providers: [] } });
await store.patch({ metadataGeneration: { providers: [] } });
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
});
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", () => {
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
writeFileSync(
@@ -930,7 +930,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
expect(store.patch({ idleRuntimeCleanup: false }).idleRuntimeCleanup).toBe(false);
expect((await store.patch({ idleRuntimeCleanup: false })).idleRuntimeCleanup).toBe(false);
expect(loadPersistedConfig(paseoHome).agents).toEqual({
metadataGeneration: { providers: [{ provider: "claude" }] },
@@ -938,11 +938,11 @@ describe("DaemonConfigStore", () => {
});
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
store.patch({ idleRuntimeCleanup: true });
await store.patch({ idleRuntimeCleanup: true });
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
});
test("patch persists custom ACP provider overrides into config.json", () => {
test("patch persists custom ACP provider overrides into config.json", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -960,7 +960,7 @@ describe("DaemonConfigStore", () => {
undefined,
);
store.patch({
await store.patch({
providers: {
"paseo-e2e-acp": {
extends: "acp",
@@ -1028,7 +1028,49 @@ describe("DaemonConfigStore reload", () => {
writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
}
test("applies mutable edits and reports startup-only edits", () => {
test("applies overlapping plugin mutations to the latest committed sources", async () => {
const { store } = createReloadableStore({
initialPersisted: {
plugins: {
a: { source: "directory", path: "/a", enabled: false },
b: { source: "directory", path: "/b", enabled: true },
},
},
});
const enable = store.patch((current) => ({
plugins: {
...current.plugins,
a: { ...current.plugins.a!, enabled: true },
},
}));
const disable = store.patch((current) => ({
plugins: {
...current.plugins,
b: { ...current.plugins.b!, enabled: false },
},
}));
await Promise.all([enable, disable]);
expect(store.get().plugins).toEqual({
a: { source: "directory", path: "/a", enabled: true },
b: { source: "directory", path: "/b", enabled: false },
});
});
test("serializes overlapping patches before committing their combined state", async () => {
const { paseoHome, store } = createReloadableStore();
const first = store.patch({ appendSystemPrompt: "first" });
const second = store.patch({ browserTools: { enabled: true } });
expect(store.get().appendSystemPrompt).toBe("");
await Promise.all([first, second]);
expect(store.get().appendSystemPrompt).toBe("first");
expect(store.get().browserTools.enabled).toBe(true);
expect(loadPersistedConfig(paseoHome).daemon).toMatchObject({
appendSystemPrompt: "first",
browserTools: { enabled: true },
});
});
test("applies mutable edits and reports startup-only edits", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
@@ -1040,7 +1082,7 @@ describe("DaemonConfigStore reload", () => {
},
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [
"daemon.browserTools.enabled",
"daemon.git.maxProcessConcurrency",
@@ -1053,13 +1095,13 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 });
});
test("reloads idle runtime cleanup in both directions without requiring a restart", () => {
test("reloads idle runtime cleanup in both directions without requiring a restart", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
const changes: unknown[] = [];
store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value));
for (const enabled of [false, true]) {
writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["agents.idleRuntimeCleanup"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1067,8 +1109,8 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().idleRuntimeCleanup).toBe(enabled);
}
expect(changes).toEqual([false, true]);
store.patch({ idleRuntimeCleanup: false });
expect(store.reload()).toEqual({
await store.patch({ idleRuntimeCleanup: false });
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1076,7 +1118,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().idleRuntimeCleanup).toBe(false);
});
test("applies the global plugin switch in both directions", () => {
test("applies the global plugin switch in both directions", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
initialPersisted: { version: 1, pluginsEnabled: false },
});
@@ -1084,7 +1126,7 @@ describe("DaemonConfigStore reload", () => {
store.onFieldChange("pluginsEnabled", (value) => changes.push(value));
writeConfig(paseoHome, { ...persisted, pluginsEnabled: true });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["pluginsEnabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1092,7 +1134,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().pluginsEnabled).toBe(true);
writeConfig(paseoHome, { ...persisted, pluginsEnabled: false });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["pluginsEnabled"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1101,7 +1143,7 @@ describe("DaemonConfigStore reload", () => {
expect(changes).toEqual([true, false]);
});
test("classifies every leaf when a parent subtree is added", () => {
test("classifies every leaf when a parent subtree is added", async () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: { version: 1 },
});
@@ -1116,14 +1158,14 @@ describe("DaemonConfigStore reload", () => {
},
});
expect(store.reload()).toEqual({
expect(await 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", () => {
test("classifies every leaf when the daemon subtree is removed", async () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: {
version: 1,
@@ -1144,7 +1186,7 @@ describe("DaemonConfigStore reload", () => {
});
writeConfig(paseoHome, { version: 1 });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: [
"daemon.listen",
@@ -1158,7 +1200,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().relay?.enabled).toBe(false);
});
test("keeps overridden leaves separate from restart-required siblings", () => {
test("keeps overridden leaves separate from restart-required siblings", async () => {
const { paseoHome, store } = createReloadableStore({
initialPersisted: { version: 1 },
overrideControlledPaths: ["daemon.relay.enabled"],
@@ -1170,25 +1212,25 @@ describe("DaemonConfigStore reload", () => {
},
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: ["daemon.relay.endpoint"],
overrideControlledPaths: ["daemon.relay.enabled"],
});
});
test("invalid JSON and invalid schema apply nothing", () => {
test("invalid JSON and invalid schema apply nothing", async () => {
const { paseoHome, store } = createReloadableStore();
writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n");
expect(() => store.reload()).toThrow("Invalid JSON");
await expect(store.reload()).rejects.toThrow("Invalid JSON");
expect(store.get().browserTools.enabled).toBe(false);
writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } });
expect(() => store.reload()).toThrow("Invalid config");
await expect(store.reload()).rejects.toThrow("Invalid config");
expect(store.get().browserTools.enabled).toBe(false);
});
test("removing providers and optional profiles clears live state", () => {
test("removing providers and optional profiles clears live state", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
@@ -1203,10 +1245,10 @@ describe("DaemonConfigStore reload", () => {
},
},
});
store.reload();
await store.reload();
writeConfig(paseoHome, persisted);
const result = store.reload();
const result = await store.reload();
expect(result.appliedPaths).toEqual([
"agents.providers",
@@ -1218,7 +1260,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().agentProfiles).toBeUndefined();
});
test("reports a launch-controlled edit without changing live state", () => {
test("reports a launch-controlled edit without changing live state", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
});
@@ -1228,7 +1270,7 @@ describe("DaemonConfigStore reload", () => {
daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } },
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.relay.enabled"],
@@ -1236,7 +1278,7 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().relay?.enabled).toBe(initialRelay);
});
test("an unrelated patch does not mark a manual override-owned edit as applied", () => {
test("an unrelated patch does not mark a manual override-owned edit as applied", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
});
@@ -1244,16 +1286,16 @@ describe("DaemonConfigStore reload", () => {
...persisted,
daemon: { ...persisted.daemon, relay: { enabled: true } },
});
store.patch({ appendSystemPrompt: "patched elsewhere" });
await store.patch({ appendSystemPrompt: "patched elsewhere" });
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.relay.enabled"],
});
});
test("reports startup-only launch overrides instead of restart warnings", () => {
test("reports startup-only launch overrides instead of restart warnings", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
});
@@ -1269,16 +1311,16 @@ describe("DaemonConfigStore reload", () => {
},
});
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
});
});
test("a no-op reload returns empty path lists", () => {
test("a no-op reload returns empty path lists", async () => {
const { store } = createReloadableStore();
expect(store.reload()).toEqual({
expect(await store.reload()).toEqual({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -1317,25 +1359,25 @@ describe("DaemonConfigStore speech settings", () => {
return { paseoHome, store, applied, persisted };
}
test("reads, patches, and clears local STT language without losing it on other saves", () => {
test("reads, patches, and clears local STT language without losing it on other saves", async () => {
const { paseoHome, store } = createSpeechStore({
features: { dictation: { stt: { language: "fr" } } },
});
expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" });
store.patch({ features: { voiceMode: { enabled: true } } });
await store.patch({ features: { voiceMode: { enabled: true } } });
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
provider: "local",
language: "fr",
});
const patched = store.patch({
const patched = await store.patch({
features: { dictation: { stt: { provider: "local", language: "de" } } },
});
expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" });
store.patch({ features: { dictation: { stt: { language: null } } } });
await store.patch({ features: { dictation: { stt: { language: null } } } });
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" });
});
test("reports language env overrides for both local STT sites", () => {
test("reports language env overrides for both local STT sites", async () => {
const { store } = createSpeechStore(
{},
{ PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" },
@@ -1346,22 +1388,22 @@ describe("DaemonConfigStore speech settings", () => {
});
});
test("keeps voice mode off until it is turned on", () => {
test("keeps voice mode off until it is turned on", async () => {
const { paseoHome, store } = createSpeechStore();
expect(store.get().features?.dictation.enabled).toBe(true);
expect(store.get().features?.voiceMode.enabled).toBe(false);
const result = store.patch({ features: { voiceMode: { enabled: true } } });
const result = await store.patch({ features: { voiceMode: { enabled: true } } });
expect(result.features?.voiceMode.enabled).toBe(true);
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true);
});
test("stores the OpenAI key without ever returning it", () => {
test("stores the OpenAI key without ever returning it", async () => {
const { paseoHome, store, applied } = createSpeechStore();
const result = store.patch({
const result = await store.patch({
features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } },
});
@@ -1373,26 +1415,26 @@ describe("DaemonConfigStore speech settings", () => {
expect(applied).toHaveLength(1);
});
test("applies a replaced key even though the visible settings do not change", () => {
test("applies a replaced key even though the visible settings do not change", async () => {
const { store, applied } = createSpeechStore({
version: 1,
features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } },
});
store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
await store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([
{ provider: "openai", apiKey: "sk-new" },
]);
});
test("switching provider clears the previous provider's model", () => {
test("switching provider clears the previous provider's model", async () => {
const { paseoHome, store } = createSpeechStore({
version: 1,
features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } },
});
const result = store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
const result = await store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false });
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
@@ -1400,62 +1442,62 @@ describe("DaemonConfigStore speech settings", () => {
});
});
test("rejects a local model id outside the catalog without persisting it", () => {
test("rejects a local model id outside the catalog without persisting it", async () => {
const { paseoHome, store, applied } = createSpeechStore();
expect(() =>
await expect(
store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }),
).toThrow("Unknown local voice model: nope");
).rejects.toThrow("Unknown local voice model: nope");
expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined();
expect(applied).toEqual([]);
});
test("reload applies hand-edited speech settings without a restart", () => {
test("reload applies hand-edited speech settings without a restart", async () => {
const { paseoHome, store, applied, persisted } = createSpeechStore();
writeFileSync(
path.join(paseoHome, "config.json"),
`${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`,
);
const result = store.reload();
const result = await store.reload();
expect(result.appliedPaths).toContain("features.voiceMode");
expect(result.restartRequiredPaths).toEqual([]);
expect(store.get().features?.voiceMode.enabled).toBe(false);
expect(applied).toHaveLength(1);
});
test("merges same-provider fields and replaces the whole variant when provider changes", () => {
test("merges same-provider fields and replaces the whole variant when provider changes", async () => {
const { paseoHome, store } = createSpeechStore();
store.patch({
await store.patch({
features: {
voiceMode: {
stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" },
},
},
});
store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
await store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({
provider: "openai",
apiKey: "secret",
model: "second",
});
store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
expect(store.get().features?.voiceMode.stt).toEqual({
provider: "openai",
model: "third",
hasApiKey: true,
});
store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
await store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
await store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
await store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false });
});
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", () => {
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", async () => {
const { paseoHome, store } = createSpeechStore({
features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } },
});
const result = store.patch({
const result = await store.patch({
features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } },
});
expect(result.features?.voiceMode.stt).toEqual({
@@ -1471,7 +1513,7 @@ describe("DaemonConfigStore speech settings", () => {
expect(JSON.stringify(result)).not.toContain("saved-secret");
});
test("loads a legacy host, redacts its key and writes the normalized config after any save", () => {
test("loads a legacy host, redacts its key and writes the normalized config after any save", async () => {
const { paseoHome, store } = createSpeechStore({
providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } },
features: { voiceMode: { enabled: true, stt: { provider: "openai" } } },
@@ -1482,13 +1524,13 @@ describe("DaemonConfigStore speech settings", () => {
hasApiKey: true,
});
expect(JSON.stringify(store.get())).not.toContain("legacy-secret");
store.patch({ autoArchiveAfterMerge: true });
await store.patch({ autoArchiveAfterMerge: true });
const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8"));
expect(disk.providers).not.toHaveProperty("openai");
expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret");
});
test("reports env overrides and removes fallback provenance after saving a key", () => {
test("reports env overrides and removes fallback provenance after saving a key", async () => {
const { store } = createSpeechStore(
{ features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } },
{
@@ -1514,7 +1556,7 @@ describe("DaemonConfigStore speech settings", () => {
"features.voiceMode.stt.model": "STT_MODEL",
});
expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false);
other.patch({
await other.patch({
features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } },
});
expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" });
@@ -1,6 +1,7 @@
import {
loadPersistedConfig,
savePersistedConfig,
savePersistedConfigAsync,
loadPersistedConfigAsync,
type PersistedConfig,
} from "./persisted-config.js";
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
@@ -335,6 +336,7 @@ export function applyMutableProviderConfigToOverrides(
}
export class DaemonConfigStore {
private mutations: Promise<unknown> = Promise.resolve();
private current: MutableDaemonConfig;
private readonly paseoHome: string;
private readonly logger: LoggerLike | undefined;
@@ -376,16 +378,29 @@ export class DaemonConfigStore {
return this.current;
}
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
return this.applySupportedPatch(parsedPatch);
public patch(
partial:
| MutableDaemonConfigPatch
| ((current: MutableDaemonConfig) => MutableDaemonConfigPatch),
): Promise<MutableDaemonConfig> {
return this.enqueue(() =>
this.applySupportedPatch(
pickSupportedPatchFields(
MutableDaemonConfigPatchSchema.parse(
typeof partial === "function" ? partial(this.current) : partial,
),
),
),
);
}
public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig {
return this.applySupportedPatch({ skills: { selection } });
public setAgentSkillSelection(selection: AgentSkillSelection): Promise<MutableDaemonConfig> {
return this.enqueue(() => this.applySupportedPatch({ skills: { selection } }));
}
private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig {
private async applySupportedPatch(
parsedPatch: SupportedMutableConfigPatch,
): Promise<MutableDaemonConfig> {
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.",
@@ -414,7 +429,7 @@ export class DaemonConfigStore {
return this.current;
}
const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
const { previous: persistedBeforePatch, knownNext } = await this.persistConfig(
patch,
removedProviders,
);
@@ -423,7 +438,7 @@ export class DaemonConfigStore {
try {
this.applyReplacement(next, { removedProviders });
} catch (error) {
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
await savePersistedConfigAsync(this.paseoHome, persistedBeforePatch, this.logger);
throw error;
}
}
@@ -454,12 +469,16 @@ export class DaemonConfigStore {
);
}
public reload(): DaemonConfigReloadResult {
public reload(): Promise<DaemonConfigReloadResult> {
return this.enqueue(() => this.reloadPersisted());
}
private async reloadPersisted(): Promise<DaemonConfigReloadResult> {
if (!this.reloadSource) {
throw new Error("Daemon config reload is unavailable for this daemon instance");
}
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
const persisted = await loadPersistedConfigAsync(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.
@@ -621,11 +640,24 @@ export class DaemonConfigStore {
};
}
private persistConfig(
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.mutations.then(operation);
this.mutations = result.catch(() => {});
return result;
}
public readServicePorts(): Promise<NonNullable<PersistedConfig["worktrees"]>["servicePorts"]> {
return this.enqueue(
async () =>
(await loadPersistedConfigAsync(this.paseoHome, this.logger)).worktrees?.servicePorts,
);
}
private async persistConfig(
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
removeProviders: readonly string[],
): { previous: PersistedConfig; knownNext: PersistedConfig } {
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
): Promise<{ previous: PersistedConfig; knownNext: PersistedConfig }> {
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
const merge = (source: PersistedConfig) =>
mergeMutablePatchIntoPersistedConfig({
persisted: source,
@@ -635,7 +667,7 @@ export class DaemonConfigStore {
});
const nextPersisted = merge(persisted);
const knownNext = merge(this.lastKnownPersisted);
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
await savePersistedConfigAsync(this.paseoHome, nextPersisted, this.logger);
return { previous: persisted, knownNext };
}
}
@@ -57,7 +57,7 @@ describe("daemon agent skill selection", () => {
it("does not expose selection through the generic config patch path", async () => {
const { config, root, store } = await createStore();
config.patch({
await config.patch({
skills: { selection: { mode: "custom", skills: ["paseo"] } },
} as MutableDaemonConfigPatch);
@@ -43,7 +43,7 @@ export function createSkillSelectionStore(
},
async set(selection) {
const parsed = coerceSkillSelection(selection);
configStore.setAgentSkillSelection(parsed);
await configStore.setAgentSkillSelection(parsed);
return parsed;
},
async isSet() {
@@ -16,9 +16,11 @@ import { describe, expect, test } from "vitest";
import {
loadPersistedConfig,
loadPersistedConfigAsync,
PersistedConfigSchema,
readPersistedConfig,
savePersistedConfig,
savePersistedConfigAsync,
} from "./persisted-config.js";
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js";
@@ -827,11 +829,14 @@ describe("readPersistedConfig with an unreadable config.json", () => {
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
test("initializes config.json with private permissions", () => {
describe.skipIf(process.platform === "win32").each([
{ name: "startup", load: loadPersistedConfig, save: savePersistedConfig },
{ name: "runtime", load: loadPersistedConfigAsync, save: savePersistedConfigAsync },
])("persisted config file permissions ($name)", ({ load, save }) => {
test("initializes config.json with private permissions", async () => {
const home = createTempHome();
try {
loadPersistedConfig(home);
await load(home);
expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE);
} finally {
@@ -839,14 +844,14 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
}
});
test("repairs permissive config.json permissions when loading", () => {
test("repairs permissive config.json permissions when loading", async () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(configPath, "{}\n", { mode: PERMISSIVE_FILE_MODE });
chmodSync(configPath, PERMISSIVE_FILE_MODE);
loadPersistedConfig(home);
await load(home);
expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE);
} finally {
@@ -854,11 +859,24 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
}
});
test("saves config.json with private permissions", () => {
test("repairs unreadable owner permissions before reading config.json", async () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(configPath, "{}\n");
chmodSync(configPath, 0);
expect(await load(home)).toEqual({});
expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("saves config.json with private permissions", async () => {
const parent = createTempHome();
const home = path.join(parent, "home");
try {
savePersistedConfig(home, {
await save(home, {
features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } },
});
@@ -870,37 +888,37 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
});
});
describe.skipIf(process.platform === "win32")(
"config.json symlinked from another directory",
() => {
test("saving writes through the symlink and leaves it in place", () => {
const parent = createTempHome();
const home = path.join(parent, "home");
const dotfiles = path.join(parent, "dotfiles");
const linkedConfigPath = path.join(dotfiles, "config.json");
const configPath = path.join(home, "config.json");
try {
mkdirSync(home);
mkdirSync(dotfiles, { mode: 0o755 });
chmodSync(dotfiles, 0o755);
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
symlinkSync(linkedConfigPath, configPath);
describe.skipIf(process.platform === "win32").each([
{ name: "startup", save: savePersistedConfig },
{ name: "runtime", save: savePersistedConfigAsync },
])("config.json symlinked from another directory ($name)", ({ save }) => {
test("saving writes through the symlink and leaves it in place", async () => {
const parent = createTempHome();
const home = path.join(parent, "home");
const dotfiles = path.join(parent, "dotfiles");
const linkedConfigPath = path.join(dotfiles, "config.json");
const configPath = path.join(home, "config.json");
try {
mkdirSync(home);
mkdirSync(dotfiles, { mode: 0o755 });
chmodSync(dotfiles, 0o755);
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
symlinkSync(linkedConfigPath, configPath);
savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } });
await save(home, { version: 1, daemon: { relay: { enabled: false } } });
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
version: 1,
daemon: { relay: { enabled: false } },
});
expect(modeOf(dotfiles)).toBe(0o755);
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
} finally {
rmSync(parent, { recursive: true, force: true });
}
});
},
);
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
version: 1,
daemon: { relay: { enabled: false } },
});
expect(modeOf(dotfiles)).toBe(0o755);
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
} finally {
rmSync(parent, { recursive: true, force: true });
}
});
});
describe("speech provider config normalization", () => {
test.each([undefined, "local"])(
+73 -9
View File
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
@@ -9,7 +10,12 @@ import {
ProviderOverridesSchema,
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
import {
ensurePrivateFile,
ensurePrivateFileAsync,
writePrivateFileAtomic,
writePrivateFileAtomicSync,
} from "./private-files.js";
import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile";
import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config";
import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile";
@@ -523,6 +529,40 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
return config;
}
/** Runtime config IO. Startup and offline CLI consumers retain their synchronous entry points. */
export async function loadPersistedConfigAsync(
paseoHome: string,
logger?: LoggerLike,
): Promise<PersistedConfig> {
const log = getLogger(logger);
const configPath = getConfigPath(paseoHome);
let raw: string;
await ensurePrivateFileAsync(configPath);
try {
raw = await readFile(configPath, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw new Error(
`[Config] Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
try {
raw = JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n";
await writePrivateFileAtomic(configPath, raw);
log?.info(`Initialized config file at ${configPath}`);
} catch (initializationError) {
throw new Error(
`[Config] Failed to initialize ${configPath}: ${initializationError instanceof Error ? initializationError.message : String(initializationError)}`,
{ cause: initializationError },
);
}
}
const config = parseConfigFile(configPath, raw, log);
log?.info(`Loaded from ${configPath}`);
return config;
}
/** Observe the file without initializing a home, identity, or default configuration. */
export function readPersistedConfig(
paseoHome: string,
@@ -642,16 +682,10 @@ export function savePersistedConfig(
const log = getLogger(logger);
const configPath = getConfigPath(paseoHome);
const result = PersistedConfigSchema.safeParse(config);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`[Config] Invalid config to save:\n${issues}`);
}
const data = serializePersistedConfig(config);
try {
writePrivateFileAtomicSync(configPath, JSON.stringify(result.data, null, 2) + "\n");
writePrivateFileAtomicSync(configPath, data);
log?.info(`Saved to ${configPath}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -660,3 +694,33 @@ export function savePersistedConfig(
});
}
}
function serializePersistedConfig(config: PersistedConfig): string {
const result = PersistedConfigSchema.safeParse(config);
if (!result.success) {
const issues = result.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`[Config] Invalid config to save:\n${issues}`);
}
return JSON.stringify(result.data, null, 2) + "\n";
}
export async function savePersistedConfigAsync(
paseoHome: string,
config: PersistedConfig,
logger?: LoggerLike,
): Promise<void> {
const configPath = getConfigPath(paseoHome);
const data = serializePersistedConfig(config);
try {
await writePrivateFileAtomic(configPath, data);
getLogger(logger)?.info(`Saved to ${configPath}`);
} catch (error) {
throw new Error(
`[Config] Failed to write ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
@@ -526,7 +526,7 @@ describe("PluginService", () => {
if (!address || typeof address === "string") throw new Error("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
const store = createStore(home);
store.patch({ pluginsEnabled: false });
await store.patch({ pluginsEnabled: false });
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
@@ -1079,13 +1079,13 @@ export default function contribute(plugin: unknown) {
await service.installDirectory({ path: first });
await service.installDirectory({ path: second });
store.patch({ pluginsEnabled: false });
await store.patch({ pluginsEnabled: false });
expect(service.catalog()).toEqual([]);
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
"Plugin is not available",
);
store.patch({ pluginsEnabled: true });
await store.patch({ pluginsEnabled: true });
await service.reloadPlugin("first");
expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([
{ id: "first", status: "running" },
@@ -1100,16 +1100,16 @@ export default function contribute(plugin: unknown) {
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: true },
});
store.patch({ pluginsEnabled: false });
await store.patch({ pluginsEnabled: false });
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
store.patch({ pluginsEnabled: true });
await store.patch({ pluginsEnabled: true });
await paused.started;
store.patch({ pluginsEnabled: false });
await store.patch({ pluginsEnabled: false });
paused.releaseStart();
await service.stopAllPlugins();
@@ -1119,6 +1119,47 @@ export default function contribute(plugin: unknown) {
]);
});
it("persists overlapping enable and disable without losing either source mutation", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
a: { source: "directory", path: "/plugins/a", enabled: false },
b: { source: "directory", path: "/plugins/b", enabled: true },
});
const paused = createPluginSelectivePausedRuntime("unused");
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
await Promise.all([service.enablePlugin("a"), service.disablePlugin("b")]);
expect(store.get().plugins).toEqual({
a: { source: "directory", path: "/plugins/a", enabled: true },
b: { source: "directory", path: "/plugins/b", enabled: false },
});
await service.stopAllPlugins();
});
it("clears a failed disable intent so the previous enabled state can publish", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
const enabling = service.enablePlugin("slow");
await paused.started;
await writeFile(path.join(home, "config.json"), "invalid JSON");
await expect(service.disablePlugin("slow")).rejects.toThrow("Invalid JSON");
paused.releaseStart();
await expect(enabling).resolves.toMatchObject({ enabled: true, status: "running" });
expect(service.catalog()).toEqual([{ id: "slow", clientBundle: "bundle" }]);
await service.stopAllPlugins();
});
it("does not publish an in-flight enable after a later plugin disable", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
+60 -20
View File
@@ -92,6 +92,10 @@ export class PluginService {
private readonly providerListeners = new Set<() => void>();
private lifecycle = Promise.resolve();
private globalStartsBlocked = true;
private readonly pendingOperations = new Map<
string,
{ kind: "disable" | "remove"; completion: Promise<void> }
>();
private started = false;
private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>();
@@ -265,11 +269,7 @@ export class PluginService {
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
);
}
const sources = {
...this.configStore.get().plugins,
[pluginId]: { source: "directory" as const, path: directory, enabled: true },
};
this.configStore.patch({ plugins: sources });
await this.patchSource(pluginId, { source: "directory", path: directory, enabled: true });
if (this.canPublish(pluginId)) await this.startConfigured(pluginId);
this.notify(pluginId);
const installed = await this.requireItem(pluginId);
@@ -328,15 +328,17 @@ export class PluginService {
await managedSources.discard(candidate);
throw error;
}
this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true });
await this.patchSource(pluginId, {
source: "directory",
path: candidate.directory,
enabled: true,
});
try {
if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory);
managedSources.commit(pluginId, candidate.record);
} catch (error) {
await this.stopPlugin(pluginId);
const sources = { ...this.configStore.get().plugins };
delete sources[pluginId];
this.configStore.patch({ plugins: sources });
await this.removeSource(pluginId);
await managedSources.discard(candidate);
this.errors.delete(pluginId);
this.notify(pluginId);
@@ -428,10 +430,11 @@ export class PluginService {
}
async enablePlugin(pluginId: string): Promise<PluginListItem> {
const source = this.requireSource(pluginId);
this.patchSource(pluginId, { ...source, enabled: true });
await this.patchSource(pluginId, (source) => ({ ...source, enabled: true }));
this.errors.delete(pluginId);
return this.enqueue(async () => {
// Cancellation persistence uses the store queue, so it never waits behind a blocked start.
await this.pendingOperations.get(pluginId)?.completion.catch(() => {});
const current = this.requireSource(pluginId);
if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) {
await this.startExplicit(pluginId, current.path);
@@ -442,8 +445,17 @@ export class PluginService {
}
async disablePlugin(pluginId: string): Promise<PluginListItem> {
const source = this.requireSource(pluginId);
this.patchSource(pluginId, { ...source, enabled: false });
const operation = {
kind: "disable",
completion: this.patchSource(pluginId, (source) => ({ ...source, enabled: false })),
} as const;
this.pendingOperations.set(pluginId, operation);
try {
await operation.completion;
} finally {
if (this.pendingOperations.get(pluginId) === operation)
this.pendingOperations.delete(pluginId);
}
const stopping = this.stopPlugin(pluginId);
return this.enqueue(async () => {
await stopping;
@@ -455,10 +467,15 @@ export class PluginService {
async removePlugin(pluginId: string): Promise<void> {
this.requireSource(pluginId);
const operation = { kind: "remove", completion: this.removeSource(pluginId) } as const;
this.pendingOperations.set(pluginId, operation);
try {
await operation.completion;
} finally {
if (this.pendingOperations.get(pluginId) === operation)
this.pendingOperations.delete(pluginId);
}
const stopping = this.stopPlugin(pluginId);
const sources = { ...this.configStore.get().plugins };
delete sources[pluginId];
this.configStore.patch({ plugins: sources });
await this.enqueue(async () => {
await stopping;
this.runtime.clearLogs(pluginId);
@@ -531,6 +548,7 @@ export class PluginService {
private canPublish(pluginId: string): boolean {
const config = this.configStore.get();
return (
!this.pendingOperations.has(pluginId) &&
!this.globalStartsBlocked &&
config.pluginsEnabled === true &&
config.plugins?.[pluginId]?.enabled !== false &&
@@ -744,7 +762,10 @@ export class PluginService {
await managedSources.discard(candidate);
throw new Error(`Plugin is no longer configured: ${pluginId}`);
}
this.patchSource(pluginId, { ...activatedSource, path: candidate.directory });
await this.patchSource(pluginId, (currentSource) => ({
...currentSource,
path: candidate.directory,
}));
this.errors.delete(pluginId);
this.notify(pluginId);
let warning: string | undefined;
@@ -786,9 +807,28 @@ export class PluginService {
return source;
}
private patchSource(pluginId: string, source: PluginSource): void {
this.configStore.patch({
plugins: { ...this.configStore.get().plugins, [pluginId]: source },
private async patchSource(
pluginId: string,
update: PluginSource | ((source: PluginSource) => PluginSource),
): Promise<void> {
await this.configStore.patch((current) => {
const source = current.plugins?.[pluginId];
if (typeof update === "function" && !source)
throw new Error(`Plugin is not configured: ${pluginId}`);
return {
plugins: {
...current.plugins,
[pluginId]: typeof update === "function" ? update(source!) : update,
},
};
});
}
private async removeSource(pluginId: string): Promise<void> {
await this.configStore.patch((current) => {
const sources = { ...current.plugins };
delete sources[pluginId];
return { plugins: sources };
});
}
@@ -1,3 +1,6 @@
import pino from "pino";
import { createPaseoDaemon } from "../bootstrap.js";
import { loadConfig } from "../config.js";
import { createRequire } from "node:module";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
@@ -198,6 +201,60 @@ export default function contribute(server: PluginServerContext) {
}
}, 60_000);
test("password-protected config reload preserves startup auth and session construction does not read config", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-protected-config-reload-"));
roots.push(home);
const password = "synthetic-audit-password";
const config = loadConfig(home, { env: { PASEO_PASSWORD: password, PASEO_RELAY_ENABLED: "0" } });
const daemon = await createPaseoDaemon(
{
...config,
listen: "127.0.0.1:0",
mcpEnabled: false,
agentClients: createTestAgentClients(),
staticDir: home,
},
pino({ level: "silent" }),
);
await daemon.start();
const target = daemon.getListenTarget();
if (target?.type !== "tcp") throw new Error("Expected TCP daemon");
const connect = () =>
new DaemonClient({ url: `ws://127.0.0.1:${target.port}/ws`, password, appVersion: "0.4.0" });
const client = connect();
const replacement = connect();
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const configPath = path.join(home, "config.json");
await writeFile(configPath, JSON.stringify({ daemon: { browserTools: { enabled: true } } }));
let longestGap = 0;
let previous = performance.now();
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - previous);
previous = now;
}, 1);
try {
const reloaded = await client.reloadDaemonConfig();
longestGap = Math.max(longestGap, performance.now() - previous);
expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled");
expect(longestGap).toBeLessThan(50);
} finally {
clearInterval(probe);
}
expect((await client.getDaemonConfig()).config.browserTools.enabled).toBe(true);
// A new physical session uses ready daemon state; corrupt config is reported only by an explicit read.
await writeFile(configPath, "invalid JSON");
await replacement.connect();
await replacement.fetchAgents({ subscribe: {} });
await expect(replacement.reloadDaemonConfig()).rejects.toThrow("Invalid JSON");
} finally {
await Promise.all([client.close(), replacement.close()]);
await daemon.stop();
}
}, 60_000);
test("daemon config reload enables and disables configured plugins without restarting", async () => {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reload-plugin-"));
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-reload-home-"));
@@ -1,3 +1,4 @@
import { chmod, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import path from "node:path";
@@ -52,3 +53,30 @@ function resolveSymlinkedFile(filePath: string): string {
throw error;
}
}
export async function ensurePrivateFileAsync(filePath: string): Promise<void> {
if (process.platform === "win32") return;
await chmod(filePath, PRIVATE_FILE_MODE).catch(() => {});
}
export async function writePrivateFileAtomic(filePath: string, data: string): Promise<void> {
const directory = path.dirname(filePath);
await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
if (process.platform !== "win32") await chmod(directory, PRIVATE_DIRECTORY_MODE).catch(() => {});
const destination = await realpath(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return filePath;
throw error;
});
const temporary = path.join(
path.dirname(destination),
`.${path.basename(destination)}.${process.pid}.${randomUUID()}`,
);
try {
await writeFile(temporary, data, { mode: PRIVATE_FILE_MODE });
await rename(temporary, destination);
await ensurePrivateFileAsync(destination);
} catch (error) {
await rm(temporary, { force: true });
throw error;
}
}
+10 -11
View File
@@ -81,7 +81,6 @@ import {
type WorkspaceScriptsService,
} from "./session/workspace-scripts/workspace-scripts-service.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
@@ -1232,7 +1231,7 @@ export class Session {
spawnWorkspaceScript,
assertAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts,
getGlobalServicePorts: () => this.daemonConfigStore.readServicePorts(),
});
this.workspaceDirectory = new WorkspaceDirectory({
logger: this.sessionLogger,
@@ -2927,8 +2926,7 @@ export class Session {
case "daemon.get_pairing_offer.request":
return this.daemonSession.handleGetPairingOfferRequest(msg);
case "daemon.config.reload.request":
this.daemonSession.handleConfigReloadRequest(msg);
return undefined;
return this.daemonSession.handleConfigReloadRequest(msg);
case "hub.management.daemon.connect.request":
case "hub.management.daemon.get_status.request":
case "hub.management.daemon.disconnect.request":
@@ -2939,14 +2937,15 @@ export class Session {
case "daemon.update.request":
return this.daemonSession.handleUpdateRequest(msg);
case "set_daemon_config_request":
this.emit({
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config: this.daemonConfigStore.patch(msg.config),
},
return this.daemonConfigStore.patch(msg.config).then((config) => {
return this.emit({
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config,
},
});
});
return undefined;
case "read_project_config_request":
return this.projectConfigSession.handleReadProjectConfigRequest(msg);
case "write_project_config_request":
@@ -43,7 +43,7 @@ function makeSubsystem(overrides: {
listProviderAvailability?: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
hubRelationships?: HubRelationshipManagement;
reloadConfig?: () => DaemonConfigReloadResult;
reloadConfig?: () => Promise<DaemonConfigReloadResult>;
}) {
const emitted: SessionOutboundMessage[] = [];
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
@@ -67,7 +67,7 @@ function makeSubsystem(overrides: {
hubRelationships: overrides.hubRelationships,
reloadConfig:
overrides.reloadConfig ??
(() => ({
(async () => ({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -78,16 +78,16 @@ function makeSubsystem(overrides: {
}
describe("DaemonSession", () => {
test("config reload returns the daemon-owned classification", () => {
test("config reload returns the daemon-owned classification", async () => {
const { subsystem, emitted } = makeSubsystem({
reloadConfig: () => ({
reloadConfig: async () => ({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: ["app.baseUrl"],
}),
});
subsystem.handleConfigReloadRequest({
await subsystem.handleConfigReloadRequest({
type: "daemon.config.reload.request",
requestId: "reload-1",
});
@@ -105,14 +105,14 @@ describe("DaemonSession", () => {
]);
});
test("config reload failures return a correlated RPC error", () => {
test("config reload failures return a correlated RPC error", async () => {
const { subsystem, emitted } = makeSubsystem({
reloadConfig: () => {
reloadConfig: async () => {
throw new Error("Invalid config");
},
});
subsystem.handleConfigReloadRequest({
await subsystem.handleConfigReloadRequest({
type: "daemon.config.reload.request",
requestId: "reload-2",
});
@@ -53,7 +53,7 @@ export interface DaemonSessionOptions {
getObservationMetrics?: () => Record<string, number>;
logger: pino.Logger;
hubRelationships?: HubRelationshipManagement;
reloadConfig: () => DaemonConfigReloadResult;
reloadConfig: () => Promise<DaemonConfigReloadResult>;
}
/**
@@ -79,7 +79,7 @@ export class DaemonSession {
private readonly logger: pino.Logger;
private readonly selfUpdate: DaemonSelfUpdateSessionController;
private readonly hubRelationships: HubRelationshipManagement | null;
private readonly reloadConfig: () => DaemonConfigReloadResult;
private readonly reloadConfig: () => Promise<DaemonConfigReloadResult>;
constructor(options: DaemonSessionOptions) {
this.host = options.host;
@@ -252,13 +252,13 @@ export class DaemonSession {
}
}
handleConfigReloadRequest(
async handleConfigReloadRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.config.reload.request" }>,
): void {
): Promise<void> {
try {
this.host.emit({
type: "daemon.config.reload.response",
payload: { requestId: msg.requestId, ...this.reloadConfig() },
payload: { requestId: msg.requestId, ...(await this.reloadConfig()) },
});
} catch (error) {
this.logger.error({ err: error }, "Failed to reload daemon config");
@@ -1,3 +1,5 @@
import { DaemonConfigStore } from "../../daemon-config-store.js";
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -82,6 +84,7 @@ interface BuildOptions {
spawnThrows?: string;
runtimeStore?: Pick<WorkspaceRuntimeStore, "get">;
automationError?: Error;
getGlobalServicePorts?: () => Promise<PaseoServicePortAllocation | undefined>;
}
function buildService(options: BuildOptions = {}) {
@@ -111,6 +114,7 @@ function buildService(options: BuildOptions = {}) {
getDaemonTcpHost: () => "127.0.0.1",
serviceProxyPublicBaseUrl: null,
resolveScriptHealth: null,
getGlobalServicePorts: options.getGlobalServicePorts ?? (async () => undefined),
logger,
emit: (message) => emitted.push(message),
publishStatusUpdate: (message) => published.push(message),
@@ -152,6 +156,24 @@ afterEach(() => {
}
});
test("reads external service-port edits at the next launch without construction IO", async () => {
const home = mkdtempSync(join(tmpdir(), "workspace-service-ports-"));
tempDirs.push(home);
const store = new DaemonConfigStore(home, {
providers: {},
mcp: { enabled: true, injectIntoAgents: false },
});
const { service, spawnCalls } = buildService({
getGlobalServicePorts: () => store.readServicePorts(),
});
writeFileSync(
join(home, "config.json"),
JSON.stringify({ worktrees: { servicePorts: { range: "9000-9999" } } }),
);
await service.start(request);
expect(spawnCalls.map((call) => call.globalServicePorts)).toEqual([{ range: "9000-9999" }]);
});
describe("buildSnapshot", () => {
test("returns no scripts when the service proxy is unavailable", async () => {
const { service } = buildService({ serviceProxy: null });
@@ -62,7 +62,7 @@ export function createWorkspaceScriptsService(deps: {
getDaemonTcpHost: (() => string | null) | null;
serviceProxyPublicBaseUrl: string | null;
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
globalServicePorts?: PaseoServicePortAllocation;
getGlobalServicePorts: () => Promise<PaseoServicePortAllocation | undefined>;
logger: pino.Logger;
emit: (message: SessionOutboundMessage) => void;
// Script status is daemon state, so it reaches every client, not only the requester.
@@ -81,7 +81,7 @@ export function createWorkspaceScriptsService(deps: {
getDaemonTcpHost,
serviceProxyPublicBaseUrl,
resolveScriptHealth,
globalServicePorts,
getGlobalServicePorts,
logger,
emit,
publishStatusUpdate,
@@ -172,6 +172,7 @@ export function createWorkspaceScriptsService(deps: {
async function launchProcess(input: { workspaceId: string; scriptName: string }) {
const available = requireAvailable();
const globalServicePorts = await getGlobalServicePorts();
const workspace = await getWorkspace(input.workspaceId);
await assertAutomationAllowed(workspace.workspaceId);
const project = await projectRegistry.get(workspace.projectId);
@@ -80,7 +80,7 @@ describe("applyTerminalAgentHookSetting", () => {
expect(existsSync(paths.opencode)).toBe(true);
});
it("installs on enable and removes hooks on disable when toggled live", () => {
it("installs on enable and removes hooks on disable when toggled live", async () => {
const root = createTempDir("paseo-hook-setting-");
const store = createStore(createTempDir("paseo-hook-setting-home-"), false);
const paths = hookPaths(root);
@@ -88,11 +88,11 @@ describe("applyTerminalAgentHookSetting", () => {
applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) });
expect(existsSync(paths.opencode)).toBe(false);
store.patch({ enableTerminalAgentHooks: true });
await store.patch({ enableTerminalAgentHooks: true });
expect(existsSync(paths.codex)).toBe(true);
expect(existsSync(paths.opencode)).toBe(true);
store.patch({ enableTerminalAgentHooks: false });
await store.patch({ enableTerminalAgentHooks: false });
expect(existsSync(paths.opencode)).toBe(false);
});
});