From 10b87ebeabfd354f0fb4fe84a990f54e8216b9bb Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:59:13 +0530 Subject: [PATCH 1/4] style: apply oxlint require-readable-spacing autofix Mechanical blank-line restoration so later semantic lint fixes stay reviewable. Co-Authored-By: Grok 4.6 via Cursor Co-authored-by: Cursor --- bin/run-entrypoint.js | 3 + scripts/dev-state.ts | 19 +++ scripts/dev.ts | 2 + scripts/fix-node-pty-permissions.mjs | 2 + scripts/generate-config-schema.ts | 1 + scripts/qa/host/host-styles.ts | 2 + scripts/qa/host/main.ts | 31 ++++ scripts/qa/run.ts | 92 ++++++++++ scripts/qa/scenario.ts | 8 + src/agents-skill.test.ts | 2 + src/agents-skill.ts | 9 + src/apply-patch.test.ts | 15 ++ src/apply-patch.ts | 63 +++++++ src/artifact-destination-darwin.ts | 33 ++++ src/artifact-destination-windows.ts | 14 ++ src/artifact-destination.ts | 6 + src/artifact-download.test.ts | 27 +++ src/artifact-path-windows.ts | 7 + src/artifact-tools.ts | 43 +++++ src/bin-launcher.test.ts | 8 + src/cli-config.test.ts | 2 + src/cli-show-changes.test.ts | 10 ++ src/cli-workspace.test.ts | 7 + src/cli-workspace.ts | 5 + src/cli-worktrees.test.ts | 3 + src/cli.test.ts | 30 ++++ src/cli.ts | 180 ++++++++++++++++++++ src/code-mode-executions.test.ts | 3 + src/code-mode-executions.ts | 26 +++ src/code-mode-registry.ts | 5 + src/config-migration.ts | 3 + src/config-schema.test.ts | 5 + src/config-schema.ts | 3 + src/config.test.ts | 2 + src/config.ts | 11 ++ src/db/client.ts | 1 + src/db/migrations.ts | 17 ++ src/db/schema.ts | 9 + src/dev-state.test.ts | 5 + src/git-worktrees.test.ts | 12 ++ src/git-worktrees.ts | 48 +++++- src/git.ts | 4 + src/incoming-artifacts.test.ts | 18 ++ src/incoming-artifacts.ts | 65 +++++++ src/local-agent-acp.test.ts | 87 ++++++++++ src/local-agent-acp.ts | 134 +++++++++++++++ src/local-agent-adapters.test.ts | 1 + src/local-agent-adapters.ts | 3 + src/local-agent-availability.test.ts | 10 ++ src/local-agent-availability.ts | 13 ++ src/local-agent-cancellation.ts | 2 + src/local-agent-catalog.ts | 5 + src/local-agent-claude.test.ts | 33 ++++ src/local-agent-claude.ts | 44 +++++ src/local-agent-client.ts | 98 +++++++++++ src/local-agent-codex.test.ts | 22 +++ src/local-agent-codex.ts | 98 +++++++++++ src/local-agent-command.ts | 10 ++ src/local-agent-config.test.ts | 12 ++ src/local-agent-config.ts | 14 ++ src/local-agent-daemon-lifecycle.test.ts | 1 + src/local-agent-daemon-lifecycle.ts | 27 +++ src/local-agent-daemon-main.ts | 16 ++ src/local-agent-daemon-protocol.test.ts | 5 + src/local-agent-daemon-protocol.ts | 94 ++++++++++ src/local-agent-daemon.test.ts | 131 +++++++++++++- src/local-agent-daemon.ts | 58 +++++++ src/local-agent-errors.ts | 20 +++ src/local-agent-events.ts | 21 +++ src/local-agent-grok.test.ts | 1 + src/local-agent-grok.ts | 33 ++++ src/local-agent-id.ts | 1 + src/local-agent-manager.test.ts | 133 +++++++++++++++ src/local-agent-manager.ts | 148 ++++++++++++++++ src/local-agent-opencode-server.ts | 19 +++ src/local-agent-opencode-v2.test.ts | 38 +++++ src/local-agent-opencode-v2.ts | 37 ++++ src/local-agent-opencode-version.ts | 9 + src/local-agent-opencode.test.ts | 45 +++++ src/local-agent-opencode.ts | 57 +++++++ src/local-agent-path.ts | 3 + src/local-agent-pi-rpc.test.ts | 4 + src/local-agent-pi-rpc.ts | 30 ++++ src/local-agent-pi-sandbox.test.ts | 10 ++ src/local-agent-pi-sandbox.ts | 67 ++++++++ src/local-agent-pi.test.ts | 52 ++++++ src/local-agent-pi.ts | 78 +++++++++ src/local-agent-presentation.test.ts | 4 + src/local-agent-presentation.ts | 17 ++ src/local-agent-profiles.test.ts | 3 + src/local-agent-profiles.ts | 13 ++ src/local-agent-provider-registry.ts | 5 + src/local-agent-provider.ts | 3 + src/local-agent-runtime-pool.ts | 76 +++++++++ src/local-agent-runtime.test.ts | 49 ++++++ src/local-agent-runtime.ts | 1 + src/local-agent-store.test.ts | 19 +++ src/local-agent-store.ts | 37 ++++ src/local-agent-targets.test.ts | 2 + src/local-agent-targets.ts | 25 +++ src/local-agent-worktree.test.ts | 6 + src/local-agent-worktree.ts | 6 + src/logger.test.ts | 4 + src/logger.ts | 8 + src/mcp-modern-server.test.ts | 24 +++ src/mcp-modern-server.ts | 6 + src/oauth-provider.ts | 22 +++ src/oauth-refresh-grace.test.ts | 14 ++ src/oauth-store.test.ts | 24 +++ src/oauth-store.ts | 6 + src/onboarding.test.ts | 2 + src/onboarding.ts | 8 + src/pi-tools.ts | 3 + src/process-platform.ts | 4 + src/process-sessions.test.ts | 35 ++++ src/process-sessions.ts | 43 +++++ src/request-meta.ts | 1 + src/review-checkpoints.test.ts | 22 +++ src/review-checkpoints.ts | 48 ++++++ src/roots.test.ts | 1 + src/roots.ts | 9 + src/server-oauth.test.ts | 9 + src/server-shutdown.test.ts | 5 + src/server-shutdown.ts | 1 + src/server.test.ts | 148 +++++++++++++++- src/server.ts | 97 +++++++++++ src/skills.test.ts | 16 ++ src/skills.ts | 17 ++ src/test-support/config.ts | 1 + src/tool-surfaces/claude.ts | 7 + src/tool-surfaces/codex.ts | 12 ++ src/tool-surfaces/detect.ts | 3 + src/tool-surfaces/index.ts | 1 + src/tool-surfaces/shared.ts | 4 + src/ui/card-types.test.ts | 1 + src/ui/card-types.ts | 2 + src/ui/icons.ts | 1 + src/ui/patch-display.ts | 13 ++ src/ui/review-diff.tsx | 4 + src/ui/review-payload.tsx | 7 + src/ui/tool-result.test.ts | 4 + src/ui/tool-result.ts | 25 +++ src/ui/web/diff-tree.ts | 9 + src/ui/web/main.tsx | 40 +++++ src/ui/workspace-app.tsx | 97 +++++++++++ src/user-config.test.ts | 6 + src/user-config.ts | 20 +++ src/workflow-cli.test.ts | 21 +++ src/workflow-cli.ts | 61 +++++++ src/workflow-context.ts | 16 ++ src/workflow-manager.test.ts | 41 +++++ src/workflow-manager.ts | 134 ++++++++++++++- src/workflow-runner-child.ts | 64 +++++++ src/workflow-runner.test.ts | 19 ++- src/workflow-runner.ts | 58 +++++++ src/workflow-schema.test.ts | 1 + src/workflow-schema.ts | 10 ++ src/workflow-script.test.ts | 4 + src/workflow-script.ts | 27 +++ src/workflow-store.ts | 7 + src/workflow-types.ts | 12 ++ src/workflow-workspaces.ts | 10 ++ src/workspace-activity-journal.test.ts | 9 + src/workspace-activity-journal.ts | 21 +++ src/workspace-activity-store.ts | 11 ++ src/workspace-activity.ts | 5 + src/workspace-conversation.test.ts | 30 ++++ src/workspace-store.test.ts | 2 + src/workspace-store.ts | 7 + src/workspace-web.test.ts | 6 + src/workspace-web.ts | 35 ++++ src/workspaces.test.ts | 17 ++ src/workspaces.ts | 77 +++++++++ src/worktree-prune.ts | 5 + test/agents-replay/conformance.ts | 42 +++++ test/agents-replay/http-shim.ts | 21 +++ test/agents-replay/providers.ts | 18 ++ test/agents-replay/replayer.ts | 37 ++++ test/agents-replay/shim.ts | 29 ++++ test/agents-replay/transcript.ts | 9 + test/package-install-smoke.test.ts | 8 + website/islands/DelegationDiagram.tsx | 4 + website/islands/Fit.tsx | 2 + website/islands/WorkflowRun.tsx | 7 + website/landing/Icon.astro | 1 + website/landing/Shell.astro | 4 + website/landing/content.ts | 1 + website/landing/home/ChatMock.astro | 11 +- website/landing/home/HeroStage.astro | 5 + website/landing/home/PixelField.astro | 7 + website/landing/home/RoutingMap.astro | 8 + website/landing/home/WorkflowStage.astro | 12 ++ website/landing/parked/TerminalScenes.astro | 6 + website/logos/index.ts | 2 + website/ui/Logos.astro | 2 + 195 files changed, 4622 insertions(+), 9 deletions(-) diff --git a/bin/run-entrypoint.js b/bin/run-entrypoint.js index f32983a1c..b9eee6fb4 100644 --- a/bin/run-entrypoint.js +++ b/bin/run-entrypoint.js @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; export async function runEntrypoint(sourcePath, distPath) { const sourceUrl = new URL(sourcePath, import.meta.url); + if (existsSync(fileURLToPath(sourceUrl))) { try { await import("tsx/esm"); @@ -12,7 +13,9 @@ export async function runEntrypoint(sourcePath, distPath) { { cause: error }, ); } + await import(sourceUrl.href); + return; } diff --git a/scripts/dev-state.ts b/scripts/dev-state.ts index 4dc92ebf8..2fa4c1f6d 100644 --- a/scripts/dev-state.ts +++ b/scripts/dev-state.ts @@ -24,6 +24,7 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}): const sourceConfigDir = resolve( expandHomePath(process.env.DEVSPACE_CONFIG_DIR ?? join(homedir(), ".devspace")), ); + if (isWithin(devRoot, sourceConfigDir)) { throw new Error("Refusing to seed development state from this checkout's own .devspace-dev directory."); } @@ -53,10 +54,12 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}): stateDir: devStateDir, }, }; + const localConfigPath = join(stagingConfigDir, "config.jsonc"); await writeFile(localConfigPath, `${JSON.stringify(localConfig, null, 2)}\n`, { mode: 0o600 }); const sourceAuthPath = join(sourceConfigDir, "auth.json"); + if (existsSync(sourceAuthPath)) { const localAuthPath = join(stagingConfigDir, "auth.json"); await cp(sourceAuthPath, localAuthPath); @@ -67,6 +70,7 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}): for (const directory of ["skills", "agents"] as const) { const sourceDirectory = join(sourceConfigDir, directory); + if (existsSync(sourceDirectory)) { await cp(sourceDirectory, join(stagingConfigDir, directory), { recursive: true }); } @@ -87,22 +91,26 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}): async function promoteStagedState(stagingRoot: string, reset: boolean): Promise { if (!reset || !existsSync(devRoot)) { await rename(stagingRoot, devRoot); + return; } const previousRoot = `${devRoot}.previous-${process.pid}-${Date.now()}`; await rename(devRoot, previousRoot); + try { await rename(stagingRoot, devRoot); } catch (error) { await rename(previousRoot, devRoot); throw error; } + await rm(previousRoot, { recursive: true, force: true }); } function isWithin(parent: string, candidate: string): boolean { const pathFromParent = relative(parent, candidate); + return pathFromParent === "" || (pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) @@ -111,19 +119,24 @@ function isWithin(parent: string, candidate: string): boolean { async function readSourceConfig(configDir: string): Promise<{ config: DevspaceConfig }> { const configPath = join(configDir, "config.jsonc"); + if (existsSync(configPath)) { const source = await readFile(configPath, "utf8"); const errors: ParseError[] = []; const value = parse(source, errors, { allowTrailingComma: true }); + if (errors.length > 0) { throw new Error(`Unable to parse ${configPath}.`); } + return { config: devspaceConfigSchema.parse(value) }; } const legacyPath = join(configDir, "config.json"); + if (existsSync(legacyPath)) { const value = JSON.parse(await readFile(legacyPath, "utf8")) as unknown; + return { config: migrateLegacyConfig(value) }; } @@ -133,6 +146,7 @@ async function readSourceConfig(configDir: string): Promise<{ config: DevspaceCo async function backupDatabase(sourcePath: string, destinationPath: string): Promise { await mkdir(dirname(destinationPath), { recursive: true }); const source = new Database(sourcePath, { readonly: true, fileMustExist: true }); + try { await source.backup(destinationPath); await chmod(destinationPath, 0o600); @@ -143,14 +157,19 @@ async function backupDatabase(sourcePath: string, destinationPath: string): Prom async function main(): Promise { const command = process.argv[2]; + if (command === "seed") { await seedDevState(); + return; } + if (command === "reset") { await seedDevState({ reset: true }); + return; } + throw new Error("Usage: dev-state "); } diff --git a/scripts/dev.ts b/scripts/dev.ts index b5326531c..a8f2ef06e 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -26,8 +26,10 @@ if (!hasConfig) { child.on("exit", (code, signal) => { if (signal) { process.kill(process.pid, signal); + return; } + process.exitCode = code ?? 1; }); } diff --git a/scripts/fix-node-pty-permissions.mjs b/scripts/fix-node-pty-permissions.mjs index 1990bf444..04fdd8442 100644 --- a/scripts/fix-node-pty-permissions.mjs +++ b/scripts/fix-node-pty-permissions.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; if (process.platform === "darwin") { const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + for (const architecture of ["arm64", "x64"]) { const helper = resolve( projectRoot, @@ -13,6 +14,7 @@ if (process.platform === "darwin") { `darwin-${architecture}`, "spawn-helper", ); + try { await chmod(helper, 0o755); } catch (error) { diff --git a/scripts/generate-config-schema.ts b/scripts/generate-config-schema.ts index 324b611b6..ceb9c7172 100644 --- a/scripts/generate-config-schema.ts +++ b/scripts/generate-config-schema.ts @@ -6,4 +6,5 @@ import { const outputPath = new URL("../schema/v1/devspace.schema.json", import.meta.url); mkdirSync(new URL(".", outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`); diff --git a/scripts/qa/host/host-styles.ts b/scripts/qa/host/host-styles.ts index 0c958981c..d2cb86404 100644 --- a/scripts/qa/host/host-styles.ts +++ b/scripts/qa/host/host-styles.ts @@ -2,7 +2,9 @@ import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; // ext-apps' package root re-exports its spec types extensionlessly, which NodeNext cannot follow. type HostContext = NonNullable[3]>["hostContext"]>; + type McpUiTheme = NonNullable; + type McpUiHostStyles = NonNullable; // Representative host palettes using the spec's style variable names. Real diff --git a/scripts/qa/host/main.ts b/scripts/qa/host/main.ts index ed6b9238a..8d4a98b55 100644 --- a/scripts/qa/host/main.ts +++ b/scripts/qa/host/main.ts @@ -20,20 +20,28 @@ declare global { } const CARD_TIMEOUT_MS = 15_000; + // Cards keep resizing while fonts and diff highlighting load; wait for a quiet period. const SIZE_QUIET_MS = 800; const params = new URLSearchParams(location.search); const theme = params.get("theme") === "dark" ? "dark" : "light"; + // A bare host sends only the theme, like hosts that do not implement style variables. const bareHost = params.get("styles") === "none"; + document.documentElement.dataset.theme = theme; + window.__devspaceQa = { settled: false, cards: [], hostEvents: [] }; const run = await (await fetch("/api/run")).json() as QaRun & { sandboxOrigin: string }; + renderSummary(run); + const pending = run.steps.map((step) => renderStep(run, step)); + await Promise.all(pending); + window.__devspaceQa.settled = true; function renderSummary(qaRun: QaRun): void { @@ -59,6 +67,7 @@ async function renderStep(qaRun: QaRun & { sandboxOrigin: string }, step: QaStep details("arguments", JSON.stringify(step.arguments, null, 2)), details("result", step.error ?? JSON.stringify(step.result, null, 2)), ); + if (!ok) section.append(element("ul", { className: "failures" }, ...step.failures.map((failure) => element("li", {}, failure)))); document.getElementById("steps")!.append(section); @@ -82,6 +91,7 @@ async function mountCard( section.append(iframe, callLog); const tool = qaRun.tools.find((item) => item.name === step.tool); + const bridge = new AppBridge( null, { name: "devspace-qa-host", version: "1.0.0" }, @@ -112,36 +122,51 @@ async function mountCard( headers: { "content-type": "application/json" }, body: JSON.stringify({ step: step.index, name: params.name, arguments: params.arguments, _meta: params._meta }), }); + const call = await response.json() as QaCardCall; hostEvent("tools/call", { name: params.name, error: call.error, isError: call.result?.isError }); + if (call.error || !call.result) throw new Error(call.error ?? "Tool call failed"); + return call.result; }; + bridge.onopenlink = async (params) => { hostEvent("open-link", params); + return {}; }; + bridge.ondownloadfile = async (params) => { hostEvent("download-file", params); + return {}; }; + bridge.onmessage = async (params) => { hostEvent("message", params); + return {}; }; + bridge.onupdatemodelcontext = async (params) => { hostEvent("update-model-context", params); + return {}; }; + bridge.onrequestdisplaymode = async (params) => { hostEvent("request-display-mode", params); + return { mode: "inline" }; }; + bridge.onloggingmessage = (params) => console.log(`[card ${step.index}]`, params.level, params.data); let quietTimer: number | undefined; let finish: () => void = () => undefined; const settled = new Promise((resolveSettled) => { finish = resolveSettled; }); + const markReady = () => { window.clearTimeout(quietTimer); quietTimer = window.setTimeout(() => { @@ -149,12 +174,15 @@ async function mountCard( finish(); }, SIZE_QUIET_MS); }; + bridge.onsizechange = ({ height }) => { if (height === undefined) return; status.height = Math.ceil(height); iframe.style.height = `${status.height}px`; + if (status.state === "initialized") markReady(); }; + bridge.oninitialized = () => { status.state = "initialized"; void bridge.sendToolInput({ arguments: step.arguments }) @@ -181,6 +209,7 @@ async function mountCard( await bridge.connect(new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!)); iframe.src = `${qaRun.sandboxOrigin}/resource?uri=${encodeURIComponent(step.resourceUri!)}`; }; + try { await Promise.race([setup(), settled]); } catch (error) { @@ -199,6 +228,7 @@ function details(label: string, text: string): HTMLElement { function summarize(value: unknown): string { const text = JSON.stringify(value); + return text.length > 160 ? `${text.slice(0, 157)}...` : text; } @@ -209,5 +239,6 @@ function element( ): HTMLElementTagNameMap[K] { const node = Object.assign(document.createElement(tag), props); node.append(...children); + return node; } diff --git a/scripts/qa/run.ts b/scripts/qa/run.ts index b3af991f9..2882c7890 100644 --- a/scripts/qa/run.ts +++ b/scripts/qa/run.ts @@ -50,37 +50,46 @@ async function main(): Promise { const scenario = await loadScenario(name); console.log(`${name}\t${scenario.description}`); } + return; } const checkout = resolve(options.checkout ?? harnessRoot); const names = positionals.length > 0 ? positionals : await listScenarioNames(); const useBrowser = !options["no-browser"]; + if (options.serve && names.length !== 1) { throw new Error("--serve keeps one scenario's server and host running; pass exactly one scenario."); } + if (!["light", "dark"].includes(options.theme ?? "")) throw new Error("--theme must be light or dark."); + if (useBrowser || options.serve) await assertCommand("agent-browser", ["--version"]); if (!options["no-build"]) { // The source server reads the widget from dist/ui, so the target checkout must be rebuilt. await run("pnpm", ["build:app"], { cwd: checkout, stdio: "inherit" }); } + // Each batch builds its own host so parallel QA runs never share output. const batch = `${timestamp()}-${process.pid}`; const hostDistRoot = join(qaStateRoot, "runs", batch, "host"); await buildHost(hostDistRoot); let failed = 0; + for (const name of names) { const scenario = await loadScenario(name); + const outcome = await runScenario(scenario, { checkout, runDir: join(qaStateRoot, "runs", batch, scenario.name), useBrowser, hostDistRoot, }); + if (outcome.failures.length > 0) failed += 1; console.log(`${outcome.failures.length === 0 ? "PASS" : "FAIL"} ${scenario.name} → ${outcome.reportPath}`); + for (const failure of outcome.failures) console.log(` - ${failure}`); if (options.serve) { @@ -92,8 +101,10 @@ async function main(): Promise { console.log("Keeping the server and host running. Press Ctrl-C to stop."); await waitForSignal(); } + await runCleanups(); } + if (failed > 0) process.exitCode = 1; } @@ -111,6 +122,7 @@ async function runScenario( context: { checkout: string; runDir: string; useBrowser: boolean; hostDistRoot: string }, ): Promise { const { checkout, runDir } = context; + const dirs = { home: join(runDir, "home"), config: join(runDir, "config"), @@ -119,6 +131,7 @@ async function runScenario( workspaces: join(runDir, "workspaces"), artifacts: join(runDir, "artifacts"), }; + for (const dir of Object.values(dirs)) await mkdir(dir, { recursive: true }); const fixture = join(dirs.workspaces, "fixture"); await prepareFixture(fixture, scenario, dirs.home); @@ -136,6 +149,7 @@ async function runScenario( const serverLog = join(runDir, "server.log"); const baseUrl = `http://127.0.0.1:${port}`; + // HOME isolation keeps the user's global instructions, skills, and agent CLIs out of the run. const server = await startServer(checkout, serverLog, { ...process.env, @@ -144,10 +158,12 @@ async function runScenario( DEVSPACE_OAUTH_OWNER_TOKEN: ownerToken, ...scenario.env, }); + cleanups.push(() => stopProcess(server)); const accessToken = await issueAccessToken(baseUrl, ownerToken, scenario.host?.clientName ?? "DevSpace QA"); const headers: Record = { authorization: `Bearer ${accessToken}` }; + if (scenario.host?.userAgent) headers["user-agent"] = scenario.host.userAgent; const client = new Client({ name: "devspace-qa-host", version: "1.0.0" }); await client.connect(new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), { @@ -156,6 +172,7 @@ async function runScenario( cleanups.push(() => client.close()); const { tools } = await client.listTools(); + const qaRun: QaRun = { scenario: { name: scenario.name, description: scenario.description }, checkout: await checkoutInfo(checkout), @@ -166,6 +183,7 @@ async function runScenario( resources: {}, cardCalls: [], }; + await replaySteps(client, scenario, qaRun); const transcriptPath = join(runDir, "transcript.json"); @@ -176,6 +194,7 @@ async function runScenario( const failures = qaRun.steps.flatMap((step) => step.failures.map((failure) => `step ${step.index} ${step.tool}: ${failure}`)); let browser: BrowserEvidence | undefined; + if (context.useBrowser) { browser = await captureBrowserEvidence(hostUrl, qaRun, dirs.artifacts, scenario.name); failures.push(...browser.failures); @@ -183,13 +202,16 @@ async function runScenario( const reportPath = join(runDir, "report.md"); await writeFile(reportPath, renderReport(qaRun, { failures, browser, hostUrl, serverLog, transcriptPath })); + return { serverUrl: baseUrl, ownerToken, failures, reportPath, hostUrl, serverLog }; } async function replaySteps(client: Client, scenario: Scenario, qaRun: QaRun): Promise { const variables: Record = { fixture: qaRun.fixture }; + for (const [index, step] of scenario.steps.entries()) { let args: Record; + try { args = resolveTemplates(step.arguments, variables) as Record; } catch (caught) { @@ -204,16 +226,19 @@ async function replaySteps(client: Client, scenario: Scenario, qaRun: QaRun): Pr }); break; } + const startedAt = performance.now(); const failures: string[] = []; let result: CallToolResult | undefined; let error: string | undefined; + try { result = await client.callTool({ name: step.tool, arguments: args }) as CallToolResult; } catch (caught) { error = caught instanceof Error ? caught.message : String(caught); failures.push(`call failed: ${error}`); } + const durationMs = Math.round(performance.now() - startedAt); const text = result ? resultText(result) : ""; @@ -224,15 +249,18 @@ async function replaySteps(client: Client, scenario: Scenario, qaRun: QaRun): Pr if (result) { const expectError = step.expect?.isError ?? false; + if (Boolean(result.isError) !== expectError) { failures.push(expectError ? "expected an error result" : `unexpected error result: ${text.slice(0, 300)}`); } + for (const needle of step.expect?.text ?? []) { if (!text.includes(needle)) failures.push(`result text does not contain ${JSON.stringify(needle)}`); } } const resourceUri = uiResourceUri(qaRun.tools.find((tool) => tool.name === step.tool)); + if (resourceUri && !qaRun.resources[resourceUri]) { try { const read = await client.readResource({ uri: resourceUri }); @@ -246,6 +274,7 @@ async function replaySteps(client: Client, scenario: Scenario, qaRun: QaRun): Pr failures.push(`reading UI resource ${resourceUri} failed: ${caught instanceof Error ? caught.message : String(caught)}`); } } + const cardResource = resourceUri && qaRun.resources[resourceUri] ? resourceUri : undefined; const qaStep: QaStep = { @@ -259,6 +288,7 @@ async function replaySteps(client: Client, scenario: Scenario, qaRun: QaRun): Pr resourceUri: result && !result.isError ? cardResource : undefined, failures, }; + qaRun.steps.push(qaStep); } } @@ -266,6 +296,7 @@ async function replaySteps(client: Client, scenario: Scenario, qaRun: QaRun): Pr function uiResourceUri(tool: Tool | undefined): string | undefined { const meta = tool?._meta as { ui?: { resourceUri?: unknown }; "ui/resourceUri"?: unknown } | undefined; const uri = meta?.ui?.resourceUri ?? meta?.["ui/resourceUri"]; + return typeof uri === "string" ? uri : undefined; } @@ -279,15 +310,18 @@ async function prepareFixture(fixture: string, scenario: Scenario, home: string) await mkdir(fixture, { recursive: true }); await writeFile(join(home, ".gitconfig"), "[user]\n\tname = DevSpace QA\n\temail = qa@devspace.invalid\n[init]\n\tdefaultBranch = main\n"); const gitEnv = { ...process.env, HOME: home }; + for (const [path, content] of Object.entries(scenario.fixture.files)) await writeFixtureFile(fixture, path, content); await run("git", ["init", "-q"], { cwd: fixture, env: gitEnv }); await run("git", ["add", "-A"], { cwd: fixture, env: gitEnv }); await run("git", ["commit", "-qm", "Initial fixture"], { cwd: fixture, env: gitEnv }); + for (const [path, content] of Object.entries(scenario.fixture.dirty ?? {})) await writeFixtureFile(fixture, path, content); } async function writeFixtureFile(root: string, path: string, content: string): Promise { const target = resolve(root, path); + if (!target.startsWith(`${root}/`)) throw new Error(`Fixture path escapes the fixture: ${path}`); await mkdir(dirname(target), { recursive: true }); await writeFile(target, content); @@ -295,35 +329,42 @@ async function writeFixtureFile(root: string, path: string, content: string): Pr async function startServer(checkout: string, logPath: string, env: NodeJS.ProcessEnv): Promise { const log = createWriteStream(logPath); + const child = spawn(process.execPath, [join(checkout, "bin", "devspace.js"), "serve"], { cwd: checkout, env, stdio: ["ignore", "pipe", "pipe"], }); + // Both streams write into one log, so neither may end it; close it when the process exits. log.on("error", (error) => console.warn(`Could not write ${logPath}:`, error)); child.stderr?.on("data", (chunk: Buffer) => log.write(chunk)); child.once("close", () => log.end()); let output = ""; + const listening = new Promise((resolveListening, reject) => { child.stdout?.on("data", (chunk: Buffer) => { log.write(chunk); output += chunk.toString(); + if (output.includes("devspace listening on")) resolveListening(); }); child.once("exit", (code, signal) => { reject(new Error(`DevSpace exited before listening (code ${code}, signal ${signal}). See ${logPath}`)); }); }); + const timeout = new Promise((_, reject) => { setTimeout(() => reject(new Error(`DevSpace did not start within 60s. See ${logPath}`)), 60_000).unref(); }); + try { await Promise.race([listening, timeout]); } catch (error) { await stopProcess(child); throw error; } + return child; } @@ -353,8 +394,10 @@ async function issueAccessToken(baseUrl: string, ownerToken: string, clientName: token_endpoint_auth_method: "none", }), }); + if (registration.status !== 201) throw new Error(`OAuth registration failed: ${await registration.text()}`); const { client_id: clientId } = await registration.json() as { client_id?: string }; + if (!clientId) throw new Error("OAuth registration did not return a client_id"); const approval = await fetch(`${baseUrl}/authorize`, { @@ -372,8 +415,10 @@ async function issueAccessToken(baseUrl: string, ownerToken: string, clientName: }), redirect: "manual", }); + const location = approval.headers.get("location"); const code = location ? new URL(location).searchParams.get("code") : null; + if (approval.status !== 302 || !code) throw new Error(`OAuth approval failed: ${await approval.text()}`); const exchange = await fetch(`${baseUrl}/token`, { @@ -388,9 +433,12 @@ async function issueAccessToken(baseUrl: string, ownerToken: string, clientName: resource, }), }); + if (!exchange.ok) throw new Error(`OAuth token exchange failed: ${await exchange.text()}`); const { access_token: accessToken } = await exchange.json() as { access_token?: string }; + if (!accessToken) throw new Error("OAuth token exchange did not return an access token"); + return accessToken; } @@ -424,51 +472,68 @@ async function startHost( const host = createHttpServer(async (req, res) => { if (!hasExpectedHost(req, hostPort)) { res.writeHead(403).end("Forbidden"); + return; } + const url = new URL(req.url ?? "/", hostOrigin); + try { if (url.pathname === "/" || url.pathname === "/index.html") { res.setHeader("content-type", "text/html; charset=utf-8"); res.end(indexHtml); + return; } + if (url.pathname === "/api/run") { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ ...qaRun, resources: undefined, sandboxOrigin })); + return; } + if (url.pathname === "/api/call" && req.method === "POST") { // The bridge proxies real tool calls such as exec_command, so only the host page may use it. if (req.headers.origin !== hostOrigin || !req.headers["content-type"]?.startsWith("application/json")) { res.writeHead(403).end("Forbidden"); + return; } + const body = JSON.parse(await readBody(req)) as { step: number; name: string; arguments?: Record; _meta?: Record; }; + const cardCall: QaCardCall = { step: body.step, name: body.name, arguments: body.arguments ?? {} }; + try { cardCall.result = await client.callTool({ name: body.name, arguments: body.arguments, _meta: body._meta }) as CallToolResult; } catch (error) { cardCall.error = error instanceof Error ? error.message : String(error); } + qaRun.cardCalls.push(cardCall); await writeFile(transcriptPath, `${JSON.stringify(qaRun, null, 2)}\n`); res.setHeader("content-type", "application/json"); res.end(JSON.stringify(cardCall)); + return; } + if (url.pathname.startsWith("/assets/")) { const asset = resolve(hostDistRoot, `.${url.pathname}`); + if (!asset.startsWith(`${hostDistRoot}/`)) throw new Error("Invalid asset path"); res.setHeader("content-type", asset.endsWith(".js") ? "text/javascript" : asset.endsWith(".css") ? "text/css" : "application/octet-stream"); res.end(await readFile(asset)); + return; } + res.writeHead(404).end("Not found"); } catch (error) { res.writeHead(500).end(String(error)); @@ -478,14 +543,19 @@ async function startHost( const sandbox = createHttpServer((req, res) => { if (!hasExpectedHost(req, sandboxPort)) { res.writeHead(403).end("Forbidden"); + return; } + const url = new URL(req.url ?? "/", sandboxOrigin); const resource = url.pathname === "/resource" ? qaRun.resources[url.searchParams.get("uri") ?? ""] : undefined; + if (!resource) { res.writeHead(404).end("Not found"); + return; } + const csp = (resource.meta.ui as { csp?: SandboxCsp } | undefined)?.csp; res.setHeader("content-security-policy", sandboxCsp(csp, hostOrigin)); res.setHeader("cache-control", "no-store"); @@ -497,6 +567,7 @@ async function startHost( sandbox.listen(sandboxPort, "127.0.0.1"); await Promise.all([once(host, "listening"), once(sandbox, "listening")]); cleanups.push(() => closeHttpServer(host), () => closeHttpServer(sandbox)); + return { url: hostOrigin }; } @@ -516,6 +587,7 @@ interface SandboxCsp { function sandboxCsp(csp: SandboxCsp | undefined, hostOrigin: string): string { const domains = (list?: string[]) => (list ?? []).filter((domain) => !/[;\r\n'" ]/.test(domain)).join(" "); const resources = domains(csp?.resourceDomains); + return [ "default-src 'none'", `script-src 'unsafe-inline' blob: data: ${resources}`.trim(), @@ -550,6 +622,7 @@ async function captureBrowserEvidence( const browser = (...args: string[]) => capture("agent-browser", args, { env }); const failures: string[] = []; const screenshots: string[] = []; + try { // Chrome throttles requestAnimationFrame in off-screen cross-origin iframes, and // MCP Apps report their size from it, so render with every card on screen. @@ -568,6 +641,7 @@ async function captureBrowserEvidence( // --full only matters past the viewport cap, where later cards may not be painted. await browser("screenshot", "--full", page); screenshots.push(page); + for (const step of qaRun.steps.filter((item) => item.resourceUri)) { const shot = join(artifacts, `step-${step.index}-${step.tool}.png`); const selector = `#step-${step.index} .card-frame`; @@ -575,6 +649,7 @@ async function captureBrowserEvidence( await browser("screenshot", selector, shot); screenshots.push(shot); } + await writeFile(join(artifacts, "snapshot.txt"), await browser("snapshot")); const consoleLog = await browser("console"); await writeFile(join(artifacts, "console.txt"), consoleLog); @@ -587,10 +662,13 @@ async function captureBrowserEvidence( if (card.state !== "ready") failures.push(`card for step ${card.step} is ${card.state}${card.error ? `: ${card.error}` : ""}`); else if (card.height < 24) failures.push(`card for step ${card.step} rendered with height ${card.height}px`); } + if (pageErrors.length > 0) failures.push(`browser reported ${pageErrors.length} page error(s) (see errors.json)`); + return { failures, screenshots, cards, consoleLog, errorsLog }; } catch (error) { failures.push(`browser capture failed: ${error instanceof Error ? error.message : String(error)}`); + return { failures, screenshots, cards: [], consoleLog: "", errorsLog: "" }; } finally { await browser("close").catch(() => undefined); @@ -624,38 +702,47 @@ function renderReport( ...qaRun.steps.map((step) => { const card = context.browser?.cards.find((item) => item.step === step.index); const status = step.error ? "call failed" : step.result?.isError ? "error" : step.failures.length ? "failed" : "ok"; + return `| ${step.index} | ${step.label ? `${step.tool} (${step.label})` : step.tool} | ${status}${step.failures.length ? " ✗" : ""} | ${step.durationMs} | ${card ? `${card.state}, ${card.height}px` : step.resourceUri ? "not captured" : "—"} |`; }), "", ]; + if (context.failures.length > 0) { lines.push("## Failures", "", ...context.failures.map((failure) => `- ${failure}`), ""); } + if (context.browser?.screenshots.length) { lines.push("## Screenshots", "", ...context.browser.screenshots.map((path) => `- \`${path}\``), ""); } + if (qaRun.cardCalls.length > 0) { lines.push("## Card-initiated tool calls", "", ...qaRun.cardCalls.map((call) => `- step ${call.step}: ${call.name}${call.error ? ` (failed: ${call.error})` : call.result?.isError ? " (error result)" : ""}`), ""); } + return `${lines.join("\n")}\n`; } async function checkoutInfo(path: string): Promise { const git = (...args: string[]) => capture("git", ["-C", path, ...args]).then((out) => out.trim()).catch(() => undefined); + const [commit, branch, status] = await Promise.all([ git("rev-parse", "HEAD"), git("symbolic-ref", "--short", "-q", "HEAD"), git("status", "--porcelain"), ]); + return { path, commit, branch: branch || undefined, dirty: Boolean(status) }; } function deepMerge(base: Record, override: Record): Record { const merged = { ...base }; + for (const [key, value] of Object.entries(override)) { const current = merged[key]; merged[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value; } + return merged; } @@ -669,18 +756,23 @@ async function freePort(): Promise { await once(server, "listening"); const address = server.address(); await new Promise((resolveClose) => server.close(() => resolveClose())); + if (!address || typeof address === "string") throw new Error("Could not allocate a loopback port."); + return address.port; } async function readBody(req: IncomingMessage): Promise { let body = ""; + for await (const chunk of req) body += chunk; + return body; } function closeHttpServer(server: HttpServer): Promise { server.closeAllConnections(); + return new Promise((resolveClose) => server.close(() => resolveClose())); } diff --git a/scripts/qa/scenario.ts b/scripts/qa/scenario.ts index e5edc4f03..a383269df 100644 --- a/scripts/qa/scenario.ts +++ b/scripts/qa/scenario.ts @@ -43,6 +43,7 @@ export const scenariosDir = new URL("./scenarios/", import.meta.url); export async function listScenarioNames(): Promise { const entries = await readdir(scenariosDir); + return entries .filter((entry) => entry.endsWith(".json")) .map((entry) => basename(entry, ".json")) @@ -53,7 +54,9 @@ export async function loadScenario(nameOrPath: string): Promise { const path = nameOrPath.endsWith(".json") ? nameOrPath : join(scenariosDir.pathname, `${nameOrPath}.json`); + const scenario = JSON.parse(await readFile(path, "utf8")) as Omit & { name?: string }; + return { ...scenario, name: scenario.name ?? basename(path, ".json") }; } @@ -61,15 +64,20 @@ export function resolveTemplates(value: unknown, variables: Record { const resolved = variables[key]; + if (resolved === undefined) throw new Error(`Scenario references ${match} before it is available.`); + return resolved; }); } + if (Array.isArray(value)) return value.map((item) => resolveTemplates(item, variables)); + if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value).map(([key, item]) => [key, resolveTemplates(item, variables)]), ); } + return value; } diff --git a/src/agents-skill.test.ts b/src/agents-skill.test.ts index 0eefa8458..8ee8313eb 100644 --- a/src/agents-skill.test.ts +++ b/src/agents-skill.test.ts @@ -31,8 +31,10 @@ test("the skill installs to each target's global and project locations", (t) => const home = join(root, "home"); const project = join(root, "project"); const env = {}; + const global = (target: Parameters[0]) => installAgentsSkill(target, { project: false, home, env, cwd: project }); + const local = (target: Parameters[0]) => installAgentsSkill(target, { project: true, home, env, cwd: project }); diff --git a/src/agents-skill.ts b/src/agents-skill.ts index 5dba0618d..397cb6cd7 100644 --- a/src/agents-skill.ts +++ b/src/agents-skill.ts @@ -8,6 +8,7 @@ import { findGitRoot } from "./cli-workspace.js"; const STANDALONE_NAME = "devspace-agents"; export const AGENT_SKILL_INSTALL_TARGETS = ["claude-code", "codex", "cursor", "opencode", "agents"] as const; + export type AgentSkillInstallTarget = typeof AGENT_SKILL_INSTALL_TARGETS[number]; interface SkillSource { @@ -23,16 +24,20 @@ function bundledSkill(): SkillSource { export function parseSkillSource(source: string): SkillSource { const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(source.replace(/\r\n/g, "\n")); const description = match?.[1]?.match(/^description:\s*(.+)$/m)?.[1]; + if (!match || !description) throw new Error("Bundled subagents skill is missing its frontmatter."); + return { description, body: match[2]! }; } /** `skill` is a SKILL.md for agents with Agent Skills; `cursor-rule` is a Cursor `.mdc` rule. */ export function renderAgentsSkill(format: "skill" | "cursor-rule" = "skill"): string { const { description, body } = bundledSkill(); + const frontmatter = format === "skill" ? `name: ${STANDALONE_NAME}\ndescription: ${description}` : `description: ${description}\nalwaysApply: false`; + return `---\n${frontmatter}\n---\n${body}`; } @@ -51,6 +56,7 @@ export function installAgentsSkill( const projectRoot = findGitRoot(cwd) ?? cwd; const skillFile = (base: string) => join(base, STANDALONE_NAME, "SKILL.md"); let path: string; + switch (target) { case "claude-code": path = skillFile(join(project ? projectRoot : home, ".claude", "skills")); @@ -75,9 +81,12 @@ export function installAgentsSkill( mkdirSync(join(projectRoot, ".cursor", "rules"), { recursive: true }); path = join(projectRoot, ".cursor", "rules", `${STANDALONE_NAME}.mdc`); writeFileSync(path, renderAgentsSkill("cursor-rule")); + return path; } + mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, renderAgentsSkill("skill")); + return path; } diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index 1a0848e0c..820384c86 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -17,10 +17,12 @@ test("replacing a file on Windows overwrites the existing destination", async (t test("patch files are the same only when their paths share a file identity", async () => { const sameIdentity = async (): Promise<{ dev: number; ino: number }> => ({ dev: 1, ino: 2 }); + const differentIdentity = async (path: string): Promise<{ dev: number; ino: number }> => ({ dev: 1, ino: path.endsWith("foo.txt") ? 3 : 2, }); + assert.equal(await isSamePatchFile("/tmp/Foo.txt", "/tmp/Foo.txt"), true); assert.equal(await isSamePatchFile("/tmp/Foo.txt", "/tmp/foo.txt", sameIdentity), true); assert.equal(await isSamePatchFile("/tmp/Foo.txt", "/tmp/bar.txt", sameIdentity), false); @@ -73,7 +75,9 @@ test("one patch adds, updates LF and CRLF files, and deletes", async (t) => { test("a moved file keeps its executable mode and leaves no source behind", async (t) => { const root = await fixture(t); await writeFile(join(root, "alpha.txt"), "one\nchanged\nthree\n"); + if (process.platform !== "win32") await chmod(join(root, "alpha.txt"), 0o755); + const moveResult = await applyPatch( root, `*** Begin Patch @@ -85,13 +89,16 @@ test("a moved file keeps its executable mode and leaves no source behind", async changed *** End Patch`, ); + assert.deepEqual(moveResult.files, [ { path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" }, ]); assert.equal(await readFile(join(root, "moved/alpha.txt"), "utf8"), "ONE\nchanged\nthree\n"); + if (process.platform !== "win32") { assert.notEqual((await stat(join(root, "moved/alpha.txt"))).mode & 0o111, 0); } + await assert.rejects(readFile(join(root, "alpha.txt"), "utf8"), /ENOENT/); }); @@ -168,6 +175,7 @@ test("distant hunks in one file produce separate diff hunks", async (t) => { join(splitHunkRoot, "long.txt"), Array.from({ length: 20 }, (_, index) => String(index + 1)).join("\n") + "\n", ); + const splitHunkResult = await applyPatch( splitHunkRoot, `*** Begin Patch @@ -184,6 +192,7 @@ test("distant hunks in one file produce separate diff hunks", async (t) => { 19 *** End Patch`, ); + assert.equal(splitHunkResult.patch.match(/^@@ /gm)?.length, 2); assert.equal( await readFile(join(splitHunkRoot, "long.txt"), "utf8"), @@ -197,6 +206,7 @@ test("distant hunks in one file produce separate diff hunks", async (t) => { test("trailing spaces in added lines are preserved", async (t) => { const trailingSpaceRoot = await fixture(t, "devspace-apply-patch-trailing-space-"); await writeFile(join(trailingSpaceRoot, "spaces.txt"), "old\n"); + const trailingSpaceResult = await applyPatch( trailingSpaceRoot, `*** Begin Patch @@ -206,6 +216,7 @@ test("trailing spaces in added lines are preserved", async (t) => { +new${" "} *** End Patch`, ); + assert.equal(trailingSpaceResult.patch.endsWith("+new "), true); assert.equal(await readFile(join(trailingSpaceRoot, "spaces.txt"), "utf8"), "new \n"); }); @@ -222,6 +233,7 @@ test("malformed patches are rejected while parsing", () => { test("adding an existing file overwrites it as an update", async (t) => { const overwriteRoot = await fixture(t, "devspace-apply-patch-overwrite-"); await writeFile(join(overwriteRoot, "duplicate.txt"), "old content\n"); + const overwriteResult = await applyPatch( overwriteRoot, `*** Begin Patch @@ -229,6 +241,7 @@ test("adding an existing file overwrites it as an update", async (t) => { +new content *** End Patch`, ); + assert.deepEqual(overwriteResult.files, [ { path: "duplicate.txt", operation: "update" }, ]); @@ -364,6 +377,7 @@ test("binary files cannot be updated", async (t) => { async function fixture(t: TestContext, prefix = "devspace-apply-patch-"): Promise { const root = await mkdtemp(join(tmpdir(), prefix)); t.after(() => rm(root, { recursive: true, force: true })); + return root; } @@ -372,5 +386,6 @@ async function movedAlphaFixture(t: TestContext): Promise { const root = await fixture(t); await mkdir(join(root, "moved")); await writeFile(join(root, "moved/alpha.txt"), "ONE\nchanged\nthree\n"); + return root; } diff --git a/src/apply-patch.ts b/src/apply-patch.ts index 05a73de63..273003631 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -42,7 +42,9 @@ interface TextFile { } type StagedTextFile = TextFile | null; + type FileIdentity = Pick; + type FileIdentityReader = (path: string) => Promise; function patchError(message: string): Error { @@ -51,9 +53,11 @@ function patchError(message: string): Error { export function parsePatch(patch: string): PatchAction[] { const lines = patchLines(patch); + if (lines.shift()?.trim() !== "*** Begin Patch") { throw patchError("missing *** Begin Patch marker"); } + if (lines.pop()?.trim() !== "*** End Patch") { throw patchError("missing *** End Patch marker"); } @@ -63,25 +67,31 @@ export function parsePatch(patch: string): PatchAction[] { while (index < lines.length) { const header = lines[index++].trim(); + if (header === "") continue; if (header.startsWith("*** Environment ID: ")) { if (!header.slice("*** Environment ID: ".length).trim()) { throw patchError("environment id cannot be empty"); } + continue; } if (header.startsWith("*** Add File: ")) { const path = header.slice("*** Add File: ".length); const content: string[] = []; + while (index < lines.length && !isTopLevelHeader(lines[index])) { const line = lines[index++]; + if (!line.startsWith("+")) { throw patchError(`added file line must start with +: ${line}`); } + content.push(line.slice(1)); } + if (content.length === 0) throw patchError(`add file for ${path} has no content`); actions.push({ kind: "add", @@ -106,8 +116,10 @@ export function parsePatch(patch: string): PatchAction[] { } let current: UpdateHunk | undefined; + const finishCurrent = (): void => { if (!current) return; + if (current.lines.length === 0) throw patchError(`empty update hunk for ${path}`); hunks.push(current); current = undefined; @@ -116,10 +128,12 @@ export function parsePatch(patch: string): PatchAction[] { while (index < lines.length) { const line = lines[index]; const trimmed = line.trim(); + if (!current && trimmed === "") { index++; continue; } + if (trimmed === "*** End of File") { if (!current) throw patchError(`end-of-file marker without update hunk for ${path}`); current.endOfFile = true; @@ -139,17 +153,20 @@ export function parsePatch(patch: string): PatchAction[] { current ??= { lines: [] }; index++; + if (line.startsWith(" ")) current.lines.push({ kind: "context", text: line.slice(1) }); else if (line.startsWith("+")) current.lines.push({ kind: "add", text: line.slice(1) }); else if (line.startsWith("-")) current.lines.push({ kind: "remove", text: line.slice(1) }); else if (line === "\\ No newline at end of file") continue; else throw patchError(`hunk line must start with space, +, or -: ${line}`); } + finishCurrent(); if (hunks.length === 0 && !moveTo) { throw patchError(`update for ${path} has no hunks or move destination`); } + actions.push({ kind: "update", path, moveTo, hunks }); continue; } @@ -158,6 +175,7 @@ export function parsePatch(patch: string): PatchAction[] { } if (actions.length === 0) throw patchError("contains no file actions"); + return actions; } @@ -165,6 +183,7 @@ function patchLines(patch: string): string[] { let lines = patch.replace(/\r\n/g, "\n").trim().split("\n"); const first = lines[0]?.trim(); const last = lines.at(-1)?.trim(); + if ( (first === "< const rootPath = await realpath(root); const target = resolve(rootPath, input); + if (!isInside(rootPath, target)) { throw patchError(`path escapes the workspace: ${input}`); } let existing = target; + while (true) { try { const resolved = await realpath(existing); + if (!isInside(rootPath, resolved)) { throw patchError(`path resolves outside the workspace: ${input}`); } + break; } catch (error) { const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw error; const parent = dirname(existing); + if (parent === existing) throw error; existing = parent; } @@ -226,7 +254,9 @@ function splitFile(content: string): { lines: string[]; eol: string; finalNewlin const normalized = content.replace(/\r\n/g, "\n"); const finalNewline = normalized.endsWith("\n"); const lines = normalized.split("\n"); + if (finalNewline) lines.pop(); + return { lines, eol, finalNewline }; } @@ -243,6 +273,7 @@ function findSequence(haystack: string[], needle: string[], from: number, endOfF ]) { const start = endOfFile ? haystack.length - needle.length : from; const end = haystack.length - needle.length; + for (let index = start; index <= end; index += 1) { if (index >= from && matchAt(index, normalize)) return index; } @@ -259,18 +290,22 @@ function applyHunks(path: string, content: string, hunks: UpdateHunk[]): string for (const hunk of hunks) { if (hunk.changeContext) { const contextIndex = findSequence(lines, [hunk.changeContext], cursor); + if (contextIndex < 0) { throw patchError(`could not find hunk context in ${path}: ${hunk.changeContext}`); } + cursor = contextIndex + 1; } const oldLines = hunk.lines .filter((line) => line.kind !== "add") .map((line) => line.text); + const newLines = hunk.lines .filter((line) => line.kind !== "remove") .map((line) => line.text); + const index = hunk.endOfFile && oldLines.length === 0 ? lines.length : findSequence(lines, oldLines, cursor, hunk.endOfFile); @@ -285,12 +320,14 @@ function applyHunks(path: string, content: string, hunks: UpdateHunk[]): string } const normalized = `${lines.join("\n")}\n`; + return file.eol === "\r\n" ? normalized.replace(/\n/g, "\r\n") : normalized; } async function fileExists(path: string): Promise { try { await access(path, constants.F_OK); + return true; } catch { return false; @@ -305,17 +342,20 @@ export async function replaceFile( ): Promise { if (platform !== "win32" || !destinationExists) { await rename(temporary, destination); + return; } const backup = `${temporary}.original`; await rename(destination, backup); + try { await rename(temporary, destination); } catch (error) { await rename(backup, destination); throw error; } + await rm(backup, { force: true }); } @@ -325,6 +365,7 @@ export async function isSamePatchFile( readIdentity: FileIdentityReader = lstat, ): Promise { if (source === destination) return true; + if (source.toLowerCase() !== destination.toLowerCase()) return false; try { @@ -332,9 +373,11 @@ export async function isSamePatchFile( readIdentity(source), readIdentity(destination), ]); + return sourceIdentity.dev === destinationIdentity.dev && sourceIdentity.ino === destinationIdentity.ino; } catch (error) { const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return false; throw error; } @@ -350,12 +393,15 @@ export async function applyPatch(root: string, patch: string): Promise => { const file = await readStagedOptional(absolute, displayPath); + if (!file) throw patchError(`file does not exist: ${displayPath}`); + return file; }; @@ -380,12 +426,16 @@ export async function applyPatch(root: string, patch: string): Promise { if (!(await fileExists(absolute))) return null; const metadata = await stat(absolute); + if (!metadata.isFile()) throw patchError(`path is not a regular file: ${displayPath}`); + return { content: await readUtf8Text(absolute, displayPath), mode: metadata.mode }; } async function readUtf8Text(absolute: string, displayPath: string): Promise { const bytes = await readFile(absolute); let content: string; + try { content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { throw patchError(`file is not valid UTF-8 text: ${displayPath}`); } + if (content.includes("\0")) throw patchError(`file appears to be binary: ${displayPath}`); + return content; } async function writeTextFile(destination: string, content: string, mode?: number): Promise { await mkdir(dirname(destination), { recursive: true }); const temporary = `${destination}.devspace-patch-${process.pid}-${randomUUID()}`; + try { await writeFile(temporary, content, mode === undefined ? undefined : { mode }); await replaceFile(temporary, destination, await fileExists(destination)); @@ -448,6 +505,7 @@ function unifiedFilePatch( ): string { const oldFileName = oldContent === null ? "/dev/null" : `a/${oldPath}`; const newFileName = newContent === null ? "/dev/null" : `b/${newPath}`; + const body = createTwoFilesPatch( oldFileName, newFileName, @@ -470,16 +528,21 @@ function unifiedFilePatch( function stripFinalNewline(value: string): string { if (value.endsWith("\r\n")) return value.slice(0, -2); + if (value.endsWith("\n")) return value.slice(0, -1); + return value; } function countPatchStats(patch: string): { additions: number; removals: number } { let additions = 0; let removals = 0; + for (const line of patch.split("\n")) { if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; + if (line.startsWith("-") && !line.startsWith("---")) removals += 1; } + return { additions, removals }; } diff --git a/src/artifact-destination-darwin.ts b/src/artifact-destination-darwin.ts index 9a2004db1..e66560ba8 100644 --- a/src/artifact-destination-darwin.ts +++ b/src/artifact-destination-darwin.ts @@ -28,6 +28,7 @@ export async function prepareDarwinArtifactDestinationDirectory( const libc = darwinLibc(); const directoryFds: number[] = []; let rootFd = libc.open(workspaceRoot, DIRECTORY_FLAGS); + if (rootFd < 0) { throw new ArtifactError( "artifact_workspace_unsafe", @@ -36,6 +37,7 @@ export async function prepareDarwinArtifactDestinationDirectory( } let parentFd = rootFd; + try { for (const part of parentParts) { if ( @@ -49,17 +51,20 @@ export async function prepareDarwinArtifactDestinationDirectory( } const childFd = libc.openat(parentFd, part, DIRECTORY_FLAGS); + if (childFd < 0) { throw new ArtifactError( "artifact_destination_parent_unsafe", "Artifact destination parent must be a real directory inside the workspace.", ); } + directoryFds.push(childFd); parentFd = childFd; } const finalParentFd = parentFd; + return { async createExclusiveFile(name, mode) { const fd = libc.openat( @@ -72,38 +77,46 @@ export async function prepareDarwinArtifactDestinationDirectory( | O_CLOEXEC, mode, ); + if (fd < 0) { throw new ArtifactError( "artifact_partial_unsafe", "Native file partial could not be created safely.", ); } + return artifactFileFromFd(fd); }, async statRegularFile(name) { // Darwin's LP64 struct stat is 144 bytes. Use a raw buffer here instead // of nested Koffi output marshalling, then read only the fields we need. const entry = Buffer.alloc(144); + if (libc.fstatat(finalParentFd, name, entry, AT_SYMLINK_NOFOLLOW) < 0) { const errno = koffi.errno(); + if (errno === koffi.os.errno.ENOENT) return undefined; throw new ArtifactError( "artifact_entry_unsafe", `Artifact entry could not be inspected safely (errno ${errno}).`, ); } + return darwinArtifactEntry(entry); }, async link(sourceName, destinationName) { if (libc.linkat(finalParentFd, sourceName, finalParentFd, destinationName, 0) === 0) { return; } + const errno = koffi.errno(); + if (errno === koffi.os.errno.EEXIST) { const error = new Error("Artifact destination already exists.") as NodeJS.ErrnoException; error.code = "EEXIST"; throw error; } + throw new ArtifactError( "artifact_destination_publish_failed", `Native file could not be published at the requested destination (errno ${errno}).`, @@ -112,6 +125,7 @@ export async function prepareDarwinArtifactDestinationDirectory( async unlink(name) { if (libc.unlinkat(finalParentFd, name, 0) === 0) return; const errno = koffi.errno(); + if (errno === koffi.os.errno.ENOENT) return; throw new Error(`Could not unlink artifact entry (errno ${errno}).`); }, @@ -120,6 +134,7 @@ export async function prepareDarwinArtifactDestinationDirectory( for (const fd of directoryFds.reverse()) { await closeFd(fd).catch(() => undefined); } + if (rootFd >= 0) { await closeFd(rootFd).catch(() => undefined); rootFd = -1; @@ -130,6 +145,7 @@ export async function prepareDarwinArtifactDestinationDirectory( for (const fd of directoryFds.reverse()) { await closeFd(fd).catch(() => undefined); } + await closeFd(rootFd).catch(() => undefined); throw error; } @@ -153,6 +169,7 @@ let cachedDarwinLibc: DarwinLibc | undefined; function darwinLibc(): DarwinLibc { cachedDarwinLibc ??= createDarwinLibc(); + return cachedDarwinLibc; } @@ -162,6 +179,7 @@ function createDarwinLibc(): DarwinLibc { const openat = libc.func("int openat(int fd, const char *path, int flags, ...)"); const DIR = koffi.opaque("DevSpaceArtifactDarwinDIR"); const DIR_PTR = koffi.pointer(DIR); + const DIRENT = koffi.struct("DevSpaceArtifactDarwinDirent", { d_ino: "uint64_t", d_seekoff: "uint64_t", @@ -170,6 +188,7 @@ function createDarwinLibc(): DarwinLibc { d_type: "uint8_t", d_name: koffi.array("char", 1024, "String"), }); + const DIRENT_PTR = koffi.pointer(DIRENT); return { @@ -199,10 +218,12 @@ function createDarwinLibc(): DarwinLibc { function darwinArtifactEntry(entry: Buffer): ArtifactEntry | undefined { const mode = entry.readUInt16LE(4); + if ((mode & S_IFMT) !== S_IFREG) return undefined; const mtimeSeconds = Number(entry.readBigInt64LE(48)); const mtimeNanoseconds = Number(entry.readBigInt64LE(56)); + return { dev: entry.readInt32LE(0), ino: Number(entry.readBigUInt64LE(8)), @@ -217,6 +238,7 @@ async function listDirectoryEntries( libc: DarwinLibc, ): Promise { const duplicateFd = libc.dup(parentFd); + if (duplicateFd < 0) { throw new ArtifactError( "artifact_directory_unsafe", @@ -225,6 +247,7 @@ async function listDirectoryEntries( } const directory = libc.fdopendir(duplicateFd); + if (directory === null) { await closeFd(duplicateFd).catch(() => undefined); throw new ArtifactError( @@ -235,21 +258,27 @@ async function listDirectoryEntries( try { const entries: string[] = []; + while (true) { koffi.errno(0); const pointer = libc.readdir(directory); + if (pointer === null) { const errno = koffi.errno(); + if (errno !== 0) { throw new ArtifactError( "artifact_directory_unsafe", `Artifact destination directory enumeration failed (errno ${errno}).`, ); } + return entries; } + const entry = koffi.decode(pointer, libc.DIRENT) as { d_name?: string }; const name = entry.d_name; + if (name && name !== "." && name !== "..") entries.push(name); } } finally { @@ -259,9 +288,11 @@ async function listDirectoryEntries( function artifactFileFromFd(fd: number): ArtifactFile { let open = true; + return { async writeAll(buffer, position) { let offset = 0; + while (offset < buffer.length) { const bytesWritten = await writeFd( fd, @@ -270,12 +301,14 @@ function artifactFileFromFd(fd: number): ArtifactFile { buffer.length - offset, position + offset, ); + if (bytesWritten <= 0) { throw new ArtifactError( "artifact_short_write", "Native file was not fully written.", ); } + offset += bytesWritten; } }, diff --git a/src/artifact-destination-windows.ts b/src/artifact-destination-windows.ts index 9c68b6cfb..c00a202d3 100644 --- a/src/artifact-destination-windows.ts +++ b/src/artifact-destination-windows.ts @@ -18,13 +18,16 @@ export async function prepareWindowsArtifactDestinationDirectory( try { pinnedHandles.push(pinWindowsDirectory(workspaceRoot, "artifact_workspace_unsafe")); + for (const part of parentParts) { parentPath = join(parentPath, part); + try { await mkdir(parentPath, { mode: 0o755 }); } catch (error) { if (!isNodeError(error) || error.code !== "EEXIST") throw error; } + pinnedHandles.push( pinWindowsDirectory(parentPath, "artifact_destination_parent_unsafe"), ); @@ -32,6 +35,7 @@ export async function prepareWindowsArtifactDestinationDirectory( directoryHandle = await open(parentPath, fsConstants.O_RDONLY); const entry = await directoryHandle.stat(); + if (!entry.isDirectory()) { throw new ArtifactError( "artifact_destination_parent_unsafe", @@ -83,16 +87,19 @@ let cachedWindowsApi: WindowsApi | undefined; function windowsApi(): WindowsApi { cachedWindowsApi ??= createWindowsApi(); + return cachedWindowsApi; } function createWindowsApi(): WindowsApi { const kernel32 = koffi.load("kernel32.dll"); const HANDLE = koffi.pointer("DevSpaceArtifactWindowsHandle", koffi.opaque()); + const FILE_ATTRIBUTE_TAG_INFO = koffi.struct("DevSpaceArtifactFileAttributeTagInfo", { FileAttributes: "uint32_t", ReparseTag: "uint32_t", }); + return { CreateFileW: kernel32.func( "DevSpaceArtifactWindowsHandle __stdcall CreateFileW(const char16_t *path, uint32_t access, uint32_t share, void *security, uint32_t disposition, uint32_t flags, void *templateFile)", @@ -134,19 +141,24 @@ function pinWindowsDirectory(path: string, code: string): unknown { FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, null, ); + const pointerBits = koffi.sizeof(api.HANDLE) * 8; + if (handle === null || koffi.address(handle) === BigInt.asUintN(pointerBits, -1n)) { throw new ArtifactError(code, "Artifact directory could not be pinned safely."); } const info: WindowsAttributeInfo = {}; + const success = api.GetFileInformationByHandleEx( handle, FILE_ATTRIBUTE_TAG_INFO_CLASS, info, koffi.sizeof(api.FILE_ATTRIBUTE_TAG_INFO), ); + const attributes = info.FileAttributes ?? 0; + if ( !success || (attributes & FILE_ATTRIBUTE_DIRECTORY) === 0 @@ -158,12 +170,14 @@ function pinWindowsDirectory(path: string, code: string): unknown { "Artifact directory must be a real directory, not a reparse point.", ); } + return handle; } function closeWindowsHandles(handles: unknown[]): void { if (handles.length === 0) return; const api = windowsApi(); + for (const handle of handles.reverse()) api.CloseHandle(handle); } diff --git a/src/artifact-destination.ts b/src/artifact-destination.ts index e9bafc2d2..d04721a82 100644 --- a/src/artifact-destination.ts +++ b/src/artifact-destination.ts @@ -47,16 +47,19 @@ export function createPathArtifactDestinationDirectory( fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NO_FOLLOW, mode, ); + return artifactFileFromHandle(handle); }, async statRegularFile(name) { let entry: Stats; + try { entry = await lstat(join(anchorPath, name)); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw error; } + return !entry.isSymbolicLink() && entry.isFile() ? artifactEntryFromStats(entry) : undefined; @@ -108,6 +111,7 @@ function artifactFileFromHandle(handle: FileHandle): ArtifactFile { return { async writeAll(buffer, position) { let offset = 0; + while (offset < buffer.length) { const { bytesWritten } = await handle.write( buffer, @@ -115,12 +119,14 @@ function artifactFileFromHandle(handle: FileHandle): ArtifactFile { buffer.length - offset, position + offset, ); + if (bytesWritten <= 0) { throw new ArtifactError( "artifact_short_write", "Native file was not fully written.", ); } + offset += bytesWritten; } }, diff --git a/src/artifact-download.test.ts b/src/artifact-download.test.ts index e48da6e55..885989e07 100644 --- a/src/artifact-download.test.ts +++ b/src/artifact-download.test.ts @@ -32,39 +32,51 @@ import { const requiresDownloadSupport = { skip: !isArtifactDownloadSupportedPlatform() }; test("artifact tools register only download_artifact with a strict file schema", testOneToolContract); + test("artifact downloads are supported on Linux, macOS, and Windows only", testPlatformSupportContract); + test("Windows artifact paths reject unsafe forms and normalize separators", testWindowsPathValidationContract); + test("a download is written once and never overwrites an existing destination", requiresDownloadSupport, async (t) => { await testSafeDownloadAndConflict(await fixture(t, "downloads")); }); + test("download destinations outside the workspace or naming a directory are invalid", requiresDownloadSupport, async (t) => { await testDestinationValidation(await fixture(t, "destinations")); }); + test("oversized downloads are rejected and leave no partial files", requiresDownloadSupport, async (t) => { await testSizeLimitAndCleanup(await fixture(t, "size-limit")); }); + test("stale download partials are removed while recent and unrelated files stay", requiresDownloadSupport, async (t) => { await testCrashLeftoverCleanup(await fixture(t, "stale-partials")); }); + test("linked workspaces and destination parents are rejected", requiresDownloadSupport, async (t) => { await testSymlinkRejection(await fixture(t, "symlinks")); }); + test("a failed publication keeps a concurrently created destination", requiresDownloadSupport, async (t) => { await testPublicationFailurePreservesReplacement(await fixture(t, "publication-race")); }); + test("published downloads are private to the user", requiresDownloadSupport, async (t) => { await testPublishedPermissions(await fixture(t, "permissions")); }); + test("artifact tool logs redact file secrets", testLogRedaction); async function fixture(t: TestContext, name: string): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-artifact-download-test-")); t.after(() => rm(root, { recursive: true, force: true })); + return join(root, name); } function testOneToolContract(): void { const registered = new Map; callback: (input: never) => unknown }>(); + const server = { registerTool( name: string, @@ -72,6 +84,7 @@ function testOneToolContract(): void { callback: (input: never) => unknown, ) { registered.set(name, { descriptor, callback }); + return {}; }, }; @@ -93,20 +106,24 @@ function testOneToolContract(): void { assert.equal((descriptor.annotations as { destructiveHint?: boolean }).destructiveHint, false); const fileSchema = (descriptor.inputSchema as z.ZodRawShape).file as z.ZodType; + const valid = { download_url: "https://files.oaiusercontent.com/file_123/download?sig=secret", file_id: "file_123", mime_type: "image/png", file_name: "generated.png", }; + assert.deepEqual(fileSchema.parse(valid), valid); assert.throws(() => fileSchema.parse({ file_id: "file_123" })); const sensitiveExtraValue = "Bearer should-not-leak"; + const rejected = fileSchema.safeParse({ ...valid, authorization: sensitiveExtraValue, }); + assert.equal(rejected.success, false); assert.equal(JSON.stringify(rejected).includes(sensitiveExtraValue), false); } @@ -156,6 +173,7 @@ async function testSafeDownloadAndConflict(testRoot: string): Promise { const workspaceRoot = join(testRoot, "workspace"); await mkdir(workspaceRoot, { recursive: true }); const bytes = Buffer.from("native artifact bytes\u0000\xff", "latin1"); + const registry = registryFor({ name: "../../generated.png", size: bytes.length, @@ -170,6 +188,7 @@ async function testSafeDownloadAndConflict(testRoot: string): Promise { file: { native: true }, path: "public/images/generated.png", }); + assert.equal(first.path, "public/images/generated.png"); assert.deepEqual(await readFile(join(workspaceRoot, first.path)), bytes); @@ -196,6 +215,7 @@ async function testDestinationValidation(testRoot: string): Promise { await mkdir(workspaceRoot, { recursive: true }); const invalidPaths = ["../outside.txt", "nested/../outside.txt", "/absolute.txt", "folder/"]; + if (process.platform === "win32") { invalidPaths.push( "C:drive-relative.txt", @@ -207,6 +227,7 @@ async function testDestinationValidation(testRoot: string): Promise { "folder/trailing-space ", ); } + for (const path of invalidPaths) { await expectArtifactError( downloadIncomingArtifact({ @@ -281,6 +302,7 @@ async function testCrashLeftoverCleanup(testRoot: string): Promise { await writeFile(unrelated, "unrelated"); const old = new Date(Date.now() - (48 * 60 * 60 * 1_000)); await utimes(stalePartial, old, old); + if (process.platform !== "win32") await chmod(stalePartial, 0o000); await downloadIncomingArtifact({ @@ -358,6 +380,7 @@ async function testPublicationFailurePreservesReplacement(testRoot: string): Pro await directory.link(partialName, candidateName); await directory.unlink(candidateName); const replacement = await directory.createExclusiveFile(candidateName, 0o600); + try { await replacement.writeAll(Buffer.from("replacement"), 0); await replacement.sync(); @@ -377,6 +400,7 @@ async function testPublishedPermissions(testRoot: string): Promise { const workspaceRoot = join(testRoot, "workspace"); await mkdir(workspaceRoot, { recursive: true }); const previousUmask = process.umask(0o077); + try { await downloadIncomingArtifact({ registry: registryFor({ @@ -395,6 +419,7 @@ async function testPublishedPermissions(testRoot: string): Promise { const published = await stat(join(workspaceRoot, "private.txt")); assert.equal(published.isFile(), true); + if (process.platform !== "win32") { assert.equal(published.mode & 0o777, 0o600); } @@ -411,6 +436,7 @@ function testLogRedaction(): void { workspace_id: "ws_secret", path: "private/generated.png", }); + const serialized = JSON.stringify(fields); assert.equal(serialized.includes("super-secret"), false); assert.equal(serialized.includes("file_secret"), false); @@ -432,6 +458,7 @@ function registryFor(source: { return source; }, }; + return new IncomingArtifactAdapterRegistry([adapter]); } diff --git a/src/artifact-path-windows.ts b/src/artifact-path-windows.ts index c71dc4876..10fd7c628 100644 --- a/src/artifact-path-windows.ts +++ b/src/artifact-path-windows.ts @@ -24,18 +24,25 @@ export function parseSafeWindowsArtifactRelativePath( ) return undefined; const rawParts = value.split(/[\\/]+/u); + if (rawParts.includes("..") || rawParts.at(-1) === ".") return undefined; const parts = rawParts.filter((part) => part !== "" && part !== "."); + if (parts.length === 0 || parts.some(isUnsafeWindowsPathPart)) return undefined; const name = parts.at(-1); + if (!name) return undefined; + return { path: parts.join("/"), parts, name }; } function isUnsafeWindowsPathPart(part: string): boolean { if (!part || part === "." || part === "..") return true; + if (part.endsWith(".") || part.endsWith(" ")) return true; + if (WINDOWS_INVALID_NAME_CHARS.test(part)) return true; const deviceBase = (part.split(".", 1)[0] ?? part).trimEnd(); + return WINDOWS_DEVICE_NAME.test(deviceBase); } diff --git a/src/artifact-tools.ts b/src/artifact-tools.ts index 243b97fce..ecb5c85e5 100644 --- a/src/artifact-tools.ts +++ b/src/artifact-tools.ts @@ -32,6 +32,7 @@ const ARTIFACT_WRITE_ANNOTATIONS = { idempotentHint: false, openWorldHint: true, }; + const NO_FOLLOW = fsConstants.O_NOFOLLOW ?? 0; const DIRECTORY_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | NO_FOLLOW; const PARTIAL_PREFIX = ".devspace-download-"; @@ -115,6 +116,7 @@ export function registerArtifactTools( }, async (input) => executeArtifactTool(config, input, async () => { const workspace = await workspaces.getWorkspace(input.workspace_id); + const downloaded = await downloadIncomingArtifact({ registry: incomingRegistry, workspaceId: workspace.id, @@ -123,6 +125,7 @@ export function registerArtifactTools( file: input.file, path: input.path, }); + return { publicResult: { path: downloaded.path }, logResult: downloaded, @@ -161,12 +164,14 @@ export async function downloadIncomingArtifact({ "Native file download requires secure platform filesystem primitives.", ); } + if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1) { throw new ArtifactError( "artifact_limit_invalid", "Artifact file-size limit must be a positive integer.", ); } + if (!workspaceId) { throw new ArtifactError( "artifact_workspace_invalid", @@ -202,14 +207,17 @@ export async function downloadIncomingArtifact({ const hash = createHash("sha256"); let size = 0; + for await (const value of opened.stream) { const chunk = incomingStreamChunk(value); + if (size + chunk.length > maxFileBytes) { throw new ArtifactError( "artifact_file_too_large", "Native file exceeds the configured per-file limit.", ); } + await artifactFile.writeAll(chunk, size); hash.update(chunk); size += chunk.length; @@ -224,6 +232,7 @@ export async function downloadIncomingArtifact({ await artifactFile.sync(); const writtenEntry = await artifactFile.stat(); + if (!writtenEntry.isFile() || writtenEntry.size !== size) { throw new ArtifactError( "artifact_write_integrity_failed", @@ -257,6 +266,7 @@ export async function downloadIncomingArtifact({ throw error; } finally { await artifactFile?.close().catch(() => undefined); + if (partialName) await destinationDirectory?.unlink(partialName).catch(() => undefined); await destinationDirectory?.close().catch(() => undefined); } @@ -283,8 +293,10 @@ async function executeArtifactTool( }>, ) { const startedAt = performance.now(); + try { const { publicResult, logResult } = await operation(); + if (config.logging.toolCalls) { logEvent(config.logging, "info", "artifact_tool_call", { tool: "download_artifact", @@ -296,6 +308,7 @@ async function executeArtifactTool( durationMs: Math.round(performance.now() - startedAt), }); } + return artifactToolResponse(publicResult); } catch (error) { if (config.logging.toolCalls) { @@ -307,6 +320,7 @@ async function executeArtifactTool( durationMs: Math.round(performance.now() - startedAt), }); } + throw error; } } @@ -324,12 +338,15 @@ async function openDirectoryNoFollow( message: string, ): Promise { let handle: FileHandle | undefined; + try { handle = await open(path, DIRECTORY_FLAGS); await assertDirectoryHandle(handle); + return handle; } catch (error) { await handle?.close().catch(() => undefined); + if (error instanceof ArtifactError) throw error; throw new ArtifactError(code, message); } @@ -337,6 +354,7 @@ async function openDirectoryNoFollow( async function assertDirectoryHandle(handle: FileHandle): Promise { const entry = await handle.stat(); + if (!entry.isDirectory()) { throw new ArtifactError( "artifact_directory_unsafe", @@ -361,26 +379,32 @@ async function prepareArtifactDestinationDirectory( const { prepareWindowsArtifactDestinationDirectory } = await import( "./artifact-destination-windows.js" ); + return prepareWindowsArtifactDestinationDirectory(workspaceRoot, parentParts); } + if (process.platform === "darwin") { const { prepareDarwinArtifactDestinationDirectory } = await import( "./artifact-destination-darwin.js" ); + return prepareDarwinArtifactDestinationDirectory(workspaceRoot, parentParts); } + return prepareLinuxArtifactDestinationDirectory(workspaceRoot, parentParts); } function normalizeArtifactDestination(value: string): ArtifactDestination { if (process.platform === "win32") { const parsed = parseSafeWindowsArtifactRelativePath(value); + if (!parsed) { throw new ArtifactError( "artifact_destination_invalid", "Artifact destination must be a safe relative Windows file path inside the workspace.", ); } + return { path: parsed.path, parentParts: parsed.parts.slice(0, -1), @@ -389,6 +413,7 @@ function normalizeArtifactDestination(value: string): ArtifactDestination { } const rawParts = value.split(sep); + if ( !value || value.includes("\u0000") @@ -403,6 +428,7 @@ function normalizeArtifactDestination(value: string): ArtifactDestination { } const normalized = normalize(value); + if ( normalized === "." || normalized === ".." @@ -416,6 +442,7 @@ function normalizeArtifactDestination(value: string): ArtifactDestination { const parts = normalized.split(sep); const name = parts.at(-1); + if (!name || name === "." || name === "..") { throw new ArtifactError( "artifact_destination_invalid", @@ -439,6 +466,7 @@ async function prepareLinuxArtifactDestinationDirectory( "artifact_workspace_unsafe", "Selected workspace root is not a real directory.", ); + const openedHandles: FileHandle[] = []; let parentHandle = rootHandle; let parentAnchor = descriptorDirectoryPath(rootHandle); @@ -450,6 +478,7 @@ async function prepareLinuxArtifactDestinationDirectory( parentAnchor, part, ); + openedHandles.push(child); parentHandle = child; parentAnchor = descriptorDirectoryPath(child); @@ -461,6 +490,7 @@ async function prepareLinuxArtifactDestinationDirectory( for (const handle of openedHandles.reverse()) { await handle.close().catch(() => undefined); } + await rootHandle.close().catch(() => undefined); }, ); @@ -468,6 +498,7 @@ async function prepareLinuxArtifactDestinationDirectory( for (const handle of openedHandles.reverse()) { await handle.close().catch(() => undefined); } + await rootHandle.close().catch(() => undefined); throw error; } @@ -480,6 +511,7 @@ async function ensureWorkspaceChildDirectory( ): Promise { await assertDirectoryHandle(parentHandle); const path = join(parentAnchor, name); + try { await mkdir(path, { mode: 0o755 }); } catch (error) { @@ -514,6 +546,7 @@ async function publishDestination( "Artifact destination already exists.", ); } + // Once the destination path exists, never unlink it during failure cleanup. // Another process may have replaced that path after publication, and a // path-based verification followed by unlink would introduce another race. @@ -535,8 +568,10 @@ async function cleanupStalePartials( const entries = await directory.listEntries(); let inspected = 0; const cutoff = Date.now() - STALE_PARTIAL_AGE_MS; + for (const name of entries) { if (inspected >= MAX_STALE_PARTIAL_CLEANUP) break; + if ( !name.startsWith(PARTIAL_PREFIX) || !name.endsWith(PARTIAL_SUFFIX) @@ -544,6 +579,7 @@ async function cleanupStalePartials( inspected += 1; const metadata = await directory.statRegularFile(name); + if ( !metadata || metadata.mtimeMs >= cutoff @@ -557,10 +593,14 @@ function incomingFileDownloadHostname(value: unknown): string | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) { return undefined; } + const rawUrl = (value as Record).download_url; + if (typeof rawUrl !== "string") return undefined; + try { const hostname = new URL(rawUrl).hostname.toLowerCase(); + return hostname.length > 0 && hostname.length <= 253 ? hostname : undefined; } catch { return undefined; @@ -569,10 +609,13 @@ function incomingFileDownloadHostname(value: unknown): string | undefined { function incomingStreamChunk(value: unknown): Buffer { if (Buffer.isBuffer(value)) return value; + if (typeof value === "string") return Buffer.from(value); + if (value instanceof Uint8Array) { return Buffer.from(value.buffer, value.byteOffset, value.byteLength); } + throw new ArtifactError( "invalid_incoming_artifact_chunk", "Incoming artifact stream yielded a value that is not bytes or text.", diff --git a/src/bin-launcher.test.ts b/src/bin-launcher.test.ts index 7601dd63a..c5768e1e7 100644 --- a/src/bin-launcher.test.ts +++ b/src/bin-launcher.test.ts @@ -37,12 +37,15 @@ test("a source checkout without tsx fails closed instead of running stale dist", function testLinkedCheckoutReadsCurrentConfig(): void { const root = mkdtempSync(join(tmpdir(), "devspace-bin-config-test-")); + try { const env = writeTestDevspaceConfig(root, { tools: { mode: "codex" } }); + const output = execFileSync(process.execPath, [join(projectRoot, "bin", "devspace.js"), "config", "get"], { encoding: "utf8", env: { ...process.env, ...env }, }); + const config = JSON.parse(output) as { tools?: { mode?: string } }; assert.equal(config.tools?.mode, "codex"); } finally { @@ -52,6 +55,7 @@ function testLinkedCheckoutReadsCurrentConfig(): void { function testMissingSourceRuntimeFailsClosed(): void { const root = mkdtempSync(join(tmpdir(), "devspace-bin-missing-tsx-test-")); + try { cpSync(join(projectRoot, "bin"), join(root, "bin"), { recursive: true }); mkdirSync(join(root, "src"), { recursive: true }); @@ -71,6 +75,7 @@ function testMissingSourceRuntimeFailsClosed(): void { function testLauncher(entrypoint: { bin: string; source: string; dist: string }): void { const root = mkdtempSync(join(tmpdir(), "devspace-bin-launcher-test-")); + try { cpSync(join(projectRoot, "bin"), join(root, "bin"), { recursive: true }); mkdirSync(dirname(join(root, entrypoint.source)), { recursive: true }); @@ -84,12 +89,15 @@ function testLauncher(entrypoint: { bin: string; source: string; dist: string }) const sourceOutput = execFileSync(process.execPath, [join(root, "bin", entrypoint.bin)], { encoding: "utf8", }).trim(); + assert.equal(sourceOutput, "source", `${entrypoint.bin} must prefer source in a linked checkout`); rmSync(join(root, entrypoint.source)); + const packagedOutput = execFileSync(process.execPath, [join(root, "bin", entrypoint.bin)], { encoding: "utf8", }).trim(); + assert.equal(packagedOutput, "dist", `${entrypoint.bin} must use dist in a published package`); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/src/cli-config.test.ts b/src/cli-config.test.ts index de30d1455..d5469460d 100644 --- a/src/cli-config.test.ts +++ b/src/cli-config.test.ts @@ -9,8 +9,10 @@ import { writeTestDevspaceConfig } from "./test-support/config.js"; test("config set tells users to restart the running server", () => { const configDir = mkdtempSync(join(tmpdir(), "devspace-cli-config-test-")); + try { const env = { ...process.env, ...writeTestDevspaceConfig(configDir) }; + const stdout = execFileSync( "node", ["--import", "tsx", "src/cli.ts", "config", "set", "publicBaseUrl", "https://example.com"], diff --git a/src/cli-show-changes.test.ts b/src/cli-show-changes.test.ts index b147b7605..3337f4558 100644 --- a/src/cli-show-changes.test.ts +++ b/src/cli-show-changes.test.ts @@ -15,9 +15,11 @@ const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); const packageJsonPath = fileURLToPath(new URL("../package.json", import.meta.url)); const repoRoot = dirname(packageJsonPath); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { bin: { devspace: string }; }; + // This verifies the compiled entrypoint declared for the installed `devspace` // command. npm's package-install shim itself is outside this focused test. const cliPath = join(repoRoot, packageJson.bin.devspace); @@ -44,11 +46,14 @@ test("show-changes prints a Git-backed historical review", async (t) => { const review = await manager.reviewChanges({ workspaceId: "ws_cli", root: project }); const configDir = join(root, ".devspace"); + const env = writeTestDevspaceConfig(configDir, { workspaces: { allowedRoots: [project] }, storage: { stateDir: join(root, ".state") }, }); + const cliArgs = [cliPath, "show-changes", review.reviewRef]; + const plain = await execFileAsync("node", cliArgs, { cwd: project, env: { @@ -59,6 +64,7 @@ test("show-changes prints a Git-backed historical review", async (t) => { }, encoding: "utf8", }); + assert.match(plain.stdout, /\+review me/); const json = await execFileAsync("node", [...cliArgs, "--json"], { @@ -71,10 +77,12 @@ test("show-changes prints a Git-backed historical review", async (t) => { }, encoding: "utf8", }); + const parsed = JSON.parse(json.stdout) as { reviewRef: string; patch: string; }; + assert.equal(parsed.reviewRef, review.reviewRef); assert.equal(parsed.patch, review.patch); @@ -82,6 +90,7 @@ test("show-changes prints a Git-backed historical review", async (t) => { cwd: project, encoding: "utf8", })).stdout.trim(); + await assert.rejects( execFileAsync("node", [cliPath, "show-changes", head], { cwd: project, @@ -98,6 +107,7 @@ test("show-changes prints a Git-backed historical review", async (t) => { (error as { stderr?: string }).stderr ?? "", /Unknown DevSpace review reference/, ); + return true; }, ); diff --git a/src/cli-workspace.test.ts b/src/cli-workspace.test.ts index 1d7e04083..1d56bd664 100644 --- a/src/cli-workspace.test.ts +++ b/src/cli-workspace.test.ts @@ -7,13 +7,19 @@ import test, { after } from "node:test"; import { resolveCliWorkspaceContext } from "./cli-workspace.js"; const root = mkdtempSync(join(tmpdir(), "devspace-cli-workspace-test-")); + after(() => rmSync(root, { recursive: true, force: true })); + const repository = join(root, "repository"); const nested = join(repository, "packages", "app"); const plainDirectory = join(root, "plain"); + mkdirSync(nested, { recursive: true }); + mkdirSync(plainDirectory); + execFileSync("git", ["init", "--quiet", repository]); + // Git returns canonical paths, while macOS and Windows temp directories may // be reported through aliases such as /var or an 8.3 short path. const allowedRoot = realpathSync.native(root); @@ -61,6 +67,7 @@ test("a managed workspace outside allowed roots is used once authorized", () => DEVSPACE_WORKSPACE_ROOT: plainRoot, }, nestedRoot, (workspaceRoot, workspaceId) => { managedChecks += 1; + return workspaceId === "ws_managed" && workspaceRoot === plainRoot; }), { workspaceId: "ws_managed", diff --git a/src/cli-workspace.ts b/src/cli-workspace.ts index e5b563d8b..7f9e45f45 100644 --- a/src/cli-workspace.ts +++ b/src/cli-workspace.ts @@ -17,11 +17,13 @@ export function resolveCliWorkspaceContext( ): CliWorkspaceContext { const workspaceId = env.DEVSPACE_WORKSPACE_ID?.trim() || undefined; const injectedRoot = workspaceId ? env.DEVSPACE_WORKSPACE_ROOT?.trim() : undefined; + const candidate = canonicalizePath( injectedRoot ? resolve(injectedRoot) : findGitRoot(cwd) ?? resolve(cwd), ); if (!workspaceId) return { workspaceId, workspaceRoot: candidate }; + try { return { workspaceId, @@ -31,6 +33,7 @@ export function resolveCliWorkspaceContext( if (authorizeManagedWorkspace?.(candidate, workspaceId)) { return { workspaceId, workspaceRoot: candidate }; } + throw error; } } @@ -50,7 +53,9 @@ export function findGitRoot(cwd: string): string | undefined { windowsHide: true, stdio: ["ignore", "pipe", "ignore"], }); + if (result.status !== 0) return undefined; const root = result.stdout.trim(); + return root ? resolve(root) : undefined; } diff --git a/src/cli-worktrees.test.ts b/src/cli-worktrees.test.ts index 2d6a40a3d..9c84f98c2 100644 --- a/src/cli-worktrees.test.ts +++ b/src/cli-worktrees.test.ts @@ -82,17 +82,20 @@ test("worktrees prune removes only managed worktrees unused for three days", asy async function git(cwd: string, args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf8" }); + return stdout.trim(); } async function pathExists(path: string): Promise { try { await stat(path); + return true; } catch (error) { if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { return false; } + throw error; } } diff --git a/src/cli.test.ts b/src/cli.test.ts index 96abb6458..636e746f5 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -40,14 +40,19 @@ const root = mkdtempSync(join(tmpdir(), "devspace-cli-agents-test-")); const configDir = join(root, ".devspace"); const stateDir = join(root, ".state"); const projectRoot = join(root, "project"); + mkdirSync(stateDir, { recursive: true }); + mkdirSync(join(configDir, "agents"), { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + const cliConfigEnv = writeTestDevspaceConfig(configDir, { workspaces: { allowedRoots: [projectRoot] }, storage: { stateDir }, subagents: { enabled: true, instructions: "on-demand", providers: [] }, }); + writeFileSync( join(configDir, "agents", "reviewer.md"), [ @@ -63,7 +68,9 @@ writeFileSync( "", ].join("\n"), ); + const store = new LocalAgentStore(stateDir); + const current = store.update( store.create({ workspaceId: "ws_current", @@ -76,6 +83,7 @@ const current = store.update( }).id, { status: "idle", latestResponse: "Review complete.", providerSessionId: "provider_secret" }, ); + const other = store.update( store.create({ workspaceId: "ws_other", @@ -86,23 +94,29 @@ const other = store.update( }).id, { status: "running" }, ); + store.close(); const daemonSocket = localAgentDaemonPaths(stateDir).endpoint; const daemonRequests: Array<{ method: string; params?: Record }> = []; + const daemon = createNetServer((socket) => { let buffer = ""; socket.setEncoding("utf8"); socket.on("data", (chunk: string | Buffer) => { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); + if (newline === -1) return; + const request = JSON.parse(buffer.slice(0, newline)) as { requestId: string; method: string; params?: Record; }; + daemonRequests.push(request); + if (request.method === "agent.start") { socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, @@ -115,8 +129,10 @@ const daemon = createNetServer((socket) => { target: "missing", }, })); + return; } + const eventsPage = (afterSeq: unknown) => ({ agentId: current.id, turnId: "trn_follow", @@ -132,6 +148,7 @@ const daemon = createNetServer((socket) => { { type: "turn_completed", status: "completed", turnId: "trn_follow", seq: 5, at: "t5" }, ], }); + const result = request.method === "agent.events" ? eventsPage(request.params?.afterSeq) : request.method === "agent.list" @@ -158,6 +175,7 @@ const daemon = createNetServer((socket) => { configMatches: true, } : null; + socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -166,6 +184,7 @@ const daemon = createNetServer((socket) => { })); }); }); + await new Promise((resolveListen, rejectListen) => { daemon.once("error", rejectListen); daemon.listen(daemonSocket, resolveListen); @@ -213,6 +232,7 @@ test("agents ls --json prints agent summaries as JSON", async () => { test("agents ls outside a DevSpace shell scopes the list to the cwd", async () => { const mark = daemonRequests.length; + const { stdout: directOutput } = await execFileAsync( "node", ["--import", tsxLoader, cliPath, "agents", "ls"], @@ -227,6 +247,7 @@ test("agents ls outside a DevSpace shell scopes the list to the cwd", async () = }, }, ); + assert.match(directOutput, new RegExp(current.id)); const directList = requestsSince(mark).reverse().find((request) => request.method === "agent.list"); assert.deepEqual(directList?.params, { workspaceRoot: realpathSync.native(projectRoot) }); @@ -249,6 +270,7 @@ test("agents show prints an immediate snapshot", async () => { // A wait that times out exits 4 so scripts can tell it from completion. test("agents wait exits 4 when an agent is still running at the timeout", async () => { const mark = daemonRequests.length; + const waitTimeout = await execCli([ "agents", "wait", @@ -260,6 +282,7 @@ test("agents wait exits 4 when an agent is still running at the timeout", async () => assert.fail("wait should exit 4 when an agent is still running"), (error: { code: number; stdout: string }) => error, ); + assert.equal(waitTimeout.code, 4); assert.equal( waitTimeout.stdout, @@ -300,16 +323,20 @@ test("agents events --follow pins its turn and resumes after the last seen event test("agents run --json prints structured errors and exits non-zero", async () => { let commandFailure: unknown; + try { await execCli(["agents", "run", "missing", "--json", "inspect"]); } catch (error) { commandFailure = error; } + assert.ok(commandFailure, "structured CLI errors should exit non-zero"); const stdout = (commandFailure as { stdout?: string }).stdout ?? ""; + const payload = JSON.parse(stdout) as { error: { code: string; message: string; retryable: boolean; target: string }; }; + assert.equal(payload.error.code, "UNKNOWN_TARGET"); assert.equal(payload.error.retryable, false); assert.equal(payload.error.target, "missing"); @@ -317,11 +344,13 @@ test("agents run --json prints structured errors and exits non-zero", async () = test("agents run prints XML errors on stderr and exits non-zero", async () => { let xmlCommandFailure: unknown; + try { await execCli(["agents", "run", "missing", "inspect"]); } catch (error) { xmlCommandFailure = error; } + assert.ok(xmlCommandFailure, "XML CLI errors should exit non-zero"); assert.equal( (xmlCommandFailure as { stderr?: string }).stderr, @@ -359,6 +388,7 @@ test("agents run rejects unknown options and suggests -- for dash-prefixed promp (error as { stderr?: string }).stderr, 'Unknown option: --unknown. Use -- before prompt text that starts with a dash.\n', ); + return true; }, ); diff --git a/src/cli.ts b/src/cli.ts index d45388b86..35aab15ee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -79,6 +79,7 @@ type Command = | "show-changes" | "help" | "version"; + const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; @@ -92,41 +93,53 @@ async function main(argv: string[]): Promise { case "serve": await ensureConfigured(); await serve(); + return; case "init": await runInit({ force: args.includes("--force") }); + return; case "doctor": await runDoctor(); + return; case "config": runConfigCommand(args); + return; case "worktrees": await runWorktreesCommand(args); + return; case "agents": await runAgentsCommand(args); + return; case "workflow": { const { args: commandArgs, json } = extractJsonOption(args); await runWorkflowCommand(commandArgs, json); + return; } + case "show-changes": await runShowChanges(args); + return; case "help": printHelp(); + return; case "version": printVersion(); + return; } } function normalizeCommand(command: string | undefined): Command { if (!command || command === "serve" || command === "start") return "serve"; + if ( command === "init" || command === "doctor" @@ -135,18 +148,24 @@ function normalizeCommand(command: string | undefined): Command { || command === "agents" || command === "show-changes" ) return command; + if (command === "workflow" && workflowsEnabled()) return command; + if (command === "help" || command === "--help" || command === "-h") return "help"; + if (command === "version" || command === "--version" || command === "-v") return "version"; throw new Error(`Unknown command: ${command}`); } async function ensureConfigured(): Promise { const files = loadDevspaceFiles(); + if (files.migratedLegacyConfig) { console.log(`Migrated legacy configuration to ${files.configPath}`); } + if (files.configExists && files.authExists) return; + if (process.env.DEVSPACE_OAUTH_OWNER_TOKEN) return; if (!input.isTTY || !output.isTTY) { @@ -167,9 +186,11 @@ async function ensureConfigured(): Promise { async function runInit({ force }: { force: boolean }): Promise { const files = loadDevspaceFiles(); + if (!force && files.configExists && files.authExists) { prompts.log.info(`DevSpace is already configured at ${files.dir}`); prompts.log.info("Run `devspace init --force` to update it."); + return; } @@ -193,20 +214,24 @@ async function runInit({ force }: { force: boolean }): Promise { initialValues: files.config.server.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], required: true, }); + if (prompts.isCancel(destinationAnswer)) throw new SetupCancelledError(); const usage = resolveOnboardingUsage(destinationAnswer as OnboardingDestination[]); const useChatGpt = usesChatGpt(usage); const useCodingAgents = usesCodingAgents(usage); let allowedRoots: string[] | undefined; + if (useChatGpt) { const defaultRoots = files.config.workspaces.allowedRoots.join(", ") || process.cwd(); + const rootsAnswer = await textPrompt({ message: `Which project folders can DevSpace access? Press Enter to use ${defaultRoots}`, placeholder: defaultRoots, defaultValue: defaultRoots, validate: (value) => value?.trim() ? undefined : "Enter at least one project root.", }); + allowedRoots = rootsAnswer .split(",") .map((root) => resolve(expandHomePath(root.trim()))) @@ -216,6 +241,7 @@ async function runInit({ force }: { force: boolean }): Promise { const port = files.config.server.port; let publicBaseUrl: string | null = null; + if (useChatGpt) { prompts.note( [ @@ -237,21 +263,26 @@ async function runInit({ force }: { force: boolean }): Promise { } const currentSubagents = resolveSubagentsConfig(files.config.subagents ?? disabledSubagentsConfig()); + const availability = getLocalAgentProviderAvailabilitySnapshot( process.env, currentSubagents, ); + const configuredProviders = currentSubagents.providers .filter((provider) => provider.enabled) .map((provider) => provider.id); + const initialValues = configuredProviders.length > 0 ? configuredProviders : availability .filter((provider) => provider.available) .map((provider) => provider.name); + prompts.log.info( "DevSpace can delegate work to these agents from ChatGPT or another coding agent.", ); + const providerAnswer = await prompts.multiselect({ message: "Which agents can DevSpace use as subagents?", options: availability.map((provider) => ({ @@ -264,8 +295,10 @@ async function runInit({ force }: { force: boolean }): Promise { initialValues, required: true, }); + if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); const selectedProviders = providerAnswer as string[]; + const subagents = updateOnboardingSubagentsConfig( currentSubagents, selectedProviders, @@ -292,7 +325,9 @@ async function runInit({ force }: { force: boolean }): Promise { `Subagents: ${selectedProviders.join(", ")}`, ...(publicBaseUrl ? [`ChatGPT connection URL: ${publicBaseUrl}/mcp`] : []), ]; + prompts.note(lines.join("\n"), "DevSpace is ready"); + if (useChatGpt) { prompts.note( [ @@ -302,6 +337,7 @@ async function runInit({ force }: { force: boolean }): Promise { "Owner password", ); } + if (useCodingAgents) { prompts.note( [ @@ -312,22 +348,27 @@ async function runInit({ force }: { force: boolean }): Promise { "Install the Subagents skill", ); } + const nextSteps = [ useChatGpt ? "Run `devspace serve`, then connect ChatGPT." : undefined, useCodingAgents ? "Run the skill command above before delegating from your Coding Agents." : undefined, ].filter(Boolean).join(" "); + prompts.outro(nextSteps); } catch (error) { if (error instanceof SetupCancelledError) { prompts.cancel("Setup cancelled"); + return; } + throw error; } } async function serve(): Promise { const sqliteStatus = checkSqliteNative(); + if (sqliteStatus !== "ok") { throw new Error( [ @@ -345,34 +386,41 @@ async function serve(): Promise { const { configureHttpServer, createServer } = await import("./server.js"); const { workspaceWebUrl } = await import("./workspace-web.js"); const { app, close, localAgentProviders } = createServer(config); + const httpServer = app.listen(config.port, config.host, () => { console.log(`devspace listening on http://${config.host}:${config.port}/mcp`); console.log(`public base url: ${config.publicBaseUrl}`); console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); + if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because server.allowedHosts contains '*'"); } + console.log("auth: Owner password approval required"); console.log(`workspace pages: ${workspaceWebUrl(config)} (sign in with the owner password)`); console.log(`logging: ${config.logging.level} ${config.logging.format}`); console.log(`subagent providers: ${formatLocalAgentProviderStatusSummary(localAgentProviders)}`); }); + configureHttpServer(httpServer); let shuttingDown = false; + const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; await shutdownHttpServer(httpServer, close); process.exit(0); }; + const handleShutdown = () => { void shutdown().catch((error) => { console.error("devspace shutdown failed", error); process.exit(1); }); }; + process.once("SIGINT", handleShutdown); process.once("SIGTERM", handleShutdown); } @@ -380,16 +428,19 @@ async function serve(): Promise { async function runStartupWorktreeCleanup(config: ServerConfig): Promise { try { const cleanup = await pruneStaleManagedWorktrees(config); + if (cleanup.isErr()) { logEvent(config.logging, "warn", "managed_worktree_cleanup_failed", { error: cleanup.error.message, operation: cleanup.error.operation, }); + return; } const result = cleanup.value; const preserved = result.removed.filter((entry) => entry.recoveryRef).length; + if (result.removed.length > 0 || result.missing.length > 0 || result.skipped.length > 0) { logEvent(config.logging, "info", "managed_worktree_cleanup", { removed: result.removed.length, @@ -398,6 +449,7 @@ async function runStartupWorktreeCleanup(config: ServerConfig): Promise { skippedUntracked: result.skipped.length, }); } + for (const failure of result.failed) { logEvent(config.logging, "warn", "managed_worktree_cleanup_failed", { workspaceId: failure.workspaceId, @@ -430,10 +482,12 @@ async function runDoctor(): Promise { console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); + const providers = buildLocalAgentProviderStatuses( config.subagents, getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); + console.log(`Subagents: ${config.subagents.enabled ? "enabled" : "disabled"}`); console.log(`Subagent providers: ${formatLocalAgentProviderStatusSummary(providers)}`); } catch (error) { @@ -447,17 +501,20 @@ function runConfigCommand(args: string[]): void { if (!subcommand || subcommand === "get") { console.log(JSON.stringify(files.config, null, 2)); + return; } if (subcommand !== "set") { throw new Error(`Unknown config command: ${subcommand}`); } + if (key !== "publicBaseUrl") { throw new Error("Only `devspace config set publicBaseUrl ` is supported right now."); } const value = rest.join(" ").trim(); + if (!value) { throw new Error("Missing publicBaseUrl value."); } @@ -472,30 +529,38 @@ function runConfigCommand(args: string[]): void { async function runWorktreesCommand(args: string[]): Promise { const [subcommand, ...extra] = args; + if (subcommand !== "prune" || extra.length > 0) { throw new Error("Usage: devspace worktrees prune"); } const cleanup = await pruneStaleManagedWorktrees(loadConfig()); + if (cleanup.isErr()) { console.warn(`Failed to prune managed worktrees: ${cleanup.error.message}`); process.exitCode = 1; + return; } const result = cleanup.value; const preserved = result.removed.filter((entry) => entry.recoveryRef).length; console.log(`Pruned ${result.removed.length} stale managed worktree${result.removed.length === 1 ? "" : "s"}.`); + if (preserved > 0) console.log(`Preserved ${preserved} recovery ref${preserved === 1 ? "" : "s"}.`); + if (result.missing.length > 0) { console.log(`Cleared ${result.missing.length} missing worktree session${result.missing.length === 1 ? "" : "s"}.`); } + if (result.skipped.length > 0) { console.log(`Skipped ${result.skipped.length} worktree${result.skipped.length === 1 ? "" : "s"} with untracked files.`); } + for (const failure of result.failed) { console.warn(`Failed to prune ${failure.workspaceId}: ${failure.error.message}`); } + if (result.failed.length > 0) process.exitCode = 1; } @@ -536,6 +601,7 @@ function printHelp(): void { async function runShowChanges(args: string[]): Promise { const { args: commandArgs, json } = extractJsonOption(args); const [reviewRef, ...extra] = commandArgs; + if (!reviewRef || extra.length > 0) { throw new Error("Usage: devspace show-changes [--json]"); } @@ -543,10 +609,13 @@ async function runShowChanges(args: string[]): Promise { const config = loadConfig(); const scope = resolveCliWorkspaceContext(config.allowedRoots); const review = await readReviewRef(scope.workspaceRoot, reviewRef); + if (json) { printJson(review); + return; } + console.log(review.patch || review.result); } @@ -555,48 +624,63 @@ async function runAgentsCommand(args: string[]): Promise { // Agents call `--help` on every subcommand; answer with the usage instead of a parse error. const terminator = rest.indexOf("--"); const optionArgs = terminator === -1 ? rest : rest.slice(0, terminator); + if (subcommand !== undefined && optionArgs.some((arg) => arg === "--help" || arg === "-h")) { printAgentsHelp(); + return; } + const { args: commandArgs, json } = extractJsonOption(rest); + switch (subcommand) { case "ls": case "list": await runAgentWorkflowCommand(json, () => runAgentsList(commandArgs, json)); + return; case "run": await runAgentWorkflowCommand(json, () => runAgentsRun(commandArgs, json)); + return; case "continue": await runAgentWorkflowCommand(json, () => runAgentsContinue(commandArgs, json)); + return; case "show": await runAgentWorkflowCommand(json, () => runAgentsShow(commandArgs, json)); + return; case "wait": await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json)); + return; case "stop": await runAgentWorkflowCommand(json, () => runAgentsStop(commandArgs, json)); + return; case "events": await runAgentWorkflowCommand(json, () => runAgentsEvents(commandArgs, json)); + return; case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); + return; case "daemon": await runAgentsDaemon(commandArgs, json); + return; case "skill": await runAgentWorkflowCommand(json, async () => runAgentsSkill(commandArgs)); + return; case undefined: case "help": case "--help": case "-h": printAgentsHelp(); + return; default: writeAgentWorkflowError(`Unknown agents command: ${subcommand}`, json); @@ -617,12 +701,15 @@ async function runAgentsTargets(args: string[], json: boolean): Promise { const config = loadAgentConfig(); const scope = resolveAgentCliWorkspaceContext(config); const profiles = await loadLocalAgentProfiles(config, scope.workspaceRoot); + const providers = buildLocalAgentProviderStatuses( config.subagents, getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); + const catalog = buildLocalAgentCatalog(config.subagents, profiles, providers); const output = presentAgentTargetCatalog(catalog); + if (json) printJson(output); else printAgentXml(formatAgentTargetCatalog(output)); } @@ -633,11 +720,14 @@ async function runAgentsList(args: string[], json: boolean): Promise { const client = createLocalAgentClient(config); const result = await client.list(resolveAgentCliWorkspaceContext(config)); const agents = presentAgentWorkflowResult(result, json); + if (!agents) return; const summaries = agents.map(presentAgentSummary); + if (json) { printJson(summaries); + return; } @@ -649,6 +739,7 @@ async function runAgentsRun(args: string[], json: boolean): Promise { const config = loadAgentConfig(); const scope = resolveAgentCliWorkspaceContext(config); const client = createLocalAgentClient(config); + const result = await client.start({ agentId: parsed.requestId, target: parsed.target, @@ -660,17 +751,25 @@ async function runAgentsRun(args: string[], json: boolean): Promise { writeMode: parsed.writeMode, worktree: parsed.worktree, }); + const record = presentAgentWorkflowResult(result, json); + if (!record) return; + if (parsed.wait) { await waitForStartedAgent(client, record.id, scope, parsed.timeoutMs, json); + return; } + const receipt = presentAgentReceipt(record); + if (json) { printJson(receipt); + return; } + printAgentXml(formatAgentReceipt(receipt)); } @@ -679,27 +778,37 @@ async function runAgentsContinue(args: string[], json: boolean): Promise { const config = loadAgentConfig(); const client = createLocalAgentClient(config); const scope = resolveAgentCliWorkspaceContext(config); + const result = await client.continue(parsed.agentId, parsed.prompt, { model: parsed.model, effort: parsed.effort, writeMode: parsed.writeMode, }, scope); + const record = presentAgentWorkflowResult(result, json); + if (!record) return; + if (parsed.wait) { await waitForStartedAgent(client, record.id, scope, parsed.timeoutMs, json); + return; } + const receipt = presentAgentReceipt(record); + if (json) { printJson(receipt); + return; } + printAgentXml(formatAgentReceipt(receipt)); } async function runAgentsShow(args: string[], json: boolean): Promise { const [id, ...extra] = args; + if (!id || extra.length > 0) throw new Error("Usage: devspace agents show [--json]"); const config = loadAgentConfig(); @@ -707,9 +816,11 @@ async function runAgentsShow(args: string[], json: boolean): Promise { const scope = resolveAgentCliWorkspaceContext(config); const initial = await client.get(id, scope); const record = presentAgentWorkflowResult(initial, json); + if (!record) return; const observation = presentAgentObservation(record); + if (json) printJson(observation); else printAgentXml(formatAgentObservation(observation)); } @@ -720,7 +831,9 @@ async function runAgentsWait(args: string[], json: boolean): Promise { const client = createLocalAgentClient(config); const scope = resolveAgentCliWorkspaceContext(config); const results = presentAgentWorkflowResult(await client.wait(ids, scope, timeoutMs), json); + if (!results) return; + if (json) printJson(results); else printAgentXml(results.map(formatAgentObservation).join("\n")); process.exitCode = agentObservationExitCode(results); @@ -736,7 +849,9 @@ async function waitForStartedAgent( ): Promise { const results = presentAgentWorkflowResult(await client.wait([id], scope, timeoutMs), json); const observation = results?.[0]; + if (!observation) return; + if (json) printJson(observation); else printAgentXml(formatAgentObservation(observation)); process.exitCode = agentObservationExitCode([observation]); @@ -744,13 +859,16 @@ async function waitForStartedAgent( async function runAgentsStop(args: string[], json: boolean): Promise { const [id, ...extra] = args; + if (!id || extra.length > 0) throw new Error("Usage: devspace agents stop [--json]"); const config = loadAgentConfig(); const client = createLocalAgentClient(config); const scope = resolveAgentCliWorkspaceContext(config); const record = presentAgentWorkflowResult(await client.stopAgent(id, scope), json); + if (!record) return; const observation = presentAgentObservation(record); + if (json) printJson(observation); else printAgentXml(formatAgentObservation(observation)); } @@ -765,20 +883,27 @@ async function runAgentsEvents(args: string[], json: boolean): Promise { const scope = resolveAgentCliWorkspaceContext(config); let turnId = parsed.turnId; let afterSeq = 0; + for (;;) { const page = presentAgentWorkflowResult(await client.events(parsed.id, scope, { turnId, afterSeq }), json); + if (!page) return; + if (json && !parsed.follow) { printJson(page); + return; } + let completed = false; + for (const event of page.events) { if (json) printJson(event); else printAgentXml(formatAgentTurnEvent(event)); afterSeq = event.seq; completed ||= event.type === "turn_completed"; } + if (!parsed.follow || completed || page.status !== "running") return; // Stay on the turn being followed even if the agent is continued meanwhile. turnId = page.turnId; @@ -791,16 +916,20 @@ function parseAgentsEventsArgs(args: string[]): { id: string; turnId?: string; f const positional: string[] = []; let turnId: string | undefined; let follow = false; + for (let index = 0; index < args.length; index += 1) { const argument = args[index]!; + if (argument === "--follow" || argument === "-f") { follow = true; } else if (argument === "--turn") { turnId = args[index + 1]; index += 1; + if (!turnId) throw new Error(usage); } else if (argument.startsWith("--turn=")) { turnId = argument.slice("--turn=".length) || undefined; + if (!turnId) throw new Error(usage); } else if (argument.startsWith("-")) { throw new Error(`Unknown option: ${argument}.`); @@ -808,30 +937,38 @@ function parseAgentsEventsArgs(args: string[]): { id: string; turnId?: string; f positional.push(argument); } } + if (positional.length !== 1) throw new Error(usage); + return { id: positional[0]!, follow, ...(turnId ? { turnId } : {}) }; } function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } { const ids: string[] = []; let timeoutMs: number | undefined; + for (let index = 0; index < args.length; index += 1) { const argument = args[index]!; + if (argument === "--timeout") { timeoutMs = parseAgentWaitTimeout(args[index + 1]); index += 1; continue; } + if (argument.startsWith("--timeout=")) { timeoutMs = parseAgentWaitTimeout(argument.slice("--timeout=".length)); continue; } + if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}.`); ids.push(argument); } + if (ids.length === 0) { throw new Error("Usage: devspace agents wait ... [--timeout ] [--json]"); } + return { ids, ...(timeoutMs === undefined ? {} : { timeoutMs }) }; } @@ -839,49 +976,68 @@ function runAgentsSkill(args: string[]): void { const usage = `Usage: devspace agents skill [--install <${AGENT_SKILL_INSTALL_TARGETS.join("|")}> [--project]]`; let target: string | undefined; let project = false; + for (let index = 0; index < args.length; index += 1) { const argument = args[index]!; + if (argument === "--project") project = true; else if (argument === "--install") target = args[++index]; else if (argument.startsWith("--install=")) target = argument.slice("--install=".length); else throw new Error(usage); } + if (target === undefined) { if (project) throw new Error(usage); process.stdout.write(renderAgentsSkill()); + return; } + const installTarget = AGENT_SKILL_INSTALL_TARGETS.find((candidate) => candidate === target); + if (!installTarget) throw new Error(usage); console.log(`Installed ${installAgentsSkill(installTarget, { project })}`); } async function runAgentsDaemon(args: string[], json: boolean): Promise { const [subcommand, ...extra] = args; + if (extra.length > 0) throw new Error("Usage: devspace agents daemon [--json]"); const config = loadAgentConfig(); const client = createLocalAgentClient(config); + switch (subcommand) { case "status": { const status = presentAgentResult(await client.status(), json); + if (!status) return; printJson(status); + return; } + case "stop": { const status = presentAgentResult(await client.stop(), json); + if (!status) return; + if (json) printJson(status); else console.log("Local agent daemon stop requested."); + return; } + case "logs": { const logs = presentAgentResult(await client.logs(), json); + if (logs === undefined) return; + if (json) printJson({ logs }); else console.log(logs || "No local agent daemon logs found."); + return; } + default: throw new Error("Usage: devspace agents daemon "); } @@ -891,18 +1047,22 @@ function extractJsonOption(args: string[]): { args: string[]; json: boolean } { const commandArgs: string[] = []; let json = false; let optionsEnded = false; + for (const argument of args) { if (!optionsEnded && argument === "--") { optionsEnded = true; commandArgs.push(argument); continue; } + if (!optionsEnded && argument === "--json") { json = true; continue; } + commandArgs.push(argument); } + return { args: commandArgs, json }; } @@ -911,11 +1071,14 @@ function presentAgentResult( json: boolean, ): T | undefined { if (result.isOk()) return result.value; + if (json) { printJson({ error: toAgentErrorPayload(result.error) }); process.exitCode = 1; + return undefined; } + throw new Error(result.error.message); } @@ -925,9 +1088,11 @@ function presentAgentWorkflowResult( ): T | undefined { if (result.isOk()) return result.value; const error = toAgentErrorPayload(result.error); + if (json) printJson({ error }); else console.error(formatAgentCommandError(error)); process.exitCode = 1; + return undefined; } @@ -941,6 +1106,7 @@ async function runAgentWorkflowCommand(json: boolean, command: () => Promise { ...options, validate: (value) => options.validate?.(value?.trim() ? value : options.defaultValue), }); + if (prompts.isCancel(result)) throw new SetupCancelledError(); const value = String(result).trim(); + return value || options.defaultValue; } function validateRequiredPublicBaseUrl(value: string | undefined): string | undefined { const trimmed = value?.trim() ?? ""; + if (!trimmed) return "Enter the public URL from your tunnel or reverse proxy."; + if (trimmed.endsWith("/mcp")) return "Enter the base URL only, without /mcp."; + return validatePublicBaseUrl(trimmed); } function validatePublicBaseUrl(value: string): string | undefined { try { const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? undefined : "Use an http or https URL."; @@ -1064,6 +1239,7 @@ function checkSqliteNative(): string { const Database = require("better-sqlite3") as typeof import("better-sqlite3"); const db = new Database(":memory:"); db.close(); + return "ok"; } catch (error) { return error instanceof Error ? error.message : String(error); @@ -1073,9 +1249,11 @@ function checkSqliteNative(): string { function checkGitAvailable(): string { try { const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); + return execFileSync("git", ["--version"], { encoding: "utf8" }).trim(); } catch (error) { const message = error instanceof Error ? error.message : String(error); + return `unavailable (${message})`; } } @@ -1083,9 +1261,11 @@ function checkGitAvailable(): string { function checkBashShell(): string { try { const { shell, args } = getShellConfig(); + return `${shell} ${args.join(" ")}`; } catch (error) { const message = error instanceof Error ? error.message : String(error); + return `unavailable (${message})`; } } diff --git a/src/code-mode-executions.test.ts b/src/code-mode-executions.test.ts index 4fc6d49b0..426843c7d 100644 --- a/src/code-mode-executions.test.ts +++ b/src/code-mode-executions.test.ts @@ -15,6 +15,7 @@ test("concurrency limiter never runs more tasks than its limit", async () => { let active = 0; let peak = 0; const releases: Array<() => void> = []; + const task = () => limit(async () => { peak = Math.max(peak, ++active); await new Promise((resolve) => releases.push(resolve)); @@ -27,10 +28,12 @@ test("concurrency limiter never runs more tasks than its limit", async () => { await afterMicrotasks(delay); started.push(task()); await settle(); + while (releases.length > 0) { releases.shift()?.(); await settle(); } + await Promise.all(started); assert.equal(peak, 4, `peak exceeded the limit with a caller arriving after ${delay} microtasks`); } diff --git a/src/code-mode-executions.ts b/src/code-mode-executions.ts index 453bf529d..432a9595a 100644 --- a/src/code-mode-executions.ts +++ b/src/code-mode-executions.ts @@ -9,6 +9,7 @@ const DEFAULT_POLL_MS = 5_000; const MAX_SCRIPT_MS = 5 * 60_000; const MAX_CALLS = 32; const MAX_CONCURRENT_CALLS = 4; + // Each running script holds a worker with its own 256 MiB heap allowance. export const MAX_RUNNING_EXECUTIONS = 4; const COMPLETED_TTL_MS = 5 * 60_000; @@ -43,13 +44,16 @@ export interface ExecutionSnapshot { export function concurrencyLimiter(max: number): (task: () => Promise) => Promise { let active = 0; const waiters: Array<() => void> = []; + return async (task) => { if (active < max) active++; else await new Promise((resolve) => waiters.push(resolve)); + try { return await task(); } finally { const next = waiters.shift(); + if (next) next(); else active--; } @@ -72,10 +76,13 @@ export class CodeModeExecutions { async start(workspaceId: string, code: string, yieldTimeMs?: number): Promise { if (this.closed) throw new Error("DevSpace is shutting down; Code Mode scripts can no longer start."); const running = [...this.executions.values()].filter((execution) => execution.status === "running").length; + if (running >= MAX_RUNNING_EXECUTIONS) { throw new Error(`DevSpace is already running ${MAX_RUNNING_EXECUTIONS} Code Mode scripts. Wait for one with poll_execution or stop one with cancel_execution, then retry.`); } + const id = randomUUID(); + const execution: Execution = { id, workspaceId, @@ -85,31 +92,38 @@ export class CodeModeExecutions { done: Promise.resolve(), startedAt: performance.now(), }; + this.executions.set(id, execution); execution.done = this.run(execution, code); await this.wait(execution, yieldWindow(yieldTimeMs, DEFAULT_YIELD_MS)); + return this.snapshot(execution); } async poll(workspaceId: string, id: string, yieldTimeMs?: number): Promise { const execution = this.get(workspaceId, id); await this.wait(execution, yieldWindow(yieldTimeMs, DEFAULT_POLL_MS)); + return this.snapshot(execution); } /** Resolves once the script has stopped and every command it started has exited. */ async cancel(workspaceId: string, id: string): Promise { const execution = this.get(workspaceId, id); + if (execution.status === "running") await this.stop(execution, "Execution cancelled by user."); + return this.snapshot(execution); } async shutdown(): Promise { this.closed = true; const executions = [...this.executions.values()]; + for (const execution of executions) { if (execution.cleanupTimer) clearTimeout(execution.cleanupTimer); } + await Promise.allSettled(executions .filter((execution) => execution.status === "running") .map((execution) => this.stop(execution, "DevSpace shutting down."))); @@ -119,19 +133,23 @@ export class CodeModeExecutions { private async stop(execution: Execution, reason: string): Promise { execution.controller.abort(new Error(reason)); const [, terminated] = await Promise.allSettled([execution.done, execution.processes.terminate()]); + if (terminated.status === "fulfilled") recordUnconfirmedExits(execution, terminated.value); } private get(workspaceId: string, id: string): Execution { const execution = this.executions.get(id); + if (!execution || execution.workspaceId !== workspaceId) { throw new Error("Code execution does not exist in this workspace."); } + return execution; } private snapshot(execution: Execution): ExecutionSnapshot { const result = execution.result; + return { execution_id: execution.id, running: execution.status === "running", @@ -147,6 +165,7 @@ export class CodeModeExecutions { private async wait(execution: Execution, ms: number): Promise { let timer: NodeJS.Timeout | undefined; + try { await Promise.race([ execution.done, @@ -163,25 +182,31 @@ export class CodeModeExecutions { const { CodemodeSandbox } = await import("@earendil-works/pi-codemode"); let calls = 0; const limit = concurrencyLimiter(MAX_CONCURRENT_CALLS); + const tools = this.registry.tools(execution.workspaceId) .map((tool) => ({ ...tool, execute: async (args: unknown, context: Parameters[1]) => { if (++calls > MAX_CALLS) throw new Error(`Code Mode limit exceeded: ${MAX_CALLS} tool calls.`); + return limit(async () => { if (execution.controller.signal.aborted) throw new Error("Code execution cancelled."); + return this.processes.runOwned(execution.processes, () => tool.execute(args, context)); }); }, })); + const sandbox = new CodemodeSandbox({ tools, timeoutMs: MAX_SCRIPT_MS, memoryLimitBytes: 256 * 1024 * 1024, }); + try { execution.result = await sandbox.execute(code, { signal: execution.controller.signal }); execution.status = execution.controller.signal.aborted ? "cancelled" : execution.result.ok ? "completed" : "failed"; + if (!execution.result.ok) execution.error = execution.result.error.message; } finally { await sandbox.close(); @@ -202,5 +227,6 @@ export class CodeModeExecutions { function recordUnconfirmedExits(execution: Execution, unconfirmed: number): void { if (unconfirmed === 0) return; const note = `${unconfirmed} command${unconfirmed === 1 ? " was" : "s were"} killed but did not close output; a detached descendant may still be running.`; + if (!execution.error?.includes(note)) execution.error = execution.error ? `${execution.error} ${note}` : note; } diff --git a/src/code-mode-registry.ts b/src/code-mode-registry.ts index 86ca9d267..529519ab5 100644 --- a/src/code-mode-registry.ts +++ b/src/code-mode-registry.ts @@ -36,6 +36,7 @@ export class CodeModeRegistry { output, handler, }); + // This target collects internal registrations; nothing is registered with the MCP host. return undefined; }) as unknown as McpRegistrationTarget["registerTool"], @@ -61,12 +62,16 @@ export class CodeModeRegistry { execute: async (value: unknown, { signal }) => { if (signal.aborted) throw new Error("Code execution cancelled."); const parsed = entry.input.safeParse({ ...(typeof value === "object" && value !== null ? value : {}), workspace_id: workspaceId }); + if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")); const response = await entry.handler(parsed.data, { signal }); + if (response.isError) throw new Error(contentText(response.content ?? []) || `${entry.name} failed`); const result = response.structuredContent ?? { result: contentText(response.content ?? []) }; const validated = entry.output.safeParse(result); + if (!validated.success) throw new Error(`Invalid ${entry.name} response: ${validated.error.message}`); + return result; }, })); diff --git a/src/config-migration.ts b/src/config-migration.ts index e7ec9665b..1cf9573f5 100644 --- a/src/config-migration.ts +++ b/src/config-migration.ts @@ -48,6 +48,7 @@ const LEGACY_CONFIG_KEYS = new Set([ export function migrateLegacyConfig(value: unknown): DevspaceConfig { const legacy = legacyConfigSchema.parse(value); const unsupportedKeys = Object.keys(legacy).filter((key) => !LEGACY_CONFIG_KEYS.has(key)); + if (unsupportedKeys.length > 0) { throw new Error( `Unsupported legacy configuration keys: ${unsupportedKeys.sort().join(", ")}`, @@ -88,7 +89,9 @@ function migrateLegacySubagents( value: z.infer | undefined, ): unknown { if (value === undefined) return undefined; + if (typeof value !== "boolean") return value; + return { enabled: value, providers: value diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts index 95083afcb..e2482a4fb 100644 --- a/src/config-schema.test.ts +++ b/src/config-schema.test.ts @@ -15,11 +15,13 @@ test("the config schema rejects unrecognized keys", () => { test("oauth.refreshTokenGraceSeconds defaults to 0 and accepts integers from 0 to 600", () => { assert.equal(devspaceConfigSchema.parse({ configVersion: 1 }).oauth.refreshTokenGraceSeconds, 0); + for (const refreshTokenGraceSeconds of [0, 1, 600]) { assert.equal(devspaceConfigSchema.parse({ configVersion: 1, oauth: { refreshTokenGraceSeconds }, }).oauth.refreshTokenGraceSeconds, refreshTokenGraceSeconds); } + for (const refreshTokenGraceSeconds of [-1, 601, 1.5, "600"]) { assert.equal(devspaceConfigSchema.safeParse({ configVersion: 1, oauth: { refreshTokenGraceSeconds }, @@ -38,6 +40,7 @@ test("oauth.allowedResourceUrls accepts HTTPS and loopback HTTP URLs only", () = configVersion: 1, oauth: { allowedResourceUrls: [url] }, }), url); } + for (const url of [ "http://tunnel.example.com/mcp", "http://192.168.1.1/mcp", "http://localhost.example.com/mcp", "http://127.0.0.1.example.com/mcp", @@ -52,9 +55,11 @@ test("oauth.allowedResourceUrls accepts HTTPS and loopback HTTP URLs only", () = test("the committed JSON schema matches the generated config schema", () => { const generatedSchema = `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`; + const committedSchema = readFileSync( new URL("../schema/v1/devspace.schema.json", import.meta.url), "utf8", ).replace(/\r\n/g, "\n"); + assert.equal(committedSchema, generatedSchema, "run `npm run schema:config` after changing config-schema.ts"); }); diff --git a/src/config-schema.ts b/src/config-schema.ts index f7d17ca6b..56db29b1f 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -2,6 +2,7 @@ import * as z from "zod/v4"; import { subagentsConfigSchema } from "./local-agent-config.js"; export const DEVSPACE_CONFIG_VERSION = 1 as const; + export const DEVSPACE_CONFIG_SCHEMA_URL = "https://raw.githubusercontent.com/Waishnav/devspace/main/schema/v1/devspace.schema.json"; @@ -68,6 +69,7 @@ const oauthConfigSchema = z.object({ scopes: z.array(z.string().trim().min(1)).min(1).default(["devspace"]), allowedResourceUrls: z.array(z.string().trim().url().refine((value) => { const url = URL.parse(value); + return url !== null && (url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))); }, "Resource URLs must use HTTPS, or HTTP on localhost, 127.0.0.1, or [::1]") @@ -100,6 +102,7 @@ export const devspaceConfigSchema = z.object({ }).strict(); export type DevspaceConfig = z.output; + export type DevspaceConfigInput = z.input; export function defaultDevspaceConfig(): DevspaceConfig { diff --git a/src/config.test.ts b/src/config.test.ts index a7e9fbfb6..bc717d9f7 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,11 +11,13 @@ const ownerToken = "test-owner-token-that-is-long-enough"; function tempConfigDir(t: TestContext, prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; } function serverConfigDir(t: TestContext) { const configDir = tempConfigDir(t, "devspace-config-test-"); + return { configDir, env: { DEVSPACE_CONFIG_DIR: configDir, DEVSPACE_OAUTH_OWNER_TOKEN: ownerToken } }; } diff --git a/src/config.ts b/src/config.ts index 11a1a4e0b..cc92a7547 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,11 +47,13 @@ export interface ServerConfig extends AgentConfig { export function loadAgentConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig { const files = loadDevspaceFiles(env); const { subagents } = files.config; + return agentConfig(files, env, subagents ? resolveSubagentsConfig(subagents) : implicitSubagentsConfig()); } function agentConfig(files: DevspaceFiles, env: NodeJS.ProcessEnv, subagents: SubagentsConfig): AgentConfig { const stored = files.config; + return { configDir: files.dir, allowedRoots: normalizePaths(stored.workspaces.allowedRoots, [process.cwd()]), @@ -69,9 +71,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const stored = files.config; const host = stored.server.host; const port = stored.server.port; + const publicBaseUrl = parsePublicBaseUrl( stored.server.publicBaseUrl ?? localPublicBaseUrl(host, port), ); + const derivedAllowedHosts = [ "localhost", "127.0.0.1", @@ -124,17 +128,21 @@ function normalizePath(path: string): string { function normalizeAllowedHosts(hosts: string[]): string[] { if (hosts.includes("*")) return ["*"]; + return Array.from(new Set(hosts.map((host) => host.trim()).filter(Boolean))); } function parseRequiredSecret(value: string | undefined): string { const secret = value?.trim(); + if (!secret) { throw new Error("OAuth owner token is required. Run: devspace init"); } + if (secret.length < 16) { throw new Error("OAuth owner token must be at least 16 characters long."); } + return secret; } @@ -143,13 +151,16 @@ function parsePublicBaseUrl(value: string): string { parsed.hash = ""; parsed.search = ""; parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + return parsed.toString().replace(/\/$/, ""); } function localPublicBaseUrl(host: string, port: number): string { const publicHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host; + const formattedHost = publicHost.includes(":") && !publicHost.startsWith("[") ? `[${publicHost}]` : publicHost; + return `http://${formattedHost}:${port}`; } diff --git a/src/db/client.ts b/src/db/client.ts index fd129fbea..5711ffdd4 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -6,6 +6,7 @@ import * as schema from "./schema.js"; import { migrateDatabase } from "./migrations.js"; export type SqliteDatabase = Database.Database; + export type AppDatabase = ReturnType; export interface DatabaseHandle { diff --git a/src/db/migrations.ts b/src/db/migrations.ts index e700ba29e..f6efed001 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -127,21 +127,27 @@ export function migrateDatabase(sqlite: Database.Database): void { const appliedRows = sqlite .prepare("select version, name from devspace_schema_migrations order by version") .all() as Array<{ version: number; name: string }>; + const migrationsByVersion = new Map(migrations.map((migration) => [migration.version, migration])); + for (const row of appliedRows) { const expected = migrationsByVersion.get(row.version); + if (!expected) { throw new Error( `Database migration history is incompatible: version ${row.version} (${JSON.stringify(row.name)}) is unknown to this build.`, ); } + if (row.name !== expected.name) { throw new Error( `Database migration history is incompatible: version ${row.version} is recorded as ${JSON.stringify(row.name)}, but this build expects ${JSON.stringify(expected.name)}.`, ); } } + const applied = new Set(appliedRows.map((row) => row.version)); + const recordMigration = sqlite.prepare( "insert into devspace_schema_migrations (version, name, applied_at) values (?, ?, ?)", ); @@ -303,7 +309,9 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { const columns = sqlite.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ name: string; }>; + const names = new Set(columns.map((column) => column.name)); + if (names.has("effort")) { if (names.has("thinking")) { sqlite.exec(` @@ -312,12 +320,16 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void { where effort is null and thinking is not null `); } + return; } + if (!names.has("thinking")) { addColumnIfMissing(sqlite, "local_agent_sessions", "effort", "text"); + return; } + sqlite.exec("alter table local_agent_sessions rename column thinking to effort"); } @@ -325,6 +337,7 @@ function migrateWorkspaceRecoveryState(sqlite: Database.Database): void { const workspaceStateExists = sqlite .prepare("select 1 from sqlite_master where type = 'table' and name = 'workspace_sessions'") .get(); + if (!workspaceStateExists) return; addColumnIfMissing(sqlite, "workspace_sessions", "recovery_kind", "text"); @@ -358,10 +371,13 @@ function migrateLocalAgentProviderInstances(sqlite: Database.Database): void { const columns = sqlite.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ name: string; }>; + const names = new Set(columns.map((column) => column.name)); + if (names.has("provider") && !names.has("provider_instance_id")) { sqlite.exec("alter table local_agent_sessions rename column provider to provider_instance_id"); } + addColumnIfMissing(sqlite, "local_agent_sessions", "driver", "text not null default ''"); sqlite.exec(` update local_agent_sessions @@ -462,6 +478,7 @@ function addColumnIfMissing( definition: string, ): void { const columns = sqlite.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>; + if (columns.some((existingColumn) => existingColumn.name === column)) return; sqlite.exec(`alter table ${table} add column ${column} ${definition}`); diff --git a/src/db/schema.ts b/src/db/schema.ts index ecc6594b3..527084458 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -167,12 +167,21 @@ export const localAgentSessions = sqliteTable( ); export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; + export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; + export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; + export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; + export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; + export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; + export type WorkspaceTurnRow = typeof workspaceTurns.$inferSelect; + export type WorkspaceTurnToolCallRow = typeof workspaceTurnToolCalls.$inferSelect; + export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; + export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/dev-state.test.ts b/src/dev-state.test.ts index e848ad5a5..557644d70 100644 --- a/src/dev-state.test.ts +++ b/src/dev-state.test.ts @@ -14,6 +14,7 @@ const sourceConfigDir = join(root, "config"); const sourceStateDir = join(root, "state"); const scriptPath = fileURLToPath(new URL("../scripts/dev-state.ts", import.meta.url)); const tsxCliPath = fileURLToPath(import.meta.resolve("tsx/cli")); + after(() => rm(root, { recursive: true, force: true })); test("seed copies source config and state, and reset reseeds only once source auth is valid", async () => { @@ -36,9 +37,11 @@ test("seed copies source config and state, and reset reseeds only once source au await runDevState("seed"); const devRoot = join(checkoutRoot, ".devspace-dev"); + const localConfig = JSON.parse( await readFile(join(devRoot, "config", "config.jsonc"), "utf8"), ) as { storage: { stateDir: string } }; + assert.equal( await realpath(localConfig.storage.stateDir), await realpath(join(devRoot, "state")), @@ -58,6 +61,7 @@ test("seed copies source config and state, and reset reseeds only once source au await assert.rejects(runDevState("reset"), /No auth\.json found/); const preservedDatabase = new Database(localDatabasePath, { readonly: true }); + try { assert.deepEqual( preservedDatabase.prepare("select value from marker order by rowid").pluck().all(), @@ -92,6 +96,7 @@ async function runDevState(command: "seed" | "reset"): Promise { stdio: ["ignore", "pipe", "pipe"], }, ); + let stderr = ""; child.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; diff --git a/src/git-worktrees.test.ts b/src/git-worktrees.test.ts index 94001ee22..a953cc7c2 100644 --- a/src/git-worktrees.test.ts +++ b/src/git-worktrees.test.ts @@ -201,6 +201,7 @@ test("prune persistence failure restores the removed worktree", async (t) => { const fixture = await worktreeFixture(t, "ws_store_failure", { createStore: (stateDir) => new FailPruneStore(stateDir), }); + await writeFile(join(fixture.worktreePath, "README.md"), "recover me\n"); const result = unwrap(await cleanupManagedWorktrees({ @@ -232,23 +233,28 @@ test("failed prune compensation leaves the session pruned for later recovery", a execFileSync("git", ["update-ref", "-d", managedWorktreeRecoveryRef(id)], { cwd: this.sourceRoot, }); + return Result.err(new WorkspaceStoreError( "mark_session_pruned", new Error("injected persistence failure"), id, )); } + return super.markSessionPruned(id, recoveryKind); } } let store!: FailOnceAndBreakRecoveryStore; + const fixture = await worktreeFixture(t, "ws_failed_compensation", { createStore: (stateDir) => { store = new FailOnceAndBreakRecoveryStore(stateDir); + return store; }, }); + store.sourceRoot = fixture.sourceRoot; await writeFile(join(fixture.worktreePath, "README.md"), "recover later\n"); @@ -287,6 +293,7 @@ test("restore rejects a pruned worktree path through an escaping symlink", async baseSha: await git(fixture.sourceRoot, ["rev-parse", "HEAD"]), managed: true, }); + unwrap(fixture.store.markSessionPruned(session.id)); const pruned = fixture.store.getSession(session.id); assert.ok(pruned); @@ -351,6 +358,7 @@ async function worktreeFixture( await mkdir(sourceRoot); await mkdir(worktreeRoot); await writeFile(join(sourceRoot, "README.md"), "initial\n"); + if (options.gitignore) await writeFile(join(sourceRoot, ".gitignore"), options.gitignore); await git(sourceRoot, ["init"]); await git(sourceRoot, ["config", "user.email", "devspace@example.com"]); @@ -385,22 +393,26 @@ function futureCutoff(): Date { async function git(cwd: string, args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf8" }); + return stdout.trim(); } async function pathExists(path: string): Promise { try { await stat(path); + return true; } catch (error) { if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { return false; } + throw error; } } function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; + return result.value; } diff --git a/src/git-worktrees.ts b/src/git-worktrees.ts index 6554f9df5..8f2eca9e0 100644 --- a/src/git-worktrees.ts +++ b/src/git-worktrees.ts @@ -93,6 +93,7 @@ export async function createManagedWorktree(input: { try { const sourceStats = await stat(sourcePath); + if (!sourceStats.isDirectory()) { throw new GitWorktreeError( "GIT_REPOSITORY_NOT_FOUND", @@ -111,6 +112,7 @@ export async function createManagedWorktree(input: { const baseRef = input.baseRef ?? "HEAD"; const baseSha = await resolveBaseCommit(sourceRoot, baseRef); const dirtySource = (await git(["status", "--porcelain=v1"], sourceRoot)).trim().length > 0; + const worktreePath = managedWorktreePath({ worktreeRoot: input.config.worktreeRoot, repoRoot: sourceRoot, @@ -161,10 +163,12 @@ export async function cleanupManagedWorktrees(input: { }; const staleSessions = input.store.listStaleManagedWorktrees(input.staleBefore); + if (staleSessions.isErr()) return staleSessions; for (const session of staleSessions.value) { const cleaned = await cleanupManagedWorktree({ ...input, session }); + if (cleaned.isErr()) { result.failed.push({ workspaceId: session.id, @@ -195,6 +199,7 @@ export async function restoreManagedWorktree(input: { allowedRoots: string[]; }): Promise> { const { session } = input; + if (session.mode !== "worktree" || !session.managed || !session.sourceRoot) { return Result.err(worktreeError( session.id, @@ -203,14 +208,17 @@ export async function restoreManagedWorktree(input: { `Workspace ${session.id} is not a recoverable managed worktree.`, )); } + const sourceRootPath = session.sourceRoot; const recoveryRef = managedWorktreeRecoveryRef(session.id); + const restoreRef = session.recoveryKind === "stash" ? `${recoveryRef}^1` : session.recoveryKind === "head" ? recoveryRef : session.baseSha; + if (!restoreRef) { return Result.err(worktreeError( session.id, @@ -222,6 +230,7 @@ export async function restoreManagedWorktree(input: { return captureManagedWorktreeResult(session.id, "restore", async () => { const worktreePath = assertAllowedPath(session.root, [input.worktreeRoot]); + if (await isDirectory(worktreePath)) { throw new Error(`Cannot restore workspace ${session.id} because its worktree path already exists.`); } @@ -231,9 +240,11 @@ export async function restoreManagedWorktree(input: { await resolveCanonicalAllowedPath(worktreePath, input.worktreeRoot, [input.worktreeRoot]); let created = false; + try { await git(["worktree", "add", "--detach", worktreePath, restoreRef], sourceRoot); created = true; + if (session.recoveryKind === "stash") { await git(["stash", "apply", "--index", recoveryRef], worktreePath); } @@ -248,6 +259,7 @@ export async function restoreManagedWorktree(input: { ); } } + throw cause; } }, "WORKTREE_RESTORE_FAILED"); @@ -259,6 +271,7 @@ export async function discardRestoredManagedWorktree(input: { allowedRoots: string[]; }): Promise> { const { session } = input; + if (!session.sourceRoot) { return Result.err(worktreeError( session.id, @@ -267,10 +280,12 @@ export async function discardRestoredManagedWorktree(input: { `Stored managed worktree is missing sourceRoot: ${session.id}`, )); } + const sourceRootPath = session.sourceRoot; return captureManagedWorktreeResult(session.id, "discard", async () => { const worktreePath = assertAllowedPath(session.root, [input.worktreeRoot]); + if (!(await isDirectory(worktreePath))) return; const sourceRoot = await assertCleanupSourceRootAllowed(sourceRootPath, input.allowedRoots); @@ -297,19 +312,23 @@ async function cleanupManagedWorktree(input: { const prepared = await captureManagedWorktreeResult(session.id, "prune", async () => { const worktreePath = assertAllowedPath(session.root, [input.worktreeRoot]); + if (!(await isDirectory(worktreePath))) { return { kind: "missing" } as const; } + if (!session.sourceRoot) { throw new Error(`Stored managed worktree is missing sourceRoot: ${session.id}`); } const sourceRoot = await assertCleanupSourceRootAllowed(session.sourceRoot, input.allowedRoots); await assertManagedWorktreePath(worktreePath, input.worktreeRoot); + const status = await git( ["status", "--porcelain=v1", "--untracked-files=normal", "--ignored=no"], worktreePath, ); + if (status.split("\n").some((line) => line.startsWith("?? "))) { return { kind: "skipped" } as const; } @@ -318,14 +337,17 @@ async function cleanupManagedWorktree(input: { const headSha = (await git(["rev-parse", "HEAD"], worktreePath)).trim(); let recoverySha: string | undefined; let recoveryKind: WorkspaceRecoveryKind | undefined; + if (hasTrackedChanges) { recoverySha = (await git( ["stash", "create", `DevSpace recovery ${session.id}`], worktreePath, )).trim(); + if (!recoverySha) { throw new Error(`Git could not snapshot tracked changes for ${session.id}.`); } + recoveryKind = "stash"; } else if (!session.baseSha || headSha !== session.baseSha) { recoverySha = headSha; @@ -333,6 +355,7 @@ async function cleanupManagedWorktree(input: { } const recoveryRef = recoverySha ? managedWorktreeRecoveryRef(session.id) : undefined; + if (recoveryRef && recoverySha) { await git(["update-ref", recoveryRef, recoverySha], sourceRoot); } @@ -340,21 +363,26 @@ async function cleanupManagedWorktree(input: { // Revalidate immediately before the only destructive filesystem operation. await assertManagedWorktreePath(worktreePath, input.worktreeRoot); await git(["worktree", "remove", "--force", worktreePath], sourceRoot); + return { kind: "removed", entry: { workspaceId: session.id, recoveryRef, recoverySha }, recoveryKind, } as const; }); + if (prepared.isErr()) return prepared; if (prepared.value.kind === "missing") { const deleted = input.store.deleteSession(session.id); + return deleted.isErr() ? deleted : Result.ok(prepared.value); } + if (prepared.value.kind === "skipped") return Result.ok(prepared.value); const markedPruned = input.store.markSessionPruned(session.id, prepared.value.recoveryKind); + if (markedPruned.isOk()) { return Result.ok({ kind: "removed", entry: prepared.value.entry }); } @@ -364,11 +392,13 @@ async function cleanupManagedWorktree(input: { worktreeRoot: input.worktreeRoot, allowedRoots: input.allowedRoots, }); + if (restored.isErr()) { const markedAfterRestoreFailure = input.store.markSessionPruned( session.id, prepared.value.recoveryKind, ); + return Result.err(worktreeError( session.id, "WORKTREE_RESTORE_FAILED", @@ -383,6 +413,7 @@ async function cleanupManagedWorktree(input: { }, )); } + return Result.err(markedPruned.error); } @@ -406,6 +437,7 @@ async function captureManagedWorktreeResult( return Result.ok(await run()); } catch (cause) { if (isProgrammerDefect(cause)) throw cause; + return Result.err(worktreeError( workspaceId, code, @@ -430,9 +462,11 @@ export function managedWorktreeRecoveryRef(workspaceId: string): string { async function assertManagedWorktreePath(worktreePath: string, worktreeRoot: string): Promise { const entry = await lstat(worktreePath); + if (entry.isSymbolicLink()) { throw new Error(`Managed worktree path was replaced by a symbolic link: ${worktreePath}`); } + if (!entry.isDirectory()) { throw new Error(`Managed worktree path is not a directory: ${worktreePath}`); } @@ -441,6 +475,7 @@ async function assertManagedWorktreePath(worktreePath: string, worktreeRoot: str realpath(worktreePath), realpath(worktreeRoot), ]); + if (!isPathInsideRoot(canonicalPath, canonicalRoot)) { throw new Error(`Managed worktree resolves outside the configured worktree root: ${worktreePath}`); } @@ -449,8 +484,10 @@ async function assertManagedWorktreePath(worktreePath: string, worktreeRoot: str async function assertCleanupSourceRootAllowed(sourceRoot: string, allowedRoots: string[]): Promise { const logicalRoot = assertAllowedPath(sourceRoot, allowedRoots); const canonicalSourceRoot = await realpath(logicalRoot); + for (const allowedRoot of allowedRoots) { const canonicalAllowedRoot = await realpath(allowedRoot).catch(() => undefined); + if (canonicalAllowedRoot && isPathInsideRoot(canonicalSourceRoot, canonicalAllowedRoot)) { return canonicalSourceRoot; } @@ -462,6 +499,7 @@ async function assertCleanupSourceRootAllowed(sourceRoot: string, allowedRoots: async function resolveGitRoot(path: string, allowedRoots: string[]): Promise { try { const output = await git(["rev-parse", "--show-toplevel"], path); + return await assertGitRootAllowed(output.trim(), allowedRoots); } catch (error) { if (isGitUnavailable(error)) { @@ -473,7 +511,7 @@ async function resolveGitRoot(path: string, allowedRoots: string[]): Promise undefined); + if (!canonicalAllowedRoot || !isPathInsideRoot(canonicalGitRoot, canonicalAllowedRoot)) { continue; } const logicalGitRoot = resolve(allowedRoot, relative(canonicalAllowedRoot, canonicalGitRoot)); + return assertAllowedPath(logicalGitRoot, allowedRoots); } @@ -518,6 +559,7 @@ async function resolveBaseCommit(sourceRoot: string, baseRef: string): Promise { if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { return false; } + throw error; } } @@ -545,6 +588,7 @@ async function git(args: string[], cwd: string): Promise { cwd, maxBuffer: 10 * 1024 * 1024, }); + return stdout; } catch (error) { if (isGitUnavailable(error)) throw error; @@ -552,9 +596,11 @@ async function git(args: string[], cwd: string): Promise { const stderr = typeof error === "object" && error && "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "").trim() : ""; + const stdout = typeof error === "object" && error && "stdout" in error ? String((error as { stdout?: unknown }).stdout ?? "").trim() : ""; + const details = stderr || stdout || (error instanceof Error ? error.message : String(error)); throw new Error(details); } diff --git a/src/git.ts b/src/git.ts index a40a36ded..897ce4924 100644 --- a/src/git.ts +++ b/src/git.ts @@ -43,10 +43,12 @@ export async function getGitEligibility(cwd: string): Promise { } const gitRoot = (await git(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim(); + try { await git(gitRoot, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]); } catch (error) { let headRef: string; + try { headRef = (await git(gitRoot, ["symbolic-ref", "--quiet", "HEAD"])).stdout.trim(); } catch { @@ -59,6 +61,7 @@ export async function getGitEligibility(cwd: string): Promise { "--count=1", headRef, ])).stdout.trim(); + if (existingHeadRef) throw error; return { @@ -73,5 +76,6 @@ export async function getGitEligibility(cwd: string): Promise { export function safeWorkspaceRefSegment(workspaceId: string): string { const safe = workspaceId.replace(/[^A-Za-z0-9._-]/g, "-"); + return safe.length > 0 ? safe : createHash("sha256").update(workspaceId).digest("hex").slice(0, 16); } diff --git a/src/incoming-artifacts.test.ts b/src/incoming-artifacts.test.ts index cfc0baea6..3e67fec54 100644 --- a/src/incoming-artifacts.test.ts +++ b/src/incoming-artifacts.test.ts @@ -10,7 +10,9 @@ import { } from "./incoming-artifacts.js"; test("the incoming artifact registry rejects unclaimed, ambiguous, and invalid adapters", testRegistryFailsClosed); + test("the OpenAI file adapter downloads only safe OpenAI file references", testOpenAIFileAdapter); + test("incoming artifact log shapes redact values", testLogShapeRedaction); async function testRegistryFailsClosed(): Promise { @@ -35,10 +37,12 @@ async function testRegistryFailsClosed(): Promise { return { name: "a.bin", stream: Readable.from(["a"]) }; }, }; + const ambiguousRegistry = new IncomingArtifactAdapterRegistry([ ambiguous, { ...ambiguous, id: "ambiguous-two" }, ]); + await expectArtifactError( ambiguousRegistry.open({ native: true }), "ambiguous_incoming_artifact", @@ -57,9 +61,11 @@ async function testRegistryFailsClosed(): Promise { async function testOpenAIFileAdapter(): Promise { const bytes = Buffer.from("chatgpt generated image bytes"); const requested: string[] = []; + const fetchImpl: typeof fetch = async (input, init) => { requested.push(String(input)); assert.equal(init?.redirect, "manual"); + return new Response(bytes, { status: 200, headers: { @@ -68,15 +74,18 @@ async function testOpenAIFileAdapter(): Promise { }, }); }; + const registry = new IncomingArtifactAdapterRegistry([ createOpenAIIncomingArtifactAdapter({ fetch: fetchImpl }), ]); + const reference = { download_url: "https://files.oaiusercontent.com/file_123/download?sig=secret", file_id: "file_123", mime_type: "image/png", file_name: "generated.png", }; + const generatedReference = { download_url: "https://oaisdmntprcentralindia.blob.core.windows.net/chatgpt-file/generated-image.png?sig=secret", file_id: "file-service://generated+opaque/abc123", @@ -104,6 +113,7 @@ async function testOpenAIFileAdapter(): Promise { file_name: null, name: null, }); + assert.equal(fallbackOpened.name, "chatgpt-file.png"); fallbackOpened.stream.destroy(); @@ -159,14 +169,17 @@ async function testOpenAIFileAdapter(): Promise { }, }); } + return new Response(bytes, { status: 200 }); }, }), ]); + const redirected = await redirectRegistry.open({ ...reference, download_url: "https://files.oaiusercontent.com/first?sig=initial", }); + assert.deepEqual(await collect(redirected.stream), bytes); const unsafeRedirectRegistry = new IncomingArtifactAdapterRegistry([ @@ -177,6 +190,7 @@ async function testOpenAIFileAdapter(): Promise { }), }), ]); + await expectArtifactError( unsafeRedirectRegistry.open(reference), "unsafe_openai_file_reference", @@ -187,6 +201,7 @@ async function testOpenAIFileAdapter(): Promise { fetch: async () => new Response("nope", { status: 500 }), }), ]); + await expectArtifactError( failedDownloadRegistry.open(reference), "openai_file_download_failed", @@ -201,6 +216,7 @@ function testLogShapeRedaction(): void { arbitrary: "private-value", }, }; + const serialized = JSON.stringify(describeIncomingArtifactValue(value)); assert.equal(serialized.includes("super-secret"), false); assert.equal(serialized.includes("file_secret"), false); @@ -211,9 +227,11 @@ function testLogShapeRedaction(): void { async function collect(stream: Readable): Promise { const chunks: Buffer[] = []; + for await (const chunk of stream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } + return Buffer.concat(chunks); } diff --git a/src/incoming-artifacts.ts b/src/incoming-artifacts.ts index 0f9260855..1b43e7e3f 100644 --- a/src/incoming-artifacts.ts +++ b/src/incoming-artifacts.ts @@ -4,15 +4,18 @@ import type { ReadableStream as NodeReadableStream } from "node:stream/web"; import { ArtifactError } from "./artifact-error.js"; const ADAPTER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u; + const OPENAI_FILE_HOSTS = new Set([ "files.oaiusercontent.com", ]); + // ChatGPT-generated files are served from regional OpenAI-managed Azure storage // accounts. Accept that account family only, never arbitrary Azure Blob hosts. const OPENAI_REGIONAL_BLOB_HOST_PATTERN = /^oaisdmntpr[a-z0-9]+\.blob\.core\.windows\.net$/u; const OPENAI_FILENAME_SAFE_FILE_ID_PATTERN = /^file[-_][A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u; const OPENAI_FILE_ID_MAX_LENGTH = 512; const OPENAI_FILE_ID_CONTROL_PATTERN = /[\u0000-\u001F\u007F]/u; + const OPENAI_FILE_KEYS = new Set([ "download_url", "file_id", @@ -21,6 +24,7 @@ const OPENAI_FILE_KEYS = new Set([ "name", "size", ]); + const OPENAI_FILE_REDIRECT_LIMIT = 3; const OPENAI_FILE_DOWNLOAD_TIMEOUT_MS = 30_000; @@ -46,6 +50,7 @@ export class IncomingArtifactAdapterRegistry { constructor(adapters: readonly IncomingArtifactAdapter[] = []) { const ids = new Set(); + for (const adapter of adapters) { if (!ADAPTER_ID_PATTERN.test(adapter.id)) { throw new ArtifactError( @@ -53,21 +58,26 @@ export class IncomingArtifactAdapterRegistry { "Incoming artifact adapter IDs must be short lowercase identifiers.", ); } + if (ids.has(adapter.id)) { throw new ArtifactError( "duplicate_incoming_adapter", `Incoming artifact adapter '${adapter.id}' is registered more than once.`, ); } + ids.add(adapter.id); } + this.adapters = [...adapters]; } async open(value: unknown): Promise { const matching: IncomingArtifactAdapter[] = []; + for (const adapter of this.adapters) { let handles = false; + try { handles = adapter.canHandle(value); } catch { @@ -76,6 +86,7 @@ export class IncomingArtifactAdapterRegistry { `Incoming artifact adapter '${adapter.id}' failed during recognition.`, ); } + if (handles) matching.push(adapter); } @@ -85,6 +96,7 @@ export class IncomingArtifactAdapterRegistry { "No trusted incoming artifact adapter recognized this file reference.", ); } + if (matching.length > 1) { throw new ArtifactError( "ambiguous_incoming_artifact", @@ -94,6 +106,7 @@ export class IncomingArtifactAdapterRegistry { const adapter = matching[0]; let source: IncomingArtifactSource; + try { source = await adapter.open(value); } catch (error) { @@ -103,12 +116,14 @@ export class IncomingArtifactAdapterRegistry { `Incoming artifact adapter '${adapter.id}' could not open the file reference.`, ); } + try { validateIncomingArtifactSource(source); } catch (error) { source?.stream?.destroy?.(); throw error; } + return { ...source, adapterId: adapter.id }; } } @@ -131,6 +146,7 @@ export function createOpenAIIncomingArtifactAdapter( ): IncomingArtifactAdapter { const fetchFile = options.fetch ?? globalThis.fetch; const timeoutMs = options.timeoutMs ?? OPENAI_FILE_DOWNLOAD_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { throw new ArtifactError( "invalid_openai_file_adapter", @@ -146,6 +162,7 @@ export function createOpenAIIncomingArtifactAdapter( let downloadUrl = validateOpenAIFileUrl(reference.download_url); let response: Response | undefined; + for (let redirect = 0; redirect <= OPENAI_FILE_REDIRECT_LIMIT; redirect += 1) { try { response = await fetchFile(downloadUrl, { @@ -162,12 +179,14 @@ export function createOpenAIIncomingArtifactAdapter( if (!isRedirectStatus(response.status)) break; const location = response.headers.get("location"); await response.body?.cancel().catch(() => undefined); + if (!location || redirect === OPENAI_FILE_REDIRECT_LIMIT) { throw new ArtifactError( "openai_file_download_failed", "ChatGPT file download returned an invalid redirect.", ); } + downloadUrl = validateOpenAIFileUrl(new URL(location, downloadUrl).toString()); } @@ -180,6 +199,7 @@ export function createOpenAIIncomingArtifactAdapter( } const responseSize = responseContentLength(response); + if ( reference.size !== undefined && responseSize !== undefined @@ -191,6 +211,7 @@ export function createOpenAIIncomingArtifactAdapter( "ChatGPT file metadata did not match the downloaded content.", ); } + const mimeType = reference.mime_type ?? responseMimeType(response); return { @@ -229,12 +250,19 @@ export function describeIncomingArtifactValue( const describe = (current: unknown, depth: number): IncomingArtifactValueShape => { if (current === null) return { type: "null" }; + if (current === undefined) return { type: "undefined" }; + if (typeof current === "boolean") return { type: "boolean" }; + if (typeof current === "number") return { type: "number", finite: Number.isFinite(current) }; + if (typeof current === "bigint") return { type: "bigint" }; + if (typeof current === "function") return { type: "function" }; + if (typeof current === "symbol") return { type: "symbol" }; + if (typeof current === "string") { return { type: "string", @@ -242,6 +270,7 @@ export function describeIncomingArtifactValue( length: current.length, }; } + if (seen.has(current)) return { type: "cycle" }; seen.add(current); @@ -249,7 +278,9 @@ export function describeIncomingArtifactValue( if (depth >= maxDepth) { return { type: "array", length: current.length, items: [], truncated: current.length > 0 }; } + const items = current.slice(0, maxEntries).map((item) => describe(item, depth + 1)); + return { type: "array", length: current.length, @@ -259,6 +290,7 @@ export function describeIncomingArtifactValue( } const keys = Object.keys(current).sort(); + if (depth >= maxDepth) { return { type: "object", @@ -267,16 +299,21 @@ export function describeIncomingArtifactValue( truncated: keys.length > 0, }; } + const entries: Record = {}; + for (const [index, key] of keys.slice(0, maxEntries).entries()) { let entryValue: unknown; + try { entryValue = (current as Record)[key]; } catch { entryValue = undefined; } + entries[safeValueEntryKey(key, index)] = describe(entryValue, depth + 1); } + return { type: "object", constructor: safeConstructorName(current), @@ -295,18 +332,21 @@ function validateIncomingArtifactSource(source: IncomingArtifactSource): void { "Incoming artifact adapter returned an invalid source.", ); } + if (typeof source.name !== "string" || source.name.length === 0) { throw new ArtifactError( "invalid_incoming_artifact_source", "Incoming artifact adapter must provide a filename.", ); } + if (source.mimeType !== undefined && typeof source.mimeType !== "string") { throw new ArtifactError( "invalid_incoming_artifact_source", "Incoming artifact adapter returned an invalid MIME hint.", ); } + if ( source.size !== undefined && (!Number.isSafeInteger(source.size) || source.size < 0) @@ -316,7 +356,9 @@ function validateIncomingArtifactSource(source: IncomingArtifactSource): void { "Incoming artifact adapter returned an invalid byte size.", ); } + const stream = source.stream as Partial | undefined; + if (!stream || typeof stream[Symbol.asyncIterator] !== "function") { throw new ArtifactError( "invalid_incoming_artifact_source", @@ -328,6 +370,7 @@ function validateIncomingArtifactSource(source: IncomingArtifactSource): void { function isOpenAIFileReferenceCandidate(value: unknown): value is Record { if (!isRecord(value)) return false; const keys = Object.keys(value); + return keys.length >= 2 && keys.every((key) => OPENAI_FILE_KEYS.has(key)) && Object.hasOwn(value, "download_url") @@ -344,6 +387,7 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { const downloadUrl = value.download_url; const fileId = value.file_id; + if ( typeof downloadUrl !== "string" || typeof fileId !== "string" @@ -358,14 +402,17 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { const mimeType = nullableString(value.mime_type); const fileName = nullableString(value.file_name); const nameAlias = nullableString(value.name); + if (mimeType === null || fileName === null || nameAlias === null) { throw new ArtifactError( "invalid_openai_file_reference", "ChatGPT file reference is malformed.", ); } + const normalizedFileName = normalizeSuppliedOpenAIFileName(fileName); const normalizedNameAlias = normalizeSuppliedOpenAIFileName(nameAlias); + if ( normalizedFileName && normalizedNameAlias @@ -379,6 +426,7 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { let size: number | undefined; const rawSize = value.size; + if (rawSize !== undefined && rawSize !== null) { if (typeof rawSize !== "number" || !Number.isSafeInteger(rawSize) || rawSize < 0) { throw new ArtifactError( @@ -386,6 +434,7 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { "ChatGPT file reference is malformed.", ); } + size = rawSize; } @@ -400,6 +449,7 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { function nullableString(value: unknown): string | undefined | null { if (value === undefined || value === null) return undefined; + return typeof value === "string" ? value : null; } @@ -409,9 +459,11 @@ function normalizeOpenAIFileName( mimeType: string | undefined, ): string { if (suppliedName) return suppliedName; + const safeBaseName = OPENAI_FILENAME_SAFE_FILE_ID_PATTERN.test(fileId) ? fileId : "chatgpt-file"; + return `${safeBaseName}${extensionForMimeType(mimeType) ?? ".bin"}`; } @@ -425,9 +477,11 @@ function normalizeSuppliedOpenAIFileName(value: string | undefined): string | un if (!value) return undefined; const normalized = value.replaceAll("\\", "/"); const candidate = basename(normalized).trim(); + if (!candidate || candidate === "." || candidate === ".." || candidate.startsWith(".")) { return undefined; } + return candidate; } @@ -447,6 +501,7 @@ function extensionForMimeType(mimeType: string | undefined): string | undefined function validateOpenAIFileUrl(value: string): string { let url: URL; + try { url = new URL(value); } catch { @@ -455,6 +510,7 @@ function validateOpenAIFileUrl(value: string): string { "ChatGPT file download URL is invalid.", ); } + if ( url.protocol !== "https:" || !isTrustedOpenAIFileHost(url.hostname) @@ -468,6 +524,7 @@ function validateOpenAIFileUrl(value: string): string { "ChatGPT file download URL is outside the trusted file host.", ); } + return url.toString(); } @@ -485,13 +542,16 @@ function isRedirectStatus(status: number): boolean { function responseMimeType(response: Response): string | undefined { const value = response.headers.get("content-type")?.split(";", 1)[0]?.trim(); + return value || undefined; } function responseContentLength(response: Response): number | undefined { const value = response.headers.get("content-length"); + if (!value || !/^\d+$/u.test(value)) return undefined; const size = Number(value); + return Number.isSafeInteger(size) ? size : undefined; } @@ -503,13 +563,17 @@ function classifyValueString( value: string, ): "absolute-path" | "url" | "data-url" | "text" { if (value.startsWith("data:")) return "data-url"; + if (isAbsolute(value)) return "absolute-path"; + try { const parsed = new URL(value); + if (parsed.protocol === "http:" || parsed.protocol === "https:") return "url"; } catch { // Non-URL strings are summarized only by type and length. } + return "text"; } @@ -522,6 +586,7 @@ function safeValueEntryKey(value: string, index: number): string { function safeConstructorName(value: object): string | undefined { try { const name = value.constructor?.name; + return typeof name === "string" && name.length <= 80 ? name : undefined; } catch { return undefined; diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 7d08daa48..bb85a776c 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -17,15 +17,18 @@ import { GrokPromptCompletionRegistry } from "./local-agent-grok.js"; function fakeCursorConnection() { const requests: Array<{ method: string; params?: unknown }> = []; const queues = new Map(); + const connection = { agent: { async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { requests.push({ method, params }); const input = params as { sessionId?: string } | undefined; + if (method === "session/new") { const sessionId = "cursor_session_1"; queues.set(sessionId, { values: [] }); + return { sessionId, configOptions: [ @@ -34,13 +37,17 @@ function fakeCursorConnection() { ], }; } + if (method === "session/resume") { const sessionId = input?.sessionId ?? "cursor_session_1"; queues.set(sessionId, { values: [] }); + return { sessionId }; } + if (method === "session/prompt" && (params as { prompt: Array<{ text: string }> }).prompt[0]?.text === "use tools") { const sessionId = input?.sessionId ?? ""; + for (const update of [ { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Let me " } }, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "look." } }, @@ -48,8 +55,10 @@ function fakeCursorConnection() { { sessionUpdate: "tool_call_update", toolCallId: "call_1", status: "completed" }, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Done." } }, ]) deliverAcpSessionUpdate(queues, { sessionId, update } as never); + return { stopReason: "end_turn" }; } + if (method === "session/prompt") { const queue = queues.get(input?.sessionId ?? ""); queue?.values.push({ @@ -58,14 +67,17 @@ function fakeCursorConnection() { content: { type: "text", text: "ACP response" }, }, }); + return { stopReason: "end_turn" }; } + return {}; }, }, close() {}, closed: new Promise(() => undefined), }; + const runtime = new AcpRuntime({ flavor: "cursor", command: "cursor-agent", @@ -74,6 +86,7 @@ function fakeCursorConnection() { capabilities: { resume: true, close: true, additionalDirectories: true }, queues, }, connection); + return { requests, queues, connection, runtime }; } @@ -90,9 +103,12 @@ async function runFirstAndWarm({ runtime }: CursorFixture, sessionIds: string[] }, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); + assert.equal(firstResult.isOk(), true); + if (firstResult.isErr()) throw firstResult.error; const first = firstResult.value; + const warmResult = await runtime.run({ prompt: "warm", workspaceRoot: "/tmp/project", @@ -103,9 +119,12 @@ async function runFirstAndWarm({ runtime }: CursorFixture, sessionIds: string[] }, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); + assert.equal(warmResult.isOk(), true); + if (warmResult.isErr()) throw warmResult.error; const warm = warmResult.value; + return { first, warm }; } @@ -141,11 +160,13 @@ test("session updates stream as activity with message chunks joined between tool const { runtime } = fixture; const { first } = await runFirstAndWarm(fixture); const acpEvents: unknown[] = []; + const toolsResult = await runtime.run({ prompt: "use tools", workspaceRoot: "/tmp/project", providerSessionId: first.providerSessionId ?? undefined, }, { onEvent: (event) => { acpEvents.push(event); } }); + assert.equal(toolsResult.isOk() && toolsResult.value.finalResponse, "Let me look.Done."); assert.deepEqual(acpEvents, [ { type: "message", text: "Let me look." }, @@ -180,6 +201,7 @@ test("a cold resume keeps persisted config and rejects an unadvertised model ove capabilities: { resume: true, close: false }, queues, }, connection); + const resumedPersistedResult = await resumedRuntime.run({ prompt: "resumed with persisted config", workspaceRoot: "/tmp/project", @@ -187,7 +209,9 @@ test("a cold resume keeps persisted config and rejects an unadvertised model ove model: "model-a", effort: "high", }); + assert.equal(resumedPersistedResult.isOk(), true); + if (resumedPersistedResult.isErr()) throw resumedPersistedResult.error; const resumedPersisted = resumedPersistedResult.value; assert.equal(resumedPersisted.finalResponse, "ACP response"); @@ -196,6 +220,7 @@ test("a cold resume keeps persisted config and rejects an unadvertised model ove 4, "cold resume must not require config metadata just to preserve prior model/effort state", ); + const resumeFailure = await resumedRuntime.run({ prompt: "resumed", workspaceRoot: "/tmp/project", @@ -203,7 +228,9 @@ test("a cold resume keeps persisted config and rejects an unadvertised model ove model: "model-that-is-not-advertised-after-resume", modelOverrideRequested: true, }); + assert.equal(resumeFailure.isErr(), true); + if (resumeFailure.isErr()) assert.equal(resumeFailure.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.equal(requests.filter(({ method }) => method === "session/resume").length, 1); assert.equal(requests.filter(({ method }) => method === "session/set_config_option").length, 4); @@ -218,6 +245,7 @@ test("a cold resume keeps persisted config and rejects an unadvertised model ove test("session close support does not depend on resume support", async () => { const { requests, connection } = fakeCursorConnection(); + const closeOnlyRuntime = new AcpRuntime({ flavor: "cursor", command: "cursor-agent", @@ -225,6 +253,7 @@ test("session close support does not depend on resume support", async () => { env: {}, capabilities: { resume: false, close: true }, }, connection); + await closeOnlyRuntime.releaseSession("close_only_session"); assert.equal( requests.filter(({ method, params }) => method === "session/close" && (params as { sessionId?: string })?.sessionId === "close_only_session").length, @@ -280,15 +309,19 @@ test("a second turn on a session with an active turn is rejected", async () => { let markPromptEntered!: () => void; const overlappingPrompt = new Promise((resolvePrompt) => { releaseOverlappingPrompt = resolvePrompt; }); const promptEntered = new Promise((resolveEntered) => { markPromptEntered = resolveEntered; }); + const overlapConnection = { agent: { async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { const input = params as { sessionId?: string } | undefined; + if (method === "session/new") { overlapQueues.set("overlap_session", { values: [] }); + return { sessionId: "overlap_session" }; } + if (method === "session/prompt") { markPromptEntered(); await overlappingPrompt; @@ -298,14 +331,17 @@ test("a second turn on a session with an active turn is rejected", async () => { content: { type: "text", text: "overlap response" }, }, }); + return { stopReason: "end_turn" }; } + return {}; }, }, close() {}, closed: new Promise(() => undefined), }; + const overlapRuntime = new AcpRuntime({ flavor: "cursor", command: "cursor-agent", @@ -313,11 +349,14 @@ test("a second turn on a session with an active turn is rejected", async () => { env: {}, queues: overlapQueues, }, overlapConnection); + let overlapSessionId: string | undefined; + const firstOverlappingTurn = overlapRuntime.run({ prompt: "first overlapping turn", workspaceRoot: "/tmp/project", }, { onSessionId: (sessionId) => { overlapSessionId = sessionId; } }); + await promptEntered; await assert.rejects( overlapRuntime.run({ @@ -330,6 +369,7 @@ test("a second turn on a session with an active turn is rejected", async () => { releaseOverlappingPrompt(); const completedOverlappingTurn = await firstOverlappingTurn; assert.equal(completedOverlappingTurn.isOk(), true); + if (completedOverlappingTurn.isErr()) throw completedOverlappingTurn.error; assert.equal(completedOverlappingTurn.value.finalResponse, "overlap response"); await overlapRuntime.close(); @@ -385,9 +425,11 @@ test("ACP runtimes are agent-scoped", () => { // Generic ACP agents cannot be held to a restricted write mode, so they run only with full_access. test("generic ACP agents run only with full_access", async () => { const genericDriver = new AcpLocalAgentDriver({ command: process.execPath, args: ["-e", ""] }); + for (const writeMode of ["read_only", "allowed"] as const) { const restricted = await genericDriver.createRuntime({ ...cachedContext, writeMode }); assert.equal(restricted.isErr(), true); + if (restricted.isErr()) assert.match(restricted.error.message, /full_access/); } }); @@ -395,6 +437,7 @@ test("generic ACP agents run only with full_access", async () => { test("a missing ACP command is a retryable protocol error", async () => { const missingCommand = await missingCommandDriver().createRuntime(cachedContext); assert.equal(missingCommand.isErr(), true); + if (missingCommand.isErr()) { assert.equal(missingCommand.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.equal(missingCommand.error.retryable, true); @@ -420,13 +463,16 @@ test( ); await writeFile(command, `@ECHO OFF\r\n"${process.execPath}" "${recorder}" %*\r\n`); const shimDriver = new AcpLocalAgentDriver({ flavor: "copilot", env: process.env, commandResolver: () => command }); + const shimStartup = await shimDriver.createRuntime({ ...cachedContext, providerInstanceId: "copilot", provider: "acp", workspaceRoot, }); + assert.equal(shimStartup.isErr(), true); + if (shimStartup.isErr()) assert.equal(shimStartup.error.code, "PROVIDER_PROTOCOL_ERROR"); const forwarded = JSON.parse(await readFile(marker, "utf8")) as string[]; assert.equal( @@ -459,6 +505,7 @@ for (const [flavor, modeId] of [ const modeRequests: Array<{ method: string; params?: unknown }> = []; const modeQueues = new Map(); let refuseMode = false; + const modeRuntime = new AcpRuntime({ flavor, command: flavor, @@ -470,22 +517,28 @@ for (const [flavor, modeId] of [ async notify(): Promise {}, async request(method: string, params?: unknown): Promise { modeRequests.push({ method, params }); + if (method === "session/new") { modeQueues.set(`${flavor}_mode_session`, { values: [] }); + return { sessionId: `${flavor}_mode_session` }; } + if (method === "session/set_mode" && refuseMode) throw new Error("unknown mode"); + if (method === "session/prompt") { modeQueues.get(`${flavor}_mode_session`)?.values.push({ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } }, }); } + return {}; }, }, close() {}, closed: new Promise(() => undefined), }); + const modeMethods = () => modeRequests.map(({ method }) => method); await modeRuntime.run({ prompt: "write", workspaceRoot: "/tmp/project", writeMode: "allowed" }); @@ -501,10 +554,12 @@ for (const [flavor, modeId] of [ modeRequests.length = 0; const refused = await modeRuntime.run({ prompt: "write", workspaceRoot: "/tmp/project", writeMode: "read_only", providerSessionId: `${flavor}_mode_session` }); assert.equal(refused.isErr(), true); + if (refused.isErr()) { assert.equal(refused.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.match(refused.error.message, /read-only mode/); } + assert.equal(modeMethods().includes("session/prompt"), false, `${flavor} must not prompt without its read-only mode`); await modeRuntime.close(); }); @@ -514,14 +569,17 @@ function grokFixture() { const grokRequests: Array<{ method: string; params?: unknown }> = []; const grokQueues = new Map(); const grokCompletionRegistry = new GrokPromptCompletionRegistry(); + const grokConnection = { agent: { async notify(_method: string, _params?: unknown): Promise {}, async request(method: string, params?: unknown): Promise { grokRequests.push({ method, params }); const input = params as { sessionId?: string; _meta?: { promptId?: string } } | undefined; + if (method === "session/new") { grokQueues.set("grok_session_1", { values: [] }); + return { sessionId: "grok_session_1", models: { @@ -533,7 +591,9 @@ function grokFixture() { }, }; } + if (method === "session/set_model") return {}; + if (method === "session/prompt") { grokQueues.get(input?.sessionId ?? "")?.values.push({ update: { @@ -546,14 +606,17 @@ function grokFixture() { promptId: input?._meta?.promptId, stopReason: "end_turn", })); + return new Promise(() => undefined); } + return {}; }, }, close() {}, closed: new Promise(() => undefined), }; + const grokRuntime = new AcpRuntime({ flavor: "grok", command: "grok", @@ -564,18 +627,22 @@ function grokFixture() { grokCompletionRegistry, promptCompletionTimeoutMs: 100, }, grokConnection); + return { grokRequests, grokCompletionRegistry, grokRuntime }; } test("Grok selects its model and effort and completes the prompt from its completion signal", async () => { const { grokRequests, grokCompletionRegistry, grokRuntime } = grokFixture(); + const grokResult = await grokRuntime.run({ prompt: "which model are you", workspaceRoot: "/tmp/project", model: "grok-4.5", effort: "low", }); + assert.equal(grokResult.isOk(), true); + if (grokResult.isErr()) throw grokResult.error; assert.equal(grokResult.value.finalResponse, "Grok response"); assert.deepEqual( @@ -620,40 +687,49 @@ test("Grok rejects unadvertised models and efforts and lists the available ones" }, }; } + return {}; }, }, close() {}, closed: new Promise(() => undefined), }; + const grokConfigurationRuntime = new AcpRuntime({ flavor: "grok", command: "grok", args: ["agent", "stdio"], env: {}, }, grokConfigurationConnection); + const invalidGrokModel = await grokConfigurationRuntime.run({ prompt: "invalid model", workspaceRoot: "/tmp/project", model: "grok-unknown", }); + assert.equal(invalidGrokModel.isErr(), true); + if (invalidGrokModel.isErr()) { assert.equal(invalidGrokModel.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.equal(invalidGrokModel.error.retryable, false); assert.match(invalidGrokModel.error.message, /Available models: grok-4\.5/); } + const invalidGrokEffort = await grokConfigurationRuntime.run({ prompt: "invalid effort", workspaceRoot: "/tmp/project", effort: "high", }); + assert.equal(invalidGrokEffort.isErr(), true); + if (invalidGrokEffort.isErr()) { assert.equal(invalidGrokEffort.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.equal(invalidGrokEffort.error.retryable, false); assert.match(invalidGrokEffort.error.message, /Available efforts: low/); } + await grokConfigurationRuntime.close(); }); @@ -661,6 +737,7 @@ test("Grok rejects unadvertised models and efforts and lists the available ones" test("a provider that never answers after session/cancel is given up on", async () => { const stuckRequests: string[] = []; const stuckNotifications: string[] = []; + const stuckRuntime = new AcpRuntime({ flavor: "cursor", command: "cursor-agent", @@ -672,19 +749,23 @@ test("a provider that never answers after session/cancel is given up on", async async notify(method: string): Promise { stuckNotifications.push(method); }, async request(method: string): Promise { stuckRequests.push(method); + if (method === "session/new") return { sessionId: "stuck_session" }; + return new Promise(() => undefined); }, }, close() {}, closed: new Promise(() => undefined), }); + const stuckAbort = new AbortController(); const stuckRun = stuckRuntime.run({ prompt: "never answers", workspaceRoot: "/tmp/project", signal: stuckAbort.signal }); await new Promise((resolveTick) => setTimeout(resolveTick, 10)); stuckAbort.abort(); const stuckResult = await stuckRun; assert.equal(stuckResult.isErr(), true); + if (stuckResult.isErr()) assert.equal(stuckResult.error.code, "PROVIDER_CANCELLED"); assert.deepEqual(stuckNotifications, ["session/cancel"]); assert.equal(stuckRuntime.isAlive(), false, "an unresponsive provider is dropped from the pool"); @@ -693,6 +774,7 @@ test("a provider that never answers after session/cancel is given up on", async // Stop requested before the prompt is sent must not start provider work. test("a stop requested before the prompt is sent starts no provider work", async () => { const preAbortRequests: string[] = []; + const preAbortRuntime = new AcpRuntime({ flavor: "cursor", command: "cursor-agent", @@ -703,20 +785,25 @@ test("a stop requested before the prompt is sent starts no provider work", async async notify(): Promise {}, async request(method: string): Promise { preAbortRequests.push(method); + return method === "session/new" ? { sessionId: "pre_abort_session" } : { stopReason: "end_turn" }; }, }, close() {}, closed: new Promise(() => undefined), }); + const preAborted = new AbortController(); preAborted.abort(); + const preAbortResult = await preAbortRuntime.run({ prompt: "should not run", workspaceRoot: "/tmp/project", signal: preAborted.signal, }); + assert.equal(preAbortResult.isErr(), true); + if (preAbortResult.isErr()) assert.equal(preAbortResult.error.code, "PROVIDER_CANCELLED"); assert.equal(preAbortRequests.includes("session/prompt"), false); await preAbortRuntime.close(); diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index f48846cbb..f8c69e061 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -33,12 +33,14 @@ import type { import { resolveExecutableCommand } from "./local-agent-command.js"; export type AcpFlavor = "cursor" | "copilot" | "grok" | "generic"; + export type AcpProvider = AcpFlavor; const MAX_ACP_QUEUE_ITEMS = 10_000; const MAX_ACP_STDERR_BYTES = 32 * 1024; const ACP_INITIALIZE_TIMEOUT_MS = 10_000; const ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS = 10 * 60_000; + // How long a provider gets to answer session/prompt after session/cancel before its process is dropped. const ACP_CANCEL_GRACE_MS = 10_000; const require = createRequire(import.meta.url); @@ -158,19 +160,26 @@ export class AcpRuntime implements LocalAgentRuntime { message: `${this.provider} ACP runtime is not running.`, }); } + const sessionId = await this.openSession(input, callbacks); + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); + if (this.activeSessions.has(sessionId)) { throw new TypeError(`${this.provider} ACP session ${sessionId} already has an active turn.`); } + this.activeSessions.add(sessionId); + const removeAbort = bindLocalAgentAbort(input.signal, () => ( this.connection.agent.notify("session/cancel", { sessionId }) )); + // A provider that ignores session/cancel would leave the turn running forever: // give up on it after a grace period and drop the process so the next turn starts clean. let cancelTimer: NodeJS.Timeout | undefined; let onAbort: (() => void) | undefined; + const cancelDeadline = input.signal && new Promise((_, reject) => { onAbort = () => { cancelTimer = setTimeout(() => { @@ -180,13 +189,16 @@ export class AcpRuntime implements LocalAgentRuntime { ))); }, this.cancelGraceMs); }; + if (input.signal?.aborted) onAbort(); else input.signal?.addEventListener("abort", onAbort, { once: true }); }); + cancelDeadline?.catch(() => undefined); const queue = this.queues.get(sessionId) ?? { values: [] }; this.queues.set(sessionId, queue); const promptId = this.flavor === "grok" ? this.nextPromptId() : undefined; + const completion = promptId && this.grokCompletionRegistry ? this.grokCompletionRegistry.wait( sessionId, @@ -201,16 +213,22 @@ export class AcpRuntime implements LocalAgentRuntime { }), ) : undefined; + const activity = callbacks?.onEvent ? new AcpActivityReporter(callbacks.onEvent) : undefined; + if (activity) queue.onUpdate = (value) => activity.handle(value); + try { queue.values.length = 0; + const standardResponse = this.connection.agent.request("session/prompt", { sessionId, prompt: [{ type: "text", text: input.prompt }], ...(promptId ? { _meta: { promptId, requestId: promptId } } : {}), }); + let response: unknown; + try { response = await Promise.race([ standardResponse, @@ -221,15 +239,19 @@ export class AcpRuntime implements LocalAgentRuntime { if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); throw cause; } + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); + if (completion && isGrokPromptCompletion(response)) { await yieldToAcpQueue(); } else if (promptId) { this.grokCompletionRegistry?.markCompleted(sessionId, promptId); } + const updates = queue.values.splice(0); activity?.flush(); const finalResponse = extractAcpText(updates); + if (!finalResponse) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -240,6 +262,7 @@ export class AcpRuntime implements LocalAgentRuntime { message: `${this.provider} ACP did not return a final assistant response.`, }); } + return { provider: this.provider, providerSessionId: sessionId, @@ -250,7 +273,9 @@ export class AcpRuntime implements LocalAgentRuntime { queue.onUpdate = undefined; removeAbort(); clearTimeout(cancelTimer); + if (onAbort) input.signal?.removeEventListener("abort", onAbort); + if (promptId) this.grokCompletionRegistry?.remove(sessionId, promptId); this.activeSessions.delete(sessionId); } @@ -263,6 +288,7 @@ export class AcpRuntime implements LocalAgentRuntime { this.liveSessions.delete(providerSessionId); this.sessionWriteModes.delete(providerSessionId); this.sessionMetadata.delete(providerSessionId); + if (!this.capabilities.close || !this.isAlive()) return; await this.connection.agent.request("session/close", { sessionId: providerSessionId }); } @@ -282,9 +308,11 @@ export class AcpRuntime implements LocalAgentRuntime { this.activeSessions.clear(); this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP runtime closed.`)); this.connection.close(new Error(`${this.provider} ACP runtime closed.`)); + if (this.child && this.child.exitCode === null) { const detached = process.platform !== "win32"; terminateProcessTree(this.child, "SIGTERM", detached); + if (!await waitForProcessExit(this.child, 1_000)) { terminateProcessTree(this.child, "SIGKILL", detached); } @@ -302,8 +330,10 @@ export class AcpRuntime implements LocalAgentRuntime { this.sessionMetadata.get(input.providerSessionId), false, ); + return input.providerSessionId; } + if (!this.capabilities.resume) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -313,6 +343,7 @@ export class AcpRuntime implements LocalAgentRuntime { message: `${this.provider} ACP does not advertise session resume support.`, }); } + const response = await this.connection.agent.request("session/resume", { sessionId: input.providerSessionId, cwd: input.workspaceRoot, @@ -320,12 +351,14 @@ export class AcpRuntime implements LocalAgentRuntime { ...this.additionalDirectoryParams(), ...this.permissionMeta(input), }); + this.cacheSessionMetadata(input.providerSessionId, response); this.queues.set(input.providerSessionId, { values: [] }); this.liveSessions.add(input.providerSessionId); this.sessionWriteModes.set(input.providerSessionId, input.writeMode ?? "allowed"); await callbacks?.onSessionId?.(input.providerSessionId); await this.configureSession(input.providerSessionId, input, response, false); + return input.providerSessionId; } @@ -335,7 +368,9 @@ export class AcpRuntime implements LocalAgentRuntime { ...this.additionalDirectoryParams(), ...this.permissionMeta(input), }); + const sessionId = readString(response, "sessionId"); + if (!sessionId) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -346,12 +381,14 @@ export class AcpRuntime implements LocalAgentRuntime { message: `${this.provider} ACP did not return a session id.`, }); } + this.cacheSessionMetadata(sessionId, response); this.queues.set(sessionId, { values: [] }); this.liveSessions.add(sessionId); this.sessionWriteModes.set(sessionId, input.writeMode ?? "allowed"); await callbacks?.onSessionId?.(sessionId); await this.configureSession(sessionId, input, response, true); + return sessionId; } @@ -368,12 +405,16 @@ export class AcpRuntime implements LocalAgentRuntime { isNewSession = false, ): Promise { const metadata = response ?? this.sessionMetadata.get(sessionId); + if (this.flavor === "grok") { await this.configureGrokSession(sessionId, input, metadata, isNewSession); + return; } + await this.enforceReadOnlyMode(sessionId, input); const canConfigure = isNewSession || hasAcpConfigOptions(metadata); + if (!canConfigure) { const requested = [ input.model && input.modelOverrideRequested ? "model" : undefined, @@ -381,6 +422,7 @@ export class AcpRuntime implements LocalAgentRuntime { ] .filter(Boolean) .join(" and "); + if (requested) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -390,15 +432,18 @@ export class AcpRuntime implements LocalAgentRuntime { message: `${this.provider} ACP cannot apply the requested ${requested} override because the resumed session did not advertise configurable options.`, }); } + // A durable resumed session keeps its previously selected provider // configuration. If resume does not re-advertise config options, do not // force a redundant set operation for persisted model/effort values. return; } + if (input.model) { const config = resolveAcpModelConfigUpdate(metadata, input.model, this.flavor, sessionId); await this.connection.agent.request("session/set_config_option", config); } + if (input.effort) { const config = resolveAcpEffortConfigUpdate(metadata, input.effort, this.flavor, sessionId); await this.connection.agent.request("session/set_config_option", config); @@ -407,7 +452,9 @@ export class AcpRuntime implements LocalAgentRuntime { private async enforceReadOnlyMode(sessionId: string, input: LocalAgentRunInput): Promise { const modeId = ACP_READ_ONLY_MODES[this.flavor]; + if (input.writeMode !== "read_only" || !modeId) return; + try { await this.connection.agent.request("session/set_mode", { sessionId, modeId }); } catch (cause) { @@ -430,11 +477,13 @@ export class AcpRuntime implements LocalAgentRuntime { isNewSession: boolean, ): Promise { const state = readGrokSessionState(response); + if (!state) { const requested = [ input.model && (isNewSession || input.modelOverrideRequested) ? "model" : undefined, input.effort && (isNewSession || input.effortOverrideRequested) ? "effort" : undefined, ].filter(Boolean).join(" and "); + if (requested) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -444,17 +493,22 @@ export class AcpRuntime implements LocalAgentRuntime { message: `${this.provider} ACP did not advertise typed model metadata required for the requested ${requested} override.`, }); } + return; } const currentModel = state.currentModelId; + const requestedModel = input.model ? resolveGrokModelId(input.model, state) : currentModel ?? state.availableModels[0]?.id ?? GROK_DEFAULT_MODEL; + const effort = input.effort ? resolveGrokEffort(input.effort, state, requestedModel) : undefined; + const shouldSetModel = Boolean(input.model && requestedModel !== currentModel) || effort !== undefined; + if (!shouldSetModel) return; try { @@ -491,6 +545,7 @@ export class AcpRuntime implements LocalAgentRuntime { private nextPromptId(): string { this.promptSequence += 1; + return `devspace-grok-prompt-${this.promptSequence}`; } } @@ -541,6 +596,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { operation: "create_runtime", run: async (): Promise => { const command = this.resolveCommand(); + if (!command) { throw new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -551,6 +607,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { message: `${this.provider} executable was not found.`, }); } + if (this.flavor === "generic" && context.writeMode !== "full_access") { // DevSpace cannot bound what an arbitrary ACP agent writes: it may act // without asking for permission, so only an explicit full_access turn may run it. @@ -563,11 +620,13 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { message: `Generic ACP providers cannot enforce the ${context.writeMode ?? "allowed"} write mode; run them with full_access.`, }); } + // Built-in flavors derive their sandbox flags from the write mode, so only // generic providers take configured arguments. const args = this.flavor === "generic" ? this.configuredArgs ?? [] : acpCommandArgs(this.flavor, context, this.env); + const child = spawn(command, args, { cwd: resolve(context.workspaceRoot), env: this.env, @@ -575,20 +634,26 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { detached: process.platform !== "win32", windowsHide: true, }); + let resolveStartupError!: (error: Error) => void; const startupError = new Promise((resolveError) => { resolveStartupError = resolveError; }); const onStartupError = (error: Error) => { resolveStartupError(error); }; + child.once("error", onStartupError); + if (!child.stdin || !child.stdout || !child.stderr) { child.on("error", observeChildError); child.removeListener("error", onStartupError); + if (child.exitCode === null) { const detached = process.platform !== "win32"; terminateProcessTree(child, "SIGTERM", detached); + if (!await waitForProcessExit(child, 1_000)) { terminateProcessTree(child, "SIGKILL", detached); } } + throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", provider: this.provider, @@ -605,17 +670,21 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { child.stderr.on("data", (chunk: string) => { stderrTail = appendTail(stderrTail, chunk, MAX_ACP_STDERR_BYTES); }); + try { const { client, methods, ndJsonStream } = await import("@agentclientprotocol/sdk"); const queues = new Map(); const sessionWriteModes = new Map(); + const grokCompletionRegistry = this.flavor === "grok" ? new GrokPromptCompletionRegistry() : undefined; + const app = client({ name: "DevSpace" }) .onRequest(methods.client.session.requestPermission, (context) => { const writeMode = sessionWriteModes.get(context.params.sessionId); const selected = selectAcpPermissionOption(context.params.options, writeMode, this.flavor); + return selected ? { outcome: { outcome: "selected", optionId: selected.optionId } } : { outcome: { outcome: "cancelled" } }; @@ -623,6 +692,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { .onNotification(methods.client.session.update, (context) => { deliverAcpSessionUpdate(queues, context.params); }); + if (grokCompletionRegistry) { for (const method of [ "x.ai/session/prompt_complete", @@ -635,11 +705,14 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { }); } } + const stream = ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, Readable.toWeb(child.stdout) as ReadableStream, ); + connection = app.connect(stream) as unknown as AcpConnectionLike; + const init = await withTimeout(Promise.race([ connection.agent.request(methods.agent.initialize, { protocolVersion: 1, @@ -651,7 +724,9 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { ACP_INITIALIZE_TIMEOUT_MS, `${this.provider} ACP initialize timed out.`, ); + const capabilities = readAcpCapabilities(init); + const runtime = new AcpRuntime({ flavor: this.flavor, command, @@ -663,25 +738,31 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { sessionWriteModes, grokCompletionRegistry, }, connection); + // AcpRuntime installs the long-lived child error listener before this // startup-only listener is removed, so there is no unobserved gap. child.removeListener("error", onStartupError); + return runtime; } catch (error) { child.on("error", observeChildError); child.removeListener("error", onStartupError); + try { connection?.close(error); } catch { // The child still needs to be terminated if the protocol failed early. } + if (child.exitCode === null) { const detached = process.platform !== "win32"; terminateProcessTree(child, "SIGTERM", detached); + if (!await waitForProcessExit(child, 1_000)) { terminateProcessTree(child, "SIGKILL", detached); } } + if (isProgrammerDefect(error)) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -702,6 +783,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { this.resolvedCommand = this.commandResolver(this.flavor, this.env, this.configuredCommand); this.commandResolved = true; } + return this.resolvedCommand; } } @@ -711,16 +793,20 @@ async function waitForProcessExit( timeoutMs: number, ): Promise { if (child.exitCode !== null) return true; + return new Promise((resolve) => { const timer = setTimeout(() => { child.removeListener("exit", onExit); resolve(false); }, timeoutMs); + timer.unref(); + const onExit = () => { clearTimeout(timer); resolve(true); }; + child.once("exit", onExit); }); } @@ -737,8 +823,11 @@ export function resolveAcpCommand( : provider === "grok" ? env.GROK_COMMAND : undefined); + const command = configured ?? ACP_COMMANDS[provider]?.[0]; + if (!command) return undefined; + return resolveExecutableCommand(command, env); } @@ -759,6 +848,7 @@ export function acpCommandArgs( env: NodeJS.ProcessEnv = process.env, ): string[] { const writeMode = context.writeMode ?? "allowed"; + if (provider === "cursor") { return [ "acp", @@ -767,11 +857,14 @@ export function acpCommandArgs( ...(writeMode === "full_access" ? ["--force"] : []), ]; } + if (provider === "grok") { const agentProfile = env.GROK_AGENT_PROFILE?.trim(); + const effort = context.effort ? resolveGrokEffort(context.effort, undefined, undefined) : undefined; + return [ "agent", ...(agentProfile ? ["--agent-profile", agentProfile] : []), @@ -779,10 +872,13 @@ export function acpCommandArgs( "stdio", ]; } + if (provider === "generic") return []; + const sandboxArgs = writeMode === "full_access" ? ["--no-sandbox"] : ["--experimental", "--sandbox"]; + return [ "--acp", ...sandboxArgs, @@ -834,39 +930,51 @@ function resolveAcpSelectConfigUpdate( }, ): { sessionId: string; configId: string; value: string } { const record = asRecord(session); + if (!record) throw new Error(`${options.provider} ACP session metadata is missing.`); const sessionId = options.sessionIdOverride ?? directString(record?.sessionId); + if (!sessionId) throw new Error(`${options.provider} ACP session did not return a session id.`); const response = asRecord(record?.newSessionResponse) ?? record; const configOptions = readArray(response, "configOptions") ?? []; + const config = configOptions .map(asRecord) .find((option) => option?.type === "select" && option.category === options.category); + if (!config) throw new Error(`${options.provider} ACP server does not expose a ${options.label}.`); const configId = directString(config.id); + if (!configId) throw new Error(`${options.provider} ACP ${options.label} is missing an id.`); const available = flattenAcpSelectValues(config); + if (!available.includes(options.value)) { const suffix = available.length > 0 ? ` Available values: ${available.join(", ")}.` : ""; throw new Error(`${options.provider} ACP ${options.label} does not support '${options.value}'.${suffix}`); } + return { sessionId, configId, value: options.value }; } export function flattenAcpSelectValues(option: Record): string[] { const values: string[] = []; + for (const item of readArray(option, "options") ?? []) { const record = asRecord(item); const value = directString(record?.value); + if (value) { values.push(value); continue; } + for (const nested of readArray(record, "options") ?? []) { const nestedValue = directString(asRecord(nested)?.value); + if (nestedValue) values.push(nestedValue); } } + return values; } @@ -882,6 +990,7 @@ export function selectAcpPermissionOption( provider?: AcpFlavor, ): { optionId: string } | undefined { if (!writeMode) return undefined; + // Copilot's native sandbox has a per-command escape hatch enabled by // default. Normal turns already pass --allow-all-tools, so any permission // request that reaches ACP is an attempted escalation (including a @@ -889,17 +998,20 @@ export function selectAcpPermissionOption( // authority. Full access deliberately keeps the provider's unrestricted // behavior. if (provider === "copilot" && writeMode !== "full_access") return undefined; + const selected = writeMode === "read_only" ? options.find((option) => option.kind === "reject_once") ?? options.find((option) => option.kind === "reject_always") : options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always"); + return selected ? { optionId: selected.optionId } : undefined; } function readAcpCapabilities(value: unknown): AcpCapabilities { const capabilities = asRecord(asRecord(value)?.agentCapabilities); const sessions = asRecord(capabilities?.sessionCapabilities); + return { resume: Boolean(sessions?.resume), close: Boolean(sessions?.close), @@ -912,6 +1024,7 @@ function extractAcpText(updates: unknown[]): string { .map((value) => { const update = asRecord(asRecord(value)?.update); const content = asRecord(update?.content); + return update?.sessionUpdate === "agent_message_chunk" && content?.type === "text" && typeof content.text === "string" ? content.text : ""; @@ -922,6 +1035,7 @@ function extractAcpText(updates: unknown[]): string { function isGrokPromptCompletion(value: unknown): boolean { const record = asRecord(value); + return typeof record?.sessionId === "string"; } @@ -932,6 +1046,7 @@ async function yieldToAcpQueue(): Promise { function hasAcpConfigOptions(value: unknown): boolean { const record = asRecord(value); const response = asRecord(record?.newSessionResponse) ?? record; + return Array.isArray(response?.configOptions); } @@ -941,6 +1056,7 @@ export function deliverAcpSessionUpdate( params: { sessionId: string }, ): void { const queue = queues.get(params.sessionId); + if (queue) appendAcpQueueValue(queue, params); } @@ -962,29 +1078,40 @@ export class AcpActivityReporter { handle(value: unknown): void { const update = asRecord(asRecord(value)?.update); + switch (update?.sessionUpdate) { case "agent_message_chunk": { const content = asRecord(update.content); + if (content?.type === "text" && typeof content.text === "string") this.text += content.text; + return; } + case "tool_call": { this.flush(); + const call = { tool: typeof update.kind === "string" && update.kind ? update.kind : "tool", summary: typeof update.title === "string" ? update.title : summarizeToolInput(update.rawInput), }; + const id = typeof update.toolCallId === "string" ? update.toolCallId : undefined; + if (id) this.toolCalls.set(id, call); this.onEvent({ type: "tool_started", ...call }); this.complete(id, update.status); + return; } + case "tool_call_update": { const id = typeof update.toolCallId === "string" ? update.toolCallId : undefined; const call = id ? this.toolCalls.get(id) : undefined; + if (call && typeof update.title === "string") call.summary = update.title; this.complete(id, update.status); + return; } } @@ -998,6 +1125,7 @@ export class AcpActivityReporter { private complete(id: string | undefined, status: unknown): void { if (status !== "completed" && status !== "failed") return; const call = id ? this.toolCalls.get(id) : undefined; + if (!call || !id) return; this.toolCalls.delete(id); this.onEvent({ type: "tool_completed", ...call, status }); @@ -1006,16 +1134,20 @@ export class AcpActivityReporter { function appendTail(current: string, chunk: string, maxBytes: number): string { const next = current + chunk; + if (Buffer.byteLength(next, "utf8") <= maxBytes) return next; + return Buffer.from(next, "utf8").subarray(-maxBytes).toString("utf8"); } async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); timer.unref(); }); + try { return await Promise.race([promise, timeout]); } finally { @@ -1025,11 +1157,13 @@ async function withTimeout(promise: Promise, timeoutMs: number, message: s function readArray(value: unknown, key: string): unknown[] | undefined { const result = asRecord(value)?.[key]; + return Array.isArray(result) ? result : undefined; } function readString(value: unknown, key: string): string | undefined { const result = asRecord(value)?.[key]; + return typeof result === "string" ? result : undefined; } diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index cbcec0617..ffd9579cd 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -214,6 +214,7 @@ test("multiple instances of one driver are created with agent-scoped runtimes", { id: "claude-personal", driver: "claude", enabled: true }, ], }); + const drivers = createLocalAgentDrivers({ subagents }); assert.deepEqual( drivers.map((driver) => [driver.providerInstanceId, driver.provider]), diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 765a439a7..e0959f85b 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -47,12 +47,15 @@ export function createLocalAgentDrivers( options: LocalAgentDriverOptions = {}, ): LocalAgentDriver[] { const env = options.env ?? process.env; + const instances = options.subagents?.providers ?? LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => ({ id, driver: id === "cursor" || id === "copilot" || id === "grok" ? "acp" : id, enabled: true, } satisfies SubagentProviderConfig)); + const registry = createLocalAgentProviderRegistry(options); + return instances.map((instance) => registry.create(instance, options.subagents, env)); } diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index d4a95ce75..080eb6553 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -8,6 +8,7 @@ import { getLocalAgentProviderAvailabilitySnapshot } from "./local-agent-availab function tempDirectory(t: TestContext, prefix: string): string { const directory = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; } @@ -16,6 +17,7 @@ test("a missing or empty provider command is reported unavailable", () => { ...process.env, CODEX_COMMAND: "/definitely/missing/devspace-codex", }); + assert.deepEqual(snapshot.find((provider) => provider.name === "codex"), { name: "codex", available: false, @@ -48,6 +50,7 @@ test("a configured provider command is preflighted without exposing its env", (t ); writeFileSync(executable, "#!/bin/sh\nexit 0\n"); chmodSync(executable, 0o700); + const availability = getLocalAgentProviderAvailabilitySnapshot( { ...process.env, @@ -66,6 +69,7 @@ test("a configured provider command is preflighted without exposing its env", (t }], }, ).find((provider) => provider.name === "codex"); + assert.deepEqual(availability, { name: "codex", available: true, @@ -80,6 +84,7 @@ test("configured ACP providers are listed once and preflight their flavor's exec writeFileSync(grok, "#!/bin/sh\nexit 0\n"); chmodSync(grok, 0o700); const env = { PATH: directory }; + const config = { enabled: true, instructions: "on-demand" as const, @@ -89,6 +94,7 @@ test("configured ACP providers are listed once and preflight their flavor's exec { id: "kiro", driver: "acp" as const, enabled: true, command: "kiro" }, ], }; + const snapshot = getLocalAgentProviderAvailabilitySnapshot(env, config); assert.deepEqual(snapshot.filter((provider) => provider.name === "cursor").length, 1, "configured built-in ACP ids are listed once"); assert.equal(snapshot.find((provider) => provider.name === "cursor-work")?.available, true, "preflight checks the flavor's executable"); @@ -101,13 +107,17 @@ test("PI_COMMAND and OPENCODE_COMMAND override executables found on PATH", (t) = const pi = join(directory, "pi"); writeFileSync(pi, "#!/bin/sh\nexit 0\n"); chmodSync(pi, 0o700); + const availability = (env: NodeJS.ProcessEnv) => getLocalAgentProviderAvailabilitySnapshot(env) .find((provider) => provider.name === "pi"); + assert.equal(availability({ PATH: directory })?.available, true, "a system pi on PATH is launchable"); assert.equal(availability({ PATH: directory, PI_COMMAND: "/definitely/missing/pi" })?.available, false); assert.match(availability({ PATH: directory, PI_COMMAND: "/definitely/missing/pi" })?.reason ?? "", /missing\/pi/); + const opencode = (env: NodeJS.ProcessEnv) => getLocalAgentProviderAvailabilitySnapshot(env) .find((provider) => provider.name === "opencode"); + assert.equal(opencode({ PATH: "", OPENCODE_COMMAND: pi })?.available, true, "OPENCODE_COMMAND names the executable"); assert.equal(opencode({ PATH: directory, OPENCODE_COMMAND: "/definitely/missing/opencode" })?.available, false); }); diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index a7cf4a0d1..81c9c6c3e 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -28,10 +28,12 @@ export function getLocalAgentProviderAvailabilitySnapshot( ): LocalAgentProviderAvailability[] { const configured = new Map(config?.providers.map((provider) => [provider.id, provider]) ?? []); const defaultIds = new Set(LOCAL_AGENT_DEFAULT_PROVIDER_IDS); + const instances = [ ...LOCAL_AGENT_DEFAULT_PROVIDER_IDS.map((id) => configured.get(id) ?? { id, driver: defaultDriverForProviderId(id)! }), ...(config?.providers.filter((provider) => !defaultIds.has(provider.id)) ?? []), ]; + return instances.map((instance) => checkLocalAgentProviderAvailability( instance.id, instance.driver, @@ -47,6 +49,7 @@ function checkLocalAgentProviderAvailability( config?: SubagentsConfig, ): LocalAgentProviderAvailability { const providerEnv = config ? localAgentProviderEnvironment(config, providerInstanceId, env) : env; + switch (driver) { case "codex": return codexAvailability(providerInstanceId, providerEnv); @@ -60,6 +63,7 @@ function checkLocalAgentProviderAvailability( // Same resolution as the runtime: PI_COMMAND, then `pi` on PATH, then the bundled package. try { resolvePiCommand(providerEnv); + return { name: providerInstanceId, available: true }; } catch (cause) { return { @@ -68,13 +72,16 @@ function checkLocalAgentProviderAvailability( reason: cause instanceof Error ? cause.message : "Pi executable not found", }; } + case "acp": { // Resolve exactly as the driver does, so preflight and startup agree on the executable. const configured = config ? subagentProviderConfig(config, providerInstanceId) : undefined; const flavor = subagentAcpFlavor({ id: providerInstanceId, config: configured?.config }); + if (resolveAcpCommand(flavor ?? "generic", providerEnv, configured?.command)) { return { name: providerInstanceId, available: true }; } + return { name: providerInstanceId, available: false, @@ -95,9 +102,12 @@ export function localAgentProviderAvailability( config?: SubagentsConfig, ): LocalAgentProviderAvailability { const provider = config ? subagentProviderConfig(config, providerInstanceId) : undefined; + const driver = provider?.driver ?? defaultDriverForProviderId(providerInstanceId); + if (!driver) return { name: providerInstanceId, available: false, reason: "provider is not configured" }; + return checkLocalAgentProviderAvailability(providerInstanceId, driver, env, config); } @@ -107,6 +117,7 @@ function packageAvailability( ): LocalAgentProviderAvailability { try { import.meta.resolve(packageName); + return { name: providerInstanceId, available: true }; } catch { return { @@ -122,6 +133,7 @@ function codexAvailability( env: NodeJS.ProcessEnv, ): LocalAgentProviderAvailability { const availability = commandAvailability(providerInstanceId, env.CODEX_COMMAND ?? "codex", env); + return availability.available ? { ...availability, @@ -136,6 +148,7 @@ function commandAvailability( env: NodeJS.ProcessEnv, ): LocalAgentProviderAvailability { if (resolveExecutableCommand(command, env)) return { name: providerInstanceId, available: true }; + return { name: providerInstanceId, available: false, diff --git a/src/local-agent-cancellation.ts b/src/local-agent-cancellation.ts index e8d47a9d7..5ff2fe3bd 100644 --- a/src/local-agent-cancellation.ts +++ b/src/local-agent-cancellation.ts @@ -22,7 +22,9 @@ export function bindLocalAgentAbort( ): () => void { if (!signal) return () => undefined; const onAbort = () => { void Promise.resolve(interrupt()).catch(() => undefined); }; + if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); + return () => signal.removeEventListener("abort", onAbort); } diff --git a/src/local-agent-catalog.ts b/src/local-agent-catalog.ts index b446adab0..05d50b794 100644 --- a/src/local-agent-catalog.ts +++ b/src/local-agent-catalog.ts @@ -33,6 +33,7 @@ export function buildLocalAgentProviderStatuses( const live = availability.find((entry) => entry.name === id); const enabled = configured?.enabled === true; const available = live?.available === true; + return { id, driver: configured.driver, @@ -53,9 +54,11 @@ export function buildLocalAgentCatalog( providers: readonly LocalAgentProviderStatus[], ): LocalAgentCatalog { const visibleProviders = providers.filter((provider) => provider.enabled); + const usable = new Map( visibleProviders.filter((provider) => provider.usable).map((provider) => [provider.id, provider]), ); + return { enabled: config.enabled, providers: visibleProviders, @@ -63,6 +66,7 @@ export function buildLocalAgentCatalog( .filter((profile) => !profile.disabled && usable.has(profile.provider)) .map((profile) => { const provider = usable.get(profile.provider)!; + return { name: profile.name, description: profile.description, @@ -85,6 +89,7 @@ export function formatLocalAgentProviderStatusSummary( : !provider.available ? `unavailable: ${provider.reason ?? "provider preflight failed"}` : "subagents disabled"; + return `${provider.id} (${state})`; }).join(", "); } diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index d1c2bb714..2c9c66147 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -29,13 +29,16 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { async next(): Promise> { if (this.pending.length > 0) return { done: false, value: this.pending.shift() }; const next = await this.iterator.next(); + if (next.done) return { done: true, value: undefined }; + if (next.value.message.content === "use tools") { this.pending.push( { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "toolu_1", is_error: true }] } }, { type: "assistant", message: { content: [{ type: "text", text: "The tests fail." }] } }, { type: "result", session_id: "claude_session_1", result: "done" }, ); + return { done: false, value: { @@ -44,6 +47,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { }, }; } + return { done: false, value: { @@ -89,12 +93,15 @@ function claudeDriverFixture() { lastOptions: undefined as Record | undefined, query: undefined as FakeClaudeQuery | undefined, }; + const driver = new ClaudeLocalAgentDriver(({ prompt, options }) => { state.factoryCalls += 1; state.lastOptions = options; state.query = new FakeClaudeQuery(prompt); + return state.query; }, { PATH: "/usr/bin" }); + return { driver, state }; } @@ -133,6 +140,7 @@ function configuredClaudeDriver(onOptions: (options: Record) => }), claudeQueryFactory: ({ prompt, options }) => { onOptions(options); + return new FakeClaudeQuery(prompt); }, }).find((driver) => driver.provider === "claude"); @@ -151,9 +159,11 @@ test("successive turns reuse one Claude query and apply each turn's authority", const { driver, state } = claudeDriverFixture(); const runtimeResult = await driver.createRuntime(context); assert.equal(runtimeResult.isOk(), true); + if (runtimeResult.isErr()) throw runtimeResult.error; const runtime = runtimeResult.value; const sessionIds: string[] = []; + const firstResult = await runtime.run({ prompt: "first", workspaceRoot: "/tmp/project", @@ -163,24 +173,31 @@ test("successive turns reuse one Claude query and apply each turn's authority", }, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); + assert.equal(firstResult.isOk(), true); + if (firstResult.isErr()) throw firstResult.error; const first = firstResult.value; + const secondResult = await runtime.run({ prompt: "second", workspaceRoot: "/tmp/project", effort: "low", writeMode: "allowed", }); + assert.equal(secondResult.isOk(), true); + if (secondResult.isErr()) throw secondResult.error; const second = secondResult.value; + const third = await runtime.run({ prompt: "third", workspaceRoot: "/tmp/project", effort: "high", writeMode: "full_access", }); + assert.equal(third.isOk(), true); assert.equal(state.factoryCalls, 1, "successive turns reuse one Claude query"); assert.equal(first.providerSessionId, "claude_session_1"); @@ -255,13 +272,17 @@ test("a cold runtime resumes its provider session", async () => { test("CLAUDE_COMMAND overrides the Claude Code executable", async () => { let lastOptions: Record | undefined; + const customCommandDriver = new ClaudeLocalAgentDriver(({ prompt, options }) => { lastOptions = options; + return new FakeClaudeQuery(prompt); }, { CLAUDE_COMMAND: "/opt/claude" }); + const customCommandRuntime = await customCommandDriver.createRuntime(context); assert.equal(customCommandRuntime.isOk(), true); assert.equal(lastOptions?.pathToClaudeCodeExecutable, "/opt/claude"); + if (customCommandRuntime.isOk()) await customCommandRuntime.value.close(); }); @@ -269,7 +290,9 @@ test("an aborted SDK start is a cancelled error", async () => { const cancelled = await new ClaudeLocalAgentDriver(async () => { throw new DOMException("cancelled", "AbortError"); }).createRuntime(context); + assert.equal(cancelled.isErr(), true); + if (cancelled.isErr()) assert.equal(cancelled.error.code, "PROVIDER_CANCELLED"); }); @@ -277,16 +300,20 @@ test("an SDK start failure is an execution error", async () => { const execution = await new ClaudeLocalAgentDriver(async () => { throw new Error("sdk failed"); }).createRuntime(context); + assert.equal(execution.isErr(), true); + if (execution.isErr()) assert.equal(execution.error.code, "PROVIDER_EXECUTION_ERROR"); }); test("a refused connection mid-stream is a retryable unavailable error", async () => { const brokenStreamRuntimeResult = await new ClaudeLocalAgentDriver(async () => brokenStreamQuery).createRuntime(context); assert.equal(brokenStreamRuntimeResult.isOk(), true); + if (brokenStreamRuntimeResult.isErr()) throw brokenStreamRuntimeResult.error; const brokenStream = await brokenStreamRuntimeResult.value.run({ prompt: "fail", workspaceRoot: "/tmp/project" }); assert.equal(brokenStream.isErr(), true); + if (brokenStream.isErr()) { assert.equal(brokenStream.error.code, "PROVIDER_UNAVAILABLE"); assert.equal(brokenStream.error.retryable, true); @@ -304,7 +331,9 @@ test("a missing Claude Code native binary is unavailable", async () => { }; }, }; + const missingExecutableRuntime = await new ClaudeLocalAgentDriver(async () => missingExecutableQuery).createRuntime(context); + if (missingExecutableRuntime.isErr()) throw missingExecutableRuntime.error; const missingExecutable = await missingExecutableRuntime.value.run({ prompt: "fail", workspaceRoot: "/tmp/project" }); assert.equal(missingExecutable.isErr() && missingExecutable.error.code, "PROVIDER_UNAVAILABLE"); @@ -326,6 +355,7 @@ test("a configured provider command and env reach the Claude SDK", async () => { assert.ok(configuredDriver); const configuredRuntime = await configuredDriver.createRuntime(context); assert.equal(configuredRuntime.isOk(), true); + if (configuredRuntime.isErr()) throw configuredRuntime.error; assert.equal(configuredOptions?.pathToClaudeCodeExecutable, "/opt/bin/claude-wrapper"); assert.deepEqual(configuredOptions?.env, { @@ -343,12 +373,15 @@ test("complete SDK messages map onto tool and message events", async () => { const configuredDriver = configuredClaudeDriver(() => {}); assert.ok(configuredDriver); const configuredRuntime = await configuredDriver.createRuntime(context); + if (configuredRuntime.isErr()) throw configuredRuntime.error; assert.equal(configuredDriver.capabilities.turns.streaming, true); const claudeEvents: unknown[] = []; + const streamed = await configuredRuntime.value.run({ prompt: "use tools", workspaceRoot: "/tmp/project" }, { onEvent: (event) => { claudeEvents.push(event); }, }); + assert.equal(streamed.isOk() && streamed.value.finalResponse, "done"); assert.deepEqual(claudeEvents, [ { type: "tool_started", tool: "Bash", summary: "pnpm test" }, diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index d2f9d3ccc..e535089c5 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -57,6 +57,7 @@ class AsyncInputQueue implements AsyncIterable { push(value: T): void { if (this.closed) throw new Error("Claude input stream is closed."); const waiter = this.waiters.shift(); + if (waiter) waiter.resolve({ done: false, value }); else this.values.push(value); } @@ -64,6 +65,7 @@ class AsyncInputQueue implements AsyncIterable { close(): void { if (this.closed) return; this.closed = true; + while (this.waiters.length > 0) this.waiters.shift()!.resolve({ done: true, value: undefined }); } @@ -73,8 +75,11 @@ class AsyncInputQueue implements AsyncIterable { next(): Promise> { const value = this.values.shift(); + if (value !== undefined) return Promise.resolve({ done: false, value }); + if (this.closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise((resolve, reject) => this.waiters.push({ resolve, reject })); } } @@ -109,19 +114,25 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude runtime is not running.", }); } + const removeAbort = bindLocalAgentAbort(input.signal, () => this.query.interrupt()); + try { if (this.providerSessionId) await callbacks?.onSessionId?.(this.providerSessionId); const flagSettings = claudeAuthoritySettings(input.workspaceRoot, input.writeMode); + if (input.effort) { Object.assign(flagSettings, { alwaysThinkingEnabled: true, effortLevel: input.effort, }); } + await this.query.applyFlagSettings(flagSettings); await this.query.setPermissionMode(claudePermissionMode(input.writeMode)); + if (input.model && this.query.setModel) await this.query.setModel(input.model); + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run"); this.inputQueue.push({ type: "user", @@ -131,13 +142,16 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { const items: unknown[] = []; const toolCalls = new Map(); + for (;;) { let next: IteratorResult; + try { next = await this.iterator.next(); } catch (error) { if (input.signal?.aborted) throw localAgentCancelledError("claude", "run", error); this.alive = false; + if (isProgrammerDefect(error) && !isClaudeExecutableError(error)) throw error; throw new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -148,6 +162,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude query stream failed.", }); } + if (next.done) { this.alive = false; throw new AgentProviderProtocolError({ @@ -158,21 +173,28 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude query ended before returning a result.", }); } + const message = next.value; items.push(message); const record = asRecord(message); + if (record && callbacks?.onEvent) reportClaudeActivity(record, toolCalls, callbacks.onEvent); + if (typeof record?.session_id === "string") { const previousSessionId = this.providerSessionId; this.providerSessionId = record.session_id; + if (previousSessionId !== this.providerSessionId) { await callbacks?.onSessionId?.(this.providerSessionId); } } + if (record?.type !== "result") continue; + if (input.signal?.aborted) throw localAgentCancelledError("claude", "run"); const resultError = claudeResultError(record); + if (resultError) { throw new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", @@ -183,7 +205,9 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude agent turn failed.", }); } + const finalResponse = typeof record.result === "string" ? record.result.trim() : ""; + if (!finalResponse) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -193,6 +217,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude did not return a final assistant response.", }); } + return { provider: this.provider, providerSessionId: this.providerSessionId ?? null, @@ -252,6 +277,7 @@ export class ClaudeLocalAgentDriver implements LocalAgentDriver { operation: "create_runtime", run: async (): Promise => { const inputQueue = new AsyncInputQueue(); + const input: LocalAgentRunInput = { prompt: "", workspaceRoot: context.workspaceRoot, @@ -260,11 +286,13 @@ export class ClaudeLocalAgentDriver implements LocalAgentDriver { model: context.model, effort: context.effort, }; + const query = await this.factory({ context, options: claudeQueryOptions(context, input, this.env), prompt: inputQueue, }); + return new ClaudeQueryRuntime(query, inputQueue, context); }, }); @@ -276,6 +304,7 @@ async function defaultClaudeQueryFactory({ prompt, }: ClaudeQueryFactoryInput): Promise { const { query } = await import("@anthropic-ai/claude-agent-sdk"); + return query({ prompt, options: options as never, @@ -290,6 +319,7 @@ export function claudeQueryOptions( const executable = env.CLAUDE_COMMAND; const permissionMode = claudePermissionMode(input.writeMode); const authority = claudeAuthorityOptions(input.workspaceRoot, input.writeMode); + return { cwd: input.workspaceRoot, ...(input.model ? { model: input.model } : {}), @@ -337,6 +367,7 @@ function claudeAuthorityOptions( enabled: false, allowUnsandboxedCommands: true, }; + return { sandbox, settings: { @@ -347,10 +378,12 @@ function claudeAuthorityOptions( } const allowed = writeMode !== "read_only"; + const permissions = { defaultMode: "dontAsk", deny: allowed ? [] : ["Bash", "Edit"], }; + const sandbox = { enabled: true, failIfUnavailable: true, @@ -361,11 +394,13 @@ function claudeAuthorityOptions( denyWrite: allowed ? [] : [workspaceRoot], }, }; + return { sandbox, settings: { permissions, sandbox } }; } export function claudeCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env }; + for (const key of [ "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", @@ -374,19 +409,23 @@ export function claudeCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.Process ]) { delete next[key]; } + return next; } export function claudeResultError(record: Record): string | undefined { const subtype = typeof record.subtype === "string" ? record.subtype : undefined; const isError = record.is_error === true || subtype?.startsWith("error"); + if (!isError) return undefined; + const message = directString(record.error) ?? directString(record.message) ?? directString(record.result) ?? subtype ?? "Claude returned an error result."; + return `Claude returned an error result: ${message}`; } @@ -403,17 +442,22 @@ export function reportClaudeActivity( ): void { if (record.type !== "assistant" && record.type !== "user") return; const content = asRecord(record.message)?.content; + if (!Array.isArray(content)) return; + for (const value of content) { const block = asRecord(value); + if (record.type === "assistant" && block?.type === "text" && typeof block.text === "string") { onEvent({ type: "message", text: block.text }); } else if (record.type === "assistant" && block?.type === "tool_use" && typeof block.name === "string") { const call = { tool: block.name, summary: summarizeToolInput(block.input) }; + if (typeof block.id === "string") toolCalls.set(block.id, call); onEvent({ type: "tool_started", ...call }); } else if (record.type === "user" && block?.type === "tool_result" && typeof block.tool_use_id === "string") { const call = toolCalls.get(block.tool_use_id); + if (!call) continue; toolCalls.delete(block.tool_use_id); onEvent({ type: "tool_completed", ...call, status: block.is_error === true ? "failed" : "completed" }); diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 04a0b0e07..6b331812c 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -131,6 +131,7 @@ export class LocalAgentClient { input: StartLocalAgentInput, ): Promise> { const result = await this.request("agent.start", input); + return decodeRequestResult(result, "agent.start", decodeAgentRecord); } @@ -146,6 +147,7 @@ export class LocalAgentClient { scope, ...(Object.keys(overrides).length > 0 ? { overrides } : {}), }); + return decodeRequestResult(result, "agent.continue", decodeAgentRecord); } @@ -154,6 +156,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("agent.get", { id: agentId, scope }); + return decodeRequestResult(result, "agent.get", decodeAgentRecord); } @@ -161,6 +164,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("agent.list", scope); + return decodeRequestResult(result, "agent.list", decodeAgentRecordList); } @@ -172,11 +176,13 @@ export class LocalAgentClient { const transportTimeoutMs = timeoutMs === undefined ? null : Math.min(2_147_483_647, timeoutMs + this.requestTimeoutMs); + const result = await this.request("agent.wait", { ids: [...agentIds], scope, ...(timeoutMs === undefined ? {} : { timeoutMs }), }, transportTimeoutMs); + return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); } @@ -185,6 +191,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("agent.stop", { id: agentId, scope }); + return decodeRequestResult(result, "agent.stop", decodeAgentRecord); } @@ -194,6 +201,7 @@ export class LocalAgentClient { query: LocalAgentTurnEventsQuery = {}, ): Promise> { const result = await this.request("agent.events", { id: agentId, scope, ...query }); + return decodeRequestResult(result, "agent.events", decodeTurnEvents); } @@ -201,6 +209,7 @@ export class LocalAgentClient { input: WorkflowRunInput, ): Promise> { const result = await this.request("workflow.run", input); + return decodeRequestResult(result, "workflow.run", decodeWorkflowRun); } @@ -209,6 +218,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("workflow.get", { id, scope }); + return decodeRequestResult(result, "workflow.get", decodeWorkflowRun); } @@ -216,6 +226,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("workflow.list", scope); + return decodeRequestResult(result, "workflow.list", decodeWorkflowRunList); } @@ -225,6 +236,7 @@ export class LocalAgentClient { timeoutMs = 60_000, ): Promise> { const result = await this.request("workflow.wait", { id, scope, timeoutMs }, timeoutMs + this.requestTimeoutMs); + return decodeRequestResult(result, "workflow.wait", decodeWorkflowRun); } @@ -233,6 +245,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("workflow.calls", { id, scope }); + return decodeRequestResult(result, "workflow.calls", decodeWorkflowCallList); } @@ -242,6 +255,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("workflow.call", { id, index, scope }); + return decodeRequestResult(result, "workflow.call", decodeWorkflowCall); } @@ -251,6 +265,7 @@ export class LocalAgentClient { after = 0, ): Promise> { const result = await this.request("workflow.events", { id, scope, after }); + return decodeRequestResult(result, "workflow.events", decodeWorkflowEventList); } @@ -259,21 +274,25 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("workflow.cancel", { id, scope }); + return decodeRequestResult(result, "workflow.cancel", decodeWorkflowRun); } async status(): Promise> { const result = await this.requestExisting("daemon.status", {}); + return decodeRequestResult(result, "daemon.status", decodeDaemonStatus); } async stop(): Promise> { const result = await this.requestExisting("daemon.stop", {}); + return decodeRequestResult(result, "daemon.stop", decodeDaemonStatus); } async logs(lines = 200): Promise> { const result = await this.requestExisting("daemon.logs", { lines }); + return decodeRequestResult(result, "daemon.logs", decodeDaemonLogs); } @@ -282,19 +301,25 @@ export class LocalAgentClient { this.startupPromise = this.ensureReadyInternal().finally(() => { this.startupPromise = undefined; }); + return this.startupPromise; } private async ensureReadyForObservation(): Promise> { const existing = await this.tryHello(true); + if (existing.isErr()) return existing; + if (existing.value) return Result.ok(existing.value); + return this.ensureReady(); } private async ensureReadyInternal(): Promise> { const existing = await this.tryHello(); + if (existing.isErr()) return existing; + if (existing.value) return Result.ok(existing.value); try { @@ -308,21 +333,27 @@ export class LocalAgentClient { message: `Unable to start the local agent daemon in ${this.stateDir}.`, })); } + const deadline = Date.now() + this.startupTimeoutMs; let lastError: AgentDaemonError | undefined; + while (Date.now() < deadline) { await delay(RETRY_DELAY_MS); const ready = await this.tryHello(); + if (ready.isErr()) { lastError = ready.error; + if ( ready.error.code === "DAEMON_PROTOCOL_MISMATCH" || ready.error.code === "DAEMON_INVALID_RESPONSE" ) return ready; continue; } + if (ready.value) return Result.ok(ready.value); } + return Result.err(new AgentDaemonStartupError({ code: "DAEMON_STARTUP_FAILURE", operation: "startup", @@ -336,7 +367,9 @@ export class LocalAgentClient { allowStaleBusyConfig = false, ): Promise> { const authToken = this.authTokenResult("hello"); + if (authToken.isErr()) return authToken; + const response = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -345,15 +378,19 @@ export class LocalAgentClient { params: {}, configRevision: this.configRevision, }, this.requestTimeoutMs); + if (response.isErr()) { if ( response.error.code === "DAEMON_UNAVAILABLE" || response.error.code === "DAEMON_TIMEOUT" ) return Result.ok(undefined); + return response; } + if (!response.value.ok) { const error = decodeRemoteError(response.value.error, "hello"); + if (!isAgentDaemonError(error)) { return Result.err(new AgentDaemonInvalidResponseError({ code: "DAEMON_INVALID_RESPONSE", @@ -363,22 +400,29 @@ export class LocalAgentClient { message: "Local agent daemon returned an invalid hello error.", })); } + if ( error.code === "DAEMON_PROTOCOL_MISMATCH" && response.value.protocolVersion < LOCAL_AGENT_DAEMON_PROTOCOL_VERSION ) { return this.replaceIdleOlderDaemon(authToken.value, response.value.protocolVersion, error); } + return error.code === "DAEMON_UNAVAILABLE" ? Result.ok(undefined) : Result.err(error); } + const decoded = decodeValue(response.value.result, "hello", decodeDaemonHello); + if (decoded.isErr()) return decoded; + if (!decoded.value.configMatches) { if (allowStaleBusyConfig && daemonBusy(decoded.value.status)) { return Result.ok(decoded.value.status); } + return this.replaceIdleChangedDaemon(authToken.value, decoded.value.status); } + return Result.ok(decoded.value.status.state === "ready" ? decoded.value.status : undefined); } @@ -394,7 +438,9 @@ export class LocalAgentClient { ? "The local agent daemon is running active turns with an older provider configuration. Retry after they finish." : "The local agent daemon is using an older provider configuration.", }); + if (daemonBusy(status)) return Result.err(changed); + return this.stopIdleDaemon(authToken, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, status, changed); } @@ -411,9 +457,12 @@ export class LocalAgentClient { params: {}, configRevision: this.configRevision, }, this.requestTimeoutMs); + if (statusResponse.isErr() || !statusResponse.value.ok) return Result.err(mismatch); const status = decodeValue(statusResponse.value.result, "hello", decodeDaemonStatus); + if (status.isErr()) return status; + if (daemonBusy(status.value)) { return Result.err(new AgentDaemonProtocolMismatchError({ code: "DAEMON_PROTOCOL_MISMATCH", @@ -444,11 +493,14 @@ export class LocalAgentClient { ? { ifIdle: true } : {}, }, this.requestTimeoutMs); + if (stopResponse.isErr() || !stopResponse.value.ok) return Result.err(cause); const deadline = Date.now() + this.startupTimeoutMs; + while (Date.now() < deadline) { await delay(RETRY_DELAY_MS); + const probe = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion, @@ -457,25 +509,32 @@ export class LocalAgentClient { params: {}, configRevision: this.configRevision, }, Math.min(this.requestTimeoutMs, 250)); + if (probe.isErr() && probe.error.code === "DAEMON_UNAVAILABLE") { if (!existsSync(this.paths.lockPath) || !isProcessAlive(status.pid)) { return Result.ok(undefined); } + continue; } + if (probe.isOk() && probe.value.protocolVersion > protocolVersion) { // Another client completed the replacement while this client was // waiting for the old endpoint to disappear. return this.tryHello(); } + if (probe.isOk() && probe.value.ok && protocolVersion === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) { const hello = decodeValue(probe.value.result, "hello", decodeDaemonHello); + if (hello.isErr()) return hello; + if (hello.value.configMatches && hello.value.status.state === "ready") { return Result.ok(hello.value.status); } } } + return Result.err(new AgentDaemonStartupError({ code: "DAEMON_STARTUP_FAILURE", operation: "startup", @@ -493,9 +552,12 @@ export class LocalAgentClient { const ready = await (isObservationRequest(method) ? this.ensureReadyForObservation() : this.ensureReady()); + if (ready.isErr()) return ready as BetterResult>; const authToken = this.authTokenResult(method); + if (authToken.isErr()) return authToken as BetterResult>; + const response = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -503,11 +565,14 @@ export class LocalAgentClient { method, params, } as LocalAgentDaemonRequest, timeoutMs ?? undefined); + if (response.isErr()) return response as BetterResult>; + if (!response.value.ok) { const error = isWorkflowMethod(method) ? decodeWorkflowRemoteError(response.value.error) : decodeRemoteError(response.value.error, method); + if (!isRequestError(method, error)) { return Result.err(new AgentDaemonInvalidResponseError({ code: "DAEMON_INVALID_RESPONSE", @@ -517,8 +582,10 @@ export class LocalAgentClient { message: "Local agent daemon returned an error that is invalid for this request.", })) as BetterResult>; } + return Result.err(error) as BetterResult>; } + return Result.ok(response.value.result); } @@ -527,7 +594,9 @@ export class LocalAgentClient { params: Extract['params'], ): Promise> { const authToken = this.existingAuthTokenResult(method); + if (authToken.isErr()) return authToken; + if (!authToken.value) { return Result.err(new AgentDaemonUnavailableError({ code: "DAEMON_UNAVAILABLE", @@ -536,6 +605,7 @@ export class LocalAgentClient { message: "Local agent daemon is not running.", })); } + const response = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -543,10 +613,14 @@ export class LocalAgentClient { method, params, } as LocalAgentDaemonRequest, this.requestTimeoutMs); + if (response.isErr()) return response; + if (!response.value.ok) { const error = decodeRemoteError(response.value.error, method); + if (isAgentDaemonError(error)) return Result.err(error); + return Result.err(new AgentDaemonInvalidResponseError({ code: "DAEMON_INVALID_RESPONSE", operation: method, @@ -555,6 +629,7 @@ export class LocalAgentClient { message: "Local agent daemon returned an invalid daemon-control error.", })); } + return Result.ok(response.value.result); } @@ -565,6 +640,7 @@ export class LocalAgentClient { return Result.ok(ensureLocalAgentDaemonSecret(this.paths)); } catch (cause) { if (isProgrammerDefect(cause)) throw cause; + return Result.err(new AgentDaemonUnavailableError({ code: "DAEMON_UNAVAILABLE", operation, @@ -582,6 +658,7 @@ export class LocalAgentClient { return Result.ok(readLocalAgentDaemonSecret(this.paths)); } catch (cause) { if (isProgrammerDefect(cause)) throw cause; + return Result.err(new AgentDaemonUnavailableError({ code: "DAEMON_UNAVAILABLE", operation, @@ -617,12 +694,14 @@ export function spawnLocalAgentDaemon( env: NodeJS.ProcessEnv = process.env, ): void { const entrypoint = resolveDaemonEntrypoint(); + const child = spawn(process.execPath, [...daemonExecArgv(process.execArgv), entrypoint], { detached: true, stdio: "ignore", windowsHide: true, env: localAgentDaemonEnvironment(configDir, env), }); + child.unref(); } @@ -635,22 +714,29 @@ export function localAgentDaemonEnvironment( export function daemonExecArgv(execArgv: readonly string[]): string[] { const result: string[] = []; + for (let index = 0; index < execArgv.length; index += 1) { const argument = execArgv[index]!; + if (/^--inspect(?:-brk|-wait)?(?:=.*)?$/.test(argument)) continue; + if (argument === "--inspect-port") { index += 1; continue; } + if (argument.startsWith("--inspect-port=")) continue; result.push(argument); } + return result; } export function resolveDaemonEntrypoint(): string { const compiled = fileURLToPath(new URL("./local-agent-daemon-main.js", import.meta.url)); + if (existsSync(compiled)) return compiled; + return fileURLToPath(new URL("./local-agent-daemon-main.ts", import.meta.url)); } @@ -663,6 +749,7 @@ async function sendRequest( const socket = createConnection(endpoint); let buffer = ""; let settled = false; + const timer = timeoutMs === undefined ? undefined : setTimeout(() => { @@ -680,7 +767,9 @@ async function sendRequest( ) => { if (settled) return; settled = true; + if (timer) clearTimeout(timer); + if (destroy) socket.destroy(); resolve(result); }; @@ -689,12 +778,16 @@ async function sendRequest( socket.on("data", (chunk: string | Buffer) => { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); + if (newline === -1) return; + try { const response = decodeLocalAgentDaemonResponse(JSON.parse(buffer.slice(0, newline)) as unknown); + if (response.requestId !== request.requestId) { throw new LocalAgentDaemonProtocolError("INVALID_RESPONSE", "Daemon response request id did not match."); } + finish(Result.ok(response)); socket.end(); } catch (cause) { @@ -759,6 +852,7 @@ function decodeRemoteError( operation: string, ): LocalAgentError { const decoded = agentErrorFromPayload(payload); + return decoded ?? new AgentDaemonInvalidResponseError({ code: "DAEMON_INVALID_RESPONSE", operation, @@ -770,6 +864,7 @@ function decodeRemoteError( function decodeWorkflowRemoteError(payload: LocalAgentDaemonErrorPayload): LocalAgentError | WorkflowError { const decoded = agentErrorFromPayload(payload); + return decoded && isAgentDaemonError(decoded) ? decoded : new WorkflowError(payload.code, payload.message, payload.retryable ?? false); @@ -786,6 +881,7 @@ function isRequestError( error: LocalAgentError | WorkflowError, ): boolean { if (error instanceof WorkflowError) return method.startsWith("workflow."); + const category = matchError(error, { AgentTargetError: () => "target" as const, AgentConflictError: () => "conflict" as const, @@ -805,7 +901,9 @@ function isRequestError( AgentDaemonInternalError: () => "daemon" as const, AgentStoreError: () => "store" as const, }); + if (category === "daemon") return true; + switch (method) { case "agent.start": case "agent.continue": diff --git a/src/local-agent-codex.test.ts b/src/local-agent-codex.test.ts index 91d4ea7aa..e5eda06d9 100644 --- a/src/local-agent-codex.test.ts +++ b/src/local-agent-codex.test.ts @@ -90,6 +90,7 @@ async function codexFixture(t: TestContext) { await rm(root, { recursive: true, force: true }); }); await runtime.initialize(); + return runtime; } @@ -129,6 +130,7 @@ test("command resolution skips candidates whose version probe exits non-zero", s test("a new thread is reported, resumed, and released", skipOnWindows, async (t) => { const runtime = await codexFixture(t); let callbackSessionId: string | undefined; + const firstResult = await runtime.run({ prompt: "first", workspaceRoot: "/tmp/project", @@ -136,15 +138,20 @@ test("a new thread is reported, resumed, and released", skipOnWindows, async (t) model: "gpt-5.4", effort: "high", }, { onSessionId: (id) => { callbackSessionId = id; } }); + assert.equal(firstResult.isOk(), true); + if (firstResult.isErr()) throw firstResult.error; const first = firstResult.value; + const resumedResult = await runtime.run({ prompt: "resumed", workspaceRoot: "/tmp/project", providerSessionId: first.providerSessionId ?? undefined, }); + assert.equal(resumedResult.isOk(), true); + if (resumedResult.isErr()) throw resumedResult.error; const resumed = resumedResult.value; assert.equal(first.providerSessionId, "thread_new"); @@ -157,12 +164,15 @@ test("a new thread is reported, resumed, and released", skipOnWindows, async (t) test("a failed turn is a non-retryable provider execution error", skipOnWindows, async (t) => { const runtime = await codexFixture(t); + const failed = await runtime.run({ prompt: "fail", workspaceRoot: "/tmp/project", providerSessionId: "thread_new", }); + assert.equal(failed.isErr(), true); + if (failed.isErr()) { assert.equal(failed.error.code, "PROVIDER_EXECUTION_ERROR"); assert.equal(failed.error.provider, "codex"); @@ -174,11 +184,13 @@ test("a failed turn is a non-retryable provider execution error", skipOnWindows, test("command items stream as tool events before the final message", skipOnWindows, async (t) => { const runtime = await codexFixture(t); const codexEvents: unknown[] = []; + const tools = await runtime.run({ prompt: "tools", workspaceRoot: "/tmp/project", providerSessionId: "thread_new", }, { onEvent: (event) => { codexEvents.push(event); } }); + assert.equal(tools.isOk() && tools.value.finalResponse, "Tests fail."); assert.deepEqual(codexEvents, [ { type: "tool_started", tool: "shell", summary: "pnpm test" }, @@ -189,12 +201,15 @@ test("command items stream as tool events before the final message", skipOnWindo test("a turn without a final message is a protocol error that keeps its cause internal", skipOnWindows, async (t) => { const runtime = await codexFixture(t); + const protocolFailure = await runtime.run({ prompt: "empty", workspaceRoot: "/tmp/project", providerSessionId: "thread_new", }); + assert.equal(protocolFailure.isErr(), true); + if (protocolFailure.isErr()) { assert.equal(protocolFailure.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.equal(protocolFailure.error.provider, "codex"); @@ -206,25 +221,31 @@ test("a turn without a final message is a protocol error that keeps its cause in test("empty turn items fall back to item/completed stream items", skipOnWindows, async (t) => { const runtime = await codexFixture(t); + const notLoaded = await runtime.run({ prompt: "notLoaded", workspaceRoot: "/tmp/project", providerSessionId: "thread_new", }); + assert.equal(notLoaded.isOk(), true, "empty turn.items must fall back to item/completed stream items"); + if (notLoaded.isErr()) throw notLoaded.error; assert.equal(notLoaded.value.finalResponse, "CODEXOK"); }); test("allowed mode sends a workspace-write sandbox policy with network access", skipOnWindows, async (t) => { const runtime = await codexFixture(t); + const policy = await runtime.run({ prompt: "policy", workspaceRoot: "/tmp/project", writeMode: "allowed", providerSessionId: "thread_new", }); + assert.equal(policy.isOk(), true); + if (policy.isErr()) throw policy.error; assert.deepEqual(JSON.parse(policy.value.finalResponse), { type: "workspaceWrite", networkAccess: true }); }); @@ -232,6 +253,7 @@ test("allowed mode sends a workspace-write sandbox policy with network access", test("a driver without a resolvable command is unavailable", async () => { const unavailable = await new CodexLocalAgentDriver({}, () => undefined).createRuntime(cachedContext); assert.equal(unavailable.isErr(), true); + if (unavailable.isErr()) { assert.equal(unavailable.error.code, "PROVIDER_UNAVAILABLE"); assert.equal(unavailable.error.retryable, false); diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index 6d063bbac..c8d39cc2a 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -33,14 +33,18 @@ export type CodexCommandResolver = (env: NodeJS.ProcessEnv) => ResolvedCodexComm export function codexCommandEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { const next = { ...env }; delete next.CODEX_INTERNAL_ORIGINATOR_OVERRIDE; + if (env.CODEX_COMMAND) return next; + if (next.PATH) next.PATH = removeDevspaceNodeModulesBinFromPath(next.PATH); + return next; } export function resolveCodexCommand(env: NodeJS.ProcessEnv = process.env): ResolvedCodexCommand | undefined { const command = env.CODEX_COMMAND ?? "codex"; const probeEnv = codexCommandEnvironment(env); + for (const candidate of commandCandidates(command, probeEnv)) { const result = spawnSync(candidate, ["--version"], { encoding: "utf8", @@ -49,11 +53,16 @@ export function resolveCodexCommand(env: NodeJS.ProcessEnv = process.env): Resol timeout: 5_000, shell: usesWindowsCommandShell(candidate), }); + const code = result.error && "code" in result.error ? result.error.code : undefined; + if (code === "ENOENT") continue; + if (result.error || result.status !== 0) continue; + return { executable: candidate, version: parseCodexVersion(result.stdout) }; } + return undefined; } @@ -68,11 +77,13 @@ export function isCodexAppServerSupported( timeout: 5_000, shell: usesWindowsCommandShell(command), }); + return result.error === undefined && result.status === 0; } export function parseCodexVersion(output: string | undefined): string | undefined { const match = output?.trim().match(/v?(\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)/); + return match?.[1]; } @@ -132,11 +143,14 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { message: "Codex app-server is not running.", }); } + const threadResponse = await this.rpc.request( input.providerSessionId ? "thread/resume" : "thread/start", threadParams(input), ); + const threadId = readString(asRecord(threadResponse)?.thread, "id"); + if (!threadId) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -149,16 +163,20 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { } await callbacks?.onSessionId?.(threadId); + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); let completed: CodexTurnResult; + try { completed = await this.rpc.runTurn(threadId, turnParams(input, threadId), input.signal, callbacks?.onEvent); } catch (cause) { if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run", cause); throw cause; } + if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); const parsed = parseCompletedTurn(completed.event.params, completed.items); + if (parsed.failure) { throw new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", @@ -169,6 +187,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { message: "Codex agent turn failed.", }); } + if (!parsed.finalResponse.trim()) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -179,6 +198,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { message: "Codex did not return a final assistant response.", }); } + return { provider: this.provider, providerSessionId: threadId, @@ -191,6 +211,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { async releaseSession(providerSessionId: string): Promise { if (!this.alive) return; + try { await this.rpc.request("thread/unsubscribe", { threadId: providerSessionId }); } catch { @@ -207,14 +228,18 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { this.closePromise = (async () => { this.alive = false; this.rpc.fail(new Error("codex app-server closed.")); + if (!this.child.stdin.destroyed) this.child.stdin.end(); + if (this.child.exitCode === null) { terminateProcessTree(this.child, "SIGTERM", process.platform !== "win32"); + if (!await waitForProcessExit(this.child, 1_000)) { terminateProcessTree(this.child, "SIGKILL", process.platform !== "win32"); } } })(); + return this.closePromise; } } @@ -224,16 +249,20 @@ async function waitForProcessExit( timeoutMs: number, ): Promise { if (child.exitCode !== null) return true; + return new Promise((resolve) => { const timer = setTimeout(() => { child.removeListener("exit", onExit); resolve(false); }, timeoutMs); + timer.unref(); + const onExit = () => { clearTimeout(timer); resolve(true); }; + child.once("exit", onExit); }); } @@ -264,6 +293,7 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { operation: "create_runtime", run: async (): Promise => { const command = this.resolveCommand(); + if (!command) { throw new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -273,6 +303,7 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { message: "Codex executable was not found.", }); } + if (!isCodexAppServerSupported(command.executable, this.env)) { throw new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -282,13 +313,16 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { message: "Installed Codex does not support app-server.", }); } + const runtime = new CodexAppServerRuntime({ command: command.executable, env: codexCommandEnvironment(this.env), version: command.version, }); + try { await runtime.initialize(); + return runtime; } catch (cause) { await runtime.close(); @@ -310,6 +344,7 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { this.resolvedCommand = this.commandResolver(this.env); this.commandResolved = true; } + return this.resolvedCommand; } } @@ -362,6 +397,7 @@ class CodexAppServerRpc { request(method: string, params?: unknown): Promise { if (this.fatalError) return Promise.reject(this.fatalError); const id = String(this.nextId++); + return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.write({ id, method, ...(params === undefined ? {} : { params }) }); @@ -379,13 +415,16 @@ class CodexAppServerRpc { onEvent?: (event: LocalAgentActivityEvent) => void, ): Promise { if (this.fatalError) throw this.fatalError; + if (this.turns.has(threadId)) throw new Error(`Codex thread ${threadId} already has an active turn.`); let resolveTurn!: (result: CodexTurnResult) => void; let rejectTurn!: (error: Error) => void; + const completion = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject; }); + const turn: CodexTurnAccumulator = { threadId, items: [], @@ -393,23 +432,30 @@ class CodexAppServerRpc { resolve: resolveTurn, reject: rejectTurn, }; + this.turns.set(threadId, turn); + const removeAbort = bindLocalAgentAbort(signal, async () => { if (!turn.turnId) return; await this.request("turn/interrupt", { threadId, turnId: turn.turnId }); }); + try { const response = await this.request("turn/start", params); turn.turnId = readString(asRecord(response)?.turn, "id"); + if (signal?.aborted && turn.turnId) { // A failed interrupt must not report the turn as stopped: keep waiting for the // provider's terminal event; the manager bounds how long stop waits for it. await this.request("turn/interrupt", { threadId, turnId: turn.turnId }).catch(() => undefined); } + if (turn.completed) return { event: turn.completed, items: turn.items }; + return await completion; } finally { removeAbort(); + if (this.turns.get(threadId) === turn) this.turns.delete(threadId); } } @@ -417,7 +463,9 @@ class CodexAppServerRpc { fail(error: Error): void { if (this.fatalError) return; this.fatalError = new Error(`${error.message}${this.stderr.trim() ? `\n${this.stderr.trim()}` : ""}${this.version ? `\ncodex version: ${this.version}` : ""}`); + for (const pending of this.pending.values()) pending.reject(this.fatalError); + for (const turn of this.turns.values()) turn.reject(this.fatalError); this.pending.clear(); this.turns.clear(); @@ -432,38 +480,54 @@ class CodexAppServerRpc { this.buffer += line; const trimmed = this.buffer.trim(); this.buffer = ""; + if (!trimmed) return; let message: Record; + try { message = JSON.parse(trimmed) as Record; } catch { this.fail(new Error("codex app-server emitted malformed JSON.")); + return; } + const id = typeof message.id === "string" || typeof message.id === "number" ? String(message.id) : undefined; const method = typeof message.method === "string" ? message.method : undefined; + if (id && !method) { const pending = this.pending.get(id); + if (!pending) return; this.pending.delete(id); + if (message.error !== undefined) pending.reject(new Error(protocolErrorText(message.error))); else pending.resolve(message.result); + return; } + if (id && method) { this.write({ id: message.id, error: { code: -32601, message: `Unsupported app-server request: ${method}` } }); + return; } + if (!method) return; const event = { method, params: message.params }; const turn = this.findTurn(event); + if (!turn) return; const params = asRecord(event.params); + if (params?.item !== undefined) { turn.items.push(params.item); + if (turn.items.length > MAX_TURN_ITEMS) turn.items.shift(); + if (turn.onEvent) reportCodexActivity(event.method, params.item, turn.onEvent); } + if (event.method !== "turn/completed" || !turnMatchesEvent(turn, event)) return; turn.completed = event; turn.resolve({ event, items: turn.items.slice() }); @@ -472,11 +536,15 @@ class CodexAppServerRpc { private findTurn(event: CodexEvent): CodexTurnAccumulator | undefined { const params = asRecord(event.params); const threadId = typeof params?.threadId === "string" ? params.threadId : undefined; + const turnId = typeof params?.turnId === "string" ? params.turnId : readString(asRecord(params?.turn), "id"); + if (threadId) return this.turns.get(threadId); + if (!turnId) return undefined; + return Array.from(this.turns.values()).find((turn) => turn.turnId === turnId); } } @@ -488,17 +556,25 @@ export function reportCodexActivity( ): void { if (method !== "item/started" && method !== "item/completed") return; const record = asRecord(item); + if (!record) return; + if (record.type === "agentMessage" || record.type === "agent_message") { if (method === "item/completed" && typeof record.text === "string") onEvent({ type: "message", text: record.text }); + return; } + const tool = codexTool(record); + if (!tool) return; + if (method === "item/started") { onEvent({ type: "tool_started", ...tool }); + return; } + const failed = record.status === "failed" || record.status === "declined"; onEvent({ type: "tool_completed", ...tool, status: failed ? "failed" : "completed" }); } @@ -510,13 +586,17 @@ function codexTool(item: Record): { tool: string; summary?: str case "fileChange": { const changes = Array.isArray(item.changes) ? item.changes : []; const paths = changes.map((change) => readString(change, "path")).filter(Boolean); + return { tool: "edit", ...(paths.length > 0 ? { summary: paths.join(", ") } : {}) }; } + case "mcpToolCall": { const server = directString(item.server); const name = directString(item.tool) ?? "tool"; + return { tool: server ? `${server}.${name}` : name, summary: summarizeToolInput(item.arguments) }; } + case "webSearch": return { tool: "web_search", summary: summarizeToolInput(item) }; default: @@ -572,19 +652,25 @@ function parseCompletedTurn(params: unknown, items: unknown[]): { const turnItems = turn?.items; const completedItems = (Array.isArray(turnItems) && turnItems.length > 0 ? turnItems : items).slice(-MAX_TURN_ITEMS); let finalResponse = ""; + for (const item of completedItems) { const record = asRecord(item); + if (!record) continue; const type = record.type; + if ((type === "agentMessage" || type === "agent_message") && typeof record.text === "string") { finalResponse = record.text; } } + const status = turn?.status; const error = asRecord(turn?.error); + const failure = status === "failed" ? directString(error?.message) ?? "Codex turn failed." : undefined; + return { finalResponse, items: completedItems, failure }; } @@ -599,10 +685,13 @@ export function codexAppServerError(message: string, version?: string, stderr?: function commandCandidates(command: string, env: NodeJS.ProcessEnv): string[] { if (command.includes("/") || command.includes("\\") || /\.(?:cmd|bat|exe|com)$/i.test(command)) return [command]; const path = env.PATH; + if (!path) return [command]; + const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""]; + return path.split(delimiter) .filter(Boolean) .flatMap((directory) => extensions.map((extension) => resolve(directory, `${command}${extension}`))); @@ -615,11 +704,15 @@ function usesWindowsCommandShell(command: string): boolean { function turnMatchesEvent(turn: CodexTurnAccumulator, event: CodexEvent): boolean { const params = asRecord(event.params); const eventThreadId = typeof params?.threadId === "string" ? params.threadId : undefined; + const eventTurnId = typeof params?.turnId === "string" ? params.turnId : readString(asRecord(params?.turn), "id"); + if (eventThreadId && eventThreadId !== turn.threadId) return false; + if (turn.turnId && eventTurnId && turn.turnId !== eventTurnId) return false; + return eventThreadId === turn.threadId || Boolean(turn.turnId && eventTurnId === turn.turnId); } @@ -631,6 +724,7 @@ function asRecord(value: unknown): Record | undefined { function readString(value: unknown, key: string): string | undefined { const result = asRecord(value)?.[key]; + return typeof result === "string" ? result : undefined; } @@ -640,9 +734,11 @@ function directString(value: unknown): string | undefined { function protocolErrorText(value: unknown): string { const record = asRecord(value); + if (!record) return String(value); const message = directString(record.message); const code = record.code; + return message ? `codex app-server${code === undefined ? "" : ` ${String(code)}`}: ${message}` : String(value); } @@ -652,7 +748,9 @@ function errorMessage(error: unknown): string { function appendTail(value: string, chunk: string, maxBytes: number): string { const next = value + chunk; + if (Buffer.byteLength(next, "utf8") <= maxBytes) return next; const bytes = Buffer.from(next, "utf8"); + return bytes.subarray(bytes.length - maxBytes).toString("utf8"); } diff --git a/src/local-agent-command.ts b/src/local-agent-command.ts index a23ca8068..fddef740f 100644 --- a/src/local-agent-command.ts +++ b/src/local-agent-command.ts @@ -6,28 +6,38 @@ export function resolveExecutableCommand( env: NodeJS.ProcessEnv, ): string | undefined { if (!command) return undefined; + if (command.includes("/") || command.includes("\\")) { return isExecutableFile(command) ? command : undefined; } + const path = env.PATH; + if (!path) return undefined; + const extensions = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""]; + for (const directory of path.split(delimiter)) { if (!directory) continue; + for (const extension of extensions) { const candidate = resolve(directory, `${command}${extension}`); + if (isExecutableFile(candidate)) return candidate; } } + return undefined; } function isExecutableFile(command: string): boolean { const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK; + try { accessSync(command, mode); + return statSync(command).isFile(); } catch { return false; diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 193a624ba..0216e25f2 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -54,11 +54,13 @@ test("provider config is trimmed, gains its driver, and reports enablement", () test("the provider environment overlays configured values without mutating the inherited environment", () => { const config = parseCodexAndClaudeConfig(); + const inherited = { CODEX_COMMAND: "/usr/bin/codex", OPENAI_API_KEY: "inherited", UNCHANGED: "yes", }; + assert.deepEqual(localAgentProviderEnvironment(config, "codex", inherited), { CODEX_COMMAND: "/opt/bin/codex-wrapper", OPENAI_API_KEY: "configured", @@ -125,6 +127,7 @@ test("a named provider instance keeps its declared driver", () => { enabled: true, providers: [{ id: "codex-work", driver: "codex", enabled: true, model: "gpt-work" }], }); + assert.deepEqual(namedInstance.providers[0], { id: "codex-work", driver: "codex", @@ -145,6 +148,7 @@ test("a generic ACP provider keeps its custom args", () => { config: { args: ["acp"] }, }], }); + assert.deepEqual(genericAcp.providers[0], { id: "kiro", driver: "acp", @@ -169,8 +173,10 @@ test("legacy ACP drivers normalize to the acp driver with a flavor", () => { enabled: true, providers: [{ id: "cursor", driver: "cursor", enabled: true }], }); + assert.equal(legacyAcp.providers[0]?.driver, "acp"); assert.deepEqual(legacyAcp.providers[0]?.config, { flavor: "cursor" }); + const namedLegacyAcp = parseSubagentsConfig({ enabled: true, providers: [ @@ -178,6 +184,7 @@ test("legacy ACP drivers normalize to the acp driver with a flavor", () => { { id: "grok", driver: "codex", enabled: true }, ], }); + assert.equal(namedLegacyAcp.providers[0]?.driver, "acp"); assert.deepEqual(namedLegacyAcp.providers[0]?.config, { flavor: "cursor" }, "a named legacy-driver instance keeps its flavor"); assert.equal(namedLegacyAcp.providers[1]?.config, undefined, "a built-in id with another driver is not an ACP provider"); @@ -213,16 +220,21 @@ test("embedded providers receive configured environment and command overrides", enabled: true, providers: [{ id, enabled: true, env: { HARNESS_ENV: id } }], }); + assert.equal(localAgentProviderEnvironment(embedded, id, {}).HARNESS_ENV, id); } + const opencodeCommand = parseSubagentsConfig({ enabled: true, providers: [{ id: "opencode", enabled: true, command: "/opt/bin/opencode" }], }); + assert.equal(localAgentProviderEnvironment(opencodeCommand, "opencode", {}).OPENCODE_COMMAND, "/opt/bin/opencode"); + const piCommand = parseSubagentsConfig({ enabled: true, providers: [{ id: "pi", enabled: true, command: "/opt/bin/pi" }], }); + assert.equal(localAgentProviderEnvironment(piCommand, "pi", {}).PI_COMMAND, "/opt/bin/pi"); }); diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 97a0ff639..a8830da24 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -46,14 +46,17 @@ const providerSchema = z.object({ const driver = provider.driver ? (isLegacyAcpDriverKind(provider.driver) ? "acp" : provider.driver) : defaultDriverForProviderId(provider.id); + if (!driver) { context.addIssue({ code: "custom", path: ["driver"], message: `Subagent provider instance ${provider.id} must declare a driver.`, }); + return; } + if (driver !== "acp" && provider.config !== undefined) { context.addIssue({ code: "custom", @@ -61,9 +64,11 @@ const providerSchema = z.object({ message: `${driver} does not support ACP config.`, }); } + const flavor = provider.config?.flavor ?? (provider.driver && isLegacyAcpDriverKind(provider.driver) ? provider.driver : undefined) ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); + if (driver === "acp" && flavor && provider.config?.args !== undefined) { context.addIssue({ code: "custom", @@ -71,6 +76,7 @@ const providerSchema = z.object({ message: `${flavor} sets its own ACP arguments for each write mode; config.args applies only to generic ACP providers.`, }); } + if (driver === "acp" && !provider.command && !isLegacyAcpDriverKind(provider.id)) { context.addIssue({ code: "custom", @@ -86,6 +92,7 @@ export const subagentsConfigSchema = z.object({ providers: z.array(providerSchema), }).strict().superRefine((value, context) => { const seen = new Set(); + for (const [index, provider] of value.providers.entries()) { if (seen.has(provider.id)) { context.addIssue({ @@ -94,6 +101,7 @@ export const subagentsConfigSchema = z.object({ message: `Duplicate subagent provider: ${provider.id}`, }); } + seen.add(provider.id); } }); @@ -104,6 +112,7 @@ export const storedSubagentsConfigSchema = z.union([ ]); type ParsedSubagentProviderConfig = z.infer; + type ParsedSubagentsConfig = z.infer; export interface SubagentProviderConfig extends Omit { @@ -123,10 +132,12 @@ export function resolveSubagentsConfig(config: ParsedSubagentsConfig): Subagents const driver = provider.driver ? (isLegacyAcpDriverKind(provider.driver) ? "acp" : provider.driver) : defaultDriverForProviderId(provider.id)!; + // `driver: "cursor"` (or a built-in id) predates generic ACP; keep it as the ACP flavor. const flavor = provider.config?.flavor ?? (provider.driver && isLegacyAcpDriverKind(provider.driver) ? provider.driver : undefined) ?? (isLegacyAcpDriverKind(provider.id) ? provider.id : undefined); + return { ...provider, driver, @@ -192,7 +203,9 @@ export function localAgentProviderEnvironment( const env = { ...inherited, ...providerConfig?.env }; const commandVariable = providerConfig ? providerCommandVariable(providerConfig.driver) : undefined; const command = providerConfig?.command; + if (commandVariable && command) env[commandVariable] = command; + return env; } @@ -233,6 +246,7 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin : {}), ...(provider.config ? { config: provider.config } : {}), })); + return createHash("sha256") .update(JSON.stringify({ enabled: config.enabled, providers })) .digest("hex"); diff --git a/src/local-agent-daemon-lifecycle.test.ts b/src/local-agent-daemon-lifecycle.test.ts index 6cf8427ae..efbf15eef 100644 --- a/src/local-agent-daemon-lifecycle.test.ts +++ b/src/local-agent-daemon-lifecycle.test.ts @@ -71,5 +71,6 @@ async function fixture(t: TestContext) { t.after(() => rm(root, { recursive: true, force: true })); const paths = localAgentDaemonPaths(join(root, "state")); ensureLocalAgentDaemonStateDir(paths.stateDir); + return paths; } diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 77b75b6c5..bd8c5b2ed 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -35,6 +35,7 @@ export function localAgentDaemonPaths( ): LocalAgentDaemonPaths { const resolvedStateDir = resolve(stateDir); const socketPath = join(resolvedStateDir, LOCAL_AGENT_DAEMON_SOCKET_NAME); + return { stateDir: resolvedStateDir, socketPath, @@ -69,9 +70,11 @@ export class LocalAgentDaemonLock { acquire(): void { ensureLocalAgentDaemonStateDir(this.paths.stateDir); + for (let attempt = 0; attempt < 2; attempt += 1) { const temporaryPath = `${this.paths.lockPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; let published = false; + try { writeFileSecure(temporaryPath, `${process.pid}\n`); // Publish the owner record atomically. An empty lock must never be @@ -82,34 +85,43 @@ export class LocalAgentDaemonLock { chmodSync(this.paths.lockPath, 0o600); writeFileSecure(this.paths.pidPath, `${process.pid}\n`); this.acquired = true; + return; } catch (error) { rmSync(temporaryPath, { force: true }); + if (published && readDaemonPid(this.paths.lockPath) === process.pid) { rmSync(this.paths.lockPath, { force: true }); } + if (!isFileExistsError(error)) throw error; const pid = readDaemonPid(this.paths.lockPath); + if (pid !== undefined && isProcessAlive(pid)) { throw new LocalAgentDaemonAlreadyRunningError(pid); } + if (pid === undefined) { // An undecodable lock may belong to a process that has not finished // publishing its owner record. Refuse to delete it automatically. throw new LocalAgentDaemonAlreadyRunningError(); } + if (!removeStaleLock(this.paths.lockPath)) continue; } } + throw new LocalAgentDaemonAlreadyRunningError(readDaemonPid(this.paths.lockPath)); } release(): void { if (!this.acquired) return; this.acquired = false; + if (readDaemonPid(this.paths.pidPath) === process.pid) { rmSync(this.paths.pidPath, { force: true }); } + if (readDaemonPid(this.paths.lockPath) === process.pid) { rmSync(this.paths.lockPath, { force: true }); } @@ -118,19 +130,24 @@ export class LocalAgentDaemonLock { export function ensureLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): string { ensureLocalAgentDaemonStateDir(paths.stateDir); + try { const secret = readFileSync(paths.secretPath, "utf8").trim(); + if (isDaemonSecret(secret)) return secret; } catch { // Create the secret below. } const secret = randomBytes(32).toString("hex"); + try { const fileDescriptor = openSync(paths.secretPath, "wx", 0o600); + try { writeSync(fileDescriptor, `${secret}\n`); chmodSync(paths.secretPath, 0o600); + return secret; } finally { closeSync(fileDescriptor); @@ -138,7 +155,9 @@ export function ensureLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): stri } catch (error) { if (!isFileExistsError(error)) throw error; const existing = readFileSync(paths.secretPath, "utf8").trim(); + if (!isDaemonSecret(existing)) throw new Error("Local agent daemon secret is invalid."); + return existing; } } @@ -146,6 +165,7 @@ export function ensureLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): stri export function readLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): string | undefined { try { const secret = readFileSync(paths.secretPath, "utf8").trim(); + return isDaemonSecret(secret) ? secret : undefined; } catch { return undefined; @@ -154,14 +174,17 @@ export function readLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): string export function removeLocalAgentDaemonFiles(paths: LocalAgentDaemonPaths): void { rmSync(paths.pidPath, { force: true }); + if (process.platform !== "win32") rmSync(paths.socketPath, { force: true }); } export function readDaemonPid(pidPath: string): number | undefined { try { const value = readFileSync(pidPath, "utf8").trim(); + if (!/^\d+$/.test(value)) return undefined; const pid = Number(value); + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; } catch { return undefined; @@ -171,6 +194,7 @@ export function readDaemonPid(pidPath: string): number | undefined { export function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); + return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; @@ -179,6 +203,7 @@ export function isProcessAlive(pid: number): boolean { function writeFileSecure(path: string, content: string): void { const fileDescriptor = openSync(path, "w", 0o600); + try { writeSync(fileDescriptor, content); chmodSync(path, 0o600); @@ -193,12 +218,14 @@ function isFileExistsError(error: unknown): boolean { function removeStaleLock(path: string): boolean { const stalePath = `${path}.stale-${process.pid}-${randomBytes(8).toString("hex")}`; + try { // Rename moves the exact lock we inspected out of the ownership path. If // another contender publishes a new lock after this point, it is never // removed with the stale one. renameSync(path, stalePath); rmSync(stalePath, { force: true }); + return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index c1cf6b6c3..50876abc2 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -20,12 +20,15 @@ import { WorkflowStore } from "./workflow-store.js"; const config = loadAgentConfig(); const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; const paths = localAgentDaemonPaths(config.stateDir); + const log = ( level: "info" | "warn" | "error", event: string, fields: Record, ) => writeLocalAgentDaemonLog(paths, level, event, fields); + const store = new LocalAgentStore(paths.stateDir); + const manager = new LocalAgentManager({ store, drivers: createLocalAgentDrivers({ subagents: config.subagents }), @@ -41,6 +44,7 @@ const manager = new LocalAgentManager({ ? (workspaceRoot, workspaceId) => isManagedWorkflowWorkspace(config, workspaceRoot, workspaceId) : undefined, }); + // Hidden in v1.1: without the flag the daemon has no workflow manager and rejects workflow requests. const workflows = workflowsEnabled() ? new WorkflowManager({ @@ -52,6 +56,7 @@ const workflows = workflowsEnabled() config, }) : undefined; + const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, manager, @@ -59,6 +64,7 @@ const daemon = new LocalAgentDaemon({ configRevision: localAgentProviderConfigRevision(config.subagents), onLockAcquired: () => { const reconciled = manager.reconcileActiveRuns(); + if (reconciled.isErr()) throw reconciled.error; workflows?.reconcile(); }, @@ -67,9 +73,11 @@ const daemon = new LocalAgentDaemon({ }); let shuttingDown = false; + const shutdown = () => { if (shuttingDown) return; shuttingDown = true; + const forceTimer = setTimeout(() => { log("error", "daemon_forced_shutdown", { activeTurns: manager.activeTurnCount, @@ -80,10 +88,13 @@ const shutdown = () => { // reconciles them to error while preserving provider continuation data. process.exit(1); }, parseShutdownTimeoutMs(process.env.DEVSPACE_AGENTD_SHUTDOWN_TIMEOUT_MS)); + forceTimer.unref(); void daemon.close().finally(() => process.exit(0)); }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); try { @@ -94,6 +105,7 @@ try { await manager.close(); process.exit(0); } + log("error", "daemon_start_failed", { error: error instanceof Error ? error.message : String(error) }); await workflows?.close(); await manager.close(); @@ -104,17 +116,21 @@ try { function parseIdleShutdownMs(value: string | undefined): number { if (value === undefined || value.trim() === "") return 30_000; const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { throw new Error("DEVSPACE_AGENTD_IDLE_TIMEOUT_MS must be a non-negative duration."); } + return parsed; } function parseShutdownTimeoutMs(value: string | undefined): number { if (value === undefined || value.trim() === "") return DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS; const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { throw new Error("DEVSPACE_AGENTD_SHUTDOWN_TIMEOUT_MS must be a non-negative duration."); } + return parsed; } diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 0edd629b7..a234bdf6b 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -62,6 +62,7 @@ test("requests keep every field the daemon acts on", () => { writeMode: "read_only", worktree: { branch: "feature/x" }, }; + assert.deepEqual({ ...request("agent.start", startParams).params }, startParams); const direct = request("agent.start", { target: "reviewer", prompt: "Review this", workspaceRoot: "/tmp/project" }); @@ -115,6 +116,7 @@ test("results keep every field and preserve response whitespace", () => { { id: "agt_two", status: "running", wait: "timeout" }, { id: "agt_three", status: "failed", error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true } }, ]; + assert.deepEqual(decodeAgentWaitResults(waitResults), waitResults); const hello = { @@ -130,6 +132,7 @@ test("results keep every field and preserve response whitespace", () => { }, configMatches: false, }; + assert.deepEqual(decodeDaemonHello(hello), hello); assert.equal(decodeWorkflowRun({ @@ -170,12 +173,14 @@ test("error responses survive an encode and decode round trip", () => { target: "reviewer", operation: "create_runtime", } as const; + const response = decodeLocalAgentDaemonResponse(JSON.parse(encodeLocalAgentDaemonResponse({ requestId: "req_error", protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error, }))); + assert.equal(response.ok, false); assert.deepEqual(!response.ok && response.error, error); }); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 3d59d7295..21d26c090 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -231,6 +231,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR case "workflow.events": { const lookup = decodeWorkflowLookup(params); const after = asRecord(params)?.after; + return { requestId, protocolVersion, @@ -242,10 +243,12 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR }, }; } + case "workflow.list": return { requestId, protocolVersion, authToken, method, params: decodeWorkspaceScope(params) }; case "workflow.wait": { const lookup = decodeWorkflowLookup(params); + return { requestId, protocolVersion, @@ -254,8 +257,10 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR params: { ...lookup, ...decodeOptionalTimeout(params, 60_000) }, }; } + case "workflow.call": { const lookup = decodeWorkflowLookup(params); + return { requestId, protocolVersion, @@ -267,6 +272,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR }, }; } + case "daemon.logs": return { requestId, @@ -284,11 +290,14 @@ export function decodeLocalAgentDaemonResponse(value: unknown): LocalAgentDaemon const record = asRecord(value); const requestId = requiredString(record?.requestId, "requestId"); const protocolVersion = requiredInteger(record?.protocolVersion, "protocolVersion"); + if (record?.ok === true) { return { requestId, protocolVersion, ok: true, result: record.result }; } + if (record?.ok === false) { const error = asRecord(record.error); + return { requestId, protocolVersion, @@ -305,17 +314,21 @@ export function decodeLocalAgentDaemonResponse(value: unknown): LocalAgentDaemon }, }; } + throw new LocalAgentDaemonProtocolError("INVALID_RESPONSE", "Daemon returned an invalid response."); } export function decodeAgentRecord(value: unknown): LocalAgentRecord { const record = asRecord(value); const status = requiredString(record?.status, "status"); + if (!isLocalAgentStatus(status)) throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent status."); const driver = requiredString(record?.driver, "driver"); + if (!isLocalAgentDriverKind(driver)) { throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent driver."); } + return { id: requiredString(record?.id, "id"), workspaceId: optionalString(record?.workspaceId), @@ -343,6 +356,7 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { export function decodeAgentRecordList(value: unknown): LocalAgentRecord[] { if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid agent list."); + return value.map(decodeAgentRecord); } @@ -350,11 +364,13 @@ export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { if (!Array.isArray(value)) { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid agent wait results."); } + return value.map((entry): LocalAgentWaitResult => { const record = asRecord(entry); const turnId = optionalString(record?.turnId); const worktree = optionalString(record?.worktree); const branch = optionalString(record?.branch); + return { ...decodeWaitBody(record), ...(turnId === undefined ? {} : { turnId }), @@ -367,18 +383,24 @@ export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { function decodeWaitBody(record: Record | undefined): LocalAgentWaitResult { const id = requiredString(record?.id, "id"); const status = requiredString(record?.status, "status"); + switch (status) { case "running": { const wait = optionalString(record?.wait); + if (wait !== undefined && wait !== "timeout") { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait state."); } + return { id, status, ...(wait ? { wait } : {}) }; } + case "completed": { const response = typeof record?.response === "string" ? record.response : undefined; + return { id, status, ...(response === undefined ? {} : { response }) }; } + case "failed": return { id, status, error: decodeWaitError(record?.error) }; case "stopped": @@ -395,12 +417,15 @@ function decodeWaitBody(record: Record | undefined): LocalAgent export function decodeTurnEvents(value: unknown): LocalAgentTurnEvents { const record = asRecord(value); const status = requiredString(record?.status, "status"); + if (status !== "running" && status !== "completed" && status !== "failed" && status !== "stopped") { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid turn status."); } + if (!Array.isArray(record?.events)) { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid turn events."); } + return { agentId: requiredString(record.agentId, "agentId"), turnId: requiredString(record.turnId, "turnId"), @@ -411,13 +436,16 @@ export function decodeTurnEvents(value: unknown): LocalAgentTurnEvents { function decodeTurnEvent(value: unknown): LocalAgentTurnEvent { const record = asRecord(value); + const base = { turnId: requiredString(record?.turnId, "event.turnId"), seq: requiredInteger(record?.seq, "event.seq"), at: requiredString(record?.at, "event.at"), }; + const type = requiredString(record?.type, "event.type"); const summary = optionalContentString(record?.summary); + switch (type) { case "turn_started": return { type, ...base }; @@ -427,14 +455,19 @@ function decodeTurnEvent(value: unknown): LocalAgentTurnEvent { return { type, tool: requiredString(record?.tool, "event.tool"), ...(summary ? { summary } : {}), ...base }; case "tool_completed": { const status = requiredString(record?.status, "event.status"); + if (status !== "completed" && status !== "failed") break; + return { type, tool: requiredString(record?.tool, "event.tool"), ...(summary ? { summary } : {}), status, ...base }; } + case "turn_completed": { const status = requiredString(record?.status, "event.status"); + if (status !== "completed" && status !== "failed" && status !== "stopped") break; const errorCode = optionalString(record?.errorCode); const droppedEvents = typeof record?.droppedEvents === "number" ? record.droppedEvents : undefined; + return { type, status, @@ -444,19 +477,24 @@ function decodeTurnEvent(value: unknown): LocalAgentTurnEvent { }; } } + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid turn event."); } export function decodeWorkflowRun(value: unknown): WorkflowRun { const record = asRecord(value); const status = requiredString(record?.status, "status"); + if (!isWorkflowStatus(status)) { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow status."); } + const writeMode = requiredString(record?.writeMode, "writeMode"); + if (!isWorkflowWriteMode(writeMode)) { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow write mode."); } + return { id: requiredString(record?.id, "id"), workspaceId: optionalString(record?.workspaceId), @@ -476,17 +514,22 @@ export function decodeWorkflowRun(value: unknown): WorkflowRun { export function decodeWorkflowRunList(value: unknown): WorkflowRun[] { if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow list."); + return value.map(decodeWorkflowRun); } export function decodeWorkflowCall(value: unknown): WorkflowCall { const record = asRecord(value); const status = requiredString(record?.status, "status"); + if (!isWorkflowCallStatus(status)) { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow call status."); } + const options = asRecord(record?.options); + if (!options) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid workflow call options."); + const decodedOptions: WorkflowAgentOptions = { target: requiredString(options.target, "options.target"), model: optionalString(options.model), @@ -498,6 +541,7 @@ export function decodeWorkflowCall(value: unknown): WorkflowCall { ...(options.isolation === "worktree" ? { isolation: "worktree" } : {}), workspace: optionalString(options.workspace), }; + return { runId: requiredString(record?.runId, "runId"), index: requiredNonNegativeInteger(record?.index, "index"), @@ -519,6 +563,7 @@ export function decodeWorkflowCall(value: unknown): WorkflowCall { export function decodeWorkflowCallList(value: unknown): WorkflowCall[] { if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow call list."); + return value.map(decodeWorkflowCall); } @@ -526,8 +571,10 @@ export function decodeWorkflowEventList(value: unknown): WorkflowEvent[] { if (!Array.isArray(value) || value.length > 100) { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow event page."); } + return value.map((entry): WorkflowEvent => { const record = asRecord(entry); + return { sequence: requiredNonNegativeInteger(record?.sequence, "sequence"), runId: requiredString(record?.runId, "runId"), @@ -541,9 +588,11 @@ export function decodeWorkflowEventList(value: unknown): WorkflowEvent[] { export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { const record = asRecord(value); const state = requiredString(record?.state, "state"); + if (state !== "ready" && state !== "stopping") { throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid status."); } + return { state, protocolVersion: requiredInteger(record?.protocolVersion, "protocolVersion"), @@ -561,6 +610,7 @@ export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { export function decodeDaemonHello(value: unknown): LocalAgentDaemonHello { const record = asRecord(value); + return { status: decodeDaemonStatus(record?.status), configMatches: requiredBoolean(record?.configMatches, "configMatches"), @@ -569,6 +619,7 @@ export function decodeDaemonHello(value: unknown): LocalAgentDaemonHello { export function decodeDaemonLogs(value: unknown): string { if (typeof value !== "string") throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid logs."); + return value; } @@ -582,15 +633,18 @@ export class LocalAgentDaemonProtocolError extends Error { function decodeEmptyParams(value: unknown): Record { if (value === undefined) return {}; const record = asRecord(value); + if (!record || Object.keys(record).length > 0) { throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "This daemon method does not accept parameters."); } + return {}; } function decodeStartInput(value: unknown): StartLocalAgentInput { const record = asRecord(value); const worktree = asRecord(record?.worktree); + return { agentId: optionalString(record?.agentId), target: requiredString(record?.target, "target"), @@ -607,6 +661,7 @@ function decodeStartInput(value: unknown): StartLocalAgentInput { function decodeContinueInput(value: unknown): { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides } { const record = asRecord(value); const overrides = asRecord(record?.overrides); + return { id: requiredString(record?.id, "id"), prompt: requiredContentString(record?.prompt, "prompt"), @@ -621,7 +676,9 @@ function decodeContinueInput(value: unknown): { id: string; prompt: string; scop function decodeWorkspaceScope(value: unknown): LocalAgentWorkspaceScope { const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Workspace scope is required."); + return { workspaceId: optionalString(record.workspaceId), workspaceRoot: requiredString(record.workspaceRoot, "scope.workspaceRoot"), @@ -634,8 +691,10 @@ function decodeListScope(value: unknown): LocalAgentWorkspaceScope { function decodeStopParams(value: unknown): { ifIdle?: boolean } { const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Daemon stop options must be an object."); const ifIdle = optionalBoolean(record.ifIdle); + return ifIdle === undefined ? {} : { ifIdle }; } @@ -645,14 +704,19 @@ function decodeWaitParams(value: unknown): { timeoutMs?: number; } { const record = asRecord(value); + if (!record) { throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Agent wait options must be an object."); } + const ids = record?.ids; + if (!Array.isArray(ids) || ids.length === 0) { throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "At least one subagent id is required."); } + const timeoutMs = record.timeoutMs; + if ( timeoutMs !== undefined && (typeof timeoutMs !== "number" @@ -665,6 +729,7 @@ function decodeWaitParams(value: unknown): { "Wait timeout must be an integer between 0 and 2147483647 milliseconds.", ); } + return { ids: ids.map((id, index) => requiredString(id, `ids[${index}]`)), scope: decodeWorkspaceScope(record.scope), @@ -674,17 +739,22 @@ function decodeWaitParams(value: unknown): { function decodeWorkflowRunInput(value: unknown): WorkflowRunInput { const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Workflow input must be an object."); const source = optionalContentString(record.source); const name = optionalString(record.name); const resume = optionalString(record.resume); + if ([source, name, resume].filter((entry) => entry !== undefined).length !== 1) { throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Exactly one workflow source, name, or resume id is required."); } + const writeMode = record.writeMode; + if (writeMode !== undefined && writeMode !== "read_only" && writeMode !== "allowed") { throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Invalid workflow write mode."); } + return { ...decodeWorkspaceScope(record), ...(source === undefined ? {} : { source }), @@ -697,6 +767,7 @@ function decodeWorkflowRunInput(value: unknown): WorkflowRunInput { function decodeWorkflowLookup(value: unknown): { id: string; scope: LocalAgentWorkspaceScope } { const record = asRecord(value); + return { id: requiredString(record?.id, "id"), scope: decodeWorkspaceScope(record?.scope), @@ -705,16 +776,20 @@ function decodeWorkflowLookup(value: unknown): { id: string; scope: LocalAgentWo function decodeOptionalTimeout(value: unknown, max: number): { timeoutMs?: number } { const timeoutMs = asRecord(value)?.timeoutMs; + if (timeoutMs === undefined) return {}; + if (!Number.isSafeInteger(timeoutMs) || (timeoutMs as number) < 0 || (timeoutMs as number) > max) { throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Wait timeout must be an integer between 0 and ${max} milliseconds.`); } + return { timeoutMs: timeoutMs as number }; } function decodeWaitError(value: unknown): LocalAgentFailure { const record = asRecord(value); const detail = optionalContentString(record?.detail); + return { code: requiredString(record?.code, "error.code"), message: requiredContentString(record?.message, "error.message"), @@ -726,10 +801,13 @@ function decodeWaitError(value: unknown): LocalAgentFailure { function decodeEventsParams(value: unknown): { id: string; scope: LocalAgentWorkspaceScope } & LocalAgentTurnEventsQuery { const record = asRecord(value); const afterSeq = record?.afterSeq; + if (afterSeq !== undefined && (typeof afterSeq !== "number" || !Number.isSafeInteger(afterSeq) || afterSeq < 0)) { throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "afterSeq must be a non-negative integer."); } + const turnId = optionalString(record?.turnId); + return { id: requiredString(record?.id, "id"), scope: decodeWorkspaceScope(record?.scope), @@ -745,17 +823,22 @@ function decodeWorkflowFailure(value: unknown): { code: string; message: string; function decodeLogsParams(value: unknown): { lines?: number } { if (value === undefined) return {}; const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Log options must be an object."); const lines = record.lines; + if (lines === undefined) return {}; + if (typeof lines !== "number" || !Number.isInteger(lines) || lines < 1 || lines > 10_000) { throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Log lines must be an integer between 1 and 10000."); } + return { lines }; } function decodeWriteMode(value: unknown): LocalAgentWriteMode | undefined { if (value === undefined) return undefined; + if (value === "read_only" || value === "allowed" || value === "full_access") return value; throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Invalid write mode."); } @@ -780,13 +863,17 @@ function isWorkflowCallStatus(value: string): value is WorkflowCall["status"] { function requiredString(value: unknown, field: string): string { const result = optionalString(value); + if (!result) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Missing ${field}.`); + return result; } function requiredContentString(value: unknown, field: string): string { const result = optionalContentString(value); + if (result === undefined) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Missing ${field}.`); + return result; } @@ -794,12 +881,15 @@ function requiredInteger(value: unknown, field: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value)) { throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); } + return value; } function requiredNonNegativeInteger(value: unknown, field: string): number { const result = requiredInteger(value, field); + if (result < 0) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `${field} must be non-negative.`); + return result; } @@ -807,17 +897,20 @@ function requiredBoolean(value: unknown, field: string): boolean { if (typeof value !== "boolean") { throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); } + return value; } function optionalString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); + return trimmed || undefined; } function optionalContentString(value: unknown): string | undefined { if (typeof value !== "string" || !value.trim()) return undefined; + return value; } @@ -827,6 +920,7 @@ function optionalBoolean(value: unknown): boolean | undefined { function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; } diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 8192cb0e2..0fde5aa80 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -30,8 +30,10 @@ import type { LocalAgentRecord } from "./local-agent-store.js"; import type { WorkflowCall, WorkflowEvent, WorkflowRun, WorkflowRunInput } from "./workflow-types.js"; const CONFIG_REVISION = "test-provider-config"; + // Only a scope value for the fake managers; the directory is never created. const PROJECT_ROOT = join(tmpdir(), "ds-ad-project"); + const record: LocalAgentRecord = { id: "agt_test", workspaceId: "ws_test", @@ -44,6 +46,7 @@ const record: LocalAgentRecord = { createdAt: "now", updatedAt: "now", }; + const workflowRun: WorkflowRun = { id: "wfl_test", workspaceId: "ws_test", @@ -56,6 +59,7 @@ const workflowRun: WorkflowRun = { updatedAt: "now", callCount: 1, }; + const workflowCall: WorkflowCall = { runId: workflowRun.id, index: 0, @@ -69,6 +73,7 @@ const workflowCall: WorkflowCall = { createdAt: "now", updatedAt: "now", }; + const workflowEvent: WorkflowEvent = { sequence: 1, runId: workflowRun.id, @@ -93,10 +98,12 @@ class FakeManager implements LocalAgentDaemonManager { async start(input: StartLocalAgentInput) { this.lastInput = input; this.startStarted = true; + if (this.blockStartUntilRelease) { await new Promise((resolveStart) => { this.releaseStart = resolveStart; }); this.activeTurnCount = 1; } + return Result.ok(record); } @@ -124,21 +131,25 @@ class FakeManager implements LocalAgentDaemonManager { async wait(agentIds: readonly string[], _scope: unknown, _timeoutMs?: number, signal?: AbortSignal) { this.waitStarted = true; + if (this.blockWaitUntilAbort) { await new Promise((resolveAbort) => { const onAbort = () => { this.waitAborted = true; resolveAbort(); }; + if (signal?.aborted) onAbort(); else signal?.addEventListener("abort", onAbort, { once: true }); }); } + return Result.ok(agentIds.map((id) => ({ id, status: "running" as const }))); } async stop(_agentId: string, _scope: unknown) { this.activeTurnCount = 0; + return Result.ok({ ...record, status: "stopped" } as LocalAgentRecord); } @@ -146,6 +157,7 @@ class FakeManager implements LocalAgentDaemonManager { events(agentId: string, _scope: unknown, query?: { turnId?: string; afterSeq?: number }) { this.lastEventsQuery = query; + return Result.ok({ agentId, turnId: "trn_test", @@ -177,6 +189,7 @@ class FakeWorkflows implements LocalAgentDaemonWorkflowManager { lastInput?: WorkflowRunInput; async run(input: WorkflowRunInput): Promise { this.lastInput = input; + return workflowRun; } get(): WorkflowRun { return workflowRun; } @@ -185,18 +198,23 @@ class FakeWorkflows implements LocalAgentDaemonWorkflowManager { if (this.blockWaitUntilAbort) { await new Promise((resolveAbort) => { const onAbort = () => { this.waitAborted = true; resolveAbort(); }; + if (signal?.aborted) onAbort(); else signal?.addEventListener("abort", onAbort, { once: true }); }); } + return workflowRun; } calls(): WorkflowCall[] { return [workflowCall]; } call(): WorkflowCall { return workflowCall; } events(): WorkflowEvent[] { return [workflowEvent]; } - async cancel(): Promise { this.cancelled = true; return { ...workflowRun, status: "cancelled" }; } + async cancel(): Promise { this.cancelled = true; + + return { ...workflowRun, status: "cancelled" }; } async close(): Promise { this.closeOrder.push("workflows"); + if (this.hangClose) await new Promise(() => {}); this.closed = true; this.activeRunCount = 0; @@ -207,6 +225,7 @@ test("daemon diagnostics do not start a missing daemon", async (t) => { const { root } = await fixture(t); const missingDaemonStateDir = join(root, "missing-daemon-state"); let diagnosticSpawnCount = 0; + const missingDaemonClient = new LocalAgentClient({ stateDir: missingDaemonStateDir, configRevision: CONFIG_REVISION, @@ -214,6 +233,7 @@ test("daemon diagnostics do not start a missing daemon", async (t) => { requestTimeoutMs: 50, spawnDaemon: () => { diagnosticSpawnCount += 1; }, }); + for (const diagnostic of [ () => missingDaemonClient.status(), () => missingDaemonClient.stop(), @@ -221,8 +241,10 @@ test("daemon diagnostics do not start a missing daemon", async (t) => { ]) { const result = await diagnostic(); assert.equal(result.isErr(), true); + if (result.isErr()) assert.equal(result.error.code, "DAEMON_UNAVAILABLE"); } + assert.equal(diagnosticSpawnCount, 0, "daemon diagnostics must not start a missing daemon"); }); @@ -254,6 +276,7 @@ test("the client proxies agent and workflow requests and stop closes workflows b const closeOrder: string[] = []; const manager = new FakeManager(closeOrder); const workflows = new FakeWorkflows(closeOrder); + const daemon = new LocalAgentDaemon({ stateDir: join(root, "state"), configRevision: CONFIG_REVISION, @@ -261,6 +284,7 @@ test("the client proxies agent and workflow requests and stop closes workflows b workflows, idleShutdownMs: 60_000, }); + const client = new LocalAgentClient({ stateDir: join(root, "state"), configRevision: CONFIG_REVISION, @@ -276,6 +300,7 @@ test("the client proxies agent and workflow requests and stop closes workflows b workspaceId: record.workspaceId!, workspaceRoot: PROJECT_ROOT, })); + assert.equal(started.id, record.id); assert.equal(manager.lastInput?.prompt, "Review this"); const recordScope = { workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot }; @@ -322,6 +347,7 @@ test("disconnecting a workflow wait does not cancel the workflow", async (t) => const workflowWaits = new FakeWorkflows(); workflowWaits.blockWaitUntilAbort = true; workflowWaits.activeRunCount = 1; + const workflowWaitDaemon = new LocalAgentDaemon({ stateDir: workflowWaitStateDir, configRevision: CONFIG_REVISION, @@ -329,6 +355,7 @@ test("disconnecting a workflow wait does not cancel the workflow", async (t) => workflows: workflowWaits, idleShutdownMs: 60_000, }); + const workflowWaitClient = new LocalAgentClient({ stateDir: workflowWaitStateDir, configRevision: CONFIG_REVISION, @@ -336,11 +363,13 @@ test("disconnecting a workflow wait does not cancel the workflow", async (t) => requestTimeoutMs: 20, spawnDaemon: () => { void workflowWaitDaemon.start(); }, }); + try { const result = await workflowWaitClient.waitWorkflow(workflowRun.id, { workspaceId: workflowRun.workspaceId, workspaceRoot: workflowRun.workspaceRoot, }, 0); + assert.equal(result.isErr(), true); await waitFor(() => workflowWaits.waitAborted); assert.equal(workflowWaits.cancelled, false, "disconnecting a wait must not cancel the workflow"); @@ -358,6 +387,7 @@ test("a stalled workflow shutdown leaves budget for the agent manager phase", as stalledManager.activeTurnCount = 0; const stalledWorkflows = new FakeWorkflows(); stalledWorkflows.hangClose = true; + const stalledDaemon = new LocalAgentDaemon({ stateDir: join(root, "stalled-workflows"), configRevision: CONFIG_REVISION, @@ -366,7 +396,9 @@ test("a stalled workflow shutdown leaves budget for the agent manager phase", as shutdownTimeoutMs: 400, idleShutdownMs: 60_000, }); + const keepAlive = setInterval(() => {}, 50); // withTimeout timers are unref'd; a never-settling close would otherwise end the process + try { await stalledDaemon.start(); const stalledStart = Date.now(); @@ -386,6 +418,7 @@ test("active workflows keep the daemon alive", async (t) => { activeWorkflowManager.activeTurnCount = 0; const activeWorkflows = new FakeWorkflows(); activeWorkflows.activeRunCount = 1; + const activeWorkflowDaemon = new LocalAgentDaemon({ stateDir: activeWorkflowStateDir, configRevision: CONFIG_REVISION, @@ -394,6 +427,7 @@ test("active workflows keep the daemon alive", async (t) => { idleShutdownMs: 20, idleCheckIntervalMs: 5, }); + try { await activeWorkflowDaemon.start(); await new Promise((resolveDelay) => setTimeout(resolveDelay, 60)); @@ -411,12 +445,14 @@ test("a daemon without a workflow manager rejects workflow requests with guidanc const hiddenStateDir = join(root, "hidden-workflows-state"); const hiddenManager = new FakeManager(); hiddenManager.activeTurnCount = 0; + const hiddenDaemon = new LocalAgentDaemon({ stateDir: hiddenStateDir, configRevision: CONFIG_REVISION, manager: hiddenManager, idleShutdownMs: 60_000, }); + const hiddenClient = new LocalAgentClient({ stateDir: hiddenStateDir, configRevision: CONFIG_REVISION, @@ -424,13 +460,16 @@ test("a daemon without a workflow manager rejects workflow requests with guidanc requestTimeoutMs: 2_000, spawnDaemon: () => { void hiddenDaemon.start(); }, }); + try { const rejected = await hiddenClient.runWorkflow({ workspaceId: record.workspaceId, workspaceRoot: record.workspaceRoot, name: "review" }); assert.equal(rejected.isErr(), true); + if (rejected.isErr()) { assert.equal(rejected.error.code, "WORKFLOWS_UNAVAILABLE"); assert.match(rejected.error.message, /DEVSPACE_EXPERIMENTAL_WORKFLOWS=1/); } + assert.equal(unwrap(await hiddenClient.status()).activeWorkflows, 0); } finally { await hiddenDaemon.close(); @@ -442,6 +481,7 @@ test("an idle daemon shuts itself down", async (t) => { const idleStateDir = join(root, "idle-state"); const idleManager = new FakeManager(); idleManager.activeTurnCount = 0; + const idleDaemon = new LocalAgentDaemon({ stateDir: idleStateDir, configRevision: CONFIG_REVISION, @@ -449,6 +489,7 @@ test("an idle daemon shuts itself down", async (t) => { idleShutdownMs: 200, idleCheckIntervalMs: 10, }); + const idleClient = new LocalAgentClient({ stateDir: idleStateDir, configRevision: CONFIG_REVISION, @@ -470,12 +511,14 @@ test("only one competing daemon acquires the state-directory lock", async (t) => const ownershipStateDir = join(root, "ownership-state"); const ownerManager = new FakeManager(); const competingManager = new FakeManager(); + const ownerDaemon = new LocalAgentDaemon({ stateDir: ownershipStateDir, configRevision: CONFIG_REVISION, manager: ownerManager, idleShutdownMs: 60_000, }); + const competingDaemon = new LocalAgentDaemon({ stateDir: ownershipStateDir, configRevision: CONFIG_REVISION, @@ -488,6 +531,7 @@ test("only one competing daemon acquires the state-directory lock", async (t) => ownerDaemon.start(), competingDaemon.start(), ]); + assert.equal( startupResults.filter((result) => result.status === "fulfilled").length, 1, @@ -503,11 +547,13 @@ test("only one competing daemon acquires the state-directory lock", async (t) => await competingDaemon.close(); assert.equal(readFileSync(ownerDaemon.paths.lockPath, "utf8"), lockBefore); assert.equal(readFileSync(ownerDaemon.paths.pidPath, "utf8"), pidBefore); + const ownerClient = new LocalAgentClient({ stateDir: ownershipStateDir, configRevision: CONFIG_REVISION, spawnDaemon: () => { throw new Error("the winning daemon should already be reachable"); }, }); + assert.equal(unwrap(await ownerClient.status()).pid, process.pid); } finally { await competingDaemon.close(); @@ -517,6 +563,7 @@ test("only one competing daemon acquires the state-directory lock", async (t) => test("a failed daemon spawn reports a startup failure", async (t) => { const { root } = await fixture(t); + const startupFailureClient = new LocalAgentClient({ stateDir: join(root, "startup-failure-state"), configRevision: CONFIG_REVISION, @@ -524,8 +571,10 @@ test("a failed daemon spawn reports a startup failure", async (t) => { requestTimeoutMs: 10, spawnDaemon: () => { throw new Error("spawn failed"); }, }); + const startupFailure = await startupFailureClient.ensureReady(); assert.equal(startupFailure.isErr(), true); + if (startupFailure.isErr()) assert.equal(startupFailure.error.code, "DAEMON_STARTUP_FAILURE"); }); @@ -535,21 +584,26 @@ test("an idle daemon with a stale provider config is replaced", async (t) => { const staleIdleStateDir = join(root, "si"); const staleIdleManager = new FakeManager(); staleIdleManager.activeTurnCount = 0; + const staleIdleDaemon = new LocalAgentDaemon({ stateDir: staleIdleStateDir, configRevision: "old-provider-config", manager: staleIdleManager, idleShutdownMs: 60_000, }); + const currentManager = new FakeManager(); currentManager.activeTurnCount = 0; + const currentDaemon = new LocalAgentDaemon({ stateDir: staleIdleStateDir, configRevision: CONFIG_REVISION, manager: currentManager, idleShutdownMs: 60_000, }); + let currentDaemonSpawns = 0; + const staleIdleClient = new LocalAgentClient({ stateDir: staleIdleStateDir, configRevision: CONFIG_REVISION, @@ -560,6 +614,7 @@ test("an idle daemon with a stale provider config is replaced", async (t) => { void currentDaemon.start(); }, }); + try { await staleIdleDaemon.start(); assert.equal(unwrap(await staleIdleClient.ensureReady()).state, "ready"); @@ -576,6 +631,7 @@ test("a busy daemon with a stale provider config keeps serving but rejects new s const staleActiveStateDir = join(root, "sa"); const staleActiveManager = new FakeManager(); const staleActiveWorkflows = new FakeWorkflows(); + const staleActiveDaemon = new LocalAgentDaemon({ stateDir: staleActiveStateDir, configRevision: "old-provider-config", @@ -583,7 +639,9 @@ test("a busy daemon with a stale provider config keeps serving but rejects new s workflows: staleActiveWorkflows, idleShutdownMs: 60_000, }); + let staleActiveSpawns = 0; + const staleActiveClient = new LocalAgentClient({ stateDir: staleActiveStateDir, configRevision: CONFIG_REVISION, @@ -591,14 +649,17 @@ test("a busy daemon with a stale provider config keeps serving but rejects new s requestTimeoutMs: 500, spawnDaemon: () => { staleActiveSpawns += 1; }, }); + try { await staleActiveDaemon.start(); const changed = await staleActiveClient.ensureReady(); assert.equal(changed.isErr(), true); + if (changed.isErr()) { assert.equal(changed.error.code, "DAEMON_CONFIG_CHANGED"); assert.equal(changed.error.retryable, true); } + assert.equal(staleActiveSpawns, 0); assert.equal(staleActiveManager.closed, false); const staleScope = { workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot }; @@ -608,13 +669,16 @@ test("a busy daemon with a stale provider config keeps serving but rejects new s { id: record.id, status: "running" }, ]); assert.equal(unwrap(await staleActiveClient.cancelWorkflow(workflowRun.id, staleScope)).status, "cancelled"); + const blockedStart = await staleActiveClient.run({ target: "reviewer", prompt: "must use current provider config", workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot, }); + assert.equal(blockedStart.isErr(), true); + if (blockedStart.isErr()) assert.equal(blockedStart.error.code, "DAEMON_CONFIG_CHANGED"); assert.equal("configRevision" in unwrap(await staleActiveClient.status()), false); } finally { @@ -628,33 +692,40 @@ test("a daemon with an agent start in flight is not replaced for a config change const configRaceManager = new FakeManager(); configRaceManager.activeTurnCount = 0; configRaceManager.blockStartUntilRelease = true; + const configRaceDaemon = new LocalAgentDaemon({ stateDir: configRaceStateDir, configRevision: "old-provider-config", manager: configRaceManager, idleShutdownMs: 60_000, }); + const matchingRaceClient = new LocalAgentClient({ stateDir: configRaceStateDir, configRevision: "old-provider-config", spawnDaemon: () => { throw new Error("the existing daemon should be used"); }, }); + const changedRaceClient = new LocalAgentClient({ stateDir: configRaceStateDir, configRevision: CONFIG_REVISION, spawnDaemon: () => { throw new Error("a busy daemon must not be replaced"); }, }); + try { await configRaceDaemon.start(); + const starting = matchingRaceClient.run({ target: "reviewer", prompt: "race with replacement", workspaceId: record.workspaceId, workspaceRoot: record.workspaceRoot, }); + await waitFor(() => configRaceManager.startStarted); const changed = await changedRaceClient.ensureReady(); assert.equal(changed.isErr(), true); + if (changed.isErr()) assert.equal(changed.error.code, "DAEMON_CONFIG_CHANGED"); assert.equal(configRaceManager.closed, false); configRaceManager.releaseBlockedStart(); @@ -674,12 +745,16 @@ test("a legacy-protocol daemon is stopped and replaced only after it releases it const legacyLock = new LocalAgentDaemonLock(upgradePaths); legacyLock.acquire(); const legacyMethods: string[] = []; + const legacyServer = await listenFakeDaemon(upgradePaths.endpoint, (request, socket) => { legacyMethods.push(`${request.method}:${request.protocolVersion}`); + if (request.protocolVersion !== 1) { socket.end(protocolMismatch(request, 1)); + return; } + const stopping = request.method === "daemon.stop"; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, @@ -694,16 +769,20 @@ test("a legacy-protocol daemon is stopped and replaced only after it releases it } }); }); + const replacementManager = new FakeManager(); replacementManager.activeTurnCount = 0; + const replacementDaemon = new LocalAgentDaemon({ stateDir: upgradeStateDir, configRevision: CONFIG_REVISION, manager: replacementManager, idleShutdownMs: 60_000, }); + let replacementSpawns = 0; let spawnedBeforeLegacyLockReleased = false; + const upgradeClient = new LocalAgentClient({ stateDir: upgradeStateDir, configRevision: CONFIG_REVISION, @@ -715,6 +794,7 @@ test("a legacy-protocol daemon is stopped and replaced only after it releases it void replacementDaemon.start(); }, }); + try { assert.equal( unwrap(await upgradeClient.ensureReady()).protocolVersion, @@ -740,16 +820,20 @@ test("a daemon that is replaced concurrently is accepted once it speaks the curr const replacementRacePaths = localAgentDaemonPaths(replacementRaceStateDir); ensureLocalAgentDaemonSecret(replacementRacePaths); let replacementRaceProtocol = 1; + const replacementRaceServer = await listenFakeDaemon(replacementRacePaths.endpoint, (request, socket) => { if (request.protocolVersion !== replacementRaceProtocol) { socket.end(protocolMismatch(request, replacementRaceProtocol)); + return; } + const status = fakeDaemonStatus( replacementRacePaths.endpoint, replacementRaceProtocol, request.method === "daemon.stop" ? "stopping" : "ready", ); + socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: replacementRaceProtocol, @@ -764,6 +848,7 @@ test("a daemon that is replaced concurrently is accepted once it speaks the curr } }); }); + const replacementRaceClient = new LocalAgentClient({ stateDir: replacementRaceStateDir, configRevision: CONFIG_REVISION, @@ -773,6 +858,7 @@ test("a daemon that is replaced concurrently is accepted once it speaks the curr throw new Error("the replacement daemon is already running"); }, }); + try { assert.equal( unwrap(await replacementRaceClient.ensureReady()).protocolVersion, @@ -789,6 +875,7 @@ test("a daemon that never answers a request times out", async (t) => { await mkdir(timeoutStateDir, { recursive: true }); const timeoutPaths = localAgentDaemonPaths(timeoutStateDir); ensureLocalAgentDaemonSecret(timeoutPaths); + const timeoutServer = await listenFakeDaemon(timeoutPaths.endpoint, (request, socket) => { if (request.method !== "hello") return; socket.end(encodeLocalAgentDaemonResponse({ @@ -798,6 +885,7 @@ test("a daemon that never answers a request times out", async (t) => { result: fakeDaemonStatus(timeoutPaths.endpoint, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION), })); }); + try { const timeoutClient = new LocalAgentClient({ stateDir: timeoutStateDir, @@ -806,8 +894,10 @@ test("a daemon that never answers a request times out", async (t) => { requestTimeoutMs: 20, spawnDaemon: () => { throw new Error("existing daemon should be used"); }, }); + const timedOut = await timeoutClient.status(); assert.equal(timedOut.isErr(), true); + if (timedOut.isErr()) assert.equal(timedOut.error.code, "DAEMON_TIMEOUT"); } finally { await new Promise((resolveClose, rejectClose) => { @@ -821,6 +911,7 @@ test("a response for another request is invalid", async (t) => { const invalidStateDir = join(root, "invalid-response-state"); await mkdir(invalidStateDir, { recursive: true }); const invalidPaths = localAgentDaemonPaths(invalidStateDir); + const invalidServer = await listenFakeDaemon(invalidPaths.endpoint, (_request, socket) => { socket.end(encodeLocalAgentDaemonResponse({ requestId: "wrong_request_id", @@ -829,6 +920,7 @@ test("a response for another request is invalid", async (t) => { result: {}, })); }); + try { const invalidClient = new LocalAgentClient({ stateDir: invalidStateDir, @@ -837,8 +929,10 @@ test("a response for another request is invalid", async (t) => { requestTimeoutMs: 50, spawnDaemon: () => { throw new Error("existing daemon should be used"); }, }); + const invalid = await invalidClient.ensureReady(); assert.equal(invalid.isErr(), true); + if (invalid.isErr()) assert.equal(invalid.error.code, "DAEMON_INVALID_RESPONSE"); } finally { await new Promise((resolveClose, rejectClose) => { @@ -860,14 +954,17 @@ async function listenFakeDaemon( socket.on("data", (chunk: string | Buffer) => { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); + if (newline === -1) return; respond(JSON.parse(buffer.slice(0, newline)) as RawDaemonRequest, socket); }); }); + await new Promise((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(endpoint, resolveListen); }); + return server; } @@ -899,6 +996,7 @@ function protocolMismatch(request: RawDaemonRequest, protocolVersion: number): s function unwrap(result: import("better-result").Result): T { if (result.isErr()) throw result.error; + return result.value; } @@ -930,15 +1028,18 @@ test("a request that is never sent times out and releases its connection", async const { socketDaemon } = await startSocketDaemon(t); const timedOutRequest = await sendRawRequest(socketDaemon.paths.endpoint); assert.equal(timedOutRequest.ok, false); + if (!timedOutRequest.ok) { assert.equal(timedOutRequest.error.code, "DAEMON_TIMEOUT"); assert.equal(timedOutRequest.error.retryable, true); } + await waitFor(() => socketDaemon.status().clientConnections === 0); }); test("a request with the wrong secret is unauthorized", async (t) => { const { socketDaemon } = await startSocketDaemon(t); + const unauthorized = await sendRawRequest(socketDaemon.paths.endpoint, JSON.stringify({ requestId: "unauthorized", protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -947,7 +1048,9 @@ test("a request with the wrong secret is unauthorized", async (t) => { params: {}, configRevision: CONFIG_REVISION, }) + "\n"); + assert.equal(unauthorized.ok, false); + if (!unauthorized.ok) assert.equal(unauthorized.error.code, "DAEMON_UNAUTHORIZED"); }); @@ -955,16 +1058,19 @@ test("malformed and oversized requests are invalid", async (t) => { const { socketDaemon } = await startSocketDaemon(t); const malformed = await sendRawRequest(socketDaemon.paths.endpoint, "{not-json}\n"); assert.equal(malformed.ok, false); + if (!malformed.ok) assert.equal(malformed.error.code, "DAEMON_INVALID_REQUEST"); const oversized = await sendRawRequest(socketDaemon.paths.endpoint, "x".repeat(512 * 1024 + 1)); assert.equal(oversized.ok, false); + if (!oversized.ok) assert.equal(oversized.error.code, "DAEMON_INVALID_REQUEST"); }); test("shutdown destroys idle client sockets before closing the server", async (t) => { const { socketDaemon } = await startSocketDaemon(t); const shutdownSocket = createConnection(socketDaemon.paths.endpoint); + try { await onceSocket(shutdownSocket, "connect"); const shutdownSocketClosed = onceSocket(shutdownSocket, "close"); @@ -982,6 +1088,7 @@ async function startSocketDaemon(t: TestContext) { const socketStateDir = join(root, "socket-state"); const socketManager = new FakeManager(); socketManager.activeTurnCount = 0; + const socketDaemon = new LocalAgentDaemon({ stateDir: socketStateDir, configRevision: CONFIG_REVISION, @@ -990,8 +1097,10 @@ async function startSocketDaemon(t: TestContext) { shutdownTimeoutMs: 100, idleShutdownMs: 60_000, }); + onCleanup(() => socketDaemon.close()); await socketDaemon.start(); + return { socketManager, socketDaemon }; } @@ -1004,14 +1113,17 @@ async function fixture(t: TestContext) { for (const cleanup of cleanups) await cleanup(); await rm(root, { recursive: true, force: true }); }); + return { root, onCleanup: (cleanup: () => unknown) => { cleanups.push(cleanup); } }; } async function waitFor(check: () => boolean): Promise { const deadline = Date.now() + 2_000; + while (!check() && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 10)); } + assert.equal(check(), true, "condition did not become true before timeout"); } @@ -1023,8 +1135,10 @@ async function sendRawRequest( socket.setEncoding("utf8"); const connected = onceSocket(socket, "connect"); let buffer = ""; + const response = new Promise((resolveResponse, rejectResponse) => { let settled = false; + const cleanup = () => { clearTimeout(timeout); socket.off("data", onData); @@ -1032,16 +1146,20 @@ async function sendRawRequest( socket.off("close", onClose); socket.off("end", onEnd); }; + const settle = (action: () => void) => { if (settled) return; settled = true; cleanup(); action(); }; + const onData = (chunk: string | Buffer) => { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); + if (newline === -1) return; + try { const parsed = JSON.parse(buffer.slice(0, newline)) as RawDaemonResponse; settle(() => resolveResponse(parsed)); @@ -1049,16 +1167,21 @@ async function sendRawRequest( settle(() => rejectResponse(error)); } }; + const onError = (error: Error) => settle(() => rejectResponse(error)); + const onClose = () => settle(() => rejectResponse( new Error("Daemon closed the connection before returning a response."), )); + const onEnd = () => settle(() => rejectResponse( new Error("Daemon ended the connection before returning a response."), )); + const timeout = setTimeout(() => settle(() => rejectResponse( new Error("Daemon did not return a response within 2000ms."), )), 2_000); + timeout.unref(); socket.on("data", onData); @@ -1066,8 +1189,11 @@ async function sendRawRequest( socket.once("close", onClose); socket.once("end", onEnd); }); + await connected; + if (payload !== undefined) socket.write(payload); + try { return await response; } finally { @@ -1089,14 +1215,17 @@ function onceSocket( cleanup(); resolve(); }; + const onError = (error: Error) => { cleanup(); reject(error); }; + const timeout = setTimeout(() => { cleanup(); reject(new Error(`Socket did not emit ${event} within ${timeoutMs}ms.`)); }, timeoutMs); + timeout.unref(); const cleanup = () => { diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index b14a6b0a6..a2e664a64 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -145,12 +145,15 @@ export class LocalAgentDaemon { this.now = options.now ?? Date.now; this.onLockAcquired = options.onLockAcquired; this.onClosed = options.onClosed; + if (!Number.isFinite(this.idleShutdownMs) || this.idleShutdownMs < 0) { throw new Error("Agent daemon idle shutdown must be a non-negative finite duration."); } + if (!Number.isFinite(this.requestReadTimeoutMs) || this.requestReadTimeoutMs <= 0) { throw new Error("Agent daemon request read timeout must be a positive finite duration."); } + if (!Number.isFinite(this.shutdownTimeoutMs) || this.shutdownTimeoutMs < 0) { throw new Error("Agent daemon shutdown timeout must be a non-negative finite duration."); } @@ -160,16 +163,19 @@ export class LocalAgentDaemon { if (this.server) return this.status(); ensureLocalAgentDaemonStateDir(this.paths.stateDir); let lockAcquired = false; + try { this.lock.acquire(); lockAcquired = true; this.ownsLock = true; this.authToken = ensureLocalAgentDaemonSecret(this.paths); await this.onLockAcquired?.(); + if (process.platform !== "win32") rmSync(this.paths.socketPath, { force: true }); const server = createServer((socket) => this.handleConnection(socket)); this.server = server; await listen(server, this.paths.endpoint); + if (process.platform !== "win32") chmodSync(this.paths.socketPath, 0o600); this.startedAt = new Date(this.now()).toISOString(); this.accepting = true; @@ -183,15 +189,18 @@ export class LocalAgentDaemon { }, this.idleCheckIntervalMs); this.idleTimer.unref(); writeLocalAgentDaemonLog(this.paths, "info", "daemon_started", { pid: process.pid }); + return this.status(); } catch (error) { this.server = undefined; this.authToken = undefined; + if (lockAcquired) { this.lock.release(); this.ownsLock = false; removeLocalAgentDaemonFiles(this.paths); } + if (error instanceof LocalAgentDaemonAlreadyRunningError) throw error; throw error; } @@ -199,6 +208,7 @@ export class LocalAgentDaemon { status(): LocalAgentDaemonStatus { if (!this.startedAt) throw new Error("Local agent daemon is not started."); + return { state: this.stopping ? "stopping" : "ready", protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -214,15 +224,18 @@ export class LocalAgentDaemon { async close(): Promise { if (this.closePromise) return this.closePromise; + if (!this.ownsLock && !this.server) return; this.accepting = false; this.stopping = true; + if (this.idleTimer) clearInterval(this.idleTimer); this.closePromise = (async () => { writeLocalAgentDaemonLog(this.paths, "info", "daemon_stopping", { activeTurns: this.manager.activeTurnCount, runtimeCount: this.manager.runtimeCount, }); + for (const socket of this.sockets) socket.destroy(); this.sockets.clear(); // One budget covers every phase (the entrypoint's force-exit timer uses the same value). @@ -230,25 +243,31 @@ export class LocalAgentDaemon { const deadline = Date.now() + this.shutdownTimeoutMs; const remaining = () => Math.max(0, deadline - Date.now()); const serverResult = await settled(withTimeout(closeServer(this.server), remaining(), "daemon socket shutdown")); + const workflowResult = this.workflows ? await settled(withTimeout(this.workflows.close(), Math.min(remaining(), this.shutdownTimeoutMs / 2), "workflow manager shutdown")) : undefined; + const managerResult = await settled(withTimeout(this.manager.close(), remaining(), "daemon manager shutdown")); + if (serverResult.status === "rejected") { writeLocalAgentDaemonLog(this.paths, "warn", "daemon_socket_close_failed", { error: errorMessage(serverResult.reason), }); } + if (managerResult.status === "rejected") { writeLocalAgentDaemonLog(this.paths, "warn", "daemon_manager_close_failed", { error: errorMessage(managerResult.reason), }); } + if (workflowResult?.status === "rejected") { writeLocalAgentDaemonLog(this.paths, "warn", "workflow_manager_close_failed", { error: errorMessage(workflowResult.reason), }); } + removeLocalAgentDaemonFiles(this.paths); this.lock.release(); writeLocalAgentDaemonLog(this.paths, "info", "daemon_stopped", {}); @@ -256,6 +275,7 @@ export class LocalAgentDaemon { this.authToken = undefined; this.onClosed?.(); })(); + return this.closePromise; } @@ -265,6 +285,7 @@ export class LocalAgentDaemon { socket.setEncoding("utf8"); let buffer = ""; let handled = false; + const requestTimer = setTimeout(() => { if (handled) return; handled = true; @@ -276,10 +297,12 @@ export class LocalAgentDaemon { }))); socket.destroy(); }, this.requestReadTimeoutMs); + requestTimer.unref(); socket.on("data", (chunk: string | Buffer) => { if (handled) return; buffer += chunk.toString(); + if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) { handled = true; this.writeError(socket, "", toAgentErrorPayload(new AgentDaemonInvalidRequestError({ @@ -288,9 +311,12 @@ export class LocalAgentDaemon { retryable: false, operation: "request", }))); + return; } + const newline = buffer.indexOf("\n"); + if (newline === -1) return; handled = true; clearTimeout(requestTimer); @@ -307,13 +333,16 @@ export class LocalAgentDaemon { private async handleLine(socket: Socket, line: string, signal: AbortSignal): Promise { let requestId = ""; + try { let parsed: unknown; + try { parsed = JSON.parse(line); } catch (cause) { throw new LocalAgentDaemonProtocolError("INVALID_REQUEST", "Daemon request is not valid JSON.", { cause }); } + requestId = readRequestId(parsed); const request = decodeLocalAgentDaemonRequest(parsed); const response = await this.dispatch(request, signal); @@ -323,6 +352,7 @@ export class LocalAgentDaemon { ok: true, result: response, })); + if (request.method === "daemon.stop") setImmediate(() => { void this.close(); }); } catch (error) { this.writeError(socket, requestId, daemonErrorPayload(error)); @@ -336,13 +366,16 @@ export class LocalAgentDaemon { `Unsupported daemon protocol version ${request.protocolVersion}; expected ${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}.`, ); } + this.assertAuthenticated(request.authToken); + if (request.method === "hello" && !request.configRevision) { throw new LocalAgentDaemonProtocolError( "INVALID_REQUEST", "Daemon hello requires a provider configuration revision.", ); } + if (!this.accepting && request.method !== "hello" && request.method !== "daemon.status") { throw new AgentDaemonUnavailableError({ code: "DAEMON_UNAVAILABLE", @@ -414,6 +447,7 @@ export class LocalAgentDaemon { case "daemon.stop": if (request.params.ifIdle) { this.accepting = false; + if (this.activeTurnRequests > 0 || this.manager.activeTurnCount > 0 || (this.workflows?.activeRunCount ?? 0) > 0) { this.accepting = true; throw new AgentDaemonUnavailableError({ @@ -424,8 +458,10 @@ export class LocalAgentDaemon { }); } } + this.stopping = true; this.accepting = false; + return this.status(); case "daemon.logs": return readLocalAgentDaemonLogs(this.paths, request.params.lines); @@ -436,6 +472,7 @@ export class LocalAgentDaemon { operation: () => Promise>, ): Promise { this.activeTurnRequests += 1; + try { return unwrapManagerResult(await operation()); } finally { @@ -445,6 +482,7 @@ export class LocalAgentDaemon { private async runWorkflowRequest(operation: () => Promise): Promise { this.activeTurnRequests += 1; + try { return await operation(); } finally { @@ -457,6 +495,7 @@ export class LocalAgentDaemon { "WORKFLOWS_UNAVAILABLE", `Workflows are experimental and disabled in this daemon. Set ${WORKFLOWS_ENV}=1 and restart it with \`devspace agents daemon stop\`.`, ); + return this.workflows; } @@ -471,6 +510,7 @@ export class LocalAgentDaemon { private assertAuthenticated(authToken: string): void { const expected = this.authToken; + if (!expected || !safeEqual(authToken, expected)) { throw new LocalAgentDaemonProtocolError("UNAUTHORIZED", "Invalid local agent daemon credentials."); } @@ -478,13 +518,17 @@ export class LocalAgentDaemon { private async maintainIdle(): Promise { await this.manager.evictIdle(this.now()); + if (this.stopping || this.manager.activeTurnCount > 0 || this.manager.runtimeCount > 0 || (this.workflows?.activeRunCount ?? 0) > 0 || this.sockets.size > 0) { this.idleSince = undefined; + return; } + const now = this.now(); this.idleSince ??= now; + if (now - this.idleSince >= this.idleShutdownMs) await this.close(); } } @@ -495,10 +539,12 @@ async function listen(server: NetServer, endpoint: string): Promise { server.off("listening", onListening); reject(error); }; + const onListening = () => { server.off("error", onError); resolve(); }; + server.once("error", onError); server.once("listening", onListening); server.listen(endpoint); @@ -507,6 +553,7 @@ async function listen(server: NetServer, endpoint: string): Promise { async function closeServer(server: NetServer | undefined): Promise { if (!server) return; + if (!server.listening) return; await new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); @@ -517,7 +564,9 @@ async function withTimeout(promise: Promise, timeoutMs: number, operation: if (timeoutMs === 0) { throw new Error(`${operation} timed out.`); } + let timer: NodeJS.Timeout | undefined; + try { return await Promise.race([ promise, @@ -542,12 +591,14 @@ async function settled(promise: Promise): Promise> function safeEqual(actual: string, expected: string): boolean { const actualBuffer = Buffer.from(actual); const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); } function readRequestId(value: unknown): string { if (!value || typeof value !== "object" || Array.isArray(value)) return ""; const requestId = (value as Record).requestId; + return typeof requestId === "string" ? requestId : ""; } @@ -569,6 +620,7 @@ export function writeLocalAgentDaemonLog( export function readLocalAgentDaemonLogs(paths: LocalAgentDaemonPaths, lines = 200): string { try { const content = readFileSync(paths.logPath, "utf8"); + return content.split(/\r?\n/).filter(Boolean).slice(-Math.max(1, lines)).join("\n"); } catch { return ""; @@ -583,7 +635,9 @@ function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { if (error instanceof WorkflowError) { return { code: error.code, message: error.message, retryable: error.retryable }; } + if (isLocalAgentError(error)) return toAgentErrorPayload(error); + if (error instanceof LocalAgentDaemonProtocolError) { if (error.code === "PROTOCOL_MISMATCH") { return toAgentErrorPayload(new AgentDaemonProtocolMismatchError({ @@ -594,6 +648,7 @@ function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { message: error.message, })); } + if (error.code === "UNAUTHORIZED") { return toAgentErrorPayload(new AgentDaemonUnauthorizedError({ code: "DAEMON_UNAUTHORIZED", @@ -603,6 +658,7 @@ function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { message: error.message, })); } + return toAgentErrorPayload(new AgentDaemonInvalidRequestError({ code: "DAEMON_INVALID_REQUEST", operation: "request", @@ -611,6 +667,7 @@ function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { message: error.message, })); } + return toAgentErrorPayload(new AgentDaemonInternalError({ code: "DAEMON_INTERNAL_ERROR", operation: "request", @@ -622,5 +679,6 @@ function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { function unwrapManagerResult(result: Result): T { if (result.isErr()) throw result.error; + return result.value; } diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index 271f16722..3d8f48371 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -236,9 +236,11 @@ export function agentErrorFromPayload(payload: { target?: string; }): LocalAgentError | undefined { const retryable = payload.retryable ?? false; + const provider = payload.provider && isLocalAgentDriverKind(payload.provider) ? payload.provider : undefined; + switch (payload.code) { case "UNKNOWN_TARGET": case "AGENT_NOT_FOUND": @@ -280,6 +282,7 @@ export function agentErrorFromPayload(payload: { case "PROVIDER_PROTOCOL_ERROR": case "PROVIDER_EXECUTION_ERROR": { if (!provider) return undefined; + const fields = { provider, agentId: payload.agentId, @@ -287,17 +290,22 @@ export function agentErrorFromPayload(payload: { retryable, message: payload.message, }; + if (payload.code === "PROVIDER_UNAVAILABLE") { return new AgentProviderUnavailableError({ code: payload.code, ...fields }); } + if (payload.code === "PROVIDER_CANCELLED") { return new AgentProviderCancelledError({ code: payload.code, ...fields }); } + if (payload.code === "PROVIDER_PROTOCOL_ERROR") { return new AgentProviderProtocolError({ code: payload.code, ...fields }); } + return new AgentProviderExecutionError({ code: payload.code, ...fields }); } + case "AGENT_STORE_ERROR": return new AgentStoreError(payload.operation ?? "request", undefined, payload.message); case "DAEMON_UNAVAILABLE": @@ -375,8 +383,10 @@ export function providerErrorFromCause(input: { cause: unknown; }): AgentProviderError | undefined { if (isAgentProviderError(input.cause)) return input.cause; + if (isLocalAgentError(input.cause)) return undefined; const unavailable = unavailableCauseKind(input.cause); + if (unavailable) { return new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", @@ -388,7 +398,9 @@ export function providerErrorFromCause(input: { message: `${displayProvider(input.provider)} provider is unavailable.`, }); } + if (isProgrammerDefect(input.cause)) return undefined; + if (isAbortError(input.cause)) { return new AgentProviderCancelledError({ code: "PROVIDER_CANCELLED", @@ -400,6 +412,7 @@ export function providerErrorFromCause(input: { message: `${displayProvider(input.provider)} agent turn was cancelled.`, }); } + return new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", provider: input.provider, @@ -426,13 +439,16 @@ export async function captureAgentProviderResult(input: { operation: input.operation, cause, }); + if (!error) throw cause; + return Result.err(error); } } export function isProgrammerDefect(error: unknown): boolean { if (unavailableCauseKind(error)) return false; + return error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError @@ -452,13 +468,17 @@ function isAbortError(error: unknown): boolean { function unavailableCauseKind(error: unknown): "permanent" | "transient" | undefined { const seen = new Set(); let current = error; + while (current && typeof current === "object" && !seen.has(current)) { seen.add(current); const code = "code" in current ? String((current as { code?: unknown }).code) : ""; + if (code === "ENOENT") return "permanent"; + if (code === "ECONNREFUSED" || code === "ENOTFOUND") return "transient"; current = "cause" in current ? (current as { cause?: unknown }).cause : undefined; } + return undefined; } diff --git a/src/local-agent-events.ts b/src/local-agent-events.ts index 5648efe21..a2307113b 100644 --- a/src/local-agent-events.ts +++ b/src/local-agent-events.ts @@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto"; * lifecycle events and stamps every event with the app-owned turn id. */ export type LocalAgentToolStatus = "completed" | "failed"; + export type LocalAgentTurnOutcome = "completed" | "failed" | "stopped"; export type LocalAgentActivityEvent = @@ -50,12 +51,16 @@ export function createLocalAgentTurnId(): string { export function sanitizeActivityEvent(event: LocalAgentActivityEvent): LocalAgentActivityEvent | undefined { if (event.type === "message") { const text = boundText(redactSecrets(event.text.trim()), MAX_MESSAGE_CHARS); + return text ? { type: "message", text } : undefined; } + const tool = boundText(redactSecrets(event.tool.trim()), MAX_TOOL_NAME_CHARS) || "tool"; + const summary = event.summary === undefined ? undefined : boundText(redactSecrets(singleLine(event.summary)), MAX_SUMMARY_CHARS) || undefined; + return event.type === "tool_started" ? { type: "tool_started", tool, ...(summary ? { summary } : {}) } : { type: "tool_completed", tool, ...(summary ? { summary } : {}), status: event.status }; @@ -67,8 +72,10 @@ export function sanitizeActivityEvent(event: LocalAgentActivityEvent): LocalAgen */ export function summarizeToolInput(input: unknown): string | undefined { if (typeof input === "string") return input.trim() || undefined; + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined; const record = input as Record; + for (const key of [ "command", "cmd", @@ -81,11 +88,14 @@ export function summarizeToolInput(input: unknown): string | undefined { "description", ]) { const value = record[key]; + if (typeof value === "string" && value.trim()) return value; + if (Array.isArray(value) && value.every((part) => typeof part === "string") && value.length > 0) { return value.join(" "); } } + return undefined; } @@ -93,18 +103,23 @@ export function summarizeToolInput(input: unknown): string | undefined { export function providerErrorDetail(error: { message: string; cause?: unknown }): string | undefined { const seen = new Set(); let current: unknown = error.cause; + while (current !== undefined && current !== null && !seen.has(current)) { seen.add(current); + const message = typeof current === "string" ? current : current instanceof Error || typeof (current as { message?: unknown }).message === "string" ? String((current as { message: unknown }).message) : undefined; + if (message?.trim() && message.trim() !== error.message) { return boundText(redactSecrets(message.trim()), MAX_ERROR_DETAIL_CHARS); } + current = typeof current === "object" ? (current as { cause?: unknown }).cause : undefined; } + return undefined; } @@ -117,26 +132,32 @@ const SECRET_PATTERNS: readonly RegExp[] = [ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, ]; + const SECRET_ASSIGNMENT = /\b([A-Za-z0-9_]*(?:api[_-]?key|token|secret|password|passwd|authorization|credential)[A-Za-z0-9_]*)(["']?\s*[:=]\s*["']?)(?:Bearer\s+)?([^\s"',;]{4,})/gi; const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g; + // `https://user:pass@host`: keep the user, drop the password. The password runs to the // authority's last `@` (it may contain `@`) and never past `/`, `?` or `#`. const URL_PASSWORD = /\b([a-z][a-z0-9+.-]*:\/\/[^\s/?#@:]*):[^\s/?#]*@(?=[^\s/?#@]*(?:[\s/?#]|$))/gi; + // `--password hunter2`: a secret-named flag followed by its value as the next word. // Plural "tokens" (e.g. --max-tokens 4096) is a count, not a credential. const SECRET_FLAG = /(--?[A-Za-z0-9_-]*(?:api[_-]?key|token(?!s)|secret|password|passwd|authorization|credential)[A-Za-z0-9_-]*\s+)(?!-)("[^"]*"|'[^']*'|\S+)/gi; export function redactSecrets(text: string): string { let redacted = text; + for (const pattern of SECRET_PATTERNS) redacted = redacted.replace(pattern, "[redacted]"); redacted = redacted.replace(BEARER, "Bearer [redacted]"); redacted = redacted.replace(URL_PASSWORD, "$1:[redacted]@"); redacted = redacted.replace(SECRET_FLAG, "$1[redacted]"); + return redacted.replace(SECRET_ASSIGNMENT, (_match, name: string, separator: string) => `${name}${separator}[redacted]`); } function boundText(text: string, maxChars: number): string { if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars - 1)}…`; } diff --git a/src/local-agent-grok.test.ts b/src/local-agent-grok.test.ts index e43b457e8..9cbfd3323 100644 --- a/src/local-agent-grok.test.ts +++ b/src/local-agent-grok.test.ts @@ -62,6 +62,7 @@ test("prompt completions are parsed from responses and turn_completed updates on }), { sessionId: "grok_session_1", promptId: "prompt_2", stopReason: "end_turn" }, ); + for (const incomplete of [ { sessionId: "grok_session_1", promptId: "task-completed-123" }, { sessionId: "grok_session_1", update: { sessionUpdate: "agent_message_chunk" } }, diff --git a/src/local-agent-grok.ts b/src/local-agent-grok.ts index 09c851bf1..de81af4d5 100644 --- a/src/local-agent-grok.ts +++ b/src/local-agent-grok.ts @@ -1,6 +1,7 @@ import { AgentProviderProtocolError } from "./local-agent-errors.js"; export const GROK_DEFAULT_MODEL = "grok-build"; + export const GROK_REASONING_EFFORTS = [ "none", "minimal", @@ -9,6 +10,7 @@ export const GROK_REASONING_EFFORTS = [ "high", "xhigh", ] as const; + export type GrokReasoningEffort = (typeof GROK_REASONING_EFFORTS)[number]; const COMPLETED_PROMPT_ID_LIMIT = 128; @@ -54,14 +56,17 @@ export class GrokPromptCompletionRegistry { onTimeout: () => Error, ): Promise { const key = promptKey(sessionId, promptId); + if (this.pending.has(key)) { throw new Error(`Grok prompt completion is already pending: ${promptId}`); } + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(key); reject(onTimeout()); }, timeoutMs); + timer.unref(); this.pending.set(key, { sessionId, promptId, resolve, reject, timer }); }); @@ -69,9 +74,11 @@ export class GrokPromptCompletionRegistry { resolve(completion: GrokPromptCompletion): void { if (completion.promptId && this.completedPromptIds.includes(completion.promptId)) return; + const pending = completion.promptId ? this.pending.get(promptKey(completion.sessionId, completion.promptId)) : findPendingForSession(this.pending, completion.sessionId); + if (!pending) return; this.pending.delete(promptKey(pending.sessionId, pending.promptId)); clearTimeout(pending.timer); @@ -82,6 +89,7 @@ export class GrokPromptCompletionRegistry { remove(sessionId: string, promptId: string): void { const key = promptKey(sessionId, promptId); const pending = this.pending.get(key); + if (!pending) return; this.pending.delete(key); clearTimeout(pending.timer); @@ -90,6 +98,7 @@ export class GrokPromptCompletionRegistry { rejectAll(error: unknown): void { const pending = Array.from(this.pending.values()); this.pending.clear(); + for (const entry of pending) { clearTimeout(entry.timer); entry.reject(error); @@ -99,10 +108,12 @@ export class GrokPromptCompletionRegistry { markCompleted(sessionId: string, promptId: string): void { const key = promptKey(sessionId, promptId); const pending = this.pending.get(key); + if (pending) { this.pending.delete(key); clearTimeout(pending.timer); } + this.rememberCompletedPromptId(promptId); } @@ -113,6 +124,7 @@ export class GrokPromptCompletionRegistry { private rememberCompletedPromptId(promptId: string): void { if (this.completedPromptIds.includes(promptId)) return; this.completedPromptIds.push(promptId); + if (this.completedPromptIds.length > COMPLETED_PROMPT_ID_LIMIT) { this.completedPromptIds.splice(0, this.completedPromptIds.length - COMPLETED_PROMPT_ID_LIMIT); } @@ -122,10 +134,12 @@ export class GrokPromptCompletionRegistry { export function parseGrokPromptCompletion(input: unknown): GrokPromptCompletion | undefined { const record = asRecord(input); const sessionId = directString(record?.sessionId); + if (!sessionId) return undefined; const update = asRecord(record?.update); const sessionUpdate = directString(update?.sessionUpdate); + if (update && sessionUpdate !== "turn_completed") return undefined; const promptId = firstString( @@ -136,6 +150,7 @@ export function parseGrokPromptCompletion(input: unknown): GrokPromptCompletion asRecord(record?._meta)?.promptId, asRecord(update?._meta)?.promptId, ); + if (promptId && isBackgroundPromptId(promptId)) return undefined; return { @@ -150,13 +165,16 @@ export function parseGrokPromptCompletion(input: unknown): GrokPromptCompletion export function readGrokSessionState(value: unknown): GrokSessionState | undefined { const record = asRecord(value); const response = asRecord(record?.newSessionResponse) ?? record; + const models = asRecord(response?.models) ?? asRecord(asRecord(response?._meta)?.modelState); + if (!models) return undefined; const availableModels = (readArray(models.availableModels) ?? []) .map(readGrokModelInfo) .filter((model): model is GrokModelInfo => model !== undefined); + return { currentModelId: directString(models.currentModelId), availableModels, @@ -165,7 +183,9 @@ export function readGrokSessionState(value: unknown): GrokSessionState | undefin export function normalizeGrokModelId(value: string | undefined): string | undefined { const trimmed = value?.trim(); + if (!trimmed) return undefined; + return trimmed.replace(/^(?:grok|xai)\//i, ""); } @@ -174,13 +194,16 @@ export function resolveGrokModelId( state: GrokSessionState | undefined, ): string { const modelId = normalizeGrokModelId(requested); + if (!modelId) throw grokConfigurationError("Grok model must not be empty."); const available = state?.availableModels ?? []; + if (available.length > 0 && !available.some((model) => model.id === modelId)) { throw grokConfigurationError( `Grok does not support '${modelId}'. Available models: ${available.map((model) => model.id).join(", ")}.`, ); } + return modelId; } @@ -190,18 +213,22 @@ export function resolveGrokEffort( modelId: string | undefined, ): GrokReasoningEffort { const normalized = effort.trim().toLowerCase(); + if (!isGrokReasoningEffort(normalized)) { throw grokConfigurationError( `Grok reasoning effort must be one of: ${GROK_REASONING_EFFORTS.join(", ")}.`, ); } + const selectedModel = state?.availableModels.find((model) => model.id === modelId); const availableEfforts = selectedModel?.reasoningEfforts ?? []; + if (availableEfforts.length > 0 && !availableEfforts.includes(normalized)) { throw grokConfigurationError( `Grok model '${modelId ?? GROK_DEFAULT_MODEL}' does not support effort '${normalized}'. Available efforts: ${availableEfforts.join(", ")}.`, ); } + return normalized; } @@ -222,16 +249,20 @@ export function isGrokReasoningEffort(value: string): value is GrokReasoningEffo function readGrokModelInfo(value: unknown): GrokModelInfo | undefined { const record = asRecord(value); const id = directString(record?.modelId) ?? directString(record?.id); + if (!id) return undefined; const modelMeta = asRecord(record?._meta); + const reasoningEfforts = (readArray(modelMeta?.reasoningEfforts) ?? []) .flatMap((entry) => { const effort = asRecord(entry); + return [directString(effort?.id), directString(effort?.value)].filter( (value): value is string => value !== undefined, ); }) .filter((effort, index, values) => values.indexOf(effort) === index); + return { id, reasoningEfforts }; } @@ -254,8 +285,10 @@ function isBackgroundPromptId(promptId: string): boolean { function firstString(...values: unknown[]): string | undefined { for (const value of values) { const result = directString(value); + if (result) return result; } + return undefined; } diff --git a/src/local-agent-id.ts b/src/local-agent-id.ts index 295d2af79..8a968e9bb 100644 --- a/src/local-agent-id.ts +++ b/src/local-agent-id.ts @@ -5,5 +5,6 @@ export function validateAgentId(id: string, label = "Subagent id"): string { if (!AGENT_ID_PATTERN.test(id)) { throw new Error(`${label} must be 1-128 characters using letters, numbers, '.', '_', ':' or '-'.`); } + return id; } diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 049b47ce7..f11db641d 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -27,6 +27,7 @@ const TEST_CAPABILITIES = { permissions: { enforcement: "native" }, mcp: { supported: false }, } as const; + import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; import type { SubagentsConfig } from "./local-agent-config.js"; @@ -53,17 +54,20 @@ class FakeRuntime implements LocalAgentRuntime { callbacks?: LocalAgentRunCallbacks, ): Promise> { this.inputs.push(input); + if (input.prompt.includes("stream")) { callbacks?.onEvent?.({ type: "tool_started", tool: "bash", summary: "curl -H 'Authorization: Bearer abcdefghijklmnop' x" }); callbacks?.onEvent?.({ type: "tool_completed", tool: "bash", summary: "curl", status: "failed" }); callbacks?.onEvent?.({ type: "message", text: " checked it " }); callbacks?.onEvent?.({ type: "message", text: " " }); } + if (input.prompt.includes("flood")) { for (let index = 0; index < 600; index += 1) { callbacks?.onEvent?.({ type: "tool_started", tool: "read", summary: `file-${index}` }); } } + if (input.prompt.includes("detail-fail")) { return Result.err(new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", @@ -74,24 +78,33 @@ class FakeRuntime implements LocalAgentRuntime { message: "Codex agent turn failed.", })); } + if (input.prompt.includes("early-fail")) { await callbacks?.onSessionId?.("thread_early"); + return Result.err(providerFailure("provider failed after session creation")); } + if (input.prompt.includes("defect")) throw new TypeError("internal defect"); + if (input.prompt.includes("fail")) return Result.err(providerFailure("provider failed")); + if (input.prompt.includes("hold")) { await new Promise((resolve) => { this.releaseHold = resolve; }); } + if (input.prompt.includes("finish-despite-stop")) { await new Promise((resolve) => input.signal?.addEventListener("abort", () => resolve(), { once: true })); } + if (input.prompt.includes("cancel-me")) { await new Promise((resolve) => { const done = () => resolve(); + if (input.signal?.aborted) done(); else input.signal?.addEventListener("abort", done, { once: true }); }); + return Result.err(new AgentProviderCancelledError({ code: "PROVIDER_CANCELLED", provider: "codex", @@ -100,6 +113,7 @@ class FakeRuntime implements LocalAgentRuntime { message: "codex agent turn was stopped.", })); } + return Result.ok({ provider: this.provider, providerSessionId: "thread_test", @@ -140,6 +154,7 @@ function providerFailure(message: string): AgentProviderExecutionError { test("a profile loader defect surfaces as a panic", async (t) => { const { root, scope, driver } = await fixture(t); const defectStore = new LocalAgentStore(join(root, "defect-state")); + const defectManager = new LocalAgentManager({ store: defectStore, drivers: [driver], @@ -150,6 +165,7 @@ test("a profile loader defect surfaces as a panic", async (t) => { allowedRoots: [root], subagents, }); + await assert.rejects( defectManager.start({ target: "reviewer", @@ -166,6 +182,7 @@ test("a profile loader defect surfaces as a panic", async (t) => { test("a missing provider executable fails the request before any agent record exists", async (t) => { const { root, scope, driver, profile } = await fixture(t); const unavailableStore = new LocalAgentStore(join(root, "unavailable-state")); + const unavailableManager = new LocalAgentManager({ store: unavailableStore, drivers: [driver], @@ -175,12 +192,14 @@ test("a missing provider executable fails the request before any agent record ex subagents, providerAvailability: (name) => ({ name, available: false, reason: "codex executable not found" }), }); + const unavailable = await unavailableManager.start({ target: "reviewer", prompt: "inspect", workspaceId: scope.workspaceId, workspaceRoot: root, }); + assert.equal(unavailable.isErr() && unavailable.error.code, "PROVIDER_UNAVAILABLE"); assert.deepEqual(unwrap(unavailableManager.list(scope)), []); await unavailableManager.close(); @@ -192,18 +211,22 @@ test("queued cancellation never launches the provider runtime", async (t) => { const queuedProfiles = new Promise((resolve) => { releaseQueuedProfiles = resolve; }); let queuedProfileLoads = 0; const queuedStore = new LocalAgentStore(join(root, "queued-state")); + const queuedManager = new LocalAgentManager({ store: queuedStore, drivers: [driver], pool: new LocalAgentRuntimePool(), loadProfiles: async () => { queuedProfileLoads += 1; + if (queuedProfileLoads === 2) await queuedProfiles; + return [profile]; }, allowedRoots: [root], subagents, }); + const queued = unwrap(await queuedManager.start({ agentId: "workflow-queued-cancel", target: "reviewer", @@ -211,6 +234,7 @@ test("queued cancellation never launches the provider runtime", async (t) => { workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => queuedProfileLoads === 2); const queuedCancellation = queuedManager.stop(queued.id, scope); releaseQueuedProfiles(); @@ -221,36 +245,44 @@ test("queued cancellation never launches the provider runtime", async (t) => { test("a workspace outside the allowed roots is rejected", async (t) => { const { manager, scope } = await fixture(t); + const outside = await manager.start({ target: "reviewer", prompt: "outside", workspaceId: scope.workspaceId, workspaceRoot: join(tmpdir(), "outside"), }); + assert.equal(outside.isErr(), true); + if (outside.isErr()) assert.equal(outside.error.code, "WORKSPACE_NOT_ALLOWED"); }); test("an unknown target is rejected", async (t) => { const { manager, root, scope } = await fixture(t); + const unknown = await manager.start({ target: "missing", prompt: "inspect", workspaceId: scope.workspaceId, workspaceRoot: root, }); + assert.equal(unknown.isErr(), true); + if (unknown.isErr()) assert.equal(unknown.error.code, "UNKNOWN_TARGET"); }); test("stop cancels a running turn", async (t) => { const { manager, store, root, scope, getRecord } = await fixture(t); + const cancellable = unwrap(await manager.start({ target: "codex", prompt: "cancel-me", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => getRecord(cancellable.id).status === "running"); const stopped = unwrap(await manager.stop(cancellable.id, scope)); assert.equal(stopped.status, "stopped"); @@ -261,19 +293,23 @@ test("stop cancels a running turn", async (t) => { // A provider that ignores stop leaves the turn running; stop reports that instead of hanging. test("stop reports a provider that ignores it instead of hanging", async (t) => { const { manager, root, scope, runtimes, getRecord } = await fixture(t); + const stubborn = unwrap(await manager.start({ target: "codex", prompt: "hold", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => runtimes.get(stubborn.id)?.inputs.length === 1); const stubbornStop = await manager.stop(stubborn.id, scope); assert.equal(stubbornStop.isErr(), true); + if (stubbornStop.isErr()) { assert.equal(stubbornStop.error.code, "AGENT_CONFLICT"); assert.equal(stubbornStop.error.retryable, true); } + assert.equal(getRecord(stubborn.id).status, "running"); runtimes.get(stubborn.id)?.release(); await waitFor(() => getRecord(stubborn.id).status === "idle"); @@ -282,15 +318,18 @@ test("stop reports a provider that ignores it instead of hanging", async (t) => // A turn that completes normally despite the stop request is not reported as stopped. test("a turn that completes normally despite a stop request is not reported as stopped", async (t) => { const { manager, root, scope, runtimes } = await fixture(t); + const finishing = unwrap(await manager.start({ target: "codex", prompt: "finish-despite-stop", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => runtimes.get(finishing.id)?.inputs.length === 1); const finishingStop = await manager.stop(finishing.id, scope); assert.equal(finishingStop.isErr(), true); + if (finishingStop.isErr()) { assert.equal(finishingStop.error.code, "AGENT_CONFLICT"); assert.equal(finishingStop.error.retryable, false); @@ -299,12 +338,14 @@ test("a turn that completes normally despite a stop request is not reported as s test("a named provider instance keeps its driver and model, even after a later same-name profile appears", async (t) => { const { manager, root, scope, runtimes, getRecord, profile, lateProfiles } = await fixture(t); + const namedInstance = unwrap(await manager.start({ target: "codex-work", prompt: "inspect named instance", workspaceId: scope.workspaceId, workspaceRoot: root, })); + assert.equal(namedInstance.providerInstanceId, "codex-work"); assert.equal(namedInstance.driver, "codex"); assert.equal(namedInstance.model, "gpt-work"); @@ -321,12 +362,14 @@ test("a named provider instance keeps its driver and model, even after a later s // A profile shadows a provider instance with the same name, on start and on every continuation. test("a profile shadows a provider instance with the same name on start and every continuation", async (t) => { const { manager, root, scope, runtimes, getRecord } = await fixture(t); + const shadowed = unwrap(await manager.start({ target: "codex-shadow", prompt: "first", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => getRecord(shadowed.id).status === "idle"); unwrap(await manager.continue(shadowed.id, "second", {}, scope)); await waitFor(() => runtimes.get(shadowed.id)?.inputs.length === 2); @@ -339,42 +382,52 @@ test("a profile shadows a provider instance with the same name on start and ever test("a disabled profile cannot start", async (t) => { const { manager, root, scope } = await fixture(t); + const disabled = await manager.start({ target: "disabled-reviewer", prompt: "inspect", workspaceId: scope.workspaceId, workspaceRoot: root, }); + assert.equal(disabled.isErr(), true); + if (disabled.isErr()) assert.equal(disabled.error.code, "PROVIDER_DISABLED"); }); test("a configured provider without a driver cannot start", async (t) => { const { manager, root, scope } = await fixture(t); + const unconfigured = await manager.start({ target: "claude", prompt: "inspect", workspaceId: scope.workspaceId, workspaceRoot: root, }); + assert.equal(unconfigured.isErr(), true); + if (unconfigured.isErr()) assert.equal(unconfigured.error.code, "PROVIDER_NOT_CONFIGURED"); }); test("a provider missing from the subagents config is an unknown target", async (t) => { const { manager, root, scope } = await fixture(t); + const disabledProvider = await manager.start({ target: "pi", prompt: "inspect", workspaceId: scope.workspaceId, workspaceRoot: root, }); + assert.equal(disabledProvider.isErr(), true); + if (disabledProvider.isErr()) assert.equal(disabledProvider.error.code, "UNKNOWN_TARGET"); }); test("an agent whose profile was disabled later cannot continue", async (t) => { const { manager, store, root, scope, disabledProfile } = await fixture(t); + const previouslyCreatedDisabled = store.create({ workspaceId: scope.workspaceId, workspaceRoot: root, @@ -382,9 +435,11 @@ test("an agent whose profile was disabled later cannot continue", async (t) => { providerInstanceId: "codex", driver: "codex", }); + store.update(previouslyCreatedDisabled.id, { status: "idle" }); const disabledContinuation = await manager.continue(previouslyCreatedDisabled.id, "inspect", {}, scope); assert.equal(disabledContinuation.isErr(), true); + if (disabledContinuation.isErr()) assert.equal(disabledContinuation.error.code, "PROVIDER_DISABLED"); }); @@ -396,6 +451,7 @@ test("reconciling after a restart fails the interrupted turn and keeps the previ const mismatchedGet = manager.get(stale.id, { workspaceId: "ws_current", workspaceRoot: root }); assert.equal(mismatchedGet.isErr(), true); + if (mismatchedGet.isErr()) assert.equal(mismatchedGet.error.code, "WORKSPACE_MISMATCH"); unwrap(manager.reconcileActiveRuns()); @@ -410,12 +466,14 @@ test("reconciling after a restart fails the interrupted turn and keeps the previ test("a running agent applies provider defaults and rejects a concurrent continuation", async (t) => { const { manager, store, root, scope, runtimes, getRecord } = await fixture(t); + const first = unwrap(await manager.start({ target: "reviewer", prompt: "hold", workspaceId: scope.workspaceId, workspaceRoot: root, })); + assert.equal(first.status, "running"); assert.equal(first.model, "gpt-default"); assert.equal(first.effort, "medium"); @@ -423,6 +481,7 @@ test("a running agent applies provider defaults and rejects a concurrent continu await waitFor(() => runtimes.get(first.id)?.inputs.length === 1); const conflict = await manager.continue(first.id, "another prompt", {}, scope); assert.equal(conflict.isErr(), true); + if (conflict.isErr()) { assert.equal(conflict.error.code, "AGENT_CONFLICT"); assert.equal("agentId" in conflict.error ? conflict.error.agentId : undefined, first.id); @@ -442,6 +501,7 @@ test("a repeated request id returns the same agent, rejects changed input, and n const { manager, root, scope, runtimes, getRecord, startCompleted } = await fixture(t); const first = await startCompleted("hold"); const duplicateId = "workflow-dispatch-1"; + const duplicateInput = { agentId: duplicateId, target: "reviewer", @@ -450,28 +510,38 @@ test("a repeated request id returns the same agent, rejects changed input, and n workspaceRoot: root, writeMode: "read_only" as const, }; + const [duplicate, simultaneousRetry] = await Promise.all([ manager.start(duplicateInput).then(unwrap), manager.start(duplicateInput).then(unwrap), ]); + assert.equal(simultaneousRetry.id, duplicate.id); await waitFor(() => runtimes.get(duplicate.id)?.inputs.length === 1); + const duplicateRetry = unwrap(await manager.start({ ...duplicateInput, })); + assert.equal(duplicateRetry.id, duplicate.id); assert.equal(runtimes.get(duplicate.id)?.inputs.length, 1); + const mismatchedDuplicate = await manager.start({ ...duplicateInput, prompt: "different prompt", }); + assert.equal(mismatchedDuplicate.isErr(), true); + if (mismatchedDuplicate.isErr()) assert.equal(mismatchedDuplicate.error.code, "AGENT_CONFLICT"); + const reconfiguredDuplicate = await manager.start({ ...duplicateInput, model: "gpt-other", }); + assert.equal(reconfiguredDuplicate.isErr(), true); + if (reconfiguredDuplicate.isErr()) assert.equal(reconfiguredDuplicate.error.code, "AGENT_CONFLICT"); const duplicateTurn = unwrap(manager.getTurn(duplicate.id, scope)); assert.equal(duplicateTurn?.status, "running"); @@ -485,31 +555,40 @@ test("a repeated request id returns the same agent, rejects changed input, and n const authorityUpgrade = await manager.continue(duplicate.id, "expand authority", { writeMode: "allowed", }, scope); + assert.equal(authorityUpgrade.isErr(), true); + if (authorityUpgrade.isErr()) assert.equal(authorityUpgrade.error.code, "AGENT_CONFLICT"); }); test("continuations apply run overrides and persist authority reductions", async (t) => { const { manager, store, scope, getRecord, startCompleted } = await fixture(t); const first = await startCompleted("hold"); + const continued = unwrap(await manager.continue(first.id, "continue", { model: "gpt-run", effort: "high", }, scope)); + assert.equal(continued.status, "running"); await waitFor(() => getRecord(first.id).status === "idle"); assert.equal(getRecord(first.id).model, "gpt-run"); assert.equal(getRecord(first.id).effort, "high"); + const reduced = unwrap(await manager.continue(first.id, "reduce authority", { writeMode: "read_only", }, scope)); + assert.equal(reduced.writeMode, "read_only"); await waitFor(() => getRecord(first.id).status === "idle"); assert.equal(getRecord(first.id).writeMode, "read_only", "authority reductions persist across continuations"); + const restoreAuthority = await manager.continue(first.id, "restore authority", { writeMode: "allowed", }, scope); + assert.equal(restoreAuthority.isErr(), true); + if (restoreAuthority.isErr()) assert.equal(restoreAuthority.error.code, "AGENT_CONFLICT"); assert.deepEqual( store.listTurns(first.id).map((turn) => ({ prompt: turn.prompt, status: turn.status })), @@ -523,6 +602,7 @@ test("continuations apply run overrides and persist authority reductions", async test("an idempotent retry returns the current agent and never redispatches", async (t) => { const { manager, store, root, scope, getRecord } = await fixture(t); + const mutableDispatchInput = { agentId: "workflow-sticky-dispatch", target: "reviewer", @@ -531,6 +611,7 @@ test("an idempotent retry returns the current agent and never redispatches", asy workspaceRoot: root, writeMode: "allowed" as const, }; + const mutableDispatch = unwrap(await manager.start(mutableDispatchInput)); await waitFor(() => getRecord(mutableDispatch.id).status === "idle"); unwrap(await manager.continue(mutableDispatch.id, "reduce stable dispatch", { @@ -546,12 +627,14 @@ test("an idempotent retry returns the current agent and never redispatches", asy test("different agents receive independent logical runtimes", async (t) => { const { manager, root, scope, runtimes, getRecord, startCompleted } = await fixture(t); const first = await startCompleted("hold"); + const second = unwrap(await manager.start({ target: "reviewer", prompt: "second agent", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => getRecord(second.id).status === "idle"); assert.notEqual(first.id, second.id); assert.notEqual(runtimes.get(first.id), runtimes.get(second.id), "different agents receive independent logical runtimes"); @@ -559,12 +642,14 @@ test("different agents receive independent logical runtimes", async (t) => { test("a provider error fails the turn and releases active-turn ownership", async (t) => { const { manager, store, root, scope, getRecord } = await fixture(t); + const failed = unwrap(await manager.start({ target: "reviewer", prompt: "fail", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => getRecord(failed.id).status === "error"); assert.equal(getRecord(failed.id).error, "provider failed"); assert.equal(getRecord(failed.id).errorCode, "PROVIDER_EXECUTION_ERROR"); @@ -584,26 +669,32 @@ test("a turn that fails after session creation keeps the provider session id", a test("multi-agent wait remains pending until every turn finishes", async (t) => { const { manager, root, scope, runtimes, getRecord } = await fixture(t); + const waitingOne = unwrap(await manager.start({ target: "reviewer", prompt: "hold wait one", workspaceId: scope.workspaceId, workspaceRoot: root, })); + const waitingTwo = unwrap(await manager.start({ target: "reviewer", prompt: "hold wait two", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => runtimes.get(waitingOne.id)?.inputs.length === 1); await waitFor(() => runtimes.get(waitingTwo.id)?.inputs.length === 1); let multiWaitSettled = false; + const multiWait = manager.wait([waitingOne.id, waitingTwo.id, waitingOne.id], scope) .then((result) => { multiWaitSettled = true; + return result; }); + runtimes.get(waitingOne.id)!.release(); await waitFor(() => getRecord(waitingOne.id).status === "idle"); assert.equal(multiWaitSettled, false, "multi-agent wait must remain pending until every turn finishes"); @@ -617,12 +708,14 @@ test("multi-agent wait remains pending until every turn finishes", async (t) => test("a timed wait reports finished turns and times out running ones", async (t) => { const { manager, root, scope, runtimes, getRecord, startEarlyFailure } = await fixture(t); const earlyFailure = await startEarlyFailure(); + const timedWaitAgent = unwrap(await manager.start({ target: "reviewer", prompt: "hold timed wait", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => runtimes.get(timedWaitAgent.id)?.inputs.length === 1); const timedWaitResults = unwrap(await manager.wait([earlyFailure.id, timedWaitAgent.id], scope, 5)); @@ -650,12 +743,14 @@ test("a timed wait reports finished turns and times out running ones", async (t) test("cancelling a waiter does not stop its turn", async (t) => { const { manager, root, scope, runtimes, getRecord } = await fixture(t); + const cancelledWaitAgent = unwrap(await manager.start({ target: "reviewer", prompt: "hold cancelled wait", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => runtimes.get(cancelledWaitAgent.id)?.inputs.length === 1); const waitAbort = new AbortController(); const cancelledWait = manager.wait([cancelledWaitAgent.id], scope, undefined, waitAbort.signal); @@ -671,19 +766,23 @@ test("waiting on an unknown agent fails", async (t) => { const waitingOne = await startCompleted("hold wait one"); const invalidWait = await manager.wait([waitingOne.id, "agt_missing"], scope, 5); assert.equal(invalidWait.isErr(), true); + if (invalidWait.isErr()) assert.equal(invalidWait.error.code, "AGENT_NOT_FOUND"); }); test("continuing from another workspace root or id is a workspace mismatch", async (t) => { const { manager, root, scope, startCompleted } = await fixture(t); const first = await startCompleted("hold"); + const wrongWorkspace = await manager.continue( first.id, "wrong workspace", {}, { workspaceId: scope.workspaceId, workspaceRoot: join(root, "other") }, ); + assert.equal(wrongWorkspace.isErr(), true); + if (wrongWorkspace.isErr()) assert.equal(wrongWorkspace.error.code, "WORKSPACE_MISMATCH"); const wrongWorkspaceId = await manager.continue( @@ -692,18 +791,22 @@ test("continuing from another workspace root or id is a workspace mismatch", asy {}, { workspaceId: "ws_other", workspaceRoot: root }, ); + assert.equal(wrongWorkspaceId.isErr(), true); + if (wrongWorkspaceId.isErr()) assert.equal(wrongWorkspaceId.error.code, "WORKSPACE_MISMATCH"); }); test("managed and direct agents may run outside the allowed roots", async (t) => { const { manager, directRoot } = await fixture(t); + const managed = unwrap(await manager.start({ target: "reviewer", prompt: "managed worktree", workspaceId: "ws_managed", workspaceRoot: directRoot, })); + await waitFor(() => unwrap(manager.get(managed.id, { workspaceId: "ws_managed", workspaceRoot: directRoot, @@ -714,6 +817,7 @@ test("managed and direct agents may run outside the allowed roots", async (t) => prompt: "direct outside allowed roots", workspaceRoot: directRoot, })); + await waitFor(() => unwrap(manager.get(directOutside.id, { workspaceRoot: directRoot })).status === "idle"); assert.equal(directOutside.workspaceId, undefined); assert.deepEqual(unwrap(manager.list({ workspaceRoot: directRoot })).map((record) => record.id).sort(), [ @@ -725,27 +829,32 @@ test("managed and direct agents may run outside the allowed roots", async (t) => test("a direct agent has no workspace id and rejects a mismatched one", async (t) => { const { manager, root, startCompleted } = await fixture(t); const first = await startCompleted("hold"); + const direct = unwrap(await manager.start({ target: "reviewer", prompt: "direct harness", workspaceRoot: root, })); + await waitFor(() => unwrap(manager.get(direct.id, { workspaceRoot: root })).status === "idle"); assert.equal(direct.workspaceId, undefined); assert.equal(unwrap(manager.get(first.id, { workspaceRoot: root })).id, first.id); const directWrongId = manager.get(direct.id, { workspaceId: "ws_other", workspaceRoot: root }); assert.equal(directWrongId.isErr(), true); + if (directWrongId.isErr()) assert.equal(directWrongId.error.code, "WORKSPACE_MISMATCH"); }); test("a thrown runtime defect is an internal error, not a provider error", async (t) => { const { manager, root, scope, getRecord } = await fixture(t); + const defect = unwrap(await manager.start({ target: "reviewer", prompt: "defect", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => getRecord(defect.id).status === "error"); assert.equal(getRecord(defect.id).errorCode, "AGENT_INTERNAL_ERROR"); assert.notEqual(getRecord(defect.id).errorCode, "PROVIDER_EXECUTION_ERROR"); @@ -755,12 +864,14 @@ test("a thrown runtime defect is an internal error, not a provider error", async test("each turn gets an app-owned id and its activity is persisted as a redacted log", async (t) => { const { manager, store, root, scope, getRecord } = await fixture(t); const { stale } = seedStaleRecord(store, root, scope.workspaceId); + const streamed = unwrap(await manager.start({ target: "codex", prompt: "stream", workspaceId: scope.workspaceId, workspaceRoot: root, })); + assert.match(streamed.latestTurnId ?? "", /^trn_[0-9a-f]{8}$/); await manager.wait([streamed.id], scope); const streamedEvents = unwrap(manager.events(streamed.id, scope)); @@ -790,12 +901,14 @@ test("each turn gets an app-owned id and its activity is persisted as a redacted test("a flooded turn log is bounded and counts the dropped events", async (t) => { const { manager, root, scope } = await fixture(t); + const flooded = unwrap(await manager.start({ target: "codex", prompt: "flood", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await manager.wait([flooded.id], scope); const floodedEvents = unwrap(manager.events(flooded.id, scope)).events; assert.equal(floodedEvents.length, 500); @@ -812,12 +925,14 @@ test("a flooded turn log is bounded and counts the dropped events", async (t) => // A failed turn keeps the provider's own explanation, redacted, next to the generic message. test("a failed turn keeps the provider's redacted explanation next to the generic message", async (t) => { const { manager, root, scope, getRecord } = await fixture(t); + const detailed = unwrap(await manager.start({ target: "codex", prompt: "detail-fail", workspaceId: scope.workspaceId, workspaceRoot: root, })); + assert.deepEqual(withoutTurnId(unwrap(await manager.wait([detailed.id], scope))), [{ id: detailed.id, status: "failed", @@ -844,12 +959,14 @@ test("a failed turn keeps the provider's redacted explanation next to the generi test("closing the manager closes runtimes with active turns", async (t) => { const { manager, root, scope, runtimes } = await fixture(t); + const shuttingDown = unwrap(await manager.start({ target: "reviewer", prompt: "hold during shutdown", workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => runtimes.get(shuttingDown.id)?.inputs.length === 1); const closing = manager.close(); await new Promise((resolve) => setImmediate(resolve)); @@ -864,6 +981,7 @@ async function fixture(t: TestContext) { const directRoot = await mkdtemp(join(tmpdir(), "devspace-direct-agent-manager-test-")); const stateDir = join(root, "state"); const scope = { workspaceId: "ws_test", workspaceRoot: root }; + const profile: LocalAgentProfile = { name: "reviewer", description: "Test reviewer", @@ -872,12 +990,14 @@ async function fixture(t: TestContext) { body: "Review only.", disabled: false, }; + const disabledProfile: LocalAgentProfile = { ...profile, name: "disabled-reviewer", filePath: join(root, "disabled-reviewer.md"), disabled: true, }; + const shadowProfile: LocalAgentProfile = { ...profile, name: "codex-shadow", @@ -887,6 +1007,7 @@ async function fixture(t: TestContext) { }; const runtimes = new Map(); + const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", @@ -895,9 +1016,11 @@ async function fixture(t: TestContext) { createRuntime: async (context: LocalAgentRuntimeContext) => { const runtime = new FakeRuntime(); runtimes.set(context.agentId, runtime); + return Result.ok(runtime); }, }; + const workDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-work" }; const shadowDriver: LocalAgentDriver = { ...driver, providerInstanceId: "codex-shadow" }; @@ -917,6 +1040,7 @@ async function fixture(t: TestContext) { subagents, stopTimeoutMs: 200, }); + t.after(async () => { await manager.close(); await rm(root, { recursive: true, force: true }); @@ -933,9 +1057,11 @@ async function fixture(t: TestContext) { workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => runtimes.get(agent.id)?.inputs.length === 1); runtimes.get(agent.id)!.release(); await waitFor(() => getRecord(agent.id).status === "idle"); + return agent; }; @@ -946,7 +1072,9 @@ async function fixture(t: TestContext) { workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => getRecord(earlyFailure.id).status === "error"); + return earlyFailure; }; @@ -976,8 +1104,10 @@ function seedStaleRecord(store: LocalAgentStore, root: string, workspaceId: stri providerInstanceId: "codex", driver: "codex", }); + const staleTurn = store.beginTurn(stale.id, { turnId: "trn_stale", prompt: "interrupted turn" }); store.update(stale.id, { latestResponse: "previous response" }); + return { stale, staleTurn }; } @@ -988,13 +1118,16 @@ function withoutTurnId(results: T[]): Omit(result: BetterResult): T { if (result.isErr()) throw result.error; + return result.value; } async function waitFor(check: () => boolean): Promise { const deadline = Date.now() + 2_000; + while (!check() && Date.now() < deadline) { await new Promise((resolve) => setImmediate(resolve)); } + assert.equal(check(), true, "condition did not become true before timeout"); } diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 5dcf9f45b..d00abf623 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -103,11 +103,17 @@ export type AgentStartError = | AgentConflictError | AgentStoreError | AgentProviderUnavailableError; + export type AgentContinueError = AgentStartError; + export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; + export type AgentListError = AgentScopeError | AgentStoreError; + export type AgentWaitError = AgentLookupError; + export type AgentStopError = AgentLookupError | AgentConflictError; + export type AgentEventsError = AgentLookupError; export interface LocalAgentFailure { @@ -167,6 +173,7 @@ export class LocalAgentManager { constructor(options: LocalAgentManagerOptions) { this.store = options.store; + for (const driver of options.drivers) this.drivers.set(driver.providerInstanceId, driver); this.pool = options.pool; this.loadProfiles = options.loadProfiles; @@ -186,14 +193,18 @@ export class LocalAgentManager { async start(input: StartLocalAgentInput): Promise> { const manager = this; + return Result.gen(async function* () { yield* manager.acceptingResult("start"); + const workspaceRoot = yield* manager.authorizeWorkspace( input.workspaceRoot, input.workspaceId, "start", ); + const profiles = yield* Result.await(manager.loadProfilesResult(workspaceRoot, input.target)); + const target = resolveLocalAgentTarget( input.target, profiles, @@ -201,6 +212,7 @@ export class LocalAgentManager { input.effort, manager.subagents.providers, ); + if (!target) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", @@ -211,6 +223,7 @@ export class LocalAgentManager { }`, })); } + if (target.kind === "profile" && target.profile.disabled) { return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", @@ -220,9 +233,11 @@ export class LocalAgentManager { message: `Subagent profile is disabled: ${target.name}.`, })); } + yield* manager.providerEnabledResult(target.provider, target.name, "start"); const driver = yield* manager.driverResult(target.provider, "start"); yield* manager.providerAvailableResult(target.provider, driver, "start"); + const createdResult = manager.store.createResult({ id: input.agentId, workspaceId: input.workspaceId, @@ -235,29 +250,38 @@ export class LocalAgentManager { writeMode: input.writeMode ?? "allowed", dispatchSignature: dispatchSignature(input, workspaceRoot, target.model, target.effort), }); + if ( createdResult.isErr() && createdResult.error.cause instanceof LocalAgentDispatchConflictError ) { return Result.err(dispatchConflict(input.agentId ?? "unknown")); } + const created = yield* createdResult; + // An idempotent retry of a workflow dispatch returns the existing agent. if (!created.created) return Result.ok(created.record); let record = created.record; + if (input.worktree) { // Created only for a new record, so a retried request id never makes a second worktree. const worktree = await manager.createWorktree(record, workspaceRoot, input.worktree.branch); + if (worktree.isErr()) { manager.store.deleteResult(record.id); + return Result.err(worktree.error); } + const updated = yield* manager.store.updateResult(record.id, { worktreePath: worktree.value.path, branch: worktree.value.branch, }); + record = updated; } + return manager.begin(record, input.prompt, { model: target.model, effort: target.effort, @@ -280,6 +304,7 @@ export class LocalAgentManager { message: "Worktrees are not configured: no worktree root.", })); } + return createAgentWorktree({ agentId: record.id, workspaceRoot, worktreeRoot: this.worktreeRoot, branch }); } @@ -290,12 +315,15 @@ export class LocalAgentManager { scope: LocalAgentWorkspaceScope, ): Promise> { const manager = this; + return Result.gen(async function* () { yield* manager.acceptingResult("continue", agentId); const record = yield* manager.store.getByIdResult(agentId); + if (!record) return Result.err(agentNotFound(agentId)); yield* manager.agentWorkspaceResult(record, scope, "continue"); const missingWorktree = missingWorktreeError(record, "continue"); + if (missingWorktree) return Result.err(missingWorktree); yield* manager.writeModeResult(record, overrides.writeMode); const profiles = yield* Result.await(manager.loadProfilesResult(record.workspaceRoot, record.profileName)); @@ -303,6 +331,7 @@ export class LocalAgentManager { yield* manager.providerEnabledResult(record.providerInstanceId, record.profileName, "continue"); const driver = yield* manager.driverResult(record.providerInstanceId, "continue", agentId, record.driver); yield* manager.providerAvailableResult(record.providerInstanceId, driver, "continue", agentId); + return manager.begin(record, prompt, overrides, scope.workspaceId); }); } @@ -312,11 +341,15 @@ export class LocalAgentManager { scope: LocalAgentWorkspaceScope, ): BetterResult { const lookup = this.store.getByIdResult(agentId); + if (lookup.isErr()) return lookup; const record = lookup.value; + if (!record) return Result.err(agentNotFound(agentId)); const scoped = this.agentWorkspaceResult(record, scope, "get"); + if (scoped.isErr()) return scoped; + return Result.ok(record); } @@ -334,7 +367,9 @@ export class LocalAgentManager { scope: LocalAgentWorkspaceScope, ): BetterResult { const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + return this.store.getLatestTurnResult(agentId); } @@ -345,18 +380,23 @@ export class LocalAgentManager { signal?: AbortSignal, ): Promise> { const captures: Array<{ agent: LocalAgentRecord; turn?: LocalAgentTurnRecord }> = []; + for (const agentId of unique(agentIds)) { const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; const turn = this.store.getLatestTurnResult(agentId); + if (turn.isErr()) return turn; captures.push({ agent: agent.value, turn: turn.value }); } const pending: Promise[] = []; + for (const capture of captures) { if (capture.turn?.status !== "running") continue; const active = this.activeTurns.get(capture.agent.id); + if (active?.turnRowId !== capture.turn.id) { return Result.err(new AgentStoreError( "wait", @@ -364,22 +404,28 @@ export class LocalAgentManager { `Running turn state is unavailable for subagent ${capture.agent.id}.`, )); } + pending.push(active.completion); } const timedOut = pending.length > 0 ? await waitForTurns(pending, timeoutMs, signal) : false; + const results: LocalAgentWaitResult[] = []; + for (const capture of captures) { const worktree = capture.agent.worktreePath ? { worktree: capture.agent.worktreePath, branch: capture.agent.branch } : {}; + if (!capture.turn) { results.push({ ...waitResultFromAgent(capture.agent, timedOut), ...worktree }); continue; } + const turn = this.store.getTurnByIdResult(capture.turn.id); + if (turn.isErr()) return turn; results.push({ ...(turn.value @@ -388,6 +434,7 @@ export class LocalAgentManager { ...worktree, }); } + return Result.ok(results); } @@ -397,12 +444,16 @@ export class LocalAgentManager { query: LocalAgentTurnEventsQuery = {}, ): BetterResult { const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + const lookup = query.turnId ? this.store.getTurnByTurnIdResult(query.turnId) : this.store.getLatestTurnResult(agentId); + if (lookup.isErr()) return lookup; const turn = lookup.value; + if (!turn || turn.agentId !== agentId) { return Result.err(new AgentTargetError({ code: "AGENT_NOT_FOUND", @@ -413,6 +464,7 @@ export class LocalAgentManager { : `Subagent ${agentId} has no turns.`, })); } + return this.store.listTurnEventsResult(turn, query.afterSeq).map((events) => ({ agentId, turnId: turn.turnId, @@ -426,9 +478,12 @@ export class LocalAgentManager { scope: LocalAgentWorkspaceScope, ): Promise> { const record = this.get(agentId, scope); + if (record.isErr()) return record; + if (record.value.status === "stopped") return Result.ok(record.value); const active = this.activeTurns.get(agentId); + if (!active) { return Result.err(new AgentConflictError({ code: "AGENT_CONFLICT", @@ -438,7 +493,9 @@ export class LocalAgentManager { message: `Agent ${agentId} does not have a running turn.`, })); } + const driver = this.drivers.get(record.value.providerInstanceId); + if (!driver?.capabilities.turns.interrupt) { return Result.err(new AgentConflictError({ code: "AGENT_CONFLICT", @@ -448,10 +505,13 @@ export class LocalAgentManager { message: `Agent ${agentId} provider does not support stopping an active turn.`, })); } + active.abortController.abort(); const settled = await this.settledWithin(active.completion, this.stopTimeoutMs); const refreshed = this.get(agentId, scope); + if (refreshed.isErr() || refreshed.value.status === "stopped") return refreshed; + return Result.err(new AgentConflictError({ code: "AGENT_CONFLICT", agentId, @@ -466,6 +526,7 @@ export class LocalAgentManager { private async settledWithin(completion: Promise, timeoutMs: number): Promise { let timer: NodeJS.Timeout | undefined; const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }); + try { return await Promise.race([completion.then(() => true as const, () => true as const), timeout]); } finally { @@ -482,13 +543,16 @@ export class LocalAgentManager { // those turns first can strand a provider process indefinitely. await this.pool.close(); const turnResults = await Promise.allSettled(turns); + for (const result of turnResults) { if (result.status === "rejected") { this.log("warn", "local_agent_close_failed", { error: errorMessage(result.reason) }); } } + this.store.close(); })(); + return this.closePromise; } @@ -527,14 +591,18 @@ export class LocalAgentManager { effort: overrides.effort ?? record.effort, writeMode: overrides.writeMode ?? record.writeMode, }); + if (begun.isErr()) return begun; + const events = new TurnEventLog(this.store, begun.value.turn, (fields) => ( this.log("warn", "agent_turn_event_failed", { agentId: record.id, ...fields }) )); + events.record({ type: "turn_started" }); // Defer invocation until after the tracking entry is visible. This keeps // cleanup correct even if runTurn later gains a synchronous completion path. const abortController = new AbortController(); + const turn = Promise.resolve().then(() => ( this.runTurn( begun.value.agent, @@ -546,12 +614,14 @@ export class LocalAgentManager { abortController.signal, ) )); + this.activeTurns.set(record.id, { turnRowId: begun.value.turn.id, completion: turn, abortController, }); void turn.catch(() => undefined); + return Result.ok(begun.value.agent); } @@ -574,36 +644,54 @@ export class LocalAgentManager { turnId: turn.turnId, providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), }); + try { const authorized = this.authorizeWorkspace(record.workspaceRoot, workspaceId, "run"); + if (authorized.isErr()) { persistRunError(authorized.error); + return; } + const workspaceRoot = authorized.value; + const authorizedRecord = workspaceRoot === record.workspaceRoot ? record : { ...record, workspaceRoot }; + const profiles = await this.loadProfilesResult(workspaceRoot, record.profileName); + if (profiles.isErr()) { persistRunError(profiles.error); + return; } + const profile = this.profileForRecordResult(record, profiles.value); + if (profile.isErr()) { persistRunError(profile.error); + return; } + const input = this.buildRunInputResult(authorizedRecord, profile.value, prompt, overrides); + if (input.isErr()) { persistRunError(input.error); + return; } + const driver = this.driverResult(record.providerInstanceId, "run", record.id, record.driver); + if (driver.isErr()) { persistRunError(driver.error); + return; } + const context: LocalAgentRuntimeContext = { agentId: record.id, providerInstanceId: record.providerInstanceId, @@ -615,36 +703,50 @@ export class LocalAgentManager { effort: input.value.effort, agentDir: this.agentDir, }; + const callbacks: LocalAgentRunCallbacks = { onSessionId: (providerSessionId) => { const current = this.store.getByIdResult(record.id); + if (current.isErr()) throw current.error; + if (!current.value || current.value.providerSessionId === providerSessionId) return; const updated = this.store.updateResult(record.id, { providerSessionId }); + if (updated.isErr()) throw updated.error; }, onEvent: (event) => events.recordActivity(event), }; + // A stop that lands while the turn is still queued must not launch a provider runtime. if (signal?.aborted) { persistRunError(localAgentCancelledError(driver.value.provider, "run")); + return; } + const result = await this.pool.run(driver.value, context, { ...input.value, signal }, callbacks); + if (result.isErr()) { persistRunError(result.error); + return; } + const runResult = result.value; const current = this.store.getByIdResult(record.id); + if (current.isErr()) throw current.error; + if (!current.value) return; events.record({ type: "turn_completed", status: "completed" }); + const updated = this.store.finishTurnResult(record.id, turnId, { providerSessionId: runResult.providerSessionId ?? current.value.providerSessionId, status: "completed", response: runResult.finalResponse, }); + if (updated.isErr()) throw updated.error; this.log("info", "agent_run_completed", { providerInstanceId: updated.value.providerInstanceId, @@ -656,15 +758,19 @@ export class LocalAgentManager { } catch (error) { if (isLocalAgentError(error)) { persistRunError(error); + return; } + events.record({ type: "turn_completed", status: "failed", errorCode: "AGENT_INTERNAL_ERROR" }); + const persisted = this.store.finishTurnResult(record.id, turnId, { status: "failed", error: "Unexpected internal subagent failure.", errorCode: "AGENT_INTERNAL_ERROR", errorRetryable: false, }); + this.log("error", "agent_run_failed", { providerInstanceId: record.providerInstanceId, driver: record.driver, @@ -691,6 +797,7 @@ export class LocalAgentManager { const stopped = AgentProviderCancelledError.is(error); const status = stopped ? "stopped" : "failed"; events.record({ type: "turn_completed", status, errorCode: error.code }); + const persisted = this.store.finishTurnResult(record.id, turnId, { status, error: error.message, @@ -698,6 +805,7 @@ export class LocalAgentManager { errorRetryable: error.retryable, errorDetail: stopped ? undefined : providerErrorDetail(error), }); + this.log(stopped ? "info" : "error", stopped ? "agent_run_stopped" : "agent_run_failed", { providerInstanceId: record.providerInstanceId, driver: record.driver, @@ -718,6 +826,7 @@ export class LocalAgentManager { overrides: RunOverrides, ): BetterResult { const isRawProvider = record.profileName === record.providerInstanceId; + if (!profile && !isRawProvider) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", @@ -727,8 +836,10 @@ export class LocalAgentManager { message: `Subagent profile not found: ${record.profileName}.`, })); } + const body = profile?.body.trim(); const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; + return Result.ok({ prompt: fullPrompt, workspaceRoot: record.worktreePath ?? record.workspaceRoot, @@ -750,7 +861,9 @@ export class LocalAgentManager { const profile = profiles.find( (candidate) => candidate.name === record.profileName && candidate.provider === record.providerInstanceId, ); + if (!profile && record.profileName === record.providerInstanceId) return Result.ok(undefined); + if (!profile) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", @@ -760,6 +873,7 @@ export class LocalAgentManager { message: `Subagent profile not found: ${record.profileName}.`, })); } + if (profile.disabled) { return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", @@ -769,6 +883,7 @@ export class LocalAgentManager { message: `Subagent profile is disabled: ${profile.name}.`, })); } + return Result.ok(profile); } @@ -779,6 +894,7 @@ export class LocalAgentManager { expectedDriver?: LocalAgentDriverKind, ): BetterResult { const driver = this.drivers.get(providerInstanceId); + if (!driver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", @@ -789,6 +905,7 @@ export class LocalAgentManager { message: `No local agent driver is configured for provider instance: ${providerInstanceId}.`, })); } + if (expectedDriver && driver.provider !== expectedDriver) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", @@ -799,6 +916,7 @@ export class LocalAgentManager { message: `Provider instance ${providerInstanceId} now uses ${driver.provider}; this agent was created with ${expectedDriver}.`, })); } + return Result.ok(driver); } @@ -808,6 +926,7 @@ export class LocalAgentManager { operation: string, ): BetterResult { const configured = subagentProviderConfig(this.subagents, providerInstanceId); + if (!configured) { return Result.err(new AgentTargetError({ code: "PROVIDER_NOT_CONFIGURED", @@ -818,7 +937,9 @@ export class LocalAgentManager { message: `Subagent provider instance is not configured: ${providerInstanceId}.`, })); } + if (isSubagentProviderEnabled(this.subagents, providerInstanceId)) return Result.ok(undefined); + return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", target, @@ -836,7 +957,9 @@ export class LocalAgentManager { agentId?: string, ): BetterResult { const availability = this.providerAvailability?.(providerInstanceId); + if (!availability || availability.available) return Result.ok(undefined); + return Result.err(new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", provider: driver.provider, @@ -852,6 +975,7 @@ export class LocalAgentManager { agentId?: string, ): BetterResult { if (this.accepting) return Result.ok(undefined); + return Result.err(new AgentConflictError({ code: "AGENT_CONFLICT", agentId, @@ -867,7 +991,9 @@ export class LocalAgentManager { operation: string, ): BetterResult { const normalized = resolve(workspaceRoot); + if (!workspaceId || !this.allowedRoots) return Result.ok(normalized); + try { return Result.ok(assertAllowedPath(normalized, [...this.allowedRoots])); } catch (cause) { @@ -879,6 +1005,7 @@ export class LocalAgentManager { // Preserve the original containment failure below. } } + return Result.err(new AgentScopeError({ code: "WORKSPACE_NOT_ALLOWED", operation, @@ -896,6 +1023,7 @@ export class LocalAgentManager { if (!requested || writeModeRank(requested) <= writeModeRank(record.writeMode)) { return Result.ok(undefined); } + return Result.err(new AgentConflictError({ code: "AGENT_CONFLICT", agentId: record.id, @@ -911,8 +1039,10 @@ export class LocalAgentManager { operation: string, ): BetterResult { const workspaceRoot = this.authorizeWorkspace(scope.workspaceRoot, scope.workspaceId, operation); + if (workspaceRoot.isErr()) return workspaceRoot; const idMismatch = scope.workspaceId !== undefined && record.workspaceId !== scope.workspaceId; + if (workspaceRoot.value !== record.workspaceRoot || idMismatch) { return Result.err(new AgentScopeError({ code: "WORKSPACE_MISMATCH", @@ -923,6 +1053,7 @@ export class LocalAgentManager { message: `Subagent ${record.id} belongs to a different workspace.`, })); } + return Result.ok(undefined); } @@ -934,6 +1065,7 @@ export class LocalAgentManager { return Result.ok(await this.loadProfiles(workspaceRoot)); } catch (cause) { if (isProgrammerDefect(cause)) throw cause; + return Result.err(new AgentTargetError({ code: "TARGET_RESOLUTION_FAILED", target, @@ -976,6 +1108,7 @@ class TurnEventLog { recordActivity(event: Parameters>[0]): void { try { const sanitized = sanitizeActivityEvent(event); + if (sanitized) this.record(sanitized); } catch (error) { // Visibility must never fail the turn it observes. @@ -989,13 +1122,17 @@ class TurnEventLog { this.completed = terminal; const body = terminal && this.dropped > 0 ? { ...event, droppedEvents: this.dropped } : event; const size = JSON.stringify(body).length; + if (!terminal && (this.seq >= MAX_TURN_EVENTS - 1 || this.bytes + size > MAX_TURN_EVENT_BYTES)) { this.dropped += 1; + return; } + this.seq += 1; this.bytes += size; const appended = this.store.appendTurnEventResult(this.turn, this.seq, body, new Date().toISOString()); + if (appended.isErr()) this.warn({ turnId: this.turn.turnId, error: appended.error.message }); } } @@ -1006,10 +1143,13 @@ function errorMessage(error: unknown): string { function safeCauseType(cause: unknown): string | undefined { if (cause instanceof Error) return cause.name; + if (cause && typeof cause === "object" && "error" in cause) { const nested = (cause as { error?: unknown }).error; + if (nested instanceof Error) return nested.name; } + return cause === undefined ? undefined : typeof cause; } @@ -1047,6 +1187,7 @@ function dispatchSignature( effort: string | undefined, ): string | undefined { if (!input.agentId) return undefined; + return createHash("sha256").update(JSON.stringify([ input.workspaceId ?? null, workspaceRoot, @@ -1071,27 +1212,33 @@ async function waitForTurns( ): Promise { let timer: NodeJS.Timeout | undefined; let onAbort: (() => void) | undefined; + const timeout = timeoutMs === undefined ? undefined : new Promise<"timeout">((resolveTimeout) => { timer = setTimeout(() => resolveTimeout("timeout"), timeoutMs); }); + const aborted = signal ? new Promise<"aborted">((resolveAbort) => { onAbort = () => resolveAbort("aborted"); + if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }) : undefined; + try { const result = await Promise.race([ Promise.allSettled(turns).then(() => "completed" as const), ...(timeout ? [timeout] : []), ...(aborted ? [aborted] : []), ]); + return result === "timeout"; } finally { if (timer) clearTimeout(timer); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); } } @@ -1123,6 +1270,7 @@ function waitBodyFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): LocalA function waitResultFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAgentWaitResult { const body = waitBodyFromAgent(agent, timedOut); + return agent.latestTurnId ? { ...body, turnId: agent.latestTurnId } : body; } diff --git a/src/local-agent-opencode-server.ts b/src/local-agent-opencode-server.ts index 6a246d349..08fe225c9 100644 --- a/src/local-agent-opencode-server.ts +++ b/src/local-agent-opencode-server.ts @@ -20,17 +20,20 @@ export interface StartedOpencodeServer extends OpencodeServerLike { export async function startOpencodeServer(env: NodeJS.ProcessEnv): Promise { for (let attempt = 1; attempt <= OPENCODE_SERVER_START_ATTEMPTS; attempt += 1) { const port = await allocateOpencodePort(); + try { return await launchOpencodeServer(env, port); } catch (error) { if (attempt === OPENCODE_SERVER_START_ATTEMPTS || !await isOpencodePortInUse(port)) throw error; } } + throw new Error("OpenCode server failed to start."); } async function launchOpencodeServer(env: NodeJS.ProcessEnv, port: number): Promise { const detached = process.platform !== "win32"; + const child = spawn(resolveOpenCodeCommand(env), [ "serve", `--hostname=${OPENCODE_SERVER_HOSTNAME}`, @@ -40,33 +43,43 @@ async function launchOpencodeServer(env: NodeJS.ProcessEnv, port: number): Promi env, stdio: ["ignore", "pipe", "pipe"], }); + let closed = false; + const close = () => { if (closed) return; closed = true; terminateProcessTree(child, "SIGTERM", detached); }; + const url = await new Promise((resolve, reject) => { let output = ""; let ready = false; + const timer = setTimeout(() => { if (ready) return; close(); reject(new Error(`Timeout waiting for OpenCode server after ${OPENCODE_SERVER_START_TIMEOUT_MS}ms`)); }, OPENCODE_SERVER_START_TIMEOUT_MS); + timer.unref(); + const inspect = (chunk: Buffer | string) => { if (ready) return; output += chunk.toString(); + for (const line of output.split("\n")) { const match = line.match(/(?:opencode )?server listening on\s+(https?:\/\/[^\s]+)/); + if (!match?.[1]) continue; ready = true; clearTimeout(timer); resolve(match[1]); + return; } }; + child.stdout?.on("data", inspect); child.stderr?.on("data", inspect); child.once("error", (error) => { @@ -82,21 +95,26 @@ async function launchOpencodeServer(env: NodeJS.ProcessEnv, port: number): Promi reject(new Error(`OpenCode server exited with code ${code}${output.trim() ? `\n${output.trim()}` : ""}`)); }); }); + return { url, close }; } async function allocateOpencodePort(): Promise { const server = createNetServer(); server.unref(); + return new Promise((resolve, reject) => { server.once("error", reject); server.listen({ host: OPENCODE_SERVER_HOSTNAME, port: 0, exclusive: true }, () => { const address = server.address(); + if (!address || typeof address === "string") { server.close(); reject(new Error("Failed to allocate an OpenCode server port.")); + return; } + server.close((error) => error ? reject(error) : resolve(address.port)); }); }); @@ -105,6 +123,7 @@ async function allocateOpencodePort(): Promise { async function isOpencodePortInUse(port: number): Promise { const server = createNetServer(); server.unref(); + return new Promise((resolve, reject) => { server.once("error", (error: NodeJS.ErrnoException) => { if (error.code === "EADDRINUSE") resolve(true); diff --git a/src/local-agent-opencode-v2.test.ts b/src/local-agent-opencode-v2.test.ts index c6182eea0..b913e6a97 100644 --- a/src/local-agent-opencode-v2.test.ts +++ b/src/local-agent-opencode-v2.test.ts @@ -49,6 +49,7 @@ function fakeV2Client() { session: { async create(input: unknown) { createInputs.push(input); + return { id: "ses_v2", projectID: "project", @@ -73,6 +74,7 @@ function fakeV2Client() { async switchModel(input: unknown) { switchModelInputs.push(input); }, async prompt(input: unknown) { promptInputs.push(input); + return { id: "inbox", sessionID: "ses_v2", time: { created: 1 }, type: "user", payload: { text: "x" }, delivery: "queue" }; }, async wait(input: unknown) { waitInputs.push(input); }, @@ -80,6 +82,7 @@ function fakeV2Client() { message: { async list(input: unknown) { messageInputs.push(input); + return { data: [assistant], cursor: { previous: null, next: null } }; }, }, @@ -121,11 +124,14 @@ test("a new session is created with its permissions and model and returns the fi const runtime = new OpencodeV2Runtime(client, { close: () => undefined }); let sessionId: string | undefined; const firstEvents: unknown[] = []; + const first = await runtime.run(firstTurn, { onSessionId: (id) => { sessionId = id; }, onEvent: (event) => { firstEvents.push(event); }, }); + assert.equal(first.isOk(), true); + if (first.isErr()) throw first.error; assert.equal(sessionId, "opencode:v2:ses_v2"); assert.equal(first.value.providerSessionId, "opencode:v2:ses_v2"); @@ -148,6 +154,7 @@ test("a continued session updates its permissions and switches its model variant const { client, updateInputs, switchModelInputs } = fakeV2Client(); const runtime = new OpencodeV2Runtime(client, { close: () => undefined }); const first = await runtime.run(firstTurn); + if (first.isErr()) throw first.error; const continued = await runtime.run({ @@ -157,7 +164,9 @@ test("a continued session updates its permissions and switches its model variant writeMode: "allowed", effort: "low", }); + assert.equal(continued.isOk(), true); + if (continued.isErr()) throw continued.error; assert.deepEqual(updateInputs[0], { sessionID: "ses_v2", @@ -173,16 +182,20 @@ test("a continued session updates its permissions and switches its model variant test("a v1 session id is rejected as a different protocol generation", async () => { const { client } = fakeV2Client(); const runtime = new OpencodeV2Runtime(client, { close: () => undefined }); + const wrongGeneration = await runtime.run({ prompt: "legacy continuation", workspaceRoot: "/tmp/project", providerSessionId: "ses_v1_legacy", }); + assert.equal(wrongGeneration.isErr(), true); + if (wrongGeneration.isErr()) { assert.equal(wrongGeneration.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.match(wrongGeneration.error.message, /different OpenCode protocol generation/); } + await runtime.close(); }); @@ -226,10 +239,13 @@ test("the v2 server environment replaces ambient passwords with the generated on test("closing a v2 runtime twice closes its server once", async () => { const { client } = fakeV2Client(); let closeCalls = 0; + const runtime = new OpencodeV2Runtime(client, { close: () => { closeCalls += 1; }, }); + const first = await runtime.run(firstTurn); + if (first.isErr()) throw first.error; await runtime.close(); await runtime.close(); @@ -240,6 +256,7 @@ test("an OpenCode 2 executable routes the driver to the v2 factory", async (t) = const { client } = fakeV2Client(); let v1FactoryCalls = 0; let v2FactoryCalls = 0; + const routedDriver = new OpencodeLocalAgentDriver({ factory: async () => { v1FactoryCalls += 1; @@ -247,12 +264,15 @@ test("an OpenCode 2 executable routes the driver to the v2 factory", async (t) = }, v2Factory: async () => { v2FactoryCalls += 1; + return { client, server: { close: () => undefined } }; }, probeRuntime: async () => ({ generation: "v2", version: "2.0.20" }), }); + const routedPool = new LocalAgentRuntimePool(); t.after(() => routedPool.close()); + const routed = await routedPool.run(routedDriver, { agentId: "agt_v2", providerInstanceId: "opencode", @@ -262,6 +282,7 @@ test("an OpenCode 2 executable routes the driver to the v2 factory", async (t) = prompt: "route through v2", workspaceRoot: "/tmp/project", }); + assert.equal(routed.isOk(), true); assert.equal(v1FactoryCalls, 0); assert.equal(v2FactoryCalls, 1); @@ -271,6 +292,7 @@ test("an OpenCode 2 executable routes the driver to the v2 factory", async (t) = test("a runtime created after an upgrade follows the installed executable", async () => { let installedGeneration: "v1" | "v2" = "v1"; const calls = { v1: 0, v2: 0 }; + const upgradingDriver = new OpencodeLocalAgentDriver({ factory: async () => { calls.v1 += 1; @@ -282,12 +304,14 @@ test("a runtime created after an upgrade follows the installed executable", asyn }, probeRuntime: async () => ({ generation: installedGeneration, version: "x.y.z" }), }); + const upgradeContext = { agentId: "agt_upgrade", providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", } as const; + await upgradingDriver.createRuntime(upgradeContext); installedGeneration = "v2"; await upgradingDriver.createRuntime(upgradeContext); @@ -297,6 +321,7 @@ test("a runtime created after an upgrade follows the installed executable", asyn // A turn that fails before any assistant output surfaces the provider's error, not a protocol error. test("a turn that fails before assistant output surfaces the provider error", async () => { const { client } = fakeV2Client(); + const failedClient = { ...client, message: { @@ -305,19 +330,23 @@ test("a turn that fails before assistant output surfaces the provider error", as }, }, } as unknown as OpencodeV2ClientLike; + const failedRuntime = new OpencodeV2Runtime(failedClient, { close: () => undefined }); const failedRun = await failedRuntime.run({ prompt: "auth fails", workspaceRoot: "/tmp/project" }); assert.equal(failedRun.isErr(), true); + if (failedRun.isErr()) { assert.equal(failedRun.error.code, "PROVIDER_EXECUTION_ERROR"); assert.match(failedRun.error.message, /invalid_grant/); } + await failedRuntime.close(); }); // A continued turn that fails before output must not return the previous turn's response. test("a continued turn that fails before output does not return the previous response", async () => { const { client } = fakeV2Client(); + const staleClient = { ...client, session: { @@ -332,13 +361,17 @@ test("a continued turn that fails before output does not return the previous res }, }, } as unknown as OpencodeV2ClientLike; + const staleRuntime = new OpencodeV2Runtime(staleClient, { close: () => undefined }); + const staleRun = await staleRuntime.run({ prompt: "continued but failing", workspaceRoot: "/tmp/project", providerSessionId: "opencode:v2:ses_v2", }); + assert.equal(staleRun.isErr(), true); + if (staleRun.isErr()) assert.match(staleRun.error.message, /invalid_grant/); await staleRuntime.close(); }); @@ -346,6 +379,7 @@ test("a continued turn that fails before output does not return the previous res // Stop ends the turn only once OpenCode confirms the interrupt; a failed interrupt leaves it running. const interruptRun = async (interrupt: () => Promise) => { const { client } = fakeV2Client(); + const interruptClient = { ...client, session: { @@ -358,12 +392,14 @@ const interruptRun = async (interrupt: () => Promise) => { }, }, } as unknown as OpencodeV2ClientLike; + const interruptRuntime = new OpencodeV2Runtime(interruptClient, { close: () => undefined }, 100); const stop = new AbortController(); const run = interruptRuntime.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal }); setTimeout(() => stop.abort(), 10); const result = await run; await interruptRuntime.close(); + return result.isErr() ? result.error.code : "ok"; }; @@ -378,6 +414,7 @@ test("a failed interrupt is a protocol error rather than a cancellation", async // A dead server surfaces as a Transport ClientError; the runtime must report itself unavailable. test("a dead server makes the runtime report itself unavailable", async () => { const { client } = fakeV2Client(); + const deadRuntime = new OpencodeV2Runtime({ ...client, server: { @@ -386,6 +423,7 @@ test("a dead server makes the runtime report itself unavailable", async () => { }, }, } as unknown as OpencodeV2ClientLike, { close: () => undefined }); + const deadRun = await deadRuntime.run({ prompt: "server gone", workspaceRoot: "/tmp/project" }); assert.equal(deadRun.isErr() ? deadRun.error.code : "ok", "PROVIDER_UNAVAILABLE"); assert.equal(deadRuntime.isAlive(), false); diff --git a/src/local-agent-opencode-v2.ts b/src/local-agent-opencode-v2.ts index 9977757e4..577308ec2 100644 --- a/src/local-agent-opencode-v2.ts +++ b/src/local-agent-opencode-v2.ts @@ -65,25 +65,31 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { message: "OpenCode runtime is not running.", }); } + try { await this.client.server.info(); const continuing = input.providerSessionId !== undefined; + const nativeSessionId = input.providerSessionId ? requireOpenCodeV2NativeSessionId(input.providerSessionId) : await this.createSession(input); + const providerSessionId = encodeOpenCodeV2SessionId(nativeSessionId); await callbacks?.onSessionId?.(providerSessionId); + if (continuing) await this.configureSession(nativeSessionId, input); const { promptedAt, messages } = await this.prompt(nativeSessionId, input); // On a continued session the latest assistant message may predate this // prompt when the new turn failed before producing output. const fresh = messages.data.filter((message) => message.time.created >= promptedAt); + if (fresh.length === 0) await this.assertTurnNotFailed(nativeSessionId); const assistant = requireLatestAssistant(fresh); assertOpenCodeV2AssistantSucceeded(assistant); const finalResponse = requireOpenCodeV2FinalResponse(assistant); // OpenCode does not stream activity to DevSpace; report the reply once the turn ends. callbacks?.onEvent?.({ type: "message", text: finalResponse }); + return { provider: this.provider, providerSessionId, @@ -102,6 +108,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { message: "OpenCode provider is unavailable.", }); } + throw error; } }, @@ -120,6 +127,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { if (this.closed) return; this.closed = true; this.alive = false; + for (const controller of this.promptControllers) controller.abort(); this.promptControllers.clear(); this.server.close(); @@ -131,12 +139,14 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { */ private async assertTurnNotFailed(sessionId: string): Promise { const recent = await this.client.message.list({ sessionID: sessionId, limit: 5, order: "desc" }); + const idle = recent.data.find((message) => ( message !== null && typeof message === "object" && "type" in message && message.type === "idle" )) as { outcome?: string; error?: { type?: string; message?: string } } | undefined; + if (idle?.outcome !== "failed") return; throw new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", @@ -150,11 +160,13 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { private async createSession(input: LocalAgentRunInput): Promise { const model = await resolveOpenCodeV2Model(this.client, input); + const session = await this.client.session.create({ location: { directory: input.workspaceRoot }, permissions: opencodeV2Permissions(input.writeMode), ...(model ? { model } : {}), }); + if (!session.id) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -164,6 +176,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { message: "OpenCode did not return a session id.", }); } + return session.id; } @@ -172,9 +185,11 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { sessionID: sessionId, permissions: opencodeV2Permissions(input.writeMode), }); + if (!input.model && !input.effort) return; const current = await this.client.session.get({ sessionID: sessionId }); const model = await resolveOpenCodeV2Model(this.client, input, current.model); + if (!model) return; await this.client.session.switchModel({ sessionID: sessionId, model }); } @@ -183,6 +198,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + // End the local request only once OpenCode confirms the interrupt (`interrupted: false` // means the session was already idle). A failed or stalled interrupt keeps the turn // running, and the manager reports the stop as a conflict. @@ -190,26 +206,32 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { await this.client.session.interrupt({ sessionID: sessionId }); controller.abort(); }); + const timer = setTimeout(() => { timedOut = true; controller.abort(); }, this.promptTimeoutMs); + try { const prompted = await this.client.session.prompt({ sessionID: sessionId, text: input.prompt, }, { signal: controller.signal }); + await this.client.session.wait({ sessionID: sessionId }, { signal: controller.signal }); + const messages = await this.client.message.list({ sessionID: sessionId, limit: 1, order: "desc", type: "assistant", }, { signal: controller.signal }); + return { promptedAt: prompted.time.created, messages }; } catch (error) { // A provider timeout after a stop request is still a timeout: the interrupt was never confirmed. if (input.signal?.aborted && !timedOut) throw localAgentCancelledError("opencode", "prompt", error); + if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -234,6 +256,7 @@ export async function defaultOpencodeV2Factory( const password = randomBytes(32).toString("base64url"); const server = await startOpencodeServer(opencodeV2ServerEnvironment(env, password)); const authorization = `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; + return { client: OpenCode.make({ baseUrl: server.url, @@ -252,6 +275,7 @@ export function opencodeV2ServerEnvironment( OPENCODE_SERVER_PASSWORD: _legacyPassword, ...rest } = env; + return { ...rest, OPENCODE_PASSWORD: password }; } @@ -263,15 +287,18 @@ export function opencodeV2Permissions( { action: "subagent", resource: "*", effect: "deny" }, { action: "question", resource: "*", effect: "deny" }, ]; + if (writeMode !== "full_access") { rules.push({ action: "external_directory", resource: "*", effect: "deny" }); } + if (writeMode === "read_only") { rules.push( { action: "edit", resource: "*", effect: "deny" }, { action: "shell", resource: "*", effect: "deny" }, ); } + return rules; } @@ -281,11 +308,15 @@ async function resolveOpenCodeV2Model( current?: ModelRef, ): Promise { if (input.model) return parseOpenCodeV2Model(input.model, input.effort); + if (!input.effort) return undefined; + if (current) return { ...current, variant: input.effort }; + const fallback = await client.model.default({ location: { directory: input.workspaceRoot }, }); + if (!fallback.data) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -295,6 +326,7 @@ async function resolveOpenCodeV2Model( message: "OpenCode has no default model to apply the requested effort variant to.", }); } + return { id: fallback.data.id, providerID: fallback.data.providerID, @@ -304,6 +336,7 @@ async function resolveOpenCodeV2Model( export function parseOpenCodeV2Model(model: string, effort?: string): ModelRef { const separator = model.indexOf("/"); + return { providerID: separator === -1 ? "opencode" : model.slice(0, separator), id: separator === -1 ? model : model.slice(separator + 1), @@ -321,6 +354,7 @@ export function extractOpenCodeV2FinalResponse(message: SessionMessageAssistant) function requireOpenCodeV2FinalResponse(message: SessionMessageAssistant): string { const response = extractOpenCodeV2FinalResponse(message); + if (!response) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -330,6 +364,7 @@ function requireOpenCodeV2FinalResponse(message: SessionMessageAssistant): strin message: "OpenCode did not return a final assistant response.", }); } + return response; } @@ -340,6 +375,7 @@ function requireLatestAssistant(messages: unknown[]): SessionMessageAssistant { && "type" in message && message.type === "assistant" )); + if (!assistant) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -349,6 +385,7 @@ function requireLatestAssistant(messages: unknown[]): SessionMessageAssistant { message: "OpenCode did not return an assistant message after the prompt completed.", }); } + return assistant as SessionMessageAssistant; } diff --git a/src/local-agent-opencode-version.ts b/src/local-agent-opencode-version.ts index 48fa93e65..6d4bfde62 100644 --- a/src/local-agent-opencode-version.ts +++ b/src/local-agent-opencode-version.ts @@ -26,9 +26,11 @@ export function resolveOpenCodeCommand(env: NodeJS.ProcessEnv): string { export function classifyOpenCodeCliVersion(output: string): ProbedOpenCodeRuntime | undefined { const match = output.trim().match(/(?:^|\s)v?((\d+)\.\d+\.\d+(?:[-+][^\s]+)?)(?:$|\s)/); + if (!match?.[1] || !match[2]) return undefined; const major = Number(match[2]); const version = match[1]; + return { generation: major >= 2 ? "v2" : "v1", version, @@ -42,8 +44,10 @@ export function encodeOpenCodeV2SessionId(nativeSessionId: string): string { export function requireOpenCodeV2NativeSessionId(providerSessionId: string): string { if (providerSessionId.startsWith(OPENCODE_V2_SESSION_PREFIX)) { const nativeSessionId = providerSessionId.slice(OPENCODE_V2_SESSION_PREFIX.length); + if (nativeSessionId) return nativeSessionId; } + throw incompatibleOpenCodeSession("v2", providerSessionId); } @@ -70,6 +74,7 @@ export async function probeOpenCodeBinary( ): Promise { const output = await runOpenCodeVersion(env); const result = classifyOpenCodeCliVersion(output); + if (result) return result; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -86,8 +91,10 @@ function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { env, stdio: ["ignore", "pipe", "pipe"], }); + let output = ""; let settled = false; + const timer = setTimeout(() => { if (settled) return; settled = true; @@ -100,6 +107,7 @@ function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { message: `OpenCode version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT_MS}ms.`, })); }, OPENCODE_VERSION_PROBE_TIMEOUT_MS); + timer.unref(); child.stdout?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); child.stderr?.on("data", (chunk: Buffer | string) => { output += chunk.toString(); }); @@ -123,6 +131,7 @@ function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { if (settled) return; settled = true; clearTimeout(timer); + if (code === 0) resolve(output); else reject(new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index d9bf000d8..da131f281 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -21,10 +21,12 @@ function fakeV1Client() { const createInputs: unknown[] = []; const promptInputs: unknown[] = []; const health = { available: true }; + const client = { global: { async health() { if (!health.available) throw new Error("server unavailable"); + return { data: { healthy: true } }; }, }, @@ -32,11 +34,13 @@ function fakeV1Client() { async create(input: unknown) { createInputs.push(input); sessionNumber += 1; + return { data: { id: `session_${sessionNumber}` } }; }, async prompt(input: unknown) { promptInputs.push(input); const sessionId = (input as { sessionID: string }).sessionID; + return { data: { info: { role: "assistant" }, @@ -46,27 +50,33 @@ function fakeV1Client() { }, }, } as unknown as OpencodeClientLike; + return { client, createInputs, promptInputs, health }; } function sharedServerFixture(t: TestContext) { const fake = fakeV1Client(); const state = { factoryCalls: 0, closeCalls: 0, factoryEnv: undefined as NodeJS.ProcessEnv | undefined }; + const factory: OpencodeFactory = async (_context, env) => { state.factoryCalls += 1; state.factoryEnv = env; + return { client: fake.client, server: { close: () => { state.closeCalls += 1; } }, }; }; + const driver = new OpencodeLocalAgentDriver({ factory, env: { HARNESS_ENV: "opencode" }, probeRuntime: probeV1, }); + const pool = new LocalAgentRuntimePool(); t.after(() => pool.close()); + return { ...fake, state, driver, pool }; } @@ -83,6 +93,7 @@ async function runTwoAgents(driver: OpencodeLocalAgentDriver, pool: LocalAgentRu model: "anthropic/sonnet", effort: "high", }); + const second = await pool.run(driver, { agentId: "agt_two", providerInstanceId: "opencode", @@ -92,6 +103,7 @@ async function runTwoAgents(driver: OpencodeLocalAgentDriver, pool: LocalAgentRu prompt: "second", workspaceRoot: "/tmp/project", }); + return { first, second }; } @@ -105,6 +117,7 @@ test("OpenCode agents share one server runtime with a session each", async (t) = assert.equal(second.isOk(), true); if (first.isErr()) throw first.error; + if (second.isErr()) throw second.error; const firstRecord = first.value; const secondRecord = second.value; @@ -127,6 +140,7 @@ test("OpenCode agents share one server runtime with a session each", async (t) = test("a resumed OpenCode session applies an effort override", async (t) => { const { promptInputs, driver, pool } = sharedServerFixture(t); const { first } = await runTwoAgents(driver, pool); + if (first.isErr()) throw first.error; const firstRecord = first.value; @@ -217,6 +231,7 @@ test( "", ].join("\n")); await chmod(command, 0o700); + const envDriver = new OpencodeLocalAgentDriver({ env: { PATH: commandRoot, @@ -231,13 +246,16 @@ test( }, probeRuntime: probeV1, }); + const created = await envDriver.createRuntime({ agentId: "agt_env", providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }); + assert.equal(created.isOk(), true); + if (created.isOk()) await created.value.close(); assert.equal(await readFile(marker, "utf8"), "opencode-child"); assert.equal(await readFile(collisionMarker, "utf8"), "collision", "OpenCode retries a claimed allocated port"); @@ -262,17 +280,22 @@ test("a prompt that outlives the provider timeout is a retryable protocol error" }, }, } as unknown as OpencodeClientLike; + const timeoutRuntime = new OpencodeRuntime(timeoutClient, { close: () => undefined }, 5); + const timedOutPrompt = await timeoutRuntime.run({ prompt: "never finishes", workspaceRoot: "/tmp/project", }); + assert.equal(timedOutPrompt.isErr(), true); + if (timedOutPrompt.isErr()) { assert.equal(timedOutPrompt.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.equal(timedOutPrompt.error.retryable, true); assert.match(timedOutPrompt.error.message, /provider timeout/); } + await timeoutRuntime.close(); }); @@ -291,6 +314,7 @@ const abortClient = (abort: () => Promise) => ({ }, }, }) as unknown as OpencodeClientLike; + const stopRun = async (abort: () => Promise) => { const stopRuntime = new OpencodeRuntime(abortClient(abort), { close: () => undefined }, 100); const stop = new AbortController(); @@ -298,6 +322,7 @@ const stopRun = async (abort: () => Promise) => { setTimeout(() => stop.abort(), 10); const result = await run; await stopRuntime.close(); + return result.isErr() ? result.error.code : "ok"; }; @@ -328,6 +353,7 @@ test("write modes map onto DevSpace agents whose permissions deny delegation", ( }); const readOnlyPermissions = opencodePermissionFor("read_only"); assert.equal(typeof readOnlyPermissions === "object" ? readOnlyPermissions.bash : undefined, "deny"); + for (const writeMode of ["read_only", "allowed", "full_access"] as const) { const config = opencodeAgentConfig(writeMode); assert.equal(config.mode, "primary"); @@ -337,6 +363,7 @@ test("write modes map onto DevSpace agents whose permissions deny delegation", ( test("an ordinary provider error keeps the healthy server runtime", async (t) => { let promptFailureCount = 0; + const applicationErrorClient = { global: { async health() { return { data: { healthy: true } }; }, @@ -345,6 +372,7 @@ test("an ordinary provider error keeps the healthy server runtime", async (t) => async create() { return { data: { id: "session_app_error" } }; }, async prompt() { promptFailureCount += 1; + if (promptFailureCount === 1) { return { data: { @@ -359,6 +387,7 @@ test("an ordinary provider error keeps the healthy server runtime", async (t) => }, }; } + return { data: { info: { role: "assistant" }, @@ -368,8 +397,10 @@ test("an ordinary provider error keeps the healthy server runtime", async (t) => }, }, } as unknown as OpencodeClientLike; + const applicationErrorPool = new LocalAgentRuntimePool(); t.after(() => applicationErrorPool.close()); + const applicationErrorDriver = new OpencodeLocalAgentDriver({ factory: async () => ({ client: applicationErrorClient, @@ -377,26 +408,33 @@ test("an ordinary provider error keeps the healthy server runtime", async (t) => }), probeRuntime: probeV1, }); + const applicationFailure = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "bad input", workspaceRoot: "/tmp/project" }); + assert.equal(applicationFailure.isErr(), true); + if (applicationFailure.isErr()) { assert.equal(applicationFailure.error.code, "PROVIDER_EXECUTION_ERROR"); assert.equal(applicationFailure.error.retryable, false); assert.equal(applicationFailure.error.message, "Provider credentials are unavailable."); } + assert.equal(applicationErrorPool.size, 1, "ordinary provider errors must not evict a healthy server runtime"); + const recoveredApplicationTurn = await applicationErrorPool.run(applicationErrorDriver, { agentId: "agt_app_error", providerInstanceId: "opencode", provider: "opencode", workspaceRoot: "/tmp/project", }, { prompt: "valid input", workspaceRoot: "/tmp/project" }); + assert.equal(recoveredApplicationTurn.isOk(), true); + if (recoveredApplicationTurn.isErr()) throw recoveredApplicationTurn.error; assert.equal(recoveredApplicationTurn.value.finalResponse, "ok"); }); @@ -404,14 +442,17 @@ test("an ordinary provider error keeps the healthy server runtime", async (t) => test("a failed health check evicts the dead runtime and the next turn starts a fresh server", async (t) => { const { client, health } = fakeV1Client(); let recoveringFactoryCalls = 0; + const recoveringDriver = new OpencodeLocalAgentDriver({ factory: async () => { recoveringFactoryCalls += 1; health.available = true; + return { client, server: { close: () => undefined } }; }, probeRuntime: probeV1, }); + const recoveringPool = new LocalAgentRuntimePool(); t.after(() => recoveringPool.close()); await recoveringPool.run(recoveringDriver, { @@ -424,6 +465,7 @@ test("a failed health check evicts the dead runtime and the next turn starts a f workspaceRoot: "/tmp/project", }); health.available = false; + const deadRuntime = await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", providerInstanceId: "opencode", @@ -433,11 +475,14 @@ test("a failed health check evicts the dead runtime and the next turn starts a f prompt: "dead runtime", workspaceRoot: "/tmp/project", }); + assert.equal(deadRuntime.isErr(), true); + if (deadRuntime.isErr()) { assert.equal(deadRuntime.error.code, "PROVIDER_UNAVAILABLE"); assert.equal(deadRuntime.error.retryable, true); } + assert.equal(recoveringPool.size, 0, "a failed health check removes the dead runtime immediately"); await recoveringPool.run(recoveringDriver, { agentId: "agt_dead", diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 262bcaa81..5c6fbca23 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -72,17 +72,21 @@ export class OpencodeRuntime implements LocalAgentRuntime { message: "OpenCode runtime is not running.", }); } + try { await assertOpencodeHealthy(this.client); + const sessionId = input.providerSessionId ? requireOpenCodeV1NativeSessionId(input.providerSessionId) : await createOpencodeSession(this.client, input); + await callbacks?.onSessionId?.(sessionId); const promptResult = await this.prompt(sessionId, input); assertOpenCodePromptSucceeded(promptResult); const finalResponse = requireFinalResponse(extractOpenCodeFinalResponse(promptResult)); // OpenCode does not stream activity to DevSpace; report the reply once the turn ends. callbacks?.onEvent?.({ type: "message", text: finalResponse }); + return { provider: this.provider, providerSessionId: sessionId, @@ -101,6 +105,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { message: "OpenCode provider is unavailable.", }); } + throw error; } }, @@ -119,6 +124,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { if (this.closed) return; this.closed = true; this.alive = false; + for (const controller of this.promptControllers) controller.abort(); this.promptControllers.clear(); this.server.close(); @@ -128,6 +134,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { const controller = new AbortController(); this.promptControllers.add(controller); let timedOut = false; + // End the local request only once OpenCode confirms the abort. A failed or // stalled abort keeps the turn running, and the manager reports the stop as a conflict. const removeAbort = bindLocalAgentAbort(input.signal, async () => { @@ -135,17 +142,21 @@ export class OpencodeRuntime implements LocalAgentRuntime { sessionID: sessionId, directory: input.workspaceRoot, }, { throwOnError: false }); + if (!aborted.error) controller.abort(); }); + const timer = setTimeout(() => { timedOut = true; controller.abort(); }, this.promptTimeoutMs); + try { return await promptOpencodeSession(this.client, sessionId, input, controller.signal); } catch (error) { // A provider timeout after a stop request is still a timeout: the abort was never confirmed. if (input.signal?.aborted && !timedOut) throw localAgentCancelledError("opencode", "prompt", error); + if (!timedOut) throw error; throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -199,11 +210,15 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { run: async (): Promise => { // Probe on every runtime creation so an upgraded executable is routed to the matching protocol generation. const runtime = await this.probeRuntime(); + if (runtime.generation === "v2") { const { client, server } = await this.v2Factory(context, this.env); + return new OpencodeV2Runtime(client, server); } + const { client, server } = await this.factory(context, this.env); + return new OpencodeRuntime(client, server); }, }); @@ -215,6 +230,7 @@ async function defaultOpencodeFactory( env: NodeJS.ProcessEnv = process.env, ): Promise<{ client: OpencodeClientLike; server: OpencodeServerLike }> { const { createOpencodeClient } = await import("@opencode-ai/sdk/v2"); + const config = { agent: { devspace_read_only: opencodeAgentConfig("read_only"), @@ -222,10 +238,12 @@ async function defaultOpencodeFactory( devspace_full_access: opencodeAgentConfig("full_access"), }, }; + const server = await startOpencodeServer({ ...env, OPENCODE_CONFIG_CONTENT: JSON.stringify(config), }); + return { client: createOpencodeClient({ baseUrl: server.url }), server, @@ -249,6 +267,7 @@ async function createOpencodeSession( const result = await client.session.create({ directory: input.workspaceRoot, }, { throwOnError: true }); + return requireSessionId(result.data); } @@ -264,6 +283,7 @@ export function opencodeAgentFor(writeMode: LocalAgentRunInput["writeMode"]): st export function opencodePermissionFor(writeMode: LocalAgentRunInput["writeMode"]): PermissionConfig { const allowed = writeMode !== "read_only"; const unrestricted = writeMode === "full_access"; + return { read: "allow", edit: allowed ? "allow" : "deny", @@ -287,6 +307,7 @@ async function assertOpencodeHealthy(client: OpencodeClientLike): Promise function isOpenCodeTransportFailure(error: unknown): boolean { if (error instanceof OpencodeHealthError) return true; const code = transportErrorCode(error); + return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE" @@ -298,8 +319,10 @@ function isOpenCodeTransportFailure(error: unknown): boolean { function transportErrorCode(error: unknown): string | undefined { if (!error || typeof error !== "object") return undefined; const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string") return code; const cause = (error as Error & { cause?: unknown }).cause; + return cause && typeof cause === "object" && typeof (cause as NodeJS.ErrnoException).code === "string" ? (cause as NodeJS.ErrnoException).code : undefined; @@ -319,6 +342,7 @@ async function promptOpencodeSession( signal: AbortSignal, ): Promise { const model = input.model ? parseOpencodeModel(input.model) : undefined; + return client.session.prompt({ sessionID: sessionId, directory: input.workspaceRoot, @@ -331,6 +355,7 @@ async function promptOpencodeSession( function parseOpencodeModel(model: string): OpencodeModelRef { const separator = model.indexOf("/"); + return separator === -1 ? { providerID: "opencode", modelID: model } : { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }; @@ -338,6 +363,7 @@ function parseOpencodeModel(model: string): OpencodeModelRef { function requireSessionId(session: unknown): string { const id = asRecord(session)?.id; + if (typeof id !== "string" || !id) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -347,6 +373,7 @@ function requireSessionId(session: unknown): string { message: "OpenCode did not return a session id.", }); } + return id; } @@ -354,8 +381,10 @@ function assertOpenCodePromptSucceeded(value: unknown): void { const result = asRecord(unwrapProviderPayload(value)); const info = asRecord(result?.info); const error = asRecord(info?.error); + if (!error) return; const data = asRecord(error.data); + const message = typeof data?.message === "string" ? data.message : typeof error.message === "string" @@ -363,6 +392,7 @@ function assertOpenCodePromptSucceeded(value: unknown): void { : typeof error.name === "string" ? `OpenCode returned ${error.name}.` : "OpenCode returned an assistant error."; + throw new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", provider: "opencode", @@ -376,75 +406,100 @@ function assertOpenCodePromptSucceeded(value: unknown): void { export function extractOpenCodeFinalResponse(value: unknown): string { const root = unwrapProviderPayload(value); const messages = Array.isArray(root) ? root : readArray(root, "messages"); + if (messages) return extractLastOpenCodeAssistantMessageText(messages); + return extractOpenCodeAssistantMessageText(root); } function extractLastOpenCodeAssistantMessageText(messages: unknown[]): string { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = asRecord(messages[index]); + if (!message) continue; const info = asRecord(message.info); const role = typeof info?.role === "string" ? info.role : message.role; const type = typeof message.type === "string" ? message.type : undefined; + if (role !== "assistant" && type !== "assistant") continue; const text = extractOpenCodeAssistantMessageText(message); + if (text) return text; } + return ""; } function extractOpenCodeAssistantMessageText(value: unknown): string { const message = asRecord(value); + if (!message) return ""; + for (const key of ["content", "parts"] as const) { const parts = readArray(message, key); + if (!parts) continue; + const text = parts .map((part) => { const record = asRecord(part); + return record?.type === "text" && typeof record.text === "string" ? record.text : ""; }) .filter(Boolean) .join(""); + if (text.trim()) return text.trim(); } + const info = asRecord(message.info) ?? message; + return stringifyStructuredMessage(info.structured); } function stringifyStructuredMessage(value: unknown): string { if (value === undefined || value === null) return ""; + if (typeof value === "string") return value.trim(); + return JSON.stringify(value); } function unwrapProviderPayload(value: unknown): unknown { let current = value; + for (let depth = 0; depth < 3; depth += 1) { const record = asRecord(current); + if (!record) return current; + if (record.data !== undefined) { current = record.data; continue; } + if (record.result !== undefined) { current = record.result; continue; } + return current; } + return current; } function readArray(value: unknown, key: string): unknown[] | undefined { const result = asRecord(value)?.[key]; + return Array.isArray(result) ? result : undefined; } function readNestedString(value: unknown, path: string[]): string | undefined { let current: unknown = value; + for (const key of path) current = asRecord(current)?.[key]; + return typeof current === "string" ? current : undefined; } @@ -456,6 +511,7 @@ function asRecord(value: unknown): Record | undefined { function requireFinalResponse(response: string): string { const trimmed = response.trim(); + if (!trimmed) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -465,6 +521,7 @@ function requireFinalResponse(response: string): string { message: "OpenCode did not return a final assistant response.", }); } + return trimmed; } diff --git a/src/local-agent-path.ts b/src/local-agent-path.ts index c8ff2935f..827731d2f 100644 --- a/src/local-agent-path.ts +++ b/src/local-agent-path.ts @@ -10,15 +10,18 @@ export function removeDevspaceNodeModulesBinFromPath(pathValue: string): string function isDevspaceNodeModulesBin(pathEntry: string): boolean { const resolvedEntry = resolve(pathEntry); + if (!resolvedEntry.endsWith(`${sep}node_modules${sep}.bin`)) { return false; } const packageJson = resolve(resolvedEntry, "..", "..", "package.json"); + if (!existsSync(packageJson)) return false; try { const packageInfo = JSON.parse(readFileSync(packageJson, "utf8")) as { name?: unknown }; + return packageInfo.name === "@waishnav/devspace"; } catch { return false; diff --git a/src/local-agent-pi-rpc.test.ts b/src/local-agent-pi-rpc.test.ts index 91da3a758..6f0eeeb1b 100644 --- a/src/local-agent-pi-rpc.test.ts +++ b/src/local-agent-pi-rpc.test.ts @@ -34,6 +34,7 @@ test("responses resolve requests while other lines queue as events", async () => cwd: process.cwd(), env: process.env, }); + const response = await connection.request({ type: "echo", value: "hello" }); assert.deepEqual(response, { echoed: "hello" }); assert.deepEqual(await connection.nextEvent(1_000), { type: "agent_start" }); @@ -46,12 +47,14 @@ test("a noisy stderr does not block and a crash reports what Pi said", async () process.stderr.write("x".repeat(256 * 1024)); process.stderr.write("fatal: broken extension\n", () => process.exit(3)); `; + const noisy = PiRpcConnection.spawn({ command: process.execPath, args: ["-e", noisyScript], cwd: process.cwd(), env: process.env, }); + await assert.rejects( () => noisy.nextEvent(5_000), /exited with code 3[\s\S]*fatal: broken extension/, @@ -67,6 +70,7 @@ test("unconsumed events can be cleared", async () => { cwd: process.cwd(), env: process.env, }); + await clearable.request({ type: "echo", value: "again" }); clearable.clearEvents(); assert.equal(await clearable.nextEvent(100), undefined); diff --git a/src/local-agent-pi-rpc.ts b/src/local-agent-pi-rpc.ts index 135ad1048..77e6f4e5f 100644 --- a/src/local-agent-pi-rpc.ts +++ b/src/local-agent-pi-rpc.ts @@ -56,6 +56,7 @@ export class PiRpcConnection { env: NodeJS.ProcessEnv; }): PiRpcConnection { const detached = process.platform !== "win32"; + const child = spawn(input.command, input.args, { cwd: input.cwd, env: input.env, @@ -63,24 +64,29 @@ export class PiRpcConnection { detached, windowsHide: true, }); + if (!child.stdin || !child.stdout || !child.stderr) { throw new Error("Pi RPC process did not expose stdio pipes."); } + return new PiRpcConnection(child as ChildProcessWithoutNullStreams); } async request(record: PiRpcRecord, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise { this.assertOpen(); const id = `devspace-${++this.nextRequestId}`; + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Pi RPC ${String(record.type ?? "request")} timed out after ${timeoutMs}ms.`)); }, timeoutMs); + timer.unref(); this.pending.set(id, { resolve, reject, timer }); this.write({ ...record, id }).catch((error) => { const pending = this.pending.get(id); + if (!pending) return; this.pending.delete(id); clearTimeout(pending.timer); @@ -97,7 +103,9 @@ export class PiRpcConnection { async nextEvent(timeoutMs?: number): Promise { this.assertOpen(); const queued = this.queuedEvents.shift(); + if (queued) return queued; + return new Promise((resolve, reject) => { const waiter: (typeof this.eventWaiters)[number] = { resolve: (value) => { @@ -109,14 +117,17 @@ export class PiRpcConnection { reject(error); }, }; + if (timeoutMs !== undefined) { waiter.timer = setTimeout(() => { const index = this.eventWaiters.indexOf(waiter); + if (index >= 0) this.eventWaiters.splice(index, 1); resolve(undefined); }, timeoutMs); waiter.timer.unref(); } + this.eventWaiters.push(waiter); }); } @@ -147,23 +158,31 @@ export class PiRpcConnection { private consume(chunk: string): void { this.buffer += chunk; + if (this.buffer.length > MAX_RECORD_CHARS && !this.buffer.includes("\n")) { this.buffer = ""; + return; } + for (;;) { const newline = this.buffer.indexOf("\n"); + if (newline < 0) return; let line = this.buffer.slice(0, newline); this.buffer = this.buffer.slice(newline + 1); + if (line.endsWith("\r")) line = line.slice(0, -1); + if (!line.trim()) continue; let parsed: unknown; + try { parsed = JSON.parse(line); } catch { continue; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; this.route(parsed as PiRpcRecord); } @@ -172,31 +191,39 @@ export class PiRpcConnection { private route(record: PiRpcRecord): void { if (record.type === "response" && typeof record.id === "string") { const pending = this.pending.get(record.id); + if (pending) { this.pending.delete(record.id); clearTimeout(pending.timer); + if (record.success === true) pending.resolve(record.data); else pending.reject(new Error(String(record.error ?? `Pi RPC ${String(record.command)} failed.`))); + return; } } + const waiter = this.eventWaiters.shift(); + if (waiter) waiter.resolve(record); else this.queuedEvents.push(record); } private assertOpen(): void { if (this.failure) throw this.failure; + if (this.closed) throw new Error("Pi RPC process is closed."); } private fail(error: Error): void { if (!this.failure) this.failure = error; + for (const [id, pending] of this.pending) { this.pending.delete(id); clearTimeout(pending.timer); pending.reject(error); } + for (const waiter of this.eventWaiters.splice(0)) waiter.reject(error); } } @@ -204,11 +231,14 @@ export class PiRpcConnection { export function piRecordString(value: unknown, key: string): string | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; } export function parsePiModelSlug(model: string): { provider: string; modelId: string } | undefined { const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) return undefined; + return { provider: model.slice(0, separator), modelId: model.slice(separator + 1) }; } diff --git a/src/local-agent-pi-sandbox.test.ts b/src/local-agent-pi-sandbox.test.ts index 06f23e4bb..10ce3f617 100644 --- a/src/local-agent-pi-sandbox.test.ts +++ b/src/local-agent-pi-sandbox.test.ts @@ -24,12 +24,14 @@ function registerTools(workspace: string, modeRef: PiSandboxModeRef, env?: NodeJ registerTool: (tool: { name: string; execute: (...args: any[]) => Promise }) => tools.set(tool.name, tool), } as never); + return tools; } const dependencies = await SandboxManager.checkDependenciesAsync(); const sandboxRequired = process.env.DEVSPACE_REQUIRE_PI_SANDBOX === "1"; const sandboxAvailable = SandboxManager.isSupportedPlatform() && dependencies.errors.length === 0; + // When this lane requires the sandbox the integration tests run and fail on missing dependencies instead of skipping. const sandboxSkip = sandboxAvailable || sandboxRequired ? false @@ -40,6 +42,7 @@ async function sandboxFixture(t: TestContext) { assert.equal(SandboxManager.isSupportedPlatform(), true, "Pi sandbox integration is required on this CI lane"); assert.deepEqual(dependencies.errors, [], "Pi sandbox dependencies must be available on this CI lane"); } + const root = await mkdtemp(join(tmpdir(), "devspace-pi-sandbox-test-")); const workspace = join(root, "workspace"); mkdirSync(workspace); @@ -52,6 +55,7 @@ async function sandboxFixture(t: TestContext) { }); const tools = registerTools(workspace, modeRef); await registerPiSandboxSession(session, workspace, modeRef, "allowed"); + return { root, workspace, outside, modeRef, tools }; } @@ -59,6 +63,7 @@ async function outsideDirectoryFixture(root: string): Promise { const outsideDirectory = join(root, "outside-directory"); mkdirSync(outsideDirectory); await writeFile(join(outsideDirectory, "secret.txt"), "secret"); + return outsideDirectory; } @@ -66,15 +71,19 @@ test("full-access bash receives the provider env", async (t) => { const workspace = await mkdtemp(join(tmpdir(), "devspace-pi-env-test-")); t.after(() => rm(workspace, { recursive: true, force: true })); const modeRef = createPiSandboxModeRef("full_access"); + const tools = registerTools(workspace, modeRef, { ...process.env, DEVSPACE_PI_ENV_TEST: "provider-env", }); + const bash = tools.get("bash"); assert.ok(bash); + const result = await bash.execute("provider-env-test", { command: "printf %s \"$DEVSPACE_PI_ENV_TEST\"" }) as { content: Array<{ type: string; text?: string }>; }; + assert.equal(result.content[0]?.text, "provider-env"); }); @@ -180,6 +189,7 @@ test("a workspace symlink retargeted outside cannot redirect sandboxed bash", { await rm(workspaceAlias, { recursive: true, force: true }); await symlink(outsideDirectory, workspaceAlias, process.platform === "win32" ? "junction" : "dir"); const escapedBashFile = join(outsideDirectory, "bash-escaped.txt"); + try { const retargetedBash = retargetedTools.get("bash"); assert.ok(retargetedBash); diff --git a/src/local-agent-pi-sandbox.ts b/src/local-agent-pi-sandbox.ts index 0cb96c9d4..82ef6e8a9 100644 --- a/src/local-agent-pi-sandbox.ts +++ b/src/local-agent-pi-sandbox.ts @@ -73,6 +73,7 @@ const DENIAL_SCAN_CHARS = 512; export function createUserNamespaceDenialDetector(): { push(chunk: string): void; readonly denied: boolean } { let tail = ""; let denied = false; + return { push(chunk) { if (denied) return; @@ -111,6 +112,7 @@ export function createPiSandboxExtension( env: NodeJS.ProcessEnv = {}, ): ExtensionFactory { const restrictedWorkspace = resolveWorkspace(workspace); + return (pi) => { const localRead = createReadTool(workspace); const restrictedRead = createReadTool(workspace, { operations: createReadOperations(restrictedWorkspace) }); @@ -140,17 +142,21 @@ export function createPiSandboxExtension( ...context, env: { ...context.env, ...env }, }); + const localBash = createBashTool(workspace, { spawnHook: withProviderEnv }); + const restrictedBash = createBashTool(workspace, { operations: createSandboxedBashOperations(restrictedWorkspace), spawnHook: withProviderEnv, }); + pi.registerTool(dynamicTool(localBash, restrictedBash, modeRef, true)); }; } export function createPiSandboxConfig(workspace?: string): SandboxRuntimeConfig { const resolvedWorkspace = workspace ? resolveWorkspace(workspace) : undefined; + return { network: { allowedDomains: [...PI_NETWORK_ALLOWLIST], @@ -179,7 +185,9 @@ export async function registerPiSandboxSession( modeRef, acquired: false, }; + sessionStates.set(session, state); + if (writeMode !== "full_access") { await acquirePiSandbox(state); } @@ -191,9 +199,11 @@ export async function updatePiSandboxSession( writeMode: PiSandboxWriteMode, ): Promise { const state = sessionStates.get(session); + if (!state) return; state.workspace = resolveWorkspace(workspace); state.modeRef.value = writeMode; + if (writeMode !== "full_access" && !state.acquired) { await acquirePiSandbox(state); } @@ -201,14 +211,19 @@ export async function updatePiSandboxSession( export async function releasePiSandboxSession(session: object): Promise { const state = sessionStates.get(session); + if (!state) return; sessionStates.delete(session); + if (!state.acquired) return; state.acquired = false; sandboxSessionCount = Math.max(0, sandboxSessionCount - 1); + if (sandboxSessionCount !== 0) return; await waitForSandboxCommands(); + if (sandboxSessionCount !== 0) return; + const reset = async (): Promise => { try { await SandboxManager.reset(); @@ -217,6 +232,7 @@ export async function releasePiSandboxSession(session: object): Promise { windowsSandboxWorkspace = undefined; } }; + if (process.platform === "win32") await enqueueWindowsSandbox(reset); else await reset(); } @@ -233,6 +249,7 @@ function dynamicTool any }>( if (writeCapable && modeRef.value === "read_only") { throw new Error("Pi read-only mode does not allow write-capable tools."); } + return (modeRef.value === "full_access" ? unrestricted : restricted).execute(...args); }, } as T; @@ -274,6 +291,7 @@ function createFindOperations(workspace: string): FindOperations { exists: async (path) => { try { await access(await assertPiWorkspacePath(path, workspace)); + return true; } catch { return false; @@ -287,19 +305,25 @@ function createFindOperations(workspace: string): FindOperations { const visit = async (directory: string): Promise => { if (results.length >= options.limit) return; + for (const entry of await readdir(directory, { withFileTypes: true })) { if (entry.isSymbolicLink()) continue; + if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue; const absolutePath = join(directory, entry.name); const relativePath = relative(searchRoot, absolutePath).split("\\").join("/"); const candidate = pattern.includes("/") ? relativePath : basename(relativePath); + if (matcher.test(candidate)) results.push(absolutePath); + if (entry.isDirectory()) await visit(absolutePath); + if (results.length >= options.limit) return; } }; await visit(searchRoot); + return results; }, }; @@ -310,6 +334,7 @@ function createLsOperations(workspace: string): LsOperations { exists: async (path) => { try { await access(await assertPiWorkspacePath(path, workspace)); + return true; } catch { return false; @@ -324,13 +349,17 @@ function createSandboxedBashOperations(workspace: string): BashOperations { return { exec: (command, cwd, options) => withSandboxCommand(async () => { const operationCwd = await assertPiWorkspacePath(cwd, workspace); + if (process.platform === "win32") { return enqueueWindowsSandbox(async () => { await ensureWindowsSandbox(operationCwd); + return runSandboxedCommand(command, operationCwd, options); }); } + await ensureSandboxInitialized(); + return runSandboxedCommand(command, operationCwd, options); }), }; @@ -340,6 +369,7 @@ async function acquirePiSandbox(state: PiSandboxSessionState): Promise { if (state.acquired) return; state.acquired = true; sandboxSessionCount += 1; + try { if (process.platform === "win32") { // Windows sandbox-runtime has process-global policy state. Serialize @@ -360,21 +390,26 @@ async function ensureSandboxInitialized(): Promise { if (!SandboxManager.isSupportedPlatform()) { throw new Error(`Pi allowed mode requires a sandbox, but ${process.platform} is not supported by sandbox-runtime.`); } + if (!sandboxInitialization) { sandboxInitialization = SandboxManager.initialize(createPiSandboxConfig()).catch((error) => { sandboxInitialization = undefined; throw new Error(`Pi allowed mode could not initialize its sandbox: ${error instanceof Error ? error.message : String(error)}`); }); } + await sandboxInitialization; } async function ensureWindowsSandbox(workspace: string): Promise { const resolvedWorkspace = resolveWorkspace(workspace); + if (windowsSandboxWorkspace === resolvedWorkspace && sandboxInitialization) { await sandboxInitialization; + return; } + if (sandboxInitialization) { try { await SandboxManager.reset(); @@ -383,6 +418,7 @@ async function ensureWindowsSandbox(workspace: string): Promise { windowsSandboxWorkspace = undefined; } } + sandboxInitialization = SandboxManager.initialize(createPiSandboxConfig(resolvedWorkspace)).catch((error) => { sandboxInitialization = undefined; windowsSandboxWorkspace = undefined; @@ -395,6 +431,7 @@ async function ensureWindowsSandbox(workspace: string): Promise { function enqueueWindowsSandbox(operation: () => Promise): Promise { const next = windowsSandboxQueue.then(operation, operation); windowsSandboxQueue = next.then(() => undefined, () => undefined); + return next; } @@ -405,9 +442,11 @@ async function runSandboxedCommand( ): Promise<{ exitCode: number | null }> { if (options.signal?.aborted) throw new Error("aborted"); const workspace = resolveWorkspace(cwd); + const customConfig: Partial = { filesystem: createPiSandboxFilesystem(workspace), }; + const wrapped = await SandboxManager.wrapWithSandboxArgv( command, undefined, @@ -415,7 +454,9 @@ async function runSandboxedCommand( options.signal, workspace, ); + const denial = createUserNamespaceDenialDetector(); + try { const result = await spawnSandboxedCommand(wrapped.argv, wrapped.env, workspace, { ...options, @@ -424,6 +465,7 @@ async function runSandboxedCommand( options.onData(data); }, }); + // The sandbox initializes fine on these hosts and only fails per command, // which the model would otherwise read as its own command failing. if (denial.denied && result.exitCode !== 0) { @@ -434,6 +476,7 @@ async function runSandboxedCommand( "`sudo apparmor_parser -R /etc/apparmor.d/bwrap-userns-restrict`.", ); } + return result; } finally { SandboxManager.cleanupAfterCommand(); @@ -447,7 +490,9 @@ function spawnSandboxedCommand( options: Parameters[2], ): Promise<{ exitCode: number | null }> { const [executable, ...args] = argv; + if (!executable) throw new Error("sandbox-runtime returned an empty command"); + return new Promise((resolveResult, reject) => { const child = spawn(executable, args, { cwd, @@ -457,26 +502,32 @@ function spawnSandboxedCommand( shell: false, windowsHide: true, }); + let timedOut = false; let settled = false; let timer: NodeJS.Timeout | undefined; const detached = process.platform !== "win32"; + const finish = (callback: () => void) => { if (settled) return; settled = true; + if (timer) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); callback(); }; + const kill = () => terminateProcessTree(child, "SIGKILL", detached); const onAbort = () => kill(); options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.timeout !== undefined && options.timeout > 0) { timer = setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000); } + child.stdout?.on("data", options.onData); child.stderr?.on("data", options.onData); child.once("error", (error) => finish(() => reject(error))); @@ -501,6 +552,7 @@ function createPiSandboxFilesystem(workspace: string): FilesystemConfig { function protectedHomePaths(): string[] { const home = homedir(); + return [ join(home, ".ssh"), join(home, ".aws"), @@ -519,18 +571,23 @@ async function assertPiWorkspacePath( const resolvedWorkspace = resolveWorkspace(workspace); const absolutePath = resolve(path); const { boundaryPath, suffix } = await resolveExistingBoundary(absolutePath); + if (!isPathInsideRoot(boundaryPath, resolvedWorkspace)) { assertAllowedPath(boundaryPath, [resolvedWorkspace]); } + const operationPath = suffix ? resolve(boundaryPath, suffix) : boundaryPath; + if (!isPathInsideRoot(operationPath, resolvedWorkspace)) { assertAllowedPath(operationPath, [resolvedWorkspace]); } + return operationPath; } async function resolveExistingBoundary(path: string): Promise<{ boundaryPath: string; suffix: string }> { let candidate = path; + for (;;) { try { return { @@ -539,6 +596,7 @@ async function resolveExistingBoundary(path: string): Promise<{ boundaryPath: st }; } catch { const parent = dirname(candidate); + if (parent === candidate) return { boundaryPath: candidate, suffix: relative(candidate, path) }; candidate = parent; } @@ -547,10 +605,12 @@ async function resolveExistingBoundary(path: string): Promise<{ boundaryPath: st async function withSandboxCommand(operation: () => Promise): Promise { sandboxCommandCount += 1; + try { return await operation(); } finally { sandboxCommandCount = Math.max(0, sandboxCommandCount - 1); + if (sandboxCommandCount === 0) { for (const resolveWaiter of sandboxCommandWaiters) resolveWaiter(); sandboxCommandWaiters.clear(); @@ -560,22 +620,28 @@ async function withSandboxCommand(operation: () => Promise): Promise { function waitForSandboxCommands(): Promise { if (sandboxCommandCount === 0) return Promise.resolve(); + return new Promise((resolveWaiter) => sandboxCommandWaiters.add(resolveWaiter)); } function resolveWorkspace(workspace: string): string { const absolute = resolve(workspace); + if (!existsSync(absolute)) throw new Error(`Pi workspace does not exist: ${workspace}`); + return realpathSync(absolute); } function globToRegExp(pattern: string): RegExp { const normalized = pattern.replaceAll("\\", "/").replace(/^\.\//, ""); let source = "^"; + for (let index = 0; index < normalized.length; index += 1) { const character = normalized[index]; + if (character === "*" && normalized[index + 1] === "*") { index += 1; + if (normalized[index + 1] === "/") { index += 1; source += "(?:.*/)?"; @@ -590,5 +656,6 @@ function globToRegExp(pattern: string): RegExp { source += character.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); } } + return new RegExp(`${source}$`); } diff --git a/src/local-agent-pi.test.ts b/src/local-agent-pi.test.ts index 2c13a5540..3ef3ffd2f 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -13,10 +13,12 @@ import type { LocalAgentRuntimeContext } from "./local-agent-runtime.js"; const sessionDirectory = mkdtempSync(join(tmpdir(), "devspace-pi-test-")); let sessionCount = 0; + // Pi session references are real files; resuming validates that. function sessionFile(name = `session-${++sessionCount}`): string { const path = join(sessionDirectory, `${name}.jsonl`); writeFileSync(path, ""); + return path; } @@ -30,12 +32,15 @@ class FakePiRpcConnection implements PiRpcRuntimeConnection { async request(record: Record): Promise { this.requests.push(record); + switch (record.type) { case "new_session": this.sessionFile = sessionFile(); + return { cancelled: false }; case "switch_session": this.sessionFile = String(record.sessionPath); + return { cancelled: false }; case "get_state": return { @@ -52,6 +57,7 @@ class FakePiRpcConnection implements PiRpcRuntimeConnection { return undefined; case "prompt": this.lastPrompt = String(record.message); + if (this.lastPrompt === "use tools") { this.events.push( { type: "tool_execution_start", toolCallId: "call_1", toolName: "bash", args: { command: "ls" } }, @@ -59,11 +65,13 @@ class FakePiRpcConnection implements PiRpcRuntimeConnection { { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "Two files." }] } }, ); } + this.events.push( { type: "agent_start" }, { type: "agent_end" }, { type: "agent_settled" }, ); + return undefined; case "get_last_assistant_text": return { text: `response:${this.lastPrompt}` }; @@ -76,9 +84,11 @@ class FakePiRpcConnection implements PiRpcRuntimeConnection { async nextEvent(timeoutMs = 0): Promise | undefined> { const event = this.events.shift(); + if (event) return event; // Like the real connection, an empty queue waits instead of spinning on microtasks. await new Promise((resolveWait) => setTimeout(resolveWait, Math.min(timeoutMs, 5))); + return undefined; } @@ -99,19 +109,23 @@ function piPoolFixture(t: TestContext) { const contexts: LocalAgentRuntimeContext[] = []; const connections: FakePiRpcConnection[] = []; const state = { factoryEnv: undefined as NodeJS.ProcessEnv | undefined, cleanupCount: 0 }; + const factory: PiRpcFactory = async (context, env) => { contexts.push(context); state.factoryEnv = env; const connection = new FakePiRpcConnection(); connections.push(connection); + return { connection, cleanup: async () => { state.cleanupCount += 1; }, }; }; + const driver = new PiLocalAgentDriver(factory, { HARNESS_ENV: "pi" }); const pool = new LocalAgentRuntimePool(); t.after(() => pool.close()); + return { contexts, connections, state, driver, pool }; } @@ -136,8 +150,10 @@ test("a first Pi turn applies the model and effort and reports its session", asy }, { onSessionId: (sessionId) => { sessionIds.push(sessionId); }, }); + assert.equal(state.factoryEnv?.HARNESS_ENV, "pi"); assert.equal(first.isOk(), true); + if (first.isErr()) throw first.error; assert.match(first.value.providerSessionId ?? "", /session-\d+\.jsonl$/); assert.equal(first.value.finalResponse, "response:first"); @@ -164,6 +180,7 @@ test("the runtime pool reuses, replaces, evicts, and cold-resumes Pi processes", effort: "high", writeMode: "read_only", }); + if (first.isErr()) throw first.error; const second = await pool.run(driver, context, { @@ -172,6 +189,7 @@ test("the runtime pool reuses, replaces, evicts, and cold-resumes Pi processes", providerSessionId: first.value.providerSessionId ?? undefined, writeMode: "read_only", }); + assert.equal(second.isOk(), true); assert.equal(contexts.length, 1, "same agent and write mode reuse one Pi RPC process"); @@ -201,25 +219,34 @@ test("a model Pi does not offer is a protocol error naming the model", async () const missingModel = new FakePiRpcConnection(); missingModel.request = async function(record: Record): Promise { this.requests.push(record); + if (record.type === "get_available_models") return { models: [] }; + if (record.type === "new_session") return { cancelled: false }; + if (record.type === "get_state") return { sessionFile: sessionFile("missing") }; throw new Error(`unexpected request: ${String(record.type)}`); }; + const missingModelDriver = new PiLocalAgentDriver(async () => ({ connection: missingModel })); const missingRuntime = await missingModelDriver.createRuntime(context); assert.equal(missingRuntime.isOk(), true); + if (missingRuntime.isErr()) throw missingRuntime.error; + const missingResult = await missingRuntime.value.run({ prompt: "inspect", workspaceRoot: "/tmp/project", model: "missing-model", }); + assert.equal(missingResult.isErr(), true); + if (missingResult.isErr()) { assert.equal(missingResult.error.code, "PROVIDER_PROTOCOL_ERROR"); assert.match(missingResult.error.message, /missing-model/); } + await missingRuntime.value.close(); }); @@ -228,13 +255,17 @@ test("a bare pre-RPC session id fails instead of opening an empty session", asyn const legacyConnection = new FakePiRpcConnection(); const legacyDriver = new PiLocalAgentDriver(async () => ({ connection: legacyConnection })); const legacyRuntime = await legacyDriver.createRuntime(context); + if (legacyRuntime.isErr()) throw legacyRuntime.error; + const legacyResult = await legacyRuntime.value.run({ prompt: "continue", workspaceRoot: "/tmp/project", providerSessionId: "019f-legacy-session-id", }); + assert.equal(legacyResult.isErr(), true); + if (legacyResult.isErr()) assert.match(legacyResult.error.message, /Start a new agent/); assert.equal(legacyConnection.requests.some((request) => request.type === "switch_session"), false); await legacyRuntime.value.close(); @@ -246,17 +277,23 @@ test("an exact model id wins over provider/model parsing", async () => { gatewayConnection.request = async function(record: Record): Promise { if (record.type === "get_available_models") { this.requests.push(record); + return { models: [{ provider: "openrouter", id: "openai/gpt-4o" }] }; } + return FakePiRpcConnection.prototype.request.call(this, record); }; + const gatewayRuntime = await new PiLocalAgentDriver(async () => ({ connection: gatewayConnection })).createRuntime(context); + if (gatewayRuntime.isErr()) throw gatewayRuntime.error; + const gatewayResult = await gatewayRuntime.value.run({ prompt: "inspect", workspaceRoot: "/tmp/project", model: "openai/gpt-4o", }); + assert.equal(gatewayResult.isOk(), true); assert.deepEqual( gatewayConnection.requests.find((request) => request.type === "set_model"), @@ -272,20 +309,27 @@ test("stopping aborts Pi and discards a Pi that does not settle", async () => { if (record.type === "prompt") { this.requests.push(record); this.events.push({ type: "agent_start" }); + return undefined; } + return FakePiRpcConnection.prototype.request.call(this, record); }; + const stalledRuntime = await new PiLocalAgentDriver(async () => ({ connection: stalledConnection })).createRuntime(context); + if (stalledRuntime.isErr()) throw stalledRuntime.error; const stop = new AbortController(); setTimeout(() => stop.abort(), 20); + const stalledResult = await stalledRuntime.value.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal, }); + assert.equal(stalledResult.isErr(), true); + if (stalledResult.isErr()) assert.equal(stalledResult.error.code, "PROVIDER_CANCELLED"); assert.ok(stalledConnection.requests.some((request) => request.type === "abort")); assert.equal(stalledRuntime.value.isAlive(), false, "a Pi that does not settle after abort is discarded"); @@ -294,14 +338,17 @@ test("stopping aborts Pi and discards a Pi that does not settle", async () => { test("an already-aborted signal never sends the prompt", async () => { const preAbortConnection = new FakePiRpcConnection(); const preAbortRuntime = await new PiLocalAgentDriver(async () => ({ connection: preAbortConnection })).createRuntime(context); + if (preAbortRuntime.isErr()) throw preAbortRuntime.error; const alreadyStopped = new AbortController(); alreadyStopped.abort(); + const preAbortResult = await preAbortRuntime.value.run({ prompt: "never starts", workspaceRoot: "/tmp/project", signal: alreadyStopped.signal, }); + assert.equal(preAbortResult.isErr(), true); assert.equal(preAbortConnection.requests.some((request) => request.type === "prompt"), false); await preAbortRuntime.value.close(); @@ -311,8 +358,10 @@ test("leftover events from an earlier turn do not settle the next one", async () const staleConnection = new FakePiRpcConnection(); staleConnection.events.push({ type: "agent_start" }, { type: "agent_settled" }); const staleRuntime = await new PiLocalAgentDriver(async () => ({ connection: staleConnection })).createRuntime(context); + if (staleRuntime.isErr()) throw staleRuntime.error; const staleResult = await staleRuntime.value.run({ prompt: "fresh", workspaceRoot: "/tmp/project" }); + if (staleResult.isErr()) throw staleResult.error; assert.ok(staleResult.value.items.some((event: any) => event.type === "agent_end"), "leftover events from an earlier turn do not settle this one"); await staleRuntime.value.close(); @@ -323,11 +372,14 @@ test("Pi RPC tool and message frames map onto DevSpace activity events", async ( const streamingConnection = new FakePiRpcConnection(); const streamingDriver = new PiLocalAgentDriver(async () => ({ connection: streamingConnection })); const streamingRuntime = await streamingDriver.createRuntime(context); + if (streamingRuntime.isErr()) throw streamingRuntime.error; const piEvents: unknown[] = []; + const streamed = await streamingRuntime.value.run({ prompt: "use tools", workspaceRoot: "/tmp/project" }, { onEvent: (event) => { piEvents.push(event); }, }); + if (streamed.isErr()) throw streamed.error; assert.equal(streamingDriver.capabilities.turns.streaming, true); assert.deepEqual(piEvents, [ diff --git a/src/local-agent-pi.ts b/src/local-agent-pi.ts index 550a83175..3d3e17871 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -76,22 +76,28 @@ export class PiRpcRuntime implements LocalAgentRuntime { const providerSessionId = await this.ensureSession(input.providerSessionId); await callbacks?.onSessionId?.(providerSessionId); await this.applyOverrides(input); + if (input.signal?.aborted) throw localAgentCancelledError("pi", "run"); const events: PiRpcRecord[] = []; // Events left over from an earlier turn must not settle this one. this.connection.clearEvents(); + try { await this.connection.request({ type: "prompt", message: input.prompt }); await this.waitForSettlement(events, input.signal, callbacks?.onEvent); + if (input.signal?.aborted) { await this.abortTurn(); throw localAgentCancelledError("pi", "run"); } + const response = await this.connection.request({ type: "get_last_assistant_text" }); const finalResponse = piRecordString(response, "text")?.trim(); + if (!finalResponse) { const providerError = extractPiProviderError(events); + if (providerError) { throw new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", @@ -102,6 +108,7 @@ export class PiRpcRuntime implements LocalAgentRuntime { message: "Pi agent turn failed.", }); } + throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", provider: "pi", @@ -110,6 +117,7 @@ export class PiRpcRuntime implements LocalAgentRuntime { message: "Pi did not return a final assistant response.", }); } + return { provider: this.provider, providerSessionId, @@ -149,24 +157,32 @@ export class PiRpcRuntime implements LocalAgentRuntime { `Pi session ${providerSessionId} is not a session file DevSpace can resume. Start a new agent.`, ); } + const result = await this.connection.request({ type: "switch_session", sessionPath: providerSessionId }); + if (recordBoolean(result, "cancelled")) { throw piProtocolError("resume_session", "A Pi extension cancelled the session switch."); } + this.sessionFile = undefined; } else if (!providerSessionId && !this.sessionFile) { const result = await this.connection.request({ type: "new_session" }); + if (recordBoolean(result, "cancelled")) { throw piProtocolError("create_session", "A Pi extension cancelled new session creation."); } } + if (this.sessionFile) return this.sessionFile; const state = await this.connection.request({ type: "get_state" }); const sessionFile = piRecordString(state, "sessionFile"); + if (!sessionFile) { throw piProtocolError("resolve_session", "Pi get_state returned no persisted session file."); } + this.sessionFile = sessionFile; + return sessionFile; } @@ -175,6 +191,7 @@ export class PiRpcRuntime implements LocalAgentRuntime { const model = await this.resolveModel(input.model); await this.connection.request({ type: "set_model", provider: model.provider, modelId: model.modelId }); } + if (input.effort) { await this.connection.request({ type: "set_thinking_level", level: input.effort }); } @@ -185,12 +202,16 @@ export class PiRpcRuntime implements LocalAgentRuntime { const available = await this.connection.request({ type: "get_available_models" }); const models = recordArray(available, "models"); const matches = models.filter((entry) => piRecordString(entry, "id") === model); + if (matches.length === 1) { const provider = piRecordString(matches[0], "provider"); const modelId = piRecordString(matches[0], "id"); + if (provider && modelId) return { provider, modelId }; } + const parsed = parsePiModelSlug(model); + if (parsed) return parsed; throw piProtocolError("configure_model", `Pi model not found or ambiguous: ${model}.`); } @@ -202,17 +223,21 @@ export class PiRpcRuntime implements LocalAgentRuntime { private async abortTurn(): Promise { await this.connection.request({ type: "abort" }, PI_ABORT_SETTLE_MS).catch(() => undefined); const deadline = Date.now() + PI_ABORT_SETTLE_MS; + try { while (Date.now() < deadline) { const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event?.type === "agent_settled") { this.connection.clearEvents(); + return; } } } catch { // The connection already failed; closing below is idempotent. } + this.connection.close(); } @@ -224,31 +249,41 @@ export class PiRpcRuntime implements LocalAgentRuntime { const deadline = Date.now() + PI_TURN_TIMEOUT_MS; const toolSummaries = new Map(); let sawAgentActivity = false; + for (;;) { if (signal?.aborted) return; + if (Date.now() >= deadline) { await this.abortTurn(); throw piProtocolError("prompt", "Pi did not settle before the provider timeout.", true); } + const event = await this.connection.nextEvent(PI_STATE_POLL_MS); + if (event) { if (events.length >= MAX_PI_EVENTS) events.shift(); events.push(event); + if (onEvent) reportPiActivity(event, toolSummaries, onEvent); + if (event.type === "agent_start" || event.type === "message_start" || event.type === "tool_execution_start") { sawAgentActivity = true; } + if (event.type === "extension_ui_request") { await cancelPiUiRequest(this.connection, event); continue; } + if (event.type === "agent_settled") return; continue; } + const state = await this.connection.request({ type: "get_state" }, 2_000); const streaming = recordBoolean(state, "isStreaming"); const compacting = recordBoolean(state, "isCompacting"); const pending = recordNumber(state, "pendingMessageCount") ?? 0; + if (!streaming && !compacting && pending === 0 && !sawAgentActivity) return; } } @@ -282,6 +317,7 @@ export class PiLocalAgentDriver implements LocalAgentDriver { operation: "create_runtime", run: async (): Promise => { const { connection, cleanup } = await this.factory(context, this.env); + return new PiRpcRuntime(connection, cleanup); }, }); @@ -293,6 +329,7 @@ async function defaultPiRpcFactory( env: NodeJS.ProcessEnv, ): Promise<{ connection: PiRpcRuntimeConnection; cleanup?: () => Promise }> { let launch: { command: string; args: string[] }; + try { launch = resolvePiCommand(env); } catch (cause) { @@ -306,10 +343,13 @@ async function defaultPiRpcFactory( message: "Pi executable not found.", }); } + const writeMode = context.writeMode ?? "allowed"; + const sandbox = writeMode === "full_access" ? undefined : await materializePiSandboxExtension(context.workspaceRoot, writeMode); + const args = [ ...launch.args, "--mode", @@ -322,6 +362,7 @@ async function defaultPiRpcFactory( ? ["--tools", PI_ALLOWED_TOOLS.join(",")] : []), ]; + try { return { connection: PiRpcConnection.spawn({ @@ -340,16 +381,22 @@ async function defaultPiRpcFactory( export function resolvePiCommand(env: NodeJS.ProcessEnv): { command: string; args: string[] } { const configured = env.PI_COMMAND?.trim(); + if (configured) { const command = resolveExecutableCommand(configured, env); + if (!command) throw new Error(`Pi executable not found: ${configured}`); + return { command, args: [] }; } + const system = resolveExecutableCommand("pi", env); + if (system) return { command: system, args: [] }; // The package is ESM-only and does not export package.json; cli.js sits beside its entry point. const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")); const cli = join(dirname(entry), "cli.js"); + return { command: process.execPath, args: [cli] }; } @@ -362,12 +409,15 @@ async function materializePiSandboxExtension( const jsModule = new URL("./local-agent-pi-sandbox.js", import.meta.url); const tsModule = new URL("./local-agent-pi-sandbox.ts", import.meta.url); const moduleUrl = existsSync(fileURLToPath(jsModule)) ? jsModule : tsModule; + const source = [ `import { createPiSandboxExtension, createPiSandboxModeRef } from ${JSON.stringify(moduleUrl.href)};`, `export default createPiSandboxExtension(${JSON.stringify(resolve(workspaceRoot))}, createPiSandboxModeRef(${JSON.stringify(writeMode)}));`, "", ].join("\n"); + await writeFile(extensionPath, source, "utf8"); + return { path: extensionPath, cleanup: () => rm(directory, { recursive: true, force: true }), @@ -381,22 +431,27 @@ export function reportPiActivity( ): void { const tool = piRecordString(event, "toolName"); const callId = piRecordString(event, "toolCallId"); + if (event.type === "tool_execution_start" && tool) { const summary = summarizeToolInput(event.args); + if (callId) toolSummaries.set(callId, summary); onEvent({ type: "tool_started", tool, summary }); } else if (event.type === "tool_execution_end" && tool) { const summary = callId ? toolSummaries.get(callId) : undefined; + if (callId) toolSummaries.delete(callId); onEvent({ type: "tool_completed", tool, summary, status: event.isError === true ? "failed" : "completed" }); } else if (event.type === "message_end") { const text = extractPiFinalResponse(event.message); + if (text) onEvent({ type: "message", text }); } } async function cancelPiUiRequest(connection: PiRpcRuntimeConnection, event: PiRpcRecord): Promise { if (typeof event.id !== "string") return; + if (event.method === "notify" || event.method === "setStatus" || event.method === "setWidget" || event.method === "setTitle") return; await connection.send({ type: "extension_ui_response", id: event.id, cancelled: true }); } @@ -404,17 +459,25 @@ async function cancelPiUiRequest(connection: PiRpcRuntimeConnection, event: PiRp export function extractPiFinalResponse(value: unknown): string { if (!value || typeof value !== "object") return ""; const root = value as Record; + const data = root.data && typeof root.data === "object" && !Array.isArray(root.data) ? root.data as Record : root; + const messages = Array.isArray(data.messages) ? data.messages : [data]; + for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; + if (!message || typeof message !== "object") continue; + if ((message as { role?: unknown }).role !== "assistant") continue; const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) continue; + const text = content .filter((block): block is { type: string; text: string } => ( Boolean(block) @@ -425,50 +488,65 @@ export function extractPiFinalResponse(value: unknown): string { .map((block) => block.text) .join("\n\n") .trim(); + if (text) return text; } + return ""; } export function extractPiProviderError(value: unknown): string | undefined { const records = Array.isArray(value) ? [...value] : [value]; + if (value && typeof value === "object" && !Array.isArray(value)) { const nested = (value as Record).messages; + if (Array.isArray(nested)) records.push(...nested); } + for (let index = records.length - 1; index >= 0; index -= 1) { const record = records[index]; + if (!record || typeof record !== "object") continue; const event = record as Record; + if (event.type === "extension_error" && typeof event.error === "string") return event.error; + if (event.type === "message_end") { const message = event.message; + if (message && typeof message === "object") { const error = (message as Record).errorMessage; + if (typeof error === "string" && error) return error; } } + if (event.stopReason === "error" && typeof event.errorMessage === "string" && event.errorMessage) { return event.errorMessage; } } + return undefined; } function recordBoolean(value: unknown, key: string): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return (value as Record)[key] === true; } function recordNumber(value: unknown, key: string): number | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const result = (value as Record)[key]; + return typeof result === "number" && Number.isFinite(result) ? result : undefined; } function recordArray(value: unknown, key: string): unknown[] { if (!value || typeof value !== "object" || Array.isArray(value)) return []; const result = (value as Record)[key]; + return Array.isArray(result) ? result : []; } diff --git a/src/local-agent-presentation.test.ts b/src/local-agent-presentation.test.ts index 5b10c80c8..753dbf53b 100644 --- a/src/local-agent-presentation.test.ts +++ b/src/local-agent-presentation.test.ts @@ -68,6 +68,7 @@ test("observations carry the response or a structured error", () => { status: "idle", latestResponse: "Found one issue.", }); + assert.deepEqual(completed, { id: "agt_test", status: "completed", @@ -82,6 +83,7 @@ test("observations carry the response or a structured error", () => { errorCode: "PROVIDER_EXECUTION_ERROR", errorRetryable: true, }); + assert.deepEqual(failed, { id: "agt_test", status: "failed", @@ -104,6 +106,7 @@ test("the turn id and the provider's own error cause are visible in fragments", errorRetryable: false, errorDetail: "Claude returned an error result: rate ", }); + assert.equal( formatAgentObservation(failedWithCause), 'Claude agent turn failed.Claude returned an error result: rate <limited>', @@ -143,6 +146,7 @@ test("target catalogs list usable targets and explain unavailable providers", () effort: "high", }], }; + const targetCatalog = presentAgentTargetCatalog(catalog); assert.deepEqual(targetCatalog, { enabled: true, diff --git a/src/local-agent-presentation.ts b/src/local-agent-presentation.ts index fa695c690..1a4451cd4 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -72,8 +72,11 @@ export const AGENT_EXIT_CODES = { completed: 0, failed: 2, stopped: 3, timeout: /** With several agents, the most severe outcome wins: failed, then stopped, then timeout. */ export function agentObservationExitCode(observations: readonly AgentObservationOutput[]): number { if (observations.some((entry) => entry.status === "failed")) return AGENT_EXIT_CODES.failed; + if (observations.some((entry) => entry.status === "stopped")) return AGENT_EXIT_CODES.stopped; + if (observations.some((entry) => entry.status === "running")) return AGENT_EXIT_CODES.timeout; + return AGENT_EXIT_CODES.completed; } @@ -122,6 +125,7 @@ export function presentAgentSummary(record: LocalAgentRecord): AgentSummaryOutpu export function presentAgentObservation(record: LocalAgentRecord): AgentObservationOutput { const receipt = presentAgentReceipt(record); + switch (receipt.status) { case "completed": return { @@ -146,16 +150,21 @@ export function formatAgentTargetCatalog(catalog: AgentTargetCatalogOutput): str if (!catalog.enabled) { return "Subagents are disabled by subagents.enabled in config.jsonc. Remove the subagents key to detect installed agents, or set enabled to true."; } + const unavailable = catalog.unavailable.map((provider) => ( `${escapeXmlText(provider.reason)}` )); + const targets = catalog.targets.map((target) => { const settings = xmlAttributes({ model: target.model, effort: target.effort }); + if (target.kind === "provider") { return ``; } + return `${escapeXmlText(target.description)}`; }); + return [...targets, ...unavailable].join("\n"); } @@ -171,19 +180,24 @@ export function formatAgentObservation(observation: AgentObservationOutput): str if (observation.status === "running" && observation.wait) { return ``; } + if (observation.status === "completed" && observation.response !== undefined) { return `${escapeXmlText(observation.response)}`; } + if ((observation.status === "failed" || observation.status === "stopped") && observation.error) { const { code, retryable, message, detail } = observation.error; const cause = detail ? `${escapeXmlText(detail)}` : ""; + return `${escapeXmlText(message)}${cause}`; } + return formatAgentReceipt(observation); } export function formatAgentTurnEvent(event: LocalAgentTurnEvent): string { const base = { seq: String(event.seq), type: event.type, turn: event.turnId }; + switch (event.type) { case "turn_started": return ``; @@ -196,10 +210,12 @@ export function formatAgentTurnEvent(event: LocalAgentTurnEvent): string { tool: event.tool, status: event.type === "tool_completed" ? event.status : undefined, }); + return event.summary ? `${escapeXmlText(event.summary)}` : ``; } + case "turn_completed": return `${escapeXmlText(error.message)}`; } diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 18475c2c2..1c3ee691b 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -42,10 +42,12 @@ test("profiles may target a named provider instance", async (t) => { test("no profiles load while subagents are disabled", async (t) => { const { configDir, workspaceRoot } = await fixture(t); + const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { workspaces: { allowedRoots: [workspaceRoot] }, subagents: { enabled: false, instructions: "on-demand", providers: [] }, })); + assert.deepEqual(await loadLocalAgentProfiles(disabledConfig, workspaceRoot), []); }); @@ -106,5 +108,6 @@ async function fixture(t: TestContext) { workspaces: { allowedRoots: [workspaceRoot] }, subagents: { enabled: true, instructions: "on-demand", providers: [] }, })); + return { configDir, workspaceRoot, enabledConfig }; } diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 2b914d13c..85db13a1d 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -42,6 +42,7 @@ export async function loadLocalAgentProfiles( config.devspaceAgentsDir, join(workspaceRoot, ".devspace", "agents"), ]; + const profilesByName = new Map(); for (const directory of profileDirs) { @@ -57,6 +58,7 @@ export async function loadLocalAgentProfiles( async function loadProfilesFromDirectory(directory: string): Promise { const resolvedDirectory = resolve(directory); + if (!existsSync(resolvedDirectory)) return []; const entries = await readdir(resolvedDirectory, { withFileTypes: true }); @@ -64,9 +66,11 @@ async function loadProfilesFromDirectory(directory: string): Promise { const content = await readFile(filePath, "utf8"); const parsed = parseFrontmatter(content, filePath); + return profileFromFrontmatter(parsed.frontmatter, parsed.body, filePath); } function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter { const normalized = content.replace(/^\uFEFF/, ""); const lines = normalized.split(/\r?\n/); + if (lines[0]?.trim() !== FRONTMATTER_DELIMITER) { throw new Error(`Subagent profile is missing frontmatter: ${filePath}`); } @@ -93,6 +99,7 @@ function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter const endIndex = lines.findIndex( (line, index) => index > 0 && line.trim() === FRONTMATTER_DELIMITER, ); + if (endIndex === -1) { throw new Error(`Subagent profile frontmatter is not closed: ${filePath}`); } @@ -105,6 +112,7 @@ function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter function parseProfileYaml(source: string, filePath: string): Record { let parsed: unknown; + try { parsed = parseYaml(source) ?? {}; } catch (error) { @@ -126,6 +134,7 @@ function profileFromFrontmatter( const name = readString(frontmatter, "name") ?? basename(filePath, ".md"); const description = readString(frontmatter, "description"); const provider = readProvider(frontmatter, filePath); + if (!description) { throw new Error(`Subagent profile is missing description: ${filePath}`); } @@ -144,16 +153,20 @@ function profileFromFrontmatter( function readProvider(frontmatter: Record, filePath: string): LocalAgentProviderInstanceId { const provider = readString(frontmatter, "provider"); + if (!provider) { throw new Error(`Subagent profile is missing provider: ${filePath}`); } + return provider; } function readString(frontmatter: Record, key: string): string | undefined { const value = frontmatter[key]; + if (typeof value !== "string") return undefined; const trimmed = value.trim(); + return trimmed || undefined; } diff --git a/src/local-agent-provider-registry.ts b/src/local-agent-provider-registry.ts index 60c70ffbb..c021e7fef 100644 --- a/src/local-agent-provider-registry.ts +++ b/src/local-agent-provider-registry.ts @@ -28,6 +28,7 @@ export class LocalAgentProviderRegistry { register(kind: LocalAgentDriverKind, factory: LocalAgentDriverFactory): this { if (this.factories.has(kind)) throw new Error(`Local agent driver already registered: ${kind}`); this.factories.set(kind, factory); + return this; } @@ -37,13 +38,17 @@ export class LocalAgentProviderRegistry { inheritedEnv: NodeJS.ProcessEnv, ): LocalAgentDriver { const factory = this.factories.get(instance.driver); + if (!factory) throw new Error(`No local agent driver registered for: ${instance.driver}`); + const env = subagents ? localAgentProviderEnvironment(subagents, instance.id, inheritedEnv) : inheritedEnv; + const envOverrides = subagents ? localAgentProviderEnvironmentOverrides(subagents, instance.id) : {}; + return new ProviderInstanceDriver(instance.id, factory({ instance, env, envOverrides })); } } diff --git a/src/local-agent-provider.ts b/src/local-agent-provider.ts index 671ad11c6..1cafc7174 100644 --- a/src/local-agent-provider.ts +++ b/src/local-agent-provider.ts @@ -7,6 +7,7 @@ export const LOCAL_AGENT_DRIVER_KINDS = [ ] as const; export const LEGACY_ACP_DRIVER_KINDS = ["cursor", "copilot", "grok"] as const; + export const LOCAL_AGENT_DEFAULT_PROVIDER_IDS = [ "codex", "claude", @@ -33,6 +34,8 @@ export function isLegacyAcpDriverKind(value: string): value is typeof LEGACY_ACP export function defaultDriverForProviderId(value: string): LocalAgentDriverKind | undefined { if (isLocalAgentDriverKind(value)) return value; + if (isLegacyAcpDriverKind(value)) return "acp"; + return undefined; } diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index 0624e1fdc..7837e0855 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -66,6 +66,7 @@ export class LocalAgentRuntimePool { this.now = options.now ?? Date.now; this.logger = options.logger; this.sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS; + if (!Number.isFinite(this.sessionIdleTimeoutMs) || this.sessionIdleTimeoutMs < 0) { throw new Error("Local agent session idle timeout must be a non-negative finite duration."); } @@ -80,18 +81,24 @@ export class LocalAgentRuntimePool { if (this.closing) return Result.err(poolClosedError(driver, context)); let acquired = await this.acquire(driver, context); + if (acquired.isErr()) return acquired; let entry = acquired.value; let runtime = entry.runtime; + if (!runtime) throw new Error("Local agent runtime was created without a runtime."); + if (!runtime.isAlive()) { await this.discardRuntime(entry, driver.provider, "runtime_not_alive"); acquired = await this.acquire(driver, context); + if (acquired.isErr()) return acquired; entry = acquired.value; runtime = entry.runtime; + if (!runtime || !runtime.isAlive()) { await this.discardRuntime(entry, driver.provider, "runtime_not_alive"); + return Result.err(new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", provider: driver.provider, @@ -106,36 +113,47 @@ export class LocalAgentRuntimePool { this.clearIdleTimer(entry); entry.activeRuns += 1; const sessionIds = new Set(); + const reserveSession = async (providerSessionId: string): Promise => { if (!providerSessionId || sessionIds.has(providerSessionId)) return undefined; + while (true) { const existing = entry.sessions.get(providerSessionId); + if (existing?.releasePromise) { await existing.releasePromise; continue; } + if (entry.closing) return poolClosedError(driver, context); const session = existing ?? { activeRuns: 0, lastUsedAt: this.now() }; sessionIds.add(providerSessionId); session.activeRuns += 1; session.lastUsedAt = this.now(); entry.sessions.set(providerSessionId, session); + return undefined; } }; + const callbacks: LocalAgentRunCallbacks = { onSessionId: async (providerSessionId) => { const reservationError = await reserveSession(providerSessionId); + if (reservationError) throw reservationError; await inputCallbacks?.onSessionId?.(providerSessionId); }, ...(inputCallbacks?.onEvent ? { onEvent: inputCallbacks.onEvent } : {}), }; + const startedAt = this.now(); + try { const inputReservationError = await reserveSession(input.providerSessionId ?? ""); + if (inputReservationError) return Result.err(inputReservationError); const result = await runtime.run(input, callbacks); + if (result.isErr()) { if (!runtime.isAlive()) { try { @@ -148,6 +166,7 @@ export class LocalAgentRuntimePool { error: errorMessage(cleanupError), }); } + this.log("warn", "harness_runtime_crashed", { provider: driver.provider, runtimeKeyHash: hashRuntimeKey(entry.key), @@ -157,9 +176,12 @@ export class LocalAgentRuntimePool { error: result.error.message, }); } + return result; } + const outputReservationError = await reserveSession(result.value.providerSessionId ?? ""); + if (outputReservationError) { this.log("warn", "harness_session_reservation_failed", { provider: driver.provider, @@ -168,6 +190,7 @@ export class LocalAgentRuntimePool { error: outputReservationError.message, }); } + return result; } catch (error) { if (!runtime.isAlive()) { @@ -181,6 +204,7 @@ export class LocalAgentRuntimePool { error: errorMessage(cleanupError), }); } + this.log("warn", "harness_runtime_crashed", { provider: driver.provider, runtimeKeyHash: hashRuntimeKey(entry.key), @@ -190,20 +214,26 @@ export class LocalAgentRuntimePool { error: errorMessage(error), }); } + throw error; } finally { for (const providerSessionId of sessionIds) { const session = entry.sessions.get(providerSessionId); + if (!session) continue; session.activeRuns = Math.max(0, session.activeRuns - 1); session.lastUsedAt = this.now(); } + entry.activeRuns -= 1; + if (entry.activeRuns === 0) { for (const resolve of entry.activeRunWaiters) resolve(); entry.activeRunWaiters.clear(); } + entry.lastUsedAt = this.now(); + if (entry.activeRuns === 0 && !entry.closing) this.scheduleIdleClose(entry); } } @@ -228,13 +258,16 @@ export class LocalAgentRuntimePool { /** Evict entries whose runtime has been idle beyond their driver's TTL. */ async evictIdle(now = this.now()): Promise { const evictions: Promise[] = []; + for (const entry of this.entries.values()) { if (entry.closing || !entry.runtime) continue; await this.releaseIdleSessions(entry, now); + if (entry.activeRuns === 0 && now - entry.lastUsedAt >= entry.idleTimeoutMs) { evictions.push(this.removeAndClose(entry, "idle_timeout")); } } + await Promise.all(evictions); } @@ -245,6 +278,7 @@ export class LocalAgentRuntimePool { this.entries.clear(); this.closePromise = Promise.allSettled(entries.map((entry) => this.closeEntry(entry, "server_shutdown"))) .then(() => undefined); + return this.closePromise; } @@ -257,11 +291,14 @@ export class LocalAgentRuntimePool { context: LocalAgentRuntimeContext, ): Promise> { const key = runtimePoolKey(driver, context); + while (true) { const existing = this.entries.get(key); + if (existing && !existing.closing) { if (!existing.runtime || existing.runtime.isAlive()) { this.clearIdleTimer(existing); + if (existing.runtime) { this.log("info", "harness_runtime_reused", { provider: driver.provider, @@ -269,8 +306,11 @@ export class LocalAgentRuntimePool { agentId: context.agentId, }); } + const created = await existing.createPromise; + if (created.isErr()) return created; + if ( !this.closing && !existing.closing && @@ -280,6 +320,7 @@ export class LocalAgentRuntimePool { return Result.ok(existing); } } + await this.removeAndClose(existing, "runtime_not_alive"); continue; } @@ -287,13 +328,16 @@ export class LocalAgentRuntimePool { if (this.closing) return Result.err(poolClosedError(driver, context)); let entry!: RuntimeEntry; + const createPromise = Promise.resolve() .then(() => driver.createRuntime(context)) .then((result) => { if (result.isErr()) { if (this.entries.get(key) === entry) this.entries.delete(key); + return result; } + const runtime = result.value; entry.runtime = runtime; entry.lastUsedAt = this.now(); @@ -302,6 +346,7 @@ export class LocalAgentRuntimePool { runtimeKeyHash: hashRuntimeKey(key), agentId: context.agentId, }); + return result; }) .catch((error) => { @@ -323,17 +368,22 @@ export class LocalAgentRuntimePool { }; this.entries.set(key, entry); const created = await createPromise; + if (created.isErr()) return created; + if (this.closing || entry.closing || this.entries.get(key) !== entry) { await this.closeEntry(entry, "pool_shutdown_during_creation"); + return Result.err(poolClosedError(driver, context)); } + return Result.ok(entry); } } private scheduleIdleClose(entry: RuntimeEntry): void { this.clearIdleTimer(entry); + if (!Number.isFinite(entry.idleTimeoutMs) || entry.idleTimeoutMs <= 0) return; entry.idleTimer = setTimeout(() => { void this.evictIdle().catch((error) => { @@ -365,17 +415,22 @@ export class LocalAgentRuntimePool { this.clearIdleTimer(entry); entry.closePromise = (async () => { let runtime: LocalAgentRuntime | undefined; + try { const created = await entry.createPromise; + if (created.isErr()) return; runtime = created.value; } catch { return; } + if (!runtime) return; + if (reason !== "server_shutdown" && reason !== "runtime_crashed" && reason !== "runtime_not_alive") { await this.waitForNoActiveRuns(entry); } + if (reason === "server_shutdown") { // Shutdown is terminal for the provider runtime. Closing it first // aborts stuck turns and avoids waiting forever before process cleanup. @@ -383,6 +438,7 @@ export class LocalAgentRuntimePool { // turn, and the provider runtime owns their final cleanup. Existing // idle-release work is awaited so provider cleanup never overlaps it. await this.waitForSessionReleases(entry); + try { await runtime.close(); this.log("info", "harness_runtime_closed", { @@ -398,10 +454,14 @@ export class LocalAgentRuntimePool { error: errorMessage(error), }); } + entry.sessions.clear(); + return; } + await this.releaseSessions(entry, runtime, reason); + try { await runtime.close(); this.log("info", "harness_runtime_closed", { @@ -419,15 +479,18 @@ export class LocalAgentRuntimePool { throw error; } })(); + return entry.closePromise; } private async releaseIdleSessions(entry: RuntimeEntry, now: number): Promise { const releases: Promise[] = []; + for (const [providerSessionId, session] of entry.sessions) { if (session.activeRuns > 0 || now - session.lastUsedAt < entry.sessionIdleTimeoutMs) continue; releases.push(this.releaseSession(entry, providerSessionId)); } + await Promise.all(releases); } @@ -438,6 +501,7 @@ export class LocalAgentRuntimePool { ): Promise { const releases = Array.from(entry.sessions.keys()).map((providerSessionId) => this.releaseSession(entry, providerSessionId, runtime, reason)); + await Promise.all(releases); entry.sessions.clear(); } @@ -449,12 +513,17 @@ export class LocalAgentRuntimePool { reason = "idle_timeout", ): Promise { const session = entry.sessions.get(providerSessionId); + if (!runtime || !session) return; + if (entry.closing && reason === "idle_timeout") return; + if (session.releasePromise) return session.releasePromise; + const releasePromise = (async () => { try { await runtime.releaseSession(providerSessionId); + if (entry.sessions.get(providerSessionId) === session && session.activeRuns === 0) { entry.sessions.delete(providerSessionId); } @@ -468,7 +537,9 @@ export class LocalAgentRuntimePool { }); } })(); + session.releasePromise = releasePromise; + try { await releasePromise; } finally { @@ -485,6 +556,7 @@ export class LocalAgentRuntimePool { const releases = Array.from(entry.sessions.values()) .map((session) => session.releasePromise) .filter((release): release is Promise => Boolean(release)); + await Promise.all(releases); } @@ -499,10 +571,12 @@ export class LocalAgentRuntimePool { function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeContext): string { const policy = driver.runtimePolicy; + const parts: Array = [ context.providerInstanceId, policy.scope, ]; + switch (policy.scope) { case "instance": break; @@ -513,6 +587,7 @@ function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeCont parts.push(context.agentId); break; } + switch (policy.authority ?? "none") { case "none": break; @@ -523,6 +598,7 @@ function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeCont parts.push(context.writeMode === "full_access" ? "full_access" : "restricted"); break; } + return JSON.stringify(parts); } diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 82a6d067d..6f1d1ff6a 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -23,6 +23,7 @@ const TEST_CAPABILITIES = { permissions: { enforcement: "native" }, mcp: { supported: false }, } as const; + // Instance runtimes that idle eviction never closes, so tests control session release alone. const NEVER_IDLE = { scope: "instance", idleTimeoutMs: Number.POSITIVE_INFINITY } as const; @@ -32,6 +33,7 @@ const context: LocalAgentRuntimeContext = { provider: "codex", workspaceRoot: "/tmp/project", }; + const input: LocalAgentRunInput = { prompt: "inspect", workspaceRoot: "/tmp/project" }; test("connection failures classify as provider unavailable with the right retryability", async () => { @@ -41,7 +43,9 @@ test("connection failures classify as provider unavailable with the right retrya operation: "connect", run: () => { throw Object.assign(new Error(code), { code }); }, }); + assert.equal(classified.isErr(), true); + if (classified.isErr()) { assert.equal(classified.error.code, "PROVIDER_UNAVAILABLE"); assert.equal(classified.error.retryable, retryable); @@ -73,7 +77,9 @@ class FakeRuntime implements LocalAgentRuntime { async run(runInput: LocalAgentRunInput): Promise> { assert.equal(this.releaseInFlight, false, "a session turn must not overlap session release"); this.runCount += 1; + if (runInput.prompt === "wait") await new Promise((resolve) => this.pending.push(resolve)); + return Result.ok({ provider: this.provider, providerSessionId: "thread_1", @@ -86,9 +92,11 @@ class FakeRuntime implements LocalAgentRuntime { this.releaseInFlight = true; this.releaseStarted = true; this.releasedSessions.push(providerSessionId); + if (this.releaseBlocked) { await new Promise((resolve) => { this.releaseResolve = resolve; }); } + this.releaseInFlight = false; } @@ -106,6 +114,7 @@ class FakeRuntime implements LocalAgentRuntime { function countingInstancePool() { const runtime = new FakeRuntime(); const counts = { create: 0 }; + const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", @@ -114,19 +123,23 @@ function countingInstancePool() { createRuntime: async () => { counts.create += 1; await Promise.resolve(); + return Result.ok(runtime); }, }; + return { runtime, counts, driver, pool: new LocalAgentRuntimePool() }; } test("runtime creation is single-flight per runtime key", async (t) => { const { runtime, counts, driver, pool } = countingInstancePool(); t.after(() => pool.close()); + const [first, second] = await Promise.all([ pool.run(driver, context, input), pool.run(driver, { ...context, agentId: "agt_other" }, { ...input, prompt: "second" }), ]); + assert.equal(counts.create, 1, "runtime creation is single-flight per runtime key"); assert.equal(unwrap(first).finalResponse, "done:inspect"); assert.equal(unwrap(second).finalResponse, "done:second"); @@ -152,10 +165,12 @@ test("an active runtime is not evicted and shutdown closes it once without racin test("reusing a session waits for its in-flight idle release", async () => { let clock = 0; const sessionRuntime = new FakeRuntime(); + const sessionPool = new LocalAgentRuntimePool({ now: () => clock, sessionIdleTimeoutMs: 10, }); + const sessionDriver = fakeDriver(async () => Result.ok(sessionRuntime), NEVER_IDLE); await sessionPool.run(sessionDriver, context, input); clock = 11; @@ -178,20 +193,24 @@ test("reusing a session waits for its in-flight idle release", async () => { test("shutdown waits for an in-flight session release and rejects reuse during shutdown", async () => { const clock = 11; const shutdownReleaseRuntime = new FakeRuntime(); + const shutdownReleasePool = new LocalAgentRuntimePool({ now: () => clock, sessionIdleTimeoutMs: 10, }); + const shutdownReleaseDriver = fakeDriver(async () => Result.ok(shutdownReleaseRuntime), NEVER_IDLE); await shutdownReleasePool.run(shutdownReleaseDriver, context, input); shutdownReleaseRuntime.releaseBlocked = true; const shutdownRelease = shutdownReleasePool.evictIdle(30); await waitFor(() => shutdownReleaseRuntime.releaseStarted); + const shutdownReuse = shutdownReleasePool.run( shutdownReleaseDriver, context, { ...input, providerSessionId: "thread_1", prompt: "reuse during shutdown" }, ); + await new Promise((resolve) => setImmediate(resolve)); const shutdown = shutdownReleasePool.close(); await new Promise((resolve) => setImmediate(resolve)); @@ -200,10 +219,12 @@ test("shutdown waits for an in-flight session release and rejects reuse during s await shutdownRelease; const shutdownReuseResult = await shutdownReuse; assert.equal(shutdownReuseResult.isErr(), true); + if (shutdownReuseResult.isErr()) { assert.equal(shutdownReuseResult.error.code, "PROVIDER_UNAVAILABLE"); assert.equal(shutdownReuseResult.error.retryable, true); } + await shutdown; assert.equal(shutdownReleaseRuntime.closeCount, 1); }); @@ -215,6 +236,7 @@ class CleanupFailureRuntime extends FakeRuntime { override async run(): Promise> { this.alive = false; + return Result.err(new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", provider: this.provider, @@ -231,6 +253,7 @@ test("a runtime cleanup failure does not replace the provider error", async () = const cleanupDriver = fakeDriver(async () => Result.ok(cleanupRuntime)); const cleanupFailure = await cleanupPool.run(cleanupDriver, context, input); assert.equal(cleanupFailure.isErr(), true); + if (cleanupFailure.isErr()) assert.equal(cleanupFailure.error.message, "provider failed"); }); @@ -238,6 +261,7 @@ test("cleanup failure of a dead runtime does not hide successful recovery", asyn const deadRuntime = new FakeRuntime(); deadRuntime.alive = false; deadRuntime.close = async () => { throw new Error("dead runtime cleanup failed"); }; + const replacementRuntime = new FakeRuntime(); let attempts = 0; const recoveryPool = new LocalAgentRuntimePool(); @@ -252,19 +276,23 @@ test("cleanup failure of a dead runtime does not hide successful recovery", asyn test("shutdown does not discard an already completed provider turn", async () => { let closeDuringRun: Promise | undefined; let completedTurnPool!: LocalAgentRuntimePool; + class ClosingAfterTurnRuntime extends FakeRuntime { override async run(runInput: LocalAgentRunInput) { const result = await super.run(runInput); closeDuringRun = completedTurnPool.close(); + return result; } } + const completedTurnRuntime = new ClosingAfterTurnRuntime(); completedTurnPool = new LocalAgentRuntimePool(); const completedTurnDriver = fakeDriver(async () => Result.ok(completedTurnRuntime)); const completedTurn = await completedTurnPool.run(completedTurnDriver, context, input); assert.equal(completedTurn.isOk(), true, "shutdown does not discard an already completed provider turn"); + if (completedTurn.isOk()) assert.equal(completedTurn.value.finalResponse, "done:inspect"); await closeDuringRun; }); @@ -282,14 +310,17 @@ test("a runtime created during shutdown is closed and the closed pool rejects ne await pendingClose; const closedDuringCreation = await pendingRun; assert.equal(closedDuringCreation.isErr(), true); + if (closedDuringCreation.isErr()) { assert.equal(closedDuringCreation.error.code, "PROVIDER_UNAVAILABLE"); assert.equal(closedDuringCreation.error.retryable, true); } + assert.equal(raceRuntime.closeCount, 1, "a runtime created during shutdown is closed"); const afterClose = await racePool.run(raceDriver, context, input); assert.equal(afterClose.isErr(), true); + if (afterClose.isErr()) { assert.equal(afterClose.error.code, "PROVIDER_UNAVAILABLE"); assert.equal(afterClose.error.retryable, true); @@ -302,6 +333,7 @@ test("concurrent callers share one failing creation attempt and a later caller c let createAttempts = 0; const recoveryRuntime = new FakeRuntime(); const creationPool = new LocalAgentRuntimePool(); + const creationDriver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", @@ -309,9 +341,11 @@ test("concurrent callers share one failing creation attempt and a later caller c capabilities: TEST_CAPABILITIES, async createRuntime() { createAttempts += 1; + if (createAttempts === 1) { creationStarted.resolve(); await finishCreation.promise; + return Result.err(new AgentProviderUnavailableError({ code: "PROVIDER_UNAVAILABLE", provider: "codex", @@ -320,6 +354,7 @@ test("concurrent callers share one failing creation attempt and a later caller c message: "runtime creation failed", })); } + return Result.ok(recoveryRuntime); }, }; @@ -346,17 +381,21 @@ test("a failed session release returns the session to retained state for reuse a override releaseSession(): Promise { this.releaseAttempts += 1; + if (this.releaseAttempts === 1) throw new Error("session release failed"); + return Promise.resolve(); } } let releaseClock = 0; const releaseRuntime = new FlakyReleaseRuntime(); + const releasePool = new LocalAgentRuntimePool({ now: () => releaseClock, sessionIdleTimeoutMs: 10, }); + const releaseDriver = fakeDriver(async () => Result.ok(releaseRuntime), NEVER_IDLE); await releasePool.run(releaseDriver, context, input); @@ -378,6 +417,7 @@ test("a failed session release returns the session to retained state for reuse a test("runtime policy scope and authority decide which runs share a runtime", async () => { let creates = 0; const policyPool = new LocalAgentRuntimePool(); + const policyDriver = (providerInstanceId: string, runtimePolicy: LocalAgentDriver["runtimePolicy"]): LocalAgentDriver => ({ providerInstanceId, provider: "codex", @@ -385,6 +425,7 @@ test("runtime policy scope and authority decide which runs share a runtime", asy capabilities: TEST_CAPABILITIES, createRuntime: async () => { creates += 1; + return Result.ok(new FakeRuntime()); }, }); @@ -409,11 +450,13 @@ test("runtime policy scope and authority decide which runs share a runtime", asy scope: "agent", authority: "full_access_boundary", }); + const claudeContext = { ...context, agentId: "agt_claude", providerInstanceId: "claude-work", }; + await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "read_only" }, input); await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "allowed" }, input); await policyPool.run(claudeDriver, { ...claudeContext, writeMode: "full_access" }, input); @@ -432,13 +475,17 @@ test("runtime policy scope and authority decide which runs share a runtime", asy function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; + return result.value; } + async function waitFor(check: () => boolean): Promise { const deadline = Date.now() + 2_000; + while (!check() && Date.now() < deadline) { await new Promise((resolve) => setImmediate(resolve)); } + assert.equal(check(), true, "condition did not become true before timeout"); } @@ -447,9 +494,11 @@ function deferred(): { resolve(value?: T): void; } { let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); + return { promise, resolve }; } diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 73c758d30..afd76085f 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -54,6 +54,7 @@ export interface LocalAgentRuntimeContext { } export type LocalAgentRuntimeScope = "instance" | "workspace" | "agent"; + export type LocalAgentRuntimeAuthority = "none" | "write_mode" | "full_access_boundary"; export interface LocalAgentRuntimePolicy { diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index c2e83bd04..1c3092517 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -15,6 +15,7 @@ const stores: LocalAgentStore[] = []; try { const store = new LocalAgentStore(root); stores.push(store); + const created = store.create({ workspaceId: "ws_1", workspaceRoot: join(root, "project"), @@ -39,8 +40,10 @@ try { `${created.id.slice(4)}-0000-4000-8000-000000000000`, "bbbbbbbb-0000-4000-8000-000000000000", ]; + const uuidMock = mock.method(crypto, "randomUUID", () => uuids.shift() as `${string}-${string}-${string}-${string}-${string}`); syncBuiltinESMExports(); + try { const redrawn = store.create({ workspaceId: "ws_collision", @@ -51,6 +54,7 @@ try { model: "gpt-5.4", effort: "high", }); + assert.equal(redrawn.id, "agt_bbbbbbbb"); assert.notEqual(redrawn.id, created.id); } finally { @@ -67,6 +71,7 @@ try { driver: "codex", writeMode: "read_only", }); + assert.equal(ownedDispatch.id, "workflow-dispatch-store"); assert.equal(ownedDispatch.writeMode, "read_only"); assert.equal(store.create({ @@ -123,6 +128,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot model: updated.model, effort: updated.effort, }); + assert.equal(begun.agent.status, "running"); assert.equal(begun.turn.agentId, created.id); assert.equal(begun.turn.prompt, "Review the current changes."); @@ -144,6 +150,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot response: "No issues found.", providerSessionId: "thread_456", }); + assert.equal(completed.status, "idle"); assert.equal(completed.latestResponse, "No issues found."); assert.equal(completed.providerSessionId, "thread_456"); @@ -159,6 +166,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot model: completed.model, effort: completed.effort, }); + store.finishTurn(created.id, failing.turn.id, { status: "failed", error: "Provider disconnected.", @@ -190,6 +198,7 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot const otherStore = new LocalAgentStore(root); stores.push(otherStore); + const createdFromOtherStore = otherStore.create({ workspaceId: "ws_1", workspaceRoot: join(root, "project"), @@ -229,14 +238,17 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot updated_at text not null ); `); + const migration = legacy.prepare( "insert into devspace_schema_migrations (version, name, applied_at) values (?, ?, ?)", ); + // Leave migration 3 unapplied to exercise an interrupted legacy upgrade: // it adds an empty effort column before migration 6 copies thinking values. for (const [version, name] of [[1, "workspace-state"], [2, "oauth-state"], [4, "workspace-conversation-bindings"]] as const) { migration.run(version, name, "2026-08-01T00:00:00.000Z"); } + legacy.prepare(` insert into local_agent_sessions ( id, workspace_root, profile_name, provider, thinking, status, error, created_at, updated_at @@ -262,28 +274,34 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot assert.equal(legacyRecord?.providerInstanceId, "cursor"); assert.equal(legacyRecord?.driver, "acp"); const inspect = new Database(databasePath(legacyStateDir), { readonly: true }); + const driverColumn = (inspect.prepare("pragma table_info(local_agent_sessions)").all() as Array<{ name: string; notnull: number }>) .find((column) => column.name === "driver"); + inspect.close(); assert.equal(driverColumn?.notnull, 1, "migrated driver column rejects NULL like a fresh schema"); assert.equal(legacyRecord?.errorCode, undefined); assert.equal(legacyRecord?.errorRetryable, undefined); + const upgradedRecord = upgradedStore.update("agt_legacy", { errorCode: "DAEMON_TIMEOUT", errorRetryable: true, }); + assert.equal(upgradedRecord.errorCode, "DAEMON_TIMEOUT"); assert.equal(upgradedRecord.errorRetryable, true); const reloadedRecord = upgradedStore.getById("agt_legacy"); assert.equal(reloadedRecord?.error, "old error"); assert.equal(reloadedRecord?.errorCode, "DAEMON_TIMEOUT"); assert.equal(reloadedRecord?.errorRetryable, true); + const legacyTurn = upgradedStore.beginTurn("agt_legacy", { turnId: "trn_test4", prompt: "Continue after upgrade.", model: reloadedRecord?.model, effort: reloadedRecord?.effort, }); + assert.equal(legacyTurn.turn.status, "running"); assert.equal(legacyTurn.turn.turnId, "trn_test4"); assert.equal(upgradedStore.getById("agt_legacy")?.latestTurnId, "trn_test4"); @@ -291,5 +309,6 @@ assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "ot for (const store of stores) { store.close(); } + rmSync(root, { recursive: true, force: true }); } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 2ce9d6897..9129c405b 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -9,6 +9,7 @@ import type { LocalAgentDriverKind } from "./local-agent-provider.js"; import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; + export type LocalAgentTurnStatus = "running" | "completed" | "failed" | "stopped"; export interface LocalAgentRecord { @@ -161,6 +162,7 @@ export class LocalAgentStore { list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { let rows: LocalAgentRow[]; + if (scope.workspaceId && scope.workspaceRoot) { rows = this.database.sqlite .prepare( @@ -208,6 +210,7 @@ export class LocalAgentStore { let id = input.id ?? generate(); validateAgentId(id); let existing = this.getRowById(id); + // Only caller-supplied ids are idempotent; a generated id that collides must never alias another agent. if (!input.id) { while (existing) { @@ -215,12 +218,16 @@ export class LocalAgentStore { existing = this.getRowById(id); } } + if (existing) { const record = rowToLocalAgentRecord(existing); validateDispatchIdentity(record, input, existing.dispatch_signature); + return { record, created: false }; } + const now = new Date().toISOString(); + const record: LocalAgentRecord = { id, workspaceId: input.workspaceId, @@ -280,6 +287,7 @@ export class LocalAgentStore { getById(id: string): LocalAgentRecord | undefined { const exact = this.getRowById(id); + return exact ? rowToLocalAgentRecord(exact) : undefined; } @@ -307,6 +315,7 @@ export class LocalAgentStore { update(id: string, patch: Partial>): LocalAgentRecord { const current = this.getById(id); + if (!current) throw new Error(`Unknown subagent id: ${id}`); const updated: LocalAgentRecord = { @@ -384,10 +393,13 @@ export class LocalAgentStore { beginTurn(agentId: string, input: BeginLocalAgentTurnInput): BegunLocalAgentTurn { return this.database.sqlite.transaction(() => { const current = this.getById(agentId); + if (!current) throw new Error(`Unknown subagent id: ${agentId}`); + if (current.status === "running") { throw new Error(`Subagent ${agentId} already has a running turn.`); } + const agent = this.update(agentId, { status: "running", model: input.model, @@ -400,6 +412,7 @@ export class LocalAgentStore { errorDetail: undefined, latestTurnId: input.turnId, }); + const result = this.database.sqlite .prepare( `insert into local_agent_turns ( @@ -411,6 +424,7 @@ export class LocalAgentStore { ) values (?, ?, ?, 'running', ?)`, ) .run(input.turnId, agentId, input.prompt, agent.updatedAt); + this.database.sqlite .prepare( `delete from local_agent_turn_events @@ -421,7 +435,9 @@ export class LocalAgentStore { ) .run(agentId, TURNS_WITH_EVENTS_PER_AGENT); const turn = this.getTurnById(Number(result.lastInsertRowid)); + if (!turn) throw new Error(`Unable to load the new turn for subagent ${agentId}.`); + return { agent, turn }; }).immediate(); } @@ -440,13 +456,17 @@ export class LocalAgentStore { ): LocalAgentRecord { return this.database.sqlite.transaction(() => { const turn = this.getTurnById(turnId); + if (!turn || turn.agentId !== agentId) { throw new Error(`Unknown turn ${turnId} for subagent ${agentId}.`); } + if (turn.status !== "running") { throw new Error(`Turn ${turnId} for subagent ${agentId} is already ${turn.status}.`); } + const currentAgent = this.getById(agentId); + if (!currentAgent) throw new Error(`Unknown subagent id: ${agentId}`); const completedAt = new Date().toISOString(); @@ -487,6 +507,7 @@ export class LocalAgentStore { errorDetail: undefined, }); } + return this.update(agentId, { status: completion.status === "failed" ? "error" : "stopped", latestResponse: undefined, @@ -510,6 +531,7 @@ export class LocalAgentStore { const row = this.database.sqlite .prepare("select * from local_agent_turns where id = ? limit 1") .get(turnId) as LocalAgentTurnRow | undefined; + return row ? rowToLocalAgentTurnRecord(row) : undefined; } @@ -523,6 +545,7 @@ export class LocalAgentStore { const row = this.database.sqlite .prepare("select * from local_agent_turns where turn_id = ? limit 1") .get(turnId) as LocalAgentTurnRow | undefined; + return row ? rowToLocalAgentTurnRecord(row) : undefined; } @@ -558,6 +581,7 @@ export class LocalAgentStore { order by seq asc`, ) .all(turn.id, afterSeq) as LocalAgentTurnEventRow[]; + return rows.map((row) => ({ ...(JSON.parse(row.data) as LocalAgentTurnEventBody), turnId: turn.turnId, @@ -577,6 +601,7 @@ export class LocalAgentStore { const row = this.database.sqlite .prepare("select * from local_agent_turns where agent_id = ? order by id desc limit 1") .get(agentId) as LocalAgentTurnRow | undefined; + return row ? rowToLocalAgentTurnRecord(row) : undefined; } @@ -590,6 +615,7 @@ export class LocalAgentStore { const rows = this.database.sqlite .prepare("select * from local_agent_turns where agent_id = ? order by id asc") .all(agentId) as LocalAgentTurnRow[]; + return rows.map(rowToLocalAgentTurnRecord); } @@ -604,6 +630,7 @@ export class LocalAgentStore { where status = 'running'`, ) .run(message, now); + const result = this.database.sqlite .prepare( `update local_agent_sessions @@ -611,6 +638,7 @@ export class LocalAgentStore { where status in ('starting', 'running')`, ) .run(message, now); + return Number(result.changes); }).immediate(); } @@ -678,12 +706,15 @@ function readTurnStatus(status: string): LocalAgentTurnStatus { if (status === "running" || status === "completed" || status === "failed" || status === "stopped") { return status; } + throw new Error(`Invalid stored local agent turn status: ${status}`); } function readOptionalBoolean(value: string | null): boolean | undefined { if (value === "true") return true; + if (value === "false") return false; + return undefined; } @@ -692,6 +723,7 @@ function storeResult(operation: string, run: () => T): BetterResult) { if (existing[key] !== expected[key]) { throw new LocalAgentDispatchConflictError( @@ -733,12 +767,15 @@ function validateDispatchIdentity( ); } } + if (storedSignature !== (input.dispatchSignature ?? null)) { throw new LocalAgentDispatchConflictError( `Subagent id ${existing.id} is already owned by a different dispatch.`, ); } + if (storedSignature !== null) return; + if ( existing.model !== input.model || existing.effort !== input.effort diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index b0bd03d5c..be257c4f1 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -112,6 +112,7 @@ test("model and effort come from the run override, then the profile, then the pr { id: "codex", driver: "codex", enabled: true, model: "gpt-default", effort: "medium" }, { id: "opencode", driver: "opencode", enabled: true }, ] as const; + for (const [name, model, effort, expected] of [ ["reviewer", undefined, undefined, { kind: "profile", provider: "codex", model: "gpt-5-codex", effort: "high" }], ["reviewer", "gpt-run", "xhigh", { kind: "profile", provider: "codex", model: "gpt-run", effort: "xhigh" }], @@ -135,6 +136,7 @@ test("a named provider instance resolves as a provider target", () => { enabled: true, model: "gpt-work", }] as const; + const target = resolveLocalAgentTarget("codex-work", profiles, undefined, undefined, providerInstances); assert.equal(target?.kind, "provider"); assert.equal(target?.provider, "codex-work"); diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index ebfb68dac..d00e4842e 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -42,6 +42,7 @@ export type LocalAgentTarget = const WRITE_MODES: readonly LocalAgentWriteMode[] = ["read_only", "allowed", "full_access"]; const VALUE_OPTIONS = ["--model", "--effort", "--write-mode", "--request-id", "--branch", "--timeout"] as const; const RUN_ONLY_OPTIONS: readonly string[] = ["--request-id", "--branch"]; + type ValueOption = typeof VALUE_OPTIONS[number]; export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs { @@ -58,6 +59,7 @@ export function parseLocalAgentContinueArgs(args: string[]): ParsedLocalAgentCon 'Usage: devspace agents continue [--model ] [--effort ] [--write-mode ] [--wait [--timeout ]] ""', false, ); + return { agentId: target, ...parsed }; } @@ -67,6 +69,7 @@ function parseAgentPromptArgs( isRun: boolean, ): ParsedLocalAgentRunArgs { const [target, ...rest] = args; + if (!target) { throw new Error(usage); } @@ -76,26 +79,34 @@ function parseAgentPromptArgs( let worktree = false; const promptParts: string[] = []; let optionsEnded = false; + for (let index = 0; index < rest.length; index += 1) { const part = rest[index]!; + if (!optionsEnded && part === "--") { optionsEnded = true; continue; } + if (optionsEnded || !part.startsWith("-")) { promptParts.push(part); continue; } + if (part === "--wait") { wait = true; continue; } + if (isRun && part === "--worktree") { worktree = true; continue; } + const option = VALUE_OPTIONS.find((name) => part === name || part.startsWith(`${name}=`)); + if (!option || (!isRun && RUN_ONLY_OPTIONS.includes(option))) throw unknownOptionError(part); + if (part === option) { values[option] = parseOptionValue(rest[index + 1], option); index += 1; @@ -105,10 +116,13 @@ function parseAgentPromptArgs( } const prompt = promptParts.join(" ").trim(); + if (!prompt) { throw new Error(usage); } + if (values["--timeout"] !== undefined && !wait) throw new Error("--timeout requires --wait."); + if (values["--branch"] !== undefined && !worktree) throw new Error("--branch requires --worktree."); return { @@ -127,7 +141,9 @@ function parseAgentPromptArgs( function parseWriteMode(value: string | undefined): LocalAgentWriteMode | undefined { if (value === undefined) return undefined; const mode = WRITE_MODES.find((candidate) => candidate === value); + if (!mode) throw new Error(`Invalid --write-mode ${value}. Use ${WRITE_MODES.join(", ")}.`); + return mode; } @@ -135,17 +151,23 @@ export function parseAgentWaitTimeout(value: string | undefined): number { if (!value || !/^\d+$/.test(value)) { throw new Error("Agent wait timeout must be a non-negative integer number of seconds."); } + const timeoutMs = Number(value) * 1_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs > 2_147_483_647) { throw new Error("Agent wait timeout is too large."); } + return timeoutMs; } function parseOptionValue(value: string | undefined, option: string): string { const trimmed = value?.trim(); + if (!trimmed) throw new Error(`Missing value for ${option}.`); + if (trimmed.startsWith("-")) throw unknownOptionError(trimmed); + return trimmed; } @@ -161,8 +183,10 @@ export function resolveLocalAgentTarget( providerConfigs: readonly SubagentProviderConfig[] = [], ): LocalAgentTarget | undefined { const profile = profiles.find((candidate) => candidate.name === target); + if (profile) { const providerConfig = providerConfigs.find((entry) => entry.id === profile.provider); + return { kind: "profile", name: profile.name, @@ -174,6 +198,7 @@ export function resolveLocalAgentTarget( } const providerConfig = providerConfigs.find((entry) => entry.id === target); + if (providerConfig) { return { kind: "provider", diff --git a/src/local-agent-worktree.test.ts b/src/local-agent-worktree.test.ts index 2c58ee72f..c29dfe0b0 100644 --- a/src/local-agent-worktree.test.ts +++ b/src/local-agent-worktree.test.ts @@ -23,6 +23,7 @@ class FakeRuntime implements LocalAgentRuntime { constructor(private readonly workspaceRoot: string, private readonly runRoots: string[]) {} async run(_input: LocalAgentRunInput): Promise> { this.runRoots.push(this.workspaceRoot); + return Result.ok({ provider: "codex", finalResponse: "done", providerSessionId: "session", items: [] }); } async releaseSession(): Promise {} @@ -34,6 +35,7 @@ class FakeRuntime implements LocalAgentRuntime { function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; + return result.value; } @@ -161,6 +163,7 @@ function fixture(t: TestContext) { /** The workspace root each run was given, to prove where the provider actually works. */ const runRoots: string[] = []; + const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", @@ -176,6 +179,7 @@ function fixture(t: TestContext) { }; const store = new LocalAgentStore(join(temp, "state")); + const manager = new LocalAgentManager({ store, drivers: [driver], @@ -184,12 +188,14 @@ function fixture(t: TestContext) { worktreeRoot, subagents: { enabled: true, instructions: "on-demand", providers: [{ id: "codex", driver: "codex", enabled: true }] }, }); + t.after(async () => { await manager.close(); rmSync(temp, { recursive: true, force: true }); }); const scope = { workspaceRoot: source }; + const start = (extra: { agentId?: string; worktree?: { branch?: string }; workspaceRoot?: string }) => manager.start({ target: "codex", prompt: "go", workspaceRoot: source, writeMode: "full_access", ...extra }); diff --git a/src/local-agent-worktree.ts b/src/local-agent-worktree.ts index 1d6af7ffa..7d50f6e57 100644 --- a/src/local-agent-worktree.ts +++ b/src/local-agent-worktree.ts @@ -27,18 +27,21 @@ export async function createAgentWorktree(input: { Result.err(new AgentScopeError({ code, operation: "start", retryable: false, cause, message })); const eligibility = await getGitEligibility(input.workspaceRoot).catch(() => undefined); + if (!eligibility?.ok) { return failure( "WORKTREE_NOT_GIT_REPOSITORY", `--worktree needs a git repository, but ${input.workspaceRoot} is not inside one. Run from a git checkout, or drop --worktree.`, ); } + if (!eligibility.hasHead) { return failure("WORKTREE_CREATE_FAILED", "--worktree needs at least one commit to branch from. Create an initial commit first."); } // Ref names cannot contain ':', which agent ids may. const branch = input.branch ?? `devspace/${input.agentId.replaceAll(":", "-")}`; + try { // The checkout is already authorized by the manager; it is the only root the worktree may come from. const worktree = await createManagedWorktree({ @@ -46,9 +49,11 @@ export async function createAgentWorktree(input: { branch, config: { allowedRoots: [input.workspaceRoot], worktreeRoot: input.worktreeRoot }, }); + return Result.ok({ path: worktree.path, branch }); } catch (cause) { const message = cause instanceof GitWorktreeError || cause instanceof Error ? cause.message : String(cause); + return failure("WORKTREE_CREATE_FAILED", `Could not create a worktree on branch ${branch}. ${message}`, cause); } } @@ -56,6 +61,7 @@ export async function createAgentWorktree(input: { /** A worktree is the user's to remove; a later turn on a removed one fails clearly instead of running elsewhere. */ export function missingWorktreeError(record: LocalAgentRecord, operation: string): AgentScopeError | undefined { if (!record.worktreePath || existsSync(record.worktreePath)) return undefined; + return new AgentScopeError({ code: "WORKTREE_MISSING", agentId: record.id, diff --git a/src/logger.test.ts b/src/logger.test.ts index 123b26b38..e866f7837 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -6,6 +6,7 @@ import { requestIp, type TrustProxyMode } from "./logger.js"; async function observedIps(trustProxy: TrustProxyMode): Promise<{ reqIp: string; logged: string }> { const app = express(); + if (trustProxy) app.set("trust proxy", trustProxy); app.get("/", (req, res) => { res.json({ reqIp: req.ip, logged: requestIp(req, trustProxy) }); @@ -13,8 +14,10 @@ async function observedIps(trustProxy: TrustProxyMode): Promise<{ reqIp: string; const server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); + try { const { port } = server.address() as AddressInfo; + // The client supplies the leftmost hop and a CF header; the local proxy appends the real peer. const response = await fetch(`http://127.0.0.1:${port}/`, { headers: { @@ -22,6 +25,7 @@ async function observedIps(trustProxy: TrustProxyMode): Promise<{ reqIp: string; "x-forwarded-for": "198.51.100.66, 203.0.113.7", }, }); + return await response.json() as { reqIp: string; logged: string }; } finally { server.close(); diff --git a/src/logger.ts b/src/logger.ts index 7c7d160db..93fb8ceb0 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,9 @@ import type { Request } from "express"; export type LogLevel = "silent" | "error" | "warn" | "info" | "debug"; + export type LogFormat = "json" | "pretty"; + export type TrustProxyMode = boolean | "loopback"; export interface LoggingConfig { @@ -44,6 +46,7 @@ export function logEvent( }; const line = config.format === "pretty" ? formatPretty(entry) : JSON.stringify(entry); + if (level === "error") { console.error(line); } else if (level === "warn") { @@ -58,9 +61,11 @@ export function requestIp(req: Request, trustProxy: TrustProxyMode): string | un // the raw headers here would return client-supplied values. if (trustProxy === true) { const cfConnectingIp = firstHeaderValue(req.header("cf-connecting-ip")); + if (cfConnectingIp) return cfConnectingIp; const forwardedFor = firstHeaderValue(req.header("x-forwarded-for")); + if (forwardedFor) return forwardedFor; } @@ -73,6 +78,7 @@ export function requestPath(req: Request): string { export function commandPreview(command: string): string { const normalized = command.replace(/\s+/g, " ").trim(); + return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized; } @@ -84,6 +90,7 @@ function formatPretty(entry: LogFields): string { const ts = String(entry.ts); const level = String(entry.level).toUpperCase(); const event = String(entry.event); + const rest = Object.entries(entry) .filter(([key, value]) => !["ts", "level", "event"].includes(key) && value !== undefined) .map(([key, value]) => `${key}=${formatPrettyValue(value)}`) @@ -94,5 +101,6 @@ function formatPretty(entry: LogFields): string { function formatPrettyValue(value: unknown): string { if (typeof value === "string") return JSON.stringify(value); + return JSON.stringify(value); } diff --git a/src/mcp-modern-server.test.ts b/src/mcp-modern-server.test.ts index 97a7d44e1..06c2671c9 100644 --- a/src/mcp-modern-server.test.ts +++ b/src/mcp-modern-server.test.ts @@ -14,6 +14,7 @@ test("modern registration adapter preserves tools and request metadata", async ( name: "devspace-modern-test", version: "1.0.0", }); + registerAppTool( adapter.registrationTarget, "echo_scope", @@ -29,15 +30,19 @@ test("modern registration adapter preserves tools and request metadata", async ( }], }), ); + return adapter.server; }, { legacy: "reject" }); + t.after(async () => handler.close()); const listed = await handler.fetch(modernRequest("tools/list", {})); assert.equal(listed.status, 200); + const listBody = await listed.json() as { result?: { tools?: Array<{ name?: string }> }; }; + assert.ok(listBody.result?.tools?.some((tool) => tool.name === "echo_scope")); const called = await handler.fetch(modernRequest("tools/call", { @@ -45,10 +50,13 @@ test("modern registration adapter preserves tools and request metadata", async ( arguments: { value: "ok" }, _meta: { "openai/session": "modern-chat" }, })); + assert.equal(called.status, 200, await called.clone().text()); + const callBody = await called.json() as { result?: { content?: Array<{ text?: string }> }; }; + assert.equal(callBody.result?.content?.[0]?.text, "ok:modern-chat"); }); @@ -58,6 +66,7 @@ test("modern registration adapter preserves progress notifications", async (t) = name: "devspace-modern-test", version: "1.0.0", }); + registerAppTool( adapter.registrationTarget, "progress_echo", @@ -74,11 +83,14 @@ test("modern registration adapter preserves progress notifications", async (t) = total: 1, }, }); + return { content: [{ type: "text", text: "done" }] }; }, ); + return adapter.server; }, { legacy: "reject" }); + t.after(async () => handler.close()); const response = await handler.fetch(modernRequest("tools/call", { @@ -89,10 +101,12 @@ test("modern registration adapter preserves progress notifications", async (t) = assert.equal(response.status, 200, await response.clone().text()); assert.match(response.headers.get("content-type") ?? "", /text\/event-stream/); + const messages = (await response.text()) .split(/\r?\n/) .filter((line) => line.startsWith("data: ")) .map((line) => JSON.parse(line.slice(6)) as Record); + assert.ok(messages.some((message) => message.method === "notifications/progress")); assert.match(JSON.stringify(messages.at(-1)), /done/); }); @@ -103,6 +117,7 @@ test("modern registration adapter preserves resources", async (t) => { name: "devspace-modern-test", version: "1.0.0", }); + registerAppResource( adapter.registrationTarget, "Test resource", @@ -116,8 +131,10 @@ test("modern registration adapter preserves resources", async (t) => { }], }), ); + return adapter.server; }, { legacy: "reject" }); + t.after(async () => handler.close()); const response = await handler.fetch(modernRequest("resources/read", { @@ -131,6 +148,7 @@ test("modern registration adapter preserves resources", async (t) => { test("compiled registration surface reuses static tool and resource definitions", async (t) => { let registrationBuilds = 0; + const bindRegistrationSurface = compileMcpRegistrationSurface((target) => { registrationBuilds += 1; registerAppTool( @@ -158,6 +176,7 @@ test("compiled registration surface reuses static tool and resource definitions" }), ); }); + assert.equal(registrationBuilds, 1); const handler = createMcpHandler(() => { @@ -165,9 +184,12 @@ test("compiled registration surface reuses static tool and resource definitions" name: "devspace-modern-test", version: "1.0.0", }); + bindRegistrationSurface(adapter.registrationTarget); + return adapter.server; }, { legacy: "reject" }); + t.after(async () => handler.close()); const firstList = await handler.fetch(modernRequest("tools/list", {})); @@ -179,6 +201,7 @@ test("compiled registration surface reuses static tool and resource definitions" const resource = await handler.fetch(modernRequest("resources/read", { uri: "ui://devspace/cached.html", })); + assert.equal(resource.status, 200, await resource.clone().text()); assert.match(await resource.text(), /cached-resource/); assert.equal(registrationBuilds, 1); @@ -190,6 +213,7 @@ function modernRequest(method: string, params: Record): Request : typeof params.uri === "string" ? params.uri : undefined; + return new Request("https://example.test/mcp", { method: "POST", headers: { diff --git a/src/mcp-modern-server.ts b/src/mcp-modern-server.ts index 9b5cabeb1..6ac5d546a 100644 --- a/src/mcp-modern-server.ts +++ b/src/mcp-modern-server.ts @@ -33,6 +33,7 @@ export function createModernMcpServerAdapter( const server = new McpServer(serverInfo, options); const registerModernTool = server.registerTool.bind(server) as unknown as ModernRegisterTool; const registerModernResource = server.registerResource.bind(server) as unknown as ModernRegisterResource; + const registrationTarget: McpRegistrationTarget = { registerTool: (( name: string, @@ -45,10 +46,12 @@ export function createModernMcpServerAdapter( )) as LegacyMcpServer["registerTool"], registerResource: ((...args: unknown[]) => { const callback = args.at(-1) as (...callbackArgs: unknown[]) => unknown; + return registerModernResource( ...args.slice(0, -1), (...callbackArgs: unknown[]) => { const context = callbackArgs.at(-1) as ServerContext; + return callback( ...callbackArgs.slice(0, -1), legacyToolHandlerExtra(context), @@ -68,6 +71,7 @@ export function compileMcpRegistrationSurface( registerSurface: (target: McpRegistrationTarget) => void, ): (target: McpRegistrationTarget) => void { const registrations: RegistrationReplay[] = []; + const recordingTarget: McpRegistrationTarget = { registerTool: ((...args: unknown[]) => { registrations.push((target) => { @@ -83,6 +87,7 @@ export function compileMcpRegistrationSurface( registerSurface(recordingTarget); const compiled = Object.freeze(registrations.slice()); + return (target) => { for (const replay of compiled) replay(target); }; @@ -90,6 +95,7 @@ export function compileMcpRegistrationSurface( export function modernMcpAdapterErrorLogFields(error: Error): Record { const cause = error.cause; + return { error: error.message, errorName: error.name, diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index a4a779eed..ccccf4a1f 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -37,7 +37,9 @@ function randomToken(): string { function safeEquals(a: string, b: string): boolean { const left = Buffer.from(a); const right = Buffer.from(b); + if (left.byteLength !== right.byteLength) return false; + return timingSafeEqual(left, right); } @@ -59,9 +61,11 @@ function formHtml(params: { }): string { const scopeText = params.scopes.length > 0 ? params.scopes.join(" ") : "devspace"; const resourceText = params.resource?.href ?? "DevSpace MCP endpoint"; + const error = params.error ? `

${htmlEscape(params.error)}

` : ""; + const hiddenFields = Object.entries(params.fields) .filter((entry): entry is [string, string] => entry[1] !== undefined) .map(([name, value]) => ` `) @@ -141,6 +145,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { if (!params.resource || !this.isResourceAllowed(params.resource)) { throw new InvalidRequestError("Invalid or missing OAuth resource"); } + if (!requestedScopesAllowed(params.scopes ?? [], this.config.scopes)) { throw new InvalidRequestError("Requested scope is not supported"); } @@ -155,10 +160,12 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { fields: authorizationFormFields(client, params), }), ); + return; } const providedToken = String(res.req.body?.owner_token ?? ""); + if (!safeEquals(providedToken, this.config.ownerToken)) { res.status(401).setHeader("Content-Type", "text/html; charset=utf-8"); res.send( @@ -170,6 +177,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { fields: authorizationFormFields(client, params), }), ); + return; } @@ -182,6 +190,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { const redirectUrl = new URL(params.redirectUri); redirectUrl.searchParams.set("code", code); + if (params.state !== undefined) redirectUrl.searchParams.set("state", params.state); res.redirect(302, redirectUrl.href); } @@ -191,6 +200,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { authorizationCode: string, ): Promise { const record = this.validCodeRecord(client, authorizationCode); + return record.params.codeChallenge; } @@ -202,14 +212,17 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { resource?: URL, ): Promise { const record = this.validCodeRecord(client, authorizationCode); + if (redirectUri && redirectUri !== record.params.redirectUri) { throw new InvalidGrantError("redirect_uri does not match the authorization request"); } + if (resource && (!record.params.resource || !sameResource(resource, record.params.resource))) { throw new InvalidGrantError("Invalid resource"); } this.codes.delete(authorizationCode); + return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, record.params.resource); } @@ -221,18 +234,23 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { ): Promise { const refreshTokenHash = hashToken(refreshToken); const record = this.oauthStore.getRefreshToken(refreshTokenHash); + if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { throw new InvalidGrantError("Invalid refresh token"); } + const recordedResource = record.resource ? new URL(record.resource) : undefined; + if (!recordedResource || !this.isResourceAllowed(recordedResource)) { throw new InvalidGrantError("Invalid resource"); } + if (resource && !sameResource(resource, recordedResource)) { throw new InvalidGrantError("Invalid resource"); } const requestedScopes = scopes ?? record.scopes; + if (!requestedScopes.every((scope) => record.scopes.includes(scope))) { throw new AccessDeniedError("Refresh token cannot grant requested scopes"); } @@ -247,6 +265,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { async verifyAccessToken(token: string): Promise { const record = this.oauthStore.getAccessToken(hashToken(token)); + if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) { throw new InvalidTokenError("Invalid or expired access token"); } @@ -282,9 +301,11 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { authorizationCode: string, ): AuthorizationCodeRecord { const record = this.codes.get(authorizationCode); + if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) { throw new InvalidGrantError("Invalid authorization code"); } + return record; } @@ -319,6 +340,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { }, consumedRefreshTokenHash, ); + if (!saved) { throw new InvalidGrantError("Invalid refresh token"); } diff --git a/src/oauth-refresh-grace.test.ts b/src/oauth-refresh-grace.test.ts index 440158c83..7345fe234 100644 --- a/src/oauth-refresh-grace.test.ts +++ b/src/oauth-refresh-grace.test.ts @@ -17,6 +17,7 @@ const baseConfig: OAuthConfig = { allowedResourceUrls: ["https://tunnel.example.com/v1/mcp/tunnel_123"], allowedRedirectHosts: ["chatgpt.com"], }; + const serverUrl = new URL("https://agent.example.com/mcp"); const resource = new URL(baseConfig.allowedResourceUrls[0]!); const otherResource = new URL("https://other.example.com/mcp"); @@ -45,14 +46,17 @@ async function fixture(t: TestContext): Promise { Date.now = realDateNow; await rm(root, { recursive: true, force: true }); }); + return root; } async function testDefaultAndZeroGrace(root: string): Promise { const defaultDir = join(root, "default"); const zeroDir = join(root, "zero"); + for (const [stateDir, config] of [[defaultDir, baseConfig], [zeroDir, { ...baseConfig, refreshTokenGraceSeconds: 0 }]] as const) { const provider = new SingleUserOAuthProvider(config, serverUrl, stateDir); + try { const { client, tokens } = await issueInitialTokens(provider); const rotated = await provider.exchangeRefreshToken(client, tokens.refresh_token); @@ -69,6 +73,7 @@ async function testConcurrentReuseAndRestartPersistence(root: string): Promise { const baseSeconds = 2_000_000_000; setNowSeconds(baseSeconds); const provider = new SingleUserOAuthProvider(config, serverUrl, stateDir); + try { const { client, tokens } = await issueInitialTokens(provider); const oldExpiry = baseSeconds + 30; @@ -152,6 +161,7 @@ async function testGraceExpiryAndOriginalTtl(root: string): Promise { const shortTtlConfig = { ...baseConfig, refreshTokenGraceSeconds: 10, refreshTokenTtlSeconds: 6 }; setNowSeconds(baseSeconds); const shortTtlProvider = new SingleUserOAuthProvider(shortTtlConfig, serverUrl, shortTtlDir); + try { const { client, tokens } = await issueInitialTokens(shortTtlProvider); assert.equal(readRefreshExpiry(shortTtlDir, tokens.refresh_token), baseSeconds + 6); @@ -176,6 +186,7 @@ async function testGraceDoesNotRelaxRefreshPolicy(root: string): Promise { const stateDir = join(root, "policy"); const config = { ...baseConfig, refreshTokenGraceSeconds: 600 }; const provider = new SingleUserOAuthProvider(config, serverUrl, stateDir); + try { const { client, tokens } = await issueInitialTokens(provider); const otherClient = await provider.clientsStore.registerClient?.({ redirect_uris: [redirectUri] }); @@ -198,6 +209,7 @@ async function issueInitialTokens(provider: SingleUserOAuthProvider) { redirect_uris: [redirectUri], client_name: "Test client", }); + assert.ok(client); const code = `test-code-${Math.random()}`; provider["codes"].set(code, { @@ -212,11 +224,13 @@ async function issueInitialTokens(provider: SingleUserOAuthProvider) { }); const tokens = await provider.exchangeAuthorizationCode(client, code, undefined, redirectUri, resource); assert.ok(tokens.refresh_token); + return { client, tokens: { ...tokens, refresh_token: tokens.refresh_token } }; } function readRefreshExpiry(stateDir: string, token: string): number | undefined { const store = new SqliteOAuthStore(stateDir); + try { return store.getRefreshToken(hashToken(token))?.expiresAt; } finally { diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index de2ffa5d9..66fb4372f 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -17,6 +17,7 @@ const oauthConfig = { allowedResourceUrls: ["https://tunnel.example.com/v1/mcp/tunnel_123"], allowedRedirectHosts: ["chatgpt.com"], }; + const mcpUrl = new URL("https://agent.example.com/mcp"); const tunnelUrl = new URL(oauthConfig.allowedResourceUrls[0]!); const redirectUri = "https://chatgpt.com/connector_platform_oauth_redirect"; @@ -48,11 +49,13 @@ test("refresh tokens without a resource are rejected when no alias is configured async function stateDirFixture(t: TestContext, name: string): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-oauth-test-")); t.after(() => rm(root, { recursive: true, force: true })); + return join(root, name); } async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); + try { assert.equal(database.sqlite.pragma("journal_mode", { simple: true }), "wal"); assert.equal(database.sqlite.pragma("synchronous", { simple: true }), 1); @@ -62,6 +65,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { const migrations = database.sqlite .prepare("select version, name from devspace_schema_migrations order by version") .all(); + // Every migration applies once, in order, with no gaps. assert.deepEqual( migrations.map((migration) => (migration as { version: number }).version), @@ -83,6 +87,7 @@ function testPersistenceAndTokenHashing(stateDir: string): void { const refreshToken = "refresh-token-example"; const firstStore = new SqliteOAuthStore(stateDir); const firstClients = new SqliteOAuthClientsStore(firstStore, oauthConfig.allowedRedirectHosts); + const client = firstClients.registerClient({ redirect_uris: [redirectUri], client_name: "ChatGPT", @@ -107,15 +112,18 @@ function testPersistenceAndTokenHashing(stateDir: string): void { firstStore.close(); const database = openDatabase(stateDir); + try { const accessHashes = database.sqlite .prepare("select token_hash from oauth_access_tokens") .pluck() .all() as string[]; + const refreshHashes = database.sqlite .prepare("select token_hash from oauth_refresh_tokens") .pluck() .all() as string[]; + assert.deepEqual(accessHashes, [hashToken(accessToken)]); assert.deepEqual(refreshHashes, [hashToken(refreshToken)]); assert.equal(accessHashes.includes(accessToken), false); @@ -125,6 +133,7 @@ function testPersistenceAndTokenHashing(stateDir: string): void { } const restoredStore = new SqliteOAuthStore(stateDir); + try { const restoredClient = restoredStore.getClient(client.client_id); assert.equal(restoredClient?.client_id, client.client_id); @@ -137,9 +146,11 @@ function testPersistenceAndTokenHashing(stateDir: string): void { function testExpiredTokenCleanup(stateDir: string): void { const store = new SqliteOAuthStore(stateDir); + const client = new SqliteOAuthClientsStore(store, oauthConfig.allowedRedirectHosts).registerClient({ redirect_uris: [redirectUri], }); + const expiredAt = Math.floor(Date.now() / 1000) - 1; store.saveTokenPair({ accessTokenHash: "expired-access-hash", @@ -150,6 +161,7 @@ function testExpiredTokenCleanup(stateDir: string): void { store.close(); const reopened = new SqliteOAuthStore(stateDir); + try { assert.equal(reopened.getAccessToken("expired-access-hash"), undefined); assert.equal(reopened.getRefreshToken("expired-refresh-hash"), undefined); @@ -160,10 +172,12 @@ function testExpiredTokenCleanup(stateDir: string): void { function testTransactionalTokenRotation(stateDir: string): void { const store = new SqliteOAuthStore(stateDir); + try { const client = new SqliteOAuthClientsStore(store, oauthConfig.allowedRedirectHosts).registerClient({ redirect_uris: [redirectUri], }); + const expiresAt = Math.floor(Date.now() / 1000) + 3600; store.saveRefreshToken("old-refresh-hash", { clientId: client.client_id, @@ -213,10 +227,12 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi assert.equal(firstProvider.isResourceAllowed(tunnelUrl), true); assert.equal(firstProvider.isResourceAllowed(new URL(`${tunnelUrl.href}/session`)), false); assert.equal(firstProvider.isResourceAllowed(new URL(`${tunnelUrl.href}?other=1`)), false); + const client = await firstProvider.clientsStore.registerClient?.({ redirect_uris: [redirectUri], client_name: "ChatGPT", }); + assert.ok(client); const code = "code-test-123"; @@ -234,6 +250,7 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi firstProvider.exchangeAuthorizationCode(client, code, undefined, redirectUri, mcpUrl), InvalidGrantError, ); + const issued = await firstProvider.exchangeAuthorizationCode( client, code, @@ -241,12 +258,14 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi redirectUri, tunnelUrl, ); + assert.ok(issued.refresh_token); firstProvider.close(); const removedAliasProvider = new SingleUserOAuthProvider( { ...oauthConfig, allowedResourceUrls: [] }, mcpUrl, stateDir, ); + try { for (const resource of [undefined, tunnelUrl, mcpUrl]) { await assert.rejects( @@ -259,6 +278,7 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi } const secondProvider = new SingleUserOAuthProvider(oauthConfig, mcpUrl, stateDir); + try { const verified = await secondProvider.verifyAccessToken(issued.access_token); assert.equal(verified.clientId, client.client_id); @@ -275,6 +295,7 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi ["devspace"], tunnelUrl, ); + assert.ok(refreshed.refresh_token); assert.notEqual(refreshed.access_token, issued.access_token); @@ -303,14 +324,17 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi async function testRefreshResourcePolicy(stateDir: string): Promise { const store = new SqliteOAuthStore(stateDir); const client = store.registerClient({ redirect_uris: [redirectUri] }, oauthConfig.allowedRedirectHosts); + for (const [token, resource] of [["canonical", mcpUrl.href], ["missing", undefined]] as const) { store.saveRefreshToken(hashToken(token), { clientId: client.client_id, scopes: ["devspace"], expiresAt: Math.floor(Date.now() / 1000) + 3600, resource, }); } + store.close(); const provider = new SingleUserOAuthProvider({ ...oauthConfig, allowedResourceUrls: [] }, mcpUrl, stateDir); + try { await assert.rejects(provider.exchangeRefreshToken(client, "missing"), InvalidGrantError); const tokens = await provider.exchangeRefreshToken(client, "canonical"); diff --git a/src/oauth-store.ts b/src/oauth-store.ts index e171f86ae..a8b54599a 100644 --- a/src/oauth-store.ts +++ b/src/oauth-store.ts @@ -27,6 +27,7 @@ export interface PersistedTokenPair { function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boolean { let parsed: URL; + try { parsed = new URL(redirectUri); } catch { @@ -34,6 +35,7 @@ function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boole } if (["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) return true; + return allowedHosts.includes(parsed.hostname); } @@ -62,6 +64,7 @@ export class SqliteOAuthStore { } const now = Math.floor(Date.now() / 1000); + const registered: OAuthClientInformationFull = { ...client, client_id: `devspace-${randomUUID()}`, @@ -143,6 +146,7 @@ export class SqliteOAuthStore { const save = this.database.sqlite.transaction(() => { if (consumedRefreshTokenHash) { const now = Math.floor(Date.now() / 1000); + // Retried rotations must not extend the original grace deadline or TTL. const result = this.refreshTokenGraceSeconds > 0 ? this.database.sqlite @@ -151,11 +155,13 @@ export class SqliteOAuthStore { : this.database.sqlite .prepare("delete from oauth_refresh_tokens where token_hash = ?") .run(consumedRefreshTokenHash); + if (result.changes !== 1) return false; } this.saveAccessToken(pair.accessTokenHash, pair.accessToken); this.saveRefreshToken(pair.refreshTokenHash, pair.refreshToken); + return true; }); diff --git a/src/onboarding.test.ts b/src/onboarding.test.ts index 79d74017e..9ca02237d 100644 --- a/src/onboarding.test.ts +++ b/src/onboarding.test.ts @@ -13,6 +13,7 @@ test("onboarding usage resolves from the selected uses and requires at least one ] as const) { assert.equal(resolveOnboardingUsage(selections), expected); } + assert.throws(() => resolveOnboardingUsage([]), /Choose ChatGPT, Coding Agents, or both/); }); @@ -51,6 +52,7 @@ test("onboarding toggles existing providers and preserves their settings", () => { id: "codex-work", driver: "codex" as const, enabled: true, model: "gpt-work" }, ], }; + assert.deepEqual( updateOnboardingSubagentsConfig(configured, ["claude", "codex-work"]), { diff --git a/src/onboarding.ts b/src/onboarding.ts index fb2f0c2ec..2ae6ce9cc 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -10,15 +10,20 @@ export const SUBAGENT_SKILL_INSTALL_COMMAND = "npx skills add Waishnav/devspace --skill subagents --global"; export const ONBOARDING_DESTINATIONS = ["chatgpt", "coding-agents"] as const; + export type OnboardingDestination = typeof ONBOARDING_DESTINATIONS[number]; + export type OnboardingUsage = OnboardingDestination | "both"; export function resolveOnboardingUsage( destinations: readonly OnboardingDestination[], ): OnboardingUsage { const selected = new Set(destinations); + if (selected.has("chatgpt") && selected.has("coding-agents")) return "both"; + if (selected.has("chatgpt")) return "chatgpt"; + if (selected.has("coding-agents")) return "coding-agents"; throw new Error("Choose ChatGPT, Coding Agents, or both."); } @@ -37,9 +42,11 @@ export function updateOnboardingSubagentsConfig( ): SubagentsConfig { const selected = new Set(selectedProviders); const defaultIds = new Set(LOCAL_AGENT_DEFAULT_PROVIDER_IDS); + const customInstances = current.providers .filter((provider) => !defaultIds.has(provider.id)) .map((provider) => ({ ...provider, enabled: selected.has(provider.id) })); + return { enabled: true, instructions: current.instructions, @@ -48,6 +55,7 @@ export function updateOnboardingSubagentsConfig( .filter((id) => selected.has(id) || current.providers.some((provider) => provider.id === id)) .map((id) => { const existing = current.providers.find((provider) => provider.id === id); + return { ...existing, id, diff --git a/src/pi-tools.ts b/src/pi-tools.ts index 3c8707d3d..817a0729d 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -12,6 +12,7 @@ import { } from "@earendil-works/pi-coding-agent"; type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; + export type ToolResponse = { content: McpContent[]; details?: TDetails; @@ -38,6 +39,7 @@ function toMcpContent(result: AgentToolResult): McpContent[] { function formatToolError(error: unknown): McpContent[] { const message = error instanceof Error ? error.message : String(error); + return [{ type: "text", text: message }]; } @@ -48,6 +50,7 @@ async function runTool( ): Promise> { try { const result = await execute(input); + return { content: toMcpContent(result), details: result.details, diff --git a/src/process-platform.ts b/src/process-platform.ts index 905d4d731..1b834c15c 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -25,6 +25,7 @@ const defaultProcessTreeRuntime: ProcessTreeRuntime = { stdio: "ignore", windowsHide: true, }); + return !result.error && result.status === 0; }, }; @@ -46,9 +47,11 @@ export function resolveShellCommand( const configuredShell = environment.SHELL; const shellName = configuredShell ? basename(configuredShell) : ""; + if (configuredShell && LOGIN_SHELLS.has(shellName)) { return { executable: configuredShell, args: ["-lc", command] }; } + if (configuredShell && POSIX_SHELLS.has(shellName)) { return { executable: configuredShell, args: ["-c", command] }; } @@ -67,6 +70,7 @@ export function terminateProcessTree( } else if (detached && child.pid) { try { runtime.killGroup(child.pid, signal); + return; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return; diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index ddb97e76e..2b35f2095 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -46,12 +46,14 @@ test("truncation never splits a Unicode character", () => { test("a command that finishes within the yield time returns no session", async (t) => { const manager = createManager(t); + const foreground = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), command: `${node} -e "console.log('foreground')"`, yieldTimeMs: 2_000, }); + assert.equal(foreground.running, false); assert.equal(foreground.exitCode, 0); assert.match(foreground.output, /foreground/); @@ -60,6 +62,7 @@ test("a command that finishes within the yield time returns no session", async ( test("commands run with a non-interactive environment and workspace identity", async (t) => { const manager = createManager(t); + const environment = await manager.start({ workspaceId: "workspace-a", workspaceRoot: "/tmp/devspace-workspace-a", @@ -67,18 +70,21 @@ test("commands run with a non-interactive environment and workspace identity", a command: `${node} -e "console.log([process.env.NO_COLOR, process.env.TERM, process.env.PAGER, process.env.GIT_PAGER, process.env.GH_PAGER, process.env.CODEX_CI, process.env.DEVSPACE_WORKSPACE_ID, process.env.DEVSPACE_WORKSPACE_ROOT].join(','))"`, yieldTimeMs: 2_000, }); + assert.equal(environment.running, false); assert.match(environment.output, /1,dumb,cat,cat,cat,1,workspace-a,\/tmp\/devspace-workspace-a/); }); test("a background session is only readable from its own workspace and reports completion", async (t) => { const manager = createManager(t); + const background = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), command: `${node} -e "setTimeout(() => console.log('finished'), 100)"`, yieldTimeMs: 5, }); + assert.equal(background.running, true); assert.ok(background.sessionId); assert.equal(typeof background.sessionId, "number"); @@ -97,6 +103,7 @@ test("a background session is only readable from its own workspace and reports c sessionId: background.sessionId, yieldTimeMs: 2_000, }); + assert.equal(completed.running, false); assert.equal(completed.exitCode, 0); assert.match(completed.output, /finished/); @@ -104,12 +111,14 @@ test("a background session is only readable from its own workspace and reports c test("input written to an interactive session reaches stdin", async (t) => { const manager = createManager(t); + const interactive = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), command: `${node} -e "process.stdin.once('data', data => { console.log('input:' + data.toString().trim()); process.exit(0); })"`, yieldTimeMs: 5, }); + assert.equal(interactive.running, true); assert.ok(interactive.sessionId); assert.equal(typeof interactive.sessionId, "number"); @@ -120,18 +129,21 @@ test("input written to an interactive session reaches stdin", async (t) => { chars: "hello\n", yieldTimeMs: 2_000, }); + assert.equal(inputResult.running, false); assert.match(inputResult.output, /input:hello/); }); test("writing input without a yield time waits for the response", async (t) => { const manager = createManager(t); + const defaultInteractive = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), command: `${node} -e "process.stdin.once('data', data => setTimeout(() => { console.log('default-input:' + data.toString().trim()); process.exit(0); }, 100))"`, yieldTimeMs: 5, }); + assert.equal(defaultInteractive.running, true); assert.ok(defaultInteractive.sessionId); @@ -140,56 +152,67 @@ test("writing input without a yield time waits for the response", async (t) => { sessionId: defaultInteractive.sessionId, chars: "hello\n", }); + assert.equal(defaultInputResult.running, false); assert.match(defaultInputResult.output, /default-input:hello/); }); test("input reaches an interactive session that is producing output", async (t) => { const manager = createManager(t); + const noisyInteractive = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), command: `${node} -e "setInterval(() => console.log('tick'), 10); process.stdin.once('data', data => { console.log('input:' + data.toString().trim()); process.exit(0); })"`, yieldTimeMs: 100, }); + assert.equal(noisyInteractive.running, true); assert.ok(noisyInteractive.sessionId); await new Promise((resolve) => setTimeout(resolve, 50)); + const noisyInputResult = await manager.write({ workspaceId: "workspace-a", sessionId: noisyInteractive.sessionId, chars: "hello\n", yieldTimeMs: 2_000, }); + assert.equal(noisyInputResult.running, false); assert.match(noisyInputResult.output, /input:hello/); }); test("Ctrl-C interrupts a running session", async (t) => { const manager = createManager(t); + const interruptible = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), command: `${node} -e "setInterval(() => console.log('tick'), 10)"`, yieldTimeMs: 100, }); + assert.equal(interruptible.running, true); assert.ok(interruptible.sessionId); await new Promise((resolve) => setTimeout(resolve, 50)); + const interrupted = await manager.write({ workspaceId: "workspace-a", sessionId: interruptible.sessionId, chars: "\u0003", yieldTimeMs: 2_000, }); + assert.equal(interrupted.running, false); + if (process.platform !== "win32") assert.equal(interrupted.signal, "SIGINT"); }); test("output beyond the token limit is reported as truncated", async (t) => { const manager = createManager(t); + let buffered = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), @@ -197,6 +220,7 @@ test("output beyond the token limit is reported as truncated", async (t) => { yieldTimeMs: 50, maxOutputTokens: 100, }); + if (!buffered.outputTruncated && buffered.sessionId) { buffered = await manager.write({ workspaceId: "workspace-a", @@ -205,12 +229,15 @@ test("output beyond the token limit is reported as truncated", async (t) => { maxOutputTokens: 100, }); } + assert.equal(buffered.outputTruncated, true); + if (buffered.sessionId) manager.terminate("workspace-a", buffered.sessionId); }); test("a Windows pseudo-terminal session runs the command", { skip: process.platform !== "win32" }, async (t) => { const manager = createManager(t); + const pty = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), @@ -218,12 +245,14 @@ test("a Windows pseudo-terminal session runs the command", { skip: process.platf tty: true, yieldTimeMs: 10_000, }); + assert.equal(pty.running, false); assert.match(pty.output, /pty-ok/); }); test("a pseudo-terminal session reports resized columns", { skip: process.platform === "win32" }, async (t) => { const manager = createManager(t); + const pty = await manager.start({ workspaceId: "workspace-a", cwd: process.cwd(), @@ -233,6 +262,7 @@ test("a pseudo-terminal session reports resized columns", { skip: process.platfo rows: 24, yieldTimeMs: 10, }); + assert.equal(pty.running, true); assert.ok(pty.sessionId); @@ -243,6 +273,7 @@ test("a pseudo-terminal session reports resized columns", { skip: process.platfo rows: 30, yieldTimeMs: 2_000, }); + assert.equal(resizedPty.running, false); assert.match(resizedPty.output, /columns:120/); }); @@ -257,6 +288,7 @@ test("termination stays bounded when a descendant escapes the process group", { t.after(() => { // Only signal a real positive PID: kill(0) would signal this test's own process group. const escapedPid = existsSync(pidFile) ? Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10) : NaN; + if (Number.isSafeInteger(escapedPid) && escapedPid > 0) { try { process.kill(escapedPid, "SIGKILL"); @@ -264,6 +296,7 @@ test("termination stays bounded when a descendant escapes the process group", { // Already gone. } } + escapeManager.shutdown(); rmSync(dir, { recursive: true, force: true }); }); @@ -284,6 +317,8 @@ function createManager(t: TestContext): ProcessSessionManager { maxBufferCharacters: 1_024, completedSessionTtlMs: 1_000, }); + t.after(() => manager.shutdown()); + return manager; } diff --git a/src/process-sessions.ts b/src/process-sessions.ts index 3dbcf3028..73fcdce41 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -16,6 +16,7 @@ const OWNER_KILL_WAIT_MS = 2_000; async function allExitWithin(sessions: ProcessSession[], ms: number): Promise { let timer: NodeJS.Timeout | undefined; + try { return await Promise.race([ Promise.all(sessions.map((session) => session.exitPromise)).then(() => true), @@ -87,17 +88,21 @@ interface ProcessSessionManagerOptions { function boundedInteger(value: number | undefined, fallback: number, maximum: number): number { if (value === undefined) return fallback; + if (!Number.isFinite(value) || value < 0) { throw new Error("Duration and output limits must be non-negative."); } + return Math.min(Math.floor(value), maximum); } function terminalSize(value: number | undefined, fallback: number): number { if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1 || value > 1_000) { throw new Error("Terminal dimensions must be integers between 1 and 1000."); } + return value; } @@ -132,12 +137,14 @@ function sliceCodePoints(value: string, start: number, end?: number): string { function takeHead(value: string, count: number): string { if (count <= 0) return ""; + return sliceCodePoints(value, 0, count); } function takeTail(value: string, count: number): string { if (count <= 0) return ""; const characters = Array.from(value); + return characters.slice(Math.max(0, characters.length - count)).join(""); } @@ -150,6 +157,7 @@ function splitBudget(maxCharacters: number): { head: number; tail: number } { function formatHeadTail(head: string, tail: string, omittedCharacters: number): string { if (omittedCharacters <= 0) return head + tail; + return `${head}\n... output truncated (${omittedCharacters} characters omitted) ...\n${tail}`; } @@ -172,14 +180,17 @@ export class HeadTailBuffer { if (this.totalCharacters <= this.maxCharacters) { this.head += output; + return; } const budget = splitBudget(this.maxCharacters); + if (previousTotal <= this.maxCharacters) { const fullOutput = this.head + output; this.head = takeHead(fullOutput, budget.head); this.tail = takeTail(fullOutput, budget.tail); + return; } @@ -199,6 +210,7 @@ export class HeadTailBuffer { 0, this.totalCharacters - codePointLength(this.head) - codePointLength(this.tail), ); + const retained = formatHeadTail(this.head, this.tail, omittedByBuffer); const output = truncateOutput(retained, maxCharacters); const truncated = omittedByBuffer > 0 || output.truncated; @@ -213,12 +225,14 @@ export class HeadTailBuffer { function truncateOutput(output: string, maxCharacters: number): { output: string; truncated: boolean } { const outputCharacters = codePointLength(output); + if (outputCharacters <= maxCharacters) return { output, truncated: false }; const marker = "\n... output truncated ...\n"; const markerCharacters = codePointLength(marker); const available = Math.max(0, maxCharacters - markerCharacters); const budget = splitBudget(available); + return { output: takeHead(output, budget.head) + marker + takeTail(output, budget.tail), truncated: true, @@ -241,6 +255,7 @@ export class ProcessOwner { adopt(session: ProcessSession): void { this.sessions.add(session); void session.exitPromise.then(() => this.sessions.delete(session)); + if (this.terminated && session.running) session.process?.kill("SIGTERM"); } @@ -253,10 +268,14 @@ export class ProcessOwner { async terminate(): Promise { this.terminated = true; const running = [...this.sessions].filter((session) => session.running); + for (const session of running) session.process?.kill("SIGTERM"); + if (await allExitWithin(running, OWNER_TERMINATE_GRACE_MS)) return 0; + for (const session of running) if (session.running) session.process?.kill("SIGKILL"); await allExitWithin(running, OWNER_KILL_WAIT_MS); + return running.filter((session) => session.running).length; } } @@ -280,6 +299,7 @@ export class ProcessSessionManager { async start(input: StartCommandInput): Promise { const owner = this.owners.getStore(); + if (owner?.isTerminated) throw new Error("The caller that owns this command was stopped."); const session = this.createSession(input); this.sessions.set(session.id, session); @@ -291,36 +311,45 @@ export class ProcessSessionManager { this.sessions.delete(session.id); throw error; } + owner?.adopt(session); const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, MAX_PROCESS_YIELD_MS); await this.waitForExit(session, yieldTimeMs); const snapshot = this.consume(session, input.maxOutputTokens); + if (!session.running) this.removeSession(session.id); + return snapshot; } async write(input: WriteStdinInput): Promise { const session = this.getOwnedSession(input.workspaceId, input.sessionId); const chars = input.chars ?? ""; + const interactionRequested = chars.length > 0 || input.columns !== undefined || input.rows !== undefined; if (input.columns !== undefined || input.rows !== undefined) { session.columns = terminalSize(input.columns, session.columns); session.rows = terminalSize(input.rows, session.rows); + if (!session.process?.resize) { throw new Error(`Process session ${session.id} is not a PTY and cannot be resized.`); } + session.process.resize(session.columns, session.rows); } const interruptRequested = chars.includes("\u0003") && session.running; + if (interruptRequested) { session.process?.kill("SIGINT"); } + const writableChars = chars.replaceAll("\u0003", ""); + if (writableChars && session.running) session.process?.write(writableChars); if ((interactionRequested || !session.buffer.hasOutput()) && session.running) { @@ -330,25 +359,31 @@ export class ProcessSessionManager { } const snapshot = this.consume(session, input.maxOutputTokens); + if (!session.running) this.removeSession(session.id); + return snapshot; } terminate(workspaceId: string, sessionId: number): void { const session = this.getOwnedSession(workspaceId, sessionId); + if (session.running) session.process?.kill("SIGTERM"); } shutdown(): void { for (const session of this.sessions.values()) { if (session.cleanupTimer) clearTimeout(session.cleanupTimer); + if (session.running) session.process?.kill("SIGTERM"); } + this.sessions.clear(); } private async waitForExit(session: ProcessSession, yieldTimeMs: number): Promise { let timer: NodeJS.Timeout | undefined; + try { await Promise.race([ session.exitPromise, @@ -363,6 +398,7 @@ export class ProcessSessionManager { private createSession(input: StartCommandInput): ProcessSession { let resolveExit = (): void => undefined; + const exitPromise = new Promise((resolve) => { resolveExit = resolve; }); @@ -383,6 +419,7 @@ export class ProcessSessionManager { private startPipe(session: ProcessSession, input: StartCommandInput): void { const shell = resolveShellCommand(input.command); const detached = process.platform !== "win32"; + const child = spawn(input.command, { cwd: input.cwd, env: processEnvironment({ @@ -408,6 +445,7 @@ export class ProcessSessionManager { private async startPty(session: ProcessSession, input: StartCommandInput): Promise { let nodePty: typeof import("node-pty"); + try { nodePty = await import("node-pty"); } catch { @@ -416,6 +454,7 @@ export class ProcessSessionManager { const shell = resolveShellCommand(input.command); let pty: import("node-pty").IPty; + try { pty = nodePty.spawn(shell.executable, shell.args, { cwd: input.cwd, @@ -477,15 +516,19 @@ export class ProcessSessionManager { private getOwnedSession(workspaceId: string, sessionId: number): ProcessSession { const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Unknown process session: ${sessionId}`); + if (session.workspaceId !== workspaceId) { throw new Error(`Process session ${sessionId} does not belong to workspace ${workspaceId}.`); } + return session; } private removeSession(sessionId: number): void { const session = this.sessions.get(sessionId); + if (session?.cleanupTimer) clearTimeout(session.cleanupTimer); this.sessions.delete(sessionId); } diff --git a/src/request-meta.ts b/src/request-meta.ts index 0f88996cb..15e85ca8c 100644 --- a/src/request-meta.ts +++ b/src/request-meta.ts @@ -4,6 +4,7 @@ function metadataString( ): string | undefined { if (typeof meta !== "object" || meta === null) return undefined; const value = (meta as Record)[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 86f1eccd8..deecef458 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -30,11 +30,14 @@ test("initialization reports whether aggregate review is available", async (t) = await manager.initializeWorkspace({ workspaceId: "ws_git", root: gitRoot }), { available: true }, ); + const unavailable = await manager.initializeWorkspace({ workspaceId: "ws_plain", root: plainRoot, }); + assert.equal(unavailable.available, false); + if (!unavailable.available) assert.match(unavailable.reason, /git repository/i); }); @@ -51,6 +54,7 @@ test("show_changes reports and advances the last-shown checkpoint", async (t) => root, markReviewed: false, }); + assert.deepEqual(unreviewed.files.map((file) => file.path).sort(), ["README.md", "new.txt"]); assert.equal(unreviewed.summary.additions, 2); assert.match(unreviewed.patch, /world/); @@ -60,6 +64,7 @@ test("show_changes reports and advances the last-shown checkpoint", async (t) => root, markReviewed: true, }); + assert.equal(markedReviewed.summary.files, 2); assert.match(markedReviewed.reviewRef, /^[0-9a-f]{40,64}$/); @@ -68,6 +73,7 @@ test("show_changes reports and advances the last-shown checkpoint", async (t) => root, reviewRef: markedReviewed.reviewRef, }); + assert.deepEqual(restored.summary, markedReviewed.summary); assert.deepEqual(restored.files, markedReviewed.files); assert.equal(restored.patch, markedReviewed.patch); @@ -131,11 +137,13 @@ test("historical review refs survive later reviews and manager restarts", async assert.notEqual(first.reviewRef, second.reviewRef); const restarted = createReviewCheckpointManager(); + const restoredFirst = await restarted.reviewByRef({ workspaceId: "ws_history", root, reviewRef: first.reviewRef, }); + assert.deepEqual(restoredFirst.summary, first.summary); assert.equal(restoredFirst.patch, first.patch); assert.match(restoredFirst.patch, /\+first/); @@ -195,6 +203,7 @@ test("review checkpoints survive a manager restart", async (t) => { root, markReviewed: false, }); + assert.deepEqual(afterRestart.files.map((file) => file.path), ["later.txt"]); assert.match(afterRestart.patch, /after restart/); assert.doesNotMatch(afterRestart.patch, /world/); @@ -208,14 +217,17 @@ test("concurrent initialization produces one usable checkpoint state", async (t) manager.initializeWorkspace({ workspaceId: "ws_concurrent", root }), manager.reviewChanges({ workspaceId: "ws_concurrent", root, markReviewed: false }), ]); + assert.equal(concurrentReview.summary.files, 0); await writeFile(join(root, "later.txt"), "visible after initialization\n"); + const afterInitialization = await manager.reviewChanges({ workspaceId: "ws_concurrent", root, markReviewed: false, }); + assert.deepEqual(afterInitialization.files.map((file) => file.path), ["later.txt"]); }); @@ -235,6 +247,7 @@ test("a missing last-shown checkpoint falls back after restart and can be re-est root, markReviewed: false, }); + assert.equal(fallback.summary.files, 1); assert.match(fallback.patch, /changed/); @@ -243,6 +256,7 @@ test("a missing last-shown checkpoint falls back after restart and can be re-est root, markReviewed: true, }); + assert.equal(reestablished.summary.files, 1); const afterReestablished = await restartedManager.reviewChanges({ @@ -250,6 +264,7 @@ test("a missing last-shown checkpoint falls back after restart and can be re-est root, markReviewed: false, }); + assert.equal(afterReestablished.summary.files, 0); }); @@ -270,11 +285,13 @@ test("a checkpoint workspace rejects a different root without changing its state ); await writeFile(join(root, "only-first-root.txt"), "first root\n"); + const review = await manager.reviewChanges({ workspaceId: "ws_root_mismatch", root, markReviewed: false, }); + assert.deepEqual(review.files.map((file) => file.path), ["only-first-root.txt"]); }); @@ -294,6 +311,7 @@ test("a concurrent review rejects a different root after initialization", async assert.equal(initialization.status, "fulfilled"); assert.equal(review.status, "rejected"); + if (review.status === "rejected") { assert.match(String(review.reason), /workspace root mismatch/); } @@ -315,6 +333,7 @@ test("an unborn repository is reviewable without creating a HEAD commit", async root, markReviewed: false, }); + assert.deepEqual(review.files.map((file) => file.path), ["created-after-open.txt"]); assert.equal(review.files[0]?.type, "new"); assert.match(review.patch, /new file/); @@ -349,6 +368,7 @@ test("an unborn review baseline survives the first user commit", async (t) => { root, markReviewed: false, }); + assert.deepEqual(review.files.map((file) => file.path), ["after-first-commit.txt"]); assert.match(review.patch, /created after commit/); assert.doesNotMatch(review.patch, /reviewed before commit/); @@ -363,6 +383,7 @@ async function committedRepository(t: TestContext): Promise { await writeFile(join(root, "README.md"), "hello\n"); await git(root, ["add", "README.md"]); await git(root, ["commit", "-m", "Initial commit"]); + return root; } @@ -372,6 +393,7 @@ async function unbornRepository(t: TestContext): Promise { await git(root, ["init"]); await git(root, ["config", "user.email", "devspace@example.com"]); await git(root, ["config", "user.name", "DevSpace Test"]); + return root; } diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 0882f25af..eeb48417c 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -67,20 +67,24 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { async initializeWorkspace({ workspaceId, root }) { const existingState = states.get(workspaceId); assertWorkspaceRoot(existingState, workspaceId, root); + if (existingState?.root === root && existingState.gitRoot !== undefined) { return reviewAvailability(existingState); } const pending = initializations.get(workspaceId); + if (pending) { await pending; const initializedState = states.get(workspaceId); assertWorkspaceRoot(initializedState, workspaceId, root); + return reviewAvailability(initializedState); } const initialize = initializeWorkspaceState(states, workspaceId, root); initializations.set(workspaceId, initialize); + try { await initialize; } finally { @@ -88,16 +92,19 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { initializations.delete(workspaceId); } } + return reviewAvailability(states.get(workspaceId)); }, async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) { let state = states.get(workspaceId); assertWorkspaceRoot(state, workspaceId, root); + if (!isReadyState(state)) { await this.initializeWorkspace({ workspaceId, root }); state = states.get(workspaceId); } + assertWorkspaceRoot(state, workspaceId, root); if (!state?.gitRoot) { @@ -106,10 +113,12 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { let effectiveSince = since; let usedWorkspaceOpenFallback = false; + if (since === "last_shown" && !state.baselineRefAvailable) { if (!state.openRefAvailable) { throw new Error("Review checkpoints are missing; show_changes cannot reconstruct that history safely."); } + effectiveSince = "workspace_open"; usedWorkspaceOpenFallback = true; } else if (since === "workspace_open" && !state.openRefAvailable) { @@ -135,6 +144,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { const fallbackNote = usedWorkspaceOpenFallback ? ` The last-shown checkpoint was missing, so changes were compared from workspace open${markReviewed ? " and the baseline was re-established" : ""}.` : ""; + return { reviewRef: current, result: `${ @@ -149,10 +159,12 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { async reviewByRef({ workspaceId, root, reviewRef }) { let state = states.get(workspaceId); assertWorkspaceRoot(state, workspaceId, root); + if (!isReadyState(state)) { await this.initializeWorkspace({ workspaceId, root }); state = states.get(workspaceId); } + assertWorkspaceRoot(state, workspaceId, root); if (!state?.gitRoot) { @@ -164,12 +176,14 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { commitForRef(state.gitRoot, state.baselineRef), resolveReviewCommitOrUndefined(state.gitRoot, reviewRef), ]); + if ( reviewCommit && await commitForRef(state.gitRoot, `${state.checkpointRefPrefix}/${reviewCommit}`) === reviewCommit ) { return readReviewCommit(state.gitRoot, reviewCommit); } + if ( !openCommit || !baselineCommit @@ -183,6 +197,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { isAncestor(state.gitRoot, openCommit, reviewCommit), isAncestor(state.gitRoot, reviewCommit, baselineCommit), ]); + if (!isAfterOpen || !isBeforeBaseline) { throw new Error(`Unknown review reference for workspace ${workspaceId}: ${reviewRef}`); } @@ -194,14 +209,17 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { export async function readReviewRef(root: string, reviewRef: string): Promise { const eligibility = await getGitEligibility(root); + if (!eligibility.ok || !eligibility.gitRoot) { throw new Error(eligibility.message ?? "show-changes requires a Git workspace."); } const commit = await resolveReviewCommit(eligibility.gitRoot, reviewRef); + if (!await isKnownReviewCommit(eligibility.gitRoot, commit)) { throw new Error(`Unknown DevSpace review reference: ${reviewRef}`); } + return readReviewCommit(eligibility.gitRoot, commit); } @@ -221,6 +239,7 @@ async function initializeWorkspaceState( root: string, ): Promise { const refs = reviewRefs(workspaceId); + const state: WorkspaceReviewState = { root, ...refs, @@ -230,8 +249,10 @@ async function initializeWorkspaceState( try { const eligibility = await getGitEligibility(root); + if (!eligibility.ok || !eligibility.gitRoot) { state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version."; + return; } @@ -244,6 +265,7 @@ async function initializeWorkspaceState( const head = eligibility.hasHead ? (await git(eligibility.gitRoot, ["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim() : undefined; + const initialCommit = await createWorkingTreeSnapshot(eligibility.gitRoot, head); await git(eligibility.gitRoot, ["update-ref", state.openRef, initialCommit]); await git(eligibility.gitRoot, ["update-ref", state.baselineRef, initialCommit]); @@ -287,6 +309,7 @@ function reviewRefs( workspaceId: string, ): Pick { const segment = safeWorkspaceRefSegment(workspaceId); + return { openRef: `${REVIEW_REF_PREFIX}/${segment}/open`, baselineRef: `${REVIEW_REF_PREFIX}/${segment}/baseline`, @@ -304,8 +327,10 @@ async function createWorkingTreeSnapshot(gitRoot: string, parent?: string): Prom await git(gitRoot, ["add", "-A", "--", "."], { env }); const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim(); const commitArgs = ["commit-tree", tree]; + if (parent) commitArgs.push("-p", parent); commitArgs.push("-m", "DevSpace review snapshot"); + return (await git(gitRoot, commitArgs, { env })).stdout.trim(); } finally { await rm(tempDir, { recursive: true, force: true }); @@ -315,6 +340,7 @@ async function createWorkingTreeSnapshot(gitRoot: string, parent?: string): Prom async function readReviewCommit(gitRoot: string, reviewRef: string): Promise { const parent = (await git(gitRoot, ["rev-parse", "--verify", `${reviewRef}^1`])).stdout.trim(); const review = await readReviewBetween(gitRoot, parent, reviewRef); + return { reviewRef, result: review.summary.files === 0 ? "No changes in this review." : formatChangedFiles(review.summary), @@ -330,15 +356,19 @@ async function readReviewBetween( const patch = (await git(gitRoot, ["diff", "--binary", "--no-color", before, after], { maxBuffer: 50 * 1024 * 1024, })).stdout; + const numstat = (await git(gitRoot, ["diff", "--numstat", "-z", before, after], { maxBuffer: 50 * 1024 * 1024, })).stdout; + // Line counts can't tell a new file from an append (or a deletion from a truncation), // so the change type comes from git's own status for the same diff. const nameStatus = (await git(gitRoot, ["diff", "--name-status", "-z", before, after], { maxBuffer: 50 * 1024 * 1024, })).stdout; + const files = parseNumstat(numstat, parseNameStatus(nameStatus)); + return { summary: summarizeFiles(files), files, @@ -350,6 +380,7 @@ async function resolveReviewCommit(gitRoot: string, reviewRef: string): Promise< if (!isReviewRef(reviewRef)) { throw new Error(`Invalid review reference: ${reviewRef}`); } + return (await git(gitRoot, ["rev-parse", "--verify", `${reviewRef}^{commit}`])).stdout.trim(); } @@ -367,6 +398,7 @@ async function resolveReviewCommitOrUndefined( async function isAncestor(gitRoot: string, ancestor: string, descendant: string): Promise { try { await git(gitRoot, ["merge-base", "--is-ancestor", ancestor, descendant]); + return true; } catch { return false; @@ -379,17 +411,23 @@ async function isKnownReviewCommit(gitRoot: string, reviewCommit: string): Promi "--format=%(refname)\t%(objectname)", REVIEW_REF_PREFIX, ])).stdout.trim(); + if (!refs) return false; const histories = new Map(); + for (const line of refs.split("\n")) { const [ref, commit] = line.split("\t"); + if (!ref || !commit) continue; + if (commit === reviewCommit && ref.endsWith(`/checkpoints/${reviewCommit}`)) return true; const match = ref.match(/^refs\/devspace\/review\/(.+)\/(open|baseline)$/); + if (!match) continue; const [, workspace, kind] = match; + if (!workspace || !kind) continue; const history = histories.get(workspace) ?? {}; @@ -400,13 +438,16 @@ async function isKnownReviewCommit(gitRoot: string, reviewCommit: string): Promi const memberships = await Promise.all( [...histories.values()].map(async ({ open, baseline }) => { if (!open || !baseline || reviewCommit === open) return false; + const [isAfterOpen, isBeforeBaseline] = await Promise.all([ isAncestor(gitRoot, open, reviewCommit), isAncestor(gitRoot, reviewCommit, baseline), ]); + return isAfterOpen && isBeforeBaseline; }), ); + return memberships.some(Boolean); } @@ -440,6 +481,7 @@ function parseNumstat(output: string, types: Map): R // With -z, a rename header ends in an empty path and is followed by the old and new paths. const path = parts[2]; + if (path) { files.push({ path, type: types.get(path) ?? "change", additions, removals }); continue; @@ -447,6 +489,7 @@ function parseNumstat(output: string, types: Map): R const previousPath = fields[index++]; const newPath = fields[index++]; + if (!newPath) continue; files.push({ @@ -464,6 +507,7 @@ function parseNumstat(output: string, types: Map): R function parseStatNumber(value: string | undefined): number { if (!value || value === "-") return 0; const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; } @@ -475,16 +519,20 @@ function parseNameStatus(output: string): Map { for (let index = 0; index < fields.length;) { const status = fields[index++] ?? ""; const kind = status[0]; + if (kind === "R" || kind === "C") { index++; // source path const path = fields[index++]; + if (!path) continue; + if (kind === "R") types.set(path, status === "R100" ? "rename-pure" : "rename-changed"); else types.set(path, "new"); continue; } const path = fields[index++]; + if (!path) continue; types.set(path, kind === "A" ? "new" : kind === "D" ? "deleted" : "change"); } diff --git a/src/roots.test.ts b/src/roots.test.ts index 086c665ab..cb2da92ee 100644 --- a/src/roots.test.ts +++ b/src/roots.test.ts @@ -42,6 +42,7 @@ test("~ paths outside allowed roots are denied", () => { assert.throws( () => { const workspace = resolve(home, "workspace"); + return resolveAllowedPath("~/file.txt", workspace, [workspace]); }, /Path is outside allowed roots/, diff --git a/src/roots.ts b/src/roots.ts index c681f1cc3..9d2569db8 100644 --- a/src/roots.ts +++ b/src/roots.ts @@ -11,6 +11,7 @@ export class AccessDeniedError extends Error { export function expandHomePath(path: string): string { if (path === "~") return homedir(); + if (path.startsWith("~/") || path.startsWith("~\\")) { return resolve(homedir(), path.slice(2)); } @@ -34,6 +35,7 @@ export function isPathInsideRoot(path: string, root: string): boolean { export function assertAllowedPath(path: string, allowedRoots: string[]): string { const resolvedPath = resolve(expandHomePath(path)); + if (allowedRoots.some((root) => isPathInsideRoot(resolvedPath, root))) { return resolvedPath; } @@ -43,6 +45,7 @@ export function assertAllowedPath(path: string, allowedRoots: string[]): string export function resolveAllowedPath(inputPath: string, cwd: string, allowedRoots: string[]): string { const absolutePath = resolve(cwd, expandHomePath(inputPath)); + return assertAllowedPath(absolutePath, allowedRoots); } @@ -56,12 +59,14 @@ export async function resolveCanonicalAllowedPath( for (const root of allowedRoots) { let canonicalRoot: string; + try { canonicalRoot = await realpath(resolve(expandHomePath(root))); } catch (error) { if (isMissingPathError(error)) continue; throw error; } + if (isPathInsideRoot(canonicalPath, canonicalRoot)) return canonicalPath; } @@ -76,6 +81,7 @@ export async function resolvePathInsideCanonicalRoot( ): Promise { const absolutePath = resolveAllowedPath(inputPath, cwd, [logicalRoot]); const canonicalPath = await canonicalizePath(absolutePath); + if (isPathInsideRoot(canonicalPath, canonicalRoot)) return canonicalPath; throw new AccessDeniedError(`Path is outside allowed roots: ${inputPath}`); } @@ -88,12 +94,14 @@ async function canonicalizePath(path: string): Promise { try { const boundaryPath = await realpath(candidate); const suffix = relative(candidate, absolutePath); + return suffix ? resolve(boundaryPath, suffix) : boundaryPath; } catch (error) { if (!isMissingPathError(error)) throw error; try { const stats = await lstat(candidate); + if (stats.isSymbolicLink()) { throw new AccessDeniedError(`Cannot resolve symbolic link: ${path}`); } @@ -102,6 +110,7 @@ async function canonicalizePath(path: string): Promise { } const parent = dirname(candidate); + if (parent === candidate) throw error; candidate = parent; } diff --git a/src/server-oauth.test.ts b/src/server-oauth.test.ts index f9cdf5720..c3bac7c6e 100644 --- a/src/server-oauth.test.ts +++ b/src/server-oauth.test.ts @@ -14,6 +14,7 @@ test("HTTP MCP enforces canonical and exact alias bearer resources", async (t) = const root = await mkdtemp(join(tmpdir(), "devspace-http-oauth-")); const canonical = "https://agent.example.com/mcp"; const alias = "https://tunnel.example.com/v1/mcp/tunnel_123"; + const config = loadConfig(writeTestDevspaceConfig(join(root, "config"), { server: { publicBaseUrl: "https://agent.example.com" }, storage: { stateDir: join(root, "state") }, @@ -21,10 +22,13 @@ test("HTTP MCP enforces canonical and exact alias bearer resources", async (t) = oauth: { allowedResourceUrls: [alias] }, logging: { level: "silent" }, })); + const store = new SqliteOAuthStore(config.stateDir); + const client = store.registerClient({ redirect_uris: ["https://chatgpt.com/connector_platform_oauth_redirect"], }, config.oauth.allowedRedirectHosts); + const cases = [ { resource: canonical, accepted: true }, { resource: alias, accepted: true }, @@ -32,12 +36,14 @@ test("HTTP MCP enforces canonical and exact alias bearer resources", async (t) = { resource: `${alias}?other=1`, accepted: false }, { resource: "https://tunnel.example.com/v1/mcp/other", accepted: false }, ]; + for (const { resource } of cases) { store.saveAccessToken(createHash("sha256").update(resource).digest("base64url"), { clientId: client.client_id, scopes: ["devspace"], expiresAt: Math.floor(Date.now() / 1000) + 3600, resource, }); } + store.close(); const running = createServer(config); const listener = running.app.listen(0, "127.0.0.1"); @@ -50,6 +56,7 @@ test("HTTP MCP enforces canonical and exact alias bearer resources", async (t) = await once(listener, "listening"); const address = listener.address(); assert.ok(address && typeof address !== "string"); + for (const { resource, accepted } of cases) { const response: Response = await fetch(`http://127.0.0.1:${address.port}/mcp`, { method: "POST", @@ -64,8 +71,10 @@ test("HTTP MCP enforces canonical and exact alias bearer resources", async (t) = }), signal: AbortSignal.timeout(5000), }); + const body = await response.text(); assert.equal(response.status, accepted ? 200 : 401, `${resource}: ${body}`); + if (accepted) assert.match(body, /"serverInfo"/); } }); diff --git a/src/server-shutdown.test.ts b/src/server-shutdown.test.ts index 6cc3dded4..7d74dd4e1 100644 --- a/src/server-shutdown.test.ts +++ b/src/server-shutdown.test.ts @@ -34,6 +34,7 @@ test("idle connections close at drain start and remaining connections after clea let idleCloseCalls = 0; let allCloseCalls = 0; let finishIdleAwareHttpClose: (() => void) | undefined; + const idleAwareShutdown = shutdownHttpServer( { close(callback: (error?: Error) => void) { @@ -51,6 +52,7 @@ test("idle connections close at drain start and remaining connections after clea finishIdleAwareHttpClose?.(); }, ); + await idleAwareShutdown; assert.equal(idleCloseCalls, 2, "idle connections close again after application cleanup"); assert.equal(allCloseCalls, 1, "remaining connections close only after application cleanup"); @@ -75,6 +77,7 @@ test("shutdown waits for asynchronous application cleanup", async () => { immediatelyClosedHttpServer, delayedApplicationClose, ); + void delayedShutdown.then(() => { shutdownResolved = true; }); @@ -93,6 +96,7 @@ test("shutdown waits for asynchronous application cleanup", async () => { test("shutdown waits for active HTTP responses to drain", async () => { let finishDelayedHttpClose: (() => void) | undefined; let httpDrainResolved = false; + const delayedHttpDrain = shutdownHttpServer( { close(callback: (error?: Error) => void) { @@ -101,6 +105,7 @@ test("shutdown waits for active HTTP responses to drain", async () => { }, async () => {}, ); + void delayedHttpDrain.then(() => { httpDrainResolved = true; }); diff --git a/src/server-shutdown.ts b/src/server-shutdown.ts index 2caf4fc30..5eba9a5dc 100644 --- a/src/server-shutdown.ts +++ b/src/server-shutdown.ts @@ -14,6 +14,7 @@ export async function shutdownHttpServer( else resolve(); }); }); + httpServer.closeIdleConnections?.(); await closeApplication(); diff --git a/src/server.test.ts b/src/server.test.ts index c3284a955..14f37333f 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -81,6 +81,7 @@ test("experimental Code Mode bootstraps real tools and executes through MCP", as assert.doesNotMatch(codeMode.tool_signatures, /workspace_id/); await writeFile(join(context.project, "note.txt"), "hello world\n"); + const executed = structuredContent(await context.client.callTool({ name: "execute", arguments: { @@ -90,6 +91,7 @@ test("experimental Code Mode bootstraps real tools and executes through MCP", as return { content: file.result.trim(), status: found.result.trim() };`, }, })); + assert.equal(executed.status, "completed"); assert.match((executed.value as { status: string }).status, /^\?\? note\.txt/); assert.equal((executed.value as { content: string }).content, "hello world"); @@ -101,12 +103,15 @@ test("experimental Code Mode bootstraps real tools and executes through MCP", as code: `return await tools.apply_patch({ patch: "*** Begin Patch\\n*** Update File: note.txt\\n@@\\n-hello world\\n+hello code mode\\n*** End Patch" });`, }, })); + assert.equal(patched.status, "completed"); assert.equal(await readFile(join(context.project, "note.txt"), "utf8"), "hello code mode\n"); + const reviewed = structuredContent(await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, })); + assert.match(reviewed.result as string, /Changed 1 file/); }); @@ -121,16 +126,18 @@ test("Code Mode leaves the open_workspace schema unchanged when disabled", async test("experimental Code Mode polls a long-running script without restarting it", async (t) => { const context = await fixture(t, { experimentalCodeMode: true, uiEnabled: false }); const workspaceId = structuredContent(await callOpen(context.client, context.project)).workspace_id as string; + const start = structuredContent(await context.client.callTool({ name: "execute", arguments: { workspace_id: workspaceId, yield_time_ms: 0, - code: `let s = await tools.exec_command({ cmd: "node -e \\\"setTimeout(() => console.log('done'), 300)\\\"", yield_time_ms: 0 }); + code: `let s = await tools.exec_command({ cmd: "node -e \\"setTimeout(() => console.log('done'), 300)\\"", yield_time_ms: 0 }); while (s.running) s = await tools.write_stdin({ session_id: s.session_id, yield_time_ms: 1000 }); return s.exit_code;`, }, })); + assert.equal(start.running, true); assert.equal(typeof start.execution_id, "string"); @@ -138,6 +145,7 @@ test("experimental Code Mode polls a long-running script without restarting it", name: "poll_execution", arguments: { workspace_id: workspaceId, execution_id: start.execution_id, yield_time_ms: 5000 }, })); + assert.equal(end.status, "completed"); assert.equal(end.value, 0); assert.deepEqual((end.calls as { tool: string }[]).map((call) => call.tool).slice(0, 2), [ @@ -148,10 +156,12 @@ test("experimental Code Mode polls a long-running script without restarting it", test("experimental Code Mode rejects invalid tool inputs and interrupts runaway scripts", async (t) => { const context = await fixture(t, { experimentalCodeMode: true, uiEnabled: false }); const workspaceId = structuredContent(await callOpen(context.client, context.project)).workspace_id as string; + const invalid = structuredContent(await context.client.callTool({ name: "execute", arguments: { workspace_id: workspaceId, code: 'return await tools.read({ offset: -1 })' }, })); + assert.equal(invalid.status, "failed"); assert.match(invalid.error as string, /path/); @@ -159,15 +169,18 @@ test("experimental Code Mode rejects invalid tool inputs and interrupts runaway name: "execute", arguments: { workspace_id: workspaceId, code: "while (true) {}", yield_time_ms: 0 }, })); + assert.equal(start.running, true); await context.client.callTool({ name: "cancel_execution", arguments: { workspace_id: workspaceId, execution_id: start.execution_id }, }); + const cancelled = structuredContent(await context.client.callTool({ name: "poll_execution", arguments: { workspace_id: workspaceId, execution_id: start.execution_id }, })); + assert.equal(cancelled.status, "cancelled"); }); @@ -183,16 +196,19 @@ async function assertStopsGrowing(path: string): Promise { test("cancelling Code Mode stops a command whose exec_command call has not returned", async (t) => { const context = await fixture(t, { experimentalCodeMode: true, uiEnabled: false }); const workspaceId = structuredContent(await callOpen(context.client, context.project)).workspace_id as string; + const start = structuredContent(await context.client.callTool({ name: "execute", arguments: { workspace_id: workspaceId, code: TICKING_SCRIPT, yield_time_ms: 0 }, })); + await waitForFile(join(context.project, "ticks")); const cancelled = structuredContent(await context.client.callTool({ name: "cancel_execution", arguments: { workspace_id: workspaceId, execution_id: start.execution_id }, })); + assert.equal(cancelled.status, "cancelled"); await assertStopsGrowing(join(context.project, "ticks")); }); @@ -213,11 +229,14 @@ test("closing the MCP server stops running Code Mode scripts and their commands" test("Code Mode rejects scripts beyond the server-wide running limit", async (t) => { const context = await fixture(t, { experimentalCodeMode: true, uiEnabled: false }); const workspaceId = structuredContent(await callOpen(context.client, context.project)).workspace_id as string; + const execute = () => context.client.callTool({ name: "execute", arguments: { workspace_id: workspaceId, code: "while (true) {}", yield_time_ms: 0 }, }); + const running = []; + for (let index = 0; index < MAX_RUNNING_EXECUTIONS; index += 1) { running.push(structuredContent(await execute()).execution_id as string); } @@ -238,6 +257,7 @@ test("model-facing tool schemas use snake_case recursively", async (t) => { await t.test(surface, async (nested) => { const context = await fixture(nested, { surface, uiEnabled: false }); const tools = await context.client.listTools(); + const invalidPaths = tools.tools.flatMap((tool) => [ ...schemaPropertyPaths(tool.inputSchema) .filter(({ key }) => !/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(key)) @@ -258,6 +278,7 @@ test("Codex process tools bound model-facing yield windows to 12 seconds", async for (const toolName of ["exec_command", "write_stdin"] as const) { const tool = tools.tools.find(({ name }) => name === toolName); + const yieldSchema = tool?.inputSchema?.properties?.yield_time_ms as { maximum?: number; description?: string; @@ -270,9 +291,11 @@ test("Codex process tools bound model-facing yield windows to 12 seconds", async test("Claude edit and bash tools accept snake_case runtime inputs", async (t) => { const context = await fixture(t, { surface: "claude", uiEnabled: false }); + const workspaceId = structuredContent( await callOpen(context.client, context.project, "snake-case-claude"), ).workspace_id; + assert.equal(typeof workspaceId, "string"); await writeFile(join(context.project, "note.txt"), "before\n"); @@ -286,6 +309,7 @@ test("Claude edit and bash tools accept snake_case runtime inputs", async (t) => edits: [{ old_text: "before", new_text: "after" }], }, }); + assert.equal(edited.isError, undefined); assert.equal(await readFile(join(context.project, "note.txt"), "utf8"), "after\n"); @@ -297,6 +321,7 @@ test("Claude edit and bash tools accept snake_case runtime inputs", async (t) => working_directory: "nested", }, })); + assert.match(shell.result as string, /nested/i); }); @@ -308,15 +333,18 @@ test("read rejects a symlink that leaves the workspace", async (t) => { const outsideLink = join(context.project, "outside-link"); await symlink(outside, outsideLink, platform() === "win32" ? "junction" : "dir"); + const workspaceId = structuredContent( await callOpen(context.client, context.project, "symlink-read"), ).workspace_id; + assert.equal(typeof workspaceId, "string"); const result = await context.client.callTool({ name: "read", arguments: { workspace_id: workspaceId, path: "outside-link/secret.txt" }, }); + assert.equal(result.isError, true); }); @@ -327,9 +355,11 @@ test("write rejects a new file through a symlink that leaves the workspace", asy const outsideLink = join(context.project, "outside-link"); await symlink(outside, outsideLink, platform() === "win32" ? "junction" : "dir"); + const workspaceId = structuredContent( await callOpen(context.client, context.project, "symlink-write"), ).workspace_id; + assert.equal(typeof workspaceId, "string"); const result = await context.client.callTool({ @@ -340,6 +370,7 @@ test("write rejects a new file through a symlink that leaves the workspace", asy content: "escaped\n", }, }); + assert.equal(result.isError, true); await assert.rejects(access(join(outside, "new.txt"))); }); @@ -349,6 +380,7 @@ test("UI metadata is limited to workspace and aggregate review", async (t) => { await t.test(uiEnabled ? "enabled" : "disabled", async (nested) => { const context = await fixture(nested, { surface: "claude", uiEnabled }); const tools = await context.client.listTools(); + const toolsWithUi = tools.tools .filter((tool) => Boolean((tool._meta as { ui?: unknown } | undefined)?.ui)) .map((tool) => tool.name) @@ -369,10 +401,12 @@ test("show_changes reviews an unborn repository through the MCP tool surface", a assert.deepEqual(opened.review, { available: true }); await writeFile(join(context.project, "created-after-open.txt"), "new file\n"); + const review = await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, }); + const card = responseCard(review); assert.deepEqual(card.files, [ @@ -394,17 +428,21 @@ test("show_changes reviews an unborn repository through the MCP tool surface", a test("show_changes keeps model output compact and preserves the rich review card", async (t) => { const context = await fixture(t, { git: true, uiEnabled: false }); + const opened = structuredContent( await callOpen(context.client, context.project, "review"), ); + const workspaceId = opened.workspace_id; assert.equal(typeof workspaceId, "string"); await writeFile(join(context.project, "README.md"), "goodbye\n"); + const review = await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, }); + const structured = structuredContent(review); assert.equal((review._meta as Record | undefined)?.tool, undefined); @@ -435,40 +473,50 @@ test("show_changes keeps model output compact and preserves the rich review card ); const tools = await context.client.listTools(); + const outputProperties = tools.tools.find((tool) => tool.name === "show_changes") ?.outputSchema?.properties; + assert.ok(outputProperties && "workspace_id" in outputProperties); assert.equal(outputProperties && "workspaceId" in outputProperties, false); assert.ok(outputProperties && "review_ref" in outputProperties); assert.equal(outputProperties && "summary" in outputProperties, false); assert.equal(outputProperties && "files" in outputProperties, false); assert.equal(outputProperties && "patch" in outputProperties, false); + const inputProperties = tools.tools.find((tool) => tool.name === "show_changes") ?.inputSchema?.properties; + assert.equal(inputProperties && "reviewRef" in inputProperties, false); }); test("show_changes can reopen a historical review without advancing the checkpoint", async (t) => { const context = await fixture(t, { git: true }); + const workspaceId = structuredContent( await callOpen(context.client, context.project, "review-history"), ).workspace_id; + assert.equal(typeof workspaceId, "string"); await writeFile(join(context.project, "README.md"), "first\n"); + const first = structuredContent(await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, })); + const reviewRef = first.review_ref; assert.equal(typeof reviewRef, "string"); await writeFile(join(context.project, "README.md"), "second\n"); + const reopened = await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, _meta: { "devspace/reviewRef": reviewRef }, } as Parameters[0]); + assert.equal(structuredContent(reopened).review_ref, reviewRef); assert.match( (((responseCard(reopened).payload as { patch?: string } | undefined)?.patch) ?? ""), @@ -479,6 +527,7 @@ test("show_changes can reopen a historical review without advancing the checkpoi name: "show_changes", arguments: { workspace_id: workspaceId }, }); + assert.match( (((responseCard(current).payload as { patch?: string } | undefined)?.patch) ?? ""), /-first\n\+second/, @@ -488,9 +537,11 @@ test("show_changes can reopen a historical review without advancing the checkpoi test("workspace activity records turns between review checkpoints", async (t) => { const context = await fixture(t, { git: true, captureActivity: true }); const activity = context.activity!; + const workspaceId = structuredContent( await callOpen(context.client, context.project, "activity-turns"), ).workspace_id as string; + const patch = (from: string, to: string) => context.client.callTool({ name: "apply_patch", arguments: { @@ -498,6 +549,7 @@ test("workspace activity records turns between review checkpoints", async (t) => patch: `*** Begin Patch\n*** Update File: README.md\n@@\n-${from}\n+${to}\n*** End Patch`, }, }); + const showChanges = async () => structuredContent(await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, @@ -538,15 +590,18 @@ test("workspace activity records turns between review checkpoints", async (t) => const secondDiff = await readTurnDiff(activity, workspaceId, 2); assert.equal(secondDiff?.status, "available"); + if (secondDiff?.status === "available") { assert.match(secondDiff.review.patch, /-hello turn one\n\+hello turn two/); assert.doesNotMatch(secondDiff.review.patch, /^-hello$/m); } + assert.deepEqual(await readTurnDiff(activity, workspaceId, 3), { status: "open" }); const pinned = await execFileAsync("git", ["for-each-ref", "--format=%(refname)", "refs/devspace/review"], { cwd: context.project, }); + for (const ref of [firstRef, secondRef]) assert.match(pinned.stdout, new RegExp(`/checkpoints/${ref}$`, "m")); const [listed] = await listActivityWorkspaces(activity); @@ -556,9 +611,11 @@ test("workspace activity records turns between review checkpoints", async (t) => test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { const providerNote = "available"; + const context = await fixture(t, { localAgentProviders: [{ name: "codex", available: true, note: providerNote }], }); + const first = await callOpen(context.client, context.project, "chat-1"); const repeated = await callOpen(context.client, context.project, "chat-1"); assert.equal((first._meta as Record | undefined)?.tool, undefined); @@ -571,9 +628,11 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.equal(outputProperties && "workspaceId" in outputProperties, false); assert.equal(outputProperties && "workspaceReused" in outputProperties, false); assert.equal(outputProperties && "includeBootstrapContext" in outputProperties, false); + const providerSchema = outputProperties?.agent_providers as { items?: { properties?: Record }; } | undefined; + assert.ok(providerSchema?.items?.properties?.note); const firstStructured = structuredContent(first); @@ -627,6 +686,7 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com test("open_workspace refreshes provider availability for each catalog", async (t) => { let available = false; + const context = await fixture(t, { localAgentProviders: () => [{ name: "codex", available }], }); @@ -715,9 +775,11 @@ test("open_workspace scopes checkout reuse to OpenAI session metadata", async (t test("HTTP cached workspaceId arguments preserve canonical schemas and validation", async (t) => { const { root, localBaseUrl, accessToken } = await httpServerFixture(t, "devspace-cached-arguments-test-"); await writeFile(join(root, "note.txt"), "cached argument regression\n"); + const opened = await postModernMcp(localBaseUrl, accessToken, "tools/call", { name: "open_workspace", arguments: { path: root }, }); + const workspaceId = (await opened.json()).result.structuredContent.workspace_id as string; assert.equal(typeof workspaceId, "string"); @@ -729,6 +791,7 @@ test("HTTP cached workspaceId arguments preserve canonical schemas and validatio const response = await postModernMcp(localBaseUrl, accessToken, "tools/call", { name: "read", arguments: argumentsValue, }); + const body = await response.json(); assert.equal(response.status, 200, JSON.stringify(body)); assert.equal(body.error, undefined, JSON.stringify(body)); @@ -739,6 +802,7 @@ test("HTTP cached workspaceId arguments preserve canonical schemas and validatio const conflict = await postModernMcp(localBaseUrl, accessToken, "tools/call", { name: "read", arguments: { workspaceId, workspace_id: "different", path: "note.txt" }, }); + assert.equal(conflict.status, 400); assert.equal((await conflict.json()).error.code, -32602); @@ -746,6 +810,7 @@ test("HTTP cached workspaceId arguments preserve canonical schemas and validatio const invalid = await postModernMcp(localBaseUrl, accessToken, "tools/call", { name: "read", arguments: { workspaceId, path: "note.txt" }, }); + const body = await invalid.json(); assert.ok(body.error || body.result?.isError, JSON.stringify(body)); assert.notEqual(invalid.status, 500); @@ -754,10 +819,13 @@ test("HTTP cached workspaceId arguments preserve canonical schemas and validatio const unauthorized = await postModernMcp(localBaseUrl, undefined, "tools/call", { name: "read", arguments: { workspaceId, workspace_id: "different", path: "note.txt" }, }); + assert.equal(unauthorized.status, 401); + const outside = await postModernMcp(localBaseUrl, accessToken, "tools/call", { name: "read", arguments: { workspaceId, path: "../outside.txt" }, }); + assert.equal((await outside.json()).result.isError, true); const listed = await postModernMcp(localBaseUrl, accessToken, "tools/list", {}); @@ -769,6 +837,7 @@ test("HTTP cached workspaceId arguments preserve canonical schemas and validatio test("HTTP conflicting workspace aliases preserve request IDs and notification semantics", async (t) => { const { localBaseUrl, accessToken } = await httpServerFixture(t, "devspace-cached-id-test-"); + for (const id of ["cached-request", 37, null, undefined]) { await t.test(id === undefined ? "notification" : `request ID ${JSON.stringify(id)}`, async () => { const response = await fetch(`${localBaseUrl}/mcp`, { @@ -785,6 +854,7 @@ test("HTTP conflicting workspace aliases preserve request IDs and notification s params: { name: "read", arguments: { workspaceId: "old", workspace_id: "new", path: "note.txt" } }, }), }); + if (id === undefined) { assert.equal(response.status, 202); assert.equal(await response.text(), ""); @@ -806,13 +876,17 @@ test("HTTP endpoint picks the tool surface from the OAuth client", async (t) => const surfaceFor = async (clientName: string) => { const token = await issueAccessToken(clientName); + const [listed, discovered] = await Promise.all([ postModernMcp(localBaseUrl, token, "tools/list", {}), postModernMcp(localBaseUrl, token, "server/discover", {}), ]); + const tools = (await listed.json() as { result: { tools: Array<{ name: string }> } }) .result.tools.map((tool) => tool.name); + const { instructions } = (await discovered.json() as { result: { instructions: string } }).result; + return { tools, instructions }; }; @@ -837,9 +911,12 @@ test("HTTP endpoint honours an explicit tools.mode over host detection", async ( false, { tools: { mode: "claude" } }, ); + const listed = await postModernMcp(localBaseUrl, await issueAccessToken("ChatGPT"), "tools/list", {}); + const tools = (await listed.json() as { result: { tools: Array<{ name: string }> } }) .result.tools.map((tool) => tool.name); + assert.ok(tools.includes("edit") && !tools.includes("apply_patch")); }); @@ -855,6 +932,7 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = "tools/list", {}, ); + assert.equal(unauthenticated.status, 401, await unauthenticated.clone().text()); const discovery = await postModernMcp( @@ -863,10 +941,13 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = "server/discover", {}, ); + assert.equal(discovery.status, 200, await discovery.clone().text()); + const discoveryBody = await discovery.json() as { result?: { supportedVersions?: string[] }; }; + assert.ok(discoveryBody.result?.supportedVersions?.includes("2026-07-28")); const listed = await postModernMcp( @@ -875,12 +956,15 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = "tools/list", {}, ); + assert.equal(listed.status, 200, await listed.clone().text()); assert.equal(listed.headers.get("connection"), "keep-alive"); assert.match(listed.headers.get("keep-alive") ?? "", /timeout=300/); + const listBody = await listed.json() as { result?: { tools?: Array<{ name?: string }> }; }; + assert.ok(listBody.result?.tools?.some((tool) => tool.name === "open_workspace")); const called = await postModernMcp( @@ -893,10 +977,13 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = _meta: { "openai/session": "modern-http-test" }, }, ); + assert.equal(called.status, 200, await called.clone().text()); + const callBody = await called.json() as { result?: { structuredContent?: { workspace_id?: string; agents_files?: unknown[] } }; }; + const workspaceId = callBody.result?.structuredContent?.workspace_id; assert.equal(typeof workspaceId, "string"); @@ -910,10 +997,13 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = _meta: { "openai/session": "modern-http-test" }, }, ); + assert.equal(repeated.status, 200, await repeated.clone().text()); + const repeatedBody = await repeated.json() as { result?: { structuredContent?: { workspace_id?: string; agents_files?: unknown[] } }; }; + assert.equal(repeatedBody.result?.structuredContent?.workspace_id, workspaceId); assert.equal(repeatedBody.result?.structuredContent?.agents_files, undefined); @@ -935,6 +1025,7 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = }, }), }); + assert.equal(legacy.status, 200, await legacy.clone().text()); assert.equal(legacy.headers.get("mcp-session-id"), null); assert.match(await legacy.text(), /"protocolVersion"/); @@ -953,6 +1044,7 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = params: {}, }), }); + assert.equal(legacyTools.status, 200, await legacyTools.clone().text()); assert.equal(legacyTools.headers.get("mcp-session-id"), null); assert.match(await legacyTools.text(), /"open_workspace"/); @@ -963,6 +1055,7 @@ test("aborting one MCP request does not poison the next stateless request", asyn t, "devspace-aborted-http-test-", ); + const opened = await postModernMcp( localBaseUrl, accessToken, @@ -973,13 +1066,16 @@ test("aborting one MCP request does not poison the next stateless request", asyn _meta: { "openai/session": "aborted-http-test" }, }, ); + const openBody = await opened.json() as { result?: { structuredContent?: { workspace_id?: string } }; }; + const workspaceId = openBody.result?.structuredContent?.workspace_id; assert.equal(typeof workspaceId, "string"); const controller = new AbortController(); + const toolCall = postModernMcp( localBaseUrl, accessToken, @@ -995,6 +1091,7 @@ test("aborting one MCP request does not poison the next stateless request", asyn }, { signal: controller.signal }, ); + await waitForFile(join(root, "started")); controller.abort(); await assert.rejects(toolCall, /abort/i); @@ -1012,6 +1109,7 @@ test("experimental Code Mode executes over stateless HTTP MCP requests", async ( "devspace-codemode-http-test-", true, ); + await writeFile(join(root, "note.txt"), "through HTTP\n"); const listed = await postModernMcp(localBaseUrl, accessToken, "tools/list", {}); @@ -1024,9 +1122,11 @@ test("experimental Code Mode executes over stateless HTTP MCP requests", async ( name: "open_workspace", arguments: { path: root }, }); + const response = await opened.json() as { result?: { structuredContent?: { workspace_id?: string; code_mode?: { tool_signatures: string } } }; }; + const workspaceId = response.result?.structuredContent?.workspace_id; assert.ok(workspaceId); assert.match(response.result?.structuredContent?.code_mode?.tool_signatures ?? "", /tools/); @@ -1038,10 +1138,13 @@ test("experimental Code Mode executes over stateless HTTP MCP requests", async ( code: 'return (await tools.read({ path: "note.txt" })).result.trim()', }, }); + assert.equal(call.status, 200, await call.clone().text()); + const result = await call.json() as { result?: { structuredContent?: { status?: string; value?: string } }; }; + assert.equal(result.result?.structuredContent?.status, "completed"); assert.equal(result.result?.structuredContent?.value, "through HTTP"); }); @@ -1051,6 +1154,7 @@ test("server shutdown waits for an active MCP tool call", async (t) => { t, "devspace-shutdown-test-", ); + const opened = await postModernMcp( localBaseUrl, accessToken, @@ -1061,9 +1165,11 @@ test("server shutdown waits for an active MCP tool call", async (t) => { _meta: { "openai/session": "shutdown-test" }, }, ); + const openBody = await opened.json() as { result?: { structuredContent?: { workspace_id?: string } }; }; + const workspaceId = openBody.result?.structuredContent?.workspace_id; assert.equal(typeof workspaceId, "string"); @@ -1072,6 +1178,7 @@ test("server shutdown waits for an active MCP tool call", async (t) => { "fs.writeFileSync('started','')", "const timer=setInterval(()=>{if(fs.existsSync('release')) clearInterval(timer)},10)", ].join(";"); + const toolCall = postModernMcp( localBaseUrl, accessToken, @@ -1080,17 +1187,20 @@ test("server shutdown waits for an active MCP tool call", async (t) => { name: "exec_command", arguments: { workspace_id: workspaceId, - cmd: `node -e \"${command}\"`, + cmd: `node -e "${command}"`, yield_time_ms: 12_000, }, }, ); + await waitForFile(join(root, "started")); let shutdownFinished = false; + const shutdown = running.close().then(() => { shutdownFinished = true; }); + await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(shutdownFinished, false); @@ -1112,6 +1222,7 @@ function schemaPropertyPaths( prefix = "", ): Array<{ key: string; path: string }> { if (!schema || typeof schema !== "object") return []; + const record = schema as { properties?: Record; items?: unknown; @@ -1119,16 +1230,21 @@ function schemaPropertyPaths( oneOf?: unknown[]; allOf?: unknown[]; }; + const paths = Object.entries(record.properties ?? {}).flatMap(([key, child]) => { const path = prefix ? `${prefix}.${key}` : key; + return [{ key, path }, ...schemaPropertyPaths(child, path)]; }); + if (record.items) paths.push(...schemaPropertyPaths(record.items, `${prefix}[]`)); + for (const variant of [record.anyOf, record.oneOf, record.allOf]) { for (const child of variant ?? []) { paths.push(...schemaPropertyPaths(child, prefix)); } } + return paths; } @@ -1148,6 +1264,7 @@ async function httpServerFixture( ): Promise { const root = await mkdtemp(join(tmpdir(), prefix)); const ownerToken = "test-owner-token-that-is-long-enough"; + const config = { ...loadConfig(writeTestDevspaceConfig(join(root, ".config"), { server: { port: 1, @@ -1160,6 +1277,7 @@ async function httpServerFixture( storage: { stateDir: join(root, ".state") }, ...extraConfig, })), experimentalCodeMode }; + const running = createServer(config, { incomingArtifactAdapters: [] }); const httpServer = running.app.listen(0, "127.0.0.1"); await new Promise((resolve) => httpServer.once("listening", resolve)); @@ -1173,14 +1291,17 @@ async function httpServerFixture( const address = httpServer.address(); assert.ok(address && typeof address === "object"); const localBaseUrl = `http://127.0.0.1:${address.port}`; + const issueAccessToken = (clientName: string) => issueTestAccessToken( localBaseUrl, config.publicBaseUrl, ownerToken, clientName, ); + // Named as ChatGPT so HTTP tests get the Codex surface; unknown hosts get Claude tools. const accessToken = await issueAccessToken("ChatGPT"); + return { root, localBaseUrl, accessToken, issueAccessToken, running }; } @@ -1226,6 +1347,7 @@ async function fixture( const initialProviderAvailability = typeof options.localAgentProviders === "function" ? options.localAgentProviders() : options.localAgentProviders ?? []; + const loadedConfig = loadConfig(writeTestDevspaceConfig(join(root, ".config"), { server: { port: 1 }, workspaces: { allowedRoots: [root], worktreeRoot: join(root, ".worktrees") }, @@ -1236,11 +1358,13 @@ async function fixture( providers: [], }, })); + const modeConfig: ServerConfig = { ...loadedConfig, uiEnabled: options.uiEnabled ?? loadedConfig.uiEnabled, experimentalCodeMode: options.experimentalCodeMode ?? loadedConfig.experimentalCodeMode, }; + const config: ServerConfig = options.localAgentProviders ? { ...modeConfig, @@ -1251,25 +1375,32 @@ async function fixture( if (!isLocalAgentDriverKind(provider.name)) { throw new Error(`Test provider instance requires explicit config: ${provider.name}`); } + return { id: provider.name, driver: provider.name, enabled: true }; }), }, } : modeConfig; + const resolveProviderAvailability: () => LocalAgentProviderAvailability[] = typeof options.localAgentProviders === "function" ? options.localAgentProviders : () => initialProviderAvailability; + const resolveLocalAgentProviders = () => buildLocalAgentProviderStatuses( config.subagents, resolveProviderAvailability(), ); + const store = new SqliteWorkspaceStore(stateDir); + const activity = options.captureActivity ? new WorkspaceActivityStore(stateDir) : undefined; + const activityJournal = activity ? new WorkspaceActivityJournal(activity) : undefined; const workspaces = new WorkspaceRegistry(config, store); + const server = createMcpServer( config, options.surface ?? "codex", @@ -1281,6 +1412,7 @@ async function fixture( undefined, activityJournal, ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "devspace-test-client", version: "1.0.0" }); await Promise.all([ @@ -1289,6 +1421,7 @@ async function fixture( ]); let closed = false; + const close = async () => { if (closed) return; closed = true; @@ -1314,11 +1447,13 @@ async function waitForFile(path: string): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { try { await access(path); + return; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); } } + assert.fail(`Timed out waiting for ${path}`); } @@ -1332,6 +1467,7 @@ async function issueTestAccessToken( const resource = new URL("/mcp", publicBaseUrl).href; const verifier = "devspace-modern-protocol-test-verifier-0123456789"; const challenge = createHash("sha256").update(verifier).digest("base64url"); + const registration = await fetch(`${localBaseUrl}/register`, { method: "POST", headers: { "content-type": "application/json" }, @@ -1343,6 +1479,7 @@ async function issueTestAccessToken( token_endpoint_auth_method: "none", }), }); + assert.equal(registration.status, 201, await registration.clone().text()); const client = await registration.json() as { client_id?: string }; assert.ok(client.client_id); @@ -1363,6 +1500,7 @@ async function issueTestAccessToken( }), redirect: "manual", }); + assert.equal(approval.status, 302, await approval.clone().text()); const location = approval.headers.get("location"); assert.ok(location); @@ -1381,9 +1519,11 @@ async function issueTestAccessToken( resource, }), }); + assert.equal(exchange.status, 200, await exchange.clone().text()); const tokens = await exchange.json() as { access_token?: string }; assert.ok(tokens.access_token); + return tokens.access_token; } @@ -1399,6 +1539,7 @@ function postModernMcp( : typeof params.uri === "string" ? params.uri : undefined; + return fetch(`${localBaseUrl}/mcp`, { method: "POST", headers: { @@ -1447,11 +1588,13 @@ async function callOpen( ? { _meta: { "openai/session": conversationScopeId } } : {}), } as Parameters[0]; + return client.callTool(params); } function structuredContent(result: Awaited>): Record { assert.ok(result.structuredContent); + return result.structuredContent as Record; } @@ -1460,5 +1603,6 @@ function responseCard(result: Awaited>): Record).card; assert.ok(card && typeof card === "object"); + return card as Record; } diff --git a/src/server.ts b/src/server.ts index d78617a1e..8efb9b07b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -120,6 +120,7 @@ class ToolActivityTracker { this.active.add(promise); const remove = () => this.active.delete(promise); void promise.then(remove, remove); + return promise; }; @@ -145,15 +146,19 @@ function serverInstructions( if (config.experimentalCodeMode) { return "Call open_workspace once per project or worktree, then use the returned Code Mode tool signatures to call workspace primitives through execute. Await relevant operations; use poll_execution for long-running scripts and cancel_execution when no longer needed. Call show_changes after file modifications."; } + const artifactInstruction = config.artifactsEnabled && isArtifactDownloadSupportedPlatform() ? " When the user provides an attached or generated file that needs to be added to the workspace, pass the provided file directly to download_artifact with the existing workspace_id and a suitable relative destination path. Do not reconstruct attached files manually." : ""; + const showChangesInstruction = " If files are modified, call show_changes once after the final related change and before the final response."; + const skills = config.skillsEnabled ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, use ${toolNames.read} with the returned skill path before proceeding. ` : ""; + const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in available_agents_files, use ${toolNames.read} to inspect that instruction file and follow it. `; const common = `Call ${toolNames.openWorkspace} when starting work in a project folder or isolated worktree without a usable workspace_id, then reuse the returned workspace_id for subsequent operations in that workspace.`; @@ -168,6 +173,7 @@ function formatVisibleAgent(agent: { }): string { const model = agent.model ? `, model ${agent.model}` : ""; const effort = agent.effort ? `, effort ${agent.effort}` : ""; + return `${agent.name} (${agent.provider}${model}${effort})`; } @@ -182,6 +188,7 @@ function formatAvailableAgentProvider(provider: { provider.effort ? `effort ${provider.effort}` : undefined, provider.note, ].filter(Boolean).join(", "); + return `${provider.id}${details ? ` (${details})` : ""}`; } @@ -234,18 +241,25 @@ function sendJsonRpcError( function normalizeCachedWorkspaceArgument(body: unknown): boolean { if (!body || typeof body !== "object" || Array.isArray(body)) return true; const request = body as Record; + if (request.method !== "tools/call") return true; const params = request.params; + if (!params || typeof params !== "object" || Array.isArray(params)) return true; const args = (params as Record).arguments; + if (!args || typeof args !== "object" || Array.isArray(args)) return true; const values = args as Record; + if (!Object.hasOwn(values, "workspaceId")) return true; + if (Object.hasOwn(values, "workspace_id") && values.workspace_id !== values.workspaceId) { return false; } + if (!Object.hasOwn(values, "workspace_id")) values.workspace_id = values.workspaceId; delete values.workspaceId; + return true; } @@ -263,6 +277,7 @@ function requestLogFields(req: Request, config: ServerConfig): Record { if (!body || typeof body !== "object" || Array.isArray(body)) return {}; const request = body as { id?: unknown; method?: unknown }; + return { rpcId: request.id, rpcMethod: request.method, @@ -316,6 +331,7 @@ function assetUrl(baseUrl: string, assetPath: string): string { function workspaceAppHtml(config: ServerConfig): string { const baseUrl = assetBaseUrl(config); const entry = getWorkspaceAppManifestEntry(); + const stylesheets = (entry.css ?? []) .map( (stylesheet) => @@ -345,6 +361,7 @@ function appCsp(config: ServerConfig): { connectDomains: string[]; } { const publicBaseUrl = config.publicBaseUrl.replace(/\/+$/, ""); + return { resourceDomains: [publicBaseUrl], connectDomains: [publicBaseUrl], @@ -364,6 +381,7 @@ function setAssetHeaders(res: Response): void { async function assertWorkspaceAppAssets(): Promise { const entry = getWorkspaceAppManifestEntry(); + const candidates = [entry.file, ...(entry.css ?? [])].map( (assetPath) => new URL(`../dist/ui/${assetPath}`, import.meta.url), ); @@ -385,6 +403,7 @@ export function createMcpServer( workspaceActivityJournal?: WorkspaceActivityJournal, ): McpServer { const toolSurface = getToolSurface(config.experimentalCodeMode ? "codex" : surface); + const server = new McpServer( mcpServerInfo(), { @@ -408,6 +427,7 @@ export function createMcpServer( codeModeExecutions, workspaceActivityJournal, ); + if (codeModeExecutions) { // Scripts and the commands they start must not outlive the MCP server that ran them. const close = server.close.bind(server); @@ -415,12 +435,14 @@ export function createMcpServer( await codeModeExecutions.shutdown(); await close(); }; + const onclose = server.server.onclose; server.server.onclose = () => { onclose?.(); void codeModeExecutions.shutdown(); }; } + return server; } @@ -441,6 +463,7 @@ function registerMcpSurface( const registrationTarget = trackToolActivity || workspaceActivityJournal ? withObservedToolHandlers(server, { trackToolActivity, workspaceActivityJournal }) : server; + const internalTarget = codeModeRegistry?.registrationTarget ?? registrationTarget; registerAppResource( @@ -457,6 +480,7 @@ function registerMcpSurface( }, async () => { await assertWorkspaceAppAssets(); + return { contents: [ { @@ -540,6 +564,7 @@ function registerMcpSurface( async ({ path, mode, base_ref }, { _meta }) => { const startedAt = performance.now(); const baseRef = base_ref; + const { workspace, agentsFiles, @@ -550,16 +575,21 @@ function registerMcpSurface( { path, mode, baseRef }, { conversationScopeId: conversationScopeIdFromRequestMeta(_meta) }, ); + const review = await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, }); + const preloadSubagents = config.subagents.enabled && config.subagents.instructions === "preload"; + const subagentsSkill = workspace.skills.find((skill) => skill.name === "subagents"); + const preloadedSubagentInstructions = preloadSubagents && subagentsSkill ? readFileSync(subagentsSkill.filePath, "utf8") : undefined; + const cardSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) .filter((skill) => !(preloadSubagents && skill.name === "subagents")) @@ -568,11 +598,13 @@ function registerMcpSurface( description: skill.description, path: formatPathForPrompt(skill.filePath), })); + const agentCatalog = buildLocalAgentCatalog( config.subagents, workspace.agentProfiles, resolveLocalAgentProviders(), ); + const cardAgentProviders = agentCatalog.providers .filter((provider) => provider.usable) .map((provider) => ({ @@ -581,22 +613,28 @@ function registerMcpSurface( effort: provider.effort, note: provider.note, })); + const cardAgents = agentCatalog.profiles; + const cardAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, })); + const cardAvailableAgentsFiles = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); + const visibleSkills = includeBootstrapContext ? cardSkills : []; const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : []; const visibleAgents = includeBootstrapContext ? cardAgents : []; const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; + const cardInstruction = config.skillsEnabled ? "Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agents_files instructions. Before working under a path listed in available_agents_files, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." : "Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agents_files instructions. Before working under a path listed in available_agents_files, read that instruction file."; + const workspaceInstruction = workspaceReused ? [ `Workspace already open as ${workspace.id}.`, @@ -606,6 +644,7 @@ function registerMcpSurface( : workspace.mode === "worktree" ? "Use this workspace_id for subsequent work in this isolated worktree. Keep reusing it while working in this worktree. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for it." : cardInstruction; + const instruction = preloadedSubagentInstructions && includeBootstrapContext ? [ workspaceInstruction, @@ -613,9 +652,11 @@ function registerMcpSurface( preloadedSubagentInstructions, ].join("\n\n") : workspaceInstruction; + const codeMode = codeModeRegistry ? { language: "javascript" as const, tool_signatures: codeModeRegistry.signatures() } : undefined; + const resultContent: ToolContent[] = [ { type: "text" as const, @@ -653,6 +694,7 @@ function registerMcpSurface( ].filter(Boolean).join("\n"), }, ]; + logToolCall(config, { tool: "open_workspace", workspaceId: workspace.id, @@ -769,6 +811,7 @@ function registerMcpSurface( const workspaceId = workspace_id; const workspace = await workspaces.getWorkspace(workspaceId); const readPath = await workspaces.resolveReadPath(workspace, input.path); + const response = await readFileTool( { ...input, path: readPath.absolutePath }, { cwd: workspace.root }, @@ -780,6 +823,7 @@ function registerMcpSurface( workspaceId, path: input.path, }, response.content, startedAt); + return response; } @@ -806,6 +850,7 @@ function registerMcpSurface( workspaces, processSessions, }); + if (codeModeExecutions) { registerCodeModeTools(registrationTarget, workspaces, codeModeExecutions); } @@ -831,9 +876,11 @@ function registerMcpSurface( const startedAt = performance.now(); const workspaceId = workspace_id; const workspace = await workspaces.getWorkspace(workspaceId); + const reviewRef = typeof _meta?.["devspace/reviewRef"] === "string" ? _meta["devspace/reviewRef"] : undefined; + const review = reviewRef ? await reviewCheckpoints.reviewByRef({ workspaceId, @@ -890,6 +937,7 @@ function executionResponse(snapshot: ExecutionSnapshot) { status, duration_ms: Math.round(durationMs), })); + const structured = { ...snapshot, calls, @@ -897,6 +945,7 @@ function executionResponse(snapshot: ExecutionSnapshot) { result: snapshot.result ?? null, output: snapshot.output ?? [], }; + return { content: [textBlock(JSON.stringify(structured))], structuredContent: { ...structured, result: JSON.stringify(structured.result) }, @@ -909,6 +958,7 @@ function registerCodeModeTools( executions: CodeModeExecutions, ): void { const yieldSchema = z.number().int().min(0).max(MAX_CODE_MODE_YIELD_MS).optional(); + const outputSchema = resultOutputSchema({ execution_id: z.string(), running: z.boolean(), @@ -922,6 +972,7 @@ function registerCodeModeTools( duration_ms: z.number(), })).optional(), }); + target.registerTool("execute", { title: "Execute JavaScript", description: "Execute confined JavaScript using tools declared by open_workspace. If still running after the yield window, returns execution_id for poll_execution.", @@ -933,6 +984,7 @@ function registerCodeModeTools( outputSchema, }, async ({ workspace_id, code, yield_time_ms }) => { await workspaces.getWorkspace(workspace_id); + return executionResponse(await executions.start(workspace_id, code, yield_time_ms)); }); target.registerTool("poll_execution", { @@ -947,6 +999,7 @@ function registerCodeModeTools( annotations: { readOnlyHint: true }, }, async ({ workspace_id, execution_id, yield_time_ms }) => { await workspaces.getWorkspace(workspace_id); + return executionResponse(await executions.poll(workspace_id, execution_id, yield_time_ms)); }); target.registerTool("cancel_execution", { @@ -956,6 +1009,7 @@ function registerCodeModeTools( outputSchema, }, async ({ workspace_id, execution_id }) => { await workspaces.getWorkspace(workspace_id); + return executionResponse(await executions.cancel(workspace_id, execution_id)); }); } @@ -971,10 +1025,12 @@ function withObservedToolHandlers( registerTool: ((...args: unknown[]) => { const toolName = args[0] as string; const handler = args.at(-1) as (...handlerArgs: unknown[]) => unknown; + return (server.registerTool as (...callArgs: unknown[]) => unknown)( ...args.slice(0, -1), (...handlerArgs: unknown[]) => { const operation = () => Promise.resolve(handler(...handlerArgs)); + const observedOperation = options.workspaceActivityJournal ? () => options.workspaceActivityJournal!.capture({ toolName, @@ -983,6 +1039,7 @@ function withObservedToolHandlers( operation, }) : operation; + return options.trackToolActivity ? options.trackToolActivity(observedOperation) : observedOperation(); @@ -1003,21 +1060,26 @@ export function createServer( ): RunningServer { const incomingArtifactAdapters = options.incomingArtifactAdapters ?? [createOpenAIIncomingArtifactAdapter()]; + const allowedHosts = config.allowedHosts.includes("*") ? undefined : Array.from(new Set([config.host, ...config.allowedHosts])); + const app = createMcpExpressApp({ host: config.host, ...(allowedHosts ? { allowedHosts } : {}), }); + const mcpUrl = new URL("/mcp", config.publicBaseUrl); const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl); const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir); + const bearerAuth = requireBearerAuth({ verifier: oauthProvider, requiredScopes: [config.oauth.scopes[0] ?? "devspace"], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl), }); + const workspaceStore = createWorkspaceStore(config.stateDir); const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); @@ -1026,6 +1088,7 @@ export function createServer( const codeModeExecutions = codeModeRegistry ? new CodeModeExecutions(codeModeRegistry, processSessions) : undefined; const toolActivities = new ToolActivityTracker(); const workspaceActivity = new WorkspaceActivityStore(config.stateDir, config.activity.maxTurns); + const workspaceActivityJournal = config.activity.enabled ? new WorkspaceActivityJournal(workspaceActivity, (error) => { logEvent(config.logging, "warn", "workspace_activity_journal_error", { @@ -1033,16 +1096,20 @@ export function createServer( }); }) : undefined; + const localAgentProviders = buildLocalAgentProviderStatuses( config.subagents, getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); + const resolveLocalAgentProviders = () => buildLocalAgentProviderStatuses( config.subagents, getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), ); + const compileModernSurface = (name: ToolSurfaceName) => { const toolSurface = getToolSurface(config.experimentalCodeMode ? "codex" : name); + const bind = compileMcpRegistrationSurface((target) => { registerMcpSurface( target, @@ -1059,18 +1126,22 @@ export function createServer( workspaceActivityJournal, ); }); + return { toolSurface, bind }; }; + const modernSurfaces = { codex: compileModernSurface("codex"), claude: compileModernSurface("claude"), } satisfies Record; + const logMcpHandlerError = (error: Error) => logEvent( config.logging, "error", "mcp_handler_error", modernMcpAdapterErrorLogFields(error), ); + // An explicit tools.mode wins so existing setups keep their tools. Otherwise // the registered OAuth client identifies the connector, so a host keeps the // same tool list across requests. The User-Agent only fills in when the @@ -1079,21 +1150,26 @@ export function createServer( const client = authInfo ? await oauthProvider.clientsStore.getClient(authInfo.clientId) : undefined; + const { toolSurface, bind } = modernSurfaces[config.toolSurfaceOverride ?? detectToolSurface({ clientName: client?.client_name, redirectUris: client?.redirect_uris.map(String), userAgent: requestInfo?.headers.get("user-agent") ?? undefined, })]; + const adapter = createModernMcpServerAdapter( mcpServerInfo(), { instructions: serverInstructions(config, toolSurface) }, ); + bind(adapter.registrationTarget); + return adapter.server; }, { legacy: "stateless", onerror: logMcpHandlerError, }); + const mcpNodeHandler = toNodeHandler({ fetch: async (request, options) => nodeMcpResponse(await mcpHandler.fetch(request, options)), }, { @@ -1108,8 +1184,10 @@ export function createServer( const requestId = randomUUID(); const startedAt = performance.now(); const path = requestPath(req); + const shouldLogRequest = config.logging.requests && (config.logging.assets || !path.startsWith("/mcp-app-assets")); + let finished = false; res.locals.requestId = requestId; @@ -1125,6 +1203,7 @@ export function createServer( res.on("finish", () => { finished = true; + if (!shouldLogRequest) return; logEvent(config.logging, "info", "http_request", { @@ -1202,6 +1281,7 @@ export function createServer( else resolve(); }); }); + // A client that disconnected mid-auth has nothing left to answer. if (res.headersSent || res.destroyed) return; @@ -1214,6 +1294,7 @@ export function createServer( ...requestLogFields(req, config), }); sendJsonRpcError(res, 401, -32001, "Unauthorized"); + return; } @@ -1228,12 +1309,16 @@ export function createServer( if (!normalizeCachedWorkspaceArgument(req.body)) { const body = req.body as Record; + if (!Object.hasOwn(body, "id")) { res.status(202).end(); + return; } + const id = typeof body.id === "string" || typeof body.id === "number" ? body.id : null; sendJsonRpcError(res, 400, -32602, "Conflicting workspaceId and workspace_id arguments", id); + return; } @@ -1244,6 +1329,7 @@ export function createServer( requestId, error: error instanceof Error ? error.message : String(error), }); + if (!res.headersSent) { sendJsonRpcError(res, 500, -32603, "Internal server error"); } @@ -1251,6 +1337,7 @@ export function createServer( }); let closePromise: Promise | undefined; + return { app, config, @@ -1264,6 +1351,7 @@ export function createServer( error: error instanceof Error ? error.message : String(error), }); } + await codeModeExecutions?.shutdown(); await toolActivities.waitForIdle(); workspaceActivity.close(); @@ -1271,6 +1359,7 @@ export function createServer( oauthProvider.close(); workspaceStore.close?.(); })(); + return closePromise; }, }; @@ -1281,11 +1370,13 @@ async function isMainModule(): Promise { const modulePath = await realpath(fileURLToPath(import.meta.url)); const entrypointPath = await realpath(process.argv[1]); + return modulePath === entrypointPath; } if (await isMainModule()) { const { app, config, close, localAgentProviders } = createServer(); + const httpServer = app.listen(config.port, config.host, () => { console.log( `devspace listening on http://${config.host}:${config.port}/mcp`, @@ -1297,29 +1388,35 @@ if (await isMainModule()) { console.log(`request logging: ${config.logging.requests ? "enabled" : "disabled"}`); console.log(`asset logging: ${config.logging.assets ? "enabled" : "disabled"}`); console.log(`trust proxy: ${config.logging.trustProxy === true ? "enabled" : config.logging.trustProxy || "disabled"}`); + const artifactDownloadStatus = !config.artifactsEnabled ? "disabled" : isArtifactDownloadSupportedPlatform() ? "enabled" : `unsupported on ${process.platform}`; + console.log(`native artifact download: ${artifactDownloadStatus}`); console.log(`subagent providers: ${formatLocalAgentProviderStatusSummary(localAgentProviders)}`); }); + configureHttpServer(httpServer); let shuttingDown = false; + const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; await shutdownHttpServer(httpServer, close); process.exit(0); }; + const handleShutdown = () => { void shutdown().catch((error) => { console.error("devspace shutdown failed", error); process.exit(1); }); }; + process.once("SIGINT", handleShutdown); process.once("SIGTERM", handleShutdown); } diff --git a/src/skills.test.ts b/src/skills.test.ts index b2b2cbc65..d67f4acd7 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -15,16 +15,19 @@ import { writeTestDevspaceConfig } from "./test-support/config.js"; test("disabled skills load nothing", async (t) => { const { projectRoot, agentDir, explicitSkills, configDir } = await fixture(t); + const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { server: { port: 1 }, workspaces: { allowedRoots: [projectRoot] }, skills: { agentDir, paths: [explicitSkills], enabled: false }, })); + assert.deepEqual(loadWorkspaceSkills(disabledConfig, projectRoot).skills, []); }); test("skills load from agent, Claude, and DevSpace locations with duplicates collapsed", async (t) => { const { projectRoot, agentDir, explicitSkills, configDir } = await fixture(t); + const config = loadConfig(writeTestDevspaceConfig(configDir, { server: { port: 1 }, workspaces: { allowedRoots: [projectRoot] }, @@ -33,6 +36,7 @@ test("skills load from agent, Claude, and DevSpace locations with duplicates col paths: [explicitSkills, "~/.claude/skills", "./.claude/skills"], }, })); + const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "agent-global-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "agent-project-skill"), true); @@ -66,12 +70,14 @@ test("the managed subagents skill replaces a stale user copy and workflows need "# Stale subagents skill", ].join("\n"), ); + const experimentalConfig = loadConfig(writeTestDevspaceConfig(configDir, { server: { port: 1 }, workspaces: { allowedRoots: [projectRoot] }, skills: { agentDir }, subagents: { enabled: true, instructions: "on-demand", providers: [] }, })); + const experimentalSkills = loadWorkspaceSkills(experimentalConfig, projectRoot).skills; const managedSubagents = experimentalSkills.find((skill) => skill.name === "subagents"); assert.ok(managedSubagents); @@ -87,6 +93,7 @@ test("the managed subagents skill replaces a stale user copy and workflows need assert.equal(experimentalSkills.some((skill) => skill.name === "workflows"), false); assert.equal(existsSync(join(devspaceSkills, "workflows")), false); process.env.DEVSPACE_EXPERIMENTAL_WORKFLOWS = "1"; + try { const flagged = loadWorkspaceSkills(experimentalConfig, projectRoot).skills; assert.equal( @@ -96,6 +103,7 @@ test("the managed subagents skill replaces a stale user copy and workflows need } finally { delete process.env.DEVSPACE_EXPERIMENTAL_WORKFLOWS; } + assert.equal( loadWorkspaceSkills(experimentalConfig, projectRoot).skills.some((skill) => skill.name === "workflows"), false, @@ -105,11 +113,13 @@ test("the managed subagents skill replaces a stale user copy and workflows need test("a configured path matching a default skill path is listed once", async (t) => { const { projectRoot, agentDir, explicitSkills, projectAgentsSkills, configDir } = await fixture(t); + const duplicateConfig = loadConfig(writeTestDevspaceConfig(configDir, { server: { port: 1 }, workspaces: { allowedRoots: [projectRoot] }, skills: { agentDir, paths: [explicitSkills, "./.agents/skills"] }, })); + assert.equal( effectiveSkillPaths(duplicateConfig, projectRoot).filter((path) => path === projectAgentsSkills).length, 1, @@ -118,11 +128,13 @@ test("a configured path matching a default skill path is listed once", async (t) test("a configured legacy .pi skills path is loaded", async (t) => { const { projectRoot, agentDir, explicitSkills, configDir } = await fixture(t); + const legacyPiConfig = loadConfig(writeTestDevspaceConfig(configDir, { server: { port: 1 }, workspaces: { allowedRoots: [projectRoot] }, skills: { agentDir, paths: [explicitSkills, join(projectRoot, ".pi", "skills")] }, })); + assert.equal( loadWorkspaceSkills(legacyPiConfig, projectRoot).skills.some((skill) => skill.name === "project-skill"), true, @@ -131,6 +143,7 @@ test("a configured legacy .pi skills path is loaded", async (t) => { test("skill files and their resources resolve as skill reads", async (t) => { const { projectRoot, agentDir, explicitSkills, configDir } = await fixture(t); + const config = loadConfig(writeTestDevspaceConfig(configDir, { server: { port: 1 }, workspaces: { allowedRoots: [projectRoot] }, @@ -139,6 +152,7 @@ test("skill files and their resources resolve as skill reads", async (t) => { paths: [explicitSkills, "~/.claude/skills", "./.claude/skills"], }, })); + const loaded = loadWorkspaceSkills(config, projectRoot); const projectSkill = loaded.skills.find((skill) => skill.name === "agent-project-skill"); @@ -160,6 +174,7 @@ async function fixture(t: TestContext) { t.after(async () => { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = originalUserProfile; await rm(root, { recursive: true, force: true }); @@ -311,5 +326,6 @@ async function fixture(t: TestContext) { ); const configDir = join(root, ".devspace"); + return { projectRoot, agentDir, explicitSkills, devspaceSkills, projectAgentsSkills, configDir }; } diff --git a/src/skills.ts b/src/skills.ts index 7c9c9a62b..0a9c564cf 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -32,6 +32,7 @@ export interface SkillReadResolution { const SUBAGENTS_SKILL_NAME = "subagents"; const WORKFLOWS_SKILL_NAME = "workflows"; + type ManagedSkillName = typeof SUBAGENTS_SKILL_NAME | typeof WORKFLOWS_SKILL_NAME; // The workflows skill is part of the hidden DEVSPACE_EXPERIMENTAL_WORKFLOWS feature. @@ -51,9 +52,11 @@ function syncManagedSkill(config: ServerConfig, name: ManagedSkillName): string if (existsSync(targetPath)) { const stat = lstatSync(targetPath); + if (stat.isFile() && source === readFileSync(targetPath, "utf8")) { return targetPath; } + if (stat.isDirectory()) { throw new Error(`Managed ${name} skill path is a directory: ${targetPath}`); } @@ -61,6 +64,7 @@ function syncManagedSkill(config: ServerConfig, name: ManagedSkillName): string mkdirSync(dirname(targetPath), { recursive: true }); const tempPath = `${targetPath}.${process.pid}.tmp`; + try { writeFileSync(tempPath, source, { mode: 0o644 }); rmSync(targetPath, { force: true }); @@ -79,16 +83,19 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] config.devspaceSkillsDir, join(config.agentDir, "skills"), ]; + const defaultPaths = defaultPathCandidates.filter( (path): path is string => path !== undefined && existsSync(path), ); const seen = new Set(); + return [...defaultPaths, ...config.skillPaths] .map((path) => resolveSkillPath(path, cwd)) .filter((path) => { if (seen.has(path)) return false; seen.add(path); + return true; }); } @@ -101,6 +108,7 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; const managedNames = managedSkillNames(); + if (config.subagents.enabled) { for (const name of managedNames) syncManagedSkill(config, name); } @@ -113,13 +121,17 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk }); const withoutManaged = withoutManagedSkills(config, result, managedNames); + if (!config.subagents.enabled) return withoutManaged; const managed = managedNames.map((name) => { const managedDir = dirname(join(config.devspaceSkillsDir, name, "SKILL.md")); + const skill = loadSkillsFromDir({ dir: managedDir, source: "devspace" }) .skills.find((entry) => entry.name === name); + if (!skill) throw new Error(`Managed ${name} skill could not be loaded.`); + return skill; }); @@ -136,6 +148,7 @@ function withoutManagedSkills( ): LoadedSkills { // A managed workflows copy left behind while the feature was on must not be advertised once it is off. const staleWorkflows = join(config.devspaceSkillsDir, WORKFLOWS_SKILL_NAME); + return { skills: result.skills.filter((skill) => ( !managedNames.includes(skill.name as ManagedSkillName) @@ -143,6 +156,7 @@ function withoutManagedSkills( )), diagnostics: result.diagnostics.filter((diagnostic) => { const collision = diagnostic.collision; + return !(collision?.resourceType === "skill" && managedNames.includes(collision.name as ManagedSkillName)); }), }; @@ -156,6 +170,7 @@ export function resolveSkillReadPath( for (const skill of skills) { const skillFilePath = resolve(skill.filePath); + if (absolutePath === skillFilePath) { return { absolutePath, skill }; } @@ -163,6 +178,7 @@ export function resolveSkillReadPath( for (const skill of skills) { const baseDir = resolve(skill.baseDir); + if (!isPathInsideRoot(absolutePath, baseDir)) continue; return { absolutePath, skill }; @@ -176,6 +192,7 @@ export function formatPathForPrompt(path: string): string { const resolvedPath = resolve(path); if (resolvedPath === home) return "~"; + if (resolvedPath.startsWith(`${home}${sep}`)) { return `~/${resolvedPath.slice(home.length + 1).split(sep).join("/")}`; } diff --git a/src/test-support/config.ts b/src/test-support/config.ts index 2c97bc4e8..eeda9f825 100644 --- a/src/test-support/config.ts +++ b/src/test-support/config.ts @@ -36,6 +36,7 @@ export function writeTestDevspaceConfig( logging: { ...defaults.logging, ...overrides.logging }, oauth: { ...defaults.oauth, ...overrides.oauth }, }, env); + return { ...env, DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index ffb083ab5..b65bed65b 100644 --- a/src/tool-surfaces/claude.ts +++ b/src/tool-surfaces/claude.ts @@ -74,6 +74,7 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { response.content, startedAt, ); + return response; } @@ -128,6 +129,7 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { const workspaceId = workspace_id; const workspace = await workspaces.getWorkspace(workspaceId); const path = await workspaces.resolvePath(workspace, input.path); + const response = await editFileTool({ ...input, path, @@ -148,12 +150,14 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { response.content, startedAt, ); + return response; } const stats = countDiffStats( response.details?.patch ?? response.details?.diff, ); + const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; const editContent = [textBlock(editResultText)]; logToolCall(config, { @@ -209,10 +213,12 @@ function registerShellTool(context: ToolRegistrationContext): void { const workspaceId = workspace_id; const workingDirectory = working_directory; const workspace = await workspaces.getWorkspace(workspaceId); + const cwd = await workspaces.resolveWorkingDirectory( workspace, workingDirectory, ); + const response = await runShellTool(input, { cwd, }); @@ -230,6 +236,7 @@ function registerShellTool(context: ToolRegistrationContext): void { response.content, startedAt, ); + return response; } diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 9770897cb..2d8e2b8ce 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -44,6 +44,7 @@ function processResult(snapshot: ProcessSnapshot): string { : snapshot.signal ? `Process exited after signal ${snapshot.signal}.` : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`; + return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status; @@ -63,6 +64,7 @@ function processOutputSchema(): z.ZodRawShape { function processToolResponse(snapshot: ProcessSnapshot) { const result = processResult(snapshot); const content = [textBlock(result)]; + return { content, structuredContent: { @@ -110,15 +112,18 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void { async ({ workspace_id, patch }) => { const startedAt = performance.now(); const workspaceId = workspace_id; + const applied = await runLoggedToolOperation( config, { tool: "apply_patch", workspaceId }, startedAt, async () => { const workspace = await workspaces.getWorkspace(workspaceId); + return applyPatch(workspace.root, patch); }, ); + const paths = applied.files.map((file) => file.path).join(", "); const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; const content = [textBlock(result)]; @@ -212,6 +217,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { const workingDirectory = working_directory; const yieldTimeMs = yield_time_ms; const maxOutputTokens = max_output_tokens; + const snapshot = await runLoggedToolOperation( config, { @@ -224,10 +230,12 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { startedAt, async () => { const workspace = await workspaces.getWorkspace(workspaceId); + const cwd = await workspaces.resolveWorkingDirectory( workspace, workingDirectory, ); + return processSessions.start({ workspaceId, command: cmd, @@ -314,12 +322,14 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { const sessionId = session_id; const yieldTimeMs = yield_time_ms; const maxOutputTokens = max_output_tokens; + const snapshot = await runLoggedToolOperation( config, { tool: "write_stdin", workspaceId }, startedAt, async () => { await workspaces.getWorkspace(workspaceId); + return processSessions.write({ workspaceId, sessionId, @@ -340,9 +350,11 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { export function processLogFields(result: ProcessSnapshot): Partial { const success = result.running || (!result.signal && result.exitCode === 0); + const termination = result.signal ? `Process terminated by signal ${result.signal}.` : `Process exited with code ${result.exitCode ?? "unknown"}.`; + return { sessionId: result.sessionId, running: result.running, diff --git a/src/tool-surfaces/detect.ts b/src/tool-surfaces/detect.ts index 3d615c9f6..e350e1ec8 100644 --- a/src/tool-surfaces/detect.ts +++ b/src/tool-surfaces/detect.ts @@ -43,6 +43,7 @@ const HOST_SIGNATURES: readonly HostSignature[] = [ */ export function detectToolSurface(hints: HostHints): ToolSurfaceName { const { clientName, redirectUris = [], userAgent } = hints; + const match = (test: (signature: HostSignature) => boolean) => HOST_SIGNATURES.find(test)?.surface; @@ -56,6 +57,8 @@ export function detectToolSurface(hints: HostHints): ToolSurfaceName { function redirectDomainMatches(uri: string, domains: readonly string[]): boolean { const hostname = URL.parse(uri)?.hostname; + if (!hostname) return false; + return domains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)); } diff --git a/src/tool-surfaces/index.ts b/src/tool-surfaces/index.ts index 3991a4f70..c789b4946 100644 --- a/src/tool-surfaces/index.ts +++ b/src/tool-surfaces/index.ts @@ -3,6 +3,7 @@ import { claudeInstructions, registerClaudeTools } from "./claude.js"; import { type ToolSurface, type ToolSurfaceName } from "./types.js"; export type { ToolSurfaceName } from "./types.js"; + export { DEFAULT_TOOL_SURFACE, detectToolSurface, type HostHints } from "./detect.js"; const TOOL_SURFACES: Record = { diff --git a/src/tool-surfaces/shared.ts b/src/tool-surfaces/shared.ts index 1366d5a97..2f0603e66 100644 --- a/src/tool-surfaces/shared.ts +++ b/src/tool-surfaces/shared.ts @@ -62,6 +62,7 @@ export async function runLoggedToolOperation( success: resultMetadata?.success ?? true, durationMs: Math.round(performance.now() - startedAt), }); + return result; } catch (error) { logToolCall(config, { @@ -85,7 +86,9 @@ export function contentText(content: ToolContent[]): string { function toolErrorPreview(content: ToolContent[]): string | undefined { const text = contentText(content).replace(/\s+/g, " ").trim(); + if (!text) return undefined; + return text.length > 240 ? `${text.slice(0, 237)}...` : text; } @@ -115,6 +118,7 @@ export function countDiffStats(diff: string | undefined): DiffStats { for (const line of diff.split("\n")) { if (line.startsWith("+") && !line.startsWith("+++")) additions++; + if (line.startsWith("-") && !line.startsWith("---")) removals++; } diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 0e1a387c9..1fabc563c 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -8,6 +8,7 @@ test("aggregate review opens when a patch is available", () => { files: [{ path: "src/a.ts", type: "change" as const }], payload: { patch: "diff --git a/src/a.ts b/src/a.ts" }, }; + assert.equal(isExpandableCard(card), true); }); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 6c84d0fa7..a93f602c2 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -1,6 +1,7 @@ import type { App } from "@modelcontextprotocol/ext-apps"; export type ToolName = "open_workspace" | "show_changes"; + export type HostContext = NonNullable>; export type ReviewFileType = @@ -72,6 +73,7 @@ export function summaryNumber( key: string, ): number | undefined { const value = summary?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; } diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 5d489e358..88e0767a4 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -71,6 +71,7 @@ export function getProviderLogo( theme: ProviderLogoTheme = "dark", ): string | undefined { const normalizedName = name.trim().toLowerCase() as keyof typeof providerLogos; + return providerLogos[normalizedName]?.[theme]; } diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts index 83b990e9d..45e4f830a 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -30,11 +30,13 @@ export function getPatchDisplayParts( ): { title: string } { const files = card.files ?? []; const fileCount = countChangedFiles(files); + if (fileCount === 0) return { title: options.emptyTitle ?? "Changes ready" }; const kinds = new Set(files.map(getFileChangeKind)); const kind = kinds.size === 1 ? [...kinds][0] : undefined; const noun = fileCount === 1 ? "file" : "files"; + return { title: kind && kind !== "unknown" ? `${fileChangeLabels[kind]} ${fileCount} ${noun}` @@ -65,6 +67,7 @@ export function getRenderedFileChangeKind( index: number, ): FileChangeKind { const parsedKind = getFileChangeKind(parsedFile); + return parsedKind === "unknown" ? getFileChangeKind(files[index] ?? {}) : parsedKind; @@ -74,12 +77,15 @@ export function getFileChangePathDisplay( file: Pick, ): FileChangePathDisplay | undefined { const current = file.path ?? file.previousPath; + if (!current) return undefined; const previous = file.previousPath; + if (!previous || previous === current) return { current, title: current }; const sameDirectory = pathDirectory(previous) === pathDirectory(current); + return { current: sameDirectory ? pathBasename(current) : current, previous: sameDirectory ? pathBasename(previous) : previous, @@ -93,9 +99,11 @@ export function getRenderedFileChangePathDisplay( index: number, ): FileChangePathDisplay | undefined { const indexedFile = files[index]; + const matchedFile = indexedFile?.path === parsedFile.path ? indexedFile : files.find((file) => file.path === parsedFile.path); + const cardFile = matchedFile ?? indexedFile; return getFileChangePathDisplay({ @@ -111,20 +119,25 @@ export function fileChangeKindLabel(kind: FileChangeKind): string { function countChangedFiles(files: NonNullable): number { const paths = new Set(); let unnamedFiles = 0; + for (const file of files) { const path = file.path ?? file.previousPath; + if (path) paths.add(path); else unnamedFiles += 1; } + return paths.size + unnamedFiles; } function pathDirectory(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex === -1 ? "" : path.slice(0, separatorIndex); } function pathBasename(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex === -1 ? path : path.slice(separatorIndex + 1); } diff --git a/src/ui/review-diff.tsx b/src/ui/review-diff.tsx index 014b9bde9..e2d153bed 100644 --- a/src/ui/review-diff.tsx +++ b/src/ui/review-diff.tsx @@ -13,10 +13,12 @@ import { pierrePrettyScrollbarCss } from "./scrollbar.js"; // Styles live in review-diff.css. export type ReviewThemeType = "light" | "dark"; + export type ReviewFileMetadata = NonNullable; export function parseReviewFiles(patch: string | undefined): FileDiffMetadata[] { if (!patch) return []; + return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files); } @@ -56,11 +58,13 @@ export function ReviewDiffFile({ id?: string; }) { const stats = diffStats(fileDiff); + const changeKind = getRenderedFileChangeKind( files, { path: fileDiff.name, previousPath: fileDiff.prevName, type: fileDiff.type }, index, ); + const pathDisplay = getRenderedFileChangePathDisplay( files, { path: fileDiff.name, previousPath: fileDiff.prevName }, diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index eb8f7ac37..e51d9f52e 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -47,13 +47,17 @@ function ReviewPayload({ const patch = card.payload?.patch; const themeType: ReviewThemeType = hostContext?.theme === "light" ? "light" : "dark"; const files = useMemo(() => parseReviewFiles(patch), [patch]); + const visibleFiles = typeof visibleFileCount === "number" ? files.slice(0, visibleFileCount) : files; + const [openFiles, setOpenFiles] = useState(() => new Set()); if (errorMessage) return ; + if (!patch) return ; + if (files.length === 0) return ; if (files.length === 1) { @@ -69,6 +73,7 @@ function ReviewPayload({
{visibleFiles.map((fileDiff, index) => { const key = reviewFileKey(fileDiff, index); + return ( { const next = new Set(openFiles); + if (next.has(key)) { next.delete(key); } else { next.add(key); } + setOpenFiles(next); }} /> diff --git a/src/ui/tool-result.test.ts b/src/ui/tool-result.test.ts index e5b39abf9..e8622f50d 100644 --- a/src/ui/tool-result.test.ts +++ b/src/ui/tool-result.test.ts @@ -21,6 +21,7 @@ test("workspace cards can be rebuilt from structured content without result meta }); assert.equal(decoded.kind, "card"); + if (decoded.kind !== "card") return; assert.equal(decoded.card.tool, "open_workspace"); assert.equal(decoded.card.workspaceId, "ws_1"); @@ -47,6 +48,7 @@ test("review results use rich metadata when the host provides it", () => { }); assert.equal(decoded.kind, "card"); + if (decoded.kind !== "card") return; assert.equal(decoded.card.tool, "show_changes"); assert.equal(decoded.card.files?.[0]?.path, "new.txt"); @@ -100,6 +102,7 @@ test("older review results can reload from their structured patch", () => { }); assert.equal(decoded.kind, "card"); + if (decoded.kind !== "card") return; assert.equal(decoded.card.tool, "show_changes"); assert.equal(decoded.card.files?.[0]?.path, "new.txt"); @@ -112,6 +115,7 @@ test("ChatGPT globals restore structured output and hidden MCP result metadata t structuredContent: { stale: true }, _meta: { card: { workspaceId: "ws_1", payload: { patch: "patch" } } }, }; + const restored = toolResultFromChatGptGlobals({ toolOutput: { workspace_id: "ws_1", diff --git a/src/ui/tool-result.ts b/src/ui/tool-result.ts index 446af64f9..7e8f29bde 100644 --- a/src/ui/tool-result.ts +++ b/src/ui/tool-result.ts @@ -18,6 +18,7 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { if (structured) { const workspaceId = stringField(structured.workspace_id); const reviewRef = stringField(structured.review_ref); + if (workspaceId && reviewRef) { if (isCompleteReviewCard(metaCard)) { return { @@ -29,6 +30,7 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { }, }; } + return { kind: "review-reference", workspaceId, reviewRef }; } @@ -37,6 +39,7 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { ...structured, payload: { patch: structured.patch }, }); + if (legacyCard) { return { kind: "card", card: { ...legacyCard, tool: "show_changes" } }; } @@ -44,8 +47,10 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { const root = stringField(structured.root); const mode = workspaceMode(structured.mode); + if (workspaceId && root && mode) { const structuredCard = structuredWorkspaceCardFields(structured) ?? {}; + return { kind: "card", card: { @@ -66,6 +71,7 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { if (metaCard?.workspaceId && (metaCard.files?.length || metaCard.payload?.patch)) { return { kind: "card", card: { ...metaCard, tool: "show_changes" } }; } + if (metaCard?.workspaceId && metaCard.root && metaCard.mode) { return { kind: "card", card: { ...metaCard, tool: "open_workspace" } }; } @@ -78,6 +84,7 @@ function structuredWorkspaceCardFields( ): Partial | undefined { if (!record) return undefined; const worktreeRecord = asRecord(record.worktree); + return cardFields({ root: record.root, mode: record.mode, @@ -112,6 +119,7 @@ function isCompleteReviewCard( if (!card || !Array.isArray(card.files) || typeof card.payload?.patch !== "string") { return false; } + return numberField(card.summary?.files) !== undefined && numberField(card.summary?.additions) !== undefined && numberField(card.summary?.removals) !== undefined; @@ -124,10 +132,13 @@ export function toolResultFromChatGptGlobals( const responseMetadata = asRecord(globals.toolResponseMetadata); const metadataResult = mcpToolResult(globals.toolResponseMetadata); + const structuredContent = asRecord(globals.toolOutput) ?? asRecord(metadataResult?.structuredContent); + const resultMeta = asRecord(metadataResult?._meta) ?? directResultMeta(responseMetadata); + if (!metadataResult && !structuredContent && !resultMeta) return undefined; return { @@ -141,18 +152,22 @@ function directResultMeta( metadata: Record | undefined, ): Record | undefined { if (!metadata) return undefined; + return "card" in metadata ? metadata : undefined; } function mcpToolResult(value: unknown): CallToolResult | undefined { const metadata = asRecord(value); + if (!metadata) return undefined; const direct = asRecord(metadata.mcp_tool_result); + if (direct) return direct as CallToolResult; const callToolResult = asRecord(metadata.call_tool_result); const nested = asRecord(callToolResult?.mcp_tool_result); + return nested ? nested as CallToolResult : undefined; } @@ -163,20 +178,24 @@ function cardFields(record: Record | undefined): Partial ({ path: stringField(item.path), })); + const skills = arrayRecords(record.skills)?.map((item) => ({ name: stringField(item.name), description: stringField(item.description), path: stringField(item.path), })); + const agentProviders = arrayRecords(record.agentProviders)?.map((item) => ({ id: stringField(item.id), model: stringField(item.model), effort: stringField(item.effort), note: stringField(item.note), })); + const agents = arrayRecords(record.agents)?.map((item) => ({ name: stringField(item.name), description: stringField(item.description), @@ -184,6 +203,7 @@ function cardFields(record: Record | undefined): Partial ({ path: stringField(item.path), previousPath: stringField(item.previousPath), @@ -191,6 +211,7 @@ function cardFields(record: Record | undefined): Partial | undefined, ): ToolResultCard["review"] { if (!record || typeof record.available !== "boolean") return undefined; + if (record.available) return { available: true }; const reason = stringField(record.reason); + return reason ? { available: false, reason } : undefined; } @@ -263,8 +286,10 @@ function workspaceMode(value: unknown): ToolResultCard["mode"] { function arrayRecords(value: unknown): Array> | undefined { if (!Array.isArray(value)) return undefined; + return value.flatMap((item) => { const record = asRecord(item); + return record ? [record] : []; }); } diff --git a/src/ui/web/diff-tree.ts b/src/ui/web/diff-tree.ts index 59a31af7e..3ca85a5ea 100644 --- a/src/ui/web/diff-tree.ts +++ b/src/ui/web/diff-tree.ts @@ -30,22 +30,27 @@ export function buildDiffTree(files: ReadonlyArray): DiffTreeNode[ for (const file of files) { const segments = file.path.split("/").filter(Boolean); const name = segments.pop(); + if (!name) continue; const ancestors = [root]; let current = root; + for (const segment of segments) { const path = current.path ? `${current.path}/${segment}` : segment; let next = current.directories.get(segment); + if (!next) { next = directory(segment, path); current.directories.set(segment, next); } + current = next; ancestors.push(current); } current.files.push({ kind: "file", name, path: file.path, additions: file.additions, removals: file.removals }); + for (const ancestor of ancestors) { ancestor.additions += file.additions; ancestor.removals += file.removals; @@ -70,15 +75,19 @@ function toNodes(parent: MutableDirectory): DiffTreeNode[] { removals: child.removals, children: toNodes(child), })); + const files = [...parent.files].sort((left, right) => collator.compare(left.name, right.name)); + return [...directories, ...files]; } function compact(node: Extract): DiffTreeNode { let current = node; + while (current.children.length === 1 && current.children[0]?.kind === "directory") { const only = current.children[0]; current = { ...only, name: `${current.name}/${only.name}` }; } + return current; } diff --git a/src/ui/web/main.tsx b/src/ui/web/main.tsx index 17f1c8ac1..fe6e847ce 100644 --- a/src/ui/web/main.tsx +++ b/src/ui/web/main.tsx @@ -27,7 +27,9 @@ interface TurnResponse { function parseRoute(pathname: string): Route { const match = pathname.match(/^\/web\/w\/([^/]+)(?:\/turns\/(\d+))?\/?$/); + if (!match) return { page: "workspaces" }; + return { page: "workspace", workspaceId: decodeURIComponent(match[1]!), @@ -37,6 +39,7 @@ function parseRoute(pathname: string): Route { function workspacePath(workspaceId: string, turn?: number): string { const base = `${BASE}/w/${encodeURIComponent(workspaceId)}`; + return turn === undefined ? base : `${base}/turns/${turn}`; } @@ -52,9 +55,12 @@ function useRoute(): Route { setRoute(parseRoute(window.location.pathname)); window.scrollTo(0, 0); }; + window.addEventListener("popstate", update); + return () => window.removeEventListener("popstate", update); }, []); + return route; } @@ -69,20 +75,26 @@ function useJson(url: string): Loaded { .then(async (response) => { if (response.status === 401) { window.location.assign(`${BASE}/login?next=${encodeURIComponent(window.location.pathname)}`); + return; } + const body = await response.json() as T & { error?: string }; + if (cancelled) return; + if (!response.ok) setState({ status: "error", message: body.error ?? `Request failed (${response.status}).` }); else setState({ status: "ready", data: body }); }) .catch((error: unknown) => { if (!cancelled) setState({ status: "error", message: error instanceof Error ? error.message : String(error) }); }); + return () => { cancelled = true; }; }, [url]); + return state; } @@ -91,6 +103,7 @@ type ThemePreference = "system" | "light" | "dark"; function readThemePreference(): ThemePreference { try { const stored = window.localStorage.getItem("devspace-web-theme"); + return stored === "light" || stored === "dark" ? stored : "system"; } catch { return "system"; @@ -104,6 +117,7 @@ function useTheme(): [ReviewThemeType, ThemePreference, (preference: ThemePrefer useEffect(() => { const update = () => setSystemDark(media.matches); media.addEventListener("change", update); + return () => media.removeEventListener("change", update); }, [media]); @@ -114,6 +128,7 @@ function useTheme(): [ReviewThemeType, ThemePreference, (preference: ThemePrefer return [resolved, preference, (next) => { setPreference(next); + try { if (next === "system") window.localStorage.removeItem("devspace-web-theme"); else window.localStorage.setItem("devspace-web-theme", next); @@ -170,17 +185,21 @@ function Link({ href, className, children }: { href: string; className?: string; if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } + event.preventDefault(); navigate(href); }; + return {children}; } function WorkspacesPage() { const state = useJson<{ workspaces: ActivityWorkspace[] }>(`${BASE}/api/workspaces`); + if (state.status !== "ready") return ; const { workspaces } = state.data; + return (

Workspaces

@@ -215,6 +234,7 @@ function WorkspacesPage() { function WorkspacePage({ workspaceId, turn, theme }: { workspaceId: string; turn?: number; theme: ReviewThemeType }) { const state = useJson(`${BASE}/api/workspaces/${encodeURIComponent(workspaceId)}`); + if (state.status !== "ready") return ; const { workspace, context, turns } = state.data; @@ -364,6 +384,7 @@ function TurnDiffSection({ diff, theme }: { diff: TurnDiff; theme: ReviewThemeTy const review = diff.status === "available" ? diff.review : undefined; const parsed = useMemo(() => parseReviewFiles(review?.patch), [review?.patch]); const [open, setOpen] = useState(() => new Set(parsed.length <= 6 ? parsed.map((file) => file.name) : [])); + const tree = useMemo(() => buildDiffTree(review?.files.map((file) => ({ path: file.path, additions: file.additions, @@ -378,6 +399,7 @@ function TurnDiffSection({ diff, theme }: { diff: TurnDiff; theme: ReviewThemeTy
); } + if (diff.status === "unavailable") { return (
@@ -421,7 +443,9 @@ function TurnDiffSection({ diff, theme }: { diff: TurnDiff; theme: ReviewThemeTy open={open.has(fileDiff.name)} onToggle={() => setOpen((current) => { const next = new Set(current); + if (!next.delete(fileDiff.name)) next.add(fileDiff.name); + return next; })} /> @@ -519,6 +543,7 @@ function JsonBlock({ label, value }: { label: string; value: unknown }) { const text = typeof value === "object" && value !== null && "text" in value && Object.keys(value).length === 1 ? String((value as { text: unknown }).text) : JSON.stringify(value, null, 2); + return (
{label}
@@ -529,6 +554,7 @@ function JsonBlock({ label, value }: { label: string; value: unknown }) { function LoadState({ state }: { state: Loaded }) { if (state.status === "error") return

{state.message}

; + return

Loading…

; } @@ -551,28 +577,36 @@ function fileAnchor(path: string): string { function argumentSummary(args: unknown): string { if (typeof args !== "object" || args === null) return ""; const record = args as Record; + if (typeof record.patch === "string") { const files = [...record.patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((match) => match[1]); + if (files.length > 0) return truncate(files.join(", "), 120); } + for (const key of ["cmd", "command", "path", "task", "query"]) { if (typeof record[key] === "string") return truncate(record[key] as string, 120); } + const { workspace_id: _workspaceId, ...rest } = record; const json = JSON.stringify(rest); + return json === "{}" ? "" : truncate(json, 120); } function resultSummary(result: unknown): string { if (typeof result !== "object" || result === null) return ""; const text = (result as { text?: unknown }).text; + if (typeof text !== "string") return ""; const line = text.split("\n").find((entry) => entry.trim().length > 0) ?? ""; + return truncate(line, 160); } function errorMessage(error: unknown): string { const message = (error as { message?: unknown } | undefined)?.message; + return typeof message === "string" ? truncate(message, 160) : "Failed"; } @@ -599,17 +633,23 @@ function formatTime(iso: string): string { /** Omits the date from a range end on the same day as its start. */ function formatEnd(startIso: string, endIso: string): string { const sameDay = new Date(startIso).toDateString() === new Date(endIso).toDateString(); + return sameDay ? new Date(endIso).toLocaleTimeString(undefined, { timeStyle: "short" }) : formatTime(endIso); } function relativeTime(iso: string): string { const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000); + if (!Number.isFinite(seconds)) return ""; + if (seconds < 60) return "just now"; const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} h ago`; + return formatTime(iso); } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index a04f48bea..9cf209503 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -83,8 +83,10 @@ async function boot(): Promise { app.ontoolresult = (result) => { if (!connected) { pendingToolResult = result; + return; } + void applyToolResult(result); }; @@ -95,6 +97,7 @@ async function boot(): Promise { ...ctx, }; applyHostContext(); + // Workspace details inherit host variables directly. Rebuilding their DOM on // iframe resize would reset an in-progress instruction preview interaction. if (card?.tool === "open_workspace") { @@ -109,12 +112,14 @@ async function boot(): Promise { app.onteardown = async () => { window.removeEventListener("openai:set_globals", handleChatGptGlobalsChanged); unmountPayload(); + return {}; }; try { await app.connect(); const initialContext = app.getHostContext(); + if (initialContext) hostContext = initialContext; applyHostContext(); connected = true; @@ -127,6 +132,7 @@ async function boot(): Promise { const initialResult = pendingToolResult ?? chatGptRestoredResult(); pendingToolResult = null; + if (initialResult) { await applyToolResult(initialResult); } else { @@ -136,12 +142,16 @@ async function boot(): Promise { async function applyToolResult(result: CallToolResult): Promise { const decoded = decodeToolResult(result); + if (decoded.kind === "card") { setCard(decoded.card); + return; } + if (decoded.kind === "invalid") { clearCard("No result card is available for this tool result."); + return; } @@ -154,12 +164,15 @@ async function applyToolResult(result: CallToolResult): Promise { try { const restored = await reopenReview(decoded.workspaceId, decoded.reviewRef); + if (pendingReviewKey !== reviewKey) return; const restoredResult = decodeToolResult(restored); + if (restoredResult.kind !== "card" || restoredResult.card.tool !== "show_changes") { throw new Error("The host returned an incomplete historical review."); } + setCard(restoredResult.card); } catch (reviewError) { if (pendingReviewKey !== reviewKey) return; @@ -202,6 +215,7 @@ async function reopenReview( reviewRef: string, ): Promise { if (!app) throw new Error("The app bridge is not connected."); + if (!app.getHostCapabilities()?.serverTools) { throw new Error("This host cannot reload historical review details."); } @@ -221,21 +235,26 @@ function handleChatGptGlobalsChanged(event: Event): void { if (!connected || card) return; const customEvent = event as CustomEvent<{ globals?: ChatGptToolGlobals }>; + const restored = toolResultFromChatGptGlobals(customEvent.detail?.globals) ?? chatGptRestoredResult(); + if (restored) void applyToolResult(restored); } function applyHostContext(): void { if (hostContext?.theme) applyDocumentTheme(hostContext.theme); + if (hostContext?.styles?.variables) { applyHostStyleVariables(hostContext.styles.variables); } + if (hostContext?.styles?.css?.fonts) { applyHostFonts(hostContext.styles.css.fonts); } const insets = hostContext?.safeAreaInsets; + if (!insets) return; document.body.style.padding = `${insets.top}px ${insets.right}px ${insets.bottom}px ${insets.left}px`; @@ -246,30 +265,37 @@ function render(): void { if (connectionError) { renderEmpty(connectionError, "error"); + return; } if (!connected) { renderEmpty("Connecting to host..."); + return; } if (!card) { renderEmpty(errorMessage ?? "Waiting for a tool result.", errorMessage ? "error" : "muted"); + return; } const display = cardDisplay(card); + if (card.tool === "show_changes") { renderReviewCard(card, display); + return; } const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); + const section = element("section", { className: toolCardClassName(display), }); + const button = element("button", { className: "tool-header", type: "button", @@ -290,6 +316,7 @@ function render(): void { const toolMain = element("span", { className: "tool-main" }); const title = element("span", { className: "tool-title", text: display.title }); toolMain.append(title); + if (display.label) { toolMain.append(element("span", { className: "tool-label", @@ -330,11 +357,13 @@ async function renderPayloadIfNeeded(): Promise { if (errorMessage) { renderStatus(target, errorMessage, "error"); + return; } if (card.tool === "open_workspace") { renderWorkspacePayload(target, card); + return; } @@ -344,11 +373,13 @@ async function renderPayloadIfNeeded(): Promise { if (currentPayload) { currentPayload.update({ card, hostContext, errorMessage, visibleFileCount }); + return; } renderStatus(target, "Loading review..."); const { mountReviewPayload } = await import("./review-payload.js"); + if (target !== currentPayloadContainer || !card) return; currentPayload = mountReviewPayload(target, { @@ -393,6 +424,7 @@ function renderHeaderSummary(card: ToolResultCard): HTMLElement { text: `-${String(summaryNumber(card.summary, "removals") ?? 0)}`, }), ); + return stats; } @@ -400,11 +432,14 @@ function renderHeaderSummary(card: ToolResultCard): HTMLElement { countLabel(summaryNumber(card.summary, "agentsFiles"), "instruction"), countLabel(summaryNumber(card.summary, "skills"), "skill"), ].filter((part): part is string => Boolean(part)); + const meta = element("span", { className: `header-meta ${parts.length === 0 ? "empty" : ""}`, text: parts.join(" · "), }); + if (parts.length === 0) meta.setAttribute("aria-hidden", "true"); + return meta; } @@ -417,6 +452,7 @@ function renderReviewCard(card: ToolResultCard, display: CardDisplay): void { const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); const section = element("section", { className: toolCardClassName(display) }); + const header = element("button", { className: "tool-header review-header", type: "button", @@ -436,6 +472,7 @@ function renderReviewCard(card: ToolResultCard, display: CardDisplay): void { const titleGroup = element("span", { className: "tool-main review-title-group" }); titleGroup.append(element("span", { className: "tool-title", text: display.title })); + if (display.label) { titleGroup.append(element("span", { className: "tool-label", @@ -443,6 +480,7 @@ function renderReviewCard(card: ToolResultCard, display: CardDisplay): void { title: display.label, })); } + header.append( icon, titleGroup, @@ -451,6 +489,7 @@ function renderReviewCard(card: ToolResultCard, display: CardDisplay): void { ); section.append(header); + if (expanded) { const body = element("div", { className: "review-summary" }); const payload = element("div", { className: "review-payload" }); @@ -463,6 +502,7 @@ function renderReviewCard(card: ToolResultCard, display: CardDisplay): void { type: "button", text: `Show ${hiddenCount} more ${hiddenCount === 1 ? "file" : "files"}`, }); + showMore.addEventListener("click", () => { reviewFilesExpanded = true; render(); @@ -502,6 +542,7 @@ function cardDisplay(card: ToolResultCard): CardDisplay { : card.workspaceReused === false ? "Opened workspace" : "Workspace"; + return { icon: card.mode === "worktree" ? toolIcons.gitBranch : toolIcons.folderOpen, title, @@ -511,6 +552,7 @@ function cardDisplay(card: ToolResultCard): CardDisplay { } const display = getPatchDisplayParts(card, { emptyTitle: "Changes ready" }); + return { icon: toolIcons.diff, title: card.files?.length || card.payload?.patch ? display.title : "No changes", @@ -521,11 +563,13 @@ function cardDisplay(card: ToolResultCard): CardDisplay { function singleFilePath(card: ToolResultCard): string | undefined { if (card.files?.length !== 1) return undefined; + return getFileChangePathDisplay(card.files[0])?.title ?? card.path; } function countLabel(count: number | undefined, noun: string): string | undefined { if (count === undefined) return undefined; + return `${count} ${noun}${count === 1 ? "" : "s"}`; } @@ -535,6 +579,7 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v const details = element("div", { className: "workspace-details pretty-scrollbar", }); + const rows = element("div", { className: "workspace-rows" }); const worktree = card.worktree; @@ -543,6 +588,7 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v worktree.baseRef, worktree.baseSha?.slice(0, 8), ].filter((value): value is string => Boolean(value)); + const baseLabel = base.join(" · ") || "Worktree"; const baseContent = element("span", { className: "workspace-base-value" }); baseContent.append(element("span", { @@ -557,6 +603,7 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v title: "The source checkout had uncommitted changes when this worktree was created. Those changes are not included here.", ariaLabel: "Source checkout changes are not included in this worktree", }); + warning.append(renderIcon(toolIcons.warning, "workspace-base-warning-svg")); baseContent.append(warning); } @@ -590,24 +637,29 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v ); const skills = card.skills ?? []; + if (skills.length > 0) { appendWorkspaceSkills(rows, skills); } const providers = card.agentProviders ?? []; const agents = card.agents ?? []; + const providerLogoTheme: ProviderLogoTheme = hostContext?.theme === "light" ? "light" : "dark"; + const agentChips: WorkspaceChip[] = agents.map((agent) => { const name = agent.name ?? "Unnamed agent"; const providerName = agent.provider?.trim(); + const title = [ agent.description, providerName ? `Provider: ${providerName}` : undefined, agent.model ? `Model: ${agent.model}` : undefined, agent.effort ? `Effort: ${agent.effort}` : undefined, ].filter((value): value is string => Boolean(value)).join("\n"); + return { label: name, logo: providerName @@ -618,14 +670,17 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v title: title || undefined, }; }); + const providerChips: WorkspaceChip[] = providers.map((provider) => { const name = provider.id?.trim() || "Unknown provider"; const logo = getProviderLogo(name, providerLogoTheme); + const title = [ provider.model ? `Model: ${provider.model}` : undefined, provider.effort ? `Effort: ${provider.effort}` : undefined, provider.note, ].filter((value): value is string => Boolean(value)).join("\n"); + return { label: name, logo, @@ -679,6 +734,7 @@ function appendWorkspaceInstructions( ): void { const loaded: WorkspaceInstruction[] = []; const loadedPaths = new Set(); + for (const [index, file] of loadedFiles.entries()) { loaded.push({ key: `loaded:${index}`, @@ -687,10 +743,12 @@ function appendWorkspaceInstructions( content: file.content, status: "loaded", }); + if (file.path) loadedPaths.add(file.path); } const available: WorkspaceInstruction[] = []; + for (const [index, file] of availableFiles.entries()) { if (file.path && loadedPaths.has(file.path)) continue; available.push({ @@ -700,15 +758,18 @@ function appendWorkspaceInstructions( status: "available", }); } + if (loaded.length === 0 && available.length === 0) return; const instructions = showAvailableWorkspaceInstructions ? [...loaded, ...available] : loaded; + const list = renderWorkspaceInstructionList(instructions); if (available.length > 0) { const showAll = showAvailableWorkspaceInstructions; + const toggle = element("button", { className: "workspace-instructions-toggle", type: "button", @@ -718,8 +779,10 @@ function appendWorkspaceInstructions( : `View all ${available.length} available instruction files`, ariaExpanded: String(showAll), }); + toggle.addEventListener("click", () => { showAvailableWorkspaceInstructions = !showAvailableWorkspaceInstructions; + if (!showAvailableWorkspaceInstructions) openWorkspaceInstructionKey = null; render(); }); @@ -747,18 +810,21 @@ function renderWorkspaceInstructionList( const item = element("span", { className: "workspace-instruction-item" }); item.dataset.instructionKey = instruction.key; const hasContent = instruction.status === "loaded" && instruction.content !== undefined; + const header = element(hasContent ? "button" : "span", { className: `workspace-instruction-header${hasContent ? " interactive" : ""}`, type: hasContent ? "button" : undefined, ariaLabel: hasContent ? `View ${instruction.label}` : undefined, ariaExpanded: hasContent ? "false" : undefined, }); + const text = element("span", { className: "workspace-instruction-text" }); const basename = workspacePathBasename(instruction.label); text.append(element("span", { className: "workspace-instruction-name", text: basename, })); + if (instruction.path && instruction.path !== basename) { text.append(element("span", { className: "workspace-instruction-path", @@ -777,6 +843,7 @@ function renderWorkspaceInstructionList( className: "workspace-instruction-chevron", ariaHidden: "true", }); + chevron.append(renderIcon(toolIcons.chevronDown, "workspace-instruction-chevron-svg")); header.append(chevron); header.addEventListener("click", () => { @@ -790,6 +857,7 @@ function renderWorkspaceInstructionList( className: "workspace-instruction-preview pretty-scrollbar", text: instruction.content, }); + preview.hidden = true; item.append(header, preview); } else { @@ -800,6 +868,7 @@ function renderWorkspaceInstructionList( } syncWorkspaceInstructionPreviews(list); + return list; } @@ -810,6 +879,7 @@ function syncWorkspaceInstructionPreviews(list: HTMLElement): void { const header = item.querySelector(".workspace-instruction-header.interactive"); header?.setAttribute("aria-expanded", String(isOpen)); const preview = item.querySelector(".workspace-instruction-preview"); + if (preview) preview.hidden = !isOpen; } } @@ -818,16 +888,19 @@ function renderWorkspaceInstructionStatus( status: WorkspaceInstruction["status"], ): HTMLElement { const label = instructionStatusLabel(status); + const wrapper = element("span", { className: `workspace-instruction-status ${status}`, title: label, ariaLabel: label, }); + wrapper.setAttribute("role", "img"); wrapper.append(renderIcon( status === "loaded" ? toolIcons.instructionLoaded : toolIcons.instructionAvailable, "workspace-instruction-status-svg", )); + return wrapper; } @@ -839,6 +912,7 @@ function instructionStatusLabel(status: WorkspaceInstruction["status"]): string function workspacePathBasename(path: string): string { const parts = path.replaceAll("\\", "/").split("/").filter(Boolean); + return parts.at(-1) ?? path; } @@ -854,6 +928,7 @@ function appendWorkspaceTextRow( text: value, title: value, }); + appendWorkspaceRow(container, label, content, icon); } @@ -876,6 +951,7 @@ function appendWorkspaceRow( const row = element("div", { className: ["workspace-row", rowClassName].filter(Boolean).join(" "), }); + row.append( renderWorkspaceRowIcon(icon), element("span", { className: "workspace-key", text: label }), @@ -903,14 +979,18 @@ function renderWorkspaceRowIcon(icon: ToolIcon): HTMLElement { className: "workspace-row-icon", ariaHidden: "true", }); + wrapper.append(renderIcon(icon, "workspace-row-icon-svg")); + return wrapper; } function renderWorkspaceChips(chips: WorkspaceChip[]): HTMLElement { const list = element("span", { className: "workspace-chip-list" }); + for (const chip of chips) { const bareLogo = Boolean(chip.bareLogo && chip.logo); + const item = element("span", { className: [ bareLogo @@ -922,10 +1002,12 @@ function renderWorkspaceChips(chips: WorkspaceChip[]): HTMLElement { ].filter(Boolean).join(" "), title: chip.title, }); + if (bareLogo) { item.setAttribute("role", "img"); item.setAttribute("aria-label", chip.ariaLabel ?? chip.label); } + if (chip.logo) { const logo = document.createElement("img"); logo.className = bareLogo @@ -934,24 +1016,30 @@ function renderWorkspaceChips(chips: WorkspaceChip[]): HTMLElement { ? "workspace-agent-profile-logo" : "workspace-chip-logo"; logo.src = chip.logo; + if (chip.logoProvider) logo.dataset.provider = chip.logoProvider; logo.alt = ""; logo.setAttribute("aria-hidden", "true"); item.append(logo); } + if (!bareLogo) { item.append(element("span", { className: "workspace-chip-label", text: chip.label })); } + list.append(item); } + return list; } function syncWorkspaceProviderLogos(theme: ProviderLogoTheme): void { for (const logo of document.querySelectorAll("img[data-provider]")) { const providerName = logo.dataset.provider; + if (!providerName) continue; const src = getProviderLogo(providerName, theme); + if (src && logo.src !== src) logo.src = src; } } @@ -970,15 +1058,24 @@ function element( } = {}, ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined) node.textContent = options.text; + if (options.type !== undefined && "type" in node) node.setAttribute("type", options.type); + if (options.title !== undefined) node.title = options.title; + if (options.ariaHidden !== undefined) node.setAttribute("aria-hidden", options.ariaHidden); + if (options.ariaLabel !== undefined) node.setAttribute("aria-label", options.ariaLabel); + if (options.ariaExpanded !== undefined) node.setAttribute("aria-expanded", options.ariaExpanded); + if (options.disabled !== undefined && "disabled" in node) { (node as HTMLButtonElement).disabled = options.disabled; } + return node; } diff --git a/src/user-config.test.ts b/src/user-config.test.ts index e8948e1a5..0fafebfcf 100644 --- a/src/user-config.test.ts +++ b/src/user-config.test.ts @@ -60,6 +60,7 @@ test("concurrent loads migrate a legacy config exactly once", async (t) => { migrateInChildProcess(configDir), migrateInChildProcess(configDir), ]); + assert.equal(results.filter((result) => result.migrated).length, 1); assert.equal(results.filter((result) => !result.migrated).length, 1); assert.equal(existsSync(join(configDir, "config.json")), false); @@ -143,6 +144,7 @@ test("migration refuses to overwrite an existing backup", (t) => { function tempConfigDir(t: TestContext): { configDir: string; env: NodeJS.ProcessEnv } { const configDir = mkdtempSync(join(tmpdir(), "devspace-user-config-test-")); t.after(() => rmSync(configDir, { recursive: true, force: true })); + return { configDir, env: { DEVSPACE_CONFIG_DIR: configDir } }; } @@ -150,6 +152,7 @@ async function migrateInChildProcess( configDir: string, ): Promise<{ migrated: boolean }> { const moduleUrl = new URL("./user-config.ts", import.meta.url).href; + const source = [ `import { loadDevspaceFiles } from ${JSON.stringify(moduleUrl)};`, "const files = loadDevspaceFiles();", @@ -165,6 +168,7 @@ async function migrateInChildProcess( stdio: ["ignore", "pipe", "pipe"], }, ); + let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8").on("data", (chunk: string) => { @@ -177,8 +181,10 @@ async function migrateInChildProcess( child.once("close", (code) => { if (code !== 0) { reject(new Error(`migration child exited with ${code}: ${stderr}`)); + return; } + resolve(JSON.parse(stdout) as { migrated: boolean }); }); }); diff --git a/src/user-config.ts b/src/user-config.ts index 6775e4b7c..c80617ca1 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -32,6 +32,7 @@ const devspaceAuthConfigSchema = z.object({ }).passthrough(); export type DevspaceUserConfig = DevspaceConfig; + export type DevspaceAuthConfig = z.infer; export interface DevspaceFiles { @@ -83,9 +84,11 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac const configPath = devspaceConfigPath(env); const legacyConfigPath = devspaceLegacyConfigPath(env); const authPath = devspaceAuthPath(env); + const migratedLegacyConfig = !existsSync(configPath) && existsSync(legacyConfigPath) ? migrateLegacyConfigFile(legacyConfigPath, configPath, devspaceLegacyConfigBackupPath(env)) : false; + const configExists = existsSync(configPath); const authExists = existsSync(authPath); @@ -108,6 +111,7 @@ export function writeDevspaceConfig( const filePath = devspaceConfigPath(env); const parsed = devspaceConfigSchema.parse(config); atomicWrite(filePath, serializeConfig(parsed), 0o600); + return filePath; } @@ -124,17 +128,21 @@ export function setDevspaceConfigValues( env: NodeJS.ProcessEnv = process.env, ): string { const files = loadDevspaceFiles(env); + const source = files.configExists ? readFileSync(files.configPath, "utf8") : serializeConfig(files.config); + const updated = edits.reduce( (document, edit) => applyEdits(document, modify(document, edit.path, edit.value, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, })), source, ); + parseJsoncConfig(updated, files.configPath); atomicWrite(files.configPath, updated.endsWith("\n") ? updated : `${updated}\n`, 0o600); + return files.configPath; } @@ -145,6 +153,7 @@ export function writeDevspaceAuth( const filePath = devspaceAuthPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); writeJsonFile(filePath, devspaceAuthConfigSchema.parse(auth), 0o600); + return filePath; } @@ -165,6 +174,7 @@ function migrateLegacyConfigFile( } let migrated: DevspaceConfig; + try { migrated = migrateLegacyConfig(JSON.parse(readFileSync(legacyPath, "utf8")) as unknown); } catch (error) { @@ -173,10 +183,12 @@ function migrateLegacyConfigFile( const temporaryPath = temporaryFilePath(configPath); let published = false; + try { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(temporaryPath, serializeConfig(migrated), { mode: 0o600, flag: "wx" }); readJsoncConfig(temporaryPath); + try { // A hard link publishes the complete temporary file atomically without // replacing config.jsonc if another first-start process won the race. @@ -184,18 +196,22 @@ function migrateLegacyConfigFile( } catch (error) { if (!isErrnoException(error) || error.code !== "EEXIST") throw error; readJsoncConfig(configPath); + return false; } + published = true; renameSync(legacyPath, backupPath); } catch (error) { if (published && existsSync(legacyPath)) { rmSync(configPath, { force: true }); } + throw fileError("migrate", legacyPath, error); } finally { rmSync(temporaryPath, { force: true }); } + return true; } @@ -211,12 +227,14 @@ function readJsoncConfig(filePath: string): DevspaceConfig { function parseJsoncConfig(source: string, filePath: string): DevspaceConfig { const errors: ParseError[] = []; const value = parse(source, errors, { allowTrailingComma: true }); + if (errors.length > 0) { const first = errors[0]!; throw new DevspaceConfigFileError( `Unable to read ${filePath}: ${printParseErrorCode(first.error)} at offset ${first.offset}`, ); } + try { return devspaceConfigSchema.parse(value); } catch (error) { @@ -231,6 +249,7 @@ function serializeConfig(config: DevspaceConfig): string { function atomicWrite(filePath: string, source: string, mode: number): void { mkdirSync(dirname(filePath), { recursive: true }); const temporaryPath = temporaryFilePath(filePath); + try { writeFileSync(temporaryPath, source, { mode, flag: "wx" }); renameSync(temporaryPath, filePath); @@ -261,6 +280,7 @@ function writeJsonFile(filePath: string, value: unknown, mode: number): void { function fileError(action: "read" | "migrate", filePath: string, error: unknown): Error { const reason = error instanceof Error ? error.message : String(error); + return new DevspaceConfigFileError(`Unable to ${action} ${filePath}: ${reason}`); } diff --git a/src/workflow-cli.test.ts b/src/workflow-cli.test.ts index 2f845aeb2..b50907732 100644 --- a/src/workflow-cli.test.ts +++ b/src/workflow-cli.test.ts @@ -16,20 +16,28 @@ import { writeTestDevspaceConfig } from "./test-support/config.js"; import type { WorkflowRun } from "./workflow-types.js"; const execFileAsync = promisify(execFile); + // macOS limits the complete Unix socket path to 104 bytes. const root = await mkdtemp(join(tmpdir(), "ds-wf-")); const configDir = join(root, "config"); const stateDir = join(root, "state"); const workspaceDirectory = join(root, "project"); + await mkdir(workspaceDirectory, { recursive: true }); + const workspaceRoot = await realpath(workspaceDirectory); + const env = writeTestDevspaceConfig(configDir, { workspaces: { allowedRoots: [workspaceRoot] }, storage: { stateDir }, }); + const paths = localAgentDaemonPaths(stateDir); + ensureLocalAgentDaemonSecret(paths); + const requests: Array<{ method: string; params: Record }> = []; + const run: WorkflowRun = { id: "wfl_test", workspaceId: "ws_test", @@ -42,19 +50,24 @@ const run: WorkflowRun = { updatedAt: "now", callCount: 0, }; + const daemon = createServer((socket) => { let buffer = ""; socket.setEncoding("utf8"); socket.on("data", (chunk) => { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const request = JSON.parse(buffer.slice(0, newline)) as { requestId: string; method: string; params: Record; }; + requests.push(request); + const result = request.method === "hello" ? { status: { @@ -70,6 +83,7 @@ const daemon = createServer((socket) => { configMatches: true, } : run; + socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -83,6 +97,7 @@ await new Promise((resolve, reject) => { daemon.once("error", reject); daemon.listen(paths.endpoint, resolve); }); + after(async () => { await new Promise((resolve) => daemon.close(() => resolve())); await rm(root, { recursive: true, force: true }); @@ -90,12 +105,14 @@ after(async () => { // Hidden by default: without the flag the command does not exist and help does not mention it. const hiddenEnv = { ...process.env, ...env }; + delete hiddenEnv.DEVSPACE_EXPERIMENTAL_WORKFLOWS; test("workflow run submits the workflow file source and args to the daemon", async () => { const mark = requests.length; const workflowFile = join(root, "review.workflow.js"); await writeFile(workflowFile, "return agent('Review this', { target: 'reviewer' });\n"); + const { stdout } = await execFileAsync("node", [ "--import", "tsx", "src/cli.ts", "workflow", "run", "--file", workflowFile, @@ -111,6 +128,7 @@ test("workflow run submits the workflow file source and args to the daemon", asy DEVSPACE_WORKSPACE_ROOT: workspaceRoot, }, }); + assert.equal(stdout, `${JSON.stringify({ id: run.id, name: run.name, status: run.status })}\n`); const submitted = requests.slice(mark).find((request) => request.method === "workflow.run"); assert.deepEqual(submitted?.params, { @@ -134,6 +152,7 @@ test("workflow status escapes terminal control characters in run names", async ( DEVSPACE_WORKSPACE_ROOT: workspaceRoot, }, }); + assert.equal(status.stdout.includes("\u001b"), false); assert.match(status.stdout, /name="\\u001b\[31mreview\\u001b\[0m"/); }); @@ -145,6 +164,7 @@ test("workflow commands are rejected without the experimental flag", async () => () => assert.fail("workflow command must be rejected without the flag"), (error: { stderr: string }) => error.stderr, ); + assert.match(hidden, /Unknown command: workflow/); }); @@ -153,5 +173,6 @@ test("help hides workflow commands without the experimental flag", async () => { cwd: process.cwd(), env: hiddenEnv, }); + assert.doesNotMatch(hiddenHelp.stdout, /workflow/); }); diff --git a/src/workflow-cli.ts b/src/workflow-cli.ts index d4d7497a2..64ae51f34 100644 --- a/src/workflow-cli.ts +++ b/src/workflow-cli.ts @@ -9,76 +9,104 @@ import { isManagedWorkflowWorkspace } from "./workflow-workspaces.js"; export async function runWorkflowCommand(args: string[], json: boolean): Promise { try { const [command, ...rest] = args; + if (!command || command === "help" || command === "--help" || command === "-h") { printWorkflowHelp(); + return; } + const config = loadAgentConfig(); + const scope = resolveCliWorkspaceContext( config.allowedRoots, process.env, process.cwd(), (root, id) => isManagedWorkflowWorkspace(config, root, id), ); + const client = createLocalAgentClient(config); + switch (command) { case "run": { const input = await parseWorkflowRunArgs(rest); printWorkflow(unwrap(await client.runWorkflow({ ...scope, ...input })), json, true); + return; } + case "status": { const id = oneId(rest, "status"); printWorkflow(unwrap(await client.getWorkflow(id, scope)), json); + return; } + case "ls": case "list": { if (rest.length > 0) usage("ls"); const runs = unwrap(await client.listWorkflows(scope)); + if (json) printJson(runs); else for (const run of runs) console.log(formatWorkflow(run)); + return; } + case "wait": { const { id, timeoutMs } = parseWorkflowWaitArgs(rest); printWorkflow(unwrap(await client.waitWorkflow(id, scope, timeoutMs)), json); + return; } + case "calls": { const id = oneId(rest, "calls"); const calls = unwrap(await client.workflowCalls(id, scope)); + if (json) printJson(calls.map(summarizeCall)); else for (const call of calls) console.log(formatCall(call)); + return; } + case "call": { const [id, rawIndex, ...extra] = rest; + if (!id || !rawIndex || extra.length > 0 || !/^\d+$/.test(rawIndex)) usage("call "); const call = unwrap(await client.workflowCall(id, Number(rawIndex), scope)); + if (json) printJson(call); else console.log(formatCall(call, true)); + return; } + case "events": { const { id, after } = parseWorkflowEventsArgs(rest); const events = unwrap(await client.workflowEvents(id, scope, after)); + if (json) printJson(events); else for (const event of events) { console.log(`${xml(JSON.stringify(event.data) ?? "null")}`); } + return; } + case "cancel": { const id = oneId(rest, "cancel"); printWorkflow(unwrap(await client.cancelWorkflow(id, scope)), json); + return; } + default: throw new Error(`Unknown workflow command: ${command}`); } } catch (error) { const payload = workflowErrorPayload(error); + if (json) printJson({ error: payload }); else console.error(`${xml(payload.message)}`); process.exitCode = 1; @@ -92,10 +120,13 @@ async function parseWorkflowRunArgs(args: string[]): Promise|--name |--resume "); + switch (option) { case "--file": file = value; break; case "--name": name = value; break; @@ -108,38 +139,49 @@ async function parseWorkflowRunArgs(args: string[]): Promise = { ...(file ? { source: await readBoundedFile(file, 64 * 1024, "Workflow source") } : {}), ...(name ? { name } : {}), ...(resume ? { resume } : {}), ...(writeMode ? { writeMode } : {}), }; + const argsJson = rawArgs ?? (argsFile ? await readBoundedFile(argsFile, 128 * 1024, "Workflow arguments") : undefined); + if (argsJson !== undefined) { if (Buffer.byteLength(argsJson) > 128 * 1024) throw new Error("Workflow arguments exceed 131072 bytes."); + try { input.args = JSON.parse(argsJson) as unknown; } catch (cause) { throw new Error(`Workflow arguments are not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`); } } + return input; } async function readBoundedFile(path: string, maxBytes: number, label: string): Promise { const file = await open(path, "r"); + try { const buffer = Buffer.allocUnsafe(maxBytes + 1); const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes.`); + try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)); } catch { @@ -153,13 +195,17 @@ async function readBoundedFile(path: string, maxBytes: number, label: string): P function parseWorkflowWaitArgs(args: string[]): { id: string; timeoutMs: number } { let id: string | undefined; let timeoutMs = 60_000; + for (let index = 0; index < args.length; index += 1) { const value = args[index]!; + if (value === "--timeout") { const seconds = args[index + 1]; + if (!seconds || !/^\d+$/.test(seconds) || Number(seconds) > 60) { throw new Error("Workflow wait timeout must be an integer from 0 to 60 seconds."); } + timeoutMs = Number(seconds) * 1_000; index += 1; } else if (value.startsWith("-")) { @@ -170,20 +216,26 @@ function parseWorkflowWaitArgs(args: string[]): { id: string; timeoutMs: number id = value; } } + if (!id) usage("wait [--timeout ]"); + return { id, timeoutMs }; } function parseWorkflowEventsArgs(args: string[]): { id: string; after: number } { let id: string | undefined; let after = 0; + for (let index = 0; index < args.length; index += 1) { const value = args[index]!; + if (value === "--after") { const sequence = args[index + 1]; + if (!sequence || !/^\d+$/.test(sequence) || !Number.isSafeInteger(Number(sequence))) { throw new Error("Workflow event sequence must be a non-negative integer."); } + after = Number(sequence); index += 1; } else if (value.startsWith("-")) { @@ -194,17 +246,21 @@ function parseWorkflowEventsArgs(args: string[]): { id: string; after: number } id = value; } } + if (!id) usage("events [--after ]"); + return { id, after }; } function oneId(args: string[], command: string): string { if (args.length !== 1 || !args[0]) usage(`${command} `); + return args[0]; } function unwrap(result: Result): T { if (result.isErr()) throw result.error; + return result.value; } @@ -220,8 +276,10 @@ function formatWorkflow(run: WorkflowRun, receipt = false): string { `status="${run.status}"`, ...(run.error ? [`code="${xml(run.error.code)}"`, `retryable="${run.error.retryable}"`] : []), ].join(" "); + if (receipt) return ``; const detail = run.error?.message ?? (run.result === undefined ? "" : JSON.stringify(run.result)); + return detail ? `${xml(detail)}` : ``; @@ -233,12 +291,15 @@ function summarizeCall({ prompt: _prompt, result: _result, fingerprint: _fingerp function formatCall(call: WorkflowCall, detailed = false): string { const attributes = `run="${xml(call.runId)}" index="${call.index}" agent="${xml(call.agentId)}" status="${call.status}" target="${xml(call.options.target)}"`; + if (!detailed) return ``; + return `${xml(JSON.stringify({ prompt: call.prompt, options: call.options, result: call.result, error: call.error }))}`; } function workflowErrorPayload(error: unknown): { code: string; message: string; retryable: boolean } { const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { code: typeof value?.code === "string" ? value.code : "WORKFLOW_COMMAND_ERROR", message: typeof value?.message === "string" ? value.message : String(error), diff --git a/src/workflow-context.ts b/src/workflow-context.ts index 4c7f2a324..ccf7a23fe 100644 --- a/src/workflow-context.ts +++ b/src/workflow-context.ts @@ -3,6 +3,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { lstat, open, readdir } from "node:fs/promises"; import { join } from "node:path"; + const exec = promisify(execFile); export function workflowHash(value: unknown): string { @@ -20,19 +21,23 @@ export async function workflowContextHash(root: string): Promise { if (++count > 20_000) throw new Error("context too large"); const stat = await lstat(path); hash.update(JSON.stringify({ path: relative, mode: stat.mode, size: stat.isFile() ? stat.size : 0, directory: stat.isDirectory() })); + if (stat.isSymbolicLink()) { // A symlink can expose changing data outside the observed tree. throw new Error("linked context"); } + if (stat.isDirectory()) { for (const entry of (await readdir(path)).sort()) { if (entry === ".git") continue; @@ -40,35 +45,46 @@ export async function workflowContextHash(root: string): Promise 64 * 1024 * 1024) throw new Error("context too large"); const file = await open(path, "r"); + try { const buffer = Buffer.alloc(stat.size + 1); let length = 0; + while (length < buffer.length) { const chunk = await file.read(buffer, length, buffer.length - length, length); + if (!chunk.bytesRead) break; length += chunk.bytesRead; } + const after = await file.stat(); + if (length !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs || after.ino !== stat.ino) throw new Error("changing context"); hash.update(buffer.subarray(0, length)); } finally { await file.close(); } } else throw new Error("special file context"); } + // `-z` output is NUL separated with forward slashes on every platform (no CRLF or quoting). const listed = await exec("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: root, timeout: 5_000, maxBuffer: GIT_MAX_BUFFER }); const paths = [...new Set(listed.stdout.split("\0").filter(Boolean))].sort(); + for (const relative of paths) { const path = join(root, relative); + // Tracked but deleted from the worktree: the deletion is part of the context. const present = await lstat(path).then(() => true, (error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return false; throw error; }); + if (present) await visit(path, relative); else hash.update(JSON.stringify({ path: relative, deleted: true })); } + return hash.digest("hex"); } catch { return undefined; } } diff --git a/src/workflow-manager.test.ts b/src/workflow-manager.test.ts index 59018931d..eae93d3e4 100644 --- a/src/workflow-manager.test.ts +++ b/src/workflow-manager.test.ts @@ -29,35 +29,44 @@ async function fixture(runner?: typeof runWorkflowScript) { let active = 0; let maximum = 0; let releaseHold: (() => void) | undefined; + const runtime: LocalAgentRuntime = { provider: "codex", isAlive: () => true, close: async () => {}, releaseSession: async () => {}, run: async (input, callbacks) => { inputs.push(input); active++; maximum = Math.max(maximum, active); + try { await callbacks?.onSessionId?.(`session_${inputs.length}`); + if (input.prompt === "commit") { await writeFile(join(input.workspaceRoot, "committed.txt"), "preserve this commit"); execFileSync("git", ["add", "committed.txt"], { cwd: input.workspaceRoot }); execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "Worker change"], { cwd: input.workspaceRoot }); await writeFile(join(input.workspaceRoot, "untracked.txt"), "preserve this too"); } + if (input.prompt === "hold") { await new Promise((resolve) => { releaseHold = resolve; input.signal?.addEventListener("abort", () => resolve(), { once: true }); + if (input.signal?.aborted) resolve(); }); + if (input.signal?.aborted) return Result.err(new AgentProviderCancelledError({ code: "PROVIDER_CANCELLED", provider: "codex", operation: "run", retryable: false, message: "Test provider confirmed cancellation.", })); } + await new Promise((resolve) => setTimeout(resolve, 5)); + return Result.ok({ provider: "codex" as const, providerSessionId: null, finalResponse: input.prompt.startsWith("Your last response") ? '{"ok":true}' : input.prompt, items: [] }); } finally { active--; } }, }; + const agents = new LocalAgentManager({ store: new LocalAgentStore(stateDir), pool: new LocalAgentRuntimePool(), loadProfiles: async () => [], subagents: { enabled: true, instructions: "on-demand", providers: [{ id: "codex", driver: "codex", enabled: true }] }, @@ -73,12 +82,16 @@ async function fixture(runner?: typeof runWorkflowScript) { allowedRoots: [root], authorizeManagedWorkspace: (path, id) => isManagedWorkflowWorkspace(config, path, id), }); + const store = new WorkflowStore(stateDir); + const workflows = new WorkflowManager({ store, agents, runner, config, loadProfiles: async () => [], allowedRoots: [root], subagents: { enabled: true, instructions: "on-demand", providers: [{ id: "codex", driver: "codex", enabled: true }] }, }); + const scope = { workspaceRoot: root }; + return { dir, root, inputs, agents, store, workflows, scope, maximum: () => maximum, release: () => releaseHold?.(), @@ -88,9 +101,11 @@ async function fixture(runner?: typeof runWorkflowScript) { test("real JS pipeline runs in parallel, preserves order and durable turn ownership", async () => { const f = await fixture(); + try { const run = await f.workflows.run({ ...f.scope, source: `export const meta = {name:'test',concurrency:2}; return await pipeline(['a','b','c'], value => agent(value,{target:'codex'}), (value,item) => agent(value+item,{target:'codex'}));` }); + const done = await f.workflows.wait(run.id, f.scope); assert.equal(done.status, "completed", JSON.stringify(done.error)); assert.deepEqual(done.result, ["aa", "bb", "cc"].map((value) => ({ status: "completed", value }))); @@ -109,8 +124,10 @@ test("real JS pipeline runs in parallel, preserves order and durable turn owners test("workspaces outside the allowed roots are rejected with a workflow error", async () => { const f = await fixture(); + try { const outside = await mkdtemp(join(tmpdir(), "devspace-workflow-outside-")); + try { await assert.rejects( f.workflows.run({ workspaceRoot: outside, source: "return 1;" }), @@ -127,8 +144,10 @@ test("workspaces outside the allowed roots are rejected with a workflow error", test("shared daemon capacity bounds simultaneous workflows", async () => { const f = await fixture(async ({ onAgent }) => { await Promise.all(Array.from({ length: 16 }, (_, i) => onAgent(String(i), { target: "codex" }))); + return "done"; }); + try { const source = "export const meta={concurrency:16}; return null;"; const runs = await Promise.all([f.workflows.run({ ...f.scope, source }), f.workflows.run({ ...f.scope, source })]); @@ -139,6 +158,7 @@ test("shared daemon capacity bounds simultaneous workflows", async () => { test("cancelling one workflow stops its exact owned turn and preserves another shared-runtime agent", async () => { const f = await fixture(async ({ onAgent }) => await onAgent("hold", { target: "codex" }) as string); + try { const run = await f.workflows.run({ ...f.scope, source: "return null;" }); await until(() => f.inputs.length === 1); @@ -157,8 +177,10 @@ test("normal return with unawaited calls cancels children before persisting fail const f = await fixture(async ({ onAgent }) => { void Promise.resolve(onAgent("hold", { target: "codex" })).catch(() => {}); await until(() => f.inputs.length === 1); + return "early"; }); + try { const run = await f.workflows.run({ ...f.scope, source: "return null;" }); const done = await f.workflows.wait(run.id, f.scope); @@ -172,13 +194,16 @@ test("schema validates before dispatch and one correction runs with reduced auth const f = await fixture(async ({ onAgent }) => await onAgent("work", { target: "codex", schema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false }, }) as { ok: boolean }); + try { const run = await f.workflows.run({ ...f.scope, source: "return null;", writeMode: "allowed" }); assert.deepEqual((await f.workflows.wait(run.id, f.scope)).result, { ok: true }); assert.deepEqual(f.inputs.map((i) => i.writeMode), ["allowed", "read_only"]); assert.equal(f.workflows.calls(run.id, f.scope).length, 1); } finally { await f.close(); } + const invalid = await fixture(async ({ onAgent }) => await onAgent("never", { target: "codex", schema: { type: "nonsense" } }) as null); + try { const run = await invalid.workflows.run({ ...invalid.scope, source: "return null;" }); assert.equal((await invalid.workflows.wait(run.id, invalid.scope)).error?.code, "INVALID_SCHEMA"); @@ -188,6 +213,7 @@ test("schema validates before dispatch and one correction runs with reduced auth test("resume reuses only compatible read-only prefix and rejects changed context and active runs", async () => { const f = await fixture(async ({ onAgent }) => await onAgent("review", { target: "codex" }) as string); + try { const first = await f.workflows.run({ ...f.scope, source: "return null;" }); await assert.rejects(f.workflows.run({ ...f.scope, resume: first.id }), (e: unknown) => e instanceof WorkflowError && e.code === "WORKFLOW_ACTIVE"); @@ -204,6 +230,7 @@ test("resume reuses only compatible read-only prefix and rejects changed context test("resume hashes only files Git does not ignore", async () => { const f = await fixture(async () => null); + try { await writeFile(join(f.root, ".gitignore"), "node_modules/\n"); await writeFile(join(f.root, "tracked.txt"), "tracked"); @@ -229,6 +256,7 @@ test("resume hashes only files Git does not ignore", async () => { test("resume rejects staged Git state changes even when worktree files are unchanged", async () => { const f = await fixture(async () => null); + try { await writeFile(join(f.root, "staged.txt"), "same content"); const first = await f.workflows.run({ ...f.scope, source: "return null;" }); @@ -243,6 +271,7 @@ test("resume rejects staged Git state changes even when worktree files are uncha test("nested workflow shares capacity at concurrency one and cannot recursively nest", async () => { const f = await fixture(); + try { await mkdir(join(f.root, ".devspace", "workflows"), { recursive: true }); await writeFile(join(f.root, ".devspace", "workflows", "child.js"), "return await agent('nested',{target:'codex'});"); @@ -257,8 +286,10 @@ test("nested workflow shares capacity at concurrency one and cannot recursively test("worktree calls share their logical workspace and preserve commits plus dirty files", async () => { const f = await fixture(async ({ onAgent }) => { await onAgent("commit", { target: "codex", isolation: "worktree", workspace: "implementation" }); + return await onAgent("verify", { target: "codex", workspace: "implementation", writeMode: "read_only" }) as string; }); + try { const run = await f.workflows.run({ ...f.scope, source: "return null;", writeMode: "allowed" }); const done = await f.workflows.wait(run.id, f.scope); @@ -275,6 +306,7 @@ test("worktree calls share their logical workspace and preserve commits plus dir test("restart reconciliation preserves durable ownership without dispatching interrupted calls", async () => { const f = await fixture(async () => "done"); + try { const run = await f.workflows.run({ ...f.scope, source: "return null;" }); await f.workflows.wait(run.id, f.scope); @@ -296,8 +328,10 @@ test("restart reconciliation preserves durable ownership without dispatching int test("parallel unnamed isolated calls receive distinct worktrees", async () => { const f = await fixture(async ({ onAgent }) => { await Promise.all([0, 1].map((i) => onAgent(String(i), { target: "codex", isolation: "worktree" }))); + return "done"; }); + try { const run = await f.workflows.run({ ...f.scope, source: "return null;" }); const done = await f.workflows.wait(run.id, f.scope); @@ -312,14 +346,17 @@ test("shutdown stops retrying unconfirmed provider cancellation and preserves re const originalGetTurn = f.agents.getTurn.bind(f.agents); const originalStop = f.agents.stop.bind(f.agents); let close: Promise | undefined; + try { const run = await f.workflows.run({ ...f.scope, source: "return null;" }); await until(() => f.inputs.length === 1); await until(() => f.workflows.calls(run.id, f.scope)[0]?.turnId !== undefined); const call = f.workflows.call(run.id, 0, f.scope); + const runningTurn: LocalAgentTurnRecord = { id: 1, turnId: call.turnId!, agentId: call.agentId, prompt: call.prompt, status: "running", createdAt: "now", }; + f.agents.getTurn = () => Result.ok(runningTurn); f.agents.stop = async () => Result.err(new AgentConflictError({ code: "AGENT_CONFLICT", agentId: call.agentId, operation: "stop", retryable: true, @@ -332,6 +369,7 @@ test("shutdown stops retrying unconfirmed provider cancellation and preserves re new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("workflow close timed out")), 2_000); }), ]).finally(() => { if (timer) clearTimeout(timer); }); const reopened = new WorkflowStore(join(f.dir, "state")); + try { assert.equal(reopened.get(run.id)?.status, "stopping"); assert.equal(reopened.get(run.id)?.error?.code, "RECOVERY_REQUIRED"); @@ -348,12 +386,15 @@ test("shutdown stops retrying unconfirmed provider cancellation and preserves re async function until(check: () => boolean): Promise { const end = Date.now() + 5_000; + while (!check()) { if (Date.now() > end) throw new Error("Test condition timed out"); await new Promise((resolve) => setTimeout(resolve, 5)); } } + function unwrap(result: import("better-result").Result): T { if (result.isErr()) throw result.error; + return result.value; } diff --git a/src/workflow-manager.ts b/src/workflow-manager.ts index 7610b2a4a..f343be09b 100644 --- a/src/workflow-manager.ts +++ b/src/workflow-manager.ts @@ -30,6 +30,7 @@ interface WorkflowManagerOptions { /** Tests may exercise the supervisor without a provider or child process. */ runner?: typeof runWorkflowScript; } + interface ActiveRun { run: WorkflowSnapshot; controller: AbortController; @@ -58,61 +59,83 @@ export class WorkflowManager { async run(input: WorkflowRunInput): Promise { if (!this.accepting) throw new WorkflowError("WORKFLOW_STOPPING", "Workflow service is stopping."); + if (this.active.size >= 8) throw new WorkflowError("WORKFLOW_CAPACITY", "Eight workflows are already active. Wait for one to finish.", true); const scope = await this.authorize(input); + if ([input.source !== undefined, input.name !== undefined, input.resume !== undefined].filter(Boolean).length !== 1) { throw new WorkflowError("INVALID_WORKFLOW", "Supply exactly one source, name, or resume ID."); } + const previous = input.resume ? this.snapshot(input.resume, scope) : undefined; + if (previous && (!workflowTerminal(previous.status) || this.active.has(previous.id))) { throw new WorkflowError("WORKFLOW_ACTIVE", "Cancel and wait for the previous run before resuming."); } + const previousCalls = previous ? this.options.store.calls(previous.id) : []; + for (const call of previousCalls) { const record = this.options.agents.get(call.agentId, call); + if (record.isErr() && record.error.code !== "AGENT_NOT_FOUND") { throw new WorkflowError("RECOVERY_REQUIRED", `Cannot establish ownership of prior agent ${call.agentId}: ${record.error.message}`); } + if (record.isOk() && (record.value.status === "running" || record.value.status === "starting")) { throw new WorkflowError("WORKFLOW_ACTIVE", `Child agent ${call.agentId} is still active.`); } + if (call.options.writeMode !== "read_only" || call.options.isolation || call.options.workspace) { throw new WorkflowError("RECOVERY_REQUIRED", "This run may have changed a workspace. Inspect its calls and preserved worktrees, then start a new explicit workflow; editing calls are never replayed."); } } + if (previous && (input.args !== undefined || input.writeMode !== undefined)) { throw new WorkflowError("INVALID_WORKFLOW", "Resume uses the original arguments and authority. Start a new run to change them."); } + const source = previous?.source ?? input.source ?? await this.namedSource(input.name!, scope.workspaceRoot); + if (typeof source !== "string" || Buffer.byteLength(source) > 65_536) throw new WorkflowError("SOURCE_LIMIT", "Workflow source exceeds 64 KiB."); let meta: ReturnType["meta"]; + try { meta = parseWorkflowScript(source).meta; } catch (error) { throw new WorkflowError("INVALID_WORKFLOW", workflowFailure(error).message); } + const args = jsonValue(previous ? previous.args : input.args ?? {}, 131_072, "arguments"); const contextHash = await workflowContextHash(scope.workspaceRoot); + if (previous && (!contextHash || contextHash !== previous.contextHash)) { throw new WorkflowError("RECOVERY_CONTEXT_CHANGED", "Workspace context changed or cannot be fully fingerprinted. Start a new workflow after reviewing the previous run."); } + const writeMode = previous?.writeMode ?? input.writeMode ?? "read_only"; + if (writeMode !== "read_only" && writeMode !== "allowed") throw new WorkflowError("INVALID_WORKFLOW", "Invalid workflow write mode."); const now = new Date().toISOString(); + const run: WorkflowSnapshot = { id: `wf_${randomUUID()}`, ...scope, name: meta.name, source, args, contextHash: contextHash ?? "unavailable", writeMode, concurrency: meta.concurrency, status: "starting", resumeOf: previous?.id, createdAt: now, updatedAt: now, callCount: 0, }; + if (this.active.size >= 8) throw new WorkflowError("WORKFLOW_CAPACITY", "Eight workflows are already active. Wait for one to finish.", true); this.options.store.create(run); + const active: ActiveRun = { run, controller: new AbortController(), pending: new Set(), preparing: Promise.resolve(), calls: 0, dispatches: 0, nested: 0, eventBytes: 0, slots: new Slots(run.concurrency), workspaces: new Map(), previous: previousCalls, replayPrefix: true, unconfirmedStops: false, done: Promise.resolve(), }; + this.active.set(run.id, active); active.done = this.execute(active); // Completion failures remain visible in persisted state; never create an unhandled rejection. void active.done.catch(() => {}); + return summary(run); } @@ -122,23 +145,30 @@ export class WorkflowManager { } calls(id: string, scope: LocalAgentWorkspaceScope): WorkflowCall[] { this.snapshot(id, scope); + return this.options.store.calls(id); } call(id: string, index: number, scope: LocalAgentWorkspaceScope): WorkflowCall { this.snapshot(id, scope); const call = this.options.store.call(id, index); + if (!call) throw new WorkflowError("WORKFLOW_CALL_NOT_FOUND", `Unknown workflow call: ${index}`); + return call; } events(id: string, scope: LocalAgentWorkspaceScope, after = 0): WorkflowEvent[] { this.snapshot(id, scope); + if (!Number.isSafeInteger(after) || after < 0) throw new WorkflowError("INVALID_WORKFLOW", "Event cursor must be a nonnegative sequence number."); + return this.options.store.events(id, after); } async wait(id: string, scope: LocalAgentWorkspaceScope, timeoutMs = 60_000, signal?: AbortSignal): Promise { const run = this.get(id, scope); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 60_000) throw new WorkflowError("INVALID_WORKFLOW", "Wait timeout must be 0–60000 milliseconds."); const active = this.active.get(id); + if (!active || workflowTerminal(run.status) || signal?.aborted) return run; await new Promise((done) => { const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); done(); }; @@ -146,20 +176,24 @@ export class WorkflowManager { signal?.addEventListener("abort", finish, { once: true }); void active.done.then(finish, finish); }); + return this.get(id, scope); } async cancel(id: string, scope: LocalAgentWorkspaceScope): Promise { const run = this.get(id, scope); const active = this.active.get(id); + if (active) { this.options.store.update(id, { status: "stopping" }); active.controller.abort(new WorkflowError("WORKFLOW_CANCELLED", "Workflow cancellation requested.")); } + return active ? this.get(id, scope) : run; } reconcile(): void { for (const run of this.options.store.list()) { if (workflowTerminal(run.status)) continue; + for (const call of this.options.store.calls(run.id)) { if (call.status !== "queued" && call.status !== "running") continue; // Recover a crash between durable agent dispatch and saving the turn link. @@ -169,6 +203,7 @@ export class WorkflowManager { error: { code: "WORKFLOW_INTERRUPTED", message: "Daemon stopped before this call was finalized. Inspect the agent before retrying.", retryable: false }, }); } + this.options.store.update(run.id, { status: "interrupted", error: { code: "WORKFLOW_INTERRUPTED", message: "Daemon stopped. Explicit resume is required; no calls were automatically dispatched.", retryable: false }, }); @@ -177,6 +212,7 @@ export class WorkflowManager { async close(): Promise { this.accepting = false; const active = [...this.active.values()]; + for (const run of active) run.controller.abort(new WorkflowError("WORKFLOW_CANCELLED", "Daemon is stopping.")); await Promise.allSettled(active.map((run) => run.done)); this.options.store.close(); @@ -186,20 +222,26 @@ export class WorkflowManager { const { run, controller } = active; let result: unknown; let failure: unknown; + try { this.options.store.update(run.id, { status: "running" }); result = await this.script(active, run.source, run.args, 0); + if (active.pending.size) throw new WorkflowError("UNAWAITED_CALLS", "Workflow returned while agent calls were still pending. Await all calls before returning."); + if (controller.signal.aborted) throw controller.signal.reason; } catch (error) { failure = error; } finally { // One finalization path: stop admission first, then confirm every owned turn has settled. controller.abort(failure ?? new WorkflowError("WORKFLOW_FINISHED", "Workflow finished.")); + try { if (active.pending.size) { try { this.options.store.update(run.id, { status: "stopping" }); } catch { /* Still stop children if persistence is unavailable. */ } } - await Promise.allSettled([...active.pending]); + + await Promise.allSettled(active.pending); + if (active.unconfirmedStops) return; const error = failure ? workflowFailure(failure) : undefined; this.options.store.update(run.id, { @@ -213,31 +255,40 @@ export class WorkflowManager { if (this.scripts >= 16) throw this.fatal(active, new WorkflowError("WORKFLOW_RUNNER_LIMIT", "Sixteen script runners are already active.")); this.scripts++; const runner = this.options.runner ?? runWorkflowScript; + try { return await runner({ source, args, signal: active.controller.signal, depth, onAgent: (prompt, options) => { const task = this.agent(active, prompt, options as WorkflowAgentOptions, workflowHash(source)); active.pending.add(task); void task.then(() => active.pending.delete(task), () => active.pending.delete(task)); + return task; }, onWorkflow: (name, nestedArgs) => { if (depth >= 1) throw this.fatal(active, new WorkflowError("WORKFLOW_DEPTH_LIMIT", "Nested workflows are limited to one level.")); + if (++active.nested > 8) throw this.fatal(active, new WorkflowError("WORKFLOW_NESTED_LIMIT", "A run can invoke at most eight nested workflows.")); + const task = (async () => { const nestedSource = await this.namedSource(name, active.run.workspaceRoot); this.options.store.event(active.run.id, "nested_workflow", { name, source: nestedSource }); + return this.script(active, nestedSource, nestedArgs, depth + 1); })(); + active.pending.add(task); void task.then(() => active.pending.delete(task), (error) => { active.pending.delete(task); + if (workflowFailure(error).code.includes("LIMIT")) this.fatal(active, error); }); + return task; }, onEvent: (type, data) => { active.eventBytes += Buffer.byteLength(JSON.stringify(data)); + if (active.eventBytes > 65_536) throw this.fatal(active, new WorkflowError("WORKFLOW_LOG_LIMIT", "Combined workflow events exceed 64 KiB.")); this.options.store.event(active.run.id, type, data); }, @@ -247,41 +298,55 @@ export class WorkflowManager { const signal = active.controller.signal; signal.throwIfAborted(); const index = active.calls++; + if (index >= 128) throw this.fatal(active, new WorkflowError("WORKFLOW_CALL_LIMIT", "Workflow exceeds 128 agent calls.")); + // Serialize preparation/replay decisions, never the agent execution itself. const prepared = active.preparing.then(async () => { signal.throwIfAborted(); const options = agentOptions(value, active.run.writeMode); + if (typeof prompt !== "string" || !prompt.trim() || Buffer.byteLength(prompt) > 65_536) throw new WorkflowError("INVALID_AGENT_CALL", "Agent prompt must be nonempty and at most 64 KiB."); const validate = options.schema ? compileWorkflowSchema(options.schema) : undefined; const scope = await this.callWorkspace(active, options, index); const profiles = await this.options.loadProfiles(scope.workspaceRoot); const target = resolveLocalAgentTarget(options.target, profiles, options.model, options.effort, this.options.subagents.providers); + if (!target) throw new WorkflowError("UNKNOWN_TARGET", `Unknown agent target: ${options.target}`); options.model = target.model; options.effort = target.effort; + const fingerprint = workflowHash({ prompt, options, sourceHash, scope, context: active.run.contextHash, profile: target.kind === "profile" ? target.profile : null, providers: this.options.subagents }); + const previous = active.previous[index]; const reuse = active.replayPrefix && previous?.status === "completed" && previous.fingerprint === fingerprint && options.writeMode === "read_only"; + if (!reuse) { if (active.replayPrefix && active.run.resumeOf) this.options.store.event(active.run.id, "replay_invalidated", { index, reason: previous ? "call context or result changed" : "end of previous calls" }); active.replayPrefix = false; } + const now = new Date().toISOString(); + const call: WorkflowCall = { runId: active.run.id, index, agentId: `agt_${randomUUID()}`, ...scope, prompt, options, fingerprint, status: reuse ? "completed" : "queued", createdAt: now, updatedAt: now, ...(reuse ? { reusedFrom: active.run.resumeOf, result: previous.result, agentId: previous.agentId, turnId: previous.turnId } : {}), }; + this.options.store.addCall(call); + return { call, validate, reuse }; }); + active.preparing = prepared.catch(() => {}); const { call, validate, reuse } = await prepared; + if (reuse) return call.result; let releaseRun: (() => void) | undefined; let releaseGlobal: (() => void) | undefined; + try { releaseRun = await active.slots.acquire(signal); releaseGlobal = await this.slots.acquire(signal); @@ -289,15 +354,19 @@ export class WorkflowManager { this.countDispatch(active); const fullPrompt = validate ? `${prompt}\n\nReturn only JSON matching this schema:\n${JSON.stringify(call.options.schema)}` : prompt; const started = await this.options.agents.start({ ...call, ...call.options, prompt: fullPrompt, agentId: call.agentId }); + if (started.isErr()) throw started.error; let turn = this.options.agents.getTurn(call.agentId, call); + if (turn.isErr()) throw turn.error; + if (!turn.value) throw new WorkflowError("WORKFLOW_INTERNAL", "Agent dispatch returned without a durable turn."); call.turnId = turn.value.turnId; this.options.store.updateCall(call.runId, call.index, { status: "running", turnId: turn.value.turnId }); this.options.store.event(call.runId, "agent_turn", { index, agentId: call.agentId, turnId: turn.value.turnId, repair: false }); let response = await this.awaitTurn(active, call, turn.value.turnId); let output: unknown = response; + if (validate) { try { output = parseWorkflowOutput(response, validate); } catch (error) { @@ -305,12 +374,16 @@ export class WorkflowManager { signal.throwIfAborted(); this.countDispatch(active); this.options.store.event(call.runId, "schema_repair", { index, reason: workflowFailure(error).message }); + const repair = await this.options.agents.continue(call.agentId, `Your last response failed JSON validation: ${workflowFailure(error).message}\nReturn corrected JSON only. Do not redo the task or change files. Schema: ${JSON.stringify(call.options.schema)}`, { writeMode: "read_only" }, call); + if (repair.isErr()) throw repair.error; turn = this.options.agents.getTurn(call.agentId, call); + if (turn.isErr()) throw turn.error; + if (!turn.value) throw new WorkflowError("WORKFLOW_INTERNAL", "Schema repair has no durable turn."); call.turnId = turn.value.turnId; this.options.store.updateCall(call.runId, call.index, { turnId: turn.value.turnId }); @@ -319,13 +392,16 @@ export class WorkflowManager { output = parseWorkflowOutput(response, validate); } } + output = jsonValue(output, 262_144, "agent result"); this.options.store.updateCall(call.runId, index, { status: "completed", result: output }); + return output; } catch (error) { // Resolve ownership again if cancellation raced dispatch or the turn-link write. await this.stopOwnedCall(active, call); this.options.store.updateCall(call.runId, index, { status: signal.aborted ? "cancelled" : "failed", error: workflowFailure(error) }); + if (workflowFailure(error).code === "WORKFLOW_INTERNAL" || workflowFailure(error).code.includes("LIMIT")) this.fatal(active, error); throw error; } finally { releaseGlobal?.(); releaseRun?.(); } @@ -333,88 +409,116 @@ export class WorkflowManager { private async awaitTurn(active: ActiveRun, call: WorkflowCall, turnId: string): Promise { const signal = active.controller.signal; const waited = await this.options.agents.wait([call.agentId], call, undefined, signal); + if (signal.aborted) { await this.stopOwnedCall(active, { ...call, turnId }); throw signal.reason; } + if (waited.isErr()) throw waited.error; const result = waited.value[0]; + if (result?.status === "completed") { const response = result.response ?? ""; + if (Buffer.byteLength(response) > 262_144) throw new WorkflowError("WORKFLOW_VALUE_LIMIT", "Agent result exceeds 256 KiB."); + return response; } + if (result?.status === "failed" || result?.status === "stopped") throw new WorkflowError(result.error?.code ?? "AGENT_STOPPED", result.error?.message ?? "Agent stopped.", result.error?.retryable); throw new WorkflowError("WORKFLOW_INTERNAL", "Agent wait ended without a terminal result."); } private async stopOwnedCall(active: ActiveRun, call: WorkflowCall): Promise { for (;;) { let uncertainty: unknown; + try { const current = this.options.agents.getTurn(call.agentId, call); + if (current.isErr()) { if (current.error.code === "AGENT_NOT_FOUND") return; uncertainty = current.error; } else { if (!current.value || current.value.status !== "running") return; + if (call.turnId !== undefined && current.value.turnId !== call.turnId) return; call.turnId ??= current.value.turnId; const stopped = await this.options.agents.stop(call.agentId, call); + if (stopped.isOk()) return; // stop() conflicts when the turn settled by itself; re-read before treating that as uncertainty. const settled = this.options.agents.getTurn(call.agentId, call); + if (settled.isOk() && settled.value?.status !== "running") return; uncertainty = stopped.error; } } catch (error) { uncertainty = error; } + const error = new WorkflowError("RECOVERY_REQUIRED", `Waiting to confirm agent ${call.agentId} stopped: ${workflowFailure(uncertainty).message}`, true); this.fatal(active, error); + try { this.options.store.update(active.run.id, { status: "stopping", error: workflowFailure(error) }); this.options.store.updateCall(call.runId, call.index, { error: workflowFailure(error) }); } catch { /* Keep ownership in memory until persistence returns or the daemon restarts. */ } + // Admission remains closed while confirmation is unavailable; cancellation is not completion. if (!this.accepting) { active.unconfirmedStops = true; + return; } + await new Promise((resolve) => setTimeout(resolve, 1_000)); } } - private fatal(active: ActiveRun, error: unknown): unknown { active.controller.abort(error); return error; } + private fatal(active: ActiveRun, error: unknown): unknown { active.controller.abort(error); + + return error; } private countDispatch(active: ActiveRun): void { if (++active.dispatches > 128) throw this.fatal(active, new WorkflowError("WORKFLOW_CALL_LIMIT", "Workflow exceeds 128 agent turns, including schema repairs.")); } private snapshot(id: string, scope: LocalAgentWorkspaceScope): WorkflowSnapshot { const run = this.options.store.get(id); + if (!run || run.workspaceRoot !== canonicalRoot(scope.workspaceRoot) || run.workspaceId !== scope.workspaceId) { throw new WorkflowError("WORKFLOW_NOT_FOUND", `Workflow is not in this workspace: ${id}`); } + return run; } private async authorize(scope: LocalAgentWorkspaceScope): Promise { let root: string; + try { root = await realpath(scope.workspaceRoot); + if (this.options.allowedRoots) { const managed = this.options.config && scope.workspaceId && isManagedWorkflowWorkspace(this.options.config, root, scope.workspaceId); + if (!managed) await resolveCanonicalAllowedPath(root, root, [...this.options.allowedRoots]); } } catch (error) { // Keep the reason visible across the daemon boundary instead of DAEMON_INTERNAL_ERROR. throw new WorkflowError("WORKSPACE_NOT_ALLOWED", `Workspace is not available for workflows: ${workflowFailure(error).message}`); } + return { workspaceRoot: root, ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}) }; } private async namedSource(name: string, root: string): Promise { if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) throw new WorkflowError("INVALID_WORKFLOW", "Workflow name must be a simple name, without paths."); + try { const path = await resolveCanonicalAllowedPath(join(root, ".devspace", "workflows", `${name}.js`), root, [root]); const file = await open(path, "r"); + try { const buffer = Buffer.alloc(65_537); const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + if (bytesRead > 65_536) throw new WorkflowError("SOURCE_LIMIT", "Workflow source exceeds 64 KiB."); + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)); } finally { await file.close(); } } catch (error) { @@ -426,42 +530,59 @@ export class WorkflowManager { if (!options.workspace && !options.isolation) return { workspaceRoot: active.run.workspaceRoot, workspaceId: active.run.workspaceId }; const key = options.workspace ?? `@call:${index}`; let workspace = active.workspaces.get(key); + if (!workspace) { if (!options.isolation) throw new WorkflowError("INVALID_WORKSPACE", `Unknown workflow workspace: ${key}. Create it with isolation: 'worktree'.`); + if (!this.options.config) throw new WorkflowError("INVALID_WORKSPACE", "Managed worktrees are unavailable."); workspace = createWorkflowWorkspace(this.options.config, active.run); active.workspaces.set(key, workspace); const scope = await workspace; this.options.store.event(active.run.id, "workspace_created", { key, ...scope, retained: true }); } + return workspace; } } function summary({ source: _source, args: _args, contextHash: _hash, ...run }: WorkflowSnapshot): WorkflowRun { return run; } + function canonicalRoot(root: string): string { try { return realpathSync.native(root); } catch { return resolve(root); } } + function jsonValue(value: unknown, bytes: number, label: string): unknown { let encoded: string | undefined; + try { encoded = JSON.stringify(value); } catch { /* Fail with the same public boundary error. */ } + if (encoded === undefined || Buffer.byteLength(encoded) > bytes) throw new WorkflowError("WORKFLOW_VALUE_LIMIT", `Workflow ${label} must be JSON and at most ${bytes} bytes.`); + return JSON.parse(encoded); } + function agentOptions(value: WorkflowAgentOptions, authority: "read_only" | "allowed"): WorkflowAgentOptions { if (!value || typeof value !== "object" || Array.isArray(value)) throw new WorkflowError("INVALID_AGENT_CALL", "Agent options must be an object."); const options = jsonValue(value, 32_768, "agent options") as WorkflowAgentOptions; const keys = new Set(["target", "model", "effort", "schema", "label", "phase", "writeMode", "isolation", "workspace"]); + for (const key of Object.keys(options)) if (!keys.has(key)) throw new WorkflowError("INVALID_AGENT_CALL", `Unknown agent option: ${key}`); + if (typeof options.target !== "string" || !options.target.trim()) throw new WorkflowError("INVALID_AGENT_CALL", "Agent target is required."); + for (const key of ["model", "effort", "label", "phase", "workspace"] as const) { if (options[key] !== undefined && (typeof options[key] !== "string" || options[key]!.length > 256 || !options[key]!.trim())) throw new WorkflowError("INVALID_AGENT_CALL", `Invalid agent ${key}.`); } + if (options.workspace && !/^[\w-]{1,64}$/.test(options.workspace)) throw new WorkflowError("INVALID_WORKSPACE", "Workspace is a logical name, not a path."); + if (options.isolation !== undefined && options.isolation !== "worktree") throw new WorkflowError("INVALID_AGENT_CALL", "Only worktree isolation is supported."); options.writeMode ??= authority; + if (!["read_only", "allowed"].includes(options.writeMode) || (authority === "read_only" && options.writeMode !== "read_only")) throw new WorkflowError("WORKFLOW_AUTHORITY", "Agent call cannot increase the workflow's write authority."); + if (options.schema !== undefined && (!options.schema || typeof options.schema !== "object" || Array.isArray(options.schema))) throw new WorkflowError("INVALID_SCHEMA", "Schema must be an object."); + return options; } @@ -471,19 +592,26 @@ class Slots { constructor(private readonly limit: number) {} async acquire(signal: AbortSignal): Promise<() => void> { signal.throwIfAborted(); + if (this.used >= this.limit) { await new Promise((resolve, reject) => { const ready = () => { signal.removeEventListener("abort", aborted); resolve(); }; - const aborted = () => { const index = this.waiting.indexOf(ready); if (index >= 0) this.waiting.splice(index, 1); reject(signal.reason); }; + const aborted = () => { const index = this.waiting.indexOf(ready); + + if (index >= 0) this.waiting.splice(index, 1); reject(signal.reason); }; + this.waiting.push(ready); signal.addEventListener("abort", aborted, { once: true }); }); } else this.used++; + if (signal.aborted) { this.release(); throw signal.reason; } + return () => this.release(); } private release(): void { const next = this.waiting.shift(); + if (next) next(); else this.used--; } } diff --git a/src/workflow-runner-child.ts b/src/workflow-runner-child.ts index 51f3a5f3c..acbfb9c00 100644 --- a/src/workflow-runner-child.ts +++ b/src/workflow-runner-child.ts @@ -9,6 +9,7 @@ import type { WorkflowRunnerLimits } from "./workflow-runner.js"; import type { WorkflowScriptMeta } from "./workflow-script.js"; interface ErrorPayload { code: string; message: string; retryable?: boolean } + interface RunMessage { type: "run"; source: string; @@ -16,6 +17,7 @@ interface RunMessage { meta: WorkflowScriptMeta; limits: WorkflowRunnerLimits; } + interface HostResultMessage { type: "host-result"; id: number; @@ -47,6 +49,7 @@ process.once("disconnect", () => { process.on("message", (message: unknown) => { if (!isRecord(message)) return; + if (message.type === "run") { if (runtime) return fail({ code: "WORKFLOW_PROTOCOL_ERROR", message: "Workflow runner received more than one run request." }); void run(message as unknown as RunMessage); @@ -67,9 +70,12 @@ async function run(message: RunMessage): Promise { runtime.setMaxStackSize(limits.stackBytes); runtime.setInterruptHandler(() => { if (process.ppid !== parentPid) process.exit(1); + if (fatalError) return true; + if (Date.now() <= deadline) return false; fatalError = { code: "WORKFLOW_TIMEOUT", message: `Workflow exceeded ${limits?.timeoutMs ?? 0}ms.` }; + return true; }); context = runtime.newContext(); @@ -79,9 +85,11 @@ async function run(message: RunMessage): Promise { exposeHostFunctions(context); const evaluation = context.evalCode(workflowProgram(message), "workflow.js"); + if (evaluation.error) { const error = quickJsError(evaluation.error); evaluation.error.dispose(); + return fail(classifyExecutionError(error)); } @@ -90,22 +98,29 @@ async function run(message: RunMessage): Promise { pumpJobs(); const result = await resolved; promise.dispose(); + if (fatalError) return fail(fatalError, true); + if (result.error) { const error = quickJsError(result.error); result.error.dispose(); + return fail(classifyExecutionError(error)); } + if (pending.size > 0) { return fail({ code: "UNAWAITED_CALLS", message: "Workflow returned while host calls were still pending. Await all calls before returning." }); } + if (fatalError) return fail(fatalError, true); const encoded = encodeGuestJson(context, result.value, "Workflow result"); result.value.dispose(); + if (Buffer.byteLength(encoded, "utf8") > limits.maxResultBytes) { return fail({ code: "WORKFLOW_RESULT_LIMIT", message: `Workflow result exceeds the ${limits.maxResultBytes}-byte limit.` }, true); } + send({ type: "result", value: JSON.parse(encoded) }); } catch (error) { fail(classifyExecutionError(error)); @@ -117,41 +132,54 @@ async function run(message: RunMessage): Promise { function exposeHostFunctions(vm: QuickJSContext): void { const hostCall = vm.newFunction("__hostCall", (kindHandle, payloadHandle) => { const deferred = vm.newPromise(); + try { if (!limits) throw new Error("Workflow limits are unavailable."); + if (pending.size >= limits.maxOutstanding) { const error = { code: "WORKFLOW_OUTSTANDING_LIMIT", message: `Workflow has more than ${limits.maxOutstanding} outstanding host calls.`, }; + fatalError = error; reject(deferred, error); + return deferred.handle; } + const kind = vm.getString(kindHandle); + if (kind !== "agent" && kind !== "workflow") throw new Error("Unknown workflow host call."); const encoded = encodeGuestJson(vm, payloadHandle, "Workflow host call"); + if (Buffer.byteLength(encoded, "utf8") > limits.maxArgsBytes) { const error = { code: "WORKFLOW_SIZE_LIMIT", message: `Workflow host call exceeds the ${limits.maxArgsBytes}-byte limit.`, }; + fatalError = error; reject(deferred, error); + return deferred.handle; } + const id = nextCallId++; pending.set(id, deferred); const safePayload = JSON.parse(encoded) as Record; send(kind === "agent" ? { type: "host-call", id, kind, prompt: safePayload.prompt, options: safePayload.options } : { type: "host-call", id, kind, name: safePayload.name, args: safePayload.args }); + return deferred.handle; } catch (error) { reject(deferred, toErrorPayload(error)); + return deferred.handle; } }); + vm.setProp(vm.global, "__hostCall", hostCall); hostCall.dispose(); @@ -161,6 +189,7 @@ function exposeHostFunctions(vm: QuickJSContext): void { const encoded = encodeGuestJson(vm, dataHandle, "Workflow event"); logEntries += 1; logBytes += Buffer.byteLength(encoded, "utf8") + eventType.length; + if (logEntries > limits.maxLogEntries || logBytes > limits.maxLogBytes) { fatalError = { code: "WORKFLOW_LOG_LIMIT", @@ -168,8 +197,10 @@ function exposeHostFunctions(vm: QuickJSContext): void { }; throw Object.assign(new Error(fatalError.message), fatalError); } + send({ type: "event", eventType, data: JSON.parse(encoded) }); }); + vm.setProp(vm.global, "__emit", emit); emit.dispose(); } @@ -177,9 +208,11 @@ function exposeHostFunctions(vm: QuickJSContext): void { function receiveHostResult(message: HostResultMessage): void { if (!context || !runtime) return; const deferred = pending.get(message.id); + if (!deferred) return; pending.delete(message.id); let failed = false; + try { if (message.ok) { const handle = valueHandle(context, message.value); @@ -187,9 +220,11 @@ function receiveHostResult(message: HostResultMessage): void { handle.dispose(); } else { const error = message.error ?? { code: "WORKFLOW_HOST_ERROR", message: "Workflow host call failed." }; + if (message.fatal) fatalError = error; reject(deferred, error); } + pumpJobs(); } catch (error) { failed = true; @@ -205,6 +240,7 @@ function receiveHostResult(message: HostResultMessage): void { function pumpJobs(): void { if (!runtime) return; const jobs = runtime.executePendingJobs(); + if (jobs.error) { const error = quickJsError(jobs.error); jobs.error.dispose(); @@ -215,6 +251,7 @@ function pumpJobs(): void { function workflowProgram(message: RunMessage): string { const args = JSON.stringify(message.args); const concurrency = message.meta.concurrency; + return ` "use strict"; const args = Object.freeze(JSON.parse(${JSON.stringify(args)})); @@ -284,17 +321,20 @@ function valueHandle(vm: QuickJSContext, value: unknown) { const encodedHandle = vm.newString(encoded); const result = vm.callFunction(jsonParse, jsonObject, encodedHandle); encodedHandle.dispose(); + if (result.error) { const error = quickJsError(result.error); result.error.dispose(); throw error; } + return result.value; } function encodeGuestJson(vm: QuickJSContext, value: QuickJSHandle, label: string): string { if (!jsonObject || !jsonStringify) throw new Error("Workflow JSON serializer is unavailable."); const result = vm.callFunction(jsonStringify, jsonObject, value); + if (result.error) { const error = quickJsError(result.error); result.error.dispose(); @@ -302,10 +342,12 @@ function encodeGuestJson(vm: QuickJSContext, value: QuickJSHandle, label: string code: "WORKFLOW_INVALID_JSON", }); } + try { if (vm.typeof(result.value) !== "string") { throw Object.assign(new TypeError(`${label} must be JSON.`), { code: "WORKFLOW_INVALID_JSON" }); } + return vm.getString(result.value); } finally { result.value.dispose(); @@ -327,16 +369,23 @@ function reject(deferred: QuickJSDeferredPromise, error: ErrorPayload): void { function quickJsError(handle: Parameters[0]): Error & { code?: string; retryable?: boolean } { if (!context) return new Error("Workflow execution failed."); const value = context.dump(handle) as { name?: unknown; message?: unknown; stack?: unknown } | unknown; + if (isRecord(value)) { const error: Error & { code?: string; retryable?: boolean } = new Error( typeof value.message === "string" ? value.message : "Workflow execution failed.", ); + if (typeof value.name === "string") error.name = value.name; + if (typeof value.stack === "string") error.stack = value.stack; + if (typeof value.code === "string") error.code = value.code; + if (value.retryable === true) error.retryable = true; + return error; } + return new Error(typeof value === "string" ? value : JSON.stringify(value)); } @@ -344,18 +393,23 @@ function classifyExecutionError(error: unknown): ErrorPayload { if (fatalError) return fatalError; const payload = toErrorPayload(error); const text = payload.message.toLowerCase(); + if (text.includes("out of memory") || text.includes("cannot allocate memory")) { return { code: "WORKFLOW_MEMORY_LIMIT", message: "Workflow exceeded its memory limit." }; } + if (text.includes("stack overflow") || text.includes("stack size")) { return { code: "WORKFLOW_STACK_LIMIT", message: "Workflow exceeded its stack limit." }; } + if (text.includes("interrupted")) return { code: "WORKFLOW_TIMEOUT", message: "Workflow execution was interrupted." }; + return payload; } function toErrorPayload(error: unknown): ErrorPayload { const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { code: typeof value?.code === "string" ? value.code : "WORKFLOW_EXECUTION_ERROR", message: typeof value?.message === "string" ? value.message : String(error), @@ -365,18 +419,23 @@ function toErrorPayload(error: unknown): ErrorPayload { function encodeJson(value: unknown, label: string): string { let encoded: string | undefined; + try { encoded = JSON.stringify(value, (_key, current: unknown) => { if (typeof current === "number" && !Number.isFinite(current)) throw new TypeError("non-finite number"); + if (typeof current === "bigint" || typeof current === "function" || typeof current === "symbol" || current === undefined) { throw new TypeError(`unsupported ${typeof current}`); } + return current; }); } catch (error) { throw new TypeError(`${label} must be JSON: ${error instanceof Error ? error.message : String(error)}`); } + if (encoded === undefined) throw new TypeError(`${label} must be JSON.`); + return encoded; } @@ -401,8 +460,11 @@ function cleanup(): void { jsonParse = undefined; jsonStringify = undefined; jsonObject = undefined; + try { context?.dispose(); } catch { /* The process is disposable. */ } + try { runtime?.dispose(); } catch { /* The process is disposable. */ } + context = undefined; runtime = undefined; shuttingDown = true; @@ -417,10 +479,12 @@ function disconnectWhenFlushed(): void { function send(message: unknown): void { if (!process.connected) return; unflushed += 1; + try { process.send?.(message as never, () => { unflushed -= 1; disconnectWhenFlushed(); }); } catch { unflushed -= 1; + if (!shuttingDown) process.exit(1); } } diff --git a/src/workflow-runner.test.ts b/src/workflow-runner.test.ts index 6a35d58c3..702a662ca 100644 --- a/src/workflow-runner.test.ts +++ b/src/workflow-runner.test.ts @@ -10,6 +10,7 @@ test("the guest sees its args but no host globals", async () => { args: { value: 42 }, onAgent: () => null, }); + assert.deepEqual(basic, { args: { value: 42 }, host: ["undefined", "undefined", "undefined", "undefined", "undefined"], @@ -56,6 +57,7 @@ test("a __proto__ key in an agent result stays an own property and pollutes noth test("agent calls reach onAgent with their prompt and target", async () => { const calls: Array<{ prompt: string; target: string }> = []; + const bridged = await runWorkflowScript({ source: ` export const meta = { name: 'bridge', concurrency: 2 }; @@ -63,9 +65,11 @@ test("agent calls reach onAgent with their prompt and target", async () => { `, onAgent: async (prompt, options) => { calls.push({ prompt, target: options.target }); + return { ok: true }; }, }); + assert.deepEqual(bridged, { ok: true }); assert.deepEqual(calls, [{ prompt: "inspect", target: "reviewer" }]); }); @@ -81,9 +85,11 @@ test("parallel settles each item independently", async () => { `, onAgent: async (prompt) => { await delay(Number(prompt)); + return Number(prompt) * 2; }, }); + assert.deepEqual(parallel, [ { status: "completed", value: 60 }, { status: "failed", error: { code: "WORKFLOW_STEP_FAILED", message: "expected failure", retryable: false } }, @@ -101,6 +107,7 @@ test("pipeline threads each item through every stage", async () => { `, onAgent: (prompt) => Number(prompt), }); + assert.deepEqual(pipeline, [ { status: "completed", value: { value: 4, item: 2, index: 0 } }, { status: "completed", value: { value: 7, item: 3, index: 1 } }, @@ -109,6 +116,7 @@ test("pipeline threads each item through every stage", async () => { test("phase binds agent calls to the phase and emits phase and log events", async () => { const events: Array<{ type: string; data: unknown }> = []; + const phased = await runWorkflowScript({ source: ` const value = await phase("review", boundAgent => boundAgent("go", { target: "worker" })); @@ -118,6 +126,7 @@ test("phase binds agent calls to the phase and emits phase and log events", asyn onAgent: (_prompt, options) => options.phase, onEvent: (type, data) => { events.push({ type, data }); }, }); + assert.equal(phased, "review"); assert.deepEqual(events.map(({ type }) => type), ["phase_started", "phase_completed", "log"]); assert.deepEqual(events[2]?.data, ["done", "review"]); @@ -140,6 +149,7 @@ test("nested workflows run through onWorkflow", async () => { onAgent: () => null, onWorkflow: (name, args) => ({ name, args }), }); + assert.deepEqual(nested, { name: "child", args: { n: 1 } }); }); @@ -169,6 +179,7 @@ test("an invalid agent call throws a catchable validation error", async () => { source: `try { await agent("go", {}); } catch (error) { return error.code; }`, onAgent: () => null, }); + assert.equal(caughtValidation, "WORKFLOW_INVALID_AGENT_CALL"); }); @@ -187,10 +198,14 @@ test("provider errors keep their code and retryability", async () => { test("unawaited agent calls fail the run", async () => { let finishDetached!: () => void; const detachedCall = new Promise((resolve) => { finishDetached = resolve; }); + const detached = runWorkflowScript({ source: `agent("detached", { target: "worker" }); return "done";`, - onAgent: async () => { await detachedCall; return null; }, + onAgent: async () => { await detachedCall; + + return null; }, }); + await assert.rejects( detached, (error: unknown) => error instanceof WorkflowError && error.code === "UNAWAITED_CALLS", @@ -260,6 +275,7 @@ test("aborting the signal cancels a pending agent call", async () => { const controller = new AbortController(); let agentStarted!: () => void; const started = new Promise((resolve) => { agentStarted = resolve; }); + const aborted = runWorkflowScript({ source: `return agent("wait", { target: "worker" });`, signal: controller.signal, @@ -268,6 +284,7 @@ test("aborting the signal cancels a pending agent call", async () => { await new Promise(() => undefined); }, }); + await started; controller.abort(); await assert.rejects( diff --git a/src/workflow-runner.ts b/src/workflow-runner.ts index 0703e401b..5da383812 100644 --- a/src/workflow-runner.ts +++ b/src/workflow-runner.ts @@ -54,6 +54,7 @@ const HARD_LIMITS: WorkflowRunnerLimits = { }; interface RunnerErrorPayload { code: string; message: string; retryable?: boolean } + interface HostCallMessage { type: "host-call"; id: number; @@ -66,19 +67,24 @@ interface HostCallMessage { export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise { const limits = resolveLimits(input.limits); + if (typeof input.source !== "string") { throw new WorkflowError("WORKFLOW_INVALID_SOURCE", "Workflow source must be a string."); } + validateBytes(input.source, "Workflow source", limits.maxSourceBytes); const parsed = parseWorkflowScript(input.source); const args = validateJson(input.args ?? null, "Workflow arguments", limits.maxArgsBytes); + if (input.depth !== undefined && (!Number.isInteger(input.depth) || input.depth < 0 || input.depth > 1)) { throw new WorkflowError("WORKFLOW_NESTING_LIMIT", "Workflows may be nested at most one level."); } + if (input.signal?.aborted) throw cancelled(input.signal.reason); const extension = import.meta.url.endsWith(".ts") ? "ts" : "js"; const childPath = fileURLToPath(new URL(`./workflow-runner-child.${extension}`, import.meta.url)); + const child = fork(childPath, [], { stdio: ["ignore", "ignore", "pipe", "ipc"], serialization: "json", @@ -102,18 +108,24 @@ export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise< clearTimeout(timer); input.signal?.removeEventListener("abort", abort); child.removeAllListeners(); + if (child.connected) { try { child.disconnect(); } catch { /* The child is already closing. */ } } + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + if (error) reject(error); else resolve(value as JsonValue); }; + const fatal = (error: unknown) => finish(asWorkflowError(error)); const abort = () => fatal(cancelled(input.signal?.reason)); + const timer = setTimeout(() => { fatal(new WorkflowError("WORKFLOW_TIMEOUT", `Workflow exceeded ${limits.timeoutMs}ms.`)); }, limits.timeoutMs); + timer.unref(); input.signal?.addEventListener("abort", abort, { once: true }); @@ -129,6 +141,7 @@ export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise< `Workflow runner exited before returning a result (${signal ?? code ?? "unknown"})${detail}`, )); }; + if (!child.connected) return void setImmediate(report); const grace = setTimeout(report, 1_000); grace.unref(); @@ -136,44 +149,58 @@ export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise< }); child.on("message", (message: unknown) => { if (settled || !isRecord(message) || typeof message.type !== "string") return; + if (message.type === "host-call") { void handleHostCall(message as unknown as HostCallMessage).catch(fatal); + return; } + if (message.type === "event") { if (message.eventType !== "log" && message.eventType !== "phase_started" && message.eventType !== "phase_completed" && message.eventType !== "phase_failed") { fatal(new WorkflowError("WORKFLOW_PROTOCOL_ERROR", "Workflow runner sent an invalid event.")); + return; } + try { const data = validateJson(message.data, "Workflow event", limits.maxArgsBytes); eventEntries += 1; eventBytes += Buffer.byteLength(JSON.stringify(data), "utf8") + message.eventType.length; + if (eventEntries > limits.maxLogEntries || eventBytes > limits.maxLogBytes) { throw new WorkflowError("WORKFLOW_LOG_LIMIT", "Workflow events exceed their configured limit."); } + eventChain = eventChain.then(() => input.onEvent?.(message.eventType as string, data)); eventChain.catch(fatal); } catch (error) { fatal(error); } + return; } + if (message.type === "fatal" || message.type === "error") { fatal(decodeError(message.error, message.type === "fatal" ? "WORKFLOW_LIMIT" : "WORKFLOW_FAILED")); + return; } + if (message.type === "result") { let result: JsonValue; + try { result = validateJson(message.value, "Workflow result", limits.maxResultBytes); } catch (error) { fatal(error); + return; } + resultReceived = true; void eventChain.then(() => finish(undefined, result), fatal); } @@ -183,13 +210,17 @@ export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise< if (!Number.isSafeInteger(message.id) || message.id < 1 || outstanding >= limits.maxOutstanding) { throw new WorkflowError("WORKFLOW_OUTSTANDING_LIMIT", "Workflow has too many outstanding host calls."); } + outstanding += 1; + try { let value: JsonValue; + if (message.kind === "agent") { if (typeof message.prompt !== "string" || message.prompt.length === 0) { return sendHostError(child, message.id, new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent prompt must be a non-empty string.")); } + validateBytes(message.prompt, "Agent prompt", limits.maxArgsBytes); const options = validateAgentOptions(message.options, limits.maxArgsBytes); value = validateJson( @@ -201,12 +232,15 @@ export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise< if ((input.depth ?? 0) >= 1) { return sendHostError(child, message.id, new WorkflowError("WORKFLOW_NESTING_LIMIT", "Workflows may be nested at most one level.")); } + if (!input.onWorkflow) { return sendHostError(child, message.id, new WorkflowError("WORKFLOW_UNAVAILABLE", "Nested workflows are unavailable.")); } + if (typeof message.name !== "string" || message.name.trim().length === 0 || message.name.length > 128) { return sendHostError(child, message.id, new WorkflowError("WORKFLOW_INVALID_CALL", "workflow name must be a non-empty string.")); } + const nestedArgs = validateJson(message.args ?? null, "Nested workflow arguments", limits.maxArgsBytes); value = validateJson( await input.onWorkflow(message.name, nestedArgs), @@ -216,6 +250,7 @@ export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise< } else { throw new WorkflowError("WORKFLOW_PROTOCOL_ERROR", "Workflow runner requested an unknown host call."); } + send(child, { type: "host-result", id: message.id, ok: true, value }); } catch (error) { sendHostError(child, message.id, error); @@ -236,65 +271,84 @@ export async function runWorkflowScript(input: RunWorkflowScriptInput): Promise< export function validateJson(value: unknown, label: string, maxBytes: number): JsonValue { let encoded: string | undefined; + try { encoded = JSON.stringify(value, (_key, current: unknown) => { if (typeof current === "number" && !Number.isFinite(current)) throw new TypeError("non-finite number"); + if (typeof current === "bigint" || typeof current === "function" || typeof current === "symbol" || current === undefined) { throw new TypeError(`unsupported ${typeof current}`); } + if (current && typeof current === "object" && !Array.isArray(current)) { const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null) throw new TypeError("non-plain object"); } + return current; }); } catch (error) { throw new WorkflowError("WORKFLOW_INVALID_JSON", `${label} must be JSON: ${errorMessage(error)}`); } + if (encoded === undefined) throw new WorkflowError("WORKFLOW_INVALID_JSON", `${label} must be JSON.`); validateBytes(encoded, label, maxBytes); + return JSON.parse(encoded) as JsonValue; } function validateAgentOptions(value: unknown, maxBytes: number): WorkflowAgentOptions { const options = validateJson(value, "Agent options", maxBytes); + if (!isRecord(options)) throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options must be an object."); const allowed = new Set(["target", "model", "effort", "schema", "label", "phase", "writeMode", "isolation", "workspace"]); + for (const key of Object.keys(options)) { if (!allowed.has(key)) throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", `Unknown agent option: ${key}`); } + if (!validString(options.target, 128)) { throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.target must be a non-empty string."); } + for (const key of ["model", "effort", "label", "phase"] as const) { if (options[key] !== undefined && !validString(options[key], 256)) { throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", `agent options.${key} must be a non-empty string.`); } } + if (options.schema !== undefined && !isRecord(options.schema)) { throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.schema must be an object."); } + if (options.writeMode !== undefined && options.writeMode !== "read_only" && options.writeMode !== "allowed") { throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.writeMode must be read_only or allowed."); } + if (options.isolation !== undefined && options.isolation !== "worktree") { throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.isolation must be worktree."); } + if (options.workspace !== undefined && (typeof options.workspace !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(options.workspace))) { throw new WorkflowError("WORKFLOW_INVALID_AGENT_CALL", "agent options.workspace must be a logical key, not a path."); } + return options as unknown as WorkflowAgentOptions; } function resolveLimits(input: Partial | undefined): WorkflowRunnerLimits { const limits = { ...DEFAULT_WORKFLOW_LIMITS, ...input }; + for (const key of Object.keys(DEFAULT_WORKFLOW_LIMITS) as Array) { const value = limits[key]; + if (!Number.isSafeInteger(value) || value < 1 || value > HARD_LIMITS[key]) { throw new WorkflowError("WORKFLOW_INVALID_LIMIT", `${key} must be an integer between 1 and ${HARD_LIMITS[key]}.`); } } + return limits; } @@ -317,6 +371,7 @@ function sendHostError(child: ChildProcess, id: number, error: unknown): void { function send(child: ChildProcess, message: unknown): void { if (!child.connected) return; + try { child.send(message as never, () => undefined); } catch { @@ -326,6 +381,7 @@ function send(child: ChildProcess, message: unknown): void { function decodeError(value: unknown, fallbackCode: string): WorkflowError { if (!isRecord(value)) return new WorkflowError(fallbackCode, "Workflow failed."); + return new WorkflowError( typeof value.code === "string" ? value.code : fallbackCode, typeof value.message === "string" ? value.message : "Workflow failed.", @@ -336,6 +392,7 @@ function decodeError(value: unknown, fallbackCode: string): WorkflowError { function asWorkflowError(error: unknown): WorkflowError { if (error instanceof WorkflowError) return error; const value = error as { code?: unknown; retryable?: unknown } | undefined; + return new WorkflowError( typeof value?.code === "string" ? value.code : "WORKFLOW_FAILED", errorMessage(error), @@ -346,6 +403,7 @@ function asWorkflowError(error: unknown): WorkflowError { function cancelled(reason: unknown): WorkflowError { if (reason instanceof WorkflowError) return reason; const value = reason as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return new WorkflowError( typeof value?.code === "string" ? value.code : "WORKFLOW_CANCELLED", typeof value?.message === "string" ? value.message : "Workflow was cancelled.", diff --git a/src/workflow-schema.test.ts b/src/workflow-schema.test.ts index ffab647b8..de9aafd66 100644 --- a/src/workflow-schema.test.ts +++ b/src/workflow-schema.test.ts @@ -7,6 +7,7 @@ test("workflow output must be bare JSON that matches the schema", () => { type: "object", properties: { summary: { type: "string" }, count: { type: "integer", minimum: 0 } }, required: ["summary", "count"], additionalProperties: false, }); + assert.deepEqual(parseWorkflowOutput('{"summary":"ok","count":2}', validate), { summary: "ok", count: 2 }); assert.throws(() => parseWorkflowOutput('```json\n{}\n```', validate), /not JSON/); assert.throws(() => parseWorkflowOutput('{"summary":"ok","count":-1}', validate), /JSON output/); diff --git a/src/workflow-schema.ts b/src/workflow-schema.ts index dff2be554..e8e6aa1d9 100644 --- a/src/workflow-schema.ts +++ b/src/workflow-schema.ts @@ -5,22 +5,29 @@ import { WorkflowError } from "./workflow-types.js"; export function compileWorkflowSchema(schema: Record): ValidateFunction { if (Buffer.byteLength(JSON.stringify(schema)) > 16_384) throw new WorkflowError("INVALID_SCHEMA", "Schema exceeds 16 KiB."); let nodes = 0; + function visit(value: unknown, depth: number, position: "schema" | "schemas" | "data"): void { if (++nodes > 512 || depth > 16) throw new WorkflowError("INVALID_SCHEMA", "Schema is too complex."); + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { if (position === "schema" && ["$ref", "$dynamicRef", "$recursiveRef", "$async", "pattern", "patternProperties", "format"].includes(key)) { throw new WorkflowError("INVALID_SCHEMA", `Workflow schemas do not support ${key}. Use inline structural constraints.`); } + const childPosition = position === "schemas" ? "schema" : position !== "schema" ? "data" : ["properties", "definitions", "$defs", "dependencies", "allOf", "anyOf", "oneOf"].includes(key) ? "schemas" : ["additionalProperties", "additionalItems", "items", "contains", "not", "if", "then", "else", "propertyNames"].includes(key) ? (Array.isArray(child) ? "schemas" : "schema") : "data"; + visit(child, depth + 1, childPosition); } } + visit(schema, 0, "schema"); + try { return new Ajv({ strict: true, allowUnionTypes: true, allErrors: false, logger: false }).compile(schema); } catch (error) { @@ -30,11 +37,14 @@ export function compileWorkflowSchema(schema: Record): Validate export function parseWorkflowOutput(text: string, validate: ValidateFunction): unknown { let value: unknown; + try { value = JSON.parse(text); } catch { throw new WorkflowError("INVALID_OUTPUT", "Agent output was not JSON."); } + if (!validate(value)) { const error = validate.errors?.[0]; throw new WorkflowError("INVALID_OUTPUT", `JSON output ${error?.instancePath ?? ""} ${error?.message ?? "does not match the schema"}.`); } + return value; } diff --git a/src/workflow-script.test.ts b/src/workflow-script.test.ts index 1e7584599..e9f65ac5f 100644 --- a/src/workflow-script.test.ts +++ b/src/workflow-script.test.ts @@ -18,6 +18,7 @@ test("an exported meta object is parsed and stripped from the body", () => { }; return args; `); + assert.deepEqual(parsed.meta, { name: "review-files", description: "Review\nfiles", @@ -37,6 +38,7 @@ test("meta may follow leading comments", () => { const commented = parseWorkflowScript(`// @ts-check /* workflow metadata */ export const meta = { name: "commented" }; return 1;`); + assert.equal(commented.meta.name, "commented"); assert.equal(commented.body.trim(), "return 1;"); }); @@ -44,10 +46,12 @@ export const meta = { name: "commented" }; return 1;`); // Long whitespace runs that do not lead to a meta declaration must fail fast (no regex backtracking blowup). test("the meta prefix scan is linear in leading whitespace and comments", () => { const slowStart = Date.now(); + for (const filler of [" ", "\n", "\r\n", " //a\n"]) { const source = `${filler.repeat(5_000)}return 1;`; assert.equal(parseWorkflowScript(source).body, source); } + assert.ok(Date.now() - slowStart < 1_000, "meta prefix scan must be linear"); assert.equal(parseWorkflowScript(`${" ".repeat(5_000)}export const meta = { name: "late" }; return 1;`).meta.name, "late"); }); diff --git a/src/workflow-script.ts b/src/workflow-script.ts index a24949792..10704d16f 100644 --- a/src/workflow-script.ts +++ b/src/workflow-script.ts @@ -10,28 +10,34 @@ export interface ParsedWorkflowScript { } const DEFAULT_META: WorkflowScriptMeta = { name: "workflow", concurrency: 4 }; + // Each alternative matches in exactly one way (no nested quantifiers), so a failed match stays linear. const META_PREFIX = /^(?:\s|\/\/[^\r\n]*(?=[\r\n]|$)|\/\*(?:[^*]|\*(?!\/))*\*\/)*export\s+const\s+meta\s*=/; /** Parse the optional, data-only metadata declaration without executing workflow code. */ export function parseWorkflowScript(source: string): ParsedWorkflowScript { const prefix = META_PREFIX.exec(source); + if (!prefix) return { meta: { ...DEFAULT_META }, body: source }; const parser = new MetaParser(source, prefix[0].length); const fields = parser.object(); parser.space(); + if (source[parser.position] === ";") parser.position += 1; const name = fields.name ?? DEFAULT_META.name; const description = fields.description; const concurrency = fields.concurrency ?? DEFAULT_META.concurrency; + if (typeof name !== "string" || name.trim().length === 0 || name.length > 128) { throw new Error("Workflow meta.name must be a non-empty string of at most 128 characters."); } + if (description !== undefined && (typeof description !== "string" || description.length > 2_048)) { throw new Error("Workflow meta.description must be a string of at most 2048 characters."); } + if (!Number.isInteger(concurrency) || (concurrency as number) < 1 || (concurrency as number) > 16) { throw new Error("Workflow meta.concurrency must be an integer between 1 and 16."); } @@ -58,11 +64,14 @@ class MetaParser { this.expect("{"); const result: Record = {}; this.space(); + while (this.source[this.position] !== "}") { const key = this.key(); + if (key !== "name" && key !== "description" && key !== "concurrency") { throw this.error(`Unknown workflow metadata field: ${key}`); } + if (Object.hasOwn(result, key)) throw this.error(`Duplicate workflow metadata field: ${key}`); this.space(); this.expect(":"); @@ -71,12 +80,16 @@ class MetaParser { ? this.string() : this.number(); this.space(); + if (this.source[this.position] !== ",") break; this.position += 1; this.space(); + if (this.source[this.position] === "}") break; } + this.expect("}"); + return result; } @@ -86,30 +99,41 @@ class MetaParser { private key(): string { this.space(); + if (this.source[this.position] === "'" || this.source[this.position] === '"') return this.string(); const match = /^[A-Za-z_$][\w$]*/.exec(this.source.slice(this.position)); + if (!match) throw this.error("Expected a workflow metadata field."); this.position += match[0].length; + return match[0]; } private string(): string { const quote = this.source[this.position++]; let value = ""; + while (this.position < this.source.length) { const character = this.source[this.position++]; + if (character === quote) return value; + if (character === "\n" || character === "\r") throw this.error("Workflow metadata strings cannot span lines."); + if (character !== "\\") { value += character; continue; } + const escaped = this.source[this.position++]; + const simple: Record = { "\\": "\\", "'": "'", '"': '"', n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", v: "\v", 0: "\0", }; + if (escaped === "u") { const hex = this.source.slice(this.position, this.position + 4); + if (!/^[0-9A-Fa-f]{4}$/.test(hex)) throw this.error("Invalid Unicode escape in workflow metadata."); value += String.fromCharCode(Number.parseInt(hex, 16)); this.position += 4; @@ -119,13 +143,16 @@ class MetaParser { throw this.error("Unsupported escape in workflow metadata."); } } + throw this.error("Unterminated workflow metadata string."); } private number(): number { const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?/.exec(this.source.slice(this.position)); + if (!match) throw this.error("Workflow metadata values must be strings or numbers."); this.position += match[0].length; + return Number(match[0]); } diff --git a/src/workflow-store.ts b/src/workflow-store.ts index 8dc0bc40a..477244a85 100644 --- a/src/workflow-store.ts +++ b/src/workflow-store.ts @@ -20,13 +20,16 @@ export class WorkflowStore { ? this.database.sqlite.prepare("select record_json from workflow_runs where workspace_root = ? and workspace_id is ? order by rowid desc") .all(scope.workspaceRoot, scope.workspaceId ?? null) : this.database.sqlite.prepare("select record_json from workflow_runs order by rowid desc").all(); + return rows.map((row) => decode(row)!); } update(id: string, patch: Partial>): WorkflowSnapshot { const run = this.get(id); + if (!run) throw new WorkflowError("WORKFLOW_NOT_FOUND", `Unknown workflow run: ${id}`); Object.assign(run, patch, { updatedAt: new Date().toISOString() }); this.database.sqlite.prepare("update workflow_runs set record_json = ? where id = ?").run(JSON.stringify(run), id); + return run; } addCall(call: WorkflowCall): void { @@ -48,10 +51,12 @@ export class WorkflowStore { } updateCall(runId: string, index: number, patch: Partial): WorkflowCall { const call = this.call(runId, index); + if (!call) throw new WorkflowError("WORKFLOW_CALL_NOT_FOUND", `Unknown workflow call: ${index}`); Object.assign(call, patch, { updatedAt: new Date().toISOString() }); this.database.sqlite.prepare("update workflow_calls set record_json = ? where run_id = ? and call_index = ?") .run(JSON.stringify(call), runId, index); + return call; } event(runId: string, type: string, data: unknown): void { @@ -62,9 +67,11 @@ export class WorkflowStore { const rows = this.database.sqlite.prepare("select * from workflow_events where run_id = ? and sequence > ? order by sequence limit 100").all(runId, after) as Array<{ sequence: number; run_id: string; type: string; data_json: string; created_at: string; }>; + return rows.map((r) => ({ sequence: r.sequence, runId: r.run_id, type: r.type, data: JSON.parse(r.data_json), createdAt: r.created_at })); } } + function decode(row: unknown): T | undefined { return row ? JSON.parse((row as { record_json: string }).record_json) as T : undefined; } diff --git a/src/workflow-types.ts b/src/workflow-types.ts index 1cc88d61e..0d8aeaffa 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -1,14 +1,18 @@ import type { LocalAgentWorkspaceScope } from "./local-agent-store.js"; export type WorkflowStatus = "starting" | "running" | "stopping" | "completed" | "failed" | "cancelled" | "interrupted"; + export type WorkflowWriteMode = "read_only" | "allowed"; + export interface WorkflowFailure { code: string; message: string; retryable: boolean } + export class WorkflowError extends Error { constructor(readonly code: string, message: string, readonly retryable = false) { super(message); this.name = "WorkflowError"; } } + export interface WorkflowRunInput extends LocalAgentWorkspaceScope { source?: string; name?: string; @@ -16,6 +20,7 @@ export interface WorkflowRunInput extends LocalAgentWorkspaceScope { resume?: string; writeMode?: WorkflowWriteMode; } + export interface WorkflowRun extends LocalAgentWorkspaceScope { id: string; name: string; @@ -29,11 +34,13 @@ export interface WorkflowRun extends LocalAgentWorkspaceScope { updatedAt: string; callCount: number; } + export interface WorkflowSnapshot extends WorkflowRun { source: string; args: unknown; contextHash: string; } + export interface WorkflowAgentOptions { target: string; model?: string; @@ -46,6 +53,7 @@ export interface WorkflowAgentOptions { /** A logical name, shared by calls in this run; never a filesystem path. */ workspace?: string; } + export interface WorkflowCall { runId: string; index: number; @@ -63,15 +71,19 @@ export interface WorkflowCall { createdAt: string; updatedAt: string; } + export interface WorkflowEvent { sequence: number; runId: string; type: string; data: unknown; createdAt: string } + export function workflowFailure(error: unknown): WorkflowFailure { const value = error as { code?: unknown; message?: unknown; retryable?: unknown } | undefined; + return { code: typeof value?.code === "string" ? value.code : "WORKFLOW_FAILED", message: typeof value?.message === "string" ? value.message : String(error), retryable: value?.retryable === true, }; } + export function workflowTerminal(status: WorkflowStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted"; } diff --git a/src/workflow-workspaces.ts b/src/workflow-workspaces.ts index 0d221d7ae..d627b09d4 100644 --- a/src/workflow-workspaces.ts +++ b/src/workflow-workspaces.ts @@ -15,10 +15,13 @@ const canonical = realpathSync.native; export function isManagedWorkflowWorkspace(config: AgentConfig, root: string, id: string): boolean { const store = new SqliteWorkspaceStore(config.stateDir); + try { const workspace = store.getSession(id); + if (!workspace || workspace.status !== "active" || !workspace.managed || workspace.mode !== "worktree" || !workspace.sourceRoot) return false; const path = canonical(root); + return path === canonical(workspace.root) && isPathInsideRoot(path, canonical(config.worktreeRoot)) && config.allowedRoots.some((allowed) => { @@ -32,26 +35,33 @@ export function isManagedWorkflowWorkspace(config: AgentConfig, root: string, id export async function createWorkflowWorkspace(config: AgentConfig, source: LocalAgentWorkspaceScope): Promise { let sourcePath = source.workspaceRoot; let baseRef: string | undefined; + if (source.workspaceId && isManagedWorkflowWorkspace(config, sourcePath, source.workspaceId)) { const store = new SqliteWorkspaceStore(config.stateDir); + try { const session = store.getSession(source.workspaceId); + if (!session?.sourceRoot || session.status !== "active") throw new WorkflowError("INVALID_WORKSPACE", "Source worktree is no longer active."); sourcePath = session.sourceRoot; } finally { store.close(); } + const head = await promisify(execFile)("git", ["rev-parse", "HEAD"], { cwd: source.workspaceRoot, timeout: 5_000 }); baseRef = head.stdout.trim(); } + await resolveCanonicalAllowedPath(sourcePath, sourcePath, config.allowedRoots); const worktree = await createManagedWorktree({ sourcePath, baseRef, config }); const root = await resolveCanonicalAllowedPath(worktree.path, worktree.path, [config.worktreeRoot]); const store = new SqliteWorkspaceStore(config.stateDir); + try { const workspace = store.createSession({ id: `ws_${randomUUID()}`, root, mode: "worktree", sourceRoot: worktree.sourceRoot, baseRef: worktree.baseRef, baseSha: worktree.baseSha, managed: true, }); + return { workspaceRoot: root, workspaceId: workspace.id }; } catch (error) { throw new WorkflowError("WORKSPACE_REGISTRATION_FAILED", `Worktree was retained at ${root}, but registering it failed: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/workspace-activity-journal.test.ts b/src/workspace-activity-journal.test.ts index 21abc30d3..dc3bbecdf 100644 --- a/src/workspace-activity-journal.test.ts +++ b/src/workspace-activity-journal.test.ts @@ -66,6 +66,7 @@ test("open_workspace is recorded once its workspace exists, and failed opens lea meta: { "openai/session": "conversation-1" }, result: () => { workspaces.createSession({ id: "ws_opened", root: "/tmp/project" }); + return { structuredContent: { workspace_id: "ws_opened" } }; }, }); @@ -109,6 +110,7 @@ test("journal redacts secrets, caps payloads, and keeps errors", async (t) => { test("journal redacts credentials in commands and outputs", () => { const redacted = (value: string): string => JSON.parse(journalJson(value)) as string; + const secrets = [ "API_KEY=abc123def", "export GITHUB_TOKEN=ghx_notavendorshape", @@ -124,6 +126,7 @@ test("journal redacts credentials in commands and outputs", () => { "git clone https://user:pass@github.com/acme/repo.git", "postgres://app:s3cret@db.internal:5432/app", ]; + for (const secret of secrets) { const output = redacted(`run: ${secret}`); assert.match(output, /\[redacted\]/, secret); @@ -150,6 +153,7 @@ test("journal leaves ordinary prose and code alone", () => { "open http://localhost:3000/@user", "curl https://example.com:8080/path", ]; + for (const text of unchanged) { assert.equal(JSON.parse(journalJson(text)), text); } @@ -161,6 +165,7 @@ test("journal failures never replace the tool result", async (t) => { }); activity.close(); + const result = await journal.capture({ toolName: "read", arguments: { workspace_id: "ws_closed" }, @@ -179,6 +184,7 @@ async function journalFixture( ) { const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); const workspaces = new SqliteWorkspaceStore(stateDir); + for (const id of workspaceIds) workspaces.createSession({ id, root: "/tmp/project" }); const activity = new WorkspaceActivityStore(stateDir, maxTurns); const journal = new WorkspaceActivityJournal(activity, onError); @@ -188,9 +194,11 @@ async function journalFixture( } catch { // Some tests close the store early. } + workspaces.close(); await rm(stateDir, { recursive: true, force: true }); }); + return { journal, activity, workspaces }; } @@ -206,6 +214,7 @@ function call( extra: { _meta: options.meta, requestId: 7 }, operation: async () => { if (options.error) throw options.error; + return typeof options.result === "function" ? (options.result as () => unknown)() : options.result ?? {}; }, }); diff --git a/src/workspace-activity-journal.ts b/src/workspace-activity-journal.ts index 2d7f5645a..1f0fd2a23 100644 --- a/src/workspace-activity-journal.ts +++ b/src/workspace-activity-journal.ts @@ -14,6 +14,7 @@ const MAX_STRING_LENGTH = 8 * 1024; const MAX_RECORD_LENGTH = 64 * 1024; const REDACTED = "[redacted]"; const SECRET_KEY_PATTERN = /(token|secret|password|passwd|authorization|cookie|credential|api[_-]?key|private[_-]?key)/i; + // Value patterns are deliberately shape- or context-bound (an Authorization // header, an env-var name, a credential flag) so ordinary prose and source code // such as "Basic functionality" or `MAX_TOKENS=4096` survive intact. A pattern's @@ -60,12 +61,14 @@ export class WorkspaceActivityJournal { const startedAt = new Date(); const startedAtMs = performance.now(); const argumentWorkspaceId = workspaceIdFromValue(input.arguments); + // Calls belong to the turn that was open when they started. const turnId = argumentWorkspaceId ? this.safely(() => this.openTurnId(argumentWorkspaceId, startedAt)) : undefined; let result: T; + try { result = await input.operation(); } catch (error) { @@ -86,6 +89,7 @@ export class WorkspaceActivityJournal { startedAtMs, result, })); + return result; } @@ -119,12 +123,15 @@ export class WorkspaceActivityJournal { const completedAt = new Date(); const origin = toolCallOrigin(input.extra._meta); + const reviewRef = origin === "model" && input.toolName === "show_changes" && !outcome.error ? reviewRefFromValue(outcome.result) : undefined; + const turnId = reviewRef ? this.store.closeTurn(outcome.workspaceId, reviewRef, completedAt).id : outcome.turnId ?? this.store.openTurn(outcome.workspaceId, completedAt).id; + const resultSummary = outcome.result === undefined ? undefined : summarizeResult(outcome.result); this.store.recordToolCall({ @@ -152,6 +159,7 @@ export class WorkspaceActivityJournal { } catch { // Error reporting must not affect the tool call either. } + return undefined; } } @@ -160,7 +168,9 @@ export class WorkspaceActivityJournal { /** Serializes a value for the journal with secrets redacted and size capped. */ export function journalJson(value: unknown): string { const json = JSON.stringify(redact(value, 0)) ?? "null"; + if (json.length <= MAX_RECORD_LENGTH) return json; + return JSON.stringify({ truncated: true, length: json.length, @@ -175,6 +185,7 @@ export function journalJson(value: unknown): string { */ function summarizeResult(result: unknown): unknown { const record = asRecord(result); + if (!record || !Array.isArray(record.content)) return result; const text = record.content @@ -182,6 +193,7 @@ function summarizeResult(result: unknown): unknown { .filter((block) => block?.type === "text" && typeof block.text === "string") .map((block) => block!.text as string) .join("\n"); + return { ...(text ? { text } : {}), ...(record.structuredContent !== undefined ? { structuredContent: record.structuredContent } : {}), @@ -191,8 +203,11 @@ function summarizeResult(result: unknown): unknown { function redact(value: unknown, depth: number): unknown { if (typeof value === "string") return redactString(value); + if (value === null || typeof value !== "object") return value; + if (depth > 8) return "[nested]"; + if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1)); return Object.fromEntries( @@ -207,9 +222,11 @@ function redact(value: unknown, depth: number): unknown { function redactString(value: string): string { let redacted = value; + for (const [pattern, replacement] of SECRET_VALUE_PATTERNS) { redacted = redacted.replace(pattern, replacement); } + return redacted.length > MAX_STRING_LENGTH ? `${redacted.slice(0, MAX_STRING_LENGTH)}… [truncated ${redacted.length - MAX_STRING_LENGTH} chars]` : redacted; @@ -223,16 +240,19 @@ function toolCallOrigin(meta: Record | undefined): WorkspaceToo function workspaceIdFromValue(value: unknown): string | undefined { const record = asRecord(value); const direct = record?.workspace_id; + if (typeof direct === "string" && direct.length > 0) return direct; const structured = asRecord(record?.structuredContent); const nested = structured?.workspace_id; + return typeof nested === "string" && nested.length > 0 ? nested : undefined; } function reviewRefFromValue(value: unknown): string | undefined { const structured = asRecord(asRecord(value)?.structuredContent); const reviewRef = structured?.review_ref; + return typeof reviewRef === "string" && reviewRef.length > 0 ? reviewRef : undefined; } @@ -244,6 +264,7 @@ function serializeError(error: unknown): { name: string; message: string } { if (error instanceof Error) { return { name: error.name, message: error.message }; } + return { name: typeof error, message: String(error) }; } diff --git a/src/workspace-activity-store.ts b/src/workspace-activity-store.ts index 81dc0d982..cf251e53e 100644 --- a/src/workspace-activity-store.ts +++ b/src/workspace-activity-store.ts @@ -11,6 +11,7 @@ import { import type { WorkspaceMode, WorkspaceStatus } from "./workspace-store.js"; export type WorkspaceTurnStatus = "open" | "closed"; + export type WorkspaceToolCallOrigin = "model" | "card"; export interface ActivityWorkspaceRecord { @@ -93,6 +94,7 @@ export class WorkspaceActivityStore { openTurn(workspaceId: string, now = new Date()): WorkspaceTurn { const open = this.database.sqlite.transaction(() => { const existing = this.findOpenTurnRow(workspaceId); + if (existing) return existing; const previous = this.database.db @@ -100,6 +102,7 @@ export class WorkspaceActivityStore { .from(workspaceTurns) .where(eq(workspaceTurns.workspaceSessionId, workspaceId)) .get(); + return this.database.db .insert(workspaceTurns) .values({ @@ -111,11 +114,13 @@ export class WorkspaceActivityStore { .returning() .get(); }); + return this.withCallCounts(open.immediate()); } currentTurn(workspaceId: string): WorkspaceTurn | undefined { const row = this.findOpenTurnRow(workspaceId); + return row ? this.withCallCounts(row) : undefined; } @@ -128,6 +133,7 @@ export class WorkspaceActivityStore { .where(eq(workspaceTurns.id, turn.id)) .run(); this.pruneClosedTurns(workspaceId); + return { ...turn, status: "closed", reviewRef, closedAt: now.toISOString() }; } @@ -149,6 +155,7 @@ export class WorkspaceActivityStore { durationMs: input.durationMs, }) .run(); + return Number(inserted.lastInsertRowid); } @@ -159,6 +166,7 @@ export class WorkspaceActivityStore { getWorkspace(workspaceId: string): ActivityWorkspaceRecord | undefined { const row = this.workspaceQuery().where(eq(workspaceSessions.id, workspaceId)).get(); + return row ? rowToWorkspace(row) : undefined; } @@ -202,6 +210,7 @@ export class WorkspaceActivityStore { eq(workspaceTurns.sequence, sequence), )) .get(); + return row ? rowToTurn(row) : undefined; } @@ -247,6 +256,7 @@ export class WorkspaceActivityStore { private withCallCounts(row: WorkspaceTurnRow): WorkspaceTurn { const counts = this.turnQuery().where(eq(workspaceTurns.id, row.id)).get(); + return counts ? rowToTurn(counts) : rowToTurn({ turn: row, toolCalls: 0, failedToolCalls: 0 }); } @@ -288,6 +298,7 @@ function rowToTurn(row: { failedToolCalls: number; }): WorkspaceTurn { const { turn } = row; + return { id: turn.id, workspaceId: turn.workspaceSessionId, diff --git a/src/workspace-activity.ts b/src/workspace-activity.ts index a62ca73a3..deb91558e 100644 --- a/src/workspace-activity.ts +++ b/src/workspace-activity.ts @@ -34,6 +34,7 @@ export async function getActivityWorkspace( workspaceId: string, ): Promise { const workspace = store.getWorkspace(workspaceId); + return workspace ? withBranch(workspace) : undefined; } @@ -52,6 +53,7 @@ export function listTurnToolCalls( sequence: number, ): WorkspaceToolCall[] | undefined { const turn = store.getTurn(workspaceId, sequence); + return turn ? store.listToolCalls(turn.id) : undefined; } @@ -67,7 +69,9 @@ export async function readTurnDiff( ): Promise { const workspace = store.getWorkspace(workspaceId); const turn = store.getTurn(workspaceId, sequence); + if (!workspace || !turn) return undefined; + if (!turn.reviewRef) return { status: "open" }; try { @@ -83,6 +87,7 @@ export async function readTurnDiff( async function withBranch(workspace: ActivityWorkspaceRecord): Promise { try { const branch = (await git(workspace.root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim(); + return branch ? { ...workspace, branch } : workspace; } catch { return workspace; diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 4147897dc..e789f0e5c 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -45,12 +45,15 @@ test("conversation bindings distinguish canonical projects", async (t) => { const firstProjectOpen = await registry.openWorkspace(project, { conversationScopeId: "chat-1", }); + const otherProjectOpen = await registry.openWorkspace(otherProject, { conversationScopeId: "chat-1", }); + const repeatedProjectOpen = await registry.openWorkspace(project, { conversationScopeId: "chat-1", }); + const repeatedOtherProjectOpen = await registry.openWorkspace(otherProject, { conversationScopeId: "chat-1", }); @@ -87,12 +90,15 @@ test("worktree requests remain fresh without replacing the reusable checkout", a const worktreeInput = { path: project, mode: "worktree" as const }; const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const firstWorktree = await registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1", }); + const secondWorktree = await registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1", }); + const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); assert.notEqual(firstWorktree.workspace.id, secondWorktree.workspace.id); @@ -107,6 +113,7 @@ test("a worktree-first conversation creates and then reuses its checkout", async const worktree = await registry.openWorkspace(worktreeInput, { conversationScopeId: "chat-1", }); + const checkout = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); const checkoutAgain = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); @@ -138,13 +145,16 @@ test("concurrent worktree opens remain fresh and return complete context", async test("checkout reuse survives a registry restart", async (t) => { const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1", }); + context.closeStore(context.store); const restoredStore = context.openStore(); const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); + const restored = await restoredRegistry.openWorkspace(context.project, { conversationScopeId: "chat-1", }); @@ -159,6 +169,7 @@ test("a context-loading failure preserves a valid checkout binding", async (t) = const backupDir = join(project, ".devspace", "agents-backup"); await breakAgentsDirectory(agentsDir, backupDir); + try { await assert.rejects( () => registry.openWorkspace(project, { conversationScopeId: "chat-1" }), @@ -198,6 +209,7 @@ test("canonical checkout identity survives equivalent path and symlink aliases", const { root, project, registry } = await fixture(t); const direct = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + const equivalent = await registry.openWorkspace(join(project, "..", "project"), { conversationScopeId: "chat-1", }); @@ -215,13 +227,16 @@ test("canonical checkout identity survives equivalent path and symlink aliases", test("canonical checkout identity survives macOS var path aliases", { skip: platform() !== "darwin" }, async (t) => { const context = await fixture(t); + const macAlias = context.root.startsWith("/private/var/") ? `/var/${context.root.slice("/private/var/".length)}` : context.root.startsWith("/var/") ? `/private/var/${context.root.slice("/var/".length)}` : undefined; + if (!macAlias) { t.skip("temporary directory is not under /var"); + return; } @@ -233,11 +248,13 @@ test("canonical checkout identity survives macOS var path aliases", { skip: plat }, skills: { agentDir: join(context.root, "agent") }, })); + const aliasRegistry = new WorkspaceRegistry(aliasConfig, context.store); const direct = await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1", }); + const aliased = await aliasRegistry.openWorkspace( `${macAlias}/${context.project.slice(context.root.length + 1)}`, { conversationScopeId: "chat-1" }, @@ -248,12 +265,15 @@ test("canonical checkout identity survives macOS var path aliases", { skip: plat test("an invalid persisted checkout binding is not reused", async (t) => { const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1", }); + context.closeStore(context.store); const database = openDatabase(context.stateDir); + try { database.sqlite .prepare("update workspace_sessions set mode = 'worktree' where id = ?") @@ -264,6 +284,7 @@ test("an invalid persisted checkout binding is not reused", async (t) => { const restoredStore = context.openStore(); const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); + const replacement = await restoredRegistry.openWorkspace(context.project, { conversationScopeId: "chat-1", }); @@ -273,12 +294,15 @@ test("an invalid persisted checkout binding is not reused", async (t) => { test("an inactive persisted checkout binding is not reused", async (t) => { const context = await fixture(t); + const first = await context.registry.openWorkspace(context.project, { conversationScopeId: "chat-1", }); + context.closeStore(context.store); const database = openDatabase(context.stateDir); + try { database.sqlite .prepare("update workspace_sessions set status = 'inactive' where id = ?") @@ -288,6 +312,7 @@ test("an inactive persisted checkout binding is not reused", async (t) => { } const restoredRegistry = new WorkspaceRegistry(context.config, context.openStore()); + const replacement = await restoredRegistry.openWorkspace(context.project, { conversationScopeId: "chat-1", }); @@ -339,6 +364,7 @@ test("unexpected filesystem errors are propagated without replacing the binding" context.closeStore(context.store); const database = openDatabase(context.stateDir); + try { database.sqlite .prepare("update workspace_sessions set root = ? where id = ?") @@ -405,14 +431,18 @@ async function fixture( skills: { agentDir }, subagents: { enabled: true, instructions: "on-demand", providers: [] }, })); + const openStore = () => { const store = new SqliteWorkspaceStore(stateDir); stores.add(store); + return store; }; + const closeStore = (store: SqliteWorkspaceStore) => { if (stores.delete(store)) store.close(); }; + const store = openStore(); t.after(async () => { diff --git a/src/workspace-store.test.ts b/src/workspace-store.test.ts index 788e2d3ef..3a7bf6f05 100644 --- a/src/workspace-store.test.ts +++ b/src/workspace-store.test.ts @@ -21,6 +21,7 @@ test("workspace store lists only stale managed worktrees", async (t) => { sourceRoot: "/tmp/repo", managed: true, }); + store.createSession({ id: "ws_checkout", root: "/tmp/repo", @@ -43,5 +44,6 @@ test("workspace store lists only stale managed worktrees", async (t) => { function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; + return result.value; } diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 527874be2..80873759a 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -9,7 +9,9 @@ import { } from "./db/schema.js"; export type WorkspaceMode = "checkout" | "worktree"; + export type WorkspaceStatus = "active" | "inactive" | "pruned"; + export type WorkspaceRecoveryKind = "head" | "stash"; export class WorkspaceStoreError extends TaggedError("WorkspaceStoreError")<{ @@ -101,6 +103,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { managed?: boolean; }): WorkspaceSession { const now = new Date().toISOString(); + const session: WorkspaceSession = { id: input.id, root: input.root, @@ -197,6 +200,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { ), ) .run(); + return result.changes > 0; }, id); } @@ -213,6 +217,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { ), ) .run(); + return result.changes > 0; }, id); } @@ -250,6 +255,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { workspaceSessionId: string; }): WorkspaceConversationBinding { const now = new Date().toISOString(); + const row = this.database.db .insert(workspaceConversationBindings) .values({ @@ -371,6 +377,7 @@ function workspaceStoreResult( return Result.ok(run()); } catch (cause) { if (isProgrammerDefect(cause)) throw cause; + return Result.err(new WorkspaceStoreError(operation, cause, workspaceId)); } } diff --git a/src/workspace-web.test.ts b/src/workspace-web.test.ts index 8d0cd6354..44e7ca5ef 100644 --- a/src/workspace-web.test.ts +++ b/src/workspace-web.test.ts @@ -59,6 +59,7 @@ test("workspace pages require an owner session", async (t) => { headers: { ...sameOrigin, Cookie: session }, redirect: "manual", }); + assert.equal(logout.status, 303); const afterLogout = await fetch(`${baseUrl}/web/api/workspaces`, { headers: { Cookie: session } }); assert.equal(afterLogout.status, 401); @@ -66,10 +67,12 @@ test("workspace pages require an owner session", async (t) => { test("workspace pages reject hosts outside the allowed hosts", async (t) => { const { baseUrl } = await startServer(t); + // fetch cannot override Host, so use a raw request like a DNS-rebinding page would send. const response = await new Promise((resolve, reject) => { request(`${baseUrl}/web/login`, { headers: { Host: "attacker.example" } }, resolve).on("error", reject).end(); }); + response.resume(); assert.equal(response.statusCode, 403); }); @@ -77,11 +80,13 @@ test("workspace pages reject hosts outside the allowed hosts", async (t) => { async function startServer(t: TestContext): Promise<{ baseUrl: string; root: string }> { const root = await mkdtemp(join(tmpdir(), "devspace-web-test-")); const stateDir = join(root, "state"); + const config = loadConfig(writeTestDevspaceConfig(join(root, "config"), { storage: { stateDir }, workspaces: { allowedRoots: [root] }, logging: { level: "silent" }, })); + const workspaces = new SqliteWorkspaceStore(stateDir); workspaces.createSession({ id: "ws_web", root }); workspaces.close(); @@ -97,6 +102,7 @@ async function startServer(t: TestContext): Promise<{ baseUrl: string; root: str await once(listener, "listening"); const address = listener.address(); assert.ok(address && typeof address !== "string"); + return { baseUrl: `http://127.0.0.1:${address.port}`, root }; } diff --git a/src/workspace-web.ts b/src/workspace-web.ts index fe5d9291b..75399137c 100644 --- a/src/workspace-web.ts +++ b/src/workspace-web.ts @@ -65,24 +65,32 @@ export function createWorkspaceWebRouter(options: WorkspaceWebOptions): Router { router.get("/login", (req, res) => { if (sessions.isValid(sessionCookie(req))) { res.redirect(303, safeNextPath(req.query.next)); + return; } + sendLoginPage(res, 200, safeNextPath(req.query.next)); }); router.post("/login", express.urlencoded({ extended: false, limit: "8kb" }), (req, res) => { const next = safeNextPath(req.body?.next); + if (!isSameOriginRequest(req, config)) { res.status(403).type("text/plain").send("Cross-origin login is not allowed."); + return; } + if (failedLogins.isLocked()) { sendLoginPage(res, 429, next, "Too many failed attempts. Try again in a few minutes."); + return; } + if (!tokenMatches(String(req.body?.owner_token ?? ""), config.oauth.ownerToken)) { failedLogins.record(); sendLoginPage(res, 401, next, "The owner password was not accepted."); + return; } @@ -99,8 +107,10 @@ export function createWorkspaceWebRouter(options: WorkspaceWebOptions): Router { router.post("/logout", (req, res) => { if (!isSameOriginRequest(req, config)) { res.status(403).type("text/plain").send("Cross-origin logout is not allowed."); + return; } + sessions.revoke(sessionCookie(req)); res.clearCookie(SESSION_COOKIE, { path: WORKSPACE_WEB_PATH }); res.redirect(303, `${WORKSPACE_WEB_PATH}/login`); @@ -109,12 +119,16 @@ export function createWorkspaceWebRouter(options: WorkspaceWebOptions): Router { router.use((req, res, next) => { if (sessions.isValid(sessionCookie(req))) { next(); + return; } + if (req.path.startsWith("/api/")) { res.status(401).json({ error: "Sign in to view workspaces." }); + return; } + res.redirect(303, `${WORKSPACE_WEB_PATH}/login?next=${encodeURIComponent(req.originalUrl)}`); }); @@ -125,10 +139,13 @@ export function createWorkspaceWebRouter(options: WorkspaceWebOptions): Router { router.get("/api/workspaces/:workspaceId", asyncRoute(async (req, res) => { const workspaceId = String(req.params.workspaceId); const workspace = await getActivityWorkspace(activity, workspaceId); + if (!workspace) { res.status(404).json({ error: "Workspace not found." }); + return; } + res.json({ workspace, context: await workspaces.describeWorkspace(workspace), @@ -140,10 +157,13 @@ export function createWorkspaceWebRouter(options: WorkspaceWebOptions): Router { const workspaceId = String(req.params.workspaceId); const sequence = Number(req.params.sequence); const turn = Number.isSafeInteger(sequence) ? activity.getTurn(workspaceId, sequence) : undefined; + if (!turn) { res.status(404).json({ error: "Turn not found." }); + return; } + const toolCalls = listTurnToolCalls(activity, workspaceId, sequence) ?? []; const diff = await readTurnDiff(activity, workspaceId, sequence); res.json({ turn, toolCalls, diff }); @@ -167,6 +187,7 @@ class WebSessions { create(): string { const token = randomBytes(32).toString("base64url"); this.expiresAt.set(digest(token), Date.now() + SESSION_TTL_MS); + return token; } @@ -174,9 +195,12 @@ class WebSessions { if (!token) return false; const key = digest(token); const expiresAt = this.expiresAt.get(key); + if (expiresAt === undefined) return false; + if (expiresAt > Date.now()) return true; this.expiresAt.delete(key); + return false; } @@ -191,6 +215,7 @@ class FailedLoginWindow { isLocked(): boolean { this.prune(); + return this.failures.length >= MAX_FAILED_LOGINS; } @@ -216,11 +241,14 @@ function digest(value: string): string { function sessionCookie(req: Request): string | undefined { for (const part of (req.get("cookie") ?? "").split(";")) { const separator = part.indexOf("="); + if (separator === -1) continue; + if (part.slice(0, separator).trim() === SESSION_COOKIE) { return decodeURIComponent(part.slice(separator + 1).trim()); } } + return undefined; } @@ -230,11 +258,15 @@ function sessionCookie(req: Request): string | undefined { */ function isSameOriginRequest(req: Request, config: ServerConfig): boolean { const fetchSite = req.get("sec-fetch-site"); + if (fetchSite) return fetchSite === "same-origin"; const origin = req.get("origin"); + if (!origin) return false; + if (origin === new URL(config.publicBaseUrl).origin) return true; + try { return new URL(origin).host === req.get("host"); } catch { @@ -248,10 +280,13 @@ function isHttpsRequest(req: Request): boolean { function safeNextPath(value: unknown): string { const fallback = `${WORKSPACE_WEB_PATH}/`; + if (typeof value !== "string") return fallback; + if (!value.startsWith(`${WORKSPACE_WEB_PATH}/`) || value.startsWith("//") || value.includes("\\")) { return fallback; } + return value.startsWith(`${WORKSPACE_WEB_PATH}/login`) ? fallback : value; } diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index c79dd4584..a45fb7c52 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -70,6 +70,7 @@ test("global instruction symlinks may target user-managed files outside agentDir skills: { agentDir }, }, )); + const opened = await new WorkspaceRegistry(config).openWorkspace(context.root); assert.deepEqual( @@ -144,6 +145,7 @@ test("persisted checkout and worktree sessions restore after recreating the regi firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); + try { const restoredRegistry = new WorkspaceRegistry(context.config, secondStore); const restoredCheckout = await restoredRegistry.getWorkspace(checkout.workspace.id); @@ -230,17 +232,21 @@ test("failed session reactivation does not strand a restored worktree", async (t const context = await fixture(t); const gitRoot = await createGitProject(context.root); const stateDir = await mkdtemp(join(tmpdir(), "devspace-pruned-reactivation-state-test-")); + class FailOnceStore extends SqliteWorkspaceStore { private failNextReactivation = true; override reactivateSession(id: string): BetterResult { if (this.failNextReactivation) { this.failNextReactivation = false; + return Result.ok(false); } + return super.reactivateSession(id); } } + const store = new FailOnceStore(stateDir); t.after(async () => { store.close(); @@ -273,19 +279,23 @@ test("failed session reactivation does not strand a restored worktree", async (t test("invalid persisted roots are not refreshed before validation", async (t) => { const context = await fixture(t); const stateDir = await mkdtemp(join(tmpdir(), "devspace-invalid-root-state-test-")); + class TrackingStore extends SqliteWorkspaceStore { touches = 0; override touchSession(id: string): BetterResult { this.touches += 1; + return super.touchSession(id); } } + const store = new TrackingStore(stateDir); t.after(async () => { store.close(); await rm(stateDir, { recursive: true, force: true }); }); + const session = store.createSession({ id: "ws_invalid_root", root: context.outsideRoot, @@ -312,6 +322,7 @@ test("persisted symlink roots cannot restore outside allowed roots", async (t) = outsideLink, platform() === "win32" ? "junction" : "dir", ); + const session = store.createSession({ id: "ws_symlink_escape", root: outsideLink, @@ -358,6 +369,7 @@ test("workspace cache evicts old contexts without losing advertised skill reads" )); const store = new SqliteWorkspaceStore(stateDir); + try { const registry = new WorkspaceRegistry(config, store); const first = await registry.openWorkspace(context.root); @@ -424,12 +436,14 @@ test("a symlinked allowed root preserves checkout and worktree path behavior", { skills: { agentDir: context.agentDir }, }, )); + const aliasRegistry = new WorkspaceRegistry(aliasConfig); const worktree = await aliasRegistry.openWorkspace({ path: join(aliasRoot, "git-project"), mode: "worktree", }); + const checkout = await aliasRegistry.openWorkspace(aliasRoot); assert.equal(worktree.workspace.sourceRoot, join(aliasRoot, "git-project")); @@ -514,15 +528,18 @@ async function createGitProject(parent: string): Promise { await git(gitRoot, ["config", "user.name", "DevSpace Test"]); await git(gitRoot, ["add", "."]); await git(gitRoot, ["commit", "-m", "Initial commit"]); + return gitRoot; } async function git(cwd: string, args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf8" }); + return stdout.trim(); } function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; + return result.value; } diff --git a/src/workspaces.ts b/src/workspaces.ts index d834bd4be..ed3191f0f 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -101,6 +101,7 @@ export interface OpenWorkspaceOptions { } type PathStats = Stats; + type DirectoryOps = { stat: (path: string) => Promise; mkdir: (path: string, options: { recursive: true }) => Promise; @@ -127,14 +128,17 @@ export class WorkspaceRegistry { ): Promise { const workspaceInput = typeof input === "string" ? { path: input } : input; const conversationScopeId = openOptions.conversationScopeId; + if (!conversationScopeId || !this.store) { return this.openNewWorkspace(workspaceInput); } const projectKey = await this.conversationProjectKey(workspaceInput); const mode = workspaceInput.mode ?? "checkout"; + if (mode === "worktree") { const context = await this.openWorktreeWorkspace(workspaceInput.path, workspaceInput.baseRef); + return { ...context, // A new worktree always has its own workspace-specific context. @@ -145,8 +149,10 @@ export class WorkspaceRegistry { const targetKey = this.conversationCheckoutTargetKey(projectKey); const operationKey = JSON.stringify([conversationScopeId, targetKey]); const pending = this.pendingCheckoutOpens.get(operationKey); + if (pending) { const context = await pending; + return { ...context, workspaceReused: true, @@ -159,6 +165,7 @@ export class WorkspaceRegistry { conversationScopeId, targetKey, ); + this.pendingCheckoutOpens.set(operationKey, open); try { @@ -186,12 +193,14 @@ export class WorkspaceRegistry { targetKey: string, ): Promise { const binding = this.store?.getConversationBinding(conversationScopeId, targetKey); + if (binding) { const reusableWorkspace = await this.findReusableCheckoutWorkspace(binding); if (reusableWorkspace) { const context = await this.reusedWorkspaceContext(reusableWorkspace); this.store?.touchConversationBinding(conversationScopeId, targetKey); + return { ...context, includeBootstrapContext: false, @@ -208,6 +217,7 @@ export class WorkspaceRegistry { targetKey, workspaceSessionId: context.workspace.id, }); + return { ...context, includeBootstrapContext: true, @@ -218,15 +228,18 @@ export class WorkspaceRegistry { binding: WorkspaceConversationBinding, ): Promise { const session = this.store?.getSession(binding.workspaceSessionId); + if (!session || session.status !== "active" || session.mode !== "checkout") { return undefined; } let root: string; + try { root = this.assertWorkspaceRootAllowed(session.root, session.mode, session.sourceRoot); await this.resolveCanonicalWorkspaceRoot(root, session.mode, session.sourceRoot); const rootStats = await stat(root); + if (!rootStats.isDirectory()) return undefined; } catch (error) { if ( @@ -240,7 +253,9 @@ export class WorkspaceRegistry { } const workspace = await this.getWorkspace(binding.workspaceSessionId); + if (workspace.mode !== "checkout" || workspace.root !== root) return undefined; + return workspace; } @@ -268,56 +283,75 @@ export class WorkspaceRegistry { async getWorkspace(workspaceId: string): Promise { const workspace = this.workspaces.get(workspaceId); + if (workspace) { if (!this.store) { await this.assertWorkspaceRootUnchanged(workspace); this.workspaces.delete(workspaceId); this.workspaces.set(workspaceId, workspace); + return workspace; } const cachedSession = this.store.getSessionResult(workspaceId); + if (cachedSession.isErr()) throw cachedSession.error; + if (cachedSession.value?.status === "active") { await this.assertWorkspaceRootUnchanged(workspace); const touched = this.store.touchSession(workspaceId); + if (touched.isErr()) throw touched.error; + if (!touched.value) throw unavailableWorkspaceError(workspaceId); this.workspaces.delete(workspaceId); this.workspaces.set(workspaceId, workspace); + return workspace; } + this.workspaces.delete(workspaceId); } let session: WorkspaceSession | undefined; + if (this.store) { const sessionLookup = this.store.getSessionResult(workspaceId); + if (sessionLookup.isErr()) throw sessionLookup.error; session = sessionLookup.value; } + if (session?.status === "pruned") { const restored = await this.ensurePrunedWorkspaceRestored(session); + if (restored.isErr()) throw restored.error; + if (this.store) { const restoredLookup = this.store.getSessionResult(workspaceId); + if (restoredLookup.isErr()) throw restoredLookup.error; session = restoredLookup.value; } } + if (!session || session.status !== "active") { throw unavailableWorkspaceError(workspaceId); } const root = this.assertWorkspaceRootAllowed(session.root, session.mode, session.sourceRoot); + const canonicalRoot = await this.resolveCanonicalWorkspaceRoot( root, session.mode, session.sourceRoot, ); + if (this.store) { const touched = this.store.touchSession(workspaceId); + if (touched.isErr()) throw touched.error; + if (!touched.value) throw unavailableWorkspaceError(workspaceId); } @@ -341,6 +375,7 @@ export class WorkspaceRegistry { ...this.loadSkillsForWorkspace(root), agentProfiles: [], }; + this.rememberWorkspace(restoredWorkspace); return restoredWorkspace; @@ -350,10 +385,12 @@ export class WorkspaceRegistry { session: WorkspaceSession, ): Promise> { const pending = this.pendingRestores.get(session.id); + if (pending) return pending; const restore = this.restorePrunedWorkspace(session); this.pendingRestores.set(session.id, restore); + try { return await restore; } finally { @@ -380,15 +417,18 @@ export class WorkspaceRegistry { worktreeRoot: this.config.worktreeRoot, allowedRoots: this.config.allowedRoots, }); + if (restored.isErr()) return restored; const reactivated = this.store.reactivateSession(session.id); + if (reactivated.isErr() || !reactivated.value) { const discarded = await discardRestoredManagedWorktree({ session, worktreeRoot: this.config.worktreeRoot, allowedRoots: this.config.allowedRoots, }); + if (discarded.isErr()) { return Result.err(new ManagedWorktreeError({ code: "WORKTREE_RESTORE_FAILED", @@ -401,7 +441,9 @@ export class WorkspaceRegistry { }, })); } + if (reactivated.isErr()) return reactivated; + return Result.err(new ManagedWorktreeError({ code: "WORKTREE_RESTORE_FAILED", workspaceId: session.id, @@ -432,6 +474,7 @@ export class WorkspaceRegistry { workspace.skills, inputPath, ); + if (!skillRead) throw workspaceError; return { @@ -451,12 +494,15 @@ export class WorkspaceRegistry { private async openCheckoutWorkspace(path: string): Promise { const root = assertAllowedPath(path, this.config.allowedRoots); + const canonicalRoot = await resolveCanonicalAllowedPath( root, process.cwd(), this.config.allowedRoots, ); + const rootStats = await ensureCheckoutWorkspaceRoot(root); + if (!rootStats.isDirectory()) { throw new Error(`Workspace root must be a directory: ${path}`); } @@ -470,11 +516,13 @@ export class WorkspaceRegistry { baseRef, config: this.config, }); + await resolveCanonicalAllowedPath( worktree.sourceRoot, process.cwd(), this.config.allowedRoots, ); + const canonicalRoot = await resolveCanonicalAllowedPath( worktree.path, process.cwd(), @@ -541,6 +589,7 @@ export class WorkspaceRegistry { this.assertWorkspaceRootAllowed(session.root, session.mode, session.sourceRoot); const agentsFiles = await this.loadInitialAgentsFiles(session.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(session.root, agentsFiles); + return { available: true, agentsFiles: agentsFiles.map((file) => formatAgentsPath(file.path, session.root)), @@ -559,8 +608,10 @@ export class WorkspaceRegistry { this.workspaces.set(workspace.id, workspace); if (!this.store) return; + while (this.workspaces.size > MAX_CACHED_WORKSPACES) { const oldestWorkspaceId = this.workspaces.keys().next().value as string | undefined; + if (!oldestWorkspaceId) break; this.workspaces.delete(oldestWorkspaceId); } @@ -568,6 +619,7 @@ export class WorkspaceRegistry { private loadSkillsForWorkspace(root: string): Pick { const result = loadWorkspaceSkills(this.config, root); + return { skills: result.skills, skillDiagnostics: result.diagnostics, @@ -579,7 +631,9 @@ export class WorkspaceRegistry { if (!sourceRoot) { throw new Error(`Stored worktree workspace is missing sourceRoot: ${root}`); } + assertAllowedPath(sourceRoot, this.config.allowedRoots); + return assertAllowedPath(root, [this.config.worktreeRoot]); } @@ -595,7 +649,9 @@ export class WorkspaceRegistry { if (!sourceRoot) { throw new Error(`Stored worktree workspace is missing sourceRoot: ${root}`); } + await resolveCanonicalAllowedPath(sourceRoot, process.cwd(), this.config.allowedRoots); + return resolveCanonicalAllowedPath(root, process.cwd(), [this.config.worktreeRoot]); } @@ -604,11 +660,13 @@ export class WorkspaceRegistry { private async assertWorkspaceRootUnchanged(workspace: Workspace): Promise { let currentRoot: string; + try { currentRoot = await realpath(workspace.root); } catch { throw new AccessDeniedError(`Workspace root is no longer accessible: ${workspace.root}`); } + if (currentRoot !== workspace.canonicalRoot) { throw new AccessDeniedError(`Workspace root changed after it was opened: ${workspace.root}`); } @@ -622,13 +680,16 @@ export class WorkspaceRegistry { for (const file of loadProjectContextFiles({ cwd: root, agentDir })) { const path = resolve(file.path); const source = initialAgentsFileSource(path, root, agentDir); + if (!source) continue; + const content = await readResolvedContextFile( path, file.content, source, resolvedRoot, ); + if (content === undefined) continue; loadedFiles.push({ @@ -646,17 +707,23 @@ export class WorkspaceRegistry { ): Promise { const loadedPaths = new Set(loadedFiles.map((file) => resolve(file.path))); const loadedRealPaths = new Set(); + for (const file of loadedFiles) { const realPath = await tryRealpath(file.path); + if (realPath) loadedRealPaths.add(realPath); } + const discovered: AvailableAgentsFile[] = []; await walkWorkspace(root, async (path, entry) => { if (!entry.isFile()) return; + if (!CONTEXT_FILE_NAMES.has(entry.name)) return; + if (loadedPaths.has(path)) return; const realPath = await tryRealpath(path); + if (realPath && loadedRealPaths.has(realPath)) return; discovered.push({ path }); @@ -685,10 +752,12 @@ export async function ensureCheckoutWorkspaceRoot( } await ops.mkdir(path, { recursive: true }); + return await ops.stat(path); } const CONTEXT_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]); + const SKIPPED_CONTEXT_DIRS = new Set([ ".git", ".hg", @@ -706,6 +775,7 @@ export function formatAgentsPath(path: string, workspaceRoot: string | undefined if (!workspaceRoot) return path.split(sep).join("/"); const relationship = relative(workspaceRoot, path); + if ( relationship === "" || relationship.startsWith("..") || @@ -724,7 +794,9 @@ function initialAgentsFileSource( agentDir: string, ): InitialAgentsFileSource | undefined { if (isPathInsideRoot(path, agentDir)) return "global"; + if (isPathInsideRoot(path, root) && dirname(path) === root) return "workspace"; + return undefined; } @@ -736,12 +808,14 @@ async function readResolvedContextFile( ): Promise { try { const resolvedPath = await realpath(path); + if ( source === "workspace" && (!isPathInsideRoot(resolvedPath, root) || dirname(resolvedPath) !== root) ) { return undefined; } + return await readFile(resolvedPath, "utf8"); } catch { return fallbackContent; @@ -761,6 +835,7 @@ async function walkWorkspace( visit: (path: string, entry: { name: string; isFile(): boolean; isDirectory(): boolean }) => Promise | void, ): Promise { let entries; + try { entries = await opendir(directory); } catch { @@ -769,10 +844,12 @@ async function walkWorkspace( for await (const entry of entries) { const path = join(directory, entry.name); + if (entry.isDirectory()) { if (!SKIPPED_CONTEXT_DIRS.has(entry.name)) { await walkWorkspace(path, visit); } + continue; } diff --git a/src/worktree-prune.ts b/src/worktree-prune.ts index bf27fbf60..9afe3417f 100644 --- a/src/worktree-prune.ts +++ b/src/worktree-prune.ts @@ -16,10 +16,12 @@ export async function pruneStaleManagedWorktrees( now = new Date(), ): Promise> { const opened = createWorkspaceStoreResult(config.stateDir); + if (opened.isErr()) return opened; let result!: BetterResult; let closed!: BetterResult; + try { result = await cleanupManagedWorktrees({ store: opened.value, @@ -30,7 +32,10 @@ export async function pruneStaleManagedWorktrees( } finally { closed = closeWorkspaceStoreResult(opened.value); } + if (result.isErr()) return result; + if (closed.isErr()) return closed; + return result; } diff --git a/test/agents-replay/conformance.ts b/test/agents-replay/conformance.ts index c046d03ad..69e4f0a9b 100644 --- a/test/agents-replay/conformance.ts +++ b/test/agents-replay/conformance.ts @@ -51,6 +51,7 @@ const transcriptsDir = join(harnessDir, "transcripts"); const TURN_TIMEOUT_SECONDS = 180; const STOP_BOUND_MS = 15_000; const NON_STREAMING_TOOL_DELAY_MS = 8_000; + // The stop scenario's `sleep 20` plus slack for the provider to start it. const LATE_WRITE_DEADLINE_MS = 25_000; @@ -98,6 +99,7 @@ const SCENARIOS: Scenario[] = [ async run(run) { const promptedAt = Date.now(); const id = await start(run, "Run the shell command `sleep 20 && echo late > late.txt`, then reply SLEPT."); + if (streams(run.provider)) { await waitForEvent(run, id, (event) => event.type === "tool_started"); } else { @@ -105,14 +107,17 @@ const SCENARIOS: Scenario[] = [ await waitForEvent(run, id, (event) => event.type === "turn_started"); await delay(NON_STREAMING_TOOL_DELAY_MS); } + const stoppedAt = Date.now(); const stopped = await cli(run, ["agents", "stop", id, "--json"]); expect(stopped.code === 0, `stop failed: ${stopped.stdout}${stopped.stderr}`); let result = stopped.json; + while (result.status === "running" && Date.now() - stoppedAt < STOP_BOUND_MS) { await delay(250); result = (await cli(run, ["agents", "show", id, "--json"])).json; } + expect(result.status === "stopped", `expected stopped within ${STOP_BOUND_MS}ms, got ${describe(result)}`); const before = (await cli(run, ["agents", "events", id, "--json"])).json.events.length; await delay(2_000); @@ -120,6 +125,7 @@ const SCENARIOS: Scenario[] = [ const events = (await cli(run, ["agents", "events", id, "--json"])).json.events; expect(after.json.status === "stopped", `status changed after stop: ${describe(after.json)}`); expect(events.length === before, "events were appended after the turn stopped"); + // Replay runs no tools, so only a live provider can prove the sleep was killed: // check after it would have finished. if (run.mode !== "replay") await delay(Math.max(0, promptedAt + LATE_WRITE_DEADLINE_MS - Date.now())); @@ -142,6 +148,7 @@ const SCENARIOS: Scenario[] = [ expect(types.at(-1) === "turn_completed" && events.at(-1)?.status === "completed", `last event is ${JSON.stringify(events.at(-1))}`); expect(types.includes("message"), `no message event in ${types.join(",")}`); expect(events.every((event) => event.turnId === result.turnId), "events span more than one turnId"); + if (streams(run.provider)) { expect(types.includes("tool_started") && types.includes("tool_completed"), `no tool events in ${types.join(",")}`); } @@ -159,13 +166,16 @@ const SCENARIOS: Scenario[] = [ const result = await waitFor(run, id); expect(result.status === "completed", `expected completed, got ${describe(result)}`); const shown = (await cli(run, ["agents", "show", id, "--json"])).json; + for (const output of [result, shown]) { expect(output.worktree === worktree && output.branch === branch, `worktree and branch were not reported: ${describe(output)}`); } + expect(worktree !== run.workspace && existsSync(join(worktree, "README.md")), `${worktree} is not a checkout of the fixture`); expect(git(worktree, ["rev-parse", "--abbrev-ref", "HEAD"]) === branch, `worktree is not on ${branch}`); expect(git(run.workspace, ["status", "--porcelain"]) === "", "the source checkout was modified"); expect(!existsSync(join(run.workspace, "probe.txt")), "probe.txt appeared in the source checkout"); + // A replayed provider executes no tools, so the file can only be checked against a real one. if (run.mode !== "replay") { expect(existsSync(join(worktree, "probe.txt")), `probe.txt is missing from ${worktree}`); @@ -194,28 +204,37 @@ async function main(): Promise { scenario: { type: "string", multiple: true }, }, }); + if (values.live && values.record) throw new Error("--live and --record are exclusive."); const mode: Mode = values.record ? "record" : values.live ? "live" : "replay"; + if (!existsSync(cliPath)) throw new Error("dist/cli.js is missing; run pnpm build first."); const providers = (positionals.length > 0 ? positionals : recordedProviders()).map(providerDefinition); + const scenarios = values.scenario ? values.scenario.map((name) => SCENARIOS.find((scenario) => scenario.name === name) ?? fail(`Unknown scenario ${name}.`)) : SCENARIOS; const results: Array<{ provider: string; scenario: string; error?: string; ms: number }> = []; + for (const provider of providers) { for (const scenario of scenarios) { const startedAt = Date.now(); + const error = await runScenario(provider, scenario, mode).then(() => undefined, (cause: unknown) => ( cause instanceof Error ? cause.message : String(cause) )); + results.push({ provider: provider.id, scenario: scenario.name, error, ms: Date.now() - startedAt }); console.log(`${error ? "FAIL" : "PASS"} ${mode} ${provider.id} ${scenario.name} (${Date.now() - startedAt}ms)`); + if (error) console.log(indent(error)); } } + const failed = results.filter((result) => result.error); console.log(`\n${results.length - failed.length}/${results.length} passed`); + if (failed.length > 0) process.exitCode = 1; } @@ -236,6 +255,7 @@ async function runScenario(provider: ProviderDefinition, scenario: Scenario, mod : mode === "live" ? undefined : writeShimWrapper(root, provider, scenario, mode, workspace, runDir); + mkdirSync(configDir); writeFileSync(join(configDir, "config.jsonc"), JSON.stringify({ configVersion: 1, @@ -259,16 +279,19 @@ async function runScenario(provider: ProviderDefinition, scenario: Scenario, mod DEVSPACE_OAUTH_OWNER_TOKEN: "agents-qa-owner-token-that-is-long-enough", DEVSPACE_AGENTD_IDLE_TIMEOUT_MS: "60000", }; + // Run as an installed devspace would: this checkout's node_modules/.bin (with the bundled // Pi SDK) must not shadow the provider executables on PATH. if (env.PATH) env.PATH = removeDevspaceNodeModulesBinFromPath(env.PATH); delete env.DEVSPACE_WORKSPACE_ID; delete env.DEVSPACE_WORKSPACE_ROOT; const run: Run = { provider, mode, scenario: scenario.name, workspace, runDir, env }; + try { if (mode === "replay" && scenario.transcript && !existsSync(transcriptPath(provider, scenario) + ".jsonl")) { throw new Error(`No transcript; record it with: pnpm qa:agents ${provider.id} --record --scenario ${scenario.name}`); } + await scenario.run(run); } catch (error) { const report = join(runDir, "replay-failures.log"); @@ -277,6 +300,7 @@ async function runScenario(provider: ProviderDefinition, scenario: Scenario, mod } finally { await stopDaemon(run).catch(() => undefined); } + // A provider process that was just stopped may still be writing as the daemon exits. if (!process.env.DEVSPACE_QA_KEEP) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); } @@ -300,30 +324,37 @@ function writeShimWrapper( DEVSPACE_AGENT_SHIM_WORKTREE_ROOT: join(root, "worktrees"), ...(process.env.DEVSPACE_AGENT_SHIM_TRACE ? { DEVSPACE_AGENT_SHIM_TRACE: "1" } : {}), }; + const tsx = import.meta.resolve("tsx"); + const lines = [ "#!/bin/sh", ...Object.entries(variables).map(([key, value]) => `export ${key}=${shellQuote(value)}`), `exec ${shellQuote(process.execPath)} --import ${shellQuote(tsx)} ${shellQuote(shimPath)} "$@"`, "", ]; + const path = join(root, "bin", `${provider.id}-shim`); mkdirSync(join(root, "bin"), { recursive: true }); writeFileSync(path, lines.join("\n")); chmodSync(path, 0o755); + return path; } /** `daemon stop` only requests shutdown; wait for that daemon to exit so it stops logging into the directory we remove. */ async function stopDaemon(run: Run): Promise { const pid = (await cli(run, ["agents", "daemon", "stop", "--json"])).json?.pid; + if (typeof pid !== "number") return; + for (let waited = 0; waited < 10_000 && isAlive(pid); waited += 100) await delay(100); } function isAlive(pid: number): boolean { try { process.kill(pid, 0); + return true; } catch { return false; @@ -333,23 +364,29 @@ function isAlive(pid: number): boolean { async function start(run: Run, prompt: string, flags: string[] = []): Promise { const result = await cli(run, ["agents", "run", run.provider.id, ...flags, "--json", prompt]); expect(result.code === 0 && typeof result.json?.id === "string", `run failed: ${result.stdout}${result.stderr}`); + return result.json.id; } async function waitFor(run: Run, id: string): Promise { const result = await cli(run, ["agents", "wait", id, "--timeout", String(TURN_TIMEOUT_SECONDS), "--json"]); expect(Array.isArray(result.json), `wait failed: ${result.stdout}${result.stderr}`); + return result.json[0]; } async function waitForEvent(run: Run, id: string, predicate: (event: any) => boolean): Promise { const deadline = Date.now() + TURN_TIMEOUT_SECONDS * 1_000; + while (Date.now() < deadline) { const page = (await cli(run, ["agents", "events", id, "--json"])).json; + if (page?.events?.some(predicate)) return; + if (page?.status && page.status !== "running") throw new Error(`turn ended before the expected event: ${JSON.stringify(page)}`); await delay(250); } + throw new Error("timed out waiting for an agent event"); } @@ -367,11 +404,13 @@ function exec(run: Run, args: string[]): Promise { child.on("error", reject); child.on("close", (code) => { let json: unknown; + try { json = JSON.parse(stdout.trim().split("\n").at(-1) ?? ""); } catch { json = undefined; } + resolvePromise({ code, stdout, stderr, json }); }); }); @@ -379,6 +418,7 @@ function exec(run: Run, args: string[]): Promise { function streams(provider: ProviderDefinition): boolean { const driver = createLocalAgentDrivers().find((entry) => entry.providerInstanceId === provider.id); + return driver?.capabilities.turns.streaming === true; } @@ -392,7 +432,9 @@ function transcriptPath(provider: ProviderDefinition, scenario: Scenario): strin function git(cwd: string, args: string[]): string { const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + return result.stdout.trim(); } diff --git a/test/agents-replay/http-shim.ts b/test/agents-replay/http-shim.ts index 1fbd3807a..b1b0b09dc 100644 --- a/test/agents-replay/http-shim.ts +++ b/test/agents-replay/http-shim.ts @@ -30,6 +30,7 @@ export function serveArgs(args: string[]): { hostname: string; port: number } | if (args[0] !== "serve") return undefined; const flag = (name: string) => args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); const port = Number(flag("port")); + return Number.isInteger(port) ? { hostname: flag("hostname") ?? "127.0.0.1", port } : undefined; } @@ -47,26 +48,32 @@ export async function recordHttp(options: { const child = spawn(options.command, childArgs, { env: options.env, stdio: ["ignore", "pipe", "inherit"] }); child.on("error", (error) => options.fail(`Could not start ${options.command}: ${error.message}`)); child.on("exit", (code, signal) => process.exit(code ?? (signal ? 128 : 1))); + for (const signal of ["SIGTERM", "SIGINT"] as const) process.on(signal, () => child.kill(signal)); + const upstream = await new Promise((resolve) => { let output = ""; child.stdout.on("data", (chunk) => { output += chunk.toString(); const match = output.match(/listening on\s+(https?:\/\/\S+)/); + if (match?.[1]) resolve(new URL(match[1])); }); }); let nextId = 0; + const server = createServer(async (req, res) => { const id = ++nextId; const body = await readBody(req); options.append("in", requestFrame(id, req, body)); // Uncompressed responses keep transcript bodies readable. const { "accept-encoding": _encoding, ...headers } = req.headers; + const proxied = httpRequest(new URL(req.url ?? "/", upstream), { method: req.method, headers }, (response) => { const type = String(response.headers["content-type"] ?? ""); res.writeHead(response.statusCode ?? 502, response.headers); + if (type.includes("text/event-stream")) { options.append("out", { id, status: response.statusCode, type, stream: true }); let buffer = ""; @@ -74,9 +81,11 @@ export async function recordHttp(options: { res.write(chunk); buffer += chunk.toString(); let boundary: number; + while ((boundary = buffer.indexOf("\n\n")) >= 0) { const event = sseData(buffer.slice(0, boundary)); buffer = buffer.slice(boundary + 2); + if (event !== undefined) options.append("out", { id, event }); } }); @@ -84,8 +93,10 @@ export async function recordHttp(options: { options.append("out", { id, end: true }); res.end(); }); + return; } + const chunks: Buffer[] = []; response.on("data", (chunk: Buffer) => chunks.push(chunk)); response.on("end", () => { @@ -94,11 +105,13 @@ export async function recordHttp(options: { res.end(text); }); }); + proxied.on("error", () => res.destroy()); // A client that gives up on a long poll closes its socket; pass that on. res.on("close", () => { if (!res.writableFinished) proxied.destroy(); }); proxied.end(body); }); + server.listen(port, hostname, () => { process.stdout.write(`opencode server listening on http://${hostname}:${port}\n`); }); @@ -116,22 +129,28 @@ export function replayHttp(options: { const { hostname, port } = serveArgs(options.args)!; const open = new Map(); let nextId = 0; + const server = createServer(async (req, res) => { const id = ++nextId; open.set(id, res); res.on("close", () => open.delete(id)); options.receive(requestFrame(id, req, await readBody(req))); }); + server.listen(port, hostname, () => { process.stdout.write(`opencode server listening on http://${hostname}:${port}\n`); }); + return { respond(frame) { const record = frame as { id?: number; status?: number; type?: string; body?: unknown; stream?: boolean; event?: unknown; end?: boolean }; const res = open.get(Number(record.id)); + if (!res || res.writableEnded) return; + if (record.status !== undefined) { res.writeHead(record.status, record.type ? { "content-type": record.type } : {}); + if (!record.stream) { res.end(record.body === undefined ? undefined : typeof record.body === "string" ? record.body : JSON.stringify(record.body)); } @@ -151,12 +170,14 @@ function requestFrame(id: number, req: IncomingMessage, body: Buffer): HttpReque function bodyField(text: string): { body?: unknown } { if (!text) return {}; const parsed = parseFrame(text); + return { body: parsed !== null && typeof parsed === "object" && "raw" in parsed ? text : parsed }; } /** The data of one SSE event, parsed as JSON when it is. */ function sseData(block: string): unknown { const data = block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n"); + return data ? bodyField(data).body : undefined; } diff --git a/test/agents-replay/providers.ts b/test/agents-replay/providers.ts index 2c282682f..2166cd1f0 100644 --- a/test/agents-replay/providers.ts +++ b/test/agents-replay/providers.ts @@ -56,10 +56,13 @@ const require = createRequire(import.meta.url); */ function redactAcpFrame(frame: Record): unknown { const update = (frame.params as { update?: { sessionUpdate?: unknown } } | undefined)?.update; + if (frame.method === "session/update" && update?.sessionUpdate === "available_commands_update") return undefined; const result = frame.result as Record | undefined; + if (!result || !("sessionId" in result)) return frame; const models = result.models as { currentModelId?: unknown; availableModels?: unknown[] } | undefined; + return { ...frame, result: { @@ -89,8 +92,10 @@ function redactGrokFrame(frame: Record): unknown { if (typeof frame.method === "string" && /^_?x\.ai\//.test(frame.method) && !frame.method.endsWith("/session/prompt_complete")) { return undefined; } + const redacted = redactAcpFrame(frame) as Record | undefined; const result = redacted?.result as Record | undefined; + return redacted && result && "_meta" in result ? { ...redacted, result: { ...result, _meta: {} } } : redacted; } @@ -124,6 +129,7 @@ export const PROVIDERS: Record = { protocol: { kind: (frame) => { const request = frame.request as Record | undefined; + return frame.type === "control_request" && typeof request?.subtype === "string" ? `control_request:${request.subtype}` : String(frame.type); @@ -138,6 +144,7 @@ export const PROVIDERS: Record = { redactFrame: (frame) => { // SessionStart and other hooks report the user's own hook commands and output. if (frame.type === "system" && String(frame.subtype).startsWith("hook_")) return undefined; + if (frame.type === "system" && frame.subtype === "init") { return { ...frame, @@ -146,7 +153,9 @@ export const PROVIDERS: Record = { memory_paths: {}, }; } + const response = (frame.response as Record | undefined)?.response as Record | undefined; + if (frame.type === "control_response" && response && "commands" in response) { return { ...frame, @@ -156,12 +165,14 @@ export const PROVIDERS: Record = { }, }; } + return frame; }, realCommand: () => { // The SDK runs its bundled native CLI unless CLAUDE_COMMAND is set. const sdk = dirname(require.resolve("@anthropic-ai/claude-agent-sdk")); const platform = `${process.platform}-${process.arch}`; + return createRequire(join(sdk, "package.json")) .resolve(`@anthropic-ai/claude-agent-sdk-${platform}/claude${process.platform === "win32" ? ".exe" : ""}`); }, @@ -181,6 +192,7 @@ export const PROVIDERS: Record = { // so recordings may point Pi at an isolated agent dir (see README). liveEnv: (): Record => { const agentDir = process.env.DEVSPACE_QA_PI_AGENT_DIR; + return agentDir ? { PI_CODING_AGENT_DIR: agentDir } : {}; }, realCommand: () => "pi", @@ -209,9 +221,12 @@ export const PROVIDERS: Record = { redactFrame: (frame) => { if (typeof frame.method === "string") return CODEX_USER_ENVIRONMENT_METHODS.has(frame.method) ? undefined : frame; const result = frame.result as Record | undefined; + // The initialize reply names the OS; thread replies list the user's instruction files. if (result && "userAgent" in result) return { ...frame, result: { ...result, userAgent: "codex" } }; + if (result && "instructionSources" in result) return { ...frame, result: { ...result, instructionSources: [] } }; + return frame; }, realCommand: () => "codex", @@ -233,6 +248,7 @@ export const PROVIDERS: Record = { liveEnv: (): Record => { const home = join(tmpdir(), "devspace-agents-qa-copilot-home"); mkdirSync(home, { recursive: true }); + return { COPILOT_HOME: home }; }, realCommand: () => "copilot", @@ -251,8 +267,10 @@ function emptied(record: Record, keys: string[]): Record 0) { this.reportFailure(`Driver frame #${this.cursor} (${kind}) does not match the recording:`, differences, next.frame, actual); + return; } + this.bind(next.frame, actual); this.lastMs = next.ms; this.cursor += 1; @@ -81,8 +90,10 @@ export class Replayer { private queueProviderFrames(): void { const { frames } = this.options.transcript; const { protocol } = this.options; + for (;;) { const next = frames[this.cursor]; + if (next?.dir === "out") { this.pending.push(next); } else if (next && protocol.elasticKinds.has(frameKind(protocol, next.frame))) { @@ -90,8 +101,10 @@ export class Replayer { } else { break; } + this.cursor += 1; } + this.schedule(); } @@ -109,23 +122,29 @@ export class Replayer { private flush(): void { if (this.timer) clearTimeout(this.timer); this.timer = undefined; + for (const frame of this.pending.splice(0)) this.emitPending(frame); } private emitPending(entry: TranscriptFrame): void { this.lastMs = entry.ms; const answers = this.options.protocol.responseTo(asRecord(entry.frame)); + if (answers !== undefined && this.skipped.has(JSON.stringify(answers))) return; this.emit(entry.frame); } private emit(frame: unknown, extra?: Map): void { const { volatileKeys } = this.options.protocol; + const bound = rewriteVolatile(frame, volatileKeys, (value) => { const key = JSON.stringify(value); + if (extra?.has(key)) return extra.get(key); + return this.bindings.has(key) ? this.bindings.get(key) : value; }); + this.options.emit(restore(bound, this.options.context)); } @@ -133,21 +152,27 @@ export class Replayer { private answerElastic(kind: string, actual: unknown): void { const { frames } = this.options.transcript; const { protocol } = this.options; + const candidates = frames .map((entry, index) => ({ entry, index })) .filter(({ entry }) => entry.dir === "in" && frameKind(protocol, entry.frame) === kind) .sort((left, right) => Math.abs(left.index - this.cursor) - Math.abs(right.index - this.cursor)); + for (const { entry } of candidates) { const recordedId = protocol.requestId(asRecord(entry.frame)); + const response = frames.find((candidate) => ( candidate.dir === "out" && JSON.stringify(protocol.responseTo(asRecord(candidate.frame))) === JSON.stringify(recordedId) )); + if (!response) continue; const liveId = protocol.requestId(asRecord(actual)); this.emit(response.frame, new Map([[JSON.stringify(recordedId), liveId]])); + return; } + this.reportFailure(`Driver sent ${kind} but the recording has no response to replay for it:`, [], undefined, actual); } @@ -163,12 +188,14 @@ export class Replayer { private reportFailure(headline: string, differences: string[], expected: unknown, actual: unknown): void { this.failed = true; this.flush(); + const lines = [ headline, ...differences.map((line) => ` ${line}`), ...(expected === undefined ? [] : [` expected: ${JSON.stringify(expected)}`]), ` actual: ${JSON.stringify(actual)}`, ]; + this.options.fail(lines.join("\n")); } } @@ -180,29 +207,37 @@ function frameKind(protocol: ReplayProtocol, frame: unknown): string { /** Readable structural differences; volatile keys only need matching types. */ export function diff(expected: unknown, actual: unknown, volatileKeys: ReadonlySet, path = "$"): string[] { const key = path.slice(path.lastIndexOf(".") + 1); + if (volatileKeys.has(key)) { return typeOf(expected) === typeOf(actual) ? [] : [`${path}: expected a ${typeOf(expected)}, got ${JSON.stringify(actual)}`]; } + if (Array.isArray(expected) && Array.isArray(actual)) { const lines = expected.length === actual.length ? [] : [`${path}: expected ${expected.length} items, got ${actual.length}`]; + for (let index = 0; index < Math.min(expected.length, actual.length); index += 1) { lines.push(...diff(expected[index], actual[index], volatileKeys, `${path}[${index}]`)); } + return lines; } + if (isObject(expected) && isObject(actual)) { const lines: string[] = []; + for (const name of new Set([...Object.keys(expected), ...Object.keys(actual)])) { if (!(name in actual)) lines.push(`${path}.${name}: missing`); else if (!(name in expected)) lines.push(`${path}.${name}: unexpected ${JSON.stringify(actual[name])}`); else lines.push(...diff(expected[name], actual[name], volatileKeys, `${path}.${name}`)); } + return lines; } + return expected === actual ? [] : [`${path}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`]; @@ -221,7 +256,9 @@ function walkPairs(left: unknown, right: unknown, visit: (key: string, left: unk function rewriteVolatile(value: unknown, keys: ReadonlySet, rewrite: (value: unknown) => unknown): unknown { if (Array.isArray(value)) return value.map((entry) => rewriteVolatile(entry, keys, rewrite)); + if (!isObject(value)) return value; + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [ key, keys.has(key) && isPrimitive(entry) ? rewrite(entry) : rewriteVolatile(entry, keys, rewrite), diff --git a/test/agents-replay/shim.ts b/test/agents-replay/shim.ts index bbaa4c10c..bc6d2711a 100644 --- a/test/agents-replay/shim.ts +++ b/test/agents-replay/shim.ts @@ -35,12 +35,14 @@ const provider = providerDefinition(requiredEnv("PROVIDER")); const scenario = requiredEnv("SCENARIO"); const transcriptBase = requiredEnv("TRANSCRIPT"); const runDir = requiredEnv("RUN_DIR"); + const context: ScrubContext = { workspace: requiredEnv("WORKSPACE"), worktree: findWorktree(env[`${SHIM_ENV_PREFIX}WORKTREE_ROOT`]), home: homedir(), tmp: tmpdir(), }; + const args = process.argv.slice(2); const http = provider.http === true && serveArgs(args) !== undefined; @@ -58,13 +60,17 @@ function record(path: string): void { const command = provider.realCommand(); const append = startTranscript(path, command); const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith(SHIM_ENV_PREFIX))); + if (http) { void recordHttp({ command, args, env: childEnv, append, fail }); + return; } + const appendLine = (dir: "in" | "out", line: string) => { if (line.trim()) append(dir, parseFrame(line)); }; + const child = spawn(command, args, { env: childEnv, stdio: ["pipe", "pipe", "inherit"] }); child.on("error", (error) => fail(`Could not start ${command}: ${error.message}`)); // Bytes pass through unchanged; the transcript gets a parsed copy of each line. @@ -74,9 +80,11 @@ function record(path: string): void { child.stdout.on("data", (chunk) => process.stdout.write(chunk)); createInterface({ input: process.stdin }).on("line", (line) => appendLine("in", line)); createInterface({ input: child.stdout }).on("line", (line) => appendLine("out", line)); + for (const signal of ["SIGTERM", "SIGINT"] as const) { process.on(signal, () => child.kill(signal)); } + child.on("exit", (code, signal) => process.exit(code ?? (signal ? 128 : 1))); } @@ -85,6 +93,7 @@ function startTranscript(path: string, command: string): (dir: "in" | "out", fra mkdirSync(dirname(path), { recursive: true }); const startedAt = Date.now(); const version = spawnSync(command, ["--version"], { encoding: "utf8", timeout: 10_000 }).stdout?.trim(); + const meta: TranscriptMeta = { type: "meta", provider: provider.id, @@ -94,9 +103,12 @@ function startTranscript(path: string, command: string): (dir: "in" | "out", fra ...(version ? { version } : {}), recordedAt: new Date().toISOString(), }; + writeFileSync(path, `${JSON.stringify(meta)}\n`); + return (dir, parsed) => { const frame = dir === "out" && provider.redactFrame && isRecord(parsed) ? provider.redactFrame(parsed) : parsed; + if (frame === undefined) return; const entry = { dir, ms: Date.now() - startedAt, frame: scrub(frame, context) }; appendFileSync(path, `${JSON.stringify(entry)}\n`); @@ -108,6 +120,7 @@ function replay(path: string): void { const transcript = readTranscript(path); const recordedArgs = normalizeArgs(transcript.meta.args); const liveArgs = normalizeArgs(scrub(args, context) as string[]); + if (JSON.stringify(recordedArgs) !== JSON.stringify(liveArgs)) { fail([ `${provider.id} was launched with different arguments than recorded in ${path}:`, @@ -115,11 +128,15 @@ function replay(path: string): void { ` actual: ${JSON.stringify(liveArgs)}`, ].join("\n")); } + const liveCwd = scrub(process.cwd(), context) as string; + if (transcript.meta.cwd !== undefined && transcript.meta.cwd !== liveCwd) { fail(`${provider.id} was started in a different directory than recorded in ${path}:\n expected: ${transcript.meta.cwd}\n actual: ${liveCwd}`); } + let respond = (frame: unknown) => { process.stdout.write(`${JSON.stringify(frame)}\n`); }; + const replayer = new Replayer({ transcript, protocol: provider.protocol, @@ -130,6 +147,7 @@ function replay(path: string): void { }, fail: (report) => fail(`${path}\n${report}`), }); + if (http) { respond = replayHttp({ args, @@ -139,8 +157,10 @@ function replay(path: string): void { }, }).respond; replayer.start(); + return; } + createInterface({ input: process.stdin }) .on("line", (line) => { if (!line.trim()) return; @@ -165,8 +185,10 @@ function printVersion(): void { const result = spawnSync(provider.realCommand(), ["--version"], { stdio: "inherit" }); process.exit(result.status ?? 1); } + const path = `${transcriptBase}.jsonl`; const version = existsSync(path) ? readTranscript(path).meta.version : undefined; + if (!version) fail(`${path} has no recorded --version output.`); process.stdout.write(`${version}\n`); process.exit(0); @@ -175,6 +197,7 @@ function printVersion(): void { /** Each provider process in a scenario gets its own transcript, in spawn order. */ function claimTranscript(): string { mkdirSync(runDir, { recursive: true }); + for (let index = 1; ; index += 1) { try { closeSync(openSync(join(runDir, `spawn-${index}`), "wx")); @@ -182,6 +205,7 @@ function claimTranscript(): string { if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; throw error; } + return index === 1 ? `${transcriptBase}.jsonl` : `${transcriptBase}.${index}.jsonl`; } } @@ -189,6 +213,7 @@ function claimTranscript(): string { function findWorktree(root: string | undefined): string | undefined { if (!root || !existsSync(root)) return undefined; const [entry] = readdirSync(root); + return entry ? join(root, entry) : undefined; } @@ -203,16 +228,20 @@ function isRecord(value: unknown): value is Record { function requiredEnv(name: string): string { const value = env[`${SHIM_ENV_PREFIX}${name}`]; + if (!value) fail(`${SHIM_ENV_PREFIX}${name} is not set.`); + return value!; } function fail(message: string): never { process.stderr.write(`agents-replay: ${message}\n`); + try { appendFileSync(join(env[`${SHIM_ENV_PREFIX}RUN_DIR`] ?? tmpdir(), "replay-failures.log"), `${message}\n\n`); } catch { // The report file is best effort; stderr already has the message. } + process.exit(MISMATCH_EXIT_CODE); } diff --git a/test/agents-replay/transcript.ts b/test/agents-replay/transcript.ts index 06529aa3c..4f812c5d3 100644 --- a/test/agents-replay/transcript.ts +++ b/test/agents-replay/transcript.ts @@ -36,9 +36,11 @@ export interface Transcript { export function readTranscript(path: string): Transcript { const lines = readFileSync(path, "utf8").split("\n").filter((line) => line.trim()); const [meta, ...frames] = lines.map((line) => JSON.parse(line) as unknown); + if ((meta as TranscriptMeta | undefined)?.type !== "meta") { throw new Error(`${path} does not start with a transcript meta line.`); } + return { meta: meta as TranscriptMeta, frames: frames as TranscriptFrame[] }; } @@ -69,9 +71,11 @@ function placeholders(context: ScrubContext): Array<[string, string]> { export function scrub(value: unknown, context: ScrubContext): unknown { return mapStrings(value, (text) => { let next = text; + for (const [actual, placeholder] of placeholders(context)) { if (actual) next = next.replaceAll(actual, placeholder); } + return redactSecrets(next).replace(EMAIL, "user@example.invalid"); }); } @@ -79,17 +83,22 @@ export function scrub(value: unknown, context: ScrubContext): unknown { export function restore(value: unknown, context: ScrubContext): unknown { return mapStrings(value, (text) => { let next = text; + for (const [actual, placeholder] of placeholders(context)) next = next.replaceAll(placeholder, actual); + return next; }); } export function mapStrings(value: unknown, map: (text: string) => string): unknown { if (typeof value === "string") return map(value); + if (Array.isArray(value)) return value.map((entry) => mapStrings(entry, map)); + if (value && typeof value === "object") { return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, mapStrings(entry, map)])); } + return value; } diff --git a/test/package-install-smoke.test.ts b/test/package-install-smoke.test.ts index a38b5a9c8..d48efabee 100644 --- a/test/package-install-smoke.test.ts +++ b/test/package-install-smoke.test.ts @@ -16,6 +16,7 @@ test("the packed package installs working devspace and devspace-agentd launchers function testPackedPackageLaunchers(): void { const root = mkdtempSync(join(tmpdir(), "devspace-packed-bin-test-")); const installRoot = join(root, "install"); + try { mkdirSync(installRoot, { recursive: true }); execFileSync(npmExecutable(), ["pack", "--silent", "--pack-destination", root], { @@ -43,15 +44,18 @@ function testPackedPackageLaunchers(): void { }); const configRoot = join(root, "config"); + const env = writeTestDevspaceConfig(configRoot, { storage: { stateDir: join(root, "state") }, workspaces: { allowedRoots: [root], worktreeRoot: join(root, "worktrees") }, skills: { agentDir: join(root, "agents") }, }); + const cliOutput = execInstalledBin(installRoot, "devspace", ["config", "get"], { ...process.env, ...env, }); + const config = JSON.parse(cliOutput) as { configVersion?: number }; assert.equal(config.configVersion, 1); @@ -64,6 +68,7 @@ function testPackedPackageLaunchers(): void { // A fresh machine has no config, auth, or OAuth setup; agent commands must still work. const home = join(root, "home"); + const freshEnv = { ...process.env, HOME: home, @@ -71,9 +76,11 @@ function testPackedPackageLaunchers(): void { DEVSPACE_CONFIG_DIR: join(root, "fresh-config"), DEVSPACE_OAUTH_OWNER_TOKEN: "", }; + const targets = JSON.parse( execInstalledBin(installRoot, "devspace", ["agents", "targets", "--json"], freshEnv), ) as { enabled: boolean; targets: Array<{ name: string }> }; + assert.equal(targets.enabled, true); assert.ok(targets.targets.some((target) => target.name === "claude")); assert.match( @@ -103,6 +110,7 @@ function execInstalledBin( ".bin", process.platform === "win32" ? `${name}.cmd` : name, ); + return execFileSync(executable, args, { encoding: "utf8", env, diff --git a/website/islands/DelegationDiagram.tsx b/website/islands/DelegationDiagram.tsx index 130ac890d..7c83dc64c 100644 --- a/website/islands/DelegationDiagram.tsx +++ b/website/islands/DelegationDiagram.tsx @@ -26,6 +26,7 @@ export default function DelegationDiagram() { useEffect(() => { if (reduced) return; const timer = setInterval(() => setTick((t) => t + 1), TICK_MS); + return () => clearInterval(timer); }, [reduced]); @@ -40,6 +41,7 @@ export default function DelegationDiagram() { // each row keeps its DOM node and slides as `step` advances. const hostRows = [-2, -1, 0, 1, 2].map((offset) => { const idx = step + offset; + return { idx, offset, host: HOSTS[wrap(idx, HOSTS.length)]! }; }); @@ -47,6 +49,7 @@ export default function DelegationDiagram() { if (k < 2 + i) return []; const id = `agt_${AGENT_IDS[(step * 2 + i) % AGENT_IDS.length]}`; const done = phase === "completed"; + return [ { kind: "cmd" as const, text: `devspace agents run ${PROVIDERS[t]!.id} "…"` }, { kind: "out" as const, text: ``, done }, @@ -89,6 +92,7 @@ export default function DelegationDiagram() { {PROVIDERS.map((p, i) => { const active = routed && targets.includes(i); const status: Phase = active ? phase : "idle"; + return (
  • diff --git a/website/islands/Fit.tsx b/website/islands/Fit.tsx index 8f4f06675..4a0edea42 100644 --- a/website/islands/Fit.tsx +++ b/website/islands/Fit.tsx @@ -14,10 +14,12 @@ export default function Fit({ width, children }: { width: number; children: Reac setScale(Math.min(1, outer.current!.clientWidth / width)); setHeight(inner.current!.offsetHeight); }; + measure(); const observer = new ResizeObserver(measure); observer.observe(outer.current!); observer.observe(inner.current!); + return () => observer.disconnect(); }, [width]); diff --git a/website/islands/WorkflowRun.tsx b/website/islands/WorkflowRun.tsx index 620214ab2..14de28d43 100644 --- a/website/islands/WorkflowRun.tsx +++ b/website/islands/WorkflowRun.tsx @@ -6,6 +6,7 @@ type Call = { label: string; target: string; phase: string; start: number; end: // An illustrative `ship` workflow run on a shared clock (ticks). Parallel calls // overlap in time, which is the point of the illustration. const PHASES = ["Plan", "Build", "Verify"]; + const CALLS: Call[] = [ { label: "plan", target: "codex · xhigh", phase: "Plan", start: 0, end: 5 }, { label: "implement", target: "claude-implementer", phase: "Build", start: 5, end: 13 }, @@ -13,6 +14,7 @@ const CALLS: Call[] = [ { label: "qa", target: "codex-qa-tester", phase: "Verify", start: 13, end: 20 }, { label: "summary", target: "pi-reviewer", phase: "Verify", start: 20, end: 23 }, ]; + const LAST_TICK = 23; const TICK_MS = 420; @@ -23,12 +25,15 @@ export default function WorkflowRun() { // Reduced motion shows the finished run instead of animating it. if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setTick(LAST_TICK); + return; } + const timer = setTimeout( () => setTick((t) => (t > LAST_TICK + 6 ? 0 : t + 1)), TICK_MS, ); + return () => clearTimeout(timer); }, [tick]); @@ -49,6 +54,7 @@ export default function WorkflowRun() { {PHASES.map((phase) => { const calls = CALLS.filter((call) => call.phase === phase); const done = calls.filter((call) => call.end <= tick).length; + return (
    {phase} @@ -61,6 +67,7 @@ export default function WorkflowRun() { {started.map((call, index) => { const progress = Math.min(1, (tick - call.start) / (call.end - call.start)); const callStatus = progress >= 1 ? "completed" : "running"; + return (
    {index} diff --git a/website/landing/Icon.astro b/website/landing/Icon.astro index 2624b33d0..995eed63c 100644 --- a/website/landing/Icon.astro +++ b/website/landing/Icon.astro @@ -9,6 +9,7 @@ const PATHS = { } as const; interface Props { name: keyof typeof PATHS; size?: number } + const { name, size = 18 } = Astro.props; --- diff --git a/website/landing/Shell.astro b/website/landing/Shell.astro index 8bbb111f9..25b897192 100644 --- a/website/landing/Shell.astro +++ b/website/landing/Shell.astro @@ -35,14 +35,18 @@ const { // Any element with data-copy copies its value and flags data-copied briefly. document.addEventListener("click", async (event) => { const target = (event.target as HTMLElement).closest("[data-copy]"); + if (!target) return; + // Only claim success when the clipboard write actually happened. if (!navigator.clipboard) return; + try { await navigator.clipboard.writeText(target.dataset.copy ?? ""); } catch { return; } + target.dataset.copied = ""; setTimeout(() => delete target.dataset.copied, 1400); }); diff --git a/website/landing/content.ts b/website/landing/content.ts index f350a5f31..23a47d594 100644 --- a/website/landing/content.ts +++ b/website/landing/content.ts @@ -7,6 +7,7 @@ export const NPM = "https://www.npmjs.com/package/@waishnav/devspace"; export const DOCS = "/docs"; import type { LogoId } from "../logos"; + export { HOSTS, PROVIDERS } from "../logos"; // The model-per-task matrix people already maintain by hand. diff --git a/website/landing/home/ChatMock.astro b/website/landing/home/ChatMock.astro index 1a1064196..c9e373e85 100644 --- a/website/landing/home/ChatMock.astro +++ b/website/landing/home/ChatMock.astro @@ -76,6 +76,7 @@ import Logo from "../../logos/Logo.astro"; // Types the next prompt once the rest of the session has played. Restarts // whenever the tab is shown again. const composer = document.querySelector("[data-composer]"); + if (composer) { const mention = composer.querySelector("[data-mention]")!; const rest = composer.querySelector("[data-rest]")!; @@ -86,22 +87,30 @@ import Logo from "../../logos/Logo.astro"; const clear = () => { timers.forEach(clearTimeout); timers = []; }; const reset = () => { mention.textContent = ""; rest.textContent = ""; }; + const play = () => { clear(); reset(); - if (reduced) { mention.textContent = full[0]!; rest.textContent = full[1]!; return; } + + if (reduced) { mention.textContent = full[0]!; rest.textContent = full[1]!; + + return; } + let delay = 4400; full.forEach((chunk, part) => { const target = part === 0 ? mention : rest; + for (let i = 1; i <= chunk.length; i++) { delay += 40 + Math.random() * 45; timers.push(window.setTimeout(() => (target.textContent = chunk.slice(0, i)), delay)); } + delay += 250; }); }; play(); + if (panel) { new MutationObserver(() => { if (panel.classList.contains("is-active")) play(); else { clear(); reset(); } }) .observe(panel, { attributes: true, attributeFilter: ["class"] }); diff --git a/website/landing/home/HeroStage.astro b/website/landing/home/HeroStage.astro index 2246e5078..9a22b4271 100644 --- a/website/landing/home/HeroStage.astro +++ b/website/landing/home/HeroStage.astro @@ -32,20 +32,24 @@ const tabs = [ diff --git a/website/landing/home/PixelField.astro b/website/landing/home/PixelField.astro index a4e108b30..8379b1e2a 100644 --- a/website/landing/home/PixelField.astro +++ b/website/landing/home/PixelField.astro @@ -2,12 +2,14 @@ // A dithered, pixel-mosaic landscape rendered as crisp SVG at build time. // Deterministic so every build produces the same picture. interface Props { cols?: number; rows?: number } + const { cols = 64, rows = 30 } = Astro.props; let seed = 7; const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); type RGB = [number, number, number]; + const stops: Array<[number, RGB]> = [ [0.0, [86, 128, 196]], [0.3, [128, 162, 214]], @@ -17,13 +19,17 @@ const stops: Array<[number, RGB]> = [ [0.7, [120, 146, 86]], [1.0, [70, 104, 58]], ]; + const mix = (a: RGB, b: RGB, t: number): RGB => [0, 1, 2].map((i) => a[i]! + (b[i]! - a[i]!) * t) as RGB; + const colorAt = (y: number): RGB => { for (let i = 1; i < stops.length; i++) { const [p1, c1] = stops[i]!; const [p0, c0] = stops[i - 1]!; + if (y <= p1) return mix(c0, c1, (y - p0) / (p1 - p0)); } + return stops.at(-1)![1]; }; @@ -33,6 +39,7 @@ const notched = (x: number, y: number) => (y > rows - 4 && x < 3) || (y > rows - 2 && x < 6); const cells: Array<{ x: number; y: number; fill: string }> = []; + for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { if (notched(x, y)) continue; diff --git a/website/landing/home/RoutingMap.astro b/website/landing/home/RoutingMap.astro index 2c7b85186..8d6dab14f 100644 --- a/website/landing/home/RoutingMap.astro +++ b/website/landing/home/RoutingMap.astro @@ -8,6 +8,7 @@ const H = 420; const hostY = HOSTS.map((_, i) => (H / HOSTS.length) * (i + 0.5)); const provY = PROVIDERS.map((_, i) => (H / PROVIDERS.length) * (i + 0.5)); const mid = H / 2; + const curve = (x1: number, y1: number, x2: number, y2: number) => `M${x1} ${y1} C ${x1 + (x2 - x1) * 0.55} ${y1}, ${x2 - (x2 - x1) * 0.55} ${y2}, ${x2} ${y2}`; --- @@ -46,12 +47,15 @@ const curve = (x1: number, y1: number, x2: number, y2: number) => diff --git a/website/landing/home/WorkflowStage.astro b/website/landing/home/WorkflowStage.astro index 61c55995a..1a503068d 100644 --- a/website/landing/home/WorkflowStage.astro +++ b/website/landing/home/WorkflowStage.astro @@ -8,12 +8,14 @@ import { workbenchTheme } from "./theme"; // Illustrative timings (ticks). Review and QA overlap: that is the parallel(). const byTask = Object.fromEntries(MATRIX.map((m) => [m.task, m])); + const CALLS = [ { label: "plan", phase: "Plan", start: 0, end: 5, ...byTask.Plan! }, { label: "implement", phase: "Build", start: 5, end: 13, ...byTask.Implement! }, { label: "review", phase: "Verify", start: 13, end: 18, ...byTask.Review! }, { label: "qa", phase: "Verify", start: 13, end: 20, ...byTask.QA! }, ]; + const PHASES = ["Plan", "Build", "Verify"]; // What the inspector would show for the running call in each phase. @@ -26,10 +28,14 @@ const ACTIVITY: Array<{ phase: string; lines: Array<[string, string]> }> = [ // Which phase each source line belongs to (the meta block belongs to none). let current: string | null = null; + const linePhases = WORKFLOW_SNIPPET.split("\n").map((line) => { const match = /\bphase\("(\w+)"/.exec(line); + if (match) current = match[1]!; + if (line.startsWith("return")) current = null; + return current ?? ""; }); --- @@ -81,6 +87,7 @@ const linePhases = WORKFLOW_SNIPPET.split("\n").map((line) => {