Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 41099f0101 fix(server): preserve compiler error metadata across workers 2026-10-26 19:49:02 +01:00
Mohamed Boudra d8675174eb fix(server): retire the compiler worker after build errors
A failed parallel target can leave the other target compiling. Await worker termination before the next queued request instead of reusing an isolate with unfinished build work.
2026-10-26 19:41:33 +01:00
Mohamed Boudra 4b5c52ebe6 fix(server): reuse the compiler worker during plugin build batches
Amortize TypeScript startup across built-ins and queued builds, then retire the worker after one idle second. Carry preparation environments explicitly across worker reuse. Keep dependency-backed example coverage with its existing app fixture.
2026-10-26 19:33:32 +01:00
Mohamed Boudra 7c9a7c191e fix(server): keep plugin compilation off the daemon event loop
TypeScript import validation runs in esbuild callbacks on the daemon thread. Isolate preparation and compilation behind one bounded compiler queue, returning bundles and metadata only after runtime-boundary validation. Recheck lifecycle intent before executing a compiled plugin.
2026-10-26 18:50:46 +01:00
Mohamed Boudra 2d4c1aba0f test(server): adapt config consumers to async service-port reads
Provide the service-port capability in the existing Session and tool-bridge fixtures. Use the platform temporary directory for the plugin agent fixture so its working directory exists on Windows.
2026-10-26 18:07:29 +01:00
Mohamed Boudra 4d1665699c 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.
2026-10-26 16:57:41 +01:00
41 changed files with 1764 additions and 1125 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);
@@ -310,7 +310,7 @@ describe("PluginAgentClientRegistry", () => {
});
const own = { nested: { replace: "agent" } };
const agent = await manager.createAgent(
{ provider: "plugin-direct", cwd: "/tmp", providerOptions: own },
{ provider: "plugin-direct", cwd: tmpdir(), providerOptions: own },
undefined,
{ workspaceId: undefined },
);
@@ -183,29 +183,3 @@ describe("Codex app-server transport", () => {
codex.child.stdin.end();
});
});
describe("Codex stdout byte admission", () => {
test("fails pending requests and the session before joining an oversized line", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"id":1,"result":"');
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 128 * 1024 * 1024,
});
expect(terminations).toEqual([error]);
expect(client.hasPendingRequests()).toBe(false);
child.stdout.write('"}\n{"id":1,"result":"late"}\n');
await expect(client.request("model/list")).rejects.toThrow("closed");
await client.dispose();
child.stdout.end();
child.stderr.end();
});
});
@@ -1,15 +1,10 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import type { Logger } from "pino";
import { z } from "zod";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { ProviderLineReader, ProviderStreamLimitError } from "../jsonl-frame-decoder.js";
// A measured long-thread resume was 19.75 MB; preserve the existing 32 MiB fixture
// with 4x margin for longer threads. This is provider stdout, not the client protocol.
export const CODEX_PROVIDER_LINE_BYTES = 128 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -172,6 +167,39 @@ function readProviderTurnId(params: unknown): string | undefined {
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
}
/**
* Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029
* as line terminators, but Codex emits those characters raw inside JSON strings, so readline
* would cut a single JSON-RPC message into pieces that fail to parse.
*/
function createNewlineLineReader(
input: NodeJS.ReadableStream,
onLine: (line: string) => void,
): { close(): void } {
const decoder = new StringDecoder("utf8");
// Text after the last "\n", kept as parts and joined once its line ends, so a large line
// arriving in many chunks is scanned and copied once instead of once per chunk.
let unterminated: string[] = [];
const onData = (chunk: Buffer | string): void => {
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
let lineStart = 0;
let index = text.indexOf("\n");
while (index !== -1) {
unterminated.push(text.slice(lineStart, index));
const line = unterminated.join("");
unterminated = [];
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
lineStart = index + 1;
index = text.indexOf("\n", lineStart);
}
if (lineStart < text.length) {
unterminated.push(text.slice(lineStart));
}
};
input.on("data", onData);
return { close: () => void input.off("data", onData) };
}
export class CodexAppServerClient {
private readonly stdoutLineReader: { close(): void };
private activeProviderRequests = 0;
@@ -188,24 +216,11 @@ export class CodexAppServerClient {
private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) {
// Split only on LF: U+2028/U+2029 are valid raw characters inside Codex JSON.
const reader = new ProviderLineReader(
CODEX_PROVIDER_LINE_BYTES,
(line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
},
(error) => this.handleStreamLimit(error),
);
const onData = (chunk: Buffer | string) => reader.write(chunk);
child.stdout.on("data", onData);
this.stdoutLineReader = {
close: () => {
reader.close();
child.stdout.off("data", onData);
},
};
this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
});
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -303,15 +318,6 @@ export class CodexAppServerClient {
}
}
private handleStreamLimit(failure: ProviderStreamLimitError): void {
// A rejected response/notification cannot be replayed safely. Fail the session,
// including live turns with no pending RPC, rather than dropping a stream unit.
this.handleUnexpectedTermination(failure);
void this.dispose().catch((err) => {
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
});
}
private handleUnexpectedTermination(error: Error): void {
if (this.disposed) {
return;
@@ -1,5 +1,3 @@
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js";
@@ -38,16 +36,6 @@ describe("JsonlFrameDecoder", () => {
expect(problems).toEqual([]);
});
it("preserves UTF-8 characters split across raw stdout buffers", () => {
const { decoder, frames, problems } = decoderHarness();
const bytes = Buffer.from('{"type":"notice","text":"é🙂"}\n');
const split = bytes.indexOf(Buffer.from("🙂")) + 1;
decoder.write(bytes.subarray(0, split));
decoder.write(bytes.subarray(split));
expect(frames).toEqual([{ type: "notice", text: "é🙂" }]);
expect(problems).toEqual([]);
});
it("reassembles a large ordered v2 frame", () => {
const { decoder, frames, problems } = decoderHarness();
const frame = { type: "models", value: "x".repeat(1024 * 1024) };
@@ -67,57 +55,3 @@ describe("JsonlFrameDecoder", () => {
expect(problems).toEqual(["out-of-order-chunk"]);
});
});
describe("provider line admission", () => {
it("keeps the loop responsive while accumulating a fragmented provider response", async () => {
const { decoder, frames, problems } = decoderHarness();
const part = "x".repeat(512);
let last = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - last);
last = now;
}, 1);
try {
await delay(5);
decoder.write('{"type":"notice","text":"');
// 1 MiB is enough to expose the former rescan of every accumulated prefix.
for (let index = 0; index < 2048; index++) decoder.write(part);
decoder.write('"}\n');
await delay(5);
expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]);
expect(problems).toEqual([]);
expect(longestGap).toBeLessThan(50);
} finally {
clearInterval(probe);
}
});
it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => {
const frames: Record<string, unknown>[] = [];
const failures: Array<{ problem: string; detail: unknown }> = [];
const decoder = new JsonlFrameDecoder({
frame: (frame) => frames.push(frame),
problem: (problem, detail) => failures.push({ problem, detail }),
});
// 64 MiB of UTF-8, only 32 Mi UTF-16 code units. No newline or JSON parse needed.
const chunk = "é".repeat(32 * 1024);
for (let index = 0; index < 1024; index++) decoder.write(chunk);
expect(failures).toEqual([]);
decoder.write("é");
expect(failures).toEqual([
{
problem: "line-too-large",
detail: expect.objectContaining({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 64 * 1024 * 1024,
}),
},
]);
decoder.write('\n{"type":"ready"}\n');
expect(frames).toEqual([]);
expect(failures).toHaveLength(1);
});
});
@@ -1,60 +1,3 @@
// Provider-process stdout only; these are not app/daemon wire limits.
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
export class ProviderStreamLimitError extends Error {
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
constructor(readonly limitBytes: number) {
super(
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
);
this.name = "ProviderStreamLimitError";
}
}
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
export class ProviderLineReader {
private parts: Buffer[] = [];
private bytes = 0;
private stopped = false;
constructor(
private readonly limitBytes: number,
private readonly onLine: (line: string) => void,
private readonly onLimit: (error: ProviderStreamLimitError) => void,
) {}
write(chunk: Buffer | string): void {
if (this.stopped) return;
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
let start = 0;
while (start < buffer.byteLength && !this.stopped) {
const newline = buffer.indexOf(10, start);
const end = newline === -1 ? buffer.byteLength : newline;
const length = end - start;
if (length > this.limitBytes - this.bytes) {
this.close();
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
return;
}
if (length) this.parts.push(buffer.subarray(start, end));
this.bytes += length;
if (newline === -1) return;
const line = Buffer.concat(this.parts, this.bytes).toString("utf8");
this.parts = [];
this.bytes = 0;
this.onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
start = newline + 1;
}
}
close(): void {
this.stopped = true;
this.parts = [];
this.bytes = 0;
}
}
const V2_FRAME_BYTES = 1024 * 1024;
const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024;
const V2_CHUNK_BYTES = 256 * 1024;
@@ -62,7 +5,6 @@ const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4;
const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES);
export type JsonlFrameProblem =
| "line-too-large"
| "invalid-json"
| "invalid-chunk"
| "invalid-base64"
@@ -136,7 +78,7 @@ function chunkBytes(data: string): Buffer | null {
}
export class JsonlFrameDecoder {
private readonly lines: ProviderLineReader;
private lineBuffer = "";
private assembly: ChunkAssembly | null = null;
constructor(
@@ -144,18 +86,17 @@ export class JsonlFrameDecoder {
frame(message: Record<string, unknown>): void;
problem(problem: JsonlFrameProblem, detail?: unknown): void;
},
) {
this.lines = new ProviderLineReader(
JSONL_PROVIDER_LINE_BYTES,
(line) => {
if (line.trim()) this.consumeLine(line);
},
(error) => this.drop("line-too-large", error),
);
}
) {}
write(chunk: Buffer | string): void {
this.lines.write(chunk);
write(text: string): void {
this.lineBuffer += text;
let newline = this.lineBuffer.indexOf("\n");
while (newline !== -1) {
const line = this.lineBuffer.slice(0, newline).replace(/\r$/, "");
this.lineBuffer = this.lineBuffer.slice(newline + 1);
if (line.trim()) this.consumeLine(line);
newline = this.lineBuffer.indexOf("\n");
}
}
private consumeLine(line: string): void {
@@ -471,25 +471,3 @@ describe("JsonlRpcProcess", () => {
}
});
});
describe("JSONL stdout byte admission", () => {
test("fails pending requests and publishes the typed failure to live-turn consumers", async () => {
const child = createInMemoryChildProcess();
const process = startProcess({ child });
const exit = new Promise<JsonlRpcExit>((resolve) => process.onExit(resolve));
const request = process.request({ type: "hang" }, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"type":"notice","text":"');
for (let index = 0; index < 1024; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 64 * 1024 * 1024,
});
expect((await exit).error).toBe(error);
await process.close();
child.stdout.end();
child.stderr.end();
});
});
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
import { spawnProcess } from "../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js";
import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js";
export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js";
/** Default wall-clock timeout for control-plane / short RPC calls. */
@@ -81,7 +81,6 @@ export class JsonlRpcProcess {
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private streamFailure: ProviderStreamLimitError | null = null;
private exited = false;
private closing: Promise<void> | null = null;
private readonly frameDecoder: JsonlFrameDecoder;
@@ -91,14 +90,6 @@ export class JsonlRpcProcess {
this.frameDecoder = new JsonlFrameDecoder({
frame: (message) => this.dispatchFrame(message),
problem: (problem, detail) => {
if (problem === "line-too-large" && detail instanceof ProviderStreamLimitError) {
// Stop the producer: silently skipping a response or turn event loses state.
this.streamFailure = detail;
void this.close(detail).catch((err) => {
this.options.logger.warn({ err }, `Failed to close ${this.diagnosticName}`);
});
return;
}
this.options.logger.warn(
{ problem, detail },
`Ignoring invalid ${this.diagnosticName} frame`,
@@ -107,7 +98,7 @@ export class JsonlRpcProcess {
});
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
this.child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk);
this.handleStdoutChunk(chunk.toString());
});
this.child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -123,11 +114,9 @@ export class JsonlRpcProcess {
});
this.child.on("exit", (code, signal) => {
this.exited = true;
const error =
this.streamFailure ??
new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const error = new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const exit = { code, signal, error };
for (const subscriber of this.exitSubscribers) {
subscriber(exit);
@@ -265,7 +254,7 @@ export class JsonlRpcProcess {
}
}
private handleStdoutChunk(chunk: Buffer | string): void {
private handleStdoutChunk(chunk: string): void {
this.frameDecoder.write(chunk);
}
@@ -56,6 +56,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
getDaemonTcpHost: null,
serviceProxyPublicBaseUrl: null,
resolveScriptHealth: null,
getGlobalServicePorts: async () => undefined,
logger,
emit: () => {},
publishStatusUpdate: () => {},
+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 },
);
}
}
@@ -0,0 +1,431 @@
import { stat } from "node:fs/promises";
import { readPluginManifest } from "./manifest.js";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { isBuiltin } from "node:module";
import path from "node:path";
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
import {
isPluginClientOnlySdkSpecifier,
isPluginServerOnlySdkSpecifier,
PLUGIN_SDK_SPECIFIERS,
} from "./plugin-sdk-specifiers.js";
import { loadEsbuild, SERVER_HOST_MODULES } from "./compiler-esbuild.js";
type PluginBuildTarget = "client" | "server";
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
const relative = path.relative(pluginDirectory, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
const segments = relative.split(path.sep);
if (segments.includes("node_modules")) return null;
if (segments[0] === "client") return "client";
if (segments[0] === "server") return "server";
if (segments[0] === "shared") return "shared";
if (/^index\.client\.tsx?$/.test(relative)) return "client";
if (/^index\.server\.tsx?$/.test(relative)) return "server";
return "invalid";
}
function containsPath(directory: string, filePath: string): boolean {
const relative = path.relative(directory, filePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
}
function findDependencyRoot(
resolvedPath: string,
specifier: string,
pluginDirectory: string,
): string | null {
const expectedName = dependencyName(specifier);
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
let directory = path.dirname(resolvedPath);
for (;;) {
const manifestPath = path.join(directory, "package.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
if (manifest.name === expectedName || manifest.name === ambientName) {
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
return null;
}
return directory;
}
}
const parent = path.dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function moduleBoundaryError(
moduleLocation: PluginModuleLocation | null,
target: PluginBuildTarget | "shared",
filePath: string,
): OnResolveResult | null {
if (moduleLocation === "invalid") {
return {
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
};
}
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
return null;
}
return {
errors: [
{
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
},
],
};
}
function lexicalBoundaryError(
specifier: string,
resolveDirectory: string,
pluginDirectory: string,
target: PluginBuildTarget | "shared",
): OnResolveResult | null {
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
const lexicalPath = path.resolve(resolveDirectory, specifier);
if (containsPath(pluginDirectory, lexicalPath)) {
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
}
// Normalize only a containing root alias. Resolving the whole import would erase
// an authored server/ or client/ location when the final file is a symlink.
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
}
if (path.dirname(ancestor) === ancestor) return null;
}
}
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
const boundaryResolution = {};
const linkedDependencyRoots = new Set<string>();
return {
name: `paseo-plugin-${target}-runtime-boundary`,
setup(buildContext) {
const checked = new Set<string>();
const imports = createPluginImportReader(pluginDirectory);
function resolvedBoundaryError(
resolvedFile: string,
specifier: string,
importer: string,
owner: PluginBuildTarget | "shared",
) {
const file = realpathSync.native(resolvedFile);
const location = directoryTarget(file, pluginDirectory);
if (location === "invalid") {
if (
[...linkedDependencyRoots].some(
(root) => containsPath(root, importer) && containsPath(root, file),
)
)
return null;
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
const root = findDependencyRoot(file, specifier, pluginDirectory);
if (root) {
linkedDependencyRoots.add(root);
return null;
}
}
}
return moduleBoundaryError(location, owner, file);
}
async function resolveImportFiles(
file: string,
specifier: string,
kind: PluginImportKind,
typeOnly: boolean,
): Promise<Set<string>> {
const declaration = imports.resolve(specifier, file, kind);
const dependencyFiles = new Set<string>();
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
dependencyFiles.add(declaration);
if (typeOnly && !declaration) {
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
}
if (!typeOnly && kind !== "type-reference") {
const resolution = await buildContext.resolve(specifier, {
importer: file,
resolveDir: path.dirname(file),
kind,
});
// esbuild owns missing-runtime errors: erased imports and guarded optional
// requires are legal. Inspect every dependency that actually resolves.
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
dependencyFiles.add(realpathSync.native(resolution.path));
}
return dependencyFiles;
}
async function checkSourceImports(
sourcePath: string,
inheritedOwner: PluginBuildTarget | "shared",
): Promise<OnResolveResult | null> {
const file = realpathSync.native(sourcePath);
const owner =
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
const key = `${owner}:${file}`;
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
if (error) return error;
// Protocol types are shared host contracts, also exposed by the SDK's peer
// dependency during development. Erased imports need no plugin-local install;
// runtime imports still go through normal dependency resolution below.
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
// Host modules have separately enforced SDK boundaries and need no local installation.
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
packageSpecifier === "@types/node"
)
continue;
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
for (const dependencyFile of dependencyFiles) {
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
if (boundaryError) return boundaryError;
const dependencyError = await checkSourceImports(dependencyFile, owner);
if (dependencyError) return dependencyError;
}
}
return null;
}
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
checkSourceImports(args.path, target),
);
buildContext.onResolve({ filter: /.*/ }, async (args) => {
if (args.kind === "entry-point") return null;
if (args.pluginData === boundaryResolution) return null;
const importer =
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
const specifierError = runtimeSpecifierError(args.path, owner, importer);
if (specifierError) return specifierError;
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
if (lexicalError) return lexicalError;
const resolution = await buildContext.resolve(args.path, {
importer,
namespace: args.namespace,
resolveDir,
kind: args.kind,
pluginData: boundaryResolution,
with: args.with,
});
if (
resolution.errors.length > 0 ||
resolution.external ||
resolution.namespace !== "file"
) {
return null;
}
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
});
},
};
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
importer: string,
): OnResolveResult | null {
let kind: string | null = null;
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
else if (
(specifier === "@getpaseo/plugin" ||
specifier.startsWith("@getpaseo/plugin/") ||
specifier === "@paseo/plugin" ||
specifier.startsWith("@paseo/plugin/")) &&
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&
(isPluginClientOnlySdkSpecifier(specifier) ||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
specifier,
))
)
kind = "client-only";
return kind
? {
errors: [
{
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
},
],
}
: null;
}
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
function inputLocation(file: string): PluginModuleLocation | null {
const absolutePath = path.resolve(file);
// Metafile keys must stay unchanged for graph traversal. Only filesystem
// inputs have canonical paths; data URLs and external specifiers do not.
return directoryTarget(
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
pluginDirectory,
);
}
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
const visited = new Set<string>();
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const dependency of inputs[file]?.imports ?? []) {
const location = dependency.external ? null : inputLocation(dependency.path);
const error =
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
(dependency.external
? null
: moduleBoundaryError(
location === "invalid" ? null : location,
"shared",
dependency.path,
));
if (error?.errors?.length) throw new Error(error.errors[0].text);
if (!dependency.external) pending.push(dependency.path);
}
}
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = loadEsbuild();
// Use native canonical paths throughout: TypeScript expands Windows short names
// when resolving type references, while the JS realpath implementation retains them.
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
const result = await build({
entryPoints: [realpathSync.native(entryPath)],
bundle: true,
format: "cjs",
jsx: "automatic",
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
loader: { ".svg": "text" },
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// The neutral platform reads no package.json entry fields, so packages without
// `exports` would not resolve. Read the same fields as esbuild's browser platform
// minus `browser`, since the bundle also runs in React Native.
mainFields: target === "client" ? ["module", "main"] : undefined,
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
// so apply the same compatibility transform before the app evaluates them from source.
supported: target === "client" ? { "async-await": false } : undefined,
external:
target === "client"
? [
...PLUGIN_SDK_SPECIFIERS,
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: SERVER_HOST_MODULES,
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
metafile: true,
logLevel: "silent",
treeShaking: true,
write: false,
});
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
}
export async function compilePlugin(entryPaths: {
client: string | null;
server: string | null;
}): Promise<{
clientBundle: string | null;
serverBundle: string | null;
}> {
const [clientBundle, serverBundle] = await Promise.all([
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
]);
return { clientBundle, serverBundle };
}
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
for (const filename of filenames) {
const filePath = path.join(directory, filename);
const info = await stat(filePath).catch(() => null);
if (info?.isFile()) return filePath;
}
return null;
}
async function resolveEntryPaths(directory: string): Promise<{
client: string | null;
server: string | null;
}> {
const [client, server] = await Promise.all([
findEntry(directory, CLIENT_ENTRY_FILENAMES),
findEntry(directory, SERVER_ENTRY_FILENAMES),
]);
if (client || server) return { client, server };
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) {
throw new Error(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
);
}
throw new Error(
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
);
}
export async function compileDirectoryPlugin(
directory: string,
version: string,
builtinId?: string,
) {
const manifest = await readPluginManifest(directory);
if (builtinId !== undefined && manifest.id !== builtinId) {
throw new Error(`Built-in plugin ${builtinId} has manifest ID ${manifest.id}`);
}
assertPluginCompatibility({ ...manifest, version, runtime: "daemon" });
const bundles = await compilePlugin(await resolveEntryPaths(directory));
return { ...bundles, manifest };
}
@@ -0,0 +1,64 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js";
const nodeRequire = createRequire(import.meta.url);
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
// esbuild resolves its own platform binary via require.resolve() the first time its
// module is evaluated. Inside the packaged desktop app that resolves to a path under
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
export function unpackedEsbuildBinaryFromPackageDir(
esbuildPackageDir: string,
platform: NodeJS.Platform,
arch: string,
): string | null {
const asarSegment = `${path.sep}app.asar${path.sep}`;
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
if (asarIndex === -1) return null;
return path.join(
esbuildPackageDir.slice(0, asarIndex),
"app.asar.unpacked",
"node_modules",
`@esbuild/${platform}-${arch}`,
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
);
}
export function resolveExistingAsarUnpackedEsbuildBinary(
esbuildPackageDir: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
exists: (file: string) => boolean = existsSync,
): string | null {
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
return binaryPath && exists(binaryPath) ? binaryPath : null;
}
function resolveAsarUnpackedEsbuildBinary(): string | null {
let esbuildDir: string;
try {
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
export function loadEsbuild(): typeof import("esbuild") {
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
try {
// esbuild reads this variable while its CommonJS module is evaluated. Keep
// the compatibility bridge local so it cannot become an agent's environment.
return nodeRequire("esbuild") as typeof import("esbuild");
} finally {
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
}
}
@@ -0,0 +1,62 @@
import { parentPort } from "node:worker_threads";
import type { ZodIssue } from "zod";
import { compilePlugin, compileDirectoryPlugin } from "./compiler-build.js";
import { runPluginBuild } from "./preparation-build.js";
import type { CompilerTask } from "./compiler.js";
export type CompilerReply =
| { kind: "log"; data: Record<string, unknown>; message: string }
| { kind: "result"; value: unknown }
| {
kind: "error";
error: Error;
properties: Record<string, unknown>;
issues?: ZodIssue[];
errors?: unknown[];
warnings?: unknown[];
};
const port = parentPort!;
// The compiler owner serializes requests; the port queues the first request
// until this entrypoint has loaded, including in source/tsx development.
port.on("message", (task: CompilerTask) => {
void run(task);
});
async function run(task: CompilerTask): Promise<void> {
try {
let value: unknown;
switch (task.kind) {
case "entries":
value = await compilePlugin(task.entries);
break;
case "directory":
value = await compileDirectoryPlugin(task.directory, task.version, task.builtinId);
break;
case "prepare":
await runPluginBuild(
task.directory,
task.commands,
{
info: (data, message) =>
port.postMessage({ kind: "log", data, message } satisfies CompilerReply),
},
task.environment,
);
break;
}
port.postMessage({ kind: "result", value } satisfies CompilerReply);
} catch (cause) {
const error = cause instanceof Error ? cause : new Error(String(cause));
port.postMessage({
kind: "error",
error,
// Error cloning drops own metadata such as filesystem code/path. Zod's
// readonly issues are reconstructed separately by the compiler owner.
properties: Object.fromEntries(Object.entries(error).filter(([key]) => key !== "issues")),
...("issues" in error ? { issues: error.issues as ZodIssue[] } : {}),
...("errors" in error ? { errors: error.errors as unknown[] } : {}),
...("warnings" in error ? { warnings: error.warnings as unknown[] } : {}),
} satisfies CompilerReply);
}
}
@@ -3,8 +3,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ZodError } from "zod";
import {
compilePlugin,
compileDirectoryPlugin,
resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler.js";
@@ -975,3 +977,52 @@ export default function contribute() { void value; void secret; return () => und
expect(serverBundle).toBeNull();
});
});
it("preserves esbuild diagnostics across the compiler boundary", async () => {
const { server } = await createSplitPlugin();
await writeFile(server, "export default function contribute( {");
const error = await compilePlugin({ client: null, server }).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect(error).toMatchObject({
name: "Error",
message: expect.stringContaining("Expected identifier but found end of file"),
errors: [
expect.objectContaining({
text: "Expected identifier but found end of file",
location: expect.objectContaining({
line: 1,
lineText: "export default function contribute( {",
}),
}),
],
warnings: [],
});
// A failed task does not poison the next build.
await writeFile(server, 'export default () => "recovered";');
const bundles = await compilePlugin({ client: null, server });
expect(bundles.serverBundle).toContain("recovered");
});
it("preserves manifest validation errors across the compiler boundary", async () => {
const { directory } = await createSplitPlugin();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: 42 }));
const error = await compileDirectoryPlugin(directory, "0.13.2").catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(ZodError);
expect(error).toMatchObject({
name: "ZodError",
issues: [expect.objectContaining({ path: ["id"], code: "invalid_type" })],
});
await writeFile(path.join(directory, "paseo-plugin.json"), "{");
await expect(compileDirectoryPlugin(directory, "0.13.2")).rejects.toBeInstanceOf(SyntaxError);
});
it("preserves filesystem error metadata for a missing entry", async () => {
const { server } = await createSplitPlugin();
await rm(server);
await expect(compilePlugin({ client: null, server })).rejects.toMatchObject({
code: "ENOENT",
errno: expect.any(Number),
syscall: "realpath",
path: server,
});
});
+136 -427
View File
@@ -1,442 +1,151 @@
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { createRequire, isBuiltin } from "node:module";
import path from "node:path";
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
import {
isPluginClientOnlySdkSpecifier,
isPluginServerOnlySdkSpecifier,
PLUGIN_SDK_SPECIFIERS,
} from "./plugin-sdk-specifiers.js";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import pLimit from "p-limit";
import { ZodError } from "zod";
import type * as build from "./compiler-build.js";
import type { PluginManifest } from "./manifest.js";
import type { CompilerReply } from "./compiler-worker.js";
const nodeRequire = createRequire(import.meta.url);
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
export {
SERVER_HOST_MODULES,
resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler-esbuild.js";
// esbuild resolves its own platform binary via require.resolve() the first time its
// module is evaluated. Inside the packaged desktop app that resolves to a path under
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
export function unpackedEsbuildBinaryFromPackageDir(
esbuildPackageDir: string,
platform: NodeJS.Platform,
arch: string,
): string | null {
const asarSegment = `${path.sep}app.asar${path.sep}`;
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
if (asarIndex === -1) return null;
return path.join(
esbuildPackageDir.slice(0, asarIndex),
"app.asar.unpacked",
"node_modules",
`@esbuild/${platform}-${arch}`,
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
);
}
export function resolveExistingAsarUnpackedEsbuildBinary(
esbuildPackageDir: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
exists: (file: string) => boolean = existsSync,
): string | null {
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
return binaryPath && exists(binaryPath) ? binaryPath : null;
}
function resolveAsarUnpackedEsbuildBinary(): string | null {
let esbuildDir: string;
try {
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
function loadEsbuild(): typeof import("esbuild") {
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
try {
// esbuild reads this variable while its CommonJS module is evaluated. Keep
// the compatibility bridge local so it cannot become an agent's environment.
return nodeRequire("esbuild") as typeof import("esbuild");
} finally {
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
}
}
type PluginBuildTarget = "client" | "server";
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
const relative = path.relative(pluginDirectory, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
const segments = relative.split(path.sep);
if (segments.includes("node_modules")) return null;
if (segments[0] === "client") return "client";
if (segments[0] === "server") return "server";
if (segments[0] === "shared") return "shared";
if (/^index\.client\.tsx?$/.test(relative)) return "client";
if (/^index\.server\.tsx?$/.test(relative)) return "server";
return "invalid";
}
function containsPath(directory: string, filePath: string): boolean {
const relative = path.relative(directory, filePath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
}
function findDependencyRoot(
resolvedPath: string,
specifier: string,
pluginDirectory: string,
): string | null {
const expectedName = dependencyName(specifier);
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
let directory = path.dirname(resolvedPath);
for (;;) {
const manifestPath = path.join(directory, "package.json");
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
if (manifest.name === expectedName || manifest.name === ambientName) {
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
return null;
}
return directory;
}
}
const parent = path.dirname(directory);
if (parent === directory) return null;
directory = parent;
}
}
function moduleBoundaryError(
moduleLocation: PluginModuleLocation | null,
target: PluginBuildTarget | "shared",
filePath: string,
): OnResolveResult | null {
if (moduleLocation === "invalid") {
return {
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
export type CompilerTask =
| { kind: "entries"; entries: Parameters<typeof build.compilePlugin>[0] }
| { kind: "directory"; directory: string; version: string; builtinId?: string }
| {
kind: "prepare";
directory: string;
commands: PluginManifest["build"];
environment: NodeJS.ProcessEnv;
};
}
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
return null;
}
return {
errors: [
{
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
},
],
};
interface TaskResult {
entries: Awaited<ReturnType<typeof build.compilePlugin>>;
directory: Awaited<ReturnType<typeof build.compileDirectoryPlugin>>;
prepare: void;
}
function lexicalBoundaryError(
specifier: string,
resolveDirectory: string,
pluginDirectory: string,
target: PluginBuildTarget | "shared",
): OnResolveResult | null {
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
const lexicalPath = path.resolve(resolveDirectory, specifier);
if (containsPath(pluginDirectory, lexicalPath)) {
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
}
// Normalize only a containing root alias. Resolving the whole import would erase
// an authored server/ or client/ location when the final file is a symlink.
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
}
if (path.dirname(ancestor) === ancestor) return null;
}
}
const IDLE_TIMEOUT_MS = 1_000;
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
const boundaryResolution = {};
const linkedDependencyRoots = new Set<string>();
return {
name: `paseo-plugin-${target}-runtime-boundary`,
setup(buildContext) {
const checked = new Set<string>();
const imports = createPluginImportReader(pluginDirectory);
function resolvedBoundaryError(
resolvedFile: string,
specifier: string,
importer: string,
owner: PluginBuildTarget | "shared",
) {
const file = realpathSync.native(resolvedFile);
const location = directoryTarget(file, pluginDirectory);
if (location === "invalid") {
if (
[...linkedDependencyRoots].some(
(root) => containsPath(root, importer) && containsPath(root, file),
)
)
return null;
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
const root = findDependencyRoot(file, specifier, pluginDirectory);
if (root) {
linkedDependencyRoots.add(root);
return null;
}
// Reuse the isolate during a build batch (notably daemon bootstrap), then release
// TypeScript's heap when idle. Only this owner admits work or stops the worker.
class PluginCompiler {
private readonly queue = pLimit(1);
private worker: Worker | null = null;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private stopping: Promise<unknown> = Promise.resolve();
run<T extends CompilerTask>(
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
return this.queue(async () => {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
await this.stopping;
const worker = this.worker ?? this.startWorker();
worker.ref();
try {
return await this.request<T>(worker, task, onLog);
} finally {
if (this.worker === worker) {
worker.unref();
this.idleTimer = setTimeout(() => this.stopWorker(worker), IDLE_TIMEOUT_MS);
this.idleTimer.unref();
} else {
await this.stopping;
}
}
});
}
private startWorker(): Worker {
const source = import.meta.url.endsWith(".ts");
const url = new URL(source ? "./compiler-worker.ts" : "./compiler-worker.js", import.meta.url);
// Node workers do not inherit tsx's in-process loader registration. Register
// it inside development/test isolates; dist workers execute ordinary JS.
const worker = source
? new Worker(
`import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(url.href)}); });`,
{ eval: true, execArgv: [] },
)
: new Worker(url, { execArgv: [] });
this.worker = worker;
// Keep error/exit ownership while idle too. The next caller gets a fresh
// isolate after a crash; author build commands are never retried.
worker.on("error", () => this.stopWorker(worker));
worker.on("exit", () => {
if (this.worker === worker) this.stopWorker(worker);
});
return worker;
}
private stopWorker(worker: Worker): void {
if (this.worker !== worker) return;
this.worker = null;
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
this.stopping = worker.terminate();
}
private async request<T extends CompilerTask>(
worker: Worker,
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
let onMessage: (reply: CompilerReply) => void;
let onError: (error: Error) => void;
let onExit: (code: number) => void;
try {
return await new Promise<TaskResult[T["kind"]]>((resolve, reject) => {
onError = reject;
onExit = (code) => reject(new Error(`Plugin compiler worker exited (code ${code})`));
onMessage = (reply) => {
if (reply.kind === "log") {
onLog?.(reply.data, reply.message);
} else if (reply.kind === "result") {
resolve(reply.value as TaskResult[T["kind"]]);
} else {
const error = reply.issues ? new ZodError(reply.issues) : reply.error;
Object.assign(error, reply.properties);
if (reply.errors) Object.assign(error, { errors: reply.errors });
if (reply.warnings) Object.assign(error, { warnings: reply.warnings });
// Promise.all can reject while the other target is still compiling.
// Retire the isolate before another request can enter it.
this.stopWorker(worker);
reject(error);
}
}
return moduleBoundaryError(location, owner, file);
}
async function resolveImportFiles(
file: string,
specifier: string,
kind: PluginImportKind,
typeOnly: boolean,
): Promise<Set<string>> {
const declaration = imports.resolve(specifier, file, kind);
const dependencyFiles = new Set<string>();
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
dependencyFiles.add(declaration);
if (typeOnly && !declaration) {
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
}
if (!typeOnly && kind !== "type-reference") {
const resolution = await buildContext.resolve(specifier, {
importer: file,
resolveDir: path.dirname(file),
kind,
});
// esbuild owns missing-runtime errors: erased imports and guarded optional
// requires are legal. Inspect every dependency that actually resolves.
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
dependencyFiles.add(realpathSync.native(resolution.path));
}
return dependencyFiles;
}
async function checkSourceImports(
sourcePath: string,
inheritedOwner: PluginBuildTarget | "shared",
): Promise<OnResolveResult | null> {
const file = realpathSync.native(sourcePath);
const owner =
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
const key = `${owner}:${file}`;
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
if (error) return error;
// Protocol types are shared host contracts, also exposed by the SDK's peer
// dependency during development. Erased imports need no plugin-local install;
// runtime imports still go through normal dependency resolution below.
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
// Host modules have separately enforced SDK boundaries and need no local installation.
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
packageSpecifier === "@types/node"
)
continue;
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
for (const dependencyFile of dependencyFiles) {
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
if (boundaryError) return boundaryError;
const dependencyError = await checkSourceImports(dependencyFile, owner);
if (dependencyError) return dependencyError;
}
}
return null;
}
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
checkSourceImports(args.path, target),
);
buildContext.onResolve({ filter: /.*/ }, async (args) => {
if (args.kind === "entry-point") return null;
if (args.pluginData === boundaryResolution) return null;
const importer =
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
const specifierError = runtimeSpecifierError(args.path, owner, importer);
if (specifierError) return specifierError;
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
if (lexicalError) return lexicalError;
const resolution = await buildContext.resolve(args.path, {
importer,
namespace: args.namespace,
resolveDir,
kind: args.kind,
pluginData: boundaryResolution,
with: args.with,
});
if (
resolution.errors.length > 0 ||
resolution.external ||
resolution.namespace !== "file"
) {
return null;
}
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
};
worker.on("error", onError);
worker.on("exit", onExit);
worker.on("message", onMessage);
worker.postMessage(task, []);
});
},
};
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function runtimeSpecifierError(
specifier: string,
target: PluginBuildTarget | "shared",
importer: string,
): OnResolveResult | null {
let kind: string | null = null;
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
else if (
(specifier === "@getpaseo/plugin" ||
specifier.startsWith("@getpaseo/plugin/") ||
specifier === "@paseo/plugin" ||
specifier.startsWith("@paseo/plugin/")) &&
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&
(isPluginClientOnlySdkSpecifier(specifier) ||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
specifier,
))
)
kind = "client-only";
return kind
? {
errors: [
{
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
},
],
}
: null;
}
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
function inputLocation(file: string): PluginModuleLocation | null {
const absolutePath = path.resolve(file);
// Metafile keys must stay unchanged for graph traversal. Only filesystem
// inputs have canonical paths; data URLs and external specifiers do not.
return directoryTarget(
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
pluginDirectory,
);
}
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
const visited = new Set<string>();
while (pending.length) {
const file = pending.pop()!;
if (visited.has(file)) continue;
visited.add(file);
for (const dependency of inputs[file]?.imports ?? []) {
const location = dependency.external ? null : inputLocation(dependency.path);
const error =
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
(dependency.external
? null
: moduleBoundaryError(
location === "invalid" ? null : location,
"shared",
dependency.path,
));
if (error?.errors?.length) throw new Error(error.errors[0].text);
if (!dependency.external) pending.push(dependency.path);
} finally {
worker.off("message", onMessage!);
worker.off("error", onError!);
worker.off("exit", onExit!);
}
}
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = loadEsbuild();
// Use native canonical paths throughout: TypeScript expands Windows short names
// when resolving type references, while the JS realpath implementation retains them.
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
const result = await build({
entryPoints: [realpathSync.native(entryPath)],
bundle: true,
format: "cjs",
jsx: "automatic",
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
loader: { ".svg": "text" },
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// The neutral platform reads no package.json entry fields, so packages without
// `exports` would not resolve. Read the same fields as esbuild's browser platform
// minus `browser`, since the bundle also runs in React Native.
mainFields: target === "client" ? ["module", "main"] : undefined,
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
// so apply the same compatibility transform before the app evaluates them from source.
supported: target === "client" ? { "async-await": false } : undefined,
external:
target === "client"
? [
...PLUGIN_SDK_SPECIFIERS,
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: SERVER_HOST_MODULES,
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
metafile: true,
logLevel: "silent",
treeShaking: true,
write: false,
});
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
const compiler = new PluginCompiler();
export function runCompilerTask<T extends CompilerTask>(
task: T,
onLog?: (data: Record<string, unknown>, message: string) => void,
): Promise<TaskResult[T["kind"]]> {
return compiler.run(task, onLog);
}
export async function compilePlugin(entryPaths: {
client: string | null;
server: string | null;
}): Promise<{
clientBundle: string | null;
serverBundle: string | null;
}> {
const [clientBundle, serverBundle] = await Promise.all([
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
]);
return { clientBundle, serverBundle };
export function compilePlugin(entries: Parameters<typeof build.compilePlugin>[0]) {
return runCompilerTask({ kind: "entries", entries });
}
export function compileDirectoryPlugin(directory: string, version: string, builtinId?: string) {
return runCompilerTask({ kind: "directory", directory, version, builtinId });
}
@@ -4,6 +4,7 @@ import {
rename,
mkdir,
mkdtemp,
open as openFile,
readFile,
readdir,
rm,
@@ -526,7 +527,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 +1080,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 +1101,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 +1120,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);
@@ -1557,3 +1599,60 @@ async function applyReviewedUpdate(service: PluginService, pluginId: string) {
expect(preview?.outcome).toBe("update");
return service.applyUpdates([preview!.proposal!]);
}
it("keeps reload and enable responsive while compiling a real plugin", async () => {
const directory = await createPlugin("responsive-build", "export default () => {};");
// Syntax-dense input exercises TypeScript's AST walk, not just esbuild's subprocess.
await rm(path.join(directory, "index.server.ts"));
const entry = await openFile(path.join(directory, "index.client.ts"), "w");
try {
await entry.write("const values = [");
for (let offset = 0; offset < 350_000; offset += 10_000) {
await entry.write(
Array.from({ length: 10_000 }, (_, index) => offset + index).join(",") + ",",
);
}
await entry.write("]; export default function contribute() { return () => values; }");
} finally {
await entry.close();
}
const service = createService(directory, {
"responsive-build": { source: "directory", path: directory, enabled: false },
});
await service.start();
try {
for (const action of [
() => service.enablePlugin("responsive-build"),
() => service.reloadPlugin("responsive-build"),
]) {
let previous = performance.now();
let longest = 0;
const sample = () => {
const now = performance.now();
longest = Math.max(longest, now - previous);
previous = now;
};
const timer = setInterval(sample, 1);
try {
await action();
sample();
} finally {
clearInterval(timer);
}
process.stderr.write(`plugin compile longest loop gap: ${longest.toFixed(2)} ms\n`);
expect(service.catalog()[0]?.clientBundle).toContain("349999");
expect(longest).toBeLessThan(20);
}
// Evaluate after both probes so parsing the returned code is not charged to compilation.
const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as (
require: (specifier: string) => never,
) => { default: () => () => number[] };
const values = factory((specifier) => {
throw new Error(`Unexpected fixture dependency: ${specifier}`);
}).default()();
expect(values).toHaveLength(350_000);
expect(values.every((value, index) => value === index)).toBe(true);
} finally {
await service.stopAllPlugins();
}
});
+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-"));
@@ -0,0 +1,76 @@
import { spawnProcess } from "../../utils/spawn.js";
import type { PluginManifest } from "./manifest.js";
const OUTPUT_LIMIT = 64 * 1024;
export async function runPluginBuild(
directory: string,
commands: PluginManifest["build"],
logger: { info(data: Record<string, unknown>, message: string): void },
environment: NodeJS.ProcessEnv,
): Promise<void> {
for (const command of commands ?? []) {
logger.info({ directory, command }, "Running plugin build command");
const result = await run(command, directory, environment);
if (result.stdout)
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout");
if (result.stderr)
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
if (result.error) {
throw new Error(
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
);
}
if (result.exitCode !== 0) {
throw new Error(
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
);
}
}
}
function run(
command: string[],
directory: string,
environment: NodeJS.ProcessEnv,
): Promise<CommandResult> {
return new Promise((resolve) => {
const executable = command[0]!;
const arguments_ = command.slice(1);
const child = spawnProcess(executable, arguments_, { cwd: directory, env: environment });
let stdout = "";
let stderr = "";
let error: Error | undefined;
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout = appendOutput(stdout, chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr = appendOutput(stderr, chunk.toString());
});
child.once("error", (spawnError) => {
error = spawnError;
});
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
});
}
function appendOutput(current: string, next: string): string {
return `${current}${next}`.slice(-OUTPUT_LIMIT);
}
function formatCommand(command: string[]): string {
return command.map((argument) => JSON.stringify(argument)).join(" ");
}
function formatOutput(result: CommandResult): string {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
return output ? `\n${output}` : "";
}
interface CommandResult {
exitCode: number | null;
stdout: string;
stderr: string;
error?: Error;
}
@@ -1,6 +1,8 @@
import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { Writable } from "node:stream";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { runPluginBuild } from "./preparation.js";
@@ -117,6 +119,52 @@ describe("runPluginBuild", () => {
).rejects.toThrow(/Plugin build command failed \(exit 2\)/);
});
it("forwards build progress and output from the worker to the caller's logger", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const records: Array<{ msg: string; output?: string }> = [];
const logger = pino(
new Writable({
write(chunk, _encoding, callback) {
records.push(JSON.parse(chunk.toString()));
callback();
},
}),
);
await runPluginBuild(
directory,
[[process.execPath, "-e", 'console.log("prepared"); console.error("diagnostic");']],
logger,
);
expect(records).toMatchObject([
{ msg: "Running plugin build command" },
{ msg: "Plugin build stdout", output: "prepared\n" },
{ msg: "Plugin build stderr", output: expect.stringContaining("diagnostic\n") },
]);
});
it("uses the caller's current environment across consecutive builds", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
const previous = process.env.PASEO_PLUGIN_TEST_BUILD_VALUE;
const command = [
process.execPath,
"-e",
'require("node:fs").writeFileSync(process.argv[1], process.env.PASEO_PLUGIN_TEST_BUILD_VALUE);',
];
try {
process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "first";
await runPluginBuild(directory, [[...command, "first.txt"]], createTestLogger());
process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "second";
await runPluginBuild(directory, [[...command, "second.txt"]], createTestLogger());
expect(await readFile(path.join(directory, "first.txt"), "utf8")).toBe("first");
expect(await readFile(path.join(directory, "second.txt"), "utf8")).toBe("second");
} finally {
if (previous === undefined) delete process.env.PASEO_PLUGIN_TEST_BUILD_VALUE;
else process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = previous;
}
});
it("handles empty or undefined build commands gracefully", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-"));
directories.push(directory);
@@ -1,71 +1,15 @@
import { spawnProcess } from "../../utils/spawn.js";
import type pino from "pino";
import type { PluginManifest } from "./manifest.js";
const OUTPUT_LIMIT = 64 * 1024;
import { runCompilerTask } from "./compiler.js";
export async function runPluginBuild(
directory: string,
commands: PluginManifest["build"],
logger: pino.Logger,
): Promise<void> {
for (const command of commands ?? []) {
logger.info({ directory, command }, "Running plugin build command");
const result = await run(command, directory);
if (result.stdout)
logger.info({ directory, command, output: result.stdout }, "Plugin build stdout");
if (result.stderr)
logger.info({ directory, command, output: result.stderr }, "Plugin build stderr");
if (result.error) {
throw new Error(
`Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`,
);
}
if (result.exitCode !== 0) {
throw new Error(
`Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`,
);
}
}
}
function run(command: string[], directory: string): Promise<CommandResult> {
return new Promise((resolve) => {
const executable = command[0]!;
const arguments_ = command.slice(1);
const child = spawnProcess(executable, arguments_, { cwd: directory });
let stdout = "";
let stderr = "";
let error: Error | undefined;
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout = appendOutput(stdout, chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr = appendOutput(stderr, chunk.toString());
});
child.once("error", (spawnError) => {
error = spawnError;
});
child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error }));
});
}
function appendOutput(current: string, next: string): string {
return `${current}${next}`.slice(-OUTPUT_LIMIT);
}
function formatCommand(command: string[]): string {
return command.map((argument) => JSON.stringify(argument)).join(" ");
}
function formatOutput(result: CommandResult): string {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
return output ? `\n${output}` : "";
}
interface CommandResult {
exitCode: number | null;
stdout: string;
stderr: string;
error?: Error;
if (!commands?.length) return;
await runCompilerTask(
{ kind: "prepare", directory, commands, environment: { ...process.env } },
(data, message) => logger.info(data, message),
);
}
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { fork } from "node:child_process";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
@@ -1849,9 +1849,9 @@ export default function contribute(plugin: any) {
const child = createReloadChild("blocked", events);
const runtime = createTestRuntime({ spawnChild: () => child });
await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow(
"Plugin start cancelled: blocked",
);
await expect(
runtime.startPlugin("blocked", directory, () => events.length === 0),
).rejects.toThrow("Plugin start cancelled: blocked");
expect(runtime.catalog()).toEqual([]);
expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]);
@@ -2055,3 +2055,50 @@ export default function contribute(server) {
await runtime.stopAll();
}
});
const examplesDirectory = fileURLToPath(
new URL("../../../../../plugin-examples/", import.meta.url),
);
// The Markdown example owns npm preparation in the real app fixture:
// packages/app/e2e/browser/plugin-file-panel-examples.spec.ts.
const examples = (await readdir(examplesDirectory, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && entry.name !== "markdown-panel")
.map((entry) => entry.name);
it.each(examples)("builds and loads the %s plugin example", async (example) => {
const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-"));
temporaryDirectories.push(settingsDirectory);
const runtime = createTestRuntime({ settingsDirectory }, undefined, "0.13.2");
try {
await runtime.startPlugin(example, path.join(examplesDirectory, example));
expect(runtime.catalog()).toMatchObject([{ id: example }]);
expect(pluginMessages(runtime, example)).toContain("[paseo] Plugin ready");
} finally {
await runtime.stopAll();
}
});
it("does not initialize a plugin cancelled during compilation", async () => {
const directory = await createPlugin("cancelled-build", "export default () => {};");
const marker = path.join(directory, "initialized.txt");
await writeFile(
path.join(directory, "index.server.ts"),
`
import { writeFileSync } from "node:fs";
export default function contribute() {
writeFileSync(${JSON.stringify(marker)}, "initialized");
return () => {};
}`,
);
const runtime = createTestRuntime();
let allowed = true;
try {
const loading = runtime.startPlugin("cancelled-build", directory, () => allowed);
allowed = false;
await expect(loading).rejects.toThrow("Plugin start cancelled: cancelled-build");
expect(await readTextIfPresent(marker)).toBeNull();
expect(runtime.catalog()).toEqual([]);
} finally {
await runtime.stopAll();
}
});
+18 -54
View File
@@ -7,7 +7,6 @@ import {
import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
@@ -22,10 +21,8 @@ import {
type ProviderInput,
} from "@getpaseo/plugin/server/provider";
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
import { compilePlugin } from "./compiler.js";
import { readPluginManifest } from "./manifest.js";
import { compileDirectoryPlugin } from "./compiler.js";
import type { PluginRequirements } from "@getpaseo/protocol/messages";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import type {
PluginProcessMessage,
PluginProcessRequest,
@@ -37,8 +34,6 @@ import { PluginSessionSocket } from "./session-socket.js";
import { InternalPluginChild } from "./internal-child.js";
import { evaluateBundle } from "./bundle-evaluator.js";
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_LOG_ENTRIES = 500;
const MAX_LOG_BYTES = 256 * 1024;
@@ -265,35 +260,6 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise<void>
});
}
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
for (const filename of filenames) {
const filePath = path.join(directory, filename);
const info = await stat(filePath).catch(() => null);
if (info?.isFile()) return filePath;
}
return null;
}
async function resolveEntryPaths(directory: string): Promise<{
client: string | null;
server: string | null;
}> {
const [client, server] = await Promise.all([
findEntry(directory, CLIENT_ENTRY_FILENAMES),
findEntry(directory, SERVER_ENTRY_FILENAMES),
]);
if (client || server) return { client, server };
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
if (legacyEntry) {
throw new Error(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
);
}
throw new Error(
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
);
}
export class PluginRuntime {
private readonly plugins = new Map<string, LoadedPlugin>();
private readonly pendingEvents = new Set<Promise<void>>();
@@ -331,10 +297,16 @@ export class PluginRuntime {
): Promise<void> {
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
this.appendLog(pluginId, "stdout", "[paseo] Loading plugin");
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => {
this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`);
throw error;
});
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath, canPublish).catch(
(error) => {
this.appendLog(
pluginId,
"stderr",
`[paseo] Plugin failed to load: ${describeError(error)}`,
);
throw error;
},
);
if (!canPublish()) {
await this.stopPlugin(loaded);
throw new Error(`Plugin start cancelled: ${pluginId}`);
@@ -347,12 +319,8 @@ export class PluginRuntime {
if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`);
this.appendLog(input.id, "stdout", "[paseo] Loading plugin");
const directory = path.resolve(input.directory);
const manifest = await readPluginManifest(directory);
if (manifest.id !== input.id) {
throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`);
}
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const bundles = await compilePlugin(await resolveEntryPaths(directory));
const bundles = await compileDirectoryPlugin(directory, this.daemonVersion, input.id);
const { manifest } = bundles;
if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`);
const loaded = await this.launchPlugin({
pluginId: input.id,
@@ -367,11 +335,7 @@ export class PluginRuntime {
}
async validatePlugin(configuredPath: string): Promise<void> {
const directory = path.resolve(configuredPath);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const entryPaths = await resolveEntryPaths(directory);
await compilePlugin(entryPaths);
await compileDirectoryPlugin(path.resolve(configuredPath), this.daemonVersion);
}
async stopPluginById(pluginId: string): Promise<boolean> {
@@ -631,12 +595,12 @@ export class PluginRuntime {
private async loadDirectoryPlugin(
pluginId: string,
configuredPath: string,
canPublish: () => boolean,
): Promise<LoadedPlugin> {
const directory = path.resolve(configuredPath);
const manifest = await readPluginManifest(directory);
assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" });
const entryPaths = await resolveEntryPaths(directory);
const bundles = await compilePlugin(entryPaths);
const bundles = await compileDirectoryPlugin(directory, this.daemonVersion);
const { manifest } = bundles;
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
const serverBundle = bundles.serverBundle;
if (!serverBundle) {
return {
@@ -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;
}
}
@@ -425,6 +425,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
github: asGitHubService(github),
workspaceGitService: asWorkspaceGitService(workspaceGitService),
daemonConfigStore: asDaemonConfigStore({
readServicePorts: async () => undefined,
get: vi.fn(() => ({
mcp: { injectIntoAgents: false },
providers: {},
+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);
@@ -116,35 +116,6 @@ describe("workspace registries", () => {
expect(await projectRegistry.list()).toEqual([]);
});
test("writes once per mutation and skips missing-record mutations", async () => {
let writes = 0;
const registry = new FileBackedProjectRegistry(
path.join(tmpDir, "projects", "write-count.json"),
logger,
{
writeRecords: async (filePath, records) => {
writes++;
await writeJsonFileAtomic(filePath, records);
},
},
);
const project = createPersistedProjectRecord({
projectId: "counted",
rootPath: tmpDir,
kind: "git",
displayName: "Counted",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
await registry.upsert(project);
expect(writes).toBe(1);
await registry.update("missing", (current) => current);
await registry.remove("missing");
expect(writes).toBe(1);
await registry.update(project.projectId, (current) => ({ ...current, customName: "Changed" }));
expect(writes).toBe(2);
});
test("preserves a concurrent project update when archiving", async () => {
let pauseNextWrite = false;
let releaseWrite!: () => void;
@@ -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);
});
});