diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17639e87c..f830caac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,9 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Lint + run: pnpm lint + - name: Test env: DEVSPACE_REQUIRE_PI_SANDBOX: ${{ matrix.os == 'ubuntu-latest' && '1' || '0' }} 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..59e0b04db 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."); } @@ -39,7 +40,6 @@ export async function seedDevState({ reset = false }: { reset?: boolean } = {}): const stagingRoot = `${devRoot}.staging-${process.pid}-${Date.now()}`; const stagingConfigDir = join(stagingRoot, "config"); const stagingStateDir = join(stagingRoot, "state"); - const devConfigDir = join(devRoot, "config"); const devStateDir = join(devRoot, "state"); try { @@ -53,10 +53,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 +69,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 +90,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 +118,25 @@ 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)) { + // SAFETY: The surrounding checks or construction already match this type. 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..9d42e2494 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 @@ -37,6 +39,7 @@ const palettes: Record> = { export function hostStyles(theme: McpUiTheme): McpUiHostStyles { return { + // SAFETY: The surrounding checks or construction already match this type. variables: { ...palettes[theme], "--font-sans": "ui-sans-serif, system-ui, sans-serif", diff --git a/scripts/qa/host/main.ts b/scripts/qa/host/main.ts index ed6b9238a..b4e0978ac 100644 --- a/scripts/qa/host/main.ts +++ b/scripts/qa/host/main.ts @@ -1,3 +1,5 @@ +import { optionalFields } from "../../../src/json-value.js"; +import type { WireInput } from "../../../src/json-value.js"; import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { QaCardCall, QaRun, QaStep } from "../transcript.js"; @@ -20,20 +22,29 @@ 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: [] }; +// SAFETY: The surrounding checks or construction already match this type. 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 +70,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 +94,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" }, @@ -89,19 +102,19 @@ async function mountCard( { hostContext: { theme, - ...(bareHost ? {} : { styles: hostStyles(theme) }), + ...optionalFields(bareHost ? undefined : { styles: hostStyles(theme) }), displayMode: "inline", availableDisplayModes: ["inline"], platform: "web", locale: "en-US", timeZone: "UTC", containerDimensions: { maxHeight: 2000 }, - ...(tool ? { toolInfo: { tool } } : {}), + ...optionalFields(tool ? { toolInfo: { tool } } : undefined), }, }, ); - const hostEvent = (kind: string, detail: unknown) => { + const hostEvent = (kind: string, detail: WireInput) => { window.__devspaceQa.hostEvents.push({ step: step.index, kind, detail }); callLog.append(element("li", {}, `card → ${kind}: ${summarize(detail)}`)); }; @@ -112,36 +125,52 @@ async function mountCard( headers: { "content-type": "application/json" }, body: JSON.stringify({ step: step.index, name: params.name, arguments: params.arguments, _meta: params._meta }), }); + + // SAFETY: The surrounding checks or construction already match this type. 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,18 +178,21 @@ 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 }) .then(() => bridge.sendToolResult(result)) .then(markReady) - .catch((error: unknown) => { + .catch((error: WireInput) => { status.state = "error"; status.error = error instanceof Error ? error.message : String(error); finish(); @@ -181,6 +213,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) { @@ -197,8 +230,9 @@ function details(label: string, text: string): HTMLElement { return element("details", {}, element("summary", {}, label), element("pre", {}, text)); } -function summarize(value: unknown): string { +function summarize(value: WireInput): string { const text = JSON.stringify(value); + return text.length > 160 ? `${text.slice(0, 157)}...` : text; } @@ -209,5 +243,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..71c82601e 100644 --- a/scripts/qa/run.ts +++ b/scripts/qa/run.ts @@ -1,3 +1,5 @@ +import { isJsonString, isObjectValue } from "../../src/json-value.js"; +import type { JsonObject, WireInput } from "../../src/json-value.js"; import { createHash, randomBytes } from "node:crypto"; import { once } from "node:events"; import { createWriteStream } from "node:fs"; @@ -22,6 +24,7 @@ import { import type { QaCardCall, QaRun, QaStep } from "./transcript.js"; // cross-spawn resolves pnpm and agent-browser shims on Windows. +// SAFETY: The surrounding checks or construction already match this type. const spawn = createRequire(import.meta.url)("cross-spawn") as typeof import("node:child_process").spawn; const harnessRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); const qaStateRoot = join(harnessRoot, ".devspace-dev", "qa"); @@ -50,37 +53,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 +104,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 +125,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 +134,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 +152,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 +161,13 @@ 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}` }; + const headers: Record = {}; + headers.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 +176,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 +187,7 @@ async function runScenario( resources: {}, cardCalls: [], }; + await replaySteps(client, scenario, qaRun); const transcriptPath = join(runDir, "transcript.json"); @@ -176,6 +198,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,15 +206,20 @@ 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 }; + const variables: Record = {}; + variables.fixture = qaRun.fixture; + for (const [index, step] of scenario.steps.entries()) { - let args: Record; + let args: JsonObject; + try { - args = resolveTemplates(step.arguments, variables) as Record; + // SAFETY: The surrounding checks or construction already match this type. + args = resolveTemplates(step.arguments, variables) as JsonObject; } catch (caught) { // Later steps depend on this one, so stop here but keep the evidence gathered so far. qaRun.steps.push({ @@ -204,35 +232,43 @@ 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 { + // SAFETY: The surrounding checks or construction already match this type. 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) : ""; + // SAFETY: The surrounding checks or construction already match this type. const workspaceId = (result?.structuredContent as { workspace_id?: unknown } | undefined)?.workspace_id; - variables.workspace_id = typeof workspaceId === "string" + variables.workspace_id = isJsonString(workspaceId) ? workspaceId : text.match(WORKSPACE_ID_PATTERN)?.[0] ?? variables.workspace_id; 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 }); @@ -240,12 +276,14 @@ async function replaySteps(client: Client, scenario: Scenario, qaRun: QaRun): Pr qaRun.resources[resourceUri] = { mimeType: content?.mimeType, html: content && "text" in content ? content.text : "", - meta: (content?._meta ?? {}) as Record, + // SAFETY: The surrounding checks or construction already match this type. + meta: (content?._meta ?? {}) as JsonObject, }; } catch (caught) { 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,14 +297,17 @@ async function replaySteps(client: Client, scenario: Scenario, qaRun: QaRun): Pr resourceUri: result && !result.isError ? cardResource : undefined, failures, }; + qaRun.steps.push(qaStep); } } function uiResourceUri(tool: Tool | undefined): string | undefined { + // SAFETY: The surrounding checks or construction already match this type. 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; + + return isJsonString(uri) ? uri : undefined; } function resultText(result: CallToolResult): string { @@ -279,15 +320,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 +339,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 +404,11 @@ 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()}`); + // SAFETY: The surrounding checks or construction already match this type. 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 +426,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 +444,13 @@ async function issueAccessToken(baseUrl: string, ownerToken: string, clientName: resource, }), }); + if (!exchange.ok) throw new Error(`OAuth token exchange failed: ${await exchange.text()}`); + // SAFETY: The surrounding checks or construction already match this type. 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 +484,70 @@ 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; } + + // SAFETY: The surrounding checks or construction already match this type. const body = JSON.parse(await readBody(req)) as { step: number; name: string; - arguments?: Record; - _meta?: Record; + arguments?: JsonObject; + _meta?: JsonObject; }; + const cardCall: QaCardCall = { step: body.step, name: body.name, arguments: body.arguments ?? {} }; + try { + // SAFETY: The surrounding checks or construction already match this type. 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 +557,20 @@ 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; } + + // SAFETY: The surrounding checks or construction already match this type. 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 +582,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 +602,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,12 +637,14 @@ 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. await browser("set", "viewport", "1280", String(RENDER_VIEWPORT_HEIGHT)); await browser("open", hostUrl); await browser("wait", "--fn", "window.__devspaceQa?.settled === true", "--timeout", "45000"); + // SAFETY: The surrounding checks or construction already match this type. const cards = JSON.parse(JSON.parse(await browser("eval", "JSON.stringify(window.__devspaceQa.cards)"))) as BrowserEvidence["cards"]; // Shrink to the page so screenshots have no trailing blank space; every card @@ -568,6 +657,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,9 +665,11 @@ 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); + // SAFETY: The surrounding checks or construction already match this type. const { data } = JSON.parse(await browser("errors", "--json")) as { data?: { errors?: unknown[] } }; const pageErrors = data?.errors ?? []; const errorsLog = JSON.stringify(pageErrors, null, 2); @@ -587,10 +679,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,43 +719,52 @@ 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 { +function deepMerge(base: JsonObject, override: JsonObject): JsonObject { 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; } -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); +function isPlainObject(value: WireInput): value is JsonObject { + return Boolean(value) && isObjectValue(value) && !Array.isArray(value); } async function freePort(): Promise { @@ -669,24 +773,29 @@ 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."); + + if (!address || isJsonString(address)) 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())); } async function runCleanups(): Promise { while (cleanups.length > 0) { - await cleanups.pop()?.().catch((error: unknown) => console.warn("QA cleanup failed:", error)); + await cleanups.pop()?.().catch((error: WireInput) => console.warn("QA cleanup failed:", error)); } } diff --git a/scripts/qa/scenario.ts b/scripts/qa/scenario.ts index e5edc4f03..395eaab94 100644 --- a/scripts/qa/scenario.ts +++ b/scripts/qa/scenario.ts @@ -1,3 +1,5 @@ +import { isJsonString, isObjectValue } from "../../src/json-value.js"; +import type { JsonObject, WireInput } from "../../src/json-value.js"; import { readFile, readdir } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -11,7 +13,7 @@ export interface Scenario { name: string; description: string; /** Deep-merged into the generated config.jsonc, e.g. { "tools": { "mode": "claude" } }. */ - config?: Record; + config?: JsonObject; env?: Record; /** How the simulated MCP host identifies itself to DevSpace. */ host?: { @@ -29,7 +31,7 @@ export interface Scenario { export interface ScenarioStep { tool: string; - arguments: Record; + arguments: JsonObject; /** Short label shown in the host page and report. */ label?: string; expect?: { @@ -43,6 +45,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,23 +56,31 @@ export async function loadScenario(nameOrPath: string): Promise { const path = nameOrPath.endsWith(".json") ? nameOrPath : join(scenariosDir.pathname, `${nameOrPath}.json`); + + // SAFETY: The surrounding checks or construction already match this type. const scenario = JSON.parse(await readFile(path, "utf8")) as Omit & { name?: string }; + return { ...scenario, name: scenario.name ?? basename(path, ".json") }; } -export function resolveTemplates(value: unknown, variables: Record): unknown { - if (typeof value === "string") { +export function resolveTemplates(value: WireInput, variables: Record): WireInput { + if (isJsonString(value)) { return value.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { 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") { + + if (value && isObjectValue(value)) { return Object.fromEntries( Object.entries(value).map(([key, item]) => [key, resolveTemplates(item, variables)]), ); } + return value; } diff --git a/scripts/qa/transcript.ts b/scripts/qa/transcript.ts index f0df6e2e2..fc639f309 100644 --- a/scripts/qa/transcript.ts +++ b/scripts/qa/transcript.ts @@ -1,3 +1,4 @@ +import type { JsonObject } from "../../src/json-value.js"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; /** Everything one scenario replay produced; written to transcript.json and served to the QA host. */ @@ -17,7 +18,7 @@ export interface QaStep { index: number; label?: string; tool: string; - arguments: Record; + arguments: JsonObject; result?: CallToolResult; error?: string; durationMs: number; @@ -29,13 +30,13 @@ export interface QaStep { export interface QaResource { mimeType?: string; html: string; - meta: Record; + meta: JsonObject; } export interface QaCardCall { step: number; name: string; - arguments: Record; + arguments: JsonObject; result?: CallToolResult; error?: string; } 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..2f00a9078 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) { + // SAFETY: The surrounding checks or construction already match this type. const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw error; const parent = dirname(existing); + if (parent === existing) throw error; existing = parent; } @@ -221,12 +250,14 @@ async function resolveConfinedPath(root: string, input: string): Promise return target; } -function splitFile(content: string): { lines: string[]; eol: string; finalNewline: boolean } { +function splitFile(content: string) { const eol = content.includes("\r\n") ? "\r\n" : "\n"; 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 +274,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 +291,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 +321,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 +343,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 +366,7 @@ export async function isSamePatchFile( readIdentity: FileIdentityReader = lstat, ): Promise { if (source === destination) return true; + if (source.toLowerCase() !== destination.toLowerCase()) return false; try { @@ -332,9 +374,12 @@ export async function isSamePatchFile( readIdentity(source), readIdentity(destination), ]); + return sourceIdentity.dev === destinationIdentity.dev && sourceIdentity.ino === destinationIdentity.ino; } catch (error) { + // SAFETY: The surrounding checks or construction already match this type. const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return false; throw error; } @@ -350,12 +395,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 +428,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 +507,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 +530,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 } { +function countPatchStats(patch: string) { 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..c315fec32 100644 --- a/src/artifact-destination-darwin.ts +++ b/src/artifact-destination-darwin.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import { close, constants as fsConstants, @@ -28,6 +29,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 +38,7 @@ export async function prepareDarwinArtifactDestinationDirectory( } let parentFd = rootFd; + try { for (const part of parentParts) { if ( @@ -49,17 +52,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 +78,47 @@ 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) { + // SAFETY: The surrounding checks or construction already match this type. 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 +127,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 +136,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 +147,7 @@ export async function prepareDarwinArtifactDestinationDirectory( for (const fd of directoryFds.reverse()) { await closeFd(fd).catch(() => undefined); } + await closeFd(rootFd).catch(() => undefined); throw error; } @@ -143,9 +161,9 @@ interface DarwinLibc { linkat(oldFd: number, oldPath: string, newFd: number, newPath: string, flags: number): number; unlinkat(fd: number, path: string, flags: number): number; dup(fd: number): number; - fdopendir(fd: number): unknown; - readdir(dir: unknown): unknown; - closedir(dir: unknown): number; + fdopendir(fd: number): WireInput; + readdir(dir: WireInput): WireInput; + closedir(dir: WireInput): number; DIRENT: ReturnType; } @@ -153,6 +171,7 @@ let cachedDarwinLibc: DarwinLibc | undefined; function darwinLibc(): DarwinLibc { cachedDarwinLibc ??= createDarwinLibc(); + return cachedDarwinLibc; } @@ -162,6 +181,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,8 +190,10 @@ function createDarwinLibc(): DarwinLibc { d_type: "uint8_t", d_name: koffi.array("char", 1024, "String"), }); + const DIRENT_PTR = koffi.pointer(DIRENT); + // SAFETY: The surrounding checks or construction already match this type. return { open(path, flags, mode) { return mode === undefined ? open(path, flags) : open(path, flags, "int", mode); @@ -199,10 +221,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 +241,7 @@ async function listDirectoryEntries( libc: DarwinLibc, ): Promise { const duplicateFd = libc.dup(parentFd); + if (duplicateFd < 0) { throw new ArtifactError( "artifact_directory_unsafe", @@ -225,6 +250,7 @@ async function listDirectoryEntries( } const directory = libc.fdopendir(duplicateFd); + if (directory === null) { await closeFd(duplicateFd).catch(() => undefined); throw new ArtifactError( @@ -235,21 +261,28 @@ 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; } + + // SAFETY: The surrounding checks or construction already match this type. 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 +292,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 +305,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..b1e3e3a90 100644 --- a/src/artifact-destination-windows.ts +++ b/src/artifact-destination-windows.ts @@ -1,3 +1,5 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { WireInput } from "./json-value.js"; import { constants as fsConstants } from "node:fs"; import { mkdir, open, type FileHandle } from "node:fs/promises"; import { join, toNamespacedPath } from "node:path"; @@ -12,19 +14,22 @@ export async function prepareWindowsArtifactDestinationDirectory( workspaceRoot: string, parentParts: readonly string[], ): Promise { - const pinnedHandles: unknown[] = []; + const pinnedHandles: WireInput[] = []; let directoryHandle: FileHandle | undefined; let parentPath = workspaceRoot; 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 +37,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", @@ -62,14 +68,14 @@ interface WindowsApi { disposition: number, flags: number, templateFile: null, - ): unknown; + ): WireInput; GetFileInformationByHandleEx( - handle: unknown, + handle: WireInput, infoClass: number, info: WindowsAttributeInfo, size: number, ): number; - CloseHandle(handle: unknown): number; + CloseHandle(handle: WireInput): number; HANDLE: ReturnType; FILE_ATTRIBUTE_TAG_INFO: ReturnType; } @@ -83,32 +89,35 @@ 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( + CreateFileW: unsafeNarrow(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)", - ) as unknown as WindowsApi["CreateFileW"], - GetFileInformationByHandleEx: kernel32.func( + )), + GetFileInformationByHandleEx: unsafeNarrow(kernel32.func( "int __stdcall GetFileInformationByHandleEx(DevSpaceArtifactWindowsHandle handle, int infoClass, _Out_ DevSpaceArtifactFileAttributeTagInfo *info, uint32_t size)", - ) as unknown as WindowsApi["GetFileInformationByHandleEx"], - CloseHandle: kernel32.func( + )), + CloseHandle: unsafeNarrow(kernel32.func( "int __stdcall CloseHandle(DevSpaceArtifactWindowsHandle handle)", - ) as unknown as WindowsApi["CloseHandle"], + )), HANDLE, FILE_ATTRIBUTE_TAG_INFO, }; } -function pinWindowsDirectory(path: string, code: string): unknown { +function pinWindowsDirectory(path: string, code: string): WireInput { const api = windowsApi(); const FILE_LIST_DIRECTORY = 0x0001; const FILE_TRAVERSE = 0x0020; @@ -134,19 +143,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 +172,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 { +function closeWindowsHandles(handles: WireInput[]): 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..cb632c472 100644 --- a/src/artifact-download.test.ts +++ b/src/artifact-download.test.ts @@ -1,3 +1,5 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { chmod, @@ -32,55 +34,71 @@ 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 registered = new Map WireInput }>(); + const server = { registerTool( name: string, - descriptor: Record, - callback: (input: never) => unknown, + descriptor: JsonObject, + callback: (input: never) => WireInput, ) { registered.set(name, { descriptor, callback }); + return {}; }, }; + // SAFETY: The surrounding checks or construction already match this type. registerArtifactTools(server as never, { + // SAFETY: The surrounding checks or construction already match this type. config: { artifactMaxFileBytes: 1024, logging: { toolCalls: false }, } as never, + // SAFETY: The surrounding checks or construction already match this type. workspaces: {} as never, }); @@ -88,25 +106,33 @@ function testOneToolContract(): void { const descriptor = registered.get("download_artifact")?.descriptor; assert.ok(descriptor); assert.deepEqual(descriptor._meta, { "openai/fileParams": ["file"] }); + // SAFETY: The surrounding checks or construction already match this type. assert.deepEqual(Object.keys(descriptor.inputSchema as object).sort(), ["file", "path", "workspace_id"]); + // SAFETY: The surrounding checks or construction already match this type. assert.deepEqual(Object.keys(descriptor.outputSchema as object), ["path"]); + // SAFETY: The surrounding checks or construction already match this type. assert.equal((descriptor.annotations as { destructiveHint?: boolean }).destructiveHint, false); - const fileSchema = (descriptor.inputSchema as z.ZodRawShape).file as z.ZodType; + assert.ok(descriptor.inputSchema); + const fileSchema = unsafeNarrow<{ file: z.ZodType }>(descriptor.inputSchema).file; + 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 +182,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 +197,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 +224,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 +236,7 @@ async function testDestinationValidation(testRoot: string): Promise { "folder/trailing-space ", ); } + for (const path of invalidPaths) { await expectArtifactError( downloadIncomingArtifact({ @@ -281,6 +311,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 +389,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 +409,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 +428,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 +445,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,12 +467,13 @@ function registryFor(source: { return source; }, }; + return new IncomingArtifactAdapterRegistry([adapter]); } -async function expectArtifactError(promise: Promise, code: string): Promise { +async function expectArtifactError(promise: Promise, code: string): Promise { await assert.rejects( promise, - (error: unknown) => error instanceof ArtifactError && error.code === code, + (error: WireInput) => error instanceof ArtifactError && error.code === code, ); } diff --git a/src/artifact-path-windows.ts b/src/artifact-path-windows.ts index c71dc4876..6e7d325e5 100644 --- a/src/artifact-path-windows.ts +++ b/src/artifact-path-windows.ts @@ -1,5 +1,15 @@ import { win32 } from "node:path"; +function hasAsciiControlCharacters(value: string): boolean { + for (const character of value) { + const code = character.charCodeAt(0); + + if (code <= 0x1f || code === 0x7f) return true; + } + + return false; +} + const WINDOWS_INVALID_NAME_CHARS = /[<>"|?*]/u; const WINDOWS_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|CLOCK\$|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/iu; @@ -14,7 +24,7 @@ export function parseSafeWindowsArtifactRelativePath( ): SafeWindowsArtifactPath | undefined { if ( !value - || /[\u0000-\u001f\u007f]/u.test(value) + || hasAsciiControlCharacters(value) || /^[\\/]/u.test(value) || /^[A-Za-z]:/u.test(value) || win32.isAbsolute(value) @@ -24,18 +34,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..ac3d4d279 100644 --- a/src/artifact-tools.ts +++ b/src/artifact-tools.ts @@ -1,3 +1,5 @@ +import { isObjectValue, isJsonString } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import { createHash, randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { @@ -32,6 +34,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 +118,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 +127,7 @@ export function registerArtifactTools( file: input.file, path: input.path, }); + return { publicResult: { path: downloaded.path }, logResult: downloaded, @@ -151,7 +156,7 @@ export async function downloadIncomingArtifact({ workspaceId: string; workspaceRoot: string; maxFileBytes: number; - file: unknown; + file: WireInput; path: string; publishEntry?: typeof defaultPublishEntry; }): Promise { @@ -161,12 +166,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 +209,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 +234,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,17 +268,18 @@ 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); } } export function artifactToolLogFields( - input: Record, -): Record { + input: JsonObject, +): JsonObject { return { fileProvided: input.file !== undefined, - fileReferenceShape: describeIncomingArtifactValue(input.file), + "fileReferenceShape": describeIncomingArtifactValue(input.file), downloadUrlHostname: incomingFileDownloadHostname(input.file), workspaceId: input.workspace_id, path: input.path, @@ -276,15 +288,17 @@ export function artifactToolLogFields( async function executeArtifactTool( config: ServerConfig, - input: Record, + input: JsonObject, operation: () => Promise<{ publicResult: { path: string }; logResult: DownloadIncomingArtifactResult; }>, ) { 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 +310,7 @@ async function executeArtifactTool( durationMs: Math.round(performance.now() - startedAt), }); } + return artifactToolResponse(publicResult); } catch (error) { if (config.logging.toolCalls) { @@ -307,6 +322,7 @@ async function executeArtifactTool( durationMs: Math.round(performance.now() - startedAt), }); } + throw error; } } @@ -324,12 +340,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 +356,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 +381,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 +415,7 @@ function normalizeArtifactDestination(value: string): ArtifactDestination { } const rawParts = value.split(sep); + if ( !value || value.includes("\u0000") @@ -403,6 +430,7 @@ function normalizeArtifactDestination(value: string): ArtifactDestination { } const normalized = normalize(value); + if ( normalized === "." || normalized === ".." @@ -416,6 +444,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 +468,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 +480,7 @@ async function prepareLinuxArtifactDestinationDirectory( parentAnchor, part, ); + openedHandles.push(child); parentHandle = child; parentAnchor = descriptorDirectoryPath(child); @@ -461,6 +492,7 @@ async function prepareLinuxArtifactDestinationDirectory( for (const handle of openedHandles.reverse()) { await handle.close().catch(() => undefined); } + await rootHandle.close().catch(() => undefined); }, ); @@ -468,6 +500,7 @@ async function prepareLinuxArtifactDestinationDirectory( for (const handle of openedHandles.reverse()) { await handle.close().catch(() => undefined); } + await rootHandle.close().catch(() => undefined); throw error; } @@ -480,6 +513,7 @@ async function ensureWorkspaceChildDirectory( ): Promise { await assertDirectoryHandle(parentHandle); const path = join(parentAnchor, name); + try { await mkdir(path, { mode: 0o755 }); } catch (error) { @@ -514,6 +548,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 +570,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 +581,7 @@ async function cleanupStalePartials( inspected += 1; const metadata = await directory.statRegularFile(name); + if ( !metadata || metadata.mtimeMs >= cutoff @@ -553,26 +591,34 @@ async function cleanupStalePartials( } } -function incomingFileDownloadHostname(value: unknown): string | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { +function incomingFileDownloadHostname(value: WireInput): string | undefined { + if (!isObjectValue(value) || value === null || Array.isArray(value)) { return undefined; } - const rawUrl = (value as Record).download_url; - if (typeof rawUrl !== "string") return undefined; + + // SAFETY: The surrounding checks or construction already match this type. + const rawUrl = (value as JsonObject).download_url; + + if (!isJsonString(rawUrl)) return undefined; + try { const hostname = new URL(rawUrl).hostname.toLowerCase(); + return hostname.length > 0 && hostname.length <= 253 ? hostname : undefined; } catch { return undefined; } } -function incomingStreamChunk(value: unknown): Buffer { +function incomingStreamChunk(value: WireInput): Buffer { if (Buffer.isBuffer(value)) return value; - if (typeof value === "string") return Buffer.from(value); + + if (isJsonString(value)) 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..eb947662a 100644 --- a/src/bin-launcher.test.ts +++ b/src/bin-launcher.test.ts @@ -37,12 +37,16 @@ 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 }, }); + + // SAFETY: The surrounding checks or construction already match this type. const config = JSON.parse(output) as { tools?: { mode?: string } }; assert.equal(config.tools?.mode, "codex"); } finally { @@ -52,6 +56,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 +76,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 +90,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..e5f0639d0 100644 --- a/src/cli-show-changes.test.ts +++ b/src/cli-show-changes.test.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { readFileSync } from "node:fs"; @@ -15,9 +16,12 @@ const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); const packageJsonPath = fileURLToPath(new URL("../package.json", import.meta.url)); const repoRoot = dirname(packageJsonPath); + +// SAFETY: The surrounding checks or construction already match this type. 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 +48,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 +66,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 +79,13 @@ test("show-changes prints a Git-backed historical review", async (t) => { }, encoding: "utf8", }); + + // SAFETY: The surrounding checks or construction already match this type. 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 +93,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, @@ -93,11 +105,13 @@ test("show-changes prints a Git-backed historical review", async (t) => { }, encoding: "utf8", }), - (error: unknown) => { + (error: WireInput) => { assert.match( + // SAFETY: The surrounding checks or construction already match this type. (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..fbb870b3c 100644 --- a/src/cli-worktrees.test.ts +++ b/src/cli-worktrees.test.ts @@ -1,3 +1,4 @@ +import { isErrorValue } from "./json-value.js"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; @@ -82,17 +83,21 @@ 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") { + // SAFETY: Node fs errors are Error objects with an optional string code. + if (isErrorValue(error) && (error as NodeJS.ErrnoException).code === "ENOENT") { return false; } + throw error; } } diff --git a/src/cli.test.ts b/src/cli.test.ts index 96abb6458..fdc83722d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,3 +1,4 @@ +import type { JsonObject, WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; @@ -21,6 +22,7 @@ const require = createRequire(import.meta.url); const tsxLoader = pathToFileURL(require.resolve("tsx")).href; const cliPath = fileURLToPath(new URL("./cli.ts", import.meta.url)); +// SAFETY: The surrounding checks or construction already match this type. const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; }; @@ -40,14 +42,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 +70,9 @@ writeFileSync( "", ].join("\n"), ); + const store = new LocalAgentStore(stateDir); + const current = store.update( store.create({ workspaceId: "ws_current", @@ -76,6 +85,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 +96,30 @@ const other = store.update( }).id, { status: "running" }, ); + store.close(); const daemonSocket = localAgentDaemonPaths(stateDir).endpoint; -const daemonRequests: Array<{ method: string; params?: Record }> = []; +const daemonRequests: Array<{ method: string; params?: JsonObject }> = []; + 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; + + // SAFETY: The surrounding checks or construction already match this type. const request = JSON.parse(buffer.slice(0, newline)) as { requestId: string; method: string; - params?: Record; + params?: JsonObject; }; + daemonRequests.push(request); + if (request.method === "agent.start") { socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, @@ -115,9 +132,11 @@ const daemon = createNetServer((socket) => { target: "missing", }, })); + return; } - const eventsPage = (afterSeq: unknown) => ({ + + const eventsPage = (afterSeq: WireInput) => ({ agentId: current.id, turnId: "trn_follow", status: afterSeq === 0 ? "running" : "completed", @@ -132,6 +151,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 +178,7 @@ const daemon = createNetServer((socket) => { configMatches: true, } : null; + socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -166,6 +187,7 @@ const daemon = createNetServer((socket) => { })); }); }); + await new Promise((resolveListen, rejectListen) => { daemon.once("error", rejectListen); daemon.listen(daemonSocket, resolveListen); @@ -213,6 +235,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 +250,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 +273,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 +285,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 +326,22 @@ 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"); + // SAFETY: The surrounding checks or construction already match this type. const stdout = (commandFailure as { stdout?: string }).stdout ?? ""; + + // SAFETY: The surrounding checks or construction already match this type. 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,13 +349,16 @@ 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( + // SAFETY: The surrounding checks or construction already match this type. (xmlCommandFailure as { stderr?: string }).stderr, 'Unknown subagent profile or provider: missing.\n', ); @@ -340,7 +375,8 @@ test("agents run --worktree passes the branch to the daemon as a start option", test("agents run rejects --branch without --worktree", async () => { await assert.rejects( execCli(["agents", "run", "codex", "--branch", "feature/x", "inspect"], {}), - (error: unknown) => /--branch requires --worktree/.test((error as { stderr?: string }).stderr ?? ""), + // SAFETY: The surrounding checks or construction already match this type. + (error: WireInput) => /--branch requires --worktree/.test((error as { stderr?: string }).stderr ?? ""), ); }); @@ -354,11 +390,13 @@ test("agents run rejects unknown options and suggests -- for dash-prefixed promp "--unknown", "inspect", ]), - (error: unknown) => { + (error: WireInput) => { assert.equal( + // SAFETY: The surrounding checks or construction already match this type. (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..04c6a5668 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { isJsonString, optionalFields } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import { createRequire } from "node:module"; import { stdin as input, stdout as output } from "node:process"; import { resolve } from "node:path"; @@ -79,6 +81,7 @@ type Command = | "show-changes" | "help" | "version"; + const require = createRequire(import.meta.url); const SUPPORTED_NODE_RANGE = ">=20.12 <27"; @@ -92,41 +95,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 +150,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 +188,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 +216,25 @@ async function runInit({ force }: { force: boolean }): Promise { initialValues: files.config.server.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], required: true, }); + if (prompts.isCancel(destinationAnswer)) throw new SetupCancelledError(); + // SAFETY: The surrounding checks or construction already match this type. 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 +244,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 +266,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 +298,11 @@ async function runInit({ force }: { force: boolean }): Promise { initialValues, required: true, }); + if (prompts.isCancel(providerAnswer)) throw new SetupCancelledError(); + // SAFETY: The surrounding checks or construction already match this type. const selectedProviders = providerAnswer as string[]; + const subagents = updateOnboardingSubagentsConfig( currentSubagents, selectedProviders, @@ -292,7 +329,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 +341,7 @@ async function runInit({ force }: { force: boolean }): Promise { "Owner password", ); } + if (useCodingAgents) { prompts.note( [ @@ -312,22 +352,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 +390,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 +432,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 +453,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 +486,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 +505,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 +533,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 +605,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 +613,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 +628,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 +705,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 +724,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 +743,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 +755,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 +782,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 +820,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 +835,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 +853,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 +863,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 +887,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; @@ -786,21 +915,31 @@ async function runAgentsEvents(args: string[], json: boolean): Promise { } } -function parseAgentsEventsArgs(args: string[]): { id: string; turnId?: string; follow: boolean } { +interface AgentsEventsArgs { + id: string; + follow: boolean; + turnId?: string; +} + +function parseAgentsEventsArgs(args: string[]): AgentsEventsArgs { const usage = "Usage: devspace agents events [--turn ] [--follow] [--json]"; 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,101 +947,137 @@ 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 } : {}) }; + + return { id: positional[0]!, follow, ...optionalFields(turnId ? { turnId } : undefined) }; } -function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } { +interface AgentsWaitArgs { + ids: string[]; + timeoutMs?: number; +} + +function parseAgentsWaitArgs(args: string[]): AgentsWaitArgs { 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 }) }; + + return { ids, ...optionalFields(timeoutMs === undefined ? undefined : { timeoutMs }) }; } 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 "); } } -function extractJsonOption(args: string[]): { args: string[]; json: boolean } { +function extractJsonOption(args: string[]) { 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 +1086,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 +1103,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 +1121,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."; @@ -1061,9 +1252,11 @@ class SetupCancelledError extends Error {} function checkSqliteNative(): string { try { + // SAFETY: The surrounding checks or construction already match this type. 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); @@ -1072,10 +1265,13 @@ function checkSqliteNative(): string { function checkGitAvailable(): string { try { + // SAFETY: The surrounding checks or construction already match this type. 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 +1279,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..20d8d7c58 100644 --- a/src/code-mode-executions.ts +++ b/src/code-mode-executions.ts @@ -1,3 +1,5 @@ +import { optionalFields } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import { randomUUID } from "node:crypto"; import type { CodemodeResult } from "@earendil-works/pi-codemode"; import { ProcessOwner, type ProcessSessionManager } from "./process-sessions.js"; @@ -9,6 +11,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 +46,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 +78,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,68 +94,80 @@ 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."))); + + await Promise.allSettled(executions.flatMap((execution) => ( + execution.status === "running" ? [this.stop(execution, "DevSpace shutting down.")] : [] + ))); this.executions.clear(); } 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", status: execution.status, - ...(result?.ok ? { result: result.value } : {}), - ...(result ? { + ...optionalFields(result?.ok ? { result: result.value } : undefined), + ...optionalFields(result ? { output: result.output.filter((item) => item.type === "text").map((item) => item.text), calls: result.calls, - } : {}), - ...(execution.error ? { error: execution.error } : {}), + } : undefined), + ...optionalFields(execution.error ? { error: execution.error } : undefined), }; } private async wait(execution: Execution, ms: number): Promise { let timer: NodeJS.Timeout | undefined; + try { await Promise.race([ execution.done, @@ -163,25 +184,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]) => { + execute: async (args: WireInput, 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 +229,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..2063dcd3e 100644 --- a/src/code-mode-registry.ts +++ b/src/code-mode-registry.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isObjectValue, optionalFields } from "./json-value.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import { renderDeclarations, type CodemodeTool, @@ -7,17 +10,19 @@ import type { McpRegistrationTarget } from "./mcp-modern-server.js"; import { contentText } from "./tool-surfaces/shared.js"; import type { ToolContent } from "./tool-surfaces/types.js"; -type Handler = (input: Record, extra: { signal: AbortSignal }) => Promise<{ +type Handler = (input: JsonObject, extra: { signal: AbortSignal }) => Promise<{ isError?: boolean; content?: ToolContent[]; - structuredContent?: Record; + structuredContent?: JsonObject; }>; +type ZodObjectSpec = NonNullable[0]>; + interface Entry { name: string; description: string; - input: z.ZodObject; - output: z.ZodObject; + input: z.ZodObject; + output: z.ZodObject; handler: Handler; } @@ -26,20 +31,24 @@ export class CodeModeRegistry { private readonly entries = new Map(); readonly registrationTarget: McpRegistrationTarget = { - registerTool: ((name: string, definition: Record, handler: Handler) => { - const input = z.object(definition.inputSchema as z.ZodRawShape); - const output = z.object(definition.outputSchema as z.ZodRawShape); + // SAFETY: The surrounding checks or construction already match this type. + registerTool: unsafeNarrow((name: string, definition: JsonObject, handler: Handler) => { + // SAFETY: The surrounding checks or construction already match this type. + const input = z.object(unsafeNarrow[0]>(definition.inputSchema)); + const output = z.object(unsafeNarrow[0]>(definition.outputSchema)); this.entries.set(name, { name, + // SAFETY: The surrounding checks or construction already match this type. description: definition.description as string, input, output, handler, }); + // This target collects internal registrations; nothing is registered with the MCP host. return undefined; - }) as unknown as McpRegistrationTarget["registerTool"], - registerResource: (() => undefined) as unknown as McpRegistrationTarget["registerResource"], + }), + registerResource: unsafeNarrow((() => undefined)), }; signatures(): string { @@ -58,15 +67,19 @@ export class CodeModeRegistry { description: entry.description, inputSchema: z.toJSONSchema(entry.input.omit({ workspace_id: true })), outputSchema: z.toJSONSchema(entry.output), - execute: async (value: unknown, { signal }) => { + execute: async (value: WireInput, { signal }) => { if (signal.aborted) throw new Error("Code execution cancelled."); - const parsed = entry.input.safeParse({ ...(typeof value === "object" && value !== null ? value : {}), workspace_id: workspaceId }); + const parsed = entry.input.safeParse({ ...optionalFields(isObjectValue(value) && value !== null ? value : undefined), 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..aea9aac13 100644 --- a/src/config-migration.ts +++ b/src/config-migration.ts @@ -1,3 +1,5 @@ +import { isJsonBoolean } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import * as z from "zod/v4"; import { DEVSPACE_CONFIG_VERSION, @@ -45,9 +47,10 @@ const LEGACY_CONFIG_KEYS = new Set([ "ui", ]); -export function migrateLegacyConfig(value: unknown): DevspaceConfig { +export function migrateLegacyConfig(value: WireInput): 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(", ")}`, @@ -78,7 +81,8 @@ export function migrateLegacyConfig(value: unknown): DevspaceConfig { }); } -function definedEntries>(value: T): Partial { +function definedEntries(value: T): Partial { + // SAFETY: The surrounding checks or construction already match this type. return Object.fromEntries( Object.entries(value).filter((entry) => entry[1] !== undefined), ) as Partial; @@ -86,9 +90,11 @@ function definedEntries>(value: T): Partial function migrateLegacySubagents( value: z.infer | undefined, -): unknown { +): WireInput { if (value === undefined) return undefined; - if (typeof value !== "boolean") return value; + + if (!isJsonBoolean(value)) 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..f2b1a1c26 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,13 +102,14 @@ export const devspaceConfigSchema = z.object({ }).strict(); export type DevspaceConfig = z.output; + export type DevspaceConfigInput = z.input; export function defaultDevspaceConfig(): DevspaceConfig { return devspaceConfigSchema.parse({ configVersion: DEVSPACE_CONFIG_VERSION }); } -export function devspaceConfigJsonSchema(): object { +export function devspaceConfigJsonSchema() { return { $id: DEVSPACE_CONFIG_SCHEMA_URL, title: "DevSpace configuration", diff --git a/src/config.test.ts b/src/config.test.ts index a7e9fbfb6..7f992524e 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 } }; } @@ -136,6 +138,7 @@ test("server.trustProxy accepts loopback and rejects unsupported presets", (t) = writeDevspaceConfig({ configVersion: 1, server: { trustProxy: "loopback" } }, env); assert.equal(loadConfig(env).logging.trustProxy, "loopback"); assert.throws( + // SAFETY: The surrounding checks or construction already match this type. () => writeDevspaceConfig({ configVersion: 1, server: { trustProxy: "uniquelocal" as never } }, env), /trustProxy/, ); diff --git a/src/config.ts b/src/config.ts index 11a1a4e0b..00e6acc67 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,25 @@ 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))); + + return Array.from(new Set(hosts.flatMap((host) => { + const trimmed = host.trim(); + + return trimmed ? [trimmed] : []; + }))); } 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 +155,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..ab04c39a9 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -124,24 +124,31 @@ export function migrateDatabase(sqlite: Database.Database): void { ); `); + // SAFETY: The surrounding checks or construction already match this type. 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 (?, ?, ?)", ); @@ -300,10 +307,13 @@ function migrateLocalAgentStructuredErrors(sqlite: Database.Database): void { } function migrateLocalAgentEffortRename(sqlite: Database.Database): void { + // SAFETY: The surrounding checks or construction already match this type. 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 +322,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 +339,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"); @@ -355,13 +370,17 @@ function migrateLocalAgentTurns(sqlite: Database.Database): void { } function migrateLocalAgentProviderInstances(sqlite: Database.Database): void { + // SAFETY: The surrounding checks or construction already match this type. 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 @@ -461,7 +480,9 @@ function addColumnIfMissing( column: string, definition: string, ): void { + // SAFETY: The surrounding checks or construction already match this type. 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..cca18933b 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,12 @@ test("seed copies source config and state, and reset reseeds only once source au await runDevState("seed"); const devRoot = join(checkoutRoot, ".devspace-dev"); + + // SAFETY: The surrounding checks or construction already match this type. 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 +62,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 +97,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..02e4e4960 100644 --- a/src/git-worktrees.test.ts +++ b/src/git-worktrees.test.ts @@ -1,3 +1,4 @@ +import { isErrorValue } from "./json-value.js"; import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises"; @@ -201,6 +202,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 +234,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 +294,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 +359,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 +394,27 @@ 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") { + // SAFETY: Node fs errors are Error objects with an optional string code. + if (isErrorValue(error) && (error as NodeJS.ErrnoException).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..a469887b1 100644 --- a/src/git-worktrees.ts +++ b/src/git-worktrees.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isErrorValue, isObjectValue, optionalFields } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import { randomBytes } from "node:crypto"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -93,6 +96,7 @@ export async function createManagedWorktree(input: { try { const sourceStats = await stat(sourcePath); + if (!sourceStats.isDirectory()) { throw new GitWorktreeError( "GIT_REPOSITORY_NOT_FOUND", @@ -111,6 +115,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, @@ -143,7 +148,7 @@ export async function createManagedWorktree(input: { dirtySource, detached: !input.branch, managed: true, - ...(input.branch ? { branch: input.branch } : {}), + ...optionalFields(input.branch ? { branch: input.branch } : undefined), }; } @@ -161,10 +166,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 +202,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 +211,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 +233,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 +243,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 +262,7 @@ export async function restoreManagedWorktree(input: { ); } } + throw cause; } }, "WORKTREE_RESTORE_FAILED"); @@ -259,6 +274,7 @@ export async function discardRestoredManagedWorktree(input: { allowedRoots: string[]; }): Promise> { const { session } = input; + if (!session.sourceRoot) { return Result.err(worktreeError( session.id, @@ -267,10 +283,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 +315,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 +340,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 +358,7 @@ async function cleanupManagedWorktree(input: { } const recoveryRef = recoverySha ? managedWorktreeRecoveryRef(session.id) : undefined; + if (recoveryRef && recoverySha) { await git(["update-ref", recoveryRef, recoverySha], sourceRoot); } @@ -340,21 +366,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 +395,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 +416,7 @@ async function cleanupManagedWorktree(input: { }, )); } + return Result.err(markedPruned.error); } @@ -406,6 +440,7 @@ async function captureManagedWorktreeResult( return Result.ok(await run()); } catch (cause) { if (isProgrammerDefect(cause)) throw cause; + return Result.err(worktreeError( workspaceId, code, @@ -416,7 +451,7 @@ async function captureManagedWorktreeResult( } } -function isProgrammerDefect(error: unknown): boolean { +function isProgrammerDefect(error: WireInput): boolean { return error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError @@ -430,9 +465,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 +478,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 +487,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 +502,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 +514,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); } @@ -500,7 +544,7 @@ async function assertGitRootAllowed(gitRoot: string, allowedRoots: string[]): Pr async function resolveBaseCommit(sourceRoot: string, baseRef: string): Promise { try { return (await git(["rev-parse", "--verify", `${baseRef}^{commit}`], sourceRoot)).trim(); - } catch (error) { + } catch { if (baseRef === "HEAD") { throw new GitWorktreeError( "GIT_REPOSITORY_HAS_NO_COMMITS", @@ -518,6 +562,7 @@ async function resolveBaseCommit(sourceRoot: string, baseRef: string): Promise { try { return (await stat(path)).isDirectory(); } catch (error) { - if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + // SAFETY: Node fs errors are Error objects with an optional string code. + if (isErrorValue(error) && (error as NodeJS.ErrnoException).code === "ENOENT") { return false; } + throw error; } } @@ -545,26 +592,32 @@ async function git(args: string[], cwd: string): Promise { cwd, maxBuffer: 10 * 1024 * 1024, }); + return stdout; } catch (error) { if (isGitUnavailable(error)) throw error; - const stderr = typeof error === "object" && error && "stderr" in error - ? String((error as { stderr?: unknown }).stderr ?? "").trim() + const stderr = isObjectValue(error) && error && "stderr" in error + // SAFETY: The surrounding checks or construction already match this type. + ? String(unsafeNarrow<{ stderr?: WireInput }>(error).stderr ?? "").trim() : ""; - const stdout = typeof error === "object" && error && "stdout" in error - ? String((error as { stdout?: unknown }).stdout ?? "").trim() + + const stdout = isObjectValue(error) && error && "stdout" in error + // SAFETY: The surrounding checks or construction already match this type. + ? String(unsafeNarrow<{ stdout?: WireInput }>(error).stdout ?? "").trim() : ""; + const details = stderr || stdout || (error instanceof Error ? error.message : String(error)); throw new Error(details); } } -function isGitUnavailable(error: unknown): boolean { +function isGitUnavailable(error: WireInput): boolean { return Boolean( - typeof error === "object" && + isObjectValue(error) && error && "code" in error && + // SAFETY: The surrounding checks or construction already match this type. (error as { code?: unknown }).code === "ENOENT", ); } 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..bcf4f545b 100644 --- a/src/incoming-artifacts.test.ts +++ b/src/incoming-artifacts.test.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { Readable } from "node:stream"; import test from "node:test"; @@ -10,8 +11,10 @@ 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); + +test("incoming artifact log shapes redact values", testLogValueRedaction); async function testRegistryFailsClosed(): Promise { const registry = new IncomingArtifactAdapterRegistry(); @@ -35,10 +38,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", @@ -46,20 +51,22 @@ async function testRegistryFailsClosed(): Promise { assert.throws( () => new IncomingArtifactAdapterRegistry([{ ...ambiguous, id: "UPPER" }]), - (error: unknown) => error instanceof ArtifactError && error.code === "invalid_incoming_adapter", + (error: WireInput) => error instanceof ArtifactError && error.code === "invalid_incoming_adapter", ); assert.throws( () => new IncomingArtifactAdapterRegistry([ambiguous, ambiguous]), - (error: unknown) => error instanceof ArtifactError && error.code === "duplicate_incoming_adapter", + (error: WireInput) => error instanceof ArtifactError && error.code === "duplicate_incoming_adapter", ); } 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 +75,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 +114,7 @@ async function testOpenAIFileAdapter(): Promise { file_name: null, name: null, }); + assert.equal(fallbackOpened.name, "chatgpt-file.png"); fallbackOpened.stream.destroy(); @@ -159,14 +170,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 +191,7 @@ async function testOpenAIFileAdapter(): Promise { }), }), ]); + await expectArtifactError( unsafeRedirectRegistry.open(reference), "unsafe_openai_file_reference", @@ -187,13 +202,14 @@ async function testOpenAIFileAdapter(): Promise { fetch: async () => new Response("nope", { status: 500 }), }), ]); + await expectArtifactError( failedDownloadRegistry.open(reference), "openai_file_download_failed", ); } -function testLogShapeRedaction(): void { +function testLogValueRedaction(): void { const value = { download_url: "https://files.oaiusercontent.com/file_123/download?sig=super-secret", file_id: "file_secret", @@ -201,6 +217,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,15 +228,17 @@ 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); } -async function expectArtifactError(promise: Promise, code: string): Promise { +async function expectArtifactError(promise: Promise, code: string): Promise { await assert.rejects( promise, - (error: unknown) => error instanceof ArtifactError && error.code === code, + (error: WireInput) => error instanceof ArtifactError && error.code === code, ); } diff --git a/src/incoming-artifacts.ts b/src/incoming-artifacts.ts index 0f9260855..dc8a936f8 100644 --- a/src/incoming-artifacts.ts +++ b/src/incoming-artifacts.ts @@ -1,18 +1,33 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isJsonBoolean, isJsonNumber, isNumberValue, isBigintValue, isFunctionValue, isSymbolValue, isJsonString, isObjectValue } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import { basename, isAbsolute } from "node:path"; import { Readable } from "node:stream"; 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; + +function hasAsciiControlCharacters(value: string): boolean { + for (const character of value) { + const code = character.charCodeAt(0); + + if (code <= 0x1f || code === 0x7f) return true; + } + + return false; +} + const OPENAI_FILE_KEYS = new Set([ "download_url", "file_id", @@ -21,6 +36,7 @@ const OPENAI_FILE_KEYS = new Set([ "name", "size", ]); + const OPENAI_FILE_REDIRECT_LIMIT = 3; const OPENAI_FILE_DOWNLOAD_TIMEOUT_MS = 30_000; @@ -33,8 +49,8 @@ export interface IncomingArtifactSource { export interface IncomingArtifactAdapter { readonly id: string; - canHandle(value: unknown): boolean; - open(value: unknown): Promise; + canHandle(value: WireInput): boolean; + open(value: WireInput): Promise; } export interface OpenedIncomingArtifact extends IncomingArtifactSource { @@ -46,6 +62,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 +70,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 { + async open(value: WireInput): Promise { const matching: IncomingArtifactAdapter[] = []; + for (const adapter of this.adapters) { let handles = false; + try { handles = adapter.canHandle(value); } catch { @@ -76,6 +98,7 @@ export class IncomingArtifactAdapterRegistry { `Incoming artifact adapter '${adapter.id}' failed during recognition.`, ); } + if (handles) matching.push(adapter); } @@ -85,6 +108,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 +118,7 @@ export class IncomingArtifactAdapterRegistry { const adapter = matching[0]; let source: IncomingArtifactSource; + try { source = await adapter.open(value); } catch (error) { @@ -103,12 +128,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 +158,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", @@ -141,11 +169,12 @@ export function createOpenAIIncomingArtifactAdapter( return { id: "openai-file", canHandle: isOpenAIFileReferenceCandidate, - async open(value: unknown): Promise { + async open(value: WireInput): Promise { const reference = normalizeOpenAIFileReference(value); 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 +191,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 +211,7 @@ export function createOpenAIIncomingArtifactAdapter( } const responseSize = responseContentLength(response); + if ( reference.size !== undefined && responseSize !== undefined @@ -191,57 +223,66 @@ export function createOpenAIIncomingArtifactAdapter( "ChatGPT file metadata did not match the downloaded content.", ); } + const mimeType = reference.mime_type ?? responseMimeType(response); return { name: normalizeOpenAIFileName(reference.file_name, reference.file_id, mimeType), mimeType, size: responseSize ?? reference.size, - stream: Readable.fromWeb(response.body as unknown as NodeReadableStream), + stream: Readable.fromWeb(unsafeNarrow(response.body)), }; }, }; } -export type IncomingArtifactValueShape = +export type IncomingArtifactValueView = | { type: "null" } | { type: "undefined" } | { type: "boolean" } | { type: "number"; finite: boolean } | { type: "bigint" } | { type: "string"; kind: "absolute-path" | "url" | "data-url" | "text"; length: number } - | { type: "array"; length: number; items: IncomingArtifactValueShape[]; truncated: boolean } + | { type: "array"; length: number; items: IncomingArtifactValueView[]; truncated: boolean } | { type: "object"; constructor?: string; - entries: Record; + entries: Record; truncated: boolean; } | { type: "function" | "symbol" } | { type: "cycle" }; export function describeIncomingArtifactValue( - value: unknown, + value: WireInput, maxDepth = 4, maxEntries = 20, -): IncomingArtifactValueShape { +): IncomingArtifactValueView { const seen = new WeakSet(); - const describe = (current: unknown, depth: number): IncomingArtifactValueShape => { + const describe = (current: WireInput, depth: number): IncomingArtifactValueView => { 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") { + + if (isJsonBoolean(current)) return { type: "boolean" }; + + if (isNumberValue(current)) return { type: "number", finite: Number.isFinite(current) }; + + if (isBigintValue(current)) return { type: "bigint" }; + + if (isFunctionValue(current)) return { type: "function" }; + + if (isSymbolValue(current)) return { type: "symbol" }; + + if (isJsonString(current)) { return { type: "string", kind: classifyValueString(current), length: current.length, }; } + if (seen.has(current)) return { type: "cycle" }; seen.add(current); @@ -249,7 +290,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 +302,7 @@ export function describeIncomingArtifactValue( } const keys = Object.keys(current).sort(); + if (depth >= maxDepth) { return { type: "object", @@ -267,16 +311,22 @@ export function describeIncomingArtifactValue( truncated: keys.length > 0, }; } - const entries: Record = {}; + + const entries: Record = {}; + for (const [index, key] of keys.slice(0, maxEntries).entries()) { let entryValue: unknown; + try { - entryValue = (current as Record)[key]; + // SAFETY: The surrounding checks or construction already match this type. + entryValue = (current as JsonObject)[key]; } catch { entryValue = undefined; } + entries[safeValueEntryKey(key, index)] = describe(entryValue, depth + 1); } + return { type: "object", constructor: safeConstructorName(current), @@ -289,24 +339,27 @@ export function describeIncomingArtifactValue( } function validateIncomingArtifactSource(source: IncomingArtifactSource): void { - if (!source || typeof source !== "object") { + if (!source || !isObjectValue(source)) { throw new ArtifactError( "invalid_incoming_artifact_source", "Incoming artifact adapter returned an invalid source.", ); } - if (typeof source.name !== "string" || source.name.length === 0) { + + if (!isJsonString(source.name) || 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") { + + if (source.mimeType !== undefined && !isJsonString(source.mimeType)) { 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,8 +369,11 @@ function validateIncomingArtifactSource(source: IncomingArtifactSource): void { "Incoming artifact adapter returned an invalid byte size.", ); } + + // SAFETY: The surrounding checks or construction already match this type. const stream = source.stream as Partial | undefined; - if (!stream || typeof stream[Symbol.asyncIterator] !== "function") { + + if (!stream || !isFunctionValue(stream[Symbol.asyncIterator])) { throw new ArtifactError( "invalid_incoming_artifact_source", "Incoming artifact adapter must provide an async-readable stream.", @@ -325,16 +381,17 @@ function validateIncomingArtifactSource(source: IncomingArtifactSource): void { } } -function isOpenAIFileReferenceCandidate(value: unknown): value is Record { +function isOpenAIFileReferenceCandidate(value: WireInput): value is JsonObject { 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") && Object.hasOwn(value, "file_id"); } -function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { +function normalizeOpenAIFileReference(value: WireInput): OpenAIFileReference { if (!isOpenAIFileReferenceCandidate(value)) { throw new ArtifactError( "invalid_openai_file_reference", @@ -344,9 +401,10 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { const downloadUrl = value.download_url; const fileId = value.file_id; + if ( - typeof downloadUrl !== "string" - || typeof fileId !== "string" + !isJsonString(downloadUrl) + || !isJsonString(fileId) || !isValidOpenAIFileId(fileId) ) { throw new ArtifactError( @@ -358,14 +416,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,13 +440,15 @@ 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) { + if (!isJsonNumber(rawSize) || !Number.isSafeInteger(rawSize) || rawSize < 0) { throw new ArtifactError( "invalid_openai_file_reference", "ChatGPT file reference is malformed.", ); } + size = rawSize; } @@ -398,9 +461,10 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { }; } -function nullableString(value: unknown): string | undefined | null { +function nullableString(value: WireInput): string | undefined | null { if (value === undefined || value === null) return undefined; - return typeof value === "string" ? value : null; + + return isJsonString(value) ? value : null; } function normalizeOpenAIFileName( @@ -409,25 +473,29 @@ 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"}`; } function isValidOpenAIFileId(value: string): boolean { return value.length > 0 && value.length <= OPENAI_FILE_ID_MAX_LENGTH - && !OPENAI_FILE_ID_CONTROL_PATTERN.test(value); + && !hasAsciiControlCharacters(value); } function normalizeSuppliedOpenAIFileName(value: string | undefined): string | undefined { 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 +515,7 @@ function extensionForMimeType(mimeType: string | undefined): string | undefined function validateOpenAIFileUrl(value: string): string { let url: URL; + try { url = new URL(value); } catch { @@ -455,6 +524,7 @@ function validateOpenAIFileUrl(value: string): string { "ChatGPT file download URL is invalid.", ); } + if ( url.protocol !== "https:" || !isTrustedOpenAIFileHost(url.hostname) @@ -468,6 +538,7 @@ function validateOpenAIFileUrl(value: string): string { "ChatGPT file download URL is outside the trusted file host.", ); } + return url.toString(); } @@ -485,31 +556,38 @@ 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; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +function isRecord(value: WireInput): value is JsonObject { + return isObjectValue(value) && value !== null && !Array.isArray(value); } 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"; } @@ -519,10 +597,11 @@ function safeValueEntryKey(value: string, index: number): string { : ``; } -function safeConstructorName(value: object): string | undefined { +function safeConstructorName(value: {}): string | undefined { try { const name = value.constructor?.name; - return typeof name === "string" && name.length <= 80 ? name : undefined; + + return isJsonString(name) && name.length <= 80 ? name : undefined; } catch { return undefined; } diff --git a/src/json-value.ts b/src/json-value.ts new file mode 100644 index 000000000..5f7b34a08 --- /dev/null +++ b/src/json-value.ts @@ -0,0 +1,119 @@ +import * as z from "zod/v4"; + +// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- JSON objects are an open string-keyed map by contract. +export type JsonObject = { [key: string]: JsonField | WireInput }; + +// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- JSON values include nested objects with string keys. +export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject; + +export type JsonField = JsonValue | undefined; + +/** Parsed-or-unparsed input that TypeScript can accept from object literals, Errors, and SDK handles. */ +export type WireInput = {} | null | undefined; + +export const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.null(), + z.boolean(), + z.number(), + z.string(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); + +// SAFETY: Runtime JSON objects are a subset of the mutable host dictionary type. +export const jsonObjectSchema: z.ZodType = z.record(z.string(), jsonValueSchema) as z.ZodType; + +export function isErrorValue(value: T): value is T & Error { + return value instanceof Error; +} + +export function isJsonValue(value: T): value is T & JsonValue { + return jsonValueSchema.safeParse(value).success; +} + +export function isJsonObject(value: T): value is T & JsonObject { + return jsonObjectSchema.safeParse(value).success; +} + +export function isJsonString(value: T): value is T & string { + return z.string().safeParse(value).success; +} + +export function isJsonNumber(value: T): value is T & number { + return z.number().safeParse(value).success; +} + +/** Any JavaScript number, including the NaN and ±Infinity values that `isJsonNumber` rejects. */ +export function isNumberValue(value: T): value is T & number { + return isJsonNumber(value) || Number.isNaN(value) || value === Infinity || value === -Infinity; +} + +export function isJsonBoolean(value: T): value is T & boolean { + return z.boolean().safeParse(value).success; +} + +export function isBigintValue(value: T): value is T & bigint { + return z.bigint().safeParse(value).success; +} + +export function isSymbolValue(value: T): value is T & symbol { + return z.symbol().safeParse(value).success; +} + +export function isFunctionValue(value: WireInput): value is (...args: never[]) => void { + return z.function().safeParse(value).success; +} + +/** Same labels as `typeof`, for log and error text after a value has been classified. */ +export function jsonKind(value: WireInput): string { + if (value === undefined) return "undefined"; + + if (isJsonString(value)) return "string"; + + if (isNumberValue(value)) return "number"; + + if (isJsonBoolean(value)) return "boolean"; + + if (isBigintValue(value)) return "bigint"; + + if (isSymbolValue(value)) return "symbol"; + + if (isFunctionValue(value)) return "function"; + + return "object"; +} + +export function isObjectValue(value: T): value is T & object { + return value !== null && value !== undefined && Object(value) === value && !z.function().safeParse(value).success; +} + +export function parseJsonValue(value: string): JsonValue { + return jsonValueSchema.parse(JSON.parse(value)); +} + +export function optionalFields(value: T | undefined): T | { readonly [key: string]: never } { + if (value === undefined) return {}; + + return value; +} + +export function optionalField( + key: K, + value: V | undefined, +) { + const result: { [P in K]?: V } = {}; + + if (value !== undefined) result[key] = value; + + return result; +} + +export function jsonObject(value: {} | null | undefined, label: string): JsonObject { + const parsed = jsonObjectSchema.safeParse(value); + + if (!parsed.success) throw new TypeError(`${label} must be a JSON object.`); + + return parsed.data; +} diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 7d08daa48..1aa2c077e 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isJsonObject } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -15,17 +18,21 @@ import { import { GrokPromptCompletionRegistry } from "./local-agent-grok.js"; function fakeCursorConnection() { - const requests: Array<{ method: string; params?: unknown }> = []; - const queues = new Map(); + const requests: Array<{ method: string; params?: WireInput }> = []; + const queues = new Map(); + const connection = { agent: { - async notify(_method: string, _params?: unknown): Promise {}, - async request(method: string, params?: unknown): Promise { + async notify(_method: string, _params?: WireInput): Promise {}, + async request(method: string, params?: WireInput): Promise { requests.push({ method, params }); + // SAFETY: The surrounding checks or construction already match this type. const input = params as { sessionId?: string } | undefined; + if (method === "session/new") { const sessionId = "cursor_session_1"; queues.set(sessionId, { values: [] }); + return { sessionId, configOptions: [ @@ -34,22 +41,31 @@ 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") { + + if (method === "session/prompt" && unsafeNarrow<{ prompt: Array<{ text: string }> }>(params).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." } }, { sessionUpdate: "tool_call", toolCallId: "call_1", title: "Read README.md", kind: "read", status: "pending" }, { sessionUpdate: "tool_call_update", toolCallId: "call_1", status: "completed" }, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Done." } }, - ]) deliverAcpSessionUpdate(queues, { sessionId, update } as never); + ]) { + const payload = { sessionId, update }; + deliverAcpSessionUpdate(queues, payload); + } + return { stopReason: "end_turn" }; } + if (method === "session/prompt") { const queue = queues.get(input?.sessionId ?? ""); queue?.values.push({ @@ -58,14 +74,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 +93,7 @@ function fakeCursorConnection() { capabilities: { resume: true, close: true, additionalDirectories: true }, queues, }, connection); + return { requests, queues, connection, runtime }; } @@ -90,9 +110,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 +126,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 }; } @@ -130,6 +156,7 @@ test("a warm turn reuses its session and configuration without resuming", async assert.equal(requests.filter(({ method }) => method === "session/resume").length, 0); assert.equal(requests.filter(({ method }) => method === "session/set_config_option").length, 4); assert.equal( + // SAFETY: The surrounding checks or construction already match this type. Object.hasOwn(requests.find(({ method }) => method === "session/new")?.params as object, "additionalDirectories"), false, ); @@ -140,12 +167,14 @@ test("session updates stream as activity with message chunks joined between tool const fixture = fakeCursorConnection(); const { runtime } = fixture; const { first } = await runFirstAndWarm(fixture); - const acpEvents: unknown[] = []; + const acpEvents: WireInput[] = []; + 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 +209,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 +217,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 +228,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 +236,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 +253,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,8 +261,10 @@ 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( + // SAFETY: The surrounding checks or construction already match this type. requests.filter(({ method, params }) => method === "session/close" && (params as { sessionId?: string })?.sessionId === "close_only_session").length, 1, "session close support must not depend on resume support", @@ -275,20 +313,25 @@ test("permission requests follow the write mode and fail closed when sandboxed o }); test("a second turn on a session with an active turn is rejected", async () => { - const overlapQueues = new Map(); + const overlapQueues = new Map(); let releaseOverlappingPrompt!: () => void; 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 { + async notify(_method: string, _params?: WireInput): Promise {}, + async request(method: string, params?: WireInput): Promise { + // SAFETY: The surrounding checks or construction already match this type. 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 +341,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 +359,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 +379,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 +435,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 +447,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,14 +473,18 @@ 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"); + // SAFETY: The surrounding checks or construction already match this type. const forwarded = JSON.parse(await readFile(marker, "utf8")) as string[]; assert.equal( forwarded.filter((argument) => argument === resolve(workspaceRoot)).length, @@ -456,9 +513,10 @@ for (const [flavor, modeId] of [ ["copilot", "https://agentclientprotocol.com/protocol/session-modes#plan"], ] as const) { test(`${flavor} read_only turns select the restricted session mode and fail closed when refused`, async () => { - const modeRequests: Array<{ method: string; params?: unknown }> = []; - const modeQueues = new Map(); + const modeRequests: Array<{ method: string; params?: WireInput }> = []; + const modeQueues = new Map(); let refuseMode = false; + const modeRuntime = new AcpRuntime({ flavor, command: flavor, @@ -468,24 +526,30 @@ for (const [flavor, modeId] of [ }, { agent: { async notify(): Promise {}, - async request(method: string, params?: unknown): Promise { + async request(method: string, params?: WireInput): 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,27 +565,33 @@ 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(); }); } function grokFixture() { - const grokRequests: Array<{ method: string; params?: unknown }> = []; - const grokQueues = new Map(); + const grokRequests: Array<{ method: string; params?: WireInput }> = []; + 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 { + async notify(_method: string, _params?: WireInput): Promise {}, + async request(method: string, params?: WireInput): Promise { grokRequests.push({ method, params }); + // SAFETY: The surrounding checks or construction already match this type. 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 +603,9 @@ function grokFixture() { }, }; } + if (method === "session/set_model") return {}; + if (method === "session/prompt") { grokQueues.get(input?.sessionId ?? "")?.values.push({ update: { @@ -546,14 +618,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 +639,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( @@ -584,8 +663,9 @@ test("Grok selects its model and effort and completes the prompt from its comple ); assert.equal(grokCompletionRegistry.size, 0); // A user's own always-approve setting must not bypass the permission requests that enforce the write mode. + const newSessionParams = grokRequests.find(({ method }) => method === "session/new")?.params; assert.deepEqual( - (grokRequests.find(({ method }) => method === "session/new")?.params as { _meta?: unknown })._meta, + isJsonObject(newSessionParams) ? newSessionParams._meta : undefined, { yoloMode: false }, ); await grokRuntime.close(); @@ -595,8 +675,9 @@ test("only full_access may skip Grok's permission requests", async () => { const { grokRequests, grokRuntime } = grokFixture(); const grokResumed = await grokRuntime.run({ prompt: "resume", workspaceRoot: "/tmp/project", providerSessionId: "grok_session_other", writeMode: "full_access" }); assert.equal(grokResumed.isOk(), true); + const resumeParams = grokRequests.find(({ method }) => method === "session/resume")?.params; assert.deepEqual( - (grokRequests.find(({ method }) => method === "session/resume")?.params as { _meta?: unknown })._meta, + isJsonObject(resumeParams) ? resumeParams._meta : undefined, { yoloMode: true }, "only full_access may skip Grok's permission requests", ); @@ -606,8 +687,8 @@ test("only full_access may skip Grok's permission requests", async () => { test("Grok rejects unadvertised models and efforts and lists the available ones", async () => { const grokConfigurationConnection = { agent: { - async notify(_method: string, _params?: unknown): Promise {}, - async request(method: string): Promise { + async notify(_method: string, _params?: WireInput): Promise {}, + async request(method: string): Promise { if (method === "session/new") { return { sessionId: "grok_configuration_session", @@ -620,40 +701,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 +751,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", @@ -670,21 +761,25 @@ test("a provider that never answers after session/cancel is given up on", async }, { agent: { async notify(method: string): Promise { stuckNotifications.push(method); }, - async request(method: string): Promise { + 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 +788,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", @@ -701,22 +797,27 @@ test("a stop requested before the prompt is sent starts no provider work", async }, { agent: { async notify(): Promise {}, - async request(method: string): 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..784624a34 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isJsonString, isObjectValue, optionalFields } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { createRequire } from "node:module"; import { resolve } from "node:path"; @@ -33,15 +36,19 @@ 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); + +// SAFETY: The surrounding checks or construction already match this type. const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; const observeChildError = (): void => {}; @@ -62,10 +69,10 @@ const ACP_COMMANDS: Partial> = { interface AcpConnectionLike { agent: { - request(method: string, params?: unknown): Promise; - notify(method: string, params?: unknown): Promise; + request(method: string, params?: WireInput): Promise; + notify(method: string, params?: WireInput): Promise; }; - close(error?: unknown): void; + close(error?: WireInput): void; closed: Promise; } @@ -78,7 +85,7 @@ interface AcpCapabilities { interface AcpSessionQueue { values: unknown[]; /** Set while a turn runs so session/update notifications are reported as they arrive. */ - onUpdate?: (value: unknown) => void; + onUpdate?: (value: WireInput) => void; } export interface AcpRuntimeOptions { @@ -158,19 +165,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 +194,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 +218,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 } } : {}), + ...optionalFields(promptId ? { _meta: { promptId, requestId: promptId } } : undefined), }); + let response: unknown; + try { response = await Promise.race([ standardResponse, @@ -221,15 +244,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 +267,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 +278,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 +293,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 +313,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 +335,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 +348,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 +356,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 +373,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,16 +386,18 @@ 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; } - private cacheSessionMetadata(sessionId: string, response: unknown): void { + private cacheSessionMetadata(sessionId: string, response: WireInput): void { if (hasAcpConfigOptions(response) || (this.flavor === "grok" && readGrokSessionState(response))) { this.sessionMetadata.set(sessionId, response); } @@ -364,16 +406,20 @@ export class AcpRuntime implements LocalAgentRuntime { private async configureSession( sessionId: string, input: LocalAgentRunInput, - response?: unknown, + response?: WireInput, 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 +427,7 @@ export class AcpRuntime implements LocalAgentRuntime { ] .filter(Boolean) .join(" and "); + if (requested) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -390,15 +437,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 +457,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) { @@ -426,15 +478,17 @@ export class AcpRuntime implements LocalAgentRuntime { private async configureGrokSession( sessionId: string, input: LocalAgentRunInput, - response: unknown, + response: WireInput, 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,24 +498,29 @@ 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 { await this.connection.agent.request("session/set_model", { sessionId, modelId: requestedModel, - ...(effort ? { _meta: { reasoningEffort: effort } } : {}), + ...optionalFields(effort ? { _meta: { reasoningEffort: effort } } : undefined), }); } catch (cause) { throw new AgentProviderProtocolError({ @@ -483,7 +542,7 @@ export class AcpRuntime implements LocalAgentRuntime { return this.flavor === "grok" ? { _meta: { yoloMode: input.writeMode === "full_access" } } : {}; } - private additionalDirectoryParams(): { additionalDirectories?: string[] } { + private additionalDirectoryParams() { // DevSpace currently authorizes exactly one workspace root per agent turn. // Do not advertise an empty additional-directory scope to ACP providers. return {}; @@ -491,6 +550,7 @@ export class AcpRuntime implements LocalAgentRuntime { private nextPromptId(): string { this.promptSequence += 1; + return `devspace-grok-prompt-${this.promptSequence}`; } } @@ -541,6 +601,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 +612,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 +625,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 +639,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 +675,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 +697,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 +710,16 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { }); } } + const stream = ndJsonStream( + // SAFETY: The surrounding checks or construction already match this type. Writable.toWeb(child.stdin) as WritableStream, + // SAFETY: The surrounding checks or construction already match this type. Readable.toWeb(child.stdout) as ReadableStream, ); - connection = app.connect(stream) as unknown as AcpConnectionLike; + + connection = unsafeNarrow(app.connect(stream)); + const init = await withTimeout(Promise.race([ connection.agent.request(methods.agent.initialize, { protocolVersion: 1, @@ -651,7 +731,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 +745,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 +790,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { this.resolvedCommand = this.commandResolver(this.flavor, this.env, this.configuredCommand); this.commandResolved = true; } + return this.resolvedCommand; } } @@ -711,16 +800,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 +830,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 +855,7 @@ export function acpCommandArgs( env: NodeJS.ProcessEnv = process.env, ): string[] { const writeMode = context.writeMode ?? "allowed"; + if (provider === "cursor") { return [ "acp", @@ -767,11 +864,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 +879,13 @@ export function acpCommandArgs( "stdio", ]; } + if (provider === "generic") return []; + const sandboxArgs = writeMode === "full_access" ? ["--no-sandbox"] : ["--experimental", "--sandbox"]; + return [ "--acp", ...sandboxArgs, @@ -794,11 +897,11 @@ export function acpCommandArgs( } export function resolveAcpModelConfigUpdate( - session: unknown, + session: WireInput, model: string, provider: string, sessionIdOverride?: string, -): { sessionId: string; configId: string; value: string } { +) { return resolveAcpSelectConfigUpdate(session, { category: "model", label: "model", @@ -809,7 +912,7 @@ export function resolveAcpModelConfigUpdate( } export function resolveAcpEffortConfigUpdate( - session: unknown, + session: WireInput, effort: string, provider: string, sessionIdOverride?: string, @@ -824,7 +927,7 @@ export function resolveAcpEffortConfigUpdate( } function resolveAcpSelectConfigUpdate( - session: unknown, + session: WireInput, options: { category: string; label: string; @@ -832,41 +935,53 @@ function resolveAcpSelectConfigUpdate( value: string; sessionIdOverride?: string; }, -): { 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[] { +export function flattenAcpSelectValues(option: JsonObject): 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 +997,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 +1005,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 { +function readAcpCapabilities(value: WireInput): AcpCapabilities { const capabilities = asRecord(asRecord(value)?.agentCapabilities); const sessions = asRecord(capabilities?.sessionCapabilities); + return { resume: Boolean(sessions?.resume), close: Boolean(sessions?.close), @@ -907,12 +1026,13 @@ function readAcpCapabilities(value: unknown): AcpCapabilities { }; } -function extractAcpText(updates: unknown[]): string { +function extractAcpText(updates: WireInput[]): string { return updates .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" + + return update?.sessionUpdate === "agent_message_chunk" && content?.type === "text" && isJsonString(content.text) ? content.text : ""; }) @@ -920,18 +1040,20 @@ function extractAcpText(updates: unknown[]): string { .trim(); } -function isGrokPromptCompletion(value: unknown): boolean { +function isGrokPromptCompletion(value: WireInput): boolean { const record = asRecord(value); - return typeof record?.sessionId === "string"; + + return isJsonString(record?.sessionId); } async function yieldToAcpQueue(): Promise { await new Promise((resolve) => setImmediate(resolve)); } -function hasAcpConfigOptions(value: unknown): boolean { +function hasAcpConfigOptions(value: WireInput): boolean { const record = asRecord(value); const response = asRecord(record?.newSessionResponse) ?? record; + return Array.isArray(response?.configOptions); } @@ -941,10 +1063,11 @@ export function deliverAcpSessionUpdate( params: { sessionId: string }, ): void { const queue = queues.get(params.sessionId); + if (queue) appendAcpQueueValue(queue, params); } -function appendAcpQueueValue(queue: AcpSessionQueue, value: unknown): void { +function appendAcpQueueValue(queue: AcpSessionQueue, value: WireInput): void { if (queue.values.length >= MAX_ACP_QUEUE_ITEMS) queue.values.shift(); queue.values.push(value); queue.onUpdate?.(value); @@ -960,31 +1083,42 @@ export class AcpActivityReporter { constructor(private readonly onEvent: (event: LocalAgentActivityEvent) => void) {} - handle(value: unknown): void { + handle(value: WireInput): 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; + + if (content?.type === "text" && isJsonString(content.text)) 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), + tool: isJsonString(update.kind) && update.kind ? update.kind : "tool", + summary: isJsonString(update.title) ? update.title : summarizeToolInput(update.rawInput), }; - const id = typeof update.toolCallId === "string" ? update.toolCallId : undefined; + + const id = isJsonString(update.toolCallId) ? 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 id = isJsonString(update.toolCallId) ? update.toolCallId : undefined; const call = id ? this.toolCalls.get(id) : undefined; - if (call && typeof update.title === "string") call.summary = update.title; + + if (call && isJsonString(update.title)) call.summary = update.title; this.complete(id, update.status); + return; } } @@ -995,9 +1129,10 @@ export class AcpActivityReporter { this.text = ""; } - private complete(id: string | undefined, status: unknown): void { + private complete(id: string | undefined, status: WireInput): 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 +1141,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 { @@ -1023,22 +1162,25 @@ async function withTimeout(promise: Promise, timeoutMs: number, message: s } } -function readArray(value: unknown, key: string): unknown[] | undefined { +function readArray(value: WireInput, key: string): unknown[] | undefined { const result = asRecord(value)?.[key]; + return Array.isArray(result) ? result : undefined; } -function readString(value: unknown, key: string): string | undefined { +function readString(value: WireInput, key: string): string | undefined { const result = asRecord(value)?.[key]; - return typeof result === "string" ? result : undefined; + + return isJsonString(result) ? result : undefined; } -function directString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; +function directString(value: WireInput): string | undefined { + return isJsonString(value) && value.trim() ? value.trim() : undefined; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record +function asRecord(value: WireInput): JsonObject | undefined { + return value !== null && isObjectValue(value) && !Array.isArray(value) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(value) : undefined; } diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index cbcec0617..320513f91 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { delimiter } from "node:path"; import test from "node:test"; @@ -19,7 +20,7 @@ test("ACP model and effort config updates resolve flat and grouped options and f { resolve: resolveAcpModelConfigUpdate, category: "model", flavor: "cursor" as const, missing: /does not expose a model/ }, { resolve: resolveAcpEffortConfigUpdate, category: "thought_level", flavor: "copilot" as const, missing: /does not expose a reasoning effort option/ }, ]) { - const session = (options: unknown[]) => ({ + const session = (options: WireInput[]) => ({ sessionId: "session_1", newSessionResponse: { configOptions: [{ type: "select", id: "option_id", category, options }] }, }); @@ -214,6 +215,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..ecfbd0cb1 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import { subagentAcpFlavor, type SubagentProviderConfig, @@ -5,7 +6,6 @@ import { } from "./local-agent-config.js"; import { LOCAL_AGENT_DEFAULT_PROVIDER_IDS, - LOCAL_AGENT_DRIVER_KINDS, } from "./local-agent-provider.js"; import { AcpLocalAgentDriver, @@ -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)); } @@ -72,7 +75,7 @@ export function createLocalAgentProviderRegistry( })); } -export function extractLocalAgentResponseText(value: unknown): string { +export function extractLocalAgentResponseText(value: WireInput): string { return extractOpenCodeFinalResponse(value) || extractPiFinalResponse(value); } 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..c5bedf2eb 100644 --- a/src/local-agent-cancellation.ts +++ b/src/local-agent-cancellation.ts @@ -1,3 +1,4 @@ +import { optionalFields } from "./json-value.js"; import { AgentProviderCancelledError } from "./local-agent-errors.js"; import type { LocalAgentDriverKind } from "./local-agent-provider.js"; @@ -11,7 +12,7 @@ export function localAgentCancelledError( provider, operation, retryable: false, - ...(cause === undefined ? {} : { cause }), + ...optionalFields(cause === undefined ? undefined : { cause }), message: `${provider} agent turn was stopped.`, }); } @@ -22,7 +23,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..48a1ead1b 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,24 +54,26 @@ 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]), + visibleProviders.flatMap((provider) => (provider.usable ? [[provider.id, provider] as const] : [])), ); + return { enabled: config.enabled, providers: visibleProviders, - profiles: profiles - .filter((profile) => !profile.disabled && usable.has(profile.provider)) - .map((profile) => { - const provider = usable.get(profile.provider)!; - return { - name: profile.name, - description: profile.description, - provider: profile.provider, - model: profile.model ?? provider.model, - effort: profile.effort ?? provider.effort, - }; - }), + profiles: profiles.flatMap((profile) => { + if (profile.disabled || !usable.has(profile.provider)) return []; + const provider = usable.get(profile.provider)!; + + return [{ + name: profile.name, + description: profile.description, + provider: profile.provider, + model: profile.model ?? provider.model, + effort: profile.effort ?? provider.effort, + }]; + }), }; } @@ -85,6 +88,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..eb1185cc2 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -1,3 +1,5 @@ +import { isJsonObject } from "./json-value.js"; +import type { JsonObject } from "./json-value.js"; import assert from "node:assert/strict"; import test from "node:test"; import { @@ -16,7 +18,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { closeCount = 0; model?: string; permissionModes: string[] = []; - flagSettings: Array> = []; + flagSettings: Array = []; constructor(prompt: AsyncIterable) { this.iterator = prompt[Symbol.asyncIterator](); @@ -29,13 +31,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 +49,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { }, }; } + return { done: false, value: { @@ -64,7 +70,7 @@ class FakeClaudeQuery implements ClaudeQueryLike, AsyncIterator { this.permissionModes.push(mode); } - async applyFlagSettings(settings: Record): Promise { + async applyFlagSettings(settings: JsonObject): Promise { this.flagSettings.push({ ...settings }); } @@ -86,15 +92,20 @@ const context: LocalAgentRuntimeContext = { function claudeDriverFixture() { const state = { factoryCalls: 0, - lastOptions: undefined as Record | undefined, + // SAFETY: The surrounding checks or construction already match this type. + lastOptions: undefined as JsonObject | undefined, + // SAFETY: The surrounding checks or construction already match this type. 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 }; } @@ -114,7 +125,7 @@ const brokenStreamQuery: ClaudeQueryLike = { async applyFlagSettings() {}, }; -function configuredClaudeDriver(onOptions: (options: Record) => void) { +function configuredClaudeDriver(onOptions: (options: JsonObject) => void) { return createLocalAgentDrivers({ env: { PATH: "/usr/bin", @@ -133,6 +144,7 @@ function configuredClaudeDriver(onOptions: (options: Record) => }), claudeQueryFactory: ({ prompt, options }) => { onOptions(options); + return new FakeClaudeQuery(prompt); }, }).find((driver) => driver.provider === "claude"); @@ -151,9 +163,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 +177,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"); @@ -191,29 +212,41 @@ test("successive turns reuse one Claude query and apply each turn's authority", assert.equal(state.lastOptions?.allowDangerouslySkipPermissions, undefined); assert.deepEqual(state.lastOptions?.allowedTools, ["Read(/**)", "Edit(/**)", "Bash"]); assert.equal(state.lastOptions?.pathToClaudeCodeExecutable, undefined); - const initialSandbox = state.lastOptions?.sandbox as Record; + // SAFETY: The surrounding checks or construction already match this type. + const initialSandbox = state.lastOptions?.sandbox as JsonObject; assert.equal(initialSandbox.enabled, true); assert.equal(initialSandbox.failIfUnavailable, true); assert.equal(initialSandbox.autoAllowBashIfSandboxed, true); assert.equal(initialSandbox.allowUnsandboxedCommands, false); - assert.deepEqual((initialSandbox.filesystem as Record).allowWrite, []); - assert.deepEqual((initialSandbox.filesystem as Record).denyWrite, ["/tmp/project"]); + // SAFETY: The surrounding checks or construction already match this type. + assert.deepEqual((initialSandbox.filesystem as JsonObject).allowWrite, []); + // SAFETY: The surrounding checks or construction already match this type. + assert.deepEqual((initialSandbox.filesystem as JsonObject).denyWrite, ["/tmp/project"]); assert.deepEqual(sessionIds, ["claude_session_1"]); assert.deepEqual(state.query?.permissionModes, ["dontAsk", "dontAsk", "bypassPermissions"]); assert.equal(state.query?.flagSettings.length, 3); assert.equal(state.query?.flagSettings[0]?.alwaysThinkingEnabled, true); assert.equal(state.query?.flagSettings[0]?.effortLevel, "high"); assert.equal( - (state.query?.flagSettings[0]?.permissions as Record).defaultMode, + // SAFETY: The surrounding checks or construction already match this type. + isJsonObject(state.query?.flagSettings[0]?.permissions) + ? state.query.flagSettings[0].permissions.defaultMode + : undefined, "dontAsk", ); assert.equal(state.query?.flagSettings[1]?.effortLevel, "low"); + + const deny = isJsonObject(state.query?.flagSettings[1]?.permissions) + ? state.query.flagSettings[1].permissions.deny + : undefined; + + assert.equal(Array.isArray(deny) && deny.includes("Edit"), false); + assert.equal( - ((state.query?.flagSettings[1]?.permissions as Record).deny as string[]).includes("Edit"), - false, - ); - assert.equal( - (state.query?.flagSettings[2]?.permissions as Record).defaultMode, + // SAFETY: The surrounding checks or construction already match this type. + isJsonObject(state.query?.flagSettings[2]?.permissions) + ? state.query.flagSettings[2].permissions.defaultMode + : undefined, "bypassPermissions", ); @@ -224,23 +257,30 @@ test("successive turns reuse one Claude query and apply each turn's authority", test("authority settings map each write mode to permissions and sandbox", () => { const allowedSettings = claudeAuthoritySettings("/tmp/project", "allowed"); - const allowedPermissions = allowedSettings.permissions as Record; - const allowedSandbox = allowedSettings.sandbox as Record; + // SAFETY: The surrounding checks or construction already match this type. + const allowedPermissions = allowedSettings.permissions as JsonObject; + // SAFETY: The surrounding checks or construction already match this type. + const allowedSandbox = allowedSettings.sandbox as JsonObject; assert.deepEqual(allowedPermissions.deny, []); assert.deepEqual(allowedSandbox.filesystem, { allowWrite: ["/tmp/project"], denyWrite: [], }); const readOnlySettings = claudeAuthoritySettings("/tmp/project", "read_only"); - const readOnlyPermissions = readOnlySettings.permissions as Record; + // SAFETY: The surrounding checks or construction already match this type. + const readOnlyPermissions = readOnlySettings.permissions as JsonObject; + // SAFETY: The surrounding checks or construction already match this type. assert.ok((readOnlyPermissions.deny as string[]).includes("Bash")); + // SAFETY: The surrounding checks or construction already match this type. assert.ok((readOnlyPermissions.deny as string[]).includes("Edit")); assert.deepEqual( - ((readOnlySettings.sandbox as Record).filesystem as Record).allowWrite, + // SAFETY: The surrounding checks or construction already match this type. + ((readOnlySettings.sandbox as JsonObject).filesystem as JsonObject).allowWrite, [], ); const fullSettings = claudeAuthoritySettings("/tmp/project", "full_access"); - assert.deepEqual((fullSettings.sandbox as Record), { + // SAFETY: The surrounding checks or construction already match this type. + assert.deepEqual((fullSettings.sandbox as JsonObject), { enabled: false, allowUnsandboxedCommands: true, }); @@ -254,14 +294,18 @@ test("a cold runtime resumes its provider session", async () => { }); test("CLAUDE_COMMAND overrides the Claude Code executable", async () => { - let lastOptions: Record | undefined; + let lastOptions: JsonObject | 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 +313,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 +323,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 +354,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"); @@ -321,11 +373,12 @@ test("programmer defects are not reclassified as provider failures", async () => }); test("a configured provider command and env reach the Claude SDK", async () => { - let configuredOptions: Record | undefined; + let configuredOptions: JsonObject | undefined; const configuredDriver = configuredClaudeDriver((options) => { configuredOptions = options; }); 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 +396,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..4024b27bc 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isJsonString, isObjectValue, optionalFields } from "./json-value.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import { AgentProviderExecutionError, AgentProviderProtocolError, @@ -32,13 +35,13 @@ export interface ClaudeQueryLike extends AsyncIterable { close(): void; interrupt(): Promise; setPermissionMode(mode: ClaudePermissionMode): Promise; - applyFlagSettings(settings: Record): Promise; + applyFlagSettings(settings: JsonObject): Promise; setModel?(model?: string): Promise; } export interface ClaudeQueryFactoryInput { context: LocalAgentRuntimeContext; - options: Record; + options: JsonObject; prompt: AsyncIterable; } @@ -57,6 +60,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 +68,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 +78,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 +117,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 +145,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 +165,7 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude query stream failed.", }); } + if (next.done) { this.alive = false; throw new AgentProviderProtocolError({ @@ -158,21 +176,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") { + + if (isJsonString(record?.session_id)) { 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 +208,9 @@ export class ClaudeQueryRuntime implements LocalAgentRuntime { message: "Claude agent turn failed.", }); } - const finalResponse = typeof record.result === "string" ? record.result.trim() : ""; + + const finalResponse = isJsonString(record.result) ? record.result.trim() : ""; + if (!finalResponse) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -193,6 +220,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 +280,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 +289,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,36 +307,37 @@ async function defaultClaudeQueryFactory({ prompt, }: ClaudeQueryFactoryInput): Promise { const { query } = await import("@anthropic-ai/claude-agent-sdk"); - return query({ + + return unsafeNarrow(query({ prompt, + // SAFETY: The surrounding checks or construction already match this type. options: options as never, - }) as unknown as ClaudeQueryLike; + })); } export function claudeQueryOptions( context: LocalAgentRuntimeContext, input: LocalAgentRunInput, env: NodeJS.ProcessEnv = process.env, -): Record { +): JsonObject { 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 } : {}), - ...(input.effort ? { thinking: { type: "adaptive" }, effort: input.effort } : {}), - ...(context.providerSessionId ? { resume: context.providerSessionId } : {}), + ...optionalFields(input.model ? { model: input.model } : undefined), + ...optionalFields(input.effort ? { thinking: { type: "adaptive" }, effort: input.effort } : undefined), + ...optionalFields(context.providerSessionId ? { resume: context.providerSessionId } : undefined), permissionMode, // Restricted runtimes stay warm across read_only/allowed turns. Keep the // workspace capabilities static and narrow individual turns with deny rules. - ...(input.writeMode === "full_access" - ? {} - : { allowedTools: [...CLAUDE_WORKSPACE_ALLOWED_TOOLS] }), + ...optionalFields(input.writeMode === "full_access" ? undefined : { allowedTools: [...CLAUDE_WORKSPACE_ALLOWED_TOOLS] }), sandbox: authority.sandbox, settings: authority.settings, - ...(input.writeMode === "full_access" ? { allowDangerouslySkipPermissions: true } : {}), + ...optionalFields(input.writeMode === "full_access" ? { allowDangerouslySkipPermissions: true } : undefined), env: claudeCommandEnvironment(env), - ...(executable ? { pathToClaudeCodeExecutable: executable } : {}), + ...optionalFields(executable ? { pathToClaudeCodeExecutable: executable } : undefined), }; } @@ -324,19 +356,20 @@ export function claudePermissionMode( export function claudeAuthoritySettings( workspaceRoot: string, writeMode: LocalAgentWriteMode | undefined, -): Record { +): JsonObject { return claudeAuthorityOptions(workspaceRoot, writeMode).settings; } function claudeAuthorityOptions( workspaceRoot: string, writeMode: LocalAgentWriteMode | undefined, -): { sandbox: Record; settings: Record } { +) { if (writeMode === "full_access") { const sandbox = { enabled: false, allowUnsandboxedCommands: true, }; + return { sandbox, settings: { @@ -347,10 +380,12 @@ function claudeAuthorityOptions( } const allowed = writeMode !== "read_only"; + const permissions = { defaultMode: "dontAsk", deny: allowed ? [] : ["Bash", "Edit"], }; + const sandbox = { enabled: true, failIfUnavailable: true, @@ -361,11 +396,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 +411,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; +export function claudeResultError(record: JsonObject): string | undefined { + const subtype = isJsonString(record.subtype) ? 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}`; } @@ -397,23 +438,28 @@ interface ClaudeToolCall { /** Maps complete SDK messages (no partial deltas) onto DevSpace activity events. */ export function reportClaudeActivity( - record: Record, + record: JsonObject, toolCalls: Map, onEvent: (event: LocalAgentActivityEvent) => void, ): 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") { + + if (record.type === "assistant" && block?.type === "text" && isJsonString(block.text)) { onEvent({ type: "message", text: block.text }); - } else if (record.type === "assistant" && block?.type === "tool_use" && typeof block.name === "string") { + } else if (record.type === "assistant" && block?.type === "tool_use" && isJsonString(block.name)) { const call = { tool: block.name, summary: summarizeToolInput(block.input) }; - if (typeof block.id === "string") toolCalls.set(block.id, call); + + if (isJsonString(block.id)) 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") { + } else if (record.type === "user" && block?.type === "tool_result" && isJsonString(block.tool_use_id)) { 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" }); @@ -428,16 +474,17 @@ export interface ClaudeUserMessage { } /** The SDK reports a missing or unlaunchable Claude CLI as a ReferenceError, not as a spawn error. */ -function isClaudeExecutableError(error: unknown): boolean { +function isClaudeExecutableError(error: WireInput): boolean { return error instanceof ReferenceError && /^Claude Code (native binary|executable) /.test(error.message); } -function directString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; +function directString(value: WireInput): string | undefined { + return isJsonString(value) && value.trim() ? value.trim() : undefined; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record +function asRecord(value: WireInput): JsonObject | undefined { + return value !== null && isObjectValue(value) && !Array.isArray(value) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(value) : undefined; } diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 04a0b0e07..36880d927 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { optionalFields } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import { existsSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; @@ -7,12 +10,10 @@ import { matchError, Result, type Result as BetterResult } from "better-result"; import type { AgentConfig } from "./config.js"; import { AgentDaemonConfigChangedError, - AgentDaemonInvalidRequestError, AgentDaemonInvalidResponseError, AgentDaemonProtocolMismatchError, AgentDaemonStartupError, AgentDaemonTimeoutError, - AgentDaemonUnauthorizedError, AgentDaemonUnavailableError, AgentProviderUnavailableError, agentErrorFromPayload, @@ -131,6 +132,7 @@ export class LocalAgentClient { input: StartLocalAgentInput, ): Promise> { const result = await this.request("agent.start", input); + return decodeRequestResult(result, "agent.start", decodeAgentRecord); } @@ -144,8 +146,9 @@ export class LocalAgentClient { id: agentId, prompt, scope, - ...(Object.keys(overrides).length > 0 ? { overrides } : {}), + ...optionalFields(Object.keys(overrides).length > 0 ? { overrides } : undefined), }); + return decodeRequestResult(result, "agent.continue", decodeAgentRecord); } @@ -154,6 +157,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 +165,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("agent.list", scope); + return decodeRequestResult(result, "agent.list", decodeAgentRecordList); } @@ -172,11 +177,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 }), + ...optionalFields(timeoutMs === undefined ? undefined : { timeoutMs }), }, transportTimeoutMs); + return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); } @@ -185,6 +192,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 +202,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 +210,7 @@ export class LocalAgentClient { input: WorkflowRunInput, ): Promise> { const result = await this.request("workflow.run", input); + return decodeRequestResult(result, "workflow.run", decodeWorkflowRun); } @@ -209,6 +219,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("workflow.get", { id, scope }); + return decodeRequestResult(result, "workflow.get", decodeWorkflowRun); } @@ -216,6 +227,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("workflow.list", scope); + return decodeRequestResult(result, "workflow.list", decodeWorkflowRunList); } @@ -225,6 +237,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 +246,7 @@ export class LocalAgentClient { scope: LocalAgentWorkspaceScope, ): Promise> { const result = await this.request("workflow.calls", { id, scope }); + return decodeRequestResult(result, "workflow.calls", decodeWorkflowCallList); } @@ -242,6 +256,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 +266,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 +275,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 +302,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 +334,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 +368,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 +379,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 +401,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 +439,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 +458,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 +494,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 +510,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", @@ -489,26 +549,36 @@ export class LocalAgentClient { method: M, params: Extract['params'], timeoutMs: number | null = this.requestTimeoutMs, - ): Promise>> { + ): Promise>> { const ready = await (isObservationRequest(method) ? this.ensureReadyForObservation() : this.ensureReady()); - if (ready.isErr()) return ready as BetterResult>; + + // SAFETY: The surrounding checks or construction already match this type. + if (ready.isErr()) return unsafeNarrow>>(ready); const authToken = this.authTokenResult(method); - if (authToken.isErr()) return authToken as BetterResult>; - const response = await sendRequest(this.endpoint, { + + // SAFETY: The surrounding checks or construction already match this type. + if (authToken.isErr()) return unsafeNarrow>>(authToken); + + // SAFETY: The surrounding checks or construction already match this type. + const response = await sendRequest(this.endpoint, unsafeNarrow({ requestId: randomUUID(), protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: authToken.value, method, params, - } as LocalAgentDaemonRequest, timeoutMs ?? undefined); - if (response.isErr()) return response as BetterResult>; + }), timeoutMs ?? undefined); + + if (response.isErr()) return unsafeNarrow>>(response); + if (!response.value.ok) { const error = isWorkflowMethod(method) ? decodeWorkflowRemoteError(response.value.error) : decodeRemoteError(response.value.error, method); + if (!isRequestError(method, error)) { + // SAFETY: The surrounding checks or construction already match this type. return Result.err(new AgentDaemonInvalidResponseError({ code: "DAEMON_INVALID_RESPONSE", operation: method, @@ -517,8 +587,11 @@ export class LocalAgentClient { message: "Local agent daemon returned an error that is invalid for this request.", })) as BetterResult>; } + + // SAFETY: The surrounding checks or construction already match this type. return Result.err(error) as BetterResult>; } + return Result.ok(response.value.result); } @@ -527,7 +600,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 +611,8 @@ export class LocalAgentClient { message: "Local agent daemon is not running.", })); } + + // SAFETY: The surrounding checks or construction already match this type. const response = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -543,10 +620,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 +636,7 @@ export class LocalAgentClient { message: "Local agent daemon returned an invalid daemon-control error.", })); } + return Result.ok(response.value.result); } @@ -565,6 +647,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 +665,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 +701,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 +721,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 +756,7 @@ async function sendRequest( const socket = createConnection(endpoint); let buffer = ""; let settled = false; + const timer = timeoutMs === undefined ? undefined : setTimeout(() => { @@ -680,7 +774,9 @@ async function sendRequest( ) => { if (settled) return; settled = true; + if (timer) clearTimeout(timer); + if (destroy) socket.destroy(); resolve(result); }; @@ -689,12 +785,17 @@ async function sendRequest( socket.on("data", (chunk: string | Buffer) => { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); + if (newline === -1) return; + try { + // SAFETY: The surrounding checks or construction already match this type. 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) { @@ -729,17 +830,17 @@ async function sendRequest( } function decodeRequestResult( - result: BetterResult, + result: BetterResult, operation: string, - decode: (value: unknown) => T, + decode: (value: WireInput) => T, ): BetterResult { return result.andThen((value) => decodeValue(value, operation, decode)); } function decodeValue( - value: unknown, + value: WireInput, operation: string, - decode: (value: unknown) => T, + decode: (value: WireInput) => T, ): BetterResult { try { return Result.ok(decode(value)); @@ -759,6 +860,7 @@ function decodeRemoteError( operation: string, ): LocalAgentError { const decoded = agentErrorFromPayload(payload); + return decoded ?? new AgentDaemonInvalidResponseError({ code: "DAEMON_INVALID_RESPONSE", operation, @@ -770,6 +872,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 +889,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 +909,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..b7b66e62d 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -1,6 +1,8 @@ -import { homedir } from "node:os"; +import { isJsonString, isJsonNumber, isObjectValue, optionalFields } from "./json-value.js"; +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { delimiter, join, resolve } from "node:path"; +import { delimiter, resolve } from "node:path"; import { createInterface } from "node:readline"; import { AgentProviderExecutionError, @@ -33,14 +35,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 +55,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 +79,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 +145,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 +165,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 +189,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { message: "Codex agent turn failed.", }); } + if (!parsed.finalResponse.trim()) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -179,6 +200,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { message: "Codex did not return a final assistant response.", }); } + return { provider: this.provider, providerSessionId: threadId, @@ -191,6 +213,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 +230,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 +251,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 +295,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 +305,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 +315,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 +346,7 @@ export class CodexLocalAgentDriver implements LocalAgentDriver { this.resolvedCommand = this.commandResolver(this.env); this.commandResolved = true; } + return this.resolvedCommand; } } @@ -319,18 +356,18 @@ const MAX_STDERR_BYTES = 32 * 1024; interface CodexEvent { method: string; - params?: unknown; + params?: WireInput; } interface CodexTurnResult { event: CodexEvent; - items: unknown[]; + items: WireInput[]; } interface CodexTurnAccumulator { threadId: string; turnId?: string; - items: unknown[]; + items: WireInput[]; onEvent?: (event: LocalAgentActivityEvent) => void; completed?: CodexEvent; resolve: (result: CodexTurnResult) => void; @@ -339,7 +376,7 @@ interface CodexTurnAccumulator { class CodexAppServerRpc { private readonly pending = new Map void; + resolve: (value: WireInput) => void; reject: (error: Error) => void; }>(); private readonly turns = new Map(); @@ -359,33 +396,37 @@ class CodexAppServerRpc { }); } - request(method: string, params?: unknown): Promise { + request(method: string, params?: WireInput): 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 }) }); + this.write({ id, method, ...optionalFields(params === undefined ? undefined : { params }) }); }); } - notify(method: string, params?: unknown): void { - this.write({ method, ...(params === undefined ? {} : { params }) }); + notify(method: string, params?: WireInput): void { + this.write({ method, ...optionalFields(params === undefined ? undefined : { params }) }); } async runTurn( threadId: string, - params: unknown, + params: WireInput, signal?: AbortSignal, 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 +434,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,13 +465,15 @@ 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(); } - private write(message: Record): void { + private write(message: JsonObject): void { if (this.fatalError) throw this.fatalError; this.child.stdin.write(`${JSON.stringify(message)}\n`); } @@ -432,38 +482,55 @@ class CodexAppServerRpc { this.buffer += line; const trimmed = this.buffer.trim(); this.buffer = ""; + if (!trimmed) return; - let message: Record; + let message: JsonObject; + try { - message = JSON.parse(trimmed) as Record; + // SAFETY: The surrounding checks or construction already match this type. + message = JSON.parse(trimmed) as JsonObject; } 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; + + const id = isJsonString(message.id) || isJsonNumber(message.id) ? String(message.id) : undefined; + const method = isJsonString(message.method) ? 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() }); @@ -471,52 +538,68 @@ 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" + const threadId = isJsonString(params?.threadId) ? params.threadId : undefined; + + const turnId = isJsonString(params?.turnId) ? 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); } } export function reportCodexActivity( method: string, - item: unknown, + item: WireInput, onEvent: (event: LocalAgentActivityEvent) => void, ): 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 }); + if (method === "item/completed" && isJsonString(record.text)) 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" }); } -function codexTool(item: Record): { tool: string; summary?: string } | undefined { +function codexTool(item: JsonObject): { tool: string; summary?: string } | undefined { switch (item.type) { case "commandExecution": return { tool: "shell", summary: summarizeToolInput(item) }; 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(", ") } : {}) }; + + return { tool: "edit", ...optionalFields(paths.length > 0 ? { summary: paths.join(", ") } : undefined) }; } + 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: @@ -524,24 +607,24 @@ function codexTool(item: Record): { tool: string; summary?: str } } -function threadParams(input: LocalAgentRunInput): Record { +function threadParams(input: LocalAgentRunInput): JsonObject { return { - ...(input.providerSessionId ? { threadId: input.providerSessionId } : {}), + ...optionalFields(input.providerSessionId ? { threadId: input.providerSessionId } : undefined), cwd: input.workspaceRoot, approvalPolicy: "never", sandbox: sandboxFor(input.writeMode), - ...(input.model ? { model: input.model } : {}), + ...optionalFields(input.model ? { model: input.model } : undefined), }; } -function turnParams(input: LocalAgentRunInput, threadId: string): Record { +function turnParams(input: LocalAgentRunInput, threadId: string): JsonObject { return { threadId, input: [{ type: "text", text: input.prompt }], approvalPolicy: "never", sandboxPolicy: sandboxPolicyFor(input.writeMode), - ...(input.model ? { model: input.model } : {}), - ...(input.effort ? { effort: input.effort } : {}), + ...optionalFields(input.model ? { model: input.model } : undefined), + ...optionalFields(input.effort ? { effort: input.effort } : undefined), }; } @@ -554,7 +637,7 @@ export function sandboxFor(writeMode: LocalAgentWriteMode | undefined): string { } } -function sandboxPolicyFor(writeMode: LocalAgentWriteMode | undefined): Record { +function sandboxPolicyFor(writeMode: LocalAgentWriteMode | undefined) { switch (writeMode) { case "allowed": return { type: "workspaceWrite", networkAccess: true }; case "full_access": return { type: "dangerFullAccess" }; @@ -563,28 +646,30 @@ function sandboxPolicyFor(writeMode: LocalAgentWriteMode | undefined): Record 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") { + + if ((type === "agentMessage" || type === "agent_message") && isJsonString(record.text)) { 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 +684,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}`))); @@ -614,45 +702,55 @@ 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" + const eventThreadId = isJsonString(params?.threadId) ? params.threadId : undefined; + + const eventTurnId = isJsonString(params?.turnId) ? 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); } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record +function asRecord(value: WireInput): JsonObject | undefined { + return value !== null && isObjectValue(value) && !Array.isArray(value) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(value) : undefined; } -function readString(value: unknown, key: string): string | undefined { +function readString(value: WireInput, key: string): string | undefined { const result = asRecord(value)?.[key]; - return typeof result === "string" ? result : undefined; + + return isJsonString(result) ? result : undefined; } -function directString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; +function directString(value: WireInput): string | undefined { + return isJsonString(value) && value.trim() ? value.trim() : undefined; } -function protocolErrorText(value: unknown): string { +function protocolErrorText(value: WireInput): 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); } -function errorMessage(error: unknown): string { +function errorMessage(error: WireInput): string { return error instanceof Error ? error.message : String(error); } 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..3bfdcd8c5 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -1,8 +1,9 @@ +import { optionalFields } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import { createHash } from "node:crypto"; import * as z from "zod/v4"; import { defaultDriverForProviderId, - isLocalAgentDriverKind, isLegacyAcpDriverKind, LEGACY_ACP_DRIVER_KINDS, LOCAL_AGENT_DEFAULT_PROVIDER_IDS, @@ -16,7 +17,7 @@ const environmentSchema = z.record( z.string(), ); -const providerShape = { +const providerFields = { enabled: z.boolean(), model: z.string().trim().min(1).optional(), effort: z.string().trim().min(1).optional(), @@ -39,21 +40,24 @@ const inputDriverKinds = [...LOCAL_AGENT_DRIVER_KINDS, ...LEGACY_ACP_DRIVER_KIND const providerSchema = z.object({ id: z.string().trim().min(1), driver: z.enum(inputDriverKinds).optional(), - ...providerShape, + ...providerFields, command: commandSchema, config: acpConfigSchema.optional(), }).strict().superRefine((provider, context) => { 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 +65,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 +77,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 +93,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 +102,7 @@ export const subagentsConfigSchema = z.object({ message: `Duplicate subagent provider: ${provider.id}`, }); } + seen.add(provider.id); } }); @@ -104,6 +113,7 @@ export const storedSubagentsConfigSchema = z.union([ ]); type ParsedSubagentProviderConfig = z.infer; + type ParsedSubagentsConfig = z.infer; export interface SubagentProviderConfig extends Omit { @@ -123,14 +133,16 @@ 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, - ...(driver === "acp" && flavor ? { config: { ...provider.config, flavor } } : {}), + ...optionalFields(driver === "acp" && flavor ? { config: { ...provider.config, flavor } } : undefined), }; }), }; @@ -165,7 +177,7 @@ export function implicitSubagentsConfig(): SubagentsConfig { }; } -export function parseSubagentsConfig(value: unknown): SubagentsConfig { +export function parseSubagentsConfig(value: WireInput): SubagentsConfig { return resolveSubagentsConfig(subagentsConfigSchema.parse(value)); } @@ -192,14 +204,16 @@ 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; } export function localAgentProviderEnvironmentOverrides( config: SubagentsConfig, providerInstanceId: LocalAgentProviderInstanceId, -): Record { +) { return { ...subagentProviderConfig(config, providerInstanceId)?.env }; } @@ -221,18 +235,17 @@ export function localAgentProviderConfigRevision(config: SubagentsConfig): strin id: provider.id, driver: provider.driver, enabled: provider.enabled, - ...(provider.model ? { model: provider.model } : {}), - ...(provider.effort ? { effort: provider.effort } : {}), - ...("command" in provider && provider.command ? { command: provider.command } : {}), - ...(provider.env && Object.keys(provider.env).length > 0 - ? { + ...optionalFields(provider.model ? { model: provider.model } : undefined), + ...optionalFields(provider.effort ? { effort: provider.effort } : undefined), + ...optionalFields("command" in provider && provider.command ? { command: provider.command } : undefined), + ...optionalFields(provider.env && Object.keys(provider.env).length > 0 ? { env: Object.fromEntries( Object.entries(provider.env).sort(([left], [right]) => left.localeCompare(right)), ), - } - : {}), - ...(provider.config ? { config: provider.config } : {}), + } : undefined), + ...optionalFields(provider.config ? { config: provider.config } : undefined), })); + 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..5c79cf6e7 100644 --- a/src/local-agent-daemon-lifecycle.test.ts +++ b/src/local-agent-daemon-lifecycle.test.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -20,12 +21,12 @@ test("a live lock owner excludes other daemons even with a stale diagnostic PID" assert.equal(await readFile(paths.lockPath, "utf8"), `${process.pid}\n`); assert.throws( () => new LocalAgentDaemonLock(paths).acquire(), - (error: unknown) => error instanceof LocalAgentDaemonAlreadyRunningError, + (error: WireInput) => error instanceof LocalAgentDaemonAlreadyRunningError, ); await writeFile(paths.pidPath, "999999\n", { mode: 0o600 }); assert.throws( () => new LocalAgentDaemonLock(paths).acquire(), - (error: unknown) => error instanceof LocalAgentDaemonAlreadyRunningError, + (error: WireInput) => error instanceof LocalAgentDaemonAlreadyRunningError, "a stale diagnostic PID must not override the live lock owner", ); assert.equal(ensureLocalAgentDaemonSecret(paths).length, 64); @@ -49,7 +50,7 @@ test("an undecodable lock fails closed", async (t) => { await writeFile(paths.lockPath, "not-a-pid\n", { mode: 0o600 }); assert.throws( () => new LocalAgentDaemonLock(paths).acquire(), - (error: unknown) => error instanceof LocalAgentDaemonAlreadyRunningError, + (error: WireInput) => error instanceof LocalAgentDaemonAlreadyRunningError, "an undecodable lock must fail closed instead of being deleted by age", ); assert.equal(await readFile(paths.lockPath, "utf8"), "not-a-pid\n"); @@ -71,5 +72,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..a517256ab 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import { createHash, randomBytes } from "node:crypto"; import { chmodSync, @@ -35,6 +36,7 @@ export function localAgentDaemonPaths( ): LocalAgentDaemonPaths { const resolvedStateDir = resolve(stateDir); const socketPath = join(resolvedStateDir, LOCAL_AGENT_DAEMON_SOCKET_NAME); + return { stateDir: resolvedStateDir, socketPath, @@ -69,9 +71,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 +86,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 +131,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 +156,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 +166,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 +175,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,14 +195,17 @@ export function readDaemonPid(pidPath: string): number | undefined { export function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); + return true; } catch (error) { + // SAFETY: The surrounding checks or construction already match this type. return (error as NodeJS.ErrnoException).code === "EPERM"; } } function writeFileSecure(path: string, content: string): void { const fileDescriptor = openSync(path, "w", 0o600); + try { writeSync(fileDescriptor, content); chmodSync(path, 0o600); @@ -187,20 +214,24 @@ function writeFileSecure(path: string, content: string): void { } } -function isFileExistsError(error: unknown): boolean { +function isFileExistsError(error: WireInput): boolean { + // SAFETY: The surrounding checks or construction already match this type. return (error as NodeJS.ErrnoException).code === "EEXIST"; } 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) { + // SAFETY: The surrounding checks or construction already match this type. if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index c1cf6b6c3..1a9761c5f 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import type { JsonObject } from "./json-value.js"; import { loadAgentConfig } from "./config.js"; import { createLocalAgentDrivers } from "./local-agent-adapters.js"; import { localAgentProviderAvailability } from "./local-agent-availability.js"; @@ -20,12 +21,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, + fields: JsonObject, ) => writeLocalAgentDaemonLog(paths, level, event, fields); + const store = new LocalAgentStore(paths.stateDir); + const manager = new LocalAgentManager({ store, drivers: createLocalAgentDrivers({ subagents: config.subagents }), @@ -41,6 +45,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 +57,7 @@ const workflows = workflowsEnabled() config, }) : undefined; + const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, manager, @@ -59,6 +65,7 @@ const daemon = new LocalAgentDaemon({ configRevision: localAgentProviderConfigRevision(config.subagents), onLockAcquired: () => { const reconciled = manager.reconcileActiveRuns(); + if (reconciled.isErr()) throw reconciled.error; workflows?.reconcile(); }, @@ -67,9 +74,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 +89,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 +106,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 +117,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..366b80e45 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -1,3 +1,4 @@ +import type { WireInput, JsonObject } from "./json-value.js"; import assert from "node:assert/strict"; import test from "node:test"; import { @@ -14,7 +15,7 @@ import { } from "./local-agent-daemon-protocol.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; -function request(method: string, params: unknown, extra: Record = {}) { +function request(method: string, params: WireInput, extra: JsonObject = {}) { return decodeLocalAgentDaemonRequest({ requestId: "req", protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -62,6 +63,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" }); @@ -92,7 +94,7 @@ test("requests keep every field the daemon acts on", () => { test("invalid requests are rejected", () => { assert.throws( () => request("agent.start", { target: "reviewer", prompt: "" }), - (error: unknown) => error instanceof LocalAgentDaemonProtocolError && error.code === "INVALID_PARAMS", + (error: WireInput) => error instanceof LocalAgentDaemonProtocolError && error.code === "INVALID_PARAMS", ); assert.throws( () => request("workflow.run", { workspaceRoot: "/tmp/project", name: "review", resume: "wfl_old" }), @@ -115,6 +117,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 +133,7 @@ test("results keep every field and preserve response whitespace", () => { }, configMatches: false, }; + assert.deepEqual(decodeDaemonHello(hello), hello); assert.equal(decodeWorkflowRun({ @@ -170,12 +174,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..6cd168a2f 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -1,3 +1,5 @@ +import { isJsonString, isJsonNumber, isJsonBoolean, isObjectValue, optionalFields } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import type { LocalAgentRecord, LocalAgentStatus, @@ -132,11 +134,12 @@ export function encodeLocalAgentDaemonResponse(response: LocalAgentDaemonRespons return `${JSON.stringify(response)}\n`; } -export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonRequest { +export function decodeLocalAgentDaemonRequest(value: WireInput): LocalAgentDaemonRequest { const record = asRecord(value); const requestId = requiredString(record?.requestId, "requestId"); const protocolVersion = requiredInteger(record?.protocolVersion, "protocolVersion"); const authToken = requiredString(record?.authToken, "authToken"); + // SAFETY: The surrounding checks or construction already match this type. const method = requiredString(record?.method, "method") as LocalAgentDaemonMethod; const params = record?.params; @@ -151,6 +154,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR configRevision: optionalString(record?.configRevision), }; case "daemon.status": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, authToken, method, params: decodeEmptyParams(params) } as LocalAgentDaemonRequest; case "daemon.stop": return { @@ -161,6 +165,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR params: decodeStopParams(params), }; case "agent.start": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, @@ -169,6 +174,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR params: decodeStartInput(params), } as LocalAgentDaemonRequest; case "agent.continue": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, @@ -177,6 +183,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR params: decodeContinueInput(params), } as LocalAgentDaemonRequest; case "agent.get": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, @@ -188,6 +195,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR }, } as LocalAgentDaemonRequest; case "agent.list": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, @@ -196,6 +204,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR params: decodeListScope(params), } as LocalAgentDaemonRequest; case "agent.wait": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, @@ -204,6 +213,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR params: decodeWaitParams(params), } as LocalAgentDaemonRequest; case "agent.stop": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, @@ -215,6 +225,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR }, } as LocalAgentDaemonRequest; case "agent.events": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, @@ -231,6 +242,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR case "workflow.events": { const lookup = decodeWorkflowLookup(params); const after = asRecord(params)?.after; + return { requestId, protocolVersion, @@ -238,14 +250,16 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR method, params: { ...lookup, - ...(after === undefined ? {} : { after: requiredNonNegativeInteger(after, "after") }), + ...optionalFields(after === undefined ? undefined : { after: requiredNonNegativeInteger(after, "after") }), }, }; } + case "workflow.list": return { requestId, protocolVersion, authToken, method, params: decodeWorkspaceScope(params) }; case "workflow.wait": { const lookup = decodeWorkflowLookup(params); + return { requestId, protocolVersion, @@ -254,8 +268,10 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR params: { ...lookup, ...decodeOptionalTimeout(params, 60_000) }, }; } + case "workflow.call": { const lookup = decodeWorkflowLookup(params); + return { requestId, protocolVersion, @@ -267,7 +283,9 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR }, }; } + case "daemon.logs": + // SAFETY: The surrounding checks or construction already match this type. return { requestId, protocolVersion, @@ -280,15 +298,18 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR } } -export function decodeLocalAgentDaemonResponse(value: unknown): LocalAgentDaemonResponse { +export function decodeLocalAgentDaemonResponse(value: WireInput): LocalAgentDaemonResponse { 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 +326,21 @@ export function decodeLocalAgentDaemonResponse(value: unknown): LocalAgentDaemon }, }; } + throw new LocalAgentDaemonProtocolError("INVALID_RESPONSE", "Daemon returned an invalid response."); } -export function decodeAgentRecord(value: unknown): LocalAgentRecord { +export function decodeAgentRecord(value: WireInput): 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), @@ -330,7 +355,7 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { writeMode: decodeWriteMode(record?.writeMode) ?? "allowed", providerSessionId: optionalString(record?.providerSessionId), status, - latestResponse: typeof record?.latestResponse === "string" ? record.latestResponse : undefined, + latestResponse: isJsonString(record?.latestResponse) ? record.latestResponse : undefined, error: optionalContentString(record?.error), errorCode: optionalString(record?.errorCode), errorRetryable: optionalBoolean(record?.errorRetryable), @@ -341,66 +366,78 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { }; } -export function decodeAgentRecordList(value: unknown): LocalAgentRecord[] { +export function decodeAgentRecordList(value: WireInput): LocalAgentRecord[] { if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid agent list."); + return value.map(decodeAgentRecord); } -export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { +export function decodeAgentWaitResults(value: WireInput): 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 }), - ...(worktree === undefined ? {} : { worktree }), - ...(branch === undefined ? {} : { branch }), + ...optionalFields(turnId === undefined ? undefined : { turnId }), + ...optionalFields(worktree === undefined ? undefined : { worktree }), + ...optionalFields(branch === undefined ? undefined : { branch }), }; }); } -function decodeWaitBody(record: Record | undefined): LocalAgentWaitResult { +function decodeWaitBody(record: JsonObject | 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 } : {}) }; + + return { id, status, ...optionalFields(wait ? { wait } : undefined) }; } + case "completed": { - const response = typeof record?.response === "string" ? record.response : undefined; - return { id, status, ...(response === undefined ? {} : { response }) }; + const response = isJsonString(record?.response) ? record.response : undefined; + + return { id, status, ...optionalFields(response === undefined ? undefined : { response }) }; } + case "failed": return { id, status, error: decodeWaitError(record?.error) }; case "stopped": return { id, status, - ...(record?.error === undefined ? {} : { error: decodeWaitError(record.error) }), + ...optionalFields(record?.error === undefined ? undefined : { error: decodeWaitError(record.error) }), }; default: throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait result status."); } } -export function decodeTurnEvents(value: unknown): LocalAgentTurnEvents { +export function decodeTurnEvents(value: WireInput): 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"), @@ -409,54 +446,67 @@ export function decodeTurnEvents(value: unknown): LocalAgentTurnEvents { }; } -function decodeTurnEvent(value: unknown): LocalAgentTurnEvent { +function decodeTurnEvent(value: WireInput): 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 }; case "message": return { type, text: requiredContentString(record?.text, "event.text"), ...base }; case "tool_started": - return { type, tool: requiredString(record?.tool, "event.tool"), ...(summary ? { summary } : {}), ...base }; + return { type, tool: requiredString(record?.tool, "event.tool"), ...optionalFields(summary ? { summary } : undefined), ...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 }; + + return { type, tool: requiredString(record?.tool, "event.tool"), ...optionalFields(summary ? { summary } : undefined), 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; + const droppedEvents = isJsonNumber(record?.droppedEvents) ? record.droppedEvents : undefined; + return { type, status, - ...(errorCode ? { errorCode } : {}), - ...(droppedEvents ? { droppedEvents } : {}), + ...optionalFields(errorCode ? { errorCode } : undefined), + ...optionalFields(droppedEvents ? { droppedEvents } : undefined), ...base, }; } } + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid turn event."); } -export function decodeWorkflowRun(value: unknown): WorkflowRun { +export function decodeWorkflowRun(value: WireInput): 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), @@ -466,68 +516,78 @@ export function decodeWorkflowRun(value: unknown): WorkflowRun { writeMode, concurrency: requiredNonNegativeInteger(record?.concurrency, "concurrency"), resumeOf: optionalString(record?.resumeOf), - ...(record && "result" in record ? { result: record.result } : {}), - ...(record?.error === undefined ? {} : { error: decodeWorkflowFailure(record.error) }), + ...optionalFields(record && "result" in record ? { result: record.result } : undefined), + ...optionalFields(record?.error === undefined ? undefined : { error: decodeWorkflowFailure(record.error) }), createdAt: requiredString(record?.createdAt, "createdAt"), updatedAt: requiredString(record?.updatedAt, "updatedAt"), callCount: requiredNonNegativeInteger(record?.callCount, "callCount"), }; } -export function decodeWorkflowRunList(value: unknown): WorkflowRun[] { +export function decodeWorkflowRunList(value: WireInput): 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 { +export function decodeWorkflowCall(value: WireInput): 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), effort: optionalString(options.effort), - ...(asRecord(options.schema) ? { schema: options.schema as Record } : {}), + // SAFETY: The surrounding checks or construction already match this type. + ...optionalFields(asRecord(options.schema) ? { schema: options.schema as JsonObject } : undefined), label: optionalString(options.label), phase: optionalString(options.phase), - ...(isWorkflowWriteMode(options.writeMode) ? { writeMode: options.writeMode } : {}), - ...(options.isolation === "worktree" ? { isolation: "worktree" } : {}), + ...optionalFields(isWorkflowWriteMode(options.writeMode) ? { writeMode: options.writeMode } : undefined), + ...optionalFields(options.isolation === "worktree" ? { isolation: "worktree" } : undefined), workspace: optionalString(options.workspace), }; + return { runId: requiredString(record?.runId, "runId"), index: requiredNonNegativeInteger(record?.index, "index"), agentId: requiredString(record?.agentId, "agentId"), - ...(record?.turnId === undefined ? {} : { turnId: requiredString(record.turnId, "turnId") }), + ...optionalFields(record?.turnId === undefined ? undefined : { turnId: requiredString(record.turnId, "turnId") }), status, prompt: requiredContentString(record?.prompt, "prompt"), options: decodedOptions, fingerprint: requiredString(record?.fingerprint, "fingerprint"), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), workspaceId: optionalString(record?.workspaceId), - ...(record && "result" in record ? { result: record.result } : {}), - ...(record?.error === undefined ? {} : { error: decodeWorkflowFailure(record.error) }), + ...optionalFields(record && "result" in record ? { result: record.result } : undefined), + ...optionalFields(record?.error === undefined ? undefined : { error: decodeWorkflowFailure(record.error) }), reusedFrom: optionalString(record?.reusedFrom), createdAt: requiredString(record?.createdAt, "createdAt"), updatedAt: requiredString(record?.updatedAt, "updatedAt"), }; } -export function decodeWorkflowCallList(value: unknown): WorkflowCall[] { +export function decodeWorkflowCallList(value: WireInput): WorkflowCall[] { if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid workflow call list."); + return value.map(decodeWorkflowCall); } -export function decodeWorkflowEventList(value: unknown): WorkflowEvent[] { +export function decodeWorkflowEventList(value: WireInput): 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"), @@ -538,12 +598,14 @@ export function decodeWorkflowEventList(value: unknown): WorkflowEvent[] { }); } -export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { +export function decodeDaemonStatus(value: WireInput): 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"), @@ -551,7 +613,7 @@ export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { endpoint: requiredString(record?.endpoint, "endpoint"), startedAt: requiredString(record?.startedAt, "startedAt"), activeTurns: requiredInteger(record?.activeTurns, "activeTurns"), - ...(record?.activeWorkflows === undefined ? {} : { + ...optionalFields(record?.activeWorkflows === undefined ? undefined : { activeWorkflows: requiredNonNegativeInteger(record.activeWorkflows, "activeWorkflows"), }), runtimeCount: requiredInteger(record?.runtimeCount, "runtimeCount"), @@ -559,16 +621,18 @@ export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { }; } -export function decodeDaemonHello(value: unknown): LocalAgentDaemonHello { +export function decodeDaemonHello(value: WireInput): LocalAgentDaemonHello { const record = asRecord(value); + return { status: decodeDaemonStatus(record?.status), configMatches: requiredBoolean(record?.configMatches, "configMatches"), }; } -export function decodeDaemonLogs(value: unknown): string { - if (typeof value !== "string") throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid logs."); +export function decodeDaemonLogs(value: WireInput): string { + if (!isJsonString(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid logs."); + return value; } @@ -579,18 +643,21 @@ export class LocalAgentDaemonProtocolError extends Error { } } -function decodeEmptyParams(value: unknown): Record { +function decodeEmptyParams(value: WireInput): 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 { +function decodeStartInput(value: WireInput): StartLocalAgentInput { const record = asRecord(value); const worktree = asRecord(record?.worktree); + return { agentId: optionalString(record?.agentId), target: requiredString(record?.target, "target"), @@ -600,62 +667,68 @@ function decodeStartInput(value: unknown): StartLocalAgentInput { model: optionalString(record?.model), effort: optionalString(record?.effort), writeMode: decodeWriteMode(record?.writeMode), - ...(worktree ? { worktree: { branch: optionalString(worktree.branch) } } : {}), + ...optionalFields(worktree ? { worktree: { branch: optionalString(worktree.branch) } } : undefined), }; } -function decodeContinueInput(value: unknown): { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides } { +function decodeContinueInput(value: WireInput) { const record = asRecord(value); const overrides = asRecord(record?.overrides); + return { id: requiredString(record?.id, "id"), prompt: requiredContentString(record?.prompt, "prompt"), scope: decodeWorkspaceScope(record?.scope), - ...(overrides ? { overrides: { + ...optionalFields(overrides ? { overrides: { model: optionalString(overrides.model), effort: optionalString(overrides.effort), writeMode: decodeWriteMode(overrides.writeMode), - } } : {}), + } } : undefined), }; } -function decodeWorkspaceScope(value: unknown): LocalAgentWorkspaceScope { +function decodeWorkspaceScope(value: WireInput): 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"), }; } -function decodeListScope(value: unknown): LocalAgentWorkspaceScope { +function decodeListScope(value: WireInput): LocalAgentWorkspaceScope { return decodeWorkspaceScope(value); } -function decodeStopParams(value: unknown): { ifIdle?: boolean } { +function decodeStopParams(value: WireInput) { 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 }; } -function decodeWaitParams(value: unknown): { - ids: string[]; - scope: LocalAgentWorkspaceScope; - timeoutMs?: number; -} { +function decodeWaitParams(value: WireInput) { 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" + && (!isJsonNumber(timeoutMs) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 2_147_483_647) @@ -665,97 +738,118 @@ 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), - ...(timeoutMs === undefined ? {} : { timeoutMs }), + ...optionalFields(timeoutMs === undefined ? undefined : { timeoutMs }), }; } -function decodeWorkflowRunInput(value: unknown): WorkflowRunInput { +function decodeWorkflowRunInput(value: WireInput): 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 }), - ...(name === undefined ? {} : { name }), - ...(resume === undefined ? {} : { resume }), - ...(record.args === undefined ? {} : { args: record.args }), - ...(writeMode === undefined ? {} : { writeMode }), + ...optionalFields(source === undefined ? undefined : { source }), + ...optionalFields(name === undefined ? undefined : { name }), + ...optionalFields(resume === undefined ? undefined : { resume }), + ...optionalFields(record.args === undefined ? undefined : { args: record.args }), + ...optionalFields(writeMode === undefined ? undefined : { writeMode }), }; } -function decodeWorkflowLookup(value: unknown): { id: string; scope: LocalAgentWorkspaceScope } { +function decodeWorkflowLookup(value: WireInput) { const record = asRecord(value); + return { id: requiredString(record?.id, "id"), scope: decodeWorkspaceScope(record?.scope), }; } -function decodeOptionalTimeout(value: unknown, max: number): { timeoutMs?: number } { +function decodeOptionalTimeout(value: WireInput, max: number) { const timeoutMs = asRecord(value)?.timeoutMs; + if (timeoutMs === undefined) return {}; + + // SAFETY: The surrounding checks or construction already match this type. 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.`); } + + // SAFETY: The surrounding checks or construction already match this type. return { timeoutMs: timeoutMs as number }; } -function decodeWaitError(value: unknown): LocalAgentFailure { +function decodeWaitError(value: WireInput): LocalAgentFailure { const record = asRecord(value); const detail = optionalContentString(record?.detail); + return { code: requiredString(record?.code, "error.code"), message: requiredContentString(record?.message, "error.message"), retryable: optionalBoolean(record?.retryable) ?? false, - ...(detail ? { detail } : {}), + ...optionalFields(detail ? { detail } : undefined), }; } -function decodeEventsParams(value: unknown): { id: string; scope: LocalAgentWorkspaceScope } & LocalAgentTurnEventsQuery { +function decodeEventsParams(value: WireInput): { id: string; scope: LocalAgentWorkspaceScope } & LocalAgentTurnEventsQuery { const record = asRecord(value); const afterSeq = record?.afterSeq; - if (afterSeq !== undefined && (typeof afterSeq !== "number" || !Number.isSafeInteger(afterSeq) || afterSeq < 0)) { + + if (afterSeq !== undefined && (!isJsonNumber(afterSeq) || !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), - ...(turnId ? { turnId } : {}), - ...(afterSeq === undefined ? {} : { afterSeq }), + ...optionalFields(turnId ? { turnId } : undefined), + ...optionalFields(afterSeq === undefined ? undefined : { afterSeq }), }; } -function decodeWorkflowFailure(value: unknown): { code: string; message: string; retryable: boolean } { +function decodeWorkflowFailure(value: WireInput) { return decodeWaitError(value); } -function decodeLogsParams(value: unknown): { lines?: number } { +function decodeLogsParams(value: WireInput) { 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) { + + if (!isJsonNumber(lines) || !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 { +function decodeWriteMode(value: WireInput): 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."); } @@ -769,7 +863,7 @@ function isWorkflowStatus(value: string): value is WorkflowStatus { || value === "failed" || value === "cancelled" || value === "interrupted"; } -function isWorkflowWriteMode(value: unknown): value is WorkflowWriteMode { +function isWorkflowWriteMode(value: WireInput): value is WorkflowWriteMode { return value === "read_only" || value === "allowed"; } @@ -778,56 +872,68 @@ function isWorkflowCallStatus(value: string): value is WorkflowCall["status"] { || value === "cancelled" || value === "interrupted"; } -function requiredString(value: unknown, field: string): string { +function requiredString(value: WireInput, 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 { +function requiredContentString(value: WireInput, field: string): string { const result = optionalContentString(value); + if (result === undefined) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Missing ${field}.`); + return result; } -function requiredInteger(value: unknown, field: string): number { - if (typeof value !== "number" || !Number.isSafeInteger(value)) { +function requiredInteger(value: WireInput, field: string): number { + if (!isJsonNumber(value) || !Number.isSafeInteger(value)) { throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); } + return value; } -function requiredNonNegativeInteger(value: unknown, field: string): number { +function requiredNonNegativeInteger(value: WireInput, field: string): number { const result = requiredInteger(value, field); + if (result < 0) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `${field} must be non-negative.`); + return result; } -function requiredBoolean(value: unknown, field: string): boolean { - if (typeof value !== "boolean") { +function requiredBoolean(value: WireInput, field: string): boolean { + if (!isJsonBoolean(value)) { throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); } + return value; } -function optionalString(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; +function optionalString(value: WireInput): string | undefined { + if (!isJsonString(value)) return undefined; const trimmed = value.trim(); + return trimmed || undefined; } -function optionalContentString(value: unknown): string | undefined { - if (typeof value !== "string" || !value.trim()) return undefined; +function optionalContentString(value: WireInput): string | undefined { + if (!isJsonString(value) || !value.trim()) return undefined; + return value; } -function optionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; +function optionalBoolean(value: WireInput): boolean | undefined { + return isJsonBoolean(value) ? value : undefined; } -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - return value as Record; +function asRecord(value: WireInput): JsonObject | undefined { + if (!value || !isObjectValue(value) || Array.isArray(value)) return undefined; + + // SAFETY: The surrounding checks or construction already match this type. + return value as JsonObject; } export function supportedDaemonProtocolVersion(): number { diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 8192cb0e2..c7f115a12 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { Result } from "better-result"; import { existsSync, readFileSync } from "node:fs"; @@ -30,8 +31,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 +47,7 @@ const record: LocalAgentRecord = { createdAt: "now", updatedAt: "now", }; + const workflowRun: WorkflowRun = { id: "wfl_test", workspaceId: "ws_test", @@ -56,6 +60,7 @@ const workflowRun: WorkflowRun = { updatedAt: "now", callCount: 1, }; + const workflowCall: WorkflowCall = { runId: workflowRun.id, index: 0, @@ -69,6 +74,7 @@ const workflowCall: WorkflowCall = { createdAt: "now", updatedAt: "now", }; + const workflowEvent: WorkflowEvent = { sequence: 1, runId: workflowRun.id, @@ -93,10 +99,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); } @@ -111,6 +119,7 @@ class FakeManager implements LocalAgentDaemonManager { _overrides: RunOverrides | undefined, _scope: { workspaceId: string; workspaceRoot: string }, ) { + // SAFETY: The surrounding checks or construction already match this type. return Result.ok({ ...record, status: "running" } as LocalAgentRecord); } @@ -122,30 +131,36 @@ class FakeManager implements LocalAgentDaemonManager { return Result.ok([record]); } - async wait(agentIds: readonly string[], _scope: unknown, _timeoutMs?: number, signal?: AbortSignal) { + async wait(agentIds: readonly string[], _scope: WireInput, _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) { + async stop(_agentId: string, _scope: WireInput) { this.activeTurnCount = 0; + + // SAFETY: The surrounding checks or construction already match this type. return Result.ok({ ...record, status: "stopped" } as LocalAgentRecord); } lastEventsQuery?: unknown; - events(agentId: string, _scope: unknown, query?: { turnId?: string; afterSeq?: number }) { + events(agentId: string, _scope: WireInput, query?: { turnId?: string; afterSeq?: number }) { this.lastEventsQuery = query; + return Result.ok({ agentId, turnId: "trn_test", @@ -177,26 +192,32 @@ class FakeWorkflows implements LocalAgentDaemonWorkflowManager { lastInput?: WorkflowRunInput; async run(input: WorkflowRunInput): Promise { this.lastInput = input; + return workflowRun; } get(): WorkflowRun { return workflowRun; } list(): WorkflowRun[] { return [workflowRun]; } - async wait(_id: string, _scope: unknown, _timeoutMs?: number, signal?: AbortSignal): Promise { + async wait(_id: string, _scope: WireInput, _timeoutMs?: number, signal?: AbortSignal): Promise { 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 +228,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 +236,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 +244,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 +279,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 +287,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 +303,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 +350,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 +358,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 +366,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 +390,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 +399,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 +421,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 +430,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 +448,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 +463,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 +484,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 +492,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 +514,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 +534,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 +550,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 +566,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 +574,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 +587,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 +617,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 +634,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 +642,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 +652,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 +672,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 +695,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 +748,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 +772,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 +797,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 +823,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 +851,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 +861,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 +878,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 +888,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 +897,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 +914,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 +923,7 @@ test("a response for another request is invalid", async (t) => { result: {}, })); }); + try { const invalidClient = new LocalAgentClient({ stateDir: invalidStateDir, @@ -837,8 +932,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 +957,18 @@ async function listenFakeDaemon( socket.on("data", (chunk: string | Buffer) => { buffer += chunk.toString(); const newline = buffer.indexOf("\n"); + if (newline === -1) return; + // SAFETY: The surrounding checks or construction already match this type. 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 +1000,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 +1032,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 +1052,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 +1062,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 +1092,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 +1101,10 @@ async function startSocketDaemon(t: TestContext) { shutdownTimeoutMs: 100, idleShutdownMs: 60_000, }); + onCleanup(() => socketDaemon.close()); await socketDaemon.start(); + return { socketManager, socketDaemon }; } @@ -999,19 +1112,22 @@ async function fixture(t: TestContext) { // macOS limits the complete Unix socket path to 104 bytes. const root = await mkdtemp(join(tmpdir(), "ds-ad-")); // Daemons and servers must close before their state directory is removed. - const cleanups: Array<() => unknown> = []; + const cleanups: Array<() => void | Promise> = []; t.after(async () => { for (const cleanup of cleanups) await cleanup(); await rm(root, { recursive: true, force: true }); }); - return { root, onCleanup: (cleanup: () => unknown) => { cleanups.push(cleanup); } }; + + return { root, onCleanup: (cleanup: () => void | Promise) => { 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 +1139,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,33 +1150,43 @@ 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 { + // SAFETY: The surrounding checks or construction already match this type. const parsed = JSON.parse(buffer.slice(0, newline)) as RawDaemonResponse; settle(() => resolveResponse(parsed)); } catch (error) { 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 +1194,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 +1220,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..0e9030d44 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -1,10 +1,11 @@ +import { isObjectValue, isJsonString } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import { timingSafeEqual } from "node:crypto"; import { appendFileSync, chmodSync, readFileSync, rmSync } from "node:fs"; import { createServer, type Server as NetServer, type Socket } from "node:net"; import { AgentDaemonInternalError, AgentDaemonInvalidRequestError, - AgentDaemonInvalidResponseError, AgentDaemonProtocolMismatchError, AgentDaemonTimeoutError, AgentDaemonUnauthorizedError, @@ -27,7 +28,6 @@ import { encodeLocalAgentDaemonResponse, type LocalAgentDaemonRequest, type LocalAgentDaemonErrorPayload, - type LocalAgentDaemonResponse, type LocalAgentDaemonStatus, LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; @@ -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,26 +352,30 @@ export class LocalAgentDaemon { ok: true, result: response, })); + if (request.method === "daemon.stop") setImmediate(() => { void this.close(); }); } catch (error) { this.writeError(socket, requestId, daemonErrorPayload(error)); } } - private async dispatch(request: LocalAgentDaemonRequest, signal: AbortSignal): Promise { + private async dispatch(request: LocalAgentDaemonRequest, signal: AbortSignal): Promise { if (request.protocolVersion !== LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) { throw new LocalAgentDaemonProtocolError( "PROTOCOL_MISMATCH", `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); @@ -433,9 +469,10 @@ export class LocalAgentDaemon { } private async runTurnRequest( - operation: () => Promise>, + 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,20 +591,23 @@ 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 : ""; +function readRequestId(value: WireInput): string { + if (!value || !isObjectValue(value) || Array.isArray(value)) return ""; + // SAFETY: The surrounding checks or construction already match this type. + const requestId = (value as JsonObject).requestId; + + return isJsonString(requestId) ? requestId : ""; } export function writeLocalAgentDaemonLog( paths: LocalAgentDaemonPaths, level: "info" | "warn" | "error", event: string, - fields: Record, + fields: JsonObject, ): void { try { ensureLocalAgentDaemonStateDir(paths.stateDir); @@ -569,21 +621,24 @@ 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 ""; } } -function errorMessage(error: unknown): string { +function errorMessage(error: WireInput): string { return error instanceof Error ? error.message : String(error); } -function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { +function daemonErrorPayload(error: WireInput): 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 +649,7 @@ function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { message: error.message, })); } + if (error.code === "UNAUTHORIZED") { return toAgentErrorPayload(new AgentDaemonUnauthorizedError({ code: "DAEMON_UNAUTHORIZED", @@ -603,6 +659,7 @@ function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { message: error.message, })); } + return toAgentErrorPayload(new AgentDaemonInvalidRequestError({ code: "DAEMON_INVALID_REQUEST", operation: "request", @@ -611,6 +668,7 @@ function daemonErrorPayload(error: unknown): LocalAgentDaemonErrorPayload { message: error.message, })); } + return toAgentErrorPayload(new AgentDaemonInternalError({ code: "DAEMON_INTERNAL_ERROR", operation: "request", @@ -622,5 +680,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..3520a989a 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isObjectValue, optionalFields } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import { matchError, Result, @@ -236,9 +239,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 +285,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 +293,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": @@ -372,11 +383,13 @@ export function providerErrorFromCause(input: { provider: LocalAgentDriverKind; agentId?: string; operation: string; - cause: unknown; + cause: WireInput; }): 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 +401,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 +415,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 +442,16 @@ export async function captureAgentProviderResult(input: { operation: input.operation, cause, }); + if (!error) throw cause; + return Result.err(error); } } -export function isProgrammerDefect(error: unknown): boolean { +export function isProgrammerDefect(error: WireInput): boolean { if (unavailableCauseKind(error)) return false; + return error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError @@ -440,25 +459,32 @@ export function isProgrammerDefect(error: unknown): boolean { || (error instanceof Error && error.name === "AssertionError"); } -function isAbortError(error: unknown): boolean { +function isAbortError(error: WireInput): boolean { return Boolean( error - && typeof error === "object" + && isObjectValue(error) && "name" in error - && String((error as { name?: unknown }).name) === "AbortError", + // SAFETY: The surrounding checks or construction already match this type. + && String(unsafeNarrow<{ name?: WireInput }>(error).name) === "AbortError", ); } -function unavailableCauseKind(error: unknown): "permanent" | "transient" | undefined { +function unavailableCauseKind(error: WireInput): "permanent" | "transient" | undefined { const seen = new Set(); let current = error; - while (current && typeof current === "object" && !seen.has(current)) { + + while (current && isObjectValue(current) && !seen.has(current)) { seen.add(current); + // SAFETY: The surrounding checks or construction already match this type. const code = "code" in current ? String((current as { code?: unknown }).code) : ""; + if (code === "ENOENT") return "permanent"; + if (code === "ECONNREFUSED" || code === "ENOTFOUND") return "transient"; + // SAFETY: The surrounding checks or construction already match this type. current = "cause" in current ? (current as { cause?: unknown }).cause : undefined; } + return undefined; } @@ -478,8 +504,8 @@ function targetErrorPayload(error: AgentTargetError): AgentErrorPayload { message: error.message, retryable: error.retryable, target: error.target, - ...(error.provider ? { provider: error.provider } : {}), - ...(error.operation ? { operation: error.operation } : {}), + ...optionalFields(error.provider ? { provider: error.provider } : undefined), + ...optionalFields(error.operation ? { operation: error.operation } : undefined), }; } @@ -489,7 +515,7 @@ function conflictErrorPayload(error: AgentConflictError): AgentErrorPayload { message: error.message, retryable: error.retryable, operation: error.operation, - ...(error.agentId ? { agentId: error.agentId } : {}), + ...optionalFields(error.agentId ? { agentId: error.agentId } : undefined), }; } @@ -499,8 +525,8 @@ function scopeErrorPayload(error: AgentScopeError): AgentErrorPayload { message: error.message, retryable: error.retryable, operation: error.operation, - ...(error.agentId ? { agentId: error.agentId } : {}), - ...(error.workspaceId ? { workspaceId: error.workspaceId } : {}), + ...optionalFields(error.agentId ? { agentId: error.agentId } : undefined), + ...optionalFields(error.workspaceId ? { workspaceId: error.workspaceId } : undefined), }; } @@ -511,7 +537,7 @@ function providerErrorPayload(error: AgentProviderError): AgentErrorPayload { retryable: error.retryable, provider: error.provider, operation: error.operation, - ...(error.agentId ? { agentId: error.agentId } : {}), + ...optionalFields(error.agentId ? { agentId: error.agentId } : undefined), }; } diff --git a/src/local-agent-events.ts b/src/local-agent-events.ts index 5648efe21..1a5cf2c54 100644 --- a/src/local-agent-events.ts +++ b/src/local-agent-events.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isJsonString, isObjectValue, optionalFields } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import { randomUUID } from "node:crypto"; /** @@ -6,6 +9,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,25 +54,32 @@ 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 }; + ? { type: "tool_started", tool, ...optionalFields(summary ? { summary } : undefined) } + : { type: "tool_completed", tool, ...optionalFields(summary ? { summary } : undefined), status: event.status }; } /** * Picks the most identifying argument of a tool call (a command, path, or * query) instead of persisting the full argument object. */ -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; +export function summarizeToolInput(input: WireInput): string | undefined { + if (isJsonString(input)) return input.trim() || undefined; + + if (!input || !isObjectValue(input) || Array.isArray(input)) return undefined; + // SAFETY: The surrounding checks or construction already match this type. + const record = input as JsonObject; + for (const key of [ "command", "cmd", @@ -81,30 +92,40 @@ 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) { + + if (isJsonString(value) && value.trim()) return value; + + if (Array.isArray(value) && value.every((part) => isJsonString(part)) && value.length > 0) { return value.join(" "); } } + return undefined; } /** The provider's own explanation of a failure, which the generic error message omits. */ -export function providerErrorDetail(error: { message: string; cause?: unknown }): string | undefined { +export function providerErrorDetail(error: { message: string; cause?: WireInput }): 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" + + const message = isJsonString(current) ? current - : current instanceof Error || typeof (current as { message?: unknown }).message === "string" - ? String((current as { message: unknown }).message) + // SAFETY: The surrounding checks or construction already match this type. + : current instanceof Error || isJsonString(unsafeNarrow<{ message?: WireInput }>(current).message) + ? String(unsafeNarrow<{ message: WireInput }>(current).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; + + // SAFETY: The surrounding checks or construction already match this type. + current = isObjectValue(current) ? (current as { cause?: unknown }).cause : undefined; } + return undefined; } @@ -117,26 +138,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..6698f3fa9 100644 --- a/src/local-agent-grok.ts +++ b/src/local-agent-grok.ts @@ -1,6 +1,10 @@ +import { isJsonString, isObjectValue, optionalFields } from "./json-value.js"; +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import { AgentProviderProtocolError } from "./local-agent-errors.js"; export const GROK_DEFAULT_MODEL = "grok-build"; + export const GROK_REASONING_EFFORTS = [ "none", "minimal", @@ -9,6 +13,7 @@ export const GROK_REASONING_EFFORTS = [ "high", "xhigh", ] as const; + export type GrokReasoningEffort = (typeof GROK_REASONING_EFFORTS)[number]; const COMPLETED_PROMPT_ID_LIMIT = 128; @@ -33,7 +38,7 @@ interface PendingPromptCompletion { sessionId: string; promptId: string; resolve: (completion: GrokPromptCompletion) => void; - reject: (error: unknown) => void; + reject: (error: WireInput) => void; timer: NodeJS.Timeout; } @@ -54,14 +59,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 +77,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,14 +92,16 @@ 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); } - rejectAll(error: unknown): void { + rejectAll(error: WireInput): void { const pending = Array.from(this.pending.values()); this.pending.clear(); + for (const entry of pending) { clearTimeout(entry.timer); entry.reject(error); @@ -99,10 +111,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,19 +127,22 @@ 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); } } } -export function parseGrokPromptCompletion(input: unknown): GrokPromptCompletion | undefined { +export function parseGrokPromptCompletion(input: WireInput): 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,27 +153,29 @@ export function parseGrokPromptCompletion(input: unknown): GrokPromptCompletion asRecord(record?._meta)?.promptId, asRecord(update?._meta)?.promptId, ); + if (promptId && isBackgroundPromptId(promptId)) return undefined; return { sessionId, - ...(promptId ? { promptId } : {}), - ...((firstString(record?.stopReason, update?.stopReason)) - ? { stopReason: firstString(record?.stopReason, update?.stopReason) } - : {}), + ...optionalFields(promptId ? { promptId } : undefined), + ...optionalFields((firstString(record?.stopReason, update?.stopReason)) ? { stopReason: firstString(record?.stopReason, update?.stopReason) } : undefined), }; } -export function readGrokSessionState(value: unknown): GrokSessionState | undefined { +export function readGrokSessionState(value: WireInput): 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 +184,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 +195,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 +214,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; } @@ -216,22 +244,27 @@ function grokConfigurationError(message: string): AgentProviderProtocolError { } export function isGrokReasoningEffort(value: string): value is GrokReasoningEffort { + // SAFETY: The surrounding checks or construction already match this type. return (GROK_REASONING_EFFORTS as readonly string[]).includes(value); } -function readGrokModelInfo(value: unknown): GrokModelInfo | undefined { +function readGrokModelInfo(value: WireInput): 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 }; } @@ -251,24 +284,27 @@ function isBackgroundPromptId(promptId: string): boolean { || /^task-completed-/i.test(promptId); } -function firstString(...values: unknown[]): string | undefined { +function firstString(...values: WireInput[]): string | undefined { for (const value of values) { const result = directString(value); + if (result) return result; } + return undefined; } -function readArray(value: unknown): unknown[] | undefined { +function readArray(value: WireInput): unknown[] | undefined { return Array.isArray(value) ? value : undefined; } -function directString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; +function directString(value: WireInput): string | undefined { + return isJsonString(value) && value.trim() ? value.trim() : undefined; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record +function asRecord(value: WireInput): JsonObject | undefined { + return value !== null && isObjectValue(value) && !Array.isArray(value) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(value) : 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..ab4b9d52f 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { Panic, Result, type Result as BetterResult } from "better-result"; import { mkdtemp, rm } from "node:fs/promises"; @@ -27,6 +28,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 +55,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 +79,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 +114,7 @@ class FakeRuntime implements LocalAgentRuntime { message: "codex agent turn was stopped.", })); } + return Result.ok({ provider: this.provider, providerSessionId: "thread_test", @@ -140,6 +155,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 +166,7 @@ test("a profile loader defect surfaces as a panic", async (t) => { allowedRoots: [root], subagents, }); + await assert.rejects( defectManager.start({ target: "reviewer", @@ -157,7 +174,7 @@ test("a profile loader defect surfaces as a panic", async (t) => { workspaceId: scope.workspaceId, workspaceRoot: root, }), - (error: unknown) => Panic.is(error) && error.cause instanceof TypeError, + (error: WireInput) => Panic.is(error) && error.cause instanceof TypeError, ); await defectManager.close(); }); @@ -166,6 +183,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 +193,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 +212,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 +235,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 +246,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 +294,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 +319,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 +339,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 +363,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 +383,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 +436,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 +452,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 +467,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 +482,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 +502,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 +511,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 +556,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 +603,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 +612,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 +628,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 +643,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 +670,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 +709,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 +744,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 +767,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 +792,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 +818,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 +830,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 +865,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 +902,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 +926,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 +960,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 +982,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 +991,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 +1008,7 @@ async function fixture(t: TestContext) { }; const runtimes = new Map(); + const driver: LocalAgentDriver = { providerInstanceId: "codex", provider: "codex", @@ -895,9 +1017,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 +1041,7 @@ async function fixture(t: TestContext) { subagents, stopTimeoutMs: 200, }); + t.after(async () => { await manager.close(); await rm(root, { recursive: true, force: true }); @@ -933,9 +1058,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 +1073,9 @@ async function fixture(t: TestContext) { workspaceId: scope.workspaceId, workspaceRoot: root, })); + await waitFor(() => getRecord(earlyFailure.id).status === "error"); + return earlyFailure; }; @@ -976,8 +1105,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 +1119,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..2c543c197 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -1,3 +1,5 @@ +import { isObjectValue, optionalFields, jsonKind } from "./json-value.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { Result, type Result as BetterResult } from "better-result"; @@ -74,7 +76,7 @@ export interface RunOverrides { } export interface LocalAgentManagerLogger { - (level: "info" | "warn" | "error", event: string, fields: Record): void; + (level: "info" | "warn" | "error", event: string, fields: JsonObject): void; } // Kept below the client's default request deadline so a slow stop is reported as a conflict, not a daemon timeout. @@ -103,11 +105,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 +175,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; @@ -185,32 +194,36 @@ 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( + return Result.gen(async function* (this: LocalAgentManager) { + yield* this.acceptingResult("start"); + + const workspaceRoot = yield* this.authorizeWorkspace( input.workspaceRoot, input.workspaceId, "start", ); - const profiles = yield* Result.await(manager.loadProfilesResult(workspaceRoot, input.target)); + + const profiles = yield* Result.await(this.loadProfilesResult(workspaceRoot, input.target)); + const target = resolveLocalAgentTarget( input.target, profiles, input.model, input.effort, - manager.subagents.providers, + this.subagents.providers, ); + if (!target) { return Result.err(new AgentTargetError({ code: "UNKNOWN_TARGET", target: input.target, retryable: false, message: `Unknown subagent profile or provider: ${input.target}.${ - manager.subagents.enabled ? "" : " Subagents are disabled by subagents.enabled in config." + this.subagents.enabled ? "" : " Subagents are disabled by subagents.enabled in config." }`, })); } + if (target.kind === "profile" && target.profile.disabled) { return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", @@ -220,10 +233,12 @@ 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({ + + yield* this.providerEnabledResult(target.provider, target.name, "start"); + const driver = yield* this.driverResult(target.provider, "start"); + yield* this.providerAvailableResult(target.provider, driver, "start"); + + const createdResult = this.store.createResult({ id: input.agentId, workspaceId: input.workspaceId, workspaceRoot, @@ -235,35 +250,44 @@ 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); + const worktree = await this.createWorktree(record, workspaceRoot, input.worktree.branch); + if (worktree.isErr()) { - manager.store.deleteResult(record.id); + this.store.deleteResult(record.id); + return Result.err(worktree.error); } - const updated = yield* manager.store.updateResult(record.id, { + + const updated = yield* this.store.updateResult(record.id, { worktreePath: worktree.value.path, branch: worktree.value.branch, }); + record = updated; } - return manager.begin(record, input.prompt, { + + return this.begin(record, input.prompt, { model: target.model, effort: target.effort, writeMode: input.writeMode, }, input.workspaceId); - }); + }.bind(this)); } private async createWorktree( @@ -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 }); } @@ -289,22 +314,24 @@ export class LocalAgentManager { overrides: RunOverrides = {}, scope: LocalAgentWorkspaceScope, ): Promise> { - const manager = this; - return Result.gen(async function* () { - yield* manager.acceptingResult("continue", agentId); - const record = yield* manager.store.getByIdResult(agentId); + return Result.gen(async function* (this: LocalAgentManager) { + yield* this.acceptingResult("continue", agentId); + const record = yield* this.store.getByIdResult(agentId); + if (!record) return Result.err(agentNotFound(agentId)); - yield* manager.agentWorkspaceResult(record, scope, "continue"); + yield* this.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)); - yield* manager.profileForRecordResult(record, profiles); - 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); - }); + yield* this.writeModeResult(record, overrides.writeMode); + const profiles = yield* Result.await(this.loadProfilesResult(record.workspaceRoot, record.profileName)); + yield* this.profileForRecordResult(record, profiles); + yield* this.providerEnabledResult(record.providerInstanceId, record.profileName, "continue"); + const driver = yield* this.driverResult(record.providerInstanceId, "continue", agentId, record.driver); + yield* this.providerAvailableResult(record.providerInstanceId, driver, "continue", agentId); + + return this.begin(record, prompt, overrides, scope.workspaceId); + }.bind(this)); } get( @@ -312,11 +339,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 +365,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 +378,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 +402,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 +432,7 @@ export class LocalAgentManager { ...worktree, }); } + return Result.ok(results); } @@ -397,12 +442,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 +462,7 @@ export class LocalAgentManager { : `Subagent ${agentId} has no turns.`, })); } + return this.store.listTurnEventsResult(turn, query.afterSeq).map((events) => ({ agentId, turnId: turn.turnId, @@ -426,9 +476,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 +491,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 +503,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 +524,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 +541,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 +589,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 +612,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 +642,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 +701,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 +756,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, @@ -672,7 +776,7 @@ export class LocalAgentManager { providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), durationMs: Math.max(0, Date.now() - startedAt), error: "Unexpected internal subagent failure.", - errorType: error instanceof Error ? error.name : typeof error, + errorType: error instanceof Error ? error.name : jsonKind(error), persistenceFailed: persisted.isErr(), }); throw error; @@ -691,6 +795,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 +803,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 +824,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 +834,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 +859,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 +871,7 @@ export class LocalAgentManager { message: `Subagent profile not found: ${record.profileName}.`, })); } + if (profile.disabled) { return Result.err(new AgentTargetError({ code: "PROVIDER_DISABLED", @@ -769,6 +881,7 @@ export class LocalAgentManager { message: `Subagent profile is disabled: ${profile.name}.`, })); } + return Result.ok(profile); } @@ -779,6 +892,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 +903,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 +914,7 @@ export class LocalAgentManager { message: `Provider instance ${providerInstanceId} now uses ${driver.provider}; this agent was created with ${expectedDriver}.`, })); } + return Result.ok(driver); } @@ -808,6 +924,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 +935,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 +955,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 +973,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 +989,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 +1003,7 @@ export class LocalAgentManager { // Preserve the original containment failure below. } } + return Result.err(new AgentScopeError({ code: "WORKSPACE_NOT_ALLOWED", operation, @@ -896,6 +1021,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 +1037,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 +1051,7 @@ export class LocalAgentManager { message: `Subagent ${record.id} belongs to a different workspace.`, })); } + return Result.ok(undefined); } @@ -934,6 +1063,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, @@ -947,7 +1077,7 @@ export class LocalAgentManager { private log( level: "info" | "warn" | "error", event: string, - fields: Record, + fields: JsonObject, ): void { this.logger?.(level, event, fields); } @@ -970,12 +1100,13 @@ class TurnEventLog { constructor( private readonly store: LocalAgentStore, private readonly turn: LocalAgentTurnRecord, - private readonly warn: (fields: Record) => void, + private readonly warn: (fields: JsonObject) => void, ) {} 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,28 +1120,36 @@ 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 }); } } -function errorMessage(error: unknown): string { +function errorMessage(error: WireInput): string { return error instanceof Error ? error.message : String(error); } function safeCauseType(cause: unknown): string | undefined { if (cause instanceof Error) return cause.name; - if (cause && typeof cause === "object" && "error" in cause) { + + if (cause && isObjectValue(cause) && Object.hasOwn(cause, "error")) { + // SAFETY: The surrounding checks or construction already match this type. const nested = (cause as { error?: unknown }).error; + if (nested instanceof Error) return nested.name; } - return cause === undefined ? undefined : typeof cause; + + return cause === undefined ? undefined : jsonKind(cause); } function agentNotFound(agentId: string): AgentTargetError { @@ -1047,6 +1186,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 +1211,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); } } @@ -1103,12 +1249,12 @@ function waitResultFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): Loca function waitBodyFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): LocalAgentWaitResult { switch (turn.status) { case "running": - return { id: turn.agentId, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; + return { id: turn.agentId, status: "running", ...optionalFields(timedOut ? { wait: "timeout" } : undefined) }; case "completed": return { id: turn.agentId, status: "completed", - ...(turn.response === undefined ? {} : { response: turn.response }), + ...optionalFields(turn.response === undefined ? undefined : { response: turn.response }), }; case "failed": return { id: turn.agentId, status: "failed", error: turnFailure(turn) }; @@ -1116,13 +1262,14 @@ function waitBodyFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): LocalA return { id: turn.agentId, status: "stopped", - ...(hasTurnFailure(turn) ? { error: turnFailure(turn) } : {}), + ...optionalFields(hasTurnFailure(turn) ? { error: turnFailure(turn) } : undefined), }; } } function waitResultFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAgentWaitResult { const body = waitBodyFromAgent(agent, timedOut); + return agent.latestTurnId ? { ...body, turnId: agent.latestTurnId } : body; } @@ -1130,12 +1277,12 @@ function waitBodyFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAge switch (agent.status) { case "starting": case "running": - return { id: agent.id, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; + return { id: agent.id, status: "running", ...optionalFields(timedOut ? { wait: "timeout" } : undefined) }; case "idle": return { id: agent.id, status: "completed", - ...(agent.latestResponse === undefined ? {} : { response: agent.latestResponse }), + ...optionalFields(agent.latestResponse === undefined ? undefined : { response: agent.latestResponse }), }; case "error": return { @@ -1145,20 +1292,18 @@ function waitBodyFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAge code: agent.errorCode ?? "AGENT_FAILED", message: agent.error ?? "Subagent failed without an error message.", retryable: agent.errorRetryable ?? false, - ...(agent.errorDetail ? { detail: agent.errorDetail } : {}), + ...optionalFields(agent.errorDetail ? { detail: agent.errorDetail } : undefined), }, }; case "stopped": return { id: agent.id, status: "stopped", - ...(agent.error || agent.errorCode || agent.errorRetryable !== undefined - ? { error: { + ...optionalFields(agent.error || agent.errorCode || agent.errorRetryable !== undefined ? { error: { code: agent.errorCode ?? "AGENT_STOPPED", message: agent.error ?? "Subagent stopped.", retryable: agent.errorRetryable ?? false, - } } - : {}), + } } : undefined), }; } } @@ -1172,6 +1317,6 @@ function turnFailure(turn: LocalAgentTurnRecord): LocalAgentFailure { code: turn.errorCode ?? "AGENT_FAILED", message: turn.error ?? "Subagent failed without an error message.", retryable: turn.errorRetryable ?? false, - ...(turn.errorDetail ? { detail: turn.errorDetail } : {}), + ...optionalFields(turn.errorDetail ? { detail: turn.errorDetail } : undefined), }; } diff --git a/src/local-agent-opencode-server.ts b/src/local-agent-opencode-server.ts index 6a246d349..a488c85a7 100644 --- a/src/local-agent-opencode-server.ts +++ b/src/local-agent-opencode-server.ts @@ -1,3 +1,4 @@ +import { isJsonString } from "./json-value.js"; import { createRequire } from "node:module"; import { createServer as createNetServer } from "node:net"; import { resolveOpenCodeCommand } from "./local-agent-opencode-version.js"; @@ -7,6 +8,8 @@ const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; const OPENCODE_SERVER_START_ATTEMPTS = 3; const require = createRequire(import.meta.url); + +// SAFETY: The surrounding checks or construction already match this type. const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; export interface OpencodeServerLike { @@ -20,17 +23,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 +46,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 +98,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") { + + if (!address || isJsonString(address)) { server.close(); reject(new Error("Failed to allocate an OpenCode server port.")); + return; } + server.close((error) => error ? reject(error) : resolve(address.port)); }); }); @@ -105,6 +126,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..6bb588399 100644 --- a/src/local-agent-opencode-v2.test.ts +++ b/src/local-agent-opencode-v2.test.ts @@ -1,3 +1,5 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import test from "node:test"; import { @@ -42,13 +44,15 @@ function fakeV2Client() { const waitInputs: unknown[] = []; const messageInputs: unknown[] = []; - const client = { + // SAFETY: The surrounding checks or construction already match this type. + const client = unsafeNarrow({ server: { async info() { return { version: "2.0.20", pid: 1, urls: [], paths: { tmp: "/tmp" } }; }, }, session: { - async create(input: unknown) { + async create(input: WireInput) { createInputs.push(input); + return { id: "ses_v2", projectID: "project", @@ -58,7 +62,7 @@ function fakeV2Client() { location: { directory: "/tmp/project" }, }; }, - async update(input: unknown) { updateInputs.push(input); }, + async update(input: WireInput) { updateInputs.push(input); }, async get() { return { id: "ses_v2", @@ -70,16 +74,18 @@ function fakeV2Client() { location: { directory: "/tmp/project" }, }; }, - async switchModel(input: unknown) { switchModelInputs.push(input); }, - async prompt(input: unknown) { + async switchModel(input: WireInput) { switchModelInputs.push(input); }, + async prompt(input: WireInput) { 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); }, + async wait(input: WireInput) { waitInputs.push(input); }, }, message: { - async list(input: unknown) { + async list(input: WireInput) { messageInputs.push(input); + return { data: [assistant], cursor: { previous: null, next: null } }; }, }, @@ -103,7 +109,7 @@ function fakeV2Client() { }; }, }, - } as unknown as OpencodeV2ClientLike; + }); return { client, createInputs, updateInputs, switchModelInputs, promptInputs, waitInputs, messageInputs }; } @@ -121,11 +127,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 +157,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 +167,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 +185,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 +242,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 +259,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 +267,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 +285,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 +295,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 +307,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,28 +324,33 @@ 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 = { + + const failedClient = unsafeNarrow({ ...client, message: { async list(input: { type?: string }) { return { data: input.type === "assistant" ? [] : [failedIdle], cursor: { previous: null, next: null } }; }, }, - } 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 = { + + const staleClient = unsafeNarrow({ ...client, session: { ...client.session, @@ -331,39 +363,47 @@ test("a continued turn that fails before output does not return the previous res return { data: input.type === "assistant" ? [assistant] : [failedIdle], cursor: { previous: null, next: null } }; }, }, - } 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(); }); // Stop ends the turn only once OpenCode confirms the interrupt; a failed interrupt leaves it running. -const interruptRun = async (interrupt: () => Promise) => { +const interruptRun = async (interrupt: () => Promise) => { const { client } = fakeV2Client(); + + // SAFETY: The surrounding checks or construction already match this type. const interruptClient = { ...client, session: { ...client.session, interrupt, - async wait(_input: unknown, options?: { signal?: AbortSignal }) { + async wait(_input: WireInput, options?: { signal?: AbortSignal }) { return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); }); }, }, - } as unknown as OpencodeV2ClientLike; + } 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,14 +418,16 @@ 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({ + + const deadRuntime = new OpencodeV2Runtime(unsafeNarrow({ ...client, server: { async info() { throw new ClientError("Transport", { cause: new TypeError("fetch failed") }); }, }, - } as unknown as OpencodeV2ClientLike, { close: () => undefined }); + }), { 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..d89b38425 100644 --- a/src/local-agent-opencode-v2.ts +++ b/src/local-agent-opencode-v2.ts @@ -1,3 +1,5 @@ +import { isObjectValue, optionalFields } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import { randomBytes } from "node:crypto"; import { ClientError, @@ -65,25 +67,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 +110,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { message: "OpenCode provider is unavailable.", }); } + throw error; } }, @@ -120,6 +129,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 +141,15 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { */ private async assertTurnNotFailed(sessionId: string): Promise { const recent = await this.client.message.list({ sessionID: sessionId, limit: 5, order: "desc" }); + + // SAFETY: The surrounding checks or construction already match this type. const idle = recent.data.find((message) => ( message !== null - && typeof message === "object" + && isObjectValue(message) && "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 +163,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 } : {}), + ...optionalFields(model ? { model } : undefined), }); + if (!session.id) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -164,6 +179,7 @@ export class OpencodeV2Runtime implements LocalAgentRuntime { message: "OpenCode did not return a session id.", }); } + return session.id; } @@ -172,9 +188,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 +201,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 +209,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 +259,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 +278,7 @@ export function opencodeV2ServerEnvironment( OPENCODE_SERVER_PASSWORD: _legacyPassword, ...rest } = env; + return { ...rest, OPENCODE_PASSWORD: password }; } @@ -263,15 +290,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 +311,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 +329,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,10 +339,11 @@ 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), - ...(effort ? { variant: effort } : {}), + ...optionalFields(effort ? { variant: effort } : undefined), }; } @@ -321,6 +357,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,16 +367,18 @@ function requireOpenCodeV2FinalResponse(message: SessionMessageAssistant): strin message: "OpenCode did not return a final assistant response.", }); } + return response; } -function requireLatestAssistant(messages: unknown[]): SessionMessageAssistant { +function requireLatestAssistant(messages: WireInput[]): SessionMessageAssistant { const assistant = messages.find((message) => ( message !== null - && typeof message === "object" + && isObjectValue(message) && "type" in message && message.type === "assistant" )); + if (!assistant) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -349,6 +388,8 @@ function requireLatestAssistant(messages: unknown[]): SessionMessageAssistant { message: "OpenCode did not return an assistant message after the prompt completed.", }); } + + // SAFETY: The surrounding checks or construction already match this type. return assistant as SessionMessageAssistant; } @@ -365,6 +406,6 @@ function assertOpenCodeV2AssistantSucceeded(message: SessionMessageAssistant): v } /** @opencode/client wraps every connection failure, whatever its errno, in a Transport ClientError. */ -function isOpenCodeTransportFailure(error: unknown): boolean { +function isOpenCodeTransportFailure(error: WireInput): boolean { return error instanceof ClientError && error.reason === "Transport"; } diff --git a/src/local-agent-opencode-version.ts b/src/local-agent-opencode-version.ts index 48fa93e65..37a2906f6 100644 --- a/src/local-agent-opencode-version.ts +++ b/src/local-agent-opencode-version.ts @@ -6,6 +6,8 @@ import { const OPENCODE_VERSION_PROBE_TIMEOUT_MS = 4_000; const require = createRequire(import.meta.url); + +// SAFETY: The surrounding checks or construction already match this type. const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; export type OpenCodeGeneration = "v1" | "v2"; @@ -26,9 +28,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 +46,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 +76,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 +93,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 +109,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(); }); @@ -113,6 +123,7 @@ function runOpenCodeVersion(env: NodeJS.ProcessEnv): Promise { operation: "probe_version", retryable: true, cause, + // SAFETY: The surrounding checks or construction already match this type. message: (cause as NodeJS.ErrnoException).code === "ENOENT" ? "OpenCode executable is unavailable." : `OpenCode version probe failed to start: ${cause.message}`, @@ -123,6 +134,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..671075c6b 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isObjectValue } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -21,22 +24,28 @@ function fakeV1Client() { const createInputs: unknown[] = []; const promptInputs: unknown[] = []; const health = { available: true }; - const client = { + + // SAFETY: The surrounding checks or construction already match this type. + const client = unsafeNarrow({ global: { async health() { if (!health.available) throw new Error("server unavailable"); + return { data: { healthy: true } }; }, }, session: { - async create(input: unknown) { + async create(input: WireInput) { createInputs.push(input); sessionNumber += 1; + return { data: { id: `session_${sessionNumber}` } }; }, - async prompt(input: unknown) { + async prompt(input: WireInput) { promptInputs.push(input); + // SAFETY: The surrounding checks or construction already match this type. const sessionId = (input as { sessionID: string }).sessionID; + return { data: { info: { role: "assistant" }, @@ -45,28 +54,35 @@ function fakeV1Client() { }; }, }, - } as unknown as OpencodeClientLike; + }); + return { client, createInputs, promptInputs, health }; } function sharedServerFixture(t: TestContext) { const fake = fakeV1Client(); + // SAFETY: The surrounding checks or construction already match this type. 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 +99,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 +109,7 @@ async function runTwoAgents(driver: OpencodeLocalAgentDriver, pool: LocalAgentRu prompt: "second", workspaceRoot: "/tmp/project", }); + return { first, second }; } @@ -105,6 +123,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 +146,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; @@ -147,6 +167,7 @@ test("a resumed OpenCode session applies an effort override", async (t) => { onEvent: (event) => { overrideEvents.push(event); }, }); assert.equal(callbackSessionId, firstRecord.providerSessionId); + // SAFETY: The surrounding checks or construction already match this type. assert.equal((overrideEvents[0] as { type?: string } | undefined)?.type, "message"); assert.deepEqual(promptInputs[2], { sessionID: "session_1", @@ -217,6 +238,7 @@ test( "", ].join("\n")); await chmod(command, 0o700); + const envDriver = new OpencodeLocalAgentDriver({ env: { PATH: commandRoot, @@ -231,13 +253,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"); @@ -249,55 +274,64 @@ test( ); test("a prompt that outlives the provider timeout is a retryable protocol error", async () => { - const timeoutClient = { + // SAFETY: The surrounding checks or construction already match this type. + const timeoutClient = unsafeNarrow({ global: { async health() { return { data: { healthy: true } }; }, }, session: { async create() { return { data: { id: "session_timeout" } }; }, - async prompt(_input: unknown, options?: { signal?: AbortSignal }) { + async prompt(_input: WireInput, options?: { signal?: AbortSignal }) { return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); }); }, }, - } 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(); }); // Stop ends the turn only once OpenCode confirms the abort; a stalled abort leaves it running. -const abortClient = (abort: () => Promise) => ({ +// SAFETY: The surrounding checks or construction already match this type. +const abortClient = (abort: () => Promise) => unsafeNarrow({ global: { async health() { return { data: { healthy: true } }; }, }, session: { async create() { return { data: { id: "session_abort" } }; }, abort, - async prompt(_input: unknown, options?: { signal?: AbortSignal }) { + async prompt(_input: WireInput, options?: { signal?: AbortSignal }) { return new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); }); }, }, -}) as unknown as OpencodeClientLike; -const stopRun = async (abort: () => Promise) => { +}); + +const stopRun = async (abort: () => Promise) => { const stopRuntime = new OpencodeRuntime(abortClient(abort), { close: () => undefined }, 100); const stop = new AbortController(); const run = stopRuntime.run({ prompt: "stop me", workspaceRoot: "/tmp/project", signal: stop.signal }); setTimeout(() => stop.abort(), 10); const result = await run; await stopRuntime.close(); + return result.isErr() ? result.error.code : "ok"; }; @@ -327,17 +361,19 @@ test("write modes map onto DevSpace agents whose permissions deny delegation", ( external_directory: "deny", }); const readOnlyPermissions = opencodePermissionFor("read_only"); - assert.equal(typeof readOnlyPermissions === "object" ? readOnlyPermissions.bash : undefined, "deny"); + assert.equal(isObjectValue(readOnlyPermissions) ? readOnlyPermissions.bash : undefined, "deny"); + for (const writeMode of ["read_only", "allowed", "full_access"] as const) { const config = opencodeAgentConfig(writeMode); assert.equal(config.mode, "primary"); - assert.equal(typeof config.permission === "object" ? config.permission.task : undefined, "deny"); + assert.equal(isObjectValue(config.permission) ? config.permission.task : undefined, "deny"); } }); test("an ordinary provider error keeps the healthy server runtime", async (t) => { let promptFailureCount = 0; - const applicationErrorClient = { + + const applicationErrorClient = unsafeNarrow({ global: { async health() { return { data: { healthy: true } }; }, }, @@ -345,6 +381,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 +396,7 @@ test("an ordinary provider error keeps the healthy server runtime", async (t) => }, }; } + return { data: { info: { role: "assistant" }, @@ -367,9 +405,11 @@ 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 +417,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 +451,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 +474,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 +484,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..0965d9e4d 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,3 +1,6 @@ +import { isObjectValue, isJsonString, optionalFields } from "./json-value.js"; +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import type { OpencodeClient, PermissionConfig, @@ -72,17 +75,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 +108,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { message: "OpenCode provider is unavailable.", }); } + throw error; } }, @@ -119,15 +127,17 @@ 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(); } - private async prompt(sessionId: string, input: LocalAgentRunInput): Promise { + private async prompt(sessionId: string, input: LocalAgentRunInput): Promise { 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 +145,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 +213,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 +233,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,20 +241,19 @@ 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, }; } -export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { - mode: "primary"; - permission: PermissionConfig; -} { +export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]) { return { mode: "primary", permission: opencodePermissionFor(writeMode), @@ -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", @@ -284,9 +304,10 @@ async function assertOpencodeHealthy(client: OpencodeClientLike): Promise } } -function isOpenCodeTransportFailure(error: unknown): boolean { +function isOpenCodeTransportFailure(error: WireInput): boolean { if (error instanceof OpencodeHealthError) return true; const code = transportErrorCode(error); + return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE" @@ -295,12 +316,18 @@ function isOpenCodeTransportFailure(error: unknown): boolean { || code === "ETIMEDOUT"; } -function transportErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== "object") return undefined; +function transportErrorCode(error: WireInput): string | undefined { + if (!error || !isObjectValue(error)) return undefined; + // SAFETY: The surrounding checks or construction already match this type. const code = (error as NodeJS.ErrnoException).code; - if (typeof code === "string") return code; + + if (isJsonString(code)) return code; + // SAFETY: The surrounding checks or construction already match this type. const cause = (error as Error & { cause?: unknown }).cause; - return cause && typeof cause === "object" && typeof (cause as NodeJS.ErrnoException).code === "string" + + // SAFETY: The surrounding checks or construction already match this type. + return cause && isObjectValue(cause) && isJsonString((cause as NodeJS.ErrnoException).code) + // SAFETY: The surrounding checks or construction already match this type. ? (cause as NodeJS.ErrnoException).code : undefined; } @@ -317,28 +344,31 @@ async function promptOpencodeSession( sessionId: string, input: LocalAgentRunInput, signal: AbortSignal, -): Promise { +): Promise { const model = input.model ? parseOpencodeModel(input.model) : undefined; + return client.session.prompt({ sessionID: sessionId, directory: input.workspaceRoot, parts: [{ type: "text", text: input.prompt }], agent: opencodeAgentFor(input.writeMode), - ...(model ? { model } : {}), - ...(input.effort ? { variant: input.effort } : {}), + ...optionalFields(model ? { model } : undefined), + ...optionalFields(input.effort ? { variant: input.effort } : undefined), }, { throwOnError: true, signal }); } 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) }; } -function requireSessionId(session: unknown): string { +function requireSessionId(session: WireInput): string { const id = asRecord(session)?.id; - if (typeof id !== "string" || !id) { + + if (!isJsonString(id) || !id) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", provider: "opencode", @@ -347,22 +377,26 @@ function requireSessionId(session: unknown): string { message: "OpenCode did not return a session id.", }); } + return id; } -function assertOpenCodePromptSucceeded(value: unknown): void { +function assertOpenCodePromptSucceeded(value: WireInput): 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" + + const message = isJsonString(data?.message) ? data.message - : typeof error.message === "string" + : isJsonString(error.message) ? error.message - : typeof error.name === "string" + : isJsonString(error.name) ? `OpenCode returned ${error.name}.` : "OpenCode returned an assistant error."; + throw new AgentProviderExecutionError({ code: "PROVIDER_EXECUTION_ERROR", provider: "opencode", @@ -373,89 +407,108 @@ function assertOpenCodePromptSucceeded(value: unknown): void { }); } -export function extractOpenCodeFinalResponse(value: unknown): string { +export function extractOpenCodeFinalResponse(value: WireInput): 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 { +function extractLastOpenCodeAssistantMessageText(messages: WireInput[]): 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; + const role = isJsonString(info?.role) ? info.role : message.role; + const type = isJsonString(message.type) ? message.type : undefined; + if (role !== "assistant" && type !== "assistant") continue; const text = extractOpenCodeAssistantMessageText(message); + if (text) return text; } + return ""; } -function extractOpenCodeAssistantMessageText(value: unknown): string { +function extractOpenCodeAssistantMessageText(value: WireInput): 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 : ""; + + return record?.type === "text" && isJsonString(record.text) ? 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 { +function stringifyStructuredMessage(value: WireInput): string { if (value === undefined || value === null) return ""; - if (typeof value === "string") return value.trim(); + + if (isJsonString(value)) return value.trim(); + return JSON.stringify(value); } -function unwrapProviderPayload(value: unknown): unknown { +function unwrapProviderPayload(value: WireInput): WireInput { 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 { +function readArray(value: WireInput, key: string): WireInput[] | 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; + return Array.isArray(result) ? result : undefined; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record +function asRecord(value: WireInput): JsonObject | undefined { + return value !== null && isObjectValue(value) && !Array.isArray(value) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(value) : undefined; } function requireFinalResponse(response: string): string { const trimmed = response.trim(); + if (!trimmed) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", @@ -465,9 +518,10 @@ function requireFinalResponse(response: string): string { message: "OpenCode did not return a final assistant response.", }); } + return trimmed; } -function errorMessage(error: unknown): string { +function errorMessage(error: WireInput): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/local-agent-path.ts b/src/local-agent-path.ts index c8ff2935f..72cbb61c6 100644 --- a/src/local-agent-path.ts +++ b/src/local-agent-path.ts @@ -10,15 +10,19 @@ 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 { + // SAFETY: The surrounding checks or construction already match this type. 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..0334438bc 100644 --- a/src/local-agent-pi-rpc.ts +++ b/src/local-agent-pi-rpc.ts @@ -1,17 +1,21 @@ +import { isObjectValue, isJsonString } from "./json-value.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import { createRequire } from "node:module"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { terminateProcessTree } from "./process-platform.js"; const require = createRequire(import.meta.url); + +// SAFETY: The surrounding checks or construction already match this type. const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const MAX_RECORD_CHARS = 8 * 1024 * 1024; const MAX_STDERR_CHARS = 8 * 1024; -export type PiRpcRecord = Record; +export type PiRpcRecord = JsonObject; interface PendingRequest { - resolve(value: unknown): void; + resolve(value: WireInput): void; reject(error: Error): void; timer: NodeJS.Timeout; } @@ -56,6 +60,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 +68,30 @@ export class PiRpcConnection { detached, windowsHide: true, }); + if (!child.stdin || !child.stdout || !child.stderr) { throw new Error("Pi RPC process did not expose stdio pipes."); } + + // SAFETY: The surrounding checks or construction already match this type. return new PiRpcConnection(child as ChildProcessWithoutNullStreams); } - async request(record: PiRpcRecord, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise { + 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 +108,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 +122,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,68 +163,89 @@ 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; + + if (!parsed || !isObjectValue(parsed) || Array.isArray(parsed)) continue; + // SAFETY: The surrounding checks or construction already match this type. this.route(parsed as PiRpcRecord); } } private route(record: PiRpcRecord): void { - if (record.type === "response" && typeof record.id === "string") { + if (record.type === "response" && isJsonString(record.id)) { 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); } } -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 piRecordString(value: WireInput, key: string): string | undefined { + if (!value || !isObjectValue(value) || Array.isArray(value)) return undefined; + // SAFETY: The surrounding checks or construction already match this type. + const field = (value as JsonObject)[key]; + + return isJsonString(field) ? 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..55e68e8c2 100644 --- a/src/local-agent-pi-sandbox.test.ts +++ b/src/local-agent-pi-sandbox.test.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { existsSync, mkdirSync, realpathSync } from "node:fs"; import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; @@ -16,20 +17,23 @@ import { type PiSandboxModeRef, } from "./local-agent-pi-sandbox.js"; -type PiTool = { execute: (...args: any[]) => Promise }; +type PiTool = { execute: (...args: any[]) => Promise }; function registerTools(workspace: string, modeRef: PiSandboxModeRef, env?: NodeJS.ProcessEnv): Map { const tools = new Map(); + // SAFETY: The surrounding checks or construction already match this type. createPiSandboxExtension(workspace, modeRef, env)({ - registerTool: (tool: { name: string; execute: (...args: any[]) => Promise }) => + 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 +44,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 +57,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 +65,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 +73,20 @@ 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); + + // SAFETY: The surrounding checks or construction already match this type. 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 +192,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..4346f4b52 100644 --- a/src/local-agent-pi-sandbox.ts +++ b/src/local-agent-pi-sandbox.ts @@ -70,11 +70,12 @@ const USER_NAMESPACE_DENIED = const DENIAL_SCAN_CHARS = 512; /** Detects a denial message even when it is split across output chunks. */ -export function createUserNamespaceDenialDetector(): { push(chunk: string): void; readonly denied: boolean } { +export function createUserNamespaceDenialDetector() { let tail = ""; let denied = false; + return { - push(chunk) { + push(chunk: string) { if (denied) return; tail = (tail + chunk).slice(-DENIAL_SCAN_CHARS); denied = isUserNamespaceDenied(tail); @@ -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], @@ -169,7 +175,7 @@ export function createPiSandboxConfig(workspace?: string): SandboxRuntimeConfig } export async function registerPiSandboxSession( - session: object, + session: {}, workspace: string, modeRef: PiSandboxModeRef, writeMode: PiSandboxWriteMode, @@ -179,36 +185,45 @@ export async function registerPiSandboxSession( modeRef, acquired: false, }; + sessionStates.set(session, state); + if (writeMode !== "full_access") { await acquirePiSandbox(state); } } export async function updatePiSandboxSession( - session: object, + session: {}, workspace: string, 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); } } -export async function releasePiSandboxSession(session: object): Promise { +export async function releasePiSandboxSession(session: {}): 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(); } @@ -227,12 +243,14 @@ function dynamicTool any }>( modeRef: PiSandboxModeRef, writeCapable = false, ): T { + // SAFETY: The surrounding checks or construction already match this type. return { ...restricted, execute: (...args: Parameters) => { 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 +292,7 @@ function createFindOperations(workspace: string): FindOperations { exists: async (path) => { try { await access(await assertPiWorkspacePath(path, workspace)); + return true; } catch { return false; @@ -287,19 +306,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 +335,7 @@ function createLsOperations(workspace: string): LsOperations { exists: async (path) => { try { await access(await assertPiWorkspacePath(path, workspace)); + return true; } catch { return false; @@ -324,13 +350,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 +370,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 +391,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 +419,7 @@ async function ensureWindowsSandbox(workspace: string): Promise { windowsSandboxWorkspace = undefined; } } + sandboxInitialization = SandboxManager.initialize(createPiSandboxConfig(resolvedWorkspace)).catch((error) => { sandboxInitialization = undefined; windowsSandboxWorkspace = undefined; @@ -395,6 +432,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 +443,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 +455,9 @@ async function runSandboxedCommand( options.signal, workspace, ); + const denial = createUserNamespaceDenialDetector(); + try { const result = await spawnSandboxedCommand(wrapped.argv, wrapped.env, workspace, { ...options, @@ -424,6 +466,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 +477,7 @@ async function runSandboxedCommand( "`sudo apparmor_parser -R /etc/apparmor.d/bwrap-userns-restrict`.", ); } + return result; } finally { SandboxManager.cleanupAfterCommand(); @@ -447,7 +491,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 +503,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 +553,7 @@ function createPiSandboxFilesystem(workspace: string): FilesystemConfig { function protectedHomePaths(): string[] { const home = homedir(); + return [ join(home, ".ssh"), join(home, ".aws"), @@ -519,18 +572,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 +597,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 +606,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 +621,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 +657,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..6b9effd6c 100644 --- a/src/local-agent-pi.test.ts +++ b/src/local-agent-pi.test.ts @@ -1,3 +1,4 @@ +import type { JsonObject, WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -13,29 +14,34 @@ 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; } class FakePiRpcConnection implements PiRpcRuntimeConnection { - readonly requests: Array> = []; - readonly events: Array> = []; + readonly requests: Array = []; + readonly events: Array = []; sessionFile = sessionFile("one"); lastPrompt = ""; alive = true; cleanupCount = 0; - async request(record: Record): Promise { + async request(record: JsonObject): 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 +58,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 +66,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}` }; @@ -72,13 +81,15 @@ class FakePiRpcConnection implements PiRpcRuntimeConnection { } } - async send(_record: Record): Promise {} + async send(_record: JsonObject): Promise {} - async nextEvent(timeoutMs = 0): Promise | undefined> { + async nextEvent(timeoutMs = 0): Promise { 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; } @@ -98,20 +109,25 @@ class FakePiRpcConnection implements PiRpcRuntimeConnection { function piPoolFixture(t: TestContext) { const contexts: LocalAgentRuntimeContext[] = []; const connections: FakePiRpcConnection[] = []; + // SAFETY: The surrounding checks or construction already match this type. 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 +152,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 +182,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 +191,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"); @@ -199,27 +219,36 @@ test("the runtime pool reuses, replaces, evicts, and cold-resumes Pi processes", 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 { + missingModel.request = async function(record: JsonObject): 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 +257,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(); @@ -243,20 +276,26 @@ test("a bare pre-RPC session id fails instead of opening an empty session", asyn // An exact model id wins over provider/model parsing (gateway providers use ids containing "/"). test("an exact model id wins over provider/model parsing", async () => { const gatewayConnection = new FakePiRpcConnection(); - gatewayConnection.request = async function(record: Record): Promise { + gatewayConnection.request = async function(record: JsonObject): 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"), @@ -268,24 +307,31 @@ test("an exact model id wins over provider/model parsing", async () => { // Stop: Pi is aborted, a Pi that never settles is discarded, and stale events never reach the next turn. test("stopping aborts Pi and discards a Pi that does not settle", async () => { const stalledConnection = new FakePiRpcConnection(); - stalledConnection.request = async function(record: Record): Promise { + stalledConnection.request = async function(record: JsonObject): Promise { 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 +340,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 +360,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 +374,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 piEvents: WireInput[] = []; + 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..cf076f51a 100644 --- a/src/local-agent-pi.ts +++ b/src/local-agent-pi.ts @@ -1,8 +1,11 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isJsonString, isObjectValue, isJsonNumber, optionalFields } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import { existsSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { AgentProviderExecutionError, AgentProviderProtocolError, @@ -35,7 +38,7 @@ const PI_ABORT_SETTLE_MS = 2_000; const MAX_PI_EVENTS = 10_000; export interface PiRpcRuntimeConnection { - request(record: PiRpcRecord, timeoutMs?: number): Promise; + request(record: PiRpcRecord, timeoutMs?: number): Promise; send(record: PiRpcRecord): Promise; nextEvent(timeoutMs?: number): Promise; clearEvents(): void; @@ -76,22 +79,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 +111,7 @@ export class PiRpcRuntime implements LocalAgentRuntime { message: "Pi agent turn failed.", }); } + throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", provider: "pi", @@ -110,6 +120,7 @@ export class PiRpcRuntime implements LocalAgentRuntime { message: "Pi did not return a final assistant response.", }); } + return { provider: this.provider, providerSessionId, @@ -149,24 +160,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 +194,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 +205,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 +226,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 +252,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 +320,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 +332,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 +346,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 +365,7 @@ async function defaultPiRpcFactory( ? ["--tools", PI_ALLOWED_TOOLS.join(",")] : []), ]; + try { return { connection: PiRpcConnection.spawn({ @@ -330,7 +374,7 @@ async function defaultPiRpcFactory( cwd: context.workspaceRoot, env, }), - ...(sandbox ? { cleanup: sandbox.cleanup } : {}), + ...optionalFields(sandbox ? { cleanup: sandbox.cleanup } : undefined), }; } catch (error) { await sandbox?.cleanup(); @@ -338,18 +382,24 @@ async function defaultPiRpcFactory( } } -export function resolvePiCommand(env: NodeJS.ProcessEnv): { command: string; args: string[] } { +export function resolvePiCommand(env: NodeJS.ProcessEnv) { 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 +412,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,94 +434,132 @@ 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 (!isJsonString(event.id)) 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 }); } -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 +export function extractPiFinalResponse(value: WireInput): string { + if (!value || !isObjectValue(value)) return ""; + // SAFETY: The surrounding checks or construction already match this type. + const root = unsafeNarrow(value); + + const data = root.data && isObjectValue(root.data) && !Array.isArray(root.data) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(root.data) : 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 (!message || !isObjectValue(message)) continue; + + // SAFETY: The surrounding checks or construction already match this type. + if (unsafeNarrow<{ role?: WireInput }>(message).role !== "assistant") continue; + const content = unsafeNarrow<{ content?: WireInput }>(message).content; + + if (isJsonString(content)) return content.trim(); + if (!Array.isArray(content)) continue; + const text = content .filter((block): block is { type: string; text: string } => ( Boolean(block) - && typeof block === "object" - && (block as { type?: unknown }).type === "text" - && typeof (block as { text?: unknown }).text === "string" + && isObjectValue(block) + // SAFETY: The surrounding checks or construction already match this type. + && unsafeNarrow<{ type?: WireInput }>(block).type === "text" + && isJsonString(unsafeNarrow<{ text?: WireInput }>(block).text) )) .map((block) => block.text) .join("\n\n") .trim(); + if (text) return text; } + return ""; } -export function extractPiProviderError(value: unknown): string | undefined { +export function extractPiProviderError(value: WireInput): string | undefined { const records = Array.isArray(value) ? [...value] : [value]; - if (value && typeof value === "object" && !Array.isArray(value)) { - const nested = (value as Record).messages; + + if (value && isObjectValue(value) && !Array.isArray(value)) { + // SAFETY: The surrounding checks or construction already match this type. + const nested = (value as JsonObject).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 (!record || !isObjectValue(record)) continue; + // SAFETY: The surrounding checks or construction already match this type. + const event = record as JsonObject; + + if (event.type === "extension_error" && isJsonString(event.error)) 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 (message && isObjectValue(message)) { + // SAFETY: The surrounding checks or construction already match this type. + const error = (message as JsonObject).errorMessage; + + if (isJsonString(error) && error) return error; } } - if (event.stopReason === "error" && typeof event.errorMessage === "string" && event.errorMessage) { + + if (event.stopReason === "error" && isJsonString(event.errorMessage) && 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 recordBoolean(value: WireInput, key: string): boolean { + if (!value || !isObjectValue(value) || Array.isArray(value)) return false; + + // SAFETY: The surrounding checks or construction already match this type. + return (value as JsonObject)[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 recordNumber(value: WireInput, key: string): number | undefined { + if (!value || !isObjectValue(value) || Array.isArray(value)) return undefined; + // SAFETY: The surrounding checks or construction already match this type. + const result = (value as JsonObject)[key]; + + return isJsonNumber(result) && 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]; +function recordArray(value: WireInput, key: string): WireInput[] { + if (!value || !isObjectValue(value) || Array.isArray(value)) return []; + // SAFETY: The surrounding checks or construction already match this type. + const result = (value as JsonObject)[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..ccd78e6cf 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -1,3 +1,4 @@ +import { optionalFields } from "./json-value.js"; import type { LocalAgentCatalog } from "./local-agent-catalog.js"; import type { LocalAgentTurnEvent } from "./local-agent-events.js"; import type { LocalAgentRecord, LocalAgentStatus } from "./local-agent-store.js"; @@ -72,8 +73,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; } @@ -86,16 +90,16 @@ export function presentAgentTargetCatalog(catalog: LocalAgentCatalog): AgentTarg .map((provider): AgentTargetOutput => ({ name: provider.id, kind: "provider", - ...(provider.model ? { model: provider.model } : {}), - ...(provider.effort ? { effort: provider.effort } : {}), + ...optionalFields(provider.model ? { model: provider.model } : undefined), + ...optionalFields(provider.effort ? { effort: provider.effort } : undefined), })), ...catalog.profiles.map((profile): AgentTargetOutput => ({ name: profile.name, kind: "profile", provider: profile.provider, description: profile.description, - ...(profile.model ? { model: profile.model } : {}), - ...(profile.effort ? { effort: profile.effort } : {}), + ...optionalFields(profile.model ? { model: profile.model } : undefined), + ...optionalFields(profile.effort ? { effort: profile.effort } : undefined), })), ], unavailable: catalog.enabled @@ -110,9 +114,9 @@ export function presentAgentReceipt(record: LocalAgentRecord): AgentReceiptOutpu return { id: record.id, status: presentAgentStatus(record.status), - ...(record.latestTurnId ? { turnId: record.latestTurnId } : {}), - ...(record.worktreePath ? { worktree: record.worktreePath } : {}), - ...(record.branch ? { branch: record.branch } : {}), + ...optionalFields(record.latestTurnId ? { turnId: record.latestTurnId } : undefined), + ...optionalFields(record.worktreePath ? { worktree: record.worktreePath } : undefined), + ...optionalFields(record.branch ? { branch: record.branch } : undefined), }; } @@ -122,12 +126,13 @@ export function presentAgentSummary(record: LocalAgentRecord): AgentSummaryOutpu export function presentAgentObservation(record: LocalAgentRecord): AgentObservationOutput { const receipt = presentAgentReceipt(record); + switch (receipt.status) { case "completed": return { ...receipt, status: "completed", - ...(record.latestResponse === undefined ? {} : { response: record.latestResponse }), + ...optionalFields(record.latestResponse === undefined ? undefined : { response: record.latestResponse }), }; case "failed": return { ...receipt, status: "failed", error: presentAgentFailure(record) }; @@ -135,7 +140,7 @@ export function presentAgentObservation(record: LocalAgentRecord): AgentObservat return { ...receipt, status: "stopped", - ...(hasAgentFailure(record) ? { error: presentAgentFailure(record) } : {}), + ...optionalFields(hasAgentFailure(record) ? { error: presentAgentFailure(record) } : undefined), }; case "running": return { ...receipt, status: "running" }; @@ -146,16 +151,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 +181,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 +211,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)}`; } @@ -246,11 +264,26 @@ function escapeXmlText(value: string): string { } function escapeXml(value: string): string { - return value - .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "\uFFFD") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); + let escaped = ""; + + for (const character of value) { + const code = character.charCodeAt(0); + + if ( + code <= 0x8 + || code === 0xb + || code === 0xc + || (code >= 0xe && code <= 0x1f) + || code === 0xfffe + || code === 0xffff + ) { + escaped += "\uFFFD"; + } else { + escaped += character; + } + } + + return escaped.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } function presentAgentStatus(status: LocalAgentStatus): AgentCommandStatus { @@ -276,6 +309,6 @@ function presentAgentFailure(record: LocalAgentRecord): AgentFailureOutput { code: record.errorCode ?? "AGENT_FAILED", message: record.error ?? "Subagent failed without an error message.", retryable: record.errorRetryable ?? false, - ...(record.errorDetail ? { detail: record.errorDetail } : {}), + ...optionalFields(record.errorDetail ? { detail: record.errorDetail } : undefined), }; } 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..93837a9f3 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -1,3 +1,5 @@ +import { isObjectValue, isJsonString } from "./json-value.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; @@ -25,7 +27,7 @@ export interface LocalAgentProfileSummary { } interface ParsedFrontmatter { - frontmatter: Record; + frontmatter: JsonObject; body: string; } @@ -42,6 +44,7 @@ export async function loadLocalAgentProfiles( config.devspaceAgentsDir, join(workspaceRoot, ".devspace", "agents"), ]; + const profilesByName = new Map(); for (const directory of profileDirs) { @@ -57,6 +60,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 +68,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 +101,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}`); } @@ -103,29 +112,32 @@ function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter }; } -function parseProfileYaml(source: string, filePath: string): Record { +function parseProfileYaml(source: string, filePath: string): JsonObject { let parsed: unknown; + try { parsed = parseYaml(source) ?? {}; } catch (error) { throw new Error(`Unable to parse subagent profile frontmatter: ${filePath}: ${errorMessage(error)}`); } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!parsed || !isObjectValue(parsed) || Array.isArray(parsed)) { throw new Error(`Subagent profile frontmatter must be a mapping: ${filePath}`); } - return parsed as Record; + // SAFETY: The surrounding checks or construction already match this type. + return parsed as JsonObject; } function profileFromFrontmatter( - frontmatter: Record, + frontmatter: JsonObject, body: string, filePath: string, ): LocalAgentProfile { 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}`); } @@ -142,21 +154,25 @@ function profileFromFrontmatter( }; } -function readProvider(frontmatter: Record, filePath: string): LocalAgentProviderInstanceId { +function readProvider(frontmatter: JsonObject, 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 { +function readString(frontmatter: JsonObject, key: string): string | undefined { const value = frontmatter[key]; - if (typeof value !== "string") return undefined; + + if (!isJsonString(value)) return undefined; const trimmed = value.trim(); + return trimmed || undefined; } -function errorMessage(error: unknown): string { +function errorMessage(error: WireInput): string { return error instanceof Error ? error.message : String(error); } 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..bccf23cb7 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -1,3 +1,5 @@ +import { optionalFields } from "./json-value.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import { createHash } from "node:crypto"; import { Result, type Result as BetterResult } from "better-result"; import { @@ -18,7 +20,7 @@ const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000; const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 60_000; export interface LocalAgentRuntimePoolLogger { - (level: "info" | "warn" | "error", event: string, fields: Record): void; + (level: "info" | "warn" | "error", event: string, fields: JsonObject): void; } interface RuntimeEntry { @@ -66,6 +68,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 +83,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 +115,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 } : {}), + ...optionalFields(inputCallbacks?.onEvent ? { onEvent: inputCallbacks.onEvent } : undefined), }; + 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 +168,7 @@ export class LocalAgentRuntimePool { error: errorMessage(cleanupError), }); } + this.log("warn", "harness_runtime_crashed", { provider: driver.provider, runtimeKeyHash: hashRuntimeKey(entry.key), @@ -157,9 +178,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 +192,7 @@ export class LocalAgentRuntimePool { error: outputReservationError.message, }); } + return result; } catch (error) { if (!runtime.isAlive()) { @@ -181,6 +206,7 @@ export class LocalAgentRuntimePool { error: errorMessage(cleanupError), }); } + this.log("warn", "harness_runtime_crashed", { provider: driver.provider, runtimeKeyHash: hashRuntimeKey(entry.key), @@ -190,20 +216,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 +260,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 +280,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 +293,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 +308,11 @@ export class LocalAgentRuntimePool { agentId: context.agentId, }); } + const created = await existing.createPromise; + if (created.isErr()) return created; + if ( !this.closing && !existing.closing && @@ -280,6 +322,7 @@ export class LocalAgentRuntimePool { return Result.ok(existing); } } + await this.removeAndClose(existing, "runtime_not_alive"); continue; } @@ -287,13 +330,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 +348,7 @@ export class LocalAgentRuntimePool { runtimeKeyHash: hashRuntimeKey(key), agentId: context.agentId, }); + return result; }) .catch((error) => { @@ -323,17 +370,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 +417,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 +440,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 +456,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 +481,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 +503,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 +515,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 +539,9 @@ export class LocalAgentRuntimePool { }); } })(); + session.releasePromise = releasePromise; + try { await releasePromise; } finally { @@ -485,13 +558,14 @@ 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); } private log( level: "info" | "warn" | "error", event: string, - fields: Record, + fields: JsonObject, ): void { this.logger?.(level, event, fields); } @@ -499,10 +573,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 +589,7 @@ function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeCont parts.push(context.agentId); break; } + switch (policy.authority ?? "none") { case "none": break; @@ -523,6 +600,7 @@ function runtimePoolKey(driver: LocalAgentDriver, context: LocalAgentRuntimeCont parts.push(context.writeMode === "full_access" ? "full_access" : "restricted"); break; } + return JSON.stringify(parts); } @@ -544,6 +622,6 @@ function hashRuntimeKey(key: string): string { return createHash("sha256").update(key).digest("hex").slice(0, 12); } -function errorMessage(error: unknown): string { +function errorMessage(error: WireInput): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 82a6d067d..e028a2f23 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,24 +475,27 @@ 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"); } -function deferred(): { - promise: Promise; - resolve(value?: T): void; -} { +function deferred() { 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..c917496c9 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,11 @@ try { `${created.id.slice(4)}-0000-4000-8000-000000000000`, "bbbbbbbb-0000-4000-8000-000000000000", ]; + + // SAFETY: The surrounding checks or construction already match this type. 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 +55,7 @@ try { model: "gpt-5.4", effort: "high", }); + assert.equal(redrawn.id, "agt_bbbbbbbb"); assert.notEqual(redrawn.id, created.id); } finally { @@ -67,6 +72,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 +129,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 +151,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 +167,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 +199,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 +239,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 +275,35 @@ 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 }); + + // SAFETY: The surrounding checks or construction already match this type. 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 +311,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..1d4294820 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,7 +162,9 @@ export class LocalAgentStore { list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { let rows: LocalAgentRow[]; + if (scope.workspaceId && scope.workspaceRoot) { + // SAFETY: The surrounding checks or construction already match this type. rows = this.database.sqlite .prepare( `select * from local_agent_sessions @@ -170,6 +173,7 @@ export class LocalAgentStore { ) .all(scope.workspaceId, resolve(scope.workspaceRoot)) as LocalAgentRow[]; } else if (scope.workspaceId) { + // SAFETY: The surrounding checks or construction already match this type. rows = this.database.sqlite .prepare( `select * from local_agent_sessions @@ -178,6 +182,7 @@ export class LocalAgentStore { ) .all(scope.workspaceId) as LocalAgentRow[]; } else if (scope.workspaceRoot) { + // SAFETY: The surrounding checks or construction already match this type. rows = this.database.sqlite .prepare( `select * from local_agent_sessions @@ -186,6 +191,7 @@ export class LocalAgentStore { ) .all(resolve(scope.workspaceRoot)) as LocalAgentRow[]; } else { + // SAFETY: The surrounding checks or construction already match this type. rows = this.database.sqlite .prepare("select * from local_agent_sessions order by updated_at desc") .all() as LocalAgentRow[]; @@ -208,6 +214,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 +222,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,10 +291,12 @@ export class LocalAgentStore { getById(id: string): LocalAgentRecord | undefined { const exact = this.getRowById(id); + return exact ? rowToLocalAgentRecord(exact) : undefined; } private getRowById(id: string): LocalAgentRow | undefined { + // SAFETY: The surrounding checks or construction already match this type. return this.database.sqlite .prepare( `select * from local_agent_sessions @@ -307,6 +320,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 +398,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 +417,7 @@ export class LocalAgentStore { errorDetail: undefined, latestTurnId: input.turnId, }); + const result = this.database.sqlite .prepare( `insert into local_agent_turns ( @@ -411,6 +429,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 +440,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 +461,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 +512,7 @@ export class LocalAgentStore { errorDetail: undefined, }); } + return this.update(agentId, { status: completion.status === "failed" ? "error" : "stopped", latestResponse: undefined, @@ -507,9 +533,11 @@ export class LocalAgentStore { } getTurnById(turnId: number): LocalAgentTurnRecord | undefined { + // SAFETY: The surrounding checks or construction already match this type. 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; } @@ -520,9 +548,11 @@ export class LocalAgentStore { } getTurnByTurnId(turnId: string): LocalAgentTurnRecord | undefined { + // SAFETY: The surrounding checks or construction already match this type. 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; } @@ -551,6 +581,7 @@ export class LocalAgentStore { } listTurnEvents(turn: LocalAgentTurnRecord, afterSeq = 0): LocalAgentTurnEvent[] { + // SAFETY: The surrounding checks or construction already match this type. const rows = this.database.sqlite .prepare( `select seq, type, data, created_at from local_agent_turn_events @@ -558,7 +589,9 @@ export class LocalAgentStore { order by seq asc`, ) .all(turn.id, afterSeq) as LocalAgentTurnEventRow[]; + return rows.map((row) => ({ + // SAFETY: The surrounding checks or construction already match this type. ...(JSON.parse(row.data) as LocalAgentTurnEventBody), turnId: turn.turnId, seq: row.seq, @@ -574,9 +607,11 @@ export class LocalAgentStore { } getLatestTurn(agentId: string): LocalAgentTurnRecord | undefined { + // SAFETY: The surrounding checks or construction already match this type. 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; } @@ -587,9 +622,11 @@ export class LocalAgentStore { } listTurns(agentId: string): LocalAgentTurnRecord[] { + // SAFETY: The surrounding checks or construction already match this type. 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 +641,7 @@ export class LocalAgentStore { where status = 'running'`, ) .run(message, now); + const result = this.database.sqlite .prepare( `update local_agent_sessions @@ -611,6 +649,7 @@ export class LocalAgentStore { where status in ('starting', 'running')`, ) .run(message, now); + return Number(result.changes); }).immediate(); } @@ -678,12 +717,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 +734,7 @@ function storeResult(operation: string, run: () => T): BetterResult) { if (existing[key] !== expected[key]) { throw new LocalAgentDispatchConflictError( @@ -733,12 +779,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..25c54b5bf 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -1,3 +1,4 @@ +import { optionalFields } from "./json-value.js"; import type { LocalAgentProfile } from "./local-agent-profiles.js"; import type { LocalAgentProviderInstanceId } from "./local-agent-provider.js"; import type { SubagentProviderConfig } from "./local-agent-config.js"; @@ -42,6 +43,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 +60,7 @@ export function parseLocalAgentContinueArgs(args: string[]): ParsedLocalAgentCon 'Usage: devspace agents continue [--model ] [--effort ] [--write-mode ] [--wait [--timeout ]] ""', false, ); + return { agentId: target, ...parsed }; } @@ -67,6 +70,7 @@ function parseAgentPromptArgs( isRun: boolean, ): ParsedLocalAgentRunArgs { const [target, ...rest] = args; + if (!target) { throw new Error(usage); } @@ -76,26 +80,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 +117,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 { @@ -118,7 +133,7 @@ function parseAgentPromptArgs( effort: values["--effort"], writeMode: parseWriteMode(values["--write-mode"]), requestId: values["--request-id"] === undefined ? undefined : validateAgentId(values["--request-id"], "Request id"), - ...(worktree ? { worktree: { branch: values["--branch"] } } : {}), + ...optionalFields(worktree ? { worktree: { branch: values["--branch"] } } : undefined), wait, timeoutMs: values["--timeout"] === undefined ? undefined : parseAgentWaitTimeout(values["--timeout"]), }; @@ -127,7 +142,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 +152,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 +184,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 +199,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..5f8597012 100644 --- a/src/local-agent-worktree.test.ts +++ b/src/local-agent-worktree.test.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; @@ -23,6 +24,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,10 +36,12 @@ class FakeRuntime implements LocalAgentRuntime { function unwrap(result: BetterResult): T { if (result.isErr()) throw result.error; + return result.value; } -function code(result: BetterResult): string | undefined { +function code(result: BetterResult): string | undefined { + // SAFETY: The surrounding checks or construction already match this type. return result.isErr() ? (result.error as { code?: string }).code : undefined; } @@ -104,6 +108,7 @@ test("a failed worktree creation leaves no record so the request id can be retri const taken = await start({ agentId: "job-3", worktree: { branch: "feature/probe" } }); assert.equal(code(taken), "WORKTREE_CREATE_FAILED"); + // SAFETY: The surrounding checks or construction already match this type. assert.match((taken as { error: Error }).error.message, /already exists/); assert.equal(store.getById("job-3"), undefined); assert.equal(unwrap(await start({ agentId: "job-3", worktree: { branch: "feature/other" } })).branch, "feature/other"); @@ -114,7 +119,9 @@ test("a worktree agent outside a git repository fails with a typed error", async const { store, notGit, start } = fixture(t); const outside = await start({ worktree: {}, workspaceRoot: notGit }); assert.equal(code(outside), "WORKTREE_NOT_GIT_REPOSITORY"); + // SAFETY: The surrounding checks or construction already match this type. assert.equal((outside as { error: { agentId?: string } }).error.agentId, undefined, "the rolled-back id is not reported"); + // SAFETY: The surrounding checks or construction already match this type. assert.match((outside as { error: Error }).error.message, /needs a git repository/); assert.deepEqual(store.list({ workspaceRoot: notGit }), []); }); @@ -127,6 +134,7 @@ test("a removed worktree fails the follow-up clearly instead of running in the s git(source, "worktree", "remove", "--force", named.worktreePath!); const missing = await manager.continue("job-2", "again", {}, scope); assert.equal(code(missing), "WORKTREE_MISSING"); + // SAFETY: The surrounding checks or construction already match this type. assert.match((missing as { error: Error }).error.message, /git worktree add/); }); @@ -161,6 +169,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 +185,7 @@ function fixture(t: TestContext) { }; const store = new LocalAgentStore(join(temp, "state")); + const manager = new LocalAgentManager({ store, drivers: [driver], @@ -184,12 +194,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..d3d296845 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,11 @@ 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 { + // SAFETY: The surrounding checks or construction already match this type. 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 +26,8 @@ async function observedIps(trustProxy: TrustProxyMode): Promise<{ reqIp: string; "x-forwarded-for": "198.51.100.66, 203.0.113.7", }, }); + + // SAFETY: The surrounding checks or construction already match this type. return await response.json() as { reqIp: string; logged: string }; } finally { server.close(); diff --git a/src/logger.ts b/src/logger.ts index 7c7d160db..10589e57b 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,11 @@ +import { isJsonString } from "./json-value.js"; +import type { JsonObject, WireInput } from "./json-value.js"; 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 { @@ -14,7 +18,7 @@ export interface LoggingConfig { trustProxy: TrustProxyMode; } -type LogFields = Record; +type LogFields = JsonObject; const LEVEL_WEIGHT: Record = { silent: 0, @@ -44,6 +48,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 +63,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 +80,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 +92,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)}`) @@ -92,7 +101,8 @@ function formatPretty(entry: LogFields): string { return rest ? `${ts} ${level} ${event} ${rest}` : `${ts} ${level} ${event}`; } -function formatPrettyValue(value: unknown): string { - if (typeof value === "string") return JSON.stringify(value); +function formatPrettyValue(value: WireInput): string { + if (isJsonString(value)) 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..c1c1c57b1 100644 --- a/src/mcp-modern-server.test.ts +++ b/src/mcp-modern-server.test.ts @@ -1,3 +1,6 @@ +import { isJsonString, isObjectValue, optionalFields } from "./json-value.js"; +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import test from "node:test"; import { registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server"; @@ -14,6 +17,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 +33,20 @@ 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); + + // SAFETY: The surrounding checks or construction already match this type. 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 +54,14 @@ 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()); + + // SAFETY: The surrounding checks or construction already match this type. const callBody = await called.json() as { result?: { content?: Array<{ text?: string }> }; }; + assert.equal(callBody.result?.content?.[0]?.text, "ok:modern-chat"); }); @@ -58,6 +71,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 +88,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 +106,13 @@ 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); + // SAFETY: The surrounding checks or construction already match this type. + .map((line) => unsafeNarrow(JSON.parse(line.slice(6)))); + assert.ok(messages.some((message) => message.method === "notifications/progress")); assert.match(JSON.stringify(messages.at(-1)), /done/); }); @@ -103,6 +123,7 @@ test("modern registration adapter preserves resources", async (t) => { name: "devspace-modern-test", version: "1.0.0", }); + registerAppResource( adapter.registrationTarget, "Test resource", @@ -116,8 +137,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 +154,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 +182,7 @@ test("compiled registration surface reuses static tool and resource definitions" }), ); }); + assert.equal(registrationBuilds, 1); const handler = createMcpHandler(() => { @@ -165,9 +190,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,24 +207,26 @@ 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); }); -function modernRequest(method: string, params: Record): Request { - const mcpName = typeof params.name === "string" +function modernRequest(method: string, params: JsonObject): Request { + const mcpName = isJsonString(params.name) ? params.name - : typeof params.uri === "string" + : isJsonString(params.uri) ? params.uri : undefined; + return new Request("https://example.test/mcp", { method: "POST", headers: { "content-type": "application/json", "mcp-method": method, "mcp-protocol-version": "2026-07-28", - ...(mcpName ? { "mcp-name": mcpName } : {}), + ...optionalFields(mcpName ? { "mcp-name": mcpName } : undefined), }, body: JSON.stringify({ jsonrpc: "2.0", @@ -214,8 +244,9 @@ function modernRequest(method: string, params: Record): Request }); } -function objectValue(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record +function objectValue(value: WireInput): JsonObject { + return value && isObjectValue(value) && !Array.isArray(value) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(value) : {}; } diff --git a/src/mcp-modern-server.ts b/src/mcp-modern-server.ts index 9b5cabeb1..f23e6d7bc 100644 --- a/src/mcp-modern-server.ts +++ b/src/mcp-modern-server.ts @@ -1,3 +1,6 @@ +import { optionalFields, jsonKind } from "./json-value.js"; +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import { McpServer, type ServerContext, @@ -20,42 +23,49 @@ type RegistrationReplay = (target: McpRegistrationTarget) => void; type ModernRegisterTool = ( name: string, - definition: Record, - handler: (input: unknown, context: ServerContext) => unknown, -) => unknown; + definition: JsonObject, + handler: (input: WireInput, context: ServerContext) => WireInput, +) => WireInput; -type ModernRegisterResource = (...args: unknown[]) => unknown; +type ModernRegisterResource = (...args: WireInput[]) => WireInput; export function createModernMcpServerAdapter( serverInfo: LegacyImplementation, options?: ServerOptions, ): ModernMcpServerAdapter { 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 registerModernTool = unsafeNarrow(server.registerTool.bind(server)); + const registerModernResource = unsafeNarrow(server.registerResource.bind(server)); + const registrationTarget: McpRegistrationTarget = { - registerTool: (( + // SAFETY: The surrounding checks or construction already match this type. + registerTool: unsafeNarrow(( name: string, - definition: Record, - handler: (input: unknown, extra: Record) => unknown, + definition: JsonObject, + handler: (input: WireInput, extra: JsonObject) => WireInput, ) => registerModernTool( name, definition, async (input, context) => handler(input, legacyToolHandlerExtra(context)), - )) as LegacyMcpServer["registerTool"], - registerResource: ((...args: unknown[]) => { - const callback = args.at(-1) as (...callbackArgs: unknown[]) => unknown; + )), + // SAFETY: The surrounding checks or construction already match this type. + registerResource: unsafeNarrow((...args: WireInput[]) => { + // SAFETY: The surrounding checks or construction already match this type. + const callback = args.at(-1) as (...callbackArgs: WireInput[]) => WireInput; + return registerModernResource( ...args.slice(0, -1), - (...callbackArgs: unknown[]) => { + (...callbackArgs: WireInput[]) => { + // SAFETY: The surrounding checks or construction already match this type. const context = callbackArgs.at(-1) as ServerContext; + return callback( ...callbackArgs.slice(0, -1), legacyToolHandlerExtra(context), ); }, ); - }) as unknown as LegacyMcpServer["registerResource"], + }), }; return { @@ -68,40 +78,46 @@ export function compileMcpRegistrationSurface( registerSurface: (target: McpRegistrationTarget) => void, ): (target: McpRegistrationTarget) => void { const registrations: RegistrationReplay[] = []; + const recordingTarget: McpRegistrationTarget = { - registerTool: ((...args: unknown[]) => { + // SAFETY: The surrounding checks or construction already match this type. + registerTool: unsafeNarrow((...args: WireInput[]) => { registrations.push((target) => { - (target.registerTool as (...callArgs: unknown[]) => unknown)(...args); + // SAFETY: The surrounding checks or construction already match this type. + (target.registerTool as (...callArgs: WireInput[]) => WireInput)(...args); }); - }) as unknown as McpRegistrationTarget["registerTool"], - registerResource: ((...args: unknown[]) => { + }), + registerResource: unsafeNarrow((...args: WireInput[]) => { registrations.push((target) => { - (target.registerResource as (...callArgs: unknown[]) => unknown)(...args); + // SAFETY: The surrounding checks or construction already match this type. + (target.registerResource as (...callArgs: WireInput[]) => WireInput)(...args); }); - }) as unknown as McpRegistrationTarget["registerResource"], + }), }; registerSurface(recordingTarget); const compiled = Object.freeze(registrations.slice()); + return (target) => { for (const replay of compiled) replay(target); }; } -export function modernMcpAdapterErrorLogFields(error: Error): Record { +export function modernMcpAdapterErrorLogFields(error: Error): JsonObject { const cause = error.cause; + return { error: error.message, errorName: error.name, - ...(cause === undefined ? {} : { + ...optionalFields(cause === undefined ? undefined : { cause: cause instanceof Error ? { name: cause.name, message: cause.message } - : { name: typeof cause, message: String(cause) }, + : { name: jsonKind(cause), message: String(cause) }, }), }; } -function legacyToolHandlerExtra(context: ServerContext): Record { +function legacyToolHandlerExtra(context: ServerContext): JsonObject { return { signal: context.mcpReq.signal, authInfo: context.http?.authInfo, diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index a4a779eed..cefb24268 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"); } @@ -336,7 +358,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { function authorizationFormFields( client: OAuthClientInformationFull, params: AuthorizationParams, -): Record { +) { return { response_type: "code", client_id: client.client_id, 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..edb47bf9f 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,8 +65,10 @@ 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( + // SAFETY: The surrounding checks or construction already match this type. migrations.map((migration) => (migration as { version: number }).version), migrations.map((_, index) => index + 1), ); @@ -83,6 +88,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 +113,20 @@ function testPersistenceAndTokenHashing(stateDir: string): void { firstStore.close(); const database = openDatabase(stateDir); + try { + // SAFETY: The surrounding checks or construction already match this type. const accessHashes = database.sqlite .prepare("select token_hash from oauth_access_tokens") .pluck() .all() as string[]; + + // SAFETY: The surrounding checks or construction already match this type. 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 +136,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 +149,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 +164,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 +175,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 +230,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 +253,7 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi firstProvider.exchangeAuthorizationCode(client, code, undefined, redirectUri, mcpUrl), InvalidGrantError, ); + const issued = await firstProvider.exchangeAuthorizationCode( client, code, @@ -241,12 +261,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 +281,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 +298,7 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi ["devspace"], tunnelUrl, ); + assert.ok(refreshed.refresh_token); assert.notEqual(refreshed.access_token, issued.access_token); @@ -303,14 +327,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..26f6fc43d 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); } @@ -46,10 +48,12 @@ export class SqliteOAuthStore { } getClient(clientId: string): OAuthClientInformationFull | undefined { + // SAFETY: The surrounding checks or construction already match this type. const row = this.database.sqlite .prepare("select client_json from oauth_clients where client_id = ?") .get(clientId) as { client_json: string } | undefined; + // SAFETY: The surrounding checks or construction already match this type. return row ? (JSON.parse(row.client_json) as OAuthClientInformationFull) : undefined; } @@ -62,6 +66,7 @@ export class SqliteOAuthStore { } const now = Math.floor(Date.now() / 1000); + const registered: OAuthClientInformationFull = { ...client, client_id: `devspace-${randomUUID()}`, @@ -99,6 +104,7 @@ export class SqliteOAuthStore { } getAccessToken(tokenHash: string): PersistedAccessTokenRecord | undefined { + // SAFETY: The surrounding checks or construction already match this type. const row = this.database.sqlite .prepare( "select client_id, scopes_json, expires_at, resource from oauth_access_tokens where token_hash = ?", @@ -143,6 +149,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 +158,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; }); @@ -163,6 +172,7 @@ export class SqliteOAuthStore { } getRefreshToken(tokenHash: string): PersistedRefreshTokenRecord | undefined { + // SAFETY: The surrounding checks or construction already match this type. const row = this.database.sqlite .prepare( "select client_id, scopes_json, expires_at, resource from oauth_refresh_tokens where token_hash = ?", @@ -218,6 +228,7 @@ function rowToAccessTokenRecord(row: { }): PersistedAccessTokenRecord { return { clientId: row.client_id, + // SAFETY: The surrounding checks or construction already match this type. scopes: JSON.parse(row.scopes_json) as string[], expiresAt: row.expires_at, resource: row.resource ?? undefined, @@ -232,6 +243,7 @@ function rowToRefreshTokenRecord(row: { }): PersistedRefreshTokenRecord { return { clientId: row.client_id, + // SAFETY: The surrounding checks or construction already match this type. scopes: JSON.parse(row.scopes_json) as string[], expiresAt: row.expires_at, resource: row.resource ?? undefined, 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..2039854f0 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -2,7 +2,6 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import { defaultDriverForProviderId, LOCAL_AGENT_DEFAULT_PROVIDER_IDS, - isLocalAgentDriverKind, type LocalAgentProviderInstanceId, } from "./local-agent-provider.js"; @@ -10,15 +9,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 +41,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 +54,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..6383d6756 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -1,3 +1,4 @@ +import type { WireInput } from "./json-value.js"; import { createBashTool, createEditTool, @@ -12,6 +13,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; @@ -22,7 +24,7 @@ interface ToolContext { cwd: string; } -function toMcpContent(result: AgentToolResult): McpContent[] { +function toMcpContent(result: AgentToolResult): McpContent[] { return result.content.map((content) => { if (content.type === "text") { return { type: "text", text: content.text }; @@ -36,18 +38,20 @@ function toMcpContent(result: AgentToolResult): McpContent[] { }); } -function formatToolError(error: unknown): McpContent[] { +function formatToolError(error: WireInput): McpContent[] { const message = error instanceof Error ? error.message : String(error); + return [{ type: "text", text: message }]; } -async function runTool( +async function runTool( execute: (input: TInput) => Promise>, input: TInput, - context: ToolContext, + _context: ToolContext, ): 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..2a0c499c4 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,8 +70,10 @@ export function terminateProcessTree( } else if (detached && child.pid) { try { runtime.killGroup(child.pid, signal); + return; } catch (error) { + // SAFETY: The surrounding checks or construction already match this type. if ((error as NodeJS.ErrnoException).code === "ESRCH") return; } } diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index ddb97e76e..39849168a 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -1,3 +1,4 @@ +import { isJsonNumber } from "./json-value.js"; import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -46,12 +47,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 +63,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,21 +71,24 @@ 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"); + assert.equal(isJsonNumber(background.sessionId), true); await assert.rejects( manager.write({ @@ -97,6 +104,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,15 +112,17 @@ 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"); + assert.equal(isJsonNumber(interactive.sessionId), true); const inputResult = await manager.write({ workspaceId: "workspace-a", @@ -120,18 +130,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 +153,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 +221,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 +230,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 +246,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 +263,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 +274,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 +289,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 +297,7 @@ test("termination stays bounded when a descendant escapes the process group", { // Already gone. } } + escapeManager.shutdown(); rmSync(dir, { recursive: true, force: true }); }); @@ -284,6 +318,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..e8301b5e8 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -1,3 +1,4 @@ +import { optionalFields } from "./json-value.js"; import { AsyncLocalStorage } from "node:async_hooks"; import { spawn } from "node:child_process"; import { resolveShellCommand, terminateProcessTree } from "./process-platform.js"; @@ -16,6 +17,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,24 +89,28 @@ 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; } function processEnvironment(input?: { workspaceId?: string; workspaceRoot?: string; -}): Record { +}) { return { ...Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), @@ -117,8 +123,8 @@ function processEnvironment(input?: { CODEX_CI: "1", LANG: process.env.LANG ?? "C.UTF-8", LC_ALL: process.env.LC_ALL ?? "C.UTF-8", - ...(input?.workspaceId ? { DEVSPACE_WORKSPACE_ID: input.workspaceId } : {}), - ...(input?.workspaceRoot ? { DEVSPACE_WORKSPACE_ROOT: input.workspaceRoot } : {}), + ...optionalFields(input?.workspaceId ? { DEVSPACE_WORKSPACE_ID: input.workspaceId } : undefined), + ...optionalFields(input?.workspaceRoot ? { DEVSPACE_WORKSPACE_ROOT: input.workspaceRoot } : undefined), }; } @@ -132,16 +138,23 @@ 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(""); } -function splitBudget(maxCharacters: number): { head: number; tail: number } { +interface CharacterBudget { + head: number; + tail: number; +} + +function splitBudget(maxCharacters: number): CharacterBudget { return { head: Math.ceil(maxCharacters / 2), tail: Math.floor(maxCharacters / 2), @@ -150,6 +163,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 +186,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; } @@ -190,7 +207,7 @@ export class HeadTailBuffer { return this.totalCharacters > 0; } - drain(maxCharacters: number): { output: string; truncated: boolean } { + drain(maxCharacters: number): TruncatedOutput { if (!Number.isInteger(maxCharacters) || maxCharacters < 1) { throw new Error("Output limit must be a positive integer."); } @@ -199,6 +216,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; @@ -211,14 +229,21 @@ export class HeadTailBuffer { } } -function truncateOutput(output: string, maxCharacters: number): { output: string; truncated: boolean } { +interface TruncatedOutput { + output: string; + truncated: boolean; +} + +function truncateOutput(output: string, maxCharacters: number): TruncatedOutput { 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 +266,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 +279,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 +310,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 +322,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 +370,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 +409,7 @@ export class ProcessSessionManager { private createSession(input: StartCommandInput): ProcessSession { let resolveExit = (): void => undefined; + const exitPromise = new Promise((resolve) => { resolveExit = resolve; }); @@ -383,6 +430,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 +456,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,20 +465,17 @@ 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, - env: processEnvironment({ - workspaceId: input.workspaceId, - workspaceRoot: input.workspaceRoot, - }), - name: "xterm-256color", - cols: session.columns, - rows: session.rows, - }); - } catch (error) { - throw error; - } + + pty = nodePty.spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: processEnvironment({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + }), + name: "xterm-256color", + cols: session.columns, + rows: session.rows, + }); session.process = { write: (data) => pty.write(data), @@ -477,15 +523,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..29e3c6eb8 100644 --- a/src/request-meta.ts +++ b/src/request-meta.ts @@ -1,14 +1,19 @@ +import { isObjectValue, isJsonString } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; + function metadataString( - meta: unknown, + meta: WireInput, key: string, ): 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; + if (!isObjectValue(meta) || meta === null) return undefined; + // SAFETY: The surrounding checks or construction already match this type. + const value = (meta as JsonObject)[key]; + + return isJsonString(value) && value.length > 0 ? value : undefined; } export function conversationScopeIdFromRequestMeta( - meta: unknown, + meta: WireInput, ): string | undefined { return metadataString(meta, "openai/session"); } 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..b1fe54ecd 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,20 +411,27 @@ 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) ?? {}; + // SAFETY: The surrounding checks or construction already match this type. history[kind as "open" | "baseline"] = commit; histories.set(workspace, history); } @@ -400,13 +439,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 +482,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 +490,7 @@ function parseNumstat(output: string, types: Map): R const previousPath = fields[index++]; const newPath = fields[index++]; + if (!newPath) continue; files.push({ @@ -464,6 +508,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 +520,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..9cc28e960 100644 --- a/src/roots.ts +++ b/src/roots.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isObjectValue } from "./json-value.js"; +import type { WireInput } from "./json-value.js"; import { homedir } from "node:os"; import { lstat, realpath } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; @@ -11,6 +14,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 +38,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 +48,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 +62,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 +84,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 +97,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,18 +113,20 @@ async function canonicalizePath(path: string): Promise { } const parent = dirname(candidate); + if (parent === candidate) throw error; candidate = parent; } } } -function isMissingPathError(error: unknown): boolean { +function isMissingPathError(error: WireInput): boolean { return Boolean( - typeof error === "object" && + isObjectValue(error) && error && "code" in error && - ((error as NodeJS.ErrnoException).code === "ENOENT" || - (error as NodeJS.ErrnoException).code === "ENOTDIR"), + // SAFETY: The surrounding checks or construction already match this type. + (unsafeNarrow(error).code === "ENOENT" || + unsafeNarrow(error).code === "ENOTDIR"), ); } diff --git a/src/server-oauth.test.ts b/src/server-oauth.test.ts index f9cdf5720..e6b806a24 100644 --- a/src/server-oauth.test.ts +++ b/src/server-oauth.test.ts @@ -1,3 +1,4 @@ +import { isJsonString } from "./json-value.js"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { once } from "node:events"; @@ -14,6 +15,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 +23,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 +37,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"); @@ -49,7 +56,8 @@ 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"); + assert.ok(address && !isJsonString(address)); + for (const { resource, accepted } of cases) { const response: Response = await fetch(`http://127.0.0.1:${address.port}/mcp`, { method: "POST", @@ -64,8 +72,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..7dfb196b1 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,3 +1,6 @@ +import { isObjectValue, isFunctionValue, isJsonString, isJsonObject, optionalFields } from "./json-value.js"; +import { unsafeNarrow } from "./unsafe-narrow.js"; +import type { JsonObject, WireInput } from "./json-value.js"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; @@ -73,7 +76,9 @@ test("experimental Code Mode bootstraps real tools and executes through MCP", as ]); const opened = await callOpen(context.client, context.project); + // SAFETY: The surrounding checks or construction already match this type. const workspaceId = structuredContent(opened).workspace_id as string; + // SAFETY: The surrounding checks or construction already match this type. const codeMode = structuredContent(opened).code_mode as { tool_signatures: string }; assert.match(codeMode.tool_signatures, /read\(/); assert.match(codeMode.tool_signatures, /apply_patch\(/); @@ -81,6 +86,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,8 +96,11 @@ 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"); + // SAFETY: The surrounding checks or construction already match this type. assert.match((executed.value as { status: string }).status, /^\?\? note\.txt/); + // SAFETY: The surrounding checks or construction already match this type. assert.equal((executed.value as { content: string }).content, "hello world"); const patched = structuredContent(await context.client.callTool({ @@ -101,12 +110,16 @@ 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 }, })); + + // SAFETY: The surrounding checks or construction already match this type. assert.match(reviewed.result as string, /Changed 1 file/); }); @@ -120,26 +133,31 @@ 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 }); + // SAFETY: The surrounding checks or construction already match this type. 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"); + assert.equal(isJsonString(start.execution_id), true); const end = structuredContent(await context.client.callTool({ 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); + // SAFETY: The surrounding checks or construction already match this type. assert.deepEqual((end.calls as { tool: string }[]).map((call) => call.tool).slice(0, 2), [ "exec_command", "write_stdin", ]); @@ -147,27 +165,34 @@ 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 }); + // SAFETY: The surrounding checks or construction already match this type. 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"); + // SAFETY: The surrounding checks or construction already match this type. assert.match(invalid.error as string, /path/); const start = structuredContent(await context.client.callTool({ 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"); }); @@ -182,23 +207,28 @@ 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 }); + // SAFETY: The surrounding checks or construction already match this type. 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")); }); test("closing the MCP server stops running Code Mode scripts and their commands", async (t) => { const context = await fixture(t, { experimentalCodeMode: true, uiEnabled: false }); + // SAFETY: The surrounding checks or construction already match this type. const workspaceId = structuredContent(await callOpen(context.client, context.project)).workspace_id as string; await context.client.callTool({ name: "execute", @@ -212,13 +242,18 @@ 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 }); + // SAFETY: The surrounding checks or construction already match this type. 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) { + // SAFETY: The surrounding checks or construction already match this type. running.push(structuredContent(await execute()).execution_id as string); } @@ -238,6 +273,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 +294,8 @@ 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); + + // SAFETY: The surrounding checks or construction already match this type. const yieldSchema = tool?.inputSchema?.properties?.yield_time_ms as { maximum?: number; description?: string; @@ -270,10 +308,12 @@ 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"); + + assert.equal(isJsonString(workspaceId), true); await writeFile(join(context.project, "note.txt"), "before\n"); await mkdir(join(context.project, "nested")); @@ -286,6 +326,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 +338,8 @@ test("Claude edit and bash tools accept snake_case runtime inputs", async (t) => working_directory: "nested", }, })); + + // SAFETY: The surrounding checks or construction already match this type. assert.match(shell.result as string, /nested/i); }); @@ -308,15 +351,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"); + + assert.equal(isJsonString(workspaceId), true); const result = await context.client.callTool({ name: "read", arguments: { workspace_id: workspaceId, path: "outside-link/secret.txt" }, }); + assert.equal(result.isError, true); }); @@ -327,10 +373,12 @@ 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"); + + assert.equal(isJsonString(workspaceId), true); const result = await context.client.callTool({ name: "write", @@ -340,6 +388,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,8 +398,10 @@ 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)) + // SAFETY: The surrounding checks or construction already match this type. + .filter((tool) => Boolean(isJsonObject(tool._meta) ? tool._meta.ui : undefined)) .map((tool) => tool.name) .sort(); @@ -365,14 +416,16 @@ test("show_changes reviews an unborn repository through the MCP tool surface", a const opened = structuredContent(await callOpen(context.client, context.project, "unborn-review")); const workspaceId = opened.workspace_id; - assert.equal(typeof workspaceId, "string"); + assert.equal(isJsonString(workspaceId), true); 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, [ @@ -384,6 +437,7 @@ test("show_changes reviews an unborn repository through the MCP tool surface", a }, ]); assert.match( + // SAFETY: The surrounding checks or construction already match this type. ((card.payload as { patch?: string } | undefined)?.patch) ?? "", /new file/, ); @@ -394,22 +448,28 @@ 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"); + assert.equal(isJsonString(workspaceId), true); 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); + // SAFETY: The surrounding checks or construction already match this type. + assert.equal((review._meta as JsonObject | undefined)?.tool, undefined); assert.equal(structured.workspace_id, workspaceId); assert.equal("workspaceId" in structured, false); + // SAFETY: The surrounding checks or construction already match this type. assert.match(structured.review_ref as string, /^[0-9a-f]{40,64}$/); assert.equal("summary" in structured, false); assert.equal("files" in structured, false); @@ -430,48 +490,65 @@ test("show_changes keeps model output compact and preserves the rich review card }, ]); assert.match( + // SAFETY: The surrounding checks or construction already match this type. ((card.payload as { patch?: string } | undefined)?.patch) ?? "", /-hello\n\+goodbye/, ); 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"); + + assert.equal(isJsonString(workspaceId), true); 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"); + assert.equal(isJsonString(reviewRef), true); await writeFile(join(context.project, "README.md"), "second\n"); - const reopened = await context.client.callTool({ + + // SAFETY: The surrounding checks or construction already match this type. + const reopened = await context.client.callTool(unsafeNarrow[0]>({ 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) ?? ""), + // SAFETY: The surrounding checks or construction already match this type. + String((() => { + const payload = responseCard(reopened).payload; + + return isJsonObject(payload) ? payload.patch ?? "" : ""; + })()), /\+first/, ); @@ -479,8 +556,14 @@ 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) ?? ""), + // SAFETY: The surrounding checks or construction already match this type. + String((() => { + const payload = responseCard(current).payload; + + return isJsonObject(payload) ? payload.patch ?? "" : ""; + })()), /-first\n\+second/, ); }); @@ -488,9 +571,12 @@ 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!; + + // SAFETY: The surrounding checks or construction already match this type. 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 +584,8 @@ 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`, }, }); + + // SAFETY: The surrounding checks or construction already match this type. const showChanges = async () => structuredContent(await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, @@ -511,6 +599,7 @@ test("workspace activity records turns between review checkpoints", async (t) => arguments: { workspace_id: workspaceId, cmd: "printf activity" }, }); const secondRef = await showChanges(); + // SAFETY: The surrounding checks or construction already match this type. await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, @@ -538,15 +627,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,28 +648,36 @@ 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); - assert.equal((repeated._meta as Record | undefined)?.tool, undefined); + // SAFETY: The surrounding checks or construction already match this type. + assert.equal((first._meta as JsonObject | undefined)?.tool, undefined); + // SAFETY: The surrounding checks or construction already match this type. + assert.equal((repeated._meta as JsonObject | undefined)?.tool, undefined); const tools = await context.client.listTools(); const openTool = tools.tools.find((tool) => tool.name === "open_workspace"); - const outputProperties = (openTool?.outputSchema as { properties?: Record } | undefined)?.properties; + // SAFETY: The surrounding checks or construction already match this type. + const outputProperties = (openTool?.outputSchema as { properties?: JsonObject } | undefined)?.properties; assert.ok(outputProperties && "workspace_id" in outputProperties); assert.equal(outputProperties && "workspaceId" in outputProperties, false); assert.equal(outputProperties && "workspaceReused" in outputProperties, false); assert.equal(outputProperties && "includeBootstrapContext" in outputProperties, false); + + // SAFETY: The surrounding checks or construction already match this type. const providerSchema = outputProperties?.agent_providers as { - items?: { properties?: Record }; + items?: { properties?: JsonObject }; } | undefined; + assert.ok(providerSchema?.items?.properties?.note); const firstStructured = structuredContent(first); - assert.equal(typeof firstStructured.workspace_id, "string"); + assert.equal(isJsonString(firstStructured.workspace_id), true); assert.equal("workspaceId" in firstStructured, false); assert.equal(firstStructured.workspace_id, structuredContent(repeated).workspace_id); assert.ok(Array.isArray(firstStructured.agents_files)); @@ -585,11 +685,13 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(firstStructured.skills)); assert.ok(Array.isArray(firstStructured.agent_providers)); assert.equal( - (firstStructured.agent_providers as Array>)[0]?.id, + // SAFETY: The surrounding checks or construction already match this type. + (firstStructured.agent_providers as Array)[0]?.id, "codex", ); assert.equal( - (firstStructured.agent_providers as Array>)[0]?.note, + // SAFETY: The surrounding checks or construction already match this type. + (firstStructured.agent_providers as Array)[0]?.note, providerNote, ); assert.ok(Array.isArray(firstStructured.agents)); @@ -598,9 +700,13 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.equal("includeBootstrapContext" in firstStructured, false); const repeatedStructured = structuredContent(repeated); + // SAFETY: The surrounding checks or construction already match this type. assert.match(firstStructured.instruction as string, /workspace_id/); + // SAFETY: The surrounding checks or construction already match this type. assert.match(repeatedStructured.instruction as string, /workspace_id/); + // SAFETY: The surrounding checks or construction already match this type. assert.doesNotMatch(firstStructured.instruction as string, /workspaceId/); + // SAFETY: The surrounding checks or construction already match this type. assert.doesNotMatch(repeatedStructured.instruction as string, /workspaceId/); assert.equal(repeatedStructured.agents_files, undefined); assert.equal(repeatedStructured.available_agents_files, undefined); @@ -619,7 +725,8 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(card.skills)); assert.ok(Array.isArray(card.agentProviders)); assert.equal( - (card.agentProviders as Array>)[0]?.note, + // SAFETY: The surrounding checks or construction already match this type. + (card.agentProviders as Array)[0]?.note, providerNote, ); assert.ok(Array.isArray(card.agents)); @@ -627,6 +734,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 }], }); @@ -638,11 +746,13 @@ test("open_workspace refreshes provider availability for each catalog", async (t available = true; const usable = structuredContent(await callOpen(context.client, context.project, "chat-2")); assert.equal( - (usable.agent_providers as Array>)[0]?.id, + // SAFETY: The surrounding checks or construction already match this type. + (usable.agent_providers as Array)[0]?.id, "codex", ); assert.equal( - (usable.agents as Array>)[0]?.name, + // SAFETY: The surrounding checks or construction already match this type. + (usable.agents as Array)[0]?.name, "reviewer", ); }); @@ -665,7 +775,8 @@ test("open_workspace omits providers disabled by configuration", async (t) => { const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); assert.deepEqual( - (opened.agent_providers as Array>).map((provider) => provider.id), + // SAFETY: The surrounding checks or construction already match this type. + (opened.agent_providers as Array).map((provider) => provider.id), ["codex"], ); }); @@ -676,7 +787,8 @@ test("open_workspace advertises subagent instructions on demand by default", asy }); const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); - const skills = opened.skills as Array>; + // SAFETY: The surrounding checks or construction already match this type. + const skills = opened.skills as Array; assert.equal(skills.some((skill) => skill.name === "subagents"), true); assert.doesNotMatch(String(opened.instruction), /# DevSpace subagents/); }); @@ -692,7 +804,8 @@ test("open_workspace preloads subagent instructions when configured", async (t) }); const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); - const skills = opened.skills as Array>; + // SAFETY: The surrounding checks or construction already match this type. + const skills = opened.skills as Array; assert.equal(skills.some((skill) => skill.name === "subagents"), false); assert.match(String(opened.instruction), /# DevSpace subagents/); }); @@ -715,11 +828,14 @@ 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 }, }); + + // SAFETY: The surrounding checks or construction already match this type. const workspaceId = (await opened.json()).result.structuredContent.workspace_id as string; - assert.equal(typeof workspaceId, "string"); + assert.equal(isJsonString(workspaceId), true); for (const argumentsValue of [ { workspaceId, path: "note.txt" }, @@ -729,6 +845,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 +856,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 +864,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,14 +873,18 @@ 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", {}); - const tools = (await listed.json()).result.tools as Array<{ name: string; inputSchema: { properties?: Record } }>; + // SAFETY: The surrounding checks or construction already match this type. + const tools = (await listed.json()).result.tools as Array<{ name: string; inputSchema: { properties?: JsonObject } }>; const read = tools.find((tool) => tool.name === "read"); assert.ok(read?.inputSchema.properties?.workspace_id); assert.equal(read?.inputSchema.properties?.workspaceId, undefined); @@ -769,6 +892,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`, { @@ -781,10 +905,11 @@ test("HTTP conflicting workspace aliases preserve request IDs and notification s "mcp-protocol-version": "2026-07-28", }, body: JSON.stringify({ - jsonrpc: "2.0", ...(id === undefined ? {} : { id }), method: "tools/call", + jsonrpc: "2.0", ...optionalFields(id === undefined ? undefined : { id }), method: "tools/call", 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 +931,19 @@ 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", {}), ]); + + // SAFETY: The surrounding checks or construction already match this type. const tools = (await listed.json() as { result: { tools: Array<{ name: string }> } }) .result.tools.map((tool) => tool.name); + + // SAFETY: The surrounding checks or construction already match this type. const { instructions } = (await discovered.json() as { result: { instructions: string } }).result; + return { tools, instructions }; }; @@ -837,9 +968,13 @@ 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", {}); + + // SAFETY: The surrounding checks or construction already match this type. 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 +990,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 +999,14 @@ test("HTTP endpoint serves modern MCP and stateless legacy clients", async (t) = "server/discover", {}, ); + assert.equal(discovery.status, 200, await discovery.clone().text()); + + // SAFETY: The surrounding checks or construction already match this type. const discoveryBody = await discovery.json() as { result?: { supportedVersions?: string[] }; }; + assert.ok(discoveryBody.result?.supportedVersions?.includes("2026-07-28")); const listed = await postModernMcp( @@ -875,12 +1015,16 @@ 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/); + + // SAFETY: The surrounding checks or construction already match this type. 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,12 +1037,16 @@ 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()); + + // SAFETY: The surrounding checks or construction already match this type. 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"); + assert.equal(isJsonString(workspaceId), true); const repeated = await postModernMcp( localBaseUrl, @@ -910,10 +1058,14 @@ 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()); + + // SAFETY: The surrounding checks or construction already match this type. 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 +1087,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 +1106,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 +1117,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 +1128,17 @@ test("aborting one MCP request does not poison the next stateless request", asyn _meta: { "openai/session": "aborted-http-test" }, }, ); + + // SAFETY: The surrounding checks or construction already match this type. const openBody = await opened.json() as { result?: { structuredContent?: { workspace_id?: string } }; }; + const workspaceId = openBody.result?.structuredContent?.workspace_id; - assert.equal(typeof workspaceId, "string"); + assert.equal(isJsonString(workspaceId), true); const controller = new AbortController(); + const toolCall = postModernMcp( localBaseUrl, accessToken, @@ -995,6 +1154,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,9 +1172,11 @@ 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", {}); + // SAFETY: The surrounding checks or construction already match this type. const inventory = await listed.json() as { result?: { tools?: Array<{ name: string }> } }; assert.deepEqual(inventory.result?.tools?.map((tool) => tool.name).sort(), [ "cancel_execution", "execute", "open_workspace", "poll_execution", "show_changes", @@ -1024,9 +1186,12 @@ test("experimental Code Mode executes over stateless HTTP MCP requests", async ( name: "open_workspace", arguments: { path: root }, }); + + // SAFETY: The surrounding checks or construction already match this type. 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 +1203,14 @@ 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()); + + // SAFETY: The surrounding checks or construction already match this type. 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 +1220,7 @@ test("server shutdown waits for an active MCP tool call", async (t) => { t, "devspace-shutdown-test-", ); + const opened = await postModernMcp( localBaseUrl, accessToken, @@ -1061,17 +1231,21 @@ test("server shutdown waits for an active MCP tool call", async (t) => { _meta: { "openai/session": "shutdown-test" }, }, ); + + // SAFETY: The surrounding checks or construction already match this type. const openBody = await opened.json() as { result?: { structuredContent?: { workspace_id?: string } }; }; + const workspaceId = openBody.result?.structuredContent?.workspace_id; - assert.equal(typeof workspaceId, "string"); + assert.equal(isJsonString(workspaceId), true); const command = [ "const fs=require('node:fs')", "fs.writeFileSync('started','')", "const timer=setInterval(()=>{if(fs.existsSync('release')) clearInterval(timer)},10)", ].join(";"); + const toolCall = postModernMcp( localBaseUrl, accessToken, @@ -1080,17 +1254,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); @@ -1108,27 +1285,34 @@ interface ServerFixture { } function schemaPropertyPaths( - schema: unknown, + schema: WireInput, prefix = "", ): Array<{ key: string; path: string }> { - if (!schema || typeof schema !== "object") return []; + if (!schema || !isObjectValue(schema)) return []; + + // SAFETY: The surrounding checks or construction already match this type. const record = schema as { - properties?: Record; + properties?: JsonObject; items?: unknown; anyOf?: unknown[]; 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 +1332,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 +1345,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)); @@ -1171,16 +1357,19 @@ async function httpServerFixture( }); const address = httpServer.address(); - assert.ok(address && typeof address === "object"); + assert.ok(address && isObjectValue(address)); 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 }; } @@ -1223,9 +1412,10 @@ async function fixture( await git(project, ["commit", "-m", "Initial commit"]); } - const initialProviderAvailability = typeof options.localAgentProviders === "function" + const initialProviderAvailability = isFunctionValue(options.localAgentProviders) ? options.localAgentProviders() : options.localAgentProviders ?? []; + const loadedConfig = loadConfig(writeTestDevspaceConfig(join(root, ".config"), { server: { port: 1 }, workspaces: { allowedRoots: [root], worktreeRoot: join(root, ".worktrees") }, @@ -1236,11 +1426,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 +1443,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" + isFunctionValue(options.localAgentProviders) ? 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 +1480,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 +1489,7 @@ async function fixture( ]); let closed = false; + const close = async () => { if (closed) return; closed = true; @@ -1303,7 +1504,7 @@ async function fixture( await rm(root, { recursive: true, force: true }); }); - return { client, project, close, ...(activity ? { activity } : {}) }; + return { client, project, close, ...optionalFields(activity ? { activity } : undefined) }; } async function git(cwd: string, args: string[]): Promise { @@ -1314,11 +1515,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 +1535,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,7 +1547,9 @@ async function issueTestAccessToken( token_endpoint_auth_method: "none", }), }); + assert.equal(registration.status, 201, await registration.clone().text()); + // SAFETY: The surrounding checks or construction already match this type. const client = await registration.json() as { client_id?: string }; assert.ok(client.client_id); @@ -1363,6 +1569,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 +1588,12 @@ async function issueTestAccessToken( resource, }), }); + assert.equal(exchange.status, 200, await exchange.clone().text()); + // SAFETY: The surrounding checks or construction already match this type. const tokens = await exchange.json() as { access_token?: string }; assert.ok(tokens.access_token); + return tokens.access_token; } @@ -1391,22 +1601,23 @@ function postModernMcp( localBaseUrl: string, accessToken: string | undefined, method: string, - params: Record, + params: JsonObject, options: { signal?: AbortSignal } = {}, ): Promise { - const mcpName = typeof params.name === "string" + const mcpName = isJsonString(params.name) ? params.name - : typeof params.uri === "string" + : isJsonString(params.uri) ? params.uri : undefined; + return fetch(`${localBaseUrl}/mcp`, { method: "POST", headers: { - ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}), + ...optionalFields(accessToken ? { authorization: `Bearer ${accessToken}` } : undefined), "content-type": "application/json", "mcp-method": method, "mcp-protocol-version": "2026-07-28", - ...(mcpName ? { "mcp-name": mcpName } : {}), + ...optionalFields(mcpName ? { "mcp-name": mcpName } : undefined), }, signal: options.signal, body: JSON.stringify({ @@ -1429,9 +1640,10 @@ function postModernMcp( }); } -function recordValue(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? value as Record +function recordValue(value: WireInput): JsonObject { + return value && isObjectValue(value) && !Array.isArray(value) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(value) : {}; } @@ -1440,25 +1652,30 @@ async function callOpen( path: string, conversationScopeId?: string, ): Promise>> { + // SAFETY: The surrounding checks or construction already match this type. const params = { name: "open_workspace", arguments: { path }, - ...(conversationScopeId - ? { _meta: { "openai/session": conversationScopeId } } - : {}), + ...optionalFields(conversationScopeId ? { _meta: { "openai/session": conversationScopeId } } : undefined), } as Parameters[0]; + return client.callTool(params); } -function structuredContent(result: Awaited>): Record { +function structuredContent(result: Awaited>): JsonObject { assert.ok(result.structuredContent); - return result.structuredContent as Record; + + // SAFETY: The surrounding checks or construction already match this type. + return result.structuredContent as JsonObject; } -function responseCard(result: Awaited>): Record { +function responseCard(result: Awaited>): JsonObject { const metadata = result._meta; - assert.ok(metadata && typeof metadata === "object"); - const card = (metadata as Record).card; - assert.ok(card && typeof card === "object"); - return card as Record; + assert.ok(metadata && isObjectValue(metadata)); + // SAFETY: The surrounding checks or construction already match this type. + const card = (metadata as JsonObject).card; + assert.ok(card && isObjectValue(card)); + + // SAFETY: The surrounding checks or construction already match this type. + return card as JsonObject; } diff --git a/src/server.ts b/src/server.ts index d78617a1e..2002efedc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "./unsafe-narrow.js"; +import { isObjectValue, isJsonString, isJsonNumber, optionalFields } from "./json-value.js"; +import type { WireInput, JsonObject } from "./json-value.js"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { access, realpath } from "node:fs/promises"; @@ -113,13 +116,14 @@ export function configureHttpServer(httpServer: HttpServer): void { type TrackToolActivity = (operation: () => Promise) => Promise; class ToolActivityTracker { - private readonly active = new Set>(); + private readonly active = new Set>(); readonly track: TrackToolActivity = (operation: () => Promise): Promise => { const promise = operation(); this.active.add(promise); const remove = () => this.active.delete(promise); void promise.then(remove, remove); + return promise; }; @@ -145,15 +149,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 +176,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 +191,7 @@ function formatAvailableAgentProvider(provider: { provider.effort ? `effort ${provider.effort}` : undefined, provider.note, ].filter(Boolean).join(", "); + return `${provider.id}${details ? ` (${details})` : ""}`; } @@ -231,25 +241,35 @@ function sendJsonRpcError( // ChatGPT may retain the pre-snake_case workspace argument in cached tool calls. // Normalize only that known alias at the HTTP edge; the public schema remains canonical. -function normalizeCachedWorkspaceArgument(body: unknown): boolean { - if (!body || typeof body !== "object" || Array.isArray(body)) return true; - const request = body as Record; +function normalizeCachedWorkspaceArgument(body: WireInput): boolean { + if (!body || !isObjectValue(body) || Array.isArray(body)) return true; + // SAFETY: The surrounding checks or construction already match this type. + const request = body as JsonObject; + 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 (!params || !isObjectValue(params) || Array.isArray(params)) return true; + // SAFETY: The surrounding checks or construction already match this type. + const args = (params as JsonObject).arguments; + + if (!args || !isObjectValue(args) || Array.isArray(args)) return true; + // SAFETY: The surrounding checks or construction already match this type. + const values = args as JsonObject; + 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; } -function requestLogFields(req: Request, config: ServerConfig): Record { +function requestLogFields(req: Request, config: ServerConfig): JsonObject { return { ip: requestIp(req, config.logging.trustProxy), host: req.header("host"), @@ -260,9 +280,11 @@ function requestLogFields(req: Request, config: ServerConfig): Record { - if (!body || typeof body !== "object" || Array.isArray(body)) return {}; +function rpcRequestLogFields(body: WireInput): JsonObject { + if (!body || !isObjectValue(body) || Array.isArray(body)) return {}; + // SAFETY: The surrounding checks or construction already match this type. const request = body as { id?: unknown; method?: unknown }; + return { rpcId: request.id, rpcMethod: request.method, @@ -295,6 +317,7 @@ function uiManifestUrl(): URL { } function readWorkspaceAppManifest(): WorkspaceAppManifest { + // SAFETY: The surrounding checks or construction already match this type. return JSON.parse(readFileSync(uiManifestUrl(), "utf8")) as WorkspaceAppManifest; } @@ -316,6 +339,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) => @@ -340,11 +364,9 @@ ${stylesheets} `; } -function appCsp(config: ServerConfig): { - resourceDomains: string[]; - connectDomains: string[]; -} { +function appCsp(config: ServerConfig) { const publicBaseUrl = config.publicBaseUrl.replace(/\/+$/, ""); + return { resourceDomains: [publicBaseUrl], connectDomains: [publicBaseUrl], @@ -364,6 +386,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 +408,7 @@ export function createMcpServer( workspaceActivityJournal?: WorkspaceActivityJournal, ): McpServer { const toolSurface = getToolSurface(config.experimentalCodeMode ? "codex" : surface); + const server = new McpServer( mcpServerInfo(), { @@ -408,6 +432,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 +440,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 +468,7 @@ function registerMcpSurface( const registrationTarget = trackToolActivity || workspaceActivityJournal ? withObservedToolHandlers(server, { trackToolActivity, workspaceActivityJournal }) : server; + const internalTarget = codeModeRegistry?.registrationTarget ?? registrationTarget; registerAppResource( @@ -457,6 +485,7 @@ function registerMcpSurface( }, async () => { await assertWorkspaceAppAssets(); + return { contents: [ { @@ -527,12 +556,12 @@ function registerMcpSurface( }), ]), instruction: z.string(), - ...(codeModeRegistry ? { + ...optionalFields(codeModeRegistry ? { code_mode: z.object({ language: z.literal("javascript"), tool_signatures: z.string(), }), - } : {}), + } : undefined), }, ...workspaceAppDescriptorMeta(config), annotations: { readOnlyHint: true }, @@ -540,6 +569,7 @@ function registerMcpSurface( async ({ path, mode, base_ref }, { _meta }) => { const startedAt = performance.now(); const baseRef = base_ref; + const { workspace, agentsFiles, @@ -550,16 +580,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 +603,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 +618,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 +649,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 +657,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 +699,7 @@ function registerMcpSurface( ].filter(Boolean).join("\n"), }, ]; + logToolCall(config, { tool: "open_workspace", workspaceId: workspace.id, @@ -706,18 +753,16 @@ function registerMcpSurface( } : undefined, review, - ...(includeBootstrapContext - ? { + ...optionalFields(includeBootstrapContext ? { agents_files: loadedAgentsFiles, available_agents_files: availableAgentsFileOutputs, skills: visibleSkills, agent_providers: visibleAgentProviders, agents: visibleAgents, skill_diagnostics: workspace.skillDiagnostics, - } - : {}), + } : undefined), instruction, - ...(codeMode ? { code_mode: codeMode } : {}), + ...optionalFields(codeMode ? { code_mode: codeMode } : undefined), }, }; }, @@ -769,6 +814,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 +826,7 @@ function registerMcpSurface( workspaceId, path: input.path, }, response.content, startedAt); + return response; } @@ -806,6 +853,7 @@ function registerMcpSurface( workspaces, processSessions, }); + if (codeModeExecutions) { registerCodeModeTools(registrationTarget, workspaces, codeModeExecutions); } @@ -831,9 +879,11 @@ function registerMcpSurface( const startedAt = performance.now(); const workspaceId = workspace_id; const workspace = await workspaces.getWorkspace(workspaceId); - const reviewRef = typeof _meta?.["devspace/reviewRef"] === "string" + + const reviewRef = isJsonString(_meta?.["devspace/reviewRef"]) ? _meta["devspace/reviewRef"] : undefined; + const review = reviewRef ? await reviewCheckpoints.reviewByRef({ workspaceId, @@ -890,6 +940,7 @@ function executionResponse(snapshot: ExecutionSnapshot) { status, duration_ms: Math.round(durationMs), })); + const structured = { ...snapshot, calls, @@ -897,6 +948,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 +961,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 +975,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 +987,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 +1002,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 +1012,7 @@ function registerCodeModeTools( outputSchema, }, async ({ workspace_id, execution_id }) => { await workspaces.getWorkspace(workspace_id); + return executionResponse(await executions.cancel(workspace_id, execution_id)); }); } @@ -968,21 +1025,27 @@ function withObservedToolHandlers( }, ): McpRegistrationTarget { return { - 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)( + // SAFETY: The surrounding checks or construction already match this type. + registerTool: ((...args: WireInput[]) => { + // SAFETY: The surrounding checks or construction already match this type. + const toolName = unsafeNarrow(args[0]); + const handler = unsafeNarrow<(...handlerArgs: WireInput[]) => WireInput>(args.at(-1)); + + return unsafeNarrow<(...callArgs: WireInput[]) => WireInput>(server.registerTool.bind(server))( ...args.slice(0, -1), - (...handlerArgs: unknown[]) => { + (...handlerArgs: WireInput[]) => { const operation = () => Promise.resolve(handler(...handlerArgs)); + const observedOperation = options.workspaceActivityJournal ? () => options.workspaceActivityJournal!.capture({ toolName, arguments: handlerArgs[0], - extra: (handlerArgs[1] ?? {}) as Record, + // SAFETY: The surrounding checks or construction already match this type. + extra: (handlerArgs[1] ?? {}) as JsonObject, operation, }) : operation; + return options.trackToolActivity ? options.trackToolActivity(observedOperation) : observedOperation(); @@ -1003,21 +1066,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 } : {}), + ...optionalFields(allowedHosts ? { allowedHosts } : undefined), }); + 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 +1094,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 +1102,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 +1132,22 @@ export function createServer( workspaceActivityJournal, ); }); + return { toolSurface, bind }; }; + const modernSurfaces = { codex: compileModernSurface("codex"), claude: compileModernSurface("claude"), - } satisfies Record; + } 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 +1156,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 +1190,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; @@ -1119,12 +1203,13 @@ export function createServer( method: req.method, path, ...requestLogFields(req, config), - ...(path === "/mcp" ? rpcRequestLogFields(req.body) : {}), + ...optionalFields(path === "/mcp" ? rpcRequestLogFields(req.body) : undefined), }); } res.on("finish", () => { finished = true; + if (!shouldLogRequest) return; logEvent(config.logging, "info", "http_request", { @@ -1134,7 +1219,7 @@ export function createServer( status: res.statusCode, durationMs: Math.round(performance.now() - startedAt), ...requestLogFields(req, config), - ...(path === "/mcp" ? rpcRequestLogFields(req.body) : {}), + ...optionalFields(path === "/mcp" ? rpcRequestLogFields(req.body) : undefined), }); }); @@ -1145,10 +1230,10 @@ export function createServer( method: req.method, path, headersSent: res.headersSent, - ...(res.headersSent ? { status: res.statusCode } : {}), + ...optionalFields(res.headersSent ? { status: res.statusCode } : undefined), durationMs: Math.round(performance.now() - startedAt), ...requestLogFields(req, config), - ...(path === "/mcp" ? rpcRequestLogFields(req.body) : {}), + ...optionalFields(path === "/mcp" ? rpcRequestLogFields(req.body) : undefined), }); }); @@ -1192,16 +1277,18 @@ export function createServer( }); app.all("/mcp", async (req, res) => { + // SAFETY: The surrounding checks or construction already match this type. const requestId = res.locals.requestId as string | undefined; await new Promise((resolve, reject) => { res.once("finish", resolve); res.once("close", resolve); - bearerAuth(req, res, (error?: unknown) => { + bearerAuth(req, res, (error?: WireInput) => { if (error) reject(error); else resolve(); }); }); + // A client that disconnected mid-auth has nothing left to answer. if (res.headersSent || res.destroyed) return; @@ -1214,6 +1301,7 @@ export function createServer( ...requestLogFields(req, config), }); sendJsonRpcError(res, 401, -32001, "Unauthorized"); + return; } @@ -1227,13 +1315,18 @@ export function createServer( }); if (!normalizeCachedWorkspaceArgument(req.body)) { - const body = req.body as Record; + // SAFETY: The surrounding checks or construction already match this type. + const body = req.body as JsonObject; + if (!Object.hasOwn(body, "id")) { res.status(202).end(); + return; } - const id = typeof body.id === "string" || typeof body.id === "number" ? body.id : null; + + const id = isJsonString(body.id) || isJsonNumber(body.id) ? body.id : null; sendJsonRpcError(res, 400, -32602, "Conflicting workspaceId and workspace_id arguments", id); + return; } @@ -1244,6 +1337,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 +1345,7 @@ export function createServer( }); let closePromise: Promise | undefined; + return { app, config, @@ -1264,6 +1359,7 @@ export function createServer( error: error instanceof Error ? error.message : String(error), }); } + await codeModeExecutions?.shutdown(); await toolActivities.waitForIdle(); workspaceActivity.close(); @@ -1271,6 +1367,7 @@ export function createServer( oauthProvider.close(); workspaceStore.close?.(); })(); + return closePromise; }, }; @@ -1281,11 +1378,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 +1396,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..2e3f496c4 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,18 +83,21 @@ 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; - }); + + return [...defaultPaths, ...config.skillPaths].flatMap((path) => { + const resolved = resolveSkillPath(path, cwd); + + if (seen.has(resolved)) return []; + seen.add(resolved); + + return [resolved]; + }); } function resolveSkillPath(path: string, cwd: string): string { @@ -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,13 +148,17 @@ 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) => ( + // SAFETY: The surrounding checks or construction already match this type. !managedNames.includes(skill.name as ManagedSkillName) && !(skill.name === WORKFLOWS_SKILL_NAME && isPathInsideRoot(skill.filePath, staleWorkflows)) )), diagnostics: result.diagnostics.filter((diagnostic) => { const collision = diagnostic.collision; + + // SAFETY: The surrounding checks or construction already match this type. return !(collision?.resourceType === "skill" && managedNames.includes(collision.name as ManagedSkillName)); }), }; @@ -156,6 +172,7 @@ export function resolveSkillReadPath( for (const skill of skills) { const skillFilePath = resolve(skill.filePath); + if (absolutePath === skillFilePath) { return { absolutePath, skill }; } @@ -163,6 +180,7 @@ export function resolveSkillReadPath( for (const skill of skills) { const baseDir = resolve(skill.baseDir); + if (!isPathInsideRoot(absolutePath, baseDir)) continue; return { absolutePath, skill }; @@ -176,6 +194,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..f7a39ef6d 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -1,3 +1,4 @@ +import { optionalFields } from "../json-value.js"; import * as z from "zod/v4"; import { applyPatch } from "../apply-patch.js"; import { @@ -13,7 +14,6 @@ import { type ToolRegistrationContext, } from "./types.js"; import { - contentText, resultOutputSchema, runLoggedToolOperation, textBlock, @@ -44,12 +44,13 @@ 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; } -function processOutputSchema(): z.ZodRawShape { +function processOutputSchema(): Parameters[0] { return resultOutputSchema({ session_id: z.number().optional(), running: z.boolean(), @@ -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,14 +350,16 @@ 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, exitCode: result.exitCode, success, - ...(success ? {} : { error: termination }), + ...optionalFields(success ? undefined : { error: termination }), }; } 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..da315824e 100644 --- a/src/tool-surfaces/shared.ts +++ b/src/tool-surfaces/shared.ts @@ -9,7 +9,9 @@ import { type ToolWidgetDescriptorMeta, } from "./types.js"; -export function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { +type ZodObjectSpec = Parameters[0]; + +export function resultOutputSchema(extra: ZodObjectSpec = {}): ZodObjectSpec { return { result: z .string() @@ -62,6 +64,7 @@ export async function runLoggedToolOperation( success: resultMetadata?.success ?? true, durationMs: Math.round(performance.now() - startedAt), }); + return result; } catch (error) { logToolCall(config, { @@ -75,17 +78,14 @@ export async function runLoggedToolOperation( } export function contentText(content: ToolContent[]): string { - return content - .filter( - (item): item is { type: "text"; text: string } => item.type === "text", - ) - .map((item) => item.text) - .join("\n"); + return content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"); } 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 +115,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/tool-surfaces/types.ts b/src/tool-surfaces/types.ts index ea4b83ed6..4fe7acfb8 100644 --- a/src/tool-surfaces/types.ts +++ b/src/tool-surfaces/types.ts @@ -1,3 +1,4 @@ +import type { JsonObject } from "../json-value.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ProcessSessionManager } from "../process-sessions.js"; import type { ServerConfig } from "../config.js"; @@ -68,7 +69,7 @@ export interface ToolDefinitionMeta extends Record { }; } -export type EmptyToolDefinitionMeta = Record & { +export type EmptyToolDefinitionMeta = JsonObject & { "ui/resourceUri"?: string; }; 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..b98d68fec 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -1,6 +1,9 @@ +import { isJsonNumber } from "../json-value.js"; +import type { JsonObject } from "../json-value.js"; import type { App } from "@modelcontextprotocol/ext-apps"; export type ToolName = "open_workspace" | "show_changes"; + export type HostContext = NonNullable>; export type ReviewFileType = @@ -30,7 +33,7 @@ export interface ToolResultCard { review?: | { available: true } | { available: false; reason: string }; - summary?: Record; + summary?: JsonObject; files?: Array<{ path?: string; previousPath?: string; @@ -68,11 +71,12 @@ export interface ToolResultCard { } export function summaryNumber( - summary: Record | undefined, + summary: JsonObject | undefined, key: string, ): number | undefined { const value = summary?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + + return isJsonNumber(value) && Number.isFinite(value) ? value : undefined; } export function isExpandableCard(card: ToolResultCard): boolean { diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 5d489e358..6f86e8838 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -70,7 +70,9 @@ export function getProviderLogo( name: string, theme: ProviderLogoTheme = "dark", ): string | undefined { + // SAFETY: The surrounding checks or construction already match this type. 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..7b6ade217 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -27,14 +27,16 @@ const fileChangeLabels: Record, string> = { export function getPatchDisplayParts( card: Pick, options: { emptyTitle?: string } = {}, -): { 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..7161a9289 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -1,3 +1,4 @@ +import { isJsonNumber } from "../json-value.js"; import { useMemo, useState } from "react"; import { createRoot } from "react-dom/client"; import type { HostContext, ToolResultCard } from "./card-types.js"; @@ -47,13 +48,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" + + const visibleFiles = isJsonNumber(visibleFileCount) ? 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 +74,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..7ce58711e 100644 --- a/src/ui/tool-result.ts +++ b/src/ui/tool-result.ts @@ -1,3 +1,6 @@ +import { isJsonString, isJsonBoolean, isObjectValue, isJsonNumber, optionalFields } from "../json-value.js"; +import { unsafeNarrow } from "../unsafe-narrow.js"; +import type { JsonObject, WireInput } from "../json-value.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { ReviewFileType, ToolResultCard } from "./card-types.js"; @@ -18,6 +21,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,14 +33,16 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { }, }; } + return { kind: "review-reference", workspaceId, reviewRef }; } - if (typeof structured.patch === "string" && Array.isArray(structured.files)) { + if (isJsonString(structured.patch) && Array.isArray(structured.files)) { const legacyCard = cardFields({ ...structured, payload: { patch: structured.patch }, }); + if (legacyCard) { return { kind: "card", card: { ...legacyCard, tool: "show_changes" } }; } @@ -44,8 +50,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 +74,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" } }; } @@ -74,10 +83,11 @@ export function decodeToolResult(result: CallToolResult): DecodedToolResult { } function structuredWorkspaceCardFields( - record: Record | undefined, + record: JsonObject | undefined, ): Partial | undefined { if (!record) return undefined; const worktreeRecord = asRecord(record.worktree); + return cardFields({ root: record.root, mode: record.mode, @@ -107,11 +117,12 @@ function isCompleteReviewCard( ): card is Partial & { files: NonNullable; payload: { patch: string }; - summary: Record; + summary: JsonObject; } { - if (!card || !Array.isArray(card.files) || typeof card.payload?.patch !== "string") { + if (!card || !Array.isArray(card.files) || !isJsonString(card.payload?.patch)) { return false; } + return numberField(card.summary?.files) !== undefined && numberField(card.summary?.additions) !== undefined && numberField(card.summary?.removals) !== undefined; @@ -124,59 +135,72 @@ 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; + // SAFETY: The surrounding checks or construction already match this type. return { ...(metadataResult ?? { content: [] }), - ...(structuredContent ? { structuredContent } : {}), - ...(resultMeta ? { _meta: resultMeta } : {}), + ...optionalFields(structuredContent ? { structuredContent } : undefined), + ...optionalFields(resultMeta ? { _meta: resultMeta } : undefined), } as CallToolResult; } function directResultMeta( - metadata: Record | undefined, -): Record | undefined { + metadata: JsonObject | undefined, +): JsonObject | undefined { if (!metadata) return undefined; + return "card" in metadata ? metadata : undefined; } -function mcpToolResult(value: unknown): CallToolResult | undefined { +function mcpToolResult(value: WireInput): CallToolResult | undefined { const metadata = asRecord(value); + if (!metadata) return undefined; const direct = asRecord(metadata.mcp_tool_result); - if (direct) return direct as CallToolResult; + + if (direct) return unsafeNarrow(direct); const callToolResult = asRecord(metadata.call_tool_result); const nested = asRecord(callToolResult?.mcp_tool_result); - return nested ? nested as CallToolResult : undefined; + + // SAFETY: The surrounding checks or construction already match this type. + return nested ? unsafeNarrow(nested) : undefined; } -function cardFields(record: Record | undefined): Partial | undefined { +function cardFields(record: JsonObject | undefined): Partial | undefined { if (!record) return undefined; const agentsFiles = arrayRecords(record.agentsFiles)?.map((item) => ({ path: stringField(item.path), content: stringField(item.content), })); + const availableAgentsFiles = arrayRecords(record.availableAgentsFiles)?.map((item) => ({ 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 +208,7 @@ function cardFields(record: Record | undefined): Partial ({ path: stringField(item.path), previousPath: stringField(item.previousPath), @@ -191,6 +216,7 @@ function cardFields(record: Record | undefined): Partial | undefined): Partial): Record { +function workspaceSummary(card: Partial): JsonObject { return { mode: card.mode, agentsFiles: card.agentsFiles?.length ?? 0, @@ -239,15 +265,17 @@ function workspaceSummary(card: Partial): Record | undefined, + record: JsonObject | undefined, ): ToolResultCard["review"] { - if (!record || typeof record.available !== "boolean") return undefined; + if (!record || !isJsonBoolean(record.available)) return undefined; + if (record.available) return { available: true }; const reason = stringField(record.reason); + return reason ? { available: false, reason } : undefined; } -function reviewFileType(value: unknown): ReviewFileType | undefined { +function reviewFileType(value: WireInput): ReviewFileType | undefined { return value === "change" || value === "rename-pure" || value === "rename-changed" @@ -257,37 +285,41 @@ function reviewFileType(value: unknown): ReviewFileType | undefined { : undefined; } -function workspaceMode(value: unknown): ToolResultCard["mode"] { +function workspaceMode(value: WireInput): ToolResultCard["mode"] { return value === "checkout" || value === "worktree" ? value : undefined; } -function arrayRecords(value: unknown): Array> | undefined { +function arrayRecords(value: WireInput): Array | undefined { if (!Array.isArray(value)) return undefined; + return value.flatMap((item) => { const record = asRecord(item); + return record ? [record] : []; }); } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" - ? value as Record +function asRecord(value: WireInput): JsonObject | undefined { + return value !== null && isObjectValue(value) + // SAFETY: The surrounding checks or construction already match this type. + ? unsafeNarrow(value) : undefined; } -function stringField(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; +function stringField(value: WireInput): string | undefined { + return isJsonString(value) ? value : undefined; } -function numberField(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; +function numberField(value: WireInput): number | undefined { + return isJsonNumber(value) && Number.isFinite(value) ? value : undefined; } -function booleanField(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; +function booleanField(value: WireInput): boolean | undefined { + return isJsonBoolean(value) ? value : undefined; } -function definedFields>(record: T): T { +function definedFields(record: T): T { + // SAFETY: The surrounding checks or construction already match this type. return Object.fromEntries( Object.entries(record).filter(([, value]) => value !== undefined), ) as T; 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..e047bbfc4 100644 --- a/src/ui/web/main.tsx +++ b/src/ui/web/main.tsx @@ -1,3 +1,6 @@ +import { unsafeNarrow } from "../../unsafe-narrow.js"; +import { isObjectValue, isJsonString, optionalFields } from "../../json-value.js"; +import type { WireInput, JsonObject } from "../../json-value.js"; import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import type { ActivityWorkspace, TurnDiff, WorkspaceToolCall, WorkspaceTurn } from "../../workspace-activity.js"; @@ -27,16 +30,19 @@ 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]!), - ...(match[2] ? { turn: Number(match[2]) } : {}), + ...optionalFields(match[2] ? { turn: Number(match[2]) } : undefined), }; } function workspacePath(workspaceId: string, turn?: number): string { const base = `${BASE}/w/${encodeURIComponent(workspaceId)}`; + return turn === undefined ? base : `${base}/turns/${turn}`; } @@ -52,9 +58,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 +78,27 @@ function useJson(url: string): Loaded { .then(async (response) => { if (response.status === 401) { window.location.assign(`${BASE}/login?next=${encodeURIComponent(window.location.pathname)}`); + return; } + + // SAFETY: The surrounding checks or construction already match this type. 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) => { + .catch((error: WireInput) => { if (!cancelled) setState({ status: "error", message: error instanceof Error ? error.message : String(error) }); }); + return () => { cancelled = true; }; }, [url]); + return state; } @@ -91,6 +107,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 +121,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 +132,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); @@ -144,6 +163,7 @@ function App() { Theme