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
42 changed files with 1884 additions and 1701 deletions
@@ -1,6 +1,4 @@
import { describe, expect, it } from "vitest";
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
describe("InMemoryAgentTimelineStore", () => {
@@ -179,10 +177,7 @@ describe("projected sequence ownership", () => {
store.append("a", { type: "assistant_message", text: "Answer" });
store.append("a", { ...tool, status: "completed" });
store.initialize("b", { rows: store.getRows("a") });
expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" });
expect(store.getLastAssistantMessage("b")).toBe("Answer");
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" });
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
});
it("returns the last projected assistant message without joining different messages", () => {
@@ -216,151 +211,3 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
// Generation and its GC are outside the measured request window.
await new Promise<void>((resolve) => setImmediate(resolve));
const started = performance.now();
const probe = new Promise<number>((resolve) =>
setImmediate(() => resolve(performance.now() - started)),
);
let page = store.fetch("large", { limit: 200 });
for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 });
const duration = performance.now() - started;
const loopGap = await probe;
console.info({ duration, loopGap });
expect(page.rows).toHaveLength(200);
expect(page.startSeq).toBe(99_801);
expect(page.endSeq).toBe(100_000);
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
// is measured separately. Eight requests amplify the old full-history work.
expect(duration).toBeLessThan(50);
expect(loopGap).toBeLessThan(50);
});
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
let seed = 0x76aabbcc;
const random = (bound: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return Math.floor((seed / 0x1_0000_0000) * bound);
};
for (let scenario = 0; scenario < 100; scenario++) {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
for (let seq = 1; seq <= 100; seq++) {
const identity = String(random(8));
const text = String(seq);
const items: AgentTimelineItem[] = [
{ type: "user_message", text },
{ type: "assistant_message", text },
{ type: "assistant_message", text, messageId: identity },
{ type: "reasoning", text },
{
type: "tool_call",
callId: identity,
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: text },
},
{
type: "plugin",
id: identity,
pluginId: "test",
kind: "state",
version: 1,
data: { text },
},
];
store.append("a", items[random(items.length)], { turnId: String(random(3)) });
}
// Include rehydration of already-projected rows, with non-monotonic seqEnd.
store.initialize("b", { rows: store.getRows("a"), epoch: "e" });
for (const agentId of ["a", "b"]) {
assertSelectionMatchesReference(store, agentId);
store.append(agentId, { type: "assistant_message", text: "later" });
assertSelectionMatchesReference(store, agentId);
}
}
});
function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void {
for (const direction of ["tail", "before", "after"] as const) {
for (const limit of [0, 1, 7, 200]) {
for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) {
const actual = store.fetch(agentId, {
direction,
limit,
cursor: { epoch: "e", seq: cursorSeq },
});
const expected = selectReferenceProjectedTimelinePage({
rows: store.getRows(agentId),
bounds: actual.window,
direction,
limit,
cursorSeq,
});
expect({
...expected,
entries: undefined,
rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
}).toEqual({
entries: undefined,
rows: actual.rows,
startSeq: actual.startSeq,
endSeq: actual.endSeq,
hasOlder: actual.hasOlder,
hasNewer: actual.hasNewer,
});
}
}
}
}
it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
const tool: AgentTimelineItem = {
type: "tool_call",
callId: "t",
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: "work" },
};
store.append("a", tool);
for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) });
const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 });
expect(first.endSeq).toBe(2);
store.append("a", { ...tool, status: "completed" });
store.append("a", { type: "assistant_message", text: "Done" });
expect(first.rows[0].item).toMatchObject({ status: "running" });
const second = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: first.endSeq! },
limit: 2,
});
expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]);
expect(second.endSeq).toBe(4);
const third = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: second.endSeq! },
limit: 2,
});
expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]);
expect(third.rows[0].item).toMatchObject({ status: "completed" });
expect(third.endSeq).toBe(6);
const last = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: third.endSeq! },
limit: 2,
});
expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]);
expect(last.endSeq).toBe(7);
expect(last.hasNewer).toBe(false);
});
@@ -1,5 +1,9 @@
import { randomUUID } from "node:crypto";
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
import {
TimelineProjection,
selectProjectedTimelinePage,
type ProjectedTimelineRow,
} from "./timeline-projection.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -39,7 +43,8 @@ export class InMemoryAgentTimelineStore {
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
const projection = new TimelineProjection(rows);
const projection = new TimelineProjection();
for (const row of rows) projection.append(row);
this.states.set(agentId, {
epoch: options?.epoch ?? randomUUID(),
projection,
@@ -92,17 +97,18 @@ export class InMemoryAgentTimelineStore {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const cursor = options?.cursor;
const hasRows = state.minSeq !== 0;
const rows = state.projection.getRows();
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
const gap =
!staleCursor &&
direction === "after" &&
cursor !== undefined &&
hasRows &&
rows.length > 0 &&
cursor.seq < state.minSeq - 1;
const reset = staleCursor || gap;
const page = state.projection.selectPage({
const page = selectProjectedTimelinePage({
rows,
bounds: window,
direction: reset ? "tail" : direction,
cursorSeq: cursor?.seq,
@@ -144,11 +150,14 @@ export class InMemoryAgentTimelineStore {
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.projection.getLastItem(state.nextSeq - 1);
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
}
getLastAssistantMessage(agentId: string): string | null {
return this.requireState(agentId).projection.getLastAssistantMessage();
const row = this.requireState(agentId)
.projection.getRows()
.findLast((candidate) => candidate.item.type === "assistant_message");
return row?.item.type === "assistant_message" ? row.item.text : null;
}
private requireState(agentId: string): AgentTimelineState {
@@ -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 },
);
@@ -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: () => {},
@@ -1,249 +0,0 @@
// Frozen pre-index page selection: differential oracle for retained projection tests.
import type { AgentTimelineRow } from "../agent-timeline-store-types.js";
import {
TimelineProjection,
projectTimelineRows,
type TimelineProjectionEntry,
type ProjectedTimelineRow,
type TimelineLimitDirection,
type ProjectedTimelinePageSelection,
} from "../timeline-projection.js";
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] {
return rows.map((row) => {
if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) };
return {
item: row.item,
timestamp: row.timestamp,
...(row.turnId ? { turnId: row.turnId } : {}),
...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}),
seqStart: row.seq,
seqEnd: row.seq,
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
collapsed: [],
};
});
}
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
const entries = makeCanonicalEntries(rows);
return {
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
};
}
function firstSourceSeqInRange(
entry: TimelineProjectionEntry,
startSeq: number,
endSeq: number,
): number | null {
for (const range of entry.sourceSeqRanges) {
const firstSeq = Math.max(range.startSeq, startSeq);
if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq;
}
return null;
}
interface ProjectedEntryCandidate {
entry: TimelineProjectionEntry;
index: number;
firstSourceSeq: number;
}
function selectProjectedEntriesAfter(input: {
entries: readonly TimelineProjectionEntry[];
startSeq: number;
maxSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; endSeq: number | null } {
const eligible = input.entries
.map((entry, index) => ({
entry,
index,
firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq),
}))
.filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null)
.sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index);
const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit);
const selectedEntries = selected
.sort((left, right) => left.index - right.index)
.map((candidate) => candidate.entry);
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
const selectedRanges = selectedEntries
.flatMap((entry) => entry.sourceSeqRanges)
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
// Wide projected entries can include future, discontiguous source ranges. The
// page cursor advances only through source rows covered without a gap.
let endSeq = input.startSeq - 1;
for (const range of selectedRanges) {
if (range.endSeq <= endSeq) continue;
if (range.startSeq > endSeq + 1) break;
endSeq = Math.min(range.endSeq, input.maxSeq);
}
return {
entries: selectedEntries,
endSeq: endSeq >= input.startSeq ? endSeq : null,
};
}
function selectProjectedEntriesBefore(input: {
entries: readonly TimelineProjectionEntry[];
endSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
// Older history follows projected display order. Lifecycle updates can move an
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
// projected item on exactly one backward page.
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
const selected =
input.limit === 0 || input.limit >= eligible.length
? eligible
: eligible.slice(eligible.length - input.limit);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
hasOlder: selected.length < eligible.length,
};
}
function selectProjectedEntriesTail(
projectedAll: TimelineProjectionEntry[],
limit: number,
bounds: { minSeq: number; maxSeq: number },
): ProjectedTimelinePageSelection {
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
// Include earlier display anchors with updates in the selected source window,
// so a tail certifies contiguous coverage even with interleaved tool updates.
for (let i = start - 1; i >= 0; i--) {
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
}
const selected = projectedAll.slice(start);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
endSeq: bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
export function selectReferenceProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (!bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (projectedAll.length === 0) {
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq >= bounds.minSeq,
hasNewer: cursorSeq < bounds.maxSeq,
};
}
if (input.direction === "before") {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq > bounds.minSeq,
hasNewer: cursorSeq <= bounds.maxSeq,
};
}
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
return selectProjectedEntriesTail(projectedAll, limit, bounds);
}
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
const selected = selectProjectedEntriesAfter({
entries: projectedAll,
startSeq,
maxSeq: bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
};
}
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
if (endSeq < bounds.minSeq) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: endSeq < bounds.maxSeq,
};
}
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
return {
entries: selected.entries,
startSeq: selected.startSeq,
endSeq,
hasOlder: selected.hasOlder,
hasNewer: endSeq < bounds.maxSeq,
};
}
// Existing selection fixtures exercise the retained owner through its public API.
export function selectProjectedTimelinePage(
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0],
) {
const projection = new TimelineProjection(input.rows);
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
const page = projection.selectPage({ ...input, bounds });
return {
...page,
entries: page.entries.map((row) => {
const { seq: _seq, ...entry } = row as ProjectedTimelineRow;
return entry;
}),
};
}
@@ -1,9 +1,9 @@
import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import { describe, expect, test } from "vitest";
import type { AgentTimelineRow } from "./agent-manager.js";
import {
projectTimelineRows,
selectProjectedTimelinePage,
selectTimelineWindowByProjectedLimit,
} from "./timeline-projection.js";
@@ -295,43 +295,12 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
/** A retained item. `seq` is its latest source event, not its display anchor. */
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
interface ProjectedTimelinePageOptions {
bounds: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}
/** Owns projected state; source events are consumed, never retained. */
export class TimelineProjection {
private readonly rows: ProjectedTimelineRow[] = [];
private readonly identities = new Map<string, number>();
private lastAssistantRowIndex: number | null = null;
// Disjoint source-coverage spans in display order. A tail starting inside a
// span must include its first anchor to cover interleaved lifecycle updates.
private readonly spans: { start: number; endSeq: number }[] = [];
private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = [];
constructor(rows: readonly AgentTimelineRow[] = []) {
for (const row of rows) this.appendProjected(row);
for (const [rowIndex, row] of this.rows.entries()) {
for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex });
}
this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq);
}
append(row: AgentTimelineRow): void {
const rowIndex = this.appendProjected(row);
const last = this.sourceRanges.at(-1);
if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) {
last.endSeq = row.seq;
} else {
this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex });
}
}
private appendProjected(row: AgentTimelineRow): number {
const entry = makeCanonicalEntries([row])[0];
const identity = timelineItemIdentity(row.item);
const index = identity === null ? undefined : this.identities.get(identity);
@@ -339,8 +308,7 @@ export class TimelineProjection {
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
if (merged && index !== undefined) {
this.rows[index] = { ...merged, seq: merged.seqEnd };
this.extendSpan(index);
return index;
return;
}
const previous = this.rows.at(-1);
const adjacent = previous
@@ -349,159 +317,16 @@ export class TimelineProjection {
if (previous && adjacent.length === 1) {
const mergedText = adjacent[0];
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
this.extendSpan(this.rows.length - 1);
return this.rows.length - 1;
return;
}
if (identity !== null) this.identities.set(identity, this.rows.length);
if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length;
this.rows.push({ ...entry, seq: entry.seqEnd });
const last = this.spans.at(-1);
if (last && last.endSeq >= entry.seqStart) {
last.endSeq = Math.max(last.endSeq, entry.seqEnd);
} else {
this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd });
}
return this.rows.length - 1;
}
private spanIndex(rowIndex: number): number {
let low = 0;
let high = this.spans.length;
while (low + 1 < high) {
const middle = (low + high) >>> 1;
if (this.spans[middle].start <= rowIndex) low = middle;
else high = middle;
}
return low;
}
private extendSpan(rowIndex: number): void {
const index = this.spanIndex(rowIndex);
const span = this.spans[index];
span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd);
let end = index + 1;
while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) {
span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq);
end++;
}
this.spans.splice(index + 1, end - index - 1);
}
getRows(): ProjectedTimelineRow[] {
return this.rows.map((row) => ({ ...row }));
}
selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
if (this.rows.length === 0) {
const cursor =
input.cursorSeq ??
(input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1);
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder:
input.direction === "after"
? cursor >= input.bounds.minSeq
: input.direction === "before" && cursor > input.bounds.minSeq,
hasNewer:
input.direction === "after"
? cursor < input.bounds.maxSeq
: input.direction === "before" && cursor <= input.bounds.maxSeq,
};
}
switch (input.direction) {
case "tail":
return this.selectTail(input);
case "after":
return this.selectAfter(input);
case "before":
return this.selectBefore(input);
}
}
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const limit = Math.max(0, Math.floor(input.limit ?? 0));
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
const start = this.spans[this.spanIndex(requestedStart)].start;
return {
entries: this.rows.slice(start),
startSeq: this.rows[start].seqStart,
endSeq: input.bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const startSeq = Math.max(
input.bounds.minSeq,
(input.cursorSeq ?? input.bounds.minSeq - 1) + 1,
);
let low = 0;
let high = this.sourceRanges.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1;
else high = middle;
}
const indexes = new Set<number>();
const limit = Math.max(0, Math.floor(input.limit ?? 0));
for (let i = low; i < this.sourceRanges.length; i++) {
const range = this.sourceRanges[i];
if (range.startSeq > input.bounds.maxSeq) break;
indexes.add(range.rowIndex);
if (limit > 0 && indexes.size === limit) break;
}
const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]);
const selected = selectProjectedEntriesAfter({
entries,
startSeq,
maxSeq: input.bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > input.bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq,
};
}
private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1);
if (endSeq >= input.bounds.minSeq) {
let low = 0;
let high = this.rows.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (this.rows[middle].seqStart <= endSeq) low = middle + 1;
else high = middle;
}
const limit = Math.max(0, Math.floor(input.limit ?? 0));
const start = limit === 0 ? 0 : Math.max(0, low - limit);
return {
entries: this.rows.slice(start, low),
startSeq: this.rows[start]?.seqStart ?? null,
endSeq,
hasOlder: start > 0,
hasNewer: endSeq < input.bounds.maxSeq,
};
}
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
}
getLastItem(seq: number): AgentTimelineItem | null {
const last = this.sourceRanges.at(-1);
return last?.endSeq === seq ? this.rows[last.rowIndex].item : null;
}
getLastAssistantMessage(): string | null {
const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex];
return row?.item.type === "assistant_message" ? row.item.text : null;
}
enrichSubmittedUserMessage(
clientMessageId: string,
providerMessageId: string,
@@ -619,6 +444,21 @@ export function selectTimelineWindowByProjectedLimit(input: {
};
}
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
const entries = makeCanonicalEntries(rows);
return {
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
};
}
function firstSourceSeqInRange(
entry: TimelineProjectionEntry,
startSeq: number,
@@ -675,6 +515,142 @@ function selectProjectedEntriesAfter(input: {
};
}
function selectProjectedEntriesBefore(input: {
entries: readonly TimelineProjectionEntry[];
endSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
// Older history follows projected display order. Lifecycle updates can move an
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
// projected item on exactly one backward page.
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
const selected =
input.limit === 0 || input.limit >= eligible.length
? eligible
: eligible.slice(eligible.length - input.limit);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
hasOlder: selected.length < eligible.length,
};
}
function selectProjectedEntriesTail(
projectedAll: TimelineProjectionEntry[],
limit: number,
bounds: { minSeq: number; maxSeq: number },
): ProjectedTimelinePageSelection {
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
// Include earlier display anchors with updates in the selected source window,
// so a tail certifies contiguous coverage even with interleaved tool updates.
for (let i = start - 1; i >= 0; i--) {
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
}
const selected = projectedAll.slice(start);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
endSeq: bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (!bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (projectedAll.length === 0) {
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq >= bounds.minSeq,
hasNewer: cursorSeq < bounds.maxSeq,
};
}
if (input.direction === "before") {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq > bounds.minSeq,
hasNewer: cursorSeq <= bounds.maxSeq,
};
}
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
return selectProjectedEntriesTail(projectedAll, limit, bounds);
}
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
const selected = selectProjectedEntriesAfter({
entries: projectedAll,
startSeq,
maxSeq: bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
};
}
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
if (endSeq < bounds.minSeq) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: endSeq < bounds.maxSeq,
};
}
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
return {
entries: selected.entries,
startSeq: selected.startSeq,
endSeq,
hasOlder: selected.hasOlder,
hasNewer: endSeq < bounds.maxSeq,
};
}
/**
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
* Used by callers that only have items in hand (e.g. MCP tools reading
@@ -1,147 +0,0 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
});
afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it.each(["EPERM", "EACCES", "EBUSY"])(
"commits a completed receipt after transient Windows %s replace failures",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
let failures = 3;
let elapsed = 0;
await writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
elapsed += milliseconds;
},
rename: async (source, destination) => {
if (failures-- > 0) {
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
state: "pending",
});
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
throw Object.assign(new Error("destination held open"), { code });
}
await fs.rename(source, destination);
},
},
);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
expect(elapsed).toBeGreaterThan(0);
},
);
it.each(["EPERM", "EACCES", "EBUSY"])(
"preserves the previous file and removes the temp file when Windows %s persists",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("destination held open"), { code });
const waits: number[] = [];
let elapsed = 0;
const attempts: number[] = [];
await expect(
writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
elapsed += milliseconds;
},
rename: async () => {
attempts.push(elapsed);
throw error;
},
},
),
).rejects.toBe(error);
expect(elapsed).toBe(1000);
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
expect(Math.max(...waits)).toBe(100);
expect(attempts.every((time) => time < 1000)).toBe(true);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
},
);
it.each([
["linux", "EPERM"],
["darwin", "EACCES"],
["linux", "EBUSY"],
["win32", "ENOENT"],
["win32", "ENOSPC"],
["win32", "EXDEV"],
] as const)("does not retry %s %s failures", async (platform, code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("rename failed"), { code });
let attempts = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform,
now: () => 0,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
attempts++;
throw error;
},
}),
).rejects.toBe(error);
expect(attempts).toBe(1);
expect(waits).toEqual([]);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
});
it("does not retry after a rename consumes the Windows retry budget", async () => {
const file = path.join(directory, "receipt.json");
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
let elapsed = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
elapsed += 1000;
throw error;
},
}),
).rejects.toBe(error);
expect(waits).toEqual([]);
expect(await fs.readdir(directory)).toEqual([]);
});
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
const file = path.join(directory, "nested", "file");
const bytes = Buffer.from([0, 255, 128, 1]);
await writeFileAtomic(file, bytes);
expect(await fs.readFile(file)).toEqual(bytes);
await writeJsonFileAtomic(file, { state: "completed" });
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
});
+3 -54
View File
@@ -1,26 +1,10 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
interface AtomicFileRuntime {
platform: NodeJS.Platform;
rename: (source: string, destination: string) => Promise<void>;
now: () => number;
sleep: (milliseconds: number) => Promise<void>;
}
const runtime: AtomicFileRuntime = {
platform: process.platform,
rename: fs.rename,
now: () => performance.now(),
sleep,
};
export async function writeFileAtomic(
filePath: string,
data: string | NodeJS.ArrayBufferView,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = path.join(
@@ -29,48 +13,13 @@ export async function writeFileAtomic(
);
try {
await fs.writeFile(tempPath, data, "utf8");
await replaceFile(tempPath, filePath, operations);
await fs.rename(tempPath, filePath);
} catch (error) {
await fs.rm(tempPath, { force: true });
throw error;
}
}
async function replaceFile(
source: string,
destination: string,
operations: AtomicFileRuntime,
): Promise<void> {
// Windows readers/scanners can briefly deny replacement. Yield with backoff,
// but keep interactive persistence failures bounded to a one-second retry budget.
const deadline = operations.now() + 1000;
let delay = 10;
for (;;) {
try {
await operations.rename(source, destination);
return;
} catch (error) {
if (
operations.platform !== "win32" ||
!(error instanceof Error) ||
!("code" in error) ||
!["EPERM", "EACCES", "EBUSY"].includes(String(error.code))
) {
throw error;
}
const remaining = deadline - operations.now();
if (remaining <= 0) throw error;
await operations.sleep(Math.min(delay, remaining));
if (operations.now() >= deadline) throw error;
delay = Math.min(delay * 2, 100);
}
}
}
export async function writeJsonFileAtomic(
filePath: string,
value: unknown,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations);
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
}
+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 };
}
}
@@ -87,53 +87,3 @@ test("logs --follow preserves the same assistant text as one-shot logs", async (
await daemon.close();
}
}, 60_000);
test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq };
for (let index = 0; index < 451; index++) {
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: String(index),
});
}
const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor });
expect(first.entries).toHaveLength(200);
expect(first.hasNewer).toBe(true);
expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 });
const second = await client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: first.endCursor!,
});
const third = await client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: second.endCursor!,
});
expect(second.entries).toHaveLength(200);
expect(third.entries).toHaveLength(51);
expect(third.hasNewer).toBe(false);
expect(
[...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item),
).toEqual(
Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })),
);
const allAfter = await client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor,
limit: 0,
});
const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
expect(allAfter.entries).toHaveLength(451);
expect(allAfter.hasNewer).toBe(false);
expect(allTail.entries).toHaveLength(initial.entries.length + 451);
} finally {
await client.close();
await daemon.close();
}
}, 60_000);
@@ -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: {},
+13 -12
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":
@@ -7787,6 +7786,8 @@ export class Session {
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection = "projected" as const;
const requestedLimit = msg.limit;
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
@@ -7805,7 +7806,7 @@ export class Session {
const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit: msg.limit,
limit: pageLimit,
});
const selectedTimeline = {
timeline: fetchedControlTimeline,
@@ -43,7 +43,7 @@ function makeSubsystem(overrides: {
listProviderAvailability?: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
hubRelationships?: HubRelationshipManagement;
reloadConfig?: () => DaemonConfigReloadResult;
reloadConfig?: () => Promise<DaemonConfigReloadResult>;
}) {
const emitted: SessionOutboundMessage[] = [];
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
@@ -67,7 +67,7 @@ function makeSubsystem(overrides: {
hubRelationships: overrides.hubRelationships,
reloadConfig:
overrides.reloadConfig ??
(() => ({
(async () => ({
appliedPaths: [],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -78,16 +78,16 @@ function makeSubsystem(overrides: {
}
describe("DaemonSession", () => {
test("config reload returns the daemon-owned classification", () => {
test("config reload returns the daemon-owned classification", async () => {
const { subsystem, emitted } = makeSubsystem({
reloadConfig: () => ({
reloadConfig: async () => ({
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: ["app.baseUrl"],
}),
});
subsystem.handleConfigReloadRequest({
await subsystem.handleConfigReloadRequest({
type: "daemon.config.reload.request",
requestId: "reload-1",
});
@@ -105,14 +105,14 @@ describe("DaemonSession", () => {
]);
});
test("config reload failures return a correlated RPC error", () => {
test("config reload failures return a correlated RPC error", async () => {
const { subsystem, emitted } = makeSubsystem({
reloadConfig: () => {
reloadConfig: async () => {
throw new Error("Invalid config");
},
});
subsystem.handleConfigReloadRequest({
await subsystem.handleConfigReloadRequest({
type: "daemon.config.reload.request",
requestId: "reload-2",
});
@@ -53,7 +53,7 @@ export interface DaemonSessionOptions {
getObservationMetrics?: () => Record<string, number>;
logger: pino.Logger;
hubRelationships?: HubRelationshipManagement;
reloadConfig: () => DaemonConfigReloadResult;
reloadConfig: () => Promise<DaemonConfigReloadResult>;
}
/**
@@ -79,7 +79,7 @@ export class DaemonSession {
private readonly logger: pino.Logger;
private readonly selfUpdate: DaemonSelfUpdateSessionController;
private readonly hubRelationships: HubRelationshipManagement | null;
private readonly reloadConfig: () => DaemonConfigReloadResult;
private readonly reloadConfig: () => Promise<DaemonConfigReloadResult>;
constructor(options: DaemonSessionOptions) {
this.host = options.host;
@@ -252,13 +252,13 @@ export class DaemonSession {
}
}
handleConfigReloadRequest(
async handleConfigReloadRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.config.reload.request" }>,
): void {
): Promise<void> {
try {
this.host.emit({
type: "daemon.config.reload.response",
payload: { requestId: msg.requestId, ...this.reloadConfig() },
payload: { requestId: msg.requestId, ...(await this.reloadConfig()) },
});
} catch (error) {
this.logger.error({ err: error }, "Failed to reload daemon config");
@@ -1,3 +1,5 @@
import { DaemonConfigStore } from "../../daemon-config-store.js";
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -82,6 +84,7 @@ interface BuildOptions {
spawnThrows?: string;
runtimeStore?: Pick<WorkspaceRuntimeStore, "get">;
automationError?: Error;
getGlobalServicePorts?: () => Promise<PaseoServicePortAllocation | undefined>;
}
function buildService(options: BuildOptions = {}) {
@@ -111,6 +114,7 @@ function buildService(options: BuildOptions = {}) {
getDaemonTcpHost: () => "127.0.0.1",
serviceProxyPublicBaseUrl: null,
resolveScriptHealth: null,
getGlobalServicePorts: options.getGlobalServicePorts ?? (async () => undefined),
logger,
emit: (message) => emitted.push(message),
publishStatusUpdate: (message) => published.push(message),
@@ -152,6 +156,24 @@ afterEach(() => {
}
});
test("reads external service-port edits at the next launch without construction IO", async () => {
const home = mkdtempSync(join(tmpdir(), "workspace-service-ports-"));
tempDirs.push(home);
const store = new DaemonConfigStore(home, {
providers: {},
mcp: { enabled: true, injectIntoAgents: false },
});
const { service, spawnCalls } = buildService({
getGlobalServicePorts: () => store.readServicePorts(),
});
writeFileSync(
join(home, "config.json"),
JSON.stringify({ worktrees: { servicePorts: { range: "9000-9999" } } }),
);
await service.start(request);
expect(spawnCalls.map((call) => call.globalServicePorts)).toEqual([{ range: "9000-9999" }]);
});
describe("buildSnapshot", () => {
test("returns no scripts when the service proxy is unavailable", async () => {
const { service } = buildService({ serviceProxy: null });
@@ -62,7 +62,7 @@ export function createWorkspaceScriptsService(deps: {
getDaemonTcpHost: (() => string | null) | null;
serviceProxyPublicBaseUrl: string | null;
resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
globalServicePorts?: PaseoServicePortAllocation;
getGlobalServicePorts: () => Promise<PaseoServicePortAllocation | undefined>;
logger: pino.Logger;
emit: (message: SessionOutboundMessage) => void;
// Script status is daemon state, so it reaches every client, not only the requester.
@@ -81,7 +81,7 @@ export function createWorkspaceScriptsService(deps: {
getDaemonTcpHost,
serviceProxyPublicBaseUrl,
resolveScriptHealth,
globalServicePorts,
getGlobalServicePorts,
logger,
emit,
publishStatusUpdate,
@@ -172,6 +172,7 @@ export function createWorkspaceScriptsService(deps: {
async function launchProcess(input: { workspaceId: string; scriptName: string }) {
const available = requireAvailable();
const globalServicePorts = await getGlobalServicePorts();
const workspace = await getWorkspace(input.workspaceId);
await assertAutomationAllowed(workspace.workspaceId);
const project = await projectRegistry.get(workspace.projectId);
@@ -80,7 +80,7 @@ describe("applyTerminalAgentHookSetting", () => {
expect(existsSync(paths.opencode)).toBe(true);
});
it("installs on enable and removes hooks on disable when toggled live", () => {
it("installs on enable and removes hooks on disable when toggled live", async () => {
const root = createTempDir("paseo-hook-setting-");
const store = createStore(createTempDir("paseo-hook-setting-home-"), false);
const paths = hookPaths(root);
@@ -88,11 +88,11 @@ describe("applyTerminalAgentHookSetting", () => {
applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) });
expect(existsSync(paths.opencode)).toBe(false);
store.patch({ enableTerminalAgentHooks: true });
await store.patch({ enableTerminalAgentHooks: true });
expect(existsSync(paths.codex)).toBe(true);
expect(existsSync(paths.opencode)).toBe(true);
store.patch({ enableTerminalAgentHooks: false });
await store.patch({ enableTerminalAgentHooks: false });
expect(existsSync(paths.opencode)).toBe(false);
});
});