diff --git a/.changeset/atomic-project-state-migration.md b/.changeset/atomic-project-state-migration.md index 76d51cff..493b56cc 100644 --- a/.changeset/atomic-project-state-migration.md +++ b/.changeset/atomic-project-state-migration.md @@ -2,6 +2,6 @@ "@sapiom/harness": minor --- -Migrate persisted project maps atomically to immutable version histories and role-neutral proposal attribution, with storage for shared build plans. Map and brief quotas, malformed aggregates and unsupported storage schemas now report terminal manual-intervention recovery through MCP; operation history is explicitly bounded before writes. +Store project maps atomically with immutable version histories and role-neutral proposal attribution, alongside shared build plans. Map and brief quotas, malformed aggregates and unsupported storage schemas now report terminal manual-intervention recovery through MCP; operation history is explicitly bounded before writes. This storage contract does not preserve format-1 maps: startup resets those maps under the legacy-reset policy. **Breaking:** `ProposalActor` and proposal-history payloads now contain only trusted `userId` and `sessionId` attribution. Consumers must stop reading or constructing the removed `role` and `assignment` fields and use `sessionId` for attribution. Those fields never represented write or implementation authority. diff --git a/.changeset/bootstrap-coordinator.md b/.changeset/bootstrap-coordinator.md index f41f8b94..1becc779 100644 --- a/.changeset/bootstrap-coordinator.md +++ b/.changeset/bootstrap-coordinator.md @@ -2,4 +2,4 @@ "@sapiom/harness": patch --- -Internal groundwork for automatic Agent Map bootstrap, including recovery, FIFO delivery, and shutdown handling. Recovery events describe committed state. No user-facing behavior changes in this release. +Support automatic Agent Map bootstrap with recovery, FIFO delivery, and shutdown handling. Recovery events describe committed state. diff --git a/.changeset/bootstrap-storage.md b/.changeset/bootstrap-storage.md index 8ecf9f68..f0210715 100644 --- a/.changeset/bootstrap-storage.md +++ b/.changeset/bootstrap-storage.md @@ -2,4 +2,4 @@ "@sapiom/harness": patch --- -Internal storage groundwork for automatic Agent Map bootstrap. Clean up temporary state after failed writes and ignore unrelated files when reading durable project intents. No user-facing behavior changes in this release. +Add durable project intents for automatic Agent Map bootstrap. Clean up temporary state after failed writes and ignore unrelated files when reading project intents. diff --git a/.changeset/desktop-managed-installs.md b/.changeset/desktop-managed-installs.md new file mode 100644 index 00000000..753decdd --- /dev/null +++ b/.changeset/desktop-managed-installs.md @@ -0,0 +1,7 @@ +--- +"@sapiom/harness-desktop": patch +--- + +Install and verify isolated coding-provider versions using the bundled runtime, +with bounded installer processes and cancellation. Keep Electron's runtime +startup flag out of commands launched by managed providers. diff --git a/.changeset/fresh-studio-agent-clis.md b/.changeset/fresh-studio-agent-clis.md new file mode 100644 index 00000000..0adacdd8 --- /dev/null +++ b/.changeset/fresh-studio-agent-clis.md @@ -0,0 +1,8 @@ +--- +"@sapiom/harness-desktop": patch +--- + +Update installed Claude Code and Codex before desktop sessions start. Verify +each new executable before selecting it and retain the working version when +updates fail or the device is offline. Preserve provider configuration and history. +Remove unpublished installation files from failed or cancelled update attempts. diff --git a/.changeset/managed-agent-runtime.md b/.changeset/managed-agent-runtime.md new file mode 100644 index 00000000..b58d12e7 --- /dev/null +++ b/.changeset/managed-agent-runtime.md @@ -0,0 +1,7 @@ +--- +"@sapiom/harness": minor +--- + +Add optional interpreter arguments and environment to coding-provider adapters +and export `createCodexAdapter`. Preserve managed launch configuration across +new sessions, resume, and private structured inference for both providers. diff --git a/.changeset/project-session-shortcut.md b/.changeset/project-session-shortcut.md index c64a29b0..0b94b59e 100644 --- a/.changeset/project-session-shortcut.md +++ b/.changeset/project-session-shortcut.md @@ -3,4 +3,4 @@ "@sapiom/harness-desktop": patch --- -The project-row `+` now starts a coding-agent session at that project root. Previously it created an agent. Sapiom agent creation remains owned by Plan Agents. +The project-row `+` now starts an ordinary coding-agent session at that project root. Sessions can create agents and work on the shared Agent Map; Plan Agents is an ordinary session without exclusive creation authority. diff --git a/.changeset/studio-terminal-teardown.md b/.changeset/studio-terminal-teardown.md new file mode 100644 index 00000000..4e697f03 --- /dev/null +++ b/.changeset/studio-terminal-teardown.md @@ -0,0 +1,7 @@ +--- +"@sapiom/harness": patch +--- + +Prevent terminal mount cleanup from disposing the renderer before xterm's +queued viewport initialization runs. Ignore callbacks from closed terminal +connections and preserve workspace preferences across reloads in the Studio demo. diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index 92925835..a68c2e55 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -61,7 +61,9 @@ jobs: # invokes them — they require real agent binaries and credentials not in CI. playwright-mock: runs-on: ubuntu-latest - timeout-minutes: 15 + # The expanded mock suite has over 560 cases, followed by canvas checks. + # Keep the existing per-test deadlines; allow both suites to finish. + timeout-minutes: 20 # Single Node version is enough — the mock tier tests browser behaviour, # not Node version compat; that's covered by the matrix job in test.yml. steps: diff --git a/packages/harness-desktop/README.md b/packages/harness-desktop/README.md new file mode 100644 index 00000000..731ecab0 --- /dev/null +++ b/packages/harness-desktop/README.md @@ -0,0 +1,30 @@ +# Agent Studio desktop + +The Electron host for `@sapiom/harness`. See [CLAUDE.md](CLAUDE.md) for packaging. + +## Coding-agent updates + +On each normal launch, Studio checks installed Claude Code and Codex against npm +`latest` before starting sessions. If neither is available, setup installs Claude. + +Updates use isolated per-user `agent-versions/` directories and pass a version +check before activation. Global installations stay untouched; newer or unknown +versions on PATH are preserved. Previous successful directories remain for running +processes; automatic eviction is not implemented. Failed or cancelled attempts +clean up their own unpublished installation and temporary selector. + +Registry requests time out after five seconds; installers are stopped after +90 seconds, with bounded child-process cleanup. Failed/offline updates keep the +working CLI. Closing Studio cancels active updates. + +Quit and reopen Studio to check again. New and resumed processes use the selected +CLI; running sessions keep their executable. Authentication, configuration, model +caches, selected models, and history stay intact. Model access also depends on +your provider account. + +Studio manages updates for its Claude copies and disables Codex's startup update +check. JavaScript launchers use the bundled runtime, including on Node-less Windows. + +`--dev`, `--smoke`, and `SAPIOM_DISABLE_AGENT_UPDATES=1` skip registry/install calls +but can reuse selected copies. Progress appears during setup and in `main.log` +under `[boot] agent-update`. Desktop app updates and MCP refreshes run separately. diff --git a/packages/harness-desktop/src/main/agent-install.ts b/packages/harness-desktop/src/main/agent-install.ts index f6ef9c26..9a6037f4 100644 --- a/packages/harness-desktop/src/main/agent-install.ts +++ b/packages/harness-desktop/src/main/agent-install.ts @@ -23,6 +23,7 @@ import { readFileSync } from "node:fs"; import * as path from "node:path"; import { promisify } from "node:util"; import { CLAUDE_INSTALL_COMMAND } from "@sapiom/harness"; +import { runUpdateCommand } from "./agent-update-process.js"; import { SAPIOM_CLI_PACKAGE, SAPIOM_MCP_PACKAGE, @@ -125,6 +126,20 @@ export function installClaudeCode( return installNpmGlobal(packageSpecFromInstallCommand(CLAUDE_INSTALL_COMMAND), onLine); } +/** Install an exact version into an isolated prefix with a bounded deadline. */ +export async function installAgentVersion( + packageSpec: string, + prefix: string, + onLine: (line: string) => void, +): Promise { + const result = await runUpdateCommand(process.execPath, [ + resolveNpmCli(), "install", "--global", packageSpec, "--prefix", prefix, + "--no-audit", "--no-fund", "--loglevel=info", "--fetch-retries=0", "--fetch-timeout=15000", + ], { env: npmInstallEnv(process.env), timeoutMs: 90_000, onLine }); + if (!result.ok) onLine(result.detail); + return result.ok; +} + /** * Install the `sapiom` CLI into the same per-user prefix, for the same reason and * by the same mechanism as the agent: the app hands the coding agent diff --git a/packages/harness-desktop/src/main/agent-update-process-ownership.test.ts b/packages/harness-desktop/src/main/agent-update-process-ownership.test.ts new file mode 100644 index 00000000..6b505d9e --- /dev/null +++ b/packages/harness-desktop/src/main/agent-update-process-ownership.test.ts @@ -0,0 +1,27 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; + +const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() })); +vi.mock("node:child_process", async (original) => ({ + ...await original(), spawn, +})); +import { runUpdateCommand } from "./agent-update-process.js"; + +describe.skipIf(process.platform === "win32")("POSIX update group ownership", () => { + it("never signals the old group after its supervisor has exited", async () => { + const child = Object.assign(new EventEmitter(), { + pid: 12345, stdin: new PassThrough(), stdout: new PassThrough(), + stderr: new PassThrough(), kill: vi.fn(), + }); + spawn.mockReturnValueOnce(child); + const signal = vi.spyOn(process, "kill").mockReturnValue(true); + try { + const result = runUpdateCommand("installer", [], { env: {}, timeoutMs: 10 }); + child.emit("exit", 0); + expect((await result).detail).toContain("Timed out"); + expect(signal).not.toHaveBeenCalled(); + expect(child.kill).not.toHaveBeenCalled(); + } finally { signal.mockRestore(); } + }); +}); diff --git a/packages/harness-desktop/src/main/agent-update-process.test.ts b/packages/harness-desktop/src/main/agent-update-process.test.ts new file mode 100644 index 00000000..0bbc0e88 --- /dev/null +++ b/packages/harness-desktop/src/main/agent-update-process.test.ts @@ -0,0 +1,144 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { runUpdateCommand, windowsUpdateTree } from "./agent-update-process.js"; + +describe("Windows update process identity", () => { + const identity = { pid: 10, startedAt: 100, spawnedAt: 110 }; + const root = { pid: 10, parentPid: 1, createdAt: 105 }; + const child = { pid: 20, parentPid: 10, createdAt: 120 }; + const grandchild = { pid: 30, parentPid: 20, createdAt: 130 }; + + it("stops descendants before the original parent", () => { + expect(windowsUpdateTree([root, child, grandchild], identity, 200)).toEqual( + [grandchild, child, root], + ); + }); + + it("includes children spawned by a live parent while the snapshot was being collected", () => { + const lateChild = { pid: 40, parentPid: 10, createdAt: 220 }; + expect(windowsUpdateTree([root, lateChild], identity, 200)).toEqual([ + lateChild, + root, + ]); + }); + + it("finds orphaned descendants but excludes children born after npm exited", () => { + const unrelated = { pid: 40, parentPid: 10, createdAt: 180 }; + expect( + windowsUpdateTree( + [child, grandchild, unrelated], + { ...identity, exitedAt: 150 }, + 200, + ), + ).toEqual([grandchild, child]); + }); + + it("never kills or traverses an unrelated process that reused the root PID", () => { + const replacement = { ...root, createdAt: 160 }; + const unrelated = { pid: 40, parentPid: 10, createdAt: 180 }; + expect(windowsUpdateTree([replacement, unrelated], identity, 200)).toEqual( + [], + ); + expect( + windowsUpdateTree([root, child], { ...identity, exitedAt: 150 }, 200), + ).toEqual([]); + }); +}); + +describe("bounded update processes", () => { + it("preserves argument boundaries, environment and both output streams", async () => { + const result = await runUpdateCommand(process.execPath, ["-e", ` + console.log(JSON.stringify({args:process.argv.slice(1), flag:process.env.ELECTRON_RUN_AS_NODE??null, retained:process.env.STUDIO_UPDATE_TEST})); + console.error('stderr retained'); + `, "space and $literal"], { + env: { PATH: process.env.PATH, STUDIO_UPDATE_TEST: "retained" }, timeoutMs: 2_000, + }); + expect(result.ok).toBe(true); + expect(JSON.parse(result.stdout)).toEqual({args:["space and $literal"],flag:null,retained:"retained"}); + expect(result.detail).toContain("stderr retained"); + }); + + it("reports missing executables and failed commands without hanging startup", async () => { + const opts = { env: process.env, timeoutMs: 1_000 }; + const missing = await runUpdateCommand( + "studio-deliberately-missing-executable", + [], + opts, + ); + const failed = await runUpdateCommand( + process.execPath, + ["-e", "process.exit(1)"], + opts, + ); + expect(missing.ok).toBe(false); + expect(failed.ok).toBe(false); + }); + + it.each([false, true])( + "kills a timed-out process tree, including an already-exited parent (%s)", + async (parentExits) => { + const root = await mkdtemp(join(tmpdir(), "studio-update-timeout-")); + const marker = join(root, "child-pid"); + try { + const childCode = "setInterval(() => {}, 1000)"; + const code = `const {spawn}=require('node:child_process'); const c=spawn(process.execPath,['-e',${JSON.stringify(childCode)}],{stdio:['ignore',1,2]}); require('node:fs').writeFileSync(${JSON.stringify(marker)},String(c.pid)); ${parentExits ? "process.exit(0)" : "setInterval(()=>{},1000)"};`; + const result = await runUpdateCommand(process.execPath, ["-e", code], { + env: process.env, + timeoutMs: 700, + }); + expect(result.ok).toBe(false); + expect(result.detail).toContain("Timed out"); + const pid = Number(await readFile(marker, "utf8")); + // Linux can retain a terminated process as a zombie. + await vi.waitFor( + async () => { + if (process.platform === "linux") { + const status = await readFile( + `/proc/${pid}/status`, + "utf8", + ).catch(() => "State:\tZ"); + expect(status).toMatch(/State:\s+Z/); + } else { + expect(() => process.kill(pid, 0)).toThrow(); + } + }, + { timeout: 1_000, interval: 20 }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + ); + + it("cancels running updates and prevents new installers when Studio quits during setup", async () => { + vi.resetModules(); + const commands = await import("./agent-update-process.js"); + let ready!: () => void; + const started = new Promise((resolve) => { + ready = resolve; + }); + const command = commands.runUpdateCommand( + process.execPath, + ["-e", "console.log('ready');setInterval(()=>{},1000)"], + { + env: process.env, + timeoutMs: 5_000, + onLine: () => ready(), + }, + ); + await started; + await commands.stopAgentUpdateCommands(); + expect((await command).detail).toBe("Studio is quitting"); + const rejected = await commands.runUpdateCommand( + process.execPath, + ["-e", "process.exit(0)"], + { + env: process.env, + timeoutMs: 1_000, + }, + ); + expect(rejected.ok).toBe(false); + }); +}); diff --git a/packages/harness-desktop/src/main/agent-update-process.ts b/packages/harness-desktop/src/main/agent-update-process.ts new file mode 100644 index 00000000..86214601 --- /dev/null +++ b/packages/harness-desktop/src/main/agent-update-process.ts @@ -0,0 +1,241 @@ +import { execFile, spawn } from "node:child_process"; + +// Keep the owned process-group leader alive until the command's output pipes +// close, even when the installer exits before its descendants. Input carries +// the original environment privately; the supervisor's runtime flag is separate. +const POSIX_SUPERVISOR = ` +const {spawn} = require('node:child_process'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { input += chunk; }); +process.stdin.on('end', () => { + try { + const {command, args, env} = JSON.parse(input); + const child = spawn(command, args, {env, stdio: ['ignore', 'pipe', 'pipe']}); + child.stdout.pipe(process.stdout, {end: false}); + child.stderr.pipe(process.stderr, {end: false}); + child.on('error', error => { process.stderr.write(error.message); process.exitCode = 1; }); + child.on('close', code => { process.exitCode = code ?? 1; }); + } catch (error) { + process.stderr.write(error.message); + process.exitCode = 1; + } +}); +`; + +const running = new Set<() => Promise>(); +let stopping = false; + +export async function stopAgentUpdateCommands(): Promise { + stopping = true; + await Promise.allSettled([...running].map((stop) => stop())); +} + +interface WindowsProcess { + pid: number; + parentPid: number; + createdAt: number; +} + +interface ProcessIdentity { + pid: number; + startedAt: number; + spawnedAt: number; + exitedAt?: number; +} + +// Windows can reuse npm's PID after it exits; validate the process lifetime. +export function windowsUpdateTree( + snapshot: WindowsProcess[], + identity: ProcessIdentity, + stoppedAt: number, +): WindowsProcess[] { + const root = snapshot.find((entry) => entry.pid === identity.pid); + if ( + root && + (identity.exitedAt !== undefined || + root.createdAt < identity.startedAt || + root.createdAt > identity.spawnedAt) + ) + return []; + const queue = [ + root ?? { pid: identity.pid, parentPid: 0, createdAt: identity.startedAt }, + ]; + const found = root ? [root] : []; + const seen = new Set([identity.pid]); + for (const parent of queue) { + for (const entry of snapshot) { + if ( + entry.parentPid !== parent.pid || + seen.has(entry.pid) || + entry.createdAt < parent.createdAt + ) + continue; + if ( + !root && + parent.pid === identity.pid && + entry.createdAt > (identity.exitedAt ?? stoppedAt) + ) + continue; + seen.add(entry.pid); + queue.push(entry); + found.push(entry); + } + } + return found.reverse(); +} + +function powershell(script: string): Promise { + return new Promise((resolve) => { + execFile( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + { + windowsHide: true, + timeout: 5_000, + killSignal: "SIGKILL", + maxBuffer: 4 * 1024 * 1024, + }, + (error, stdout) => resolve(error ? "" : stdout), + ); + }); +} + +async function stopWindowsTree(identity: ProcessIdentity): Promise { + const stoppedAt = Date.now(); + const raw = await powershell( + "@(Get-CimInstance Win32_Process | Select-Object @{n='pid';e={$_.ProcessId}}, @{n='parentPid';e={$_.ParentProcessId}}, @{n='createdAt';e={([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()}}) | ConvertTo-Json -Compress", + ); + try { + const parsed: unknown = JSON.parse(raw); + const snapshot = (Array.isArray(parsed) ? parsed : [parsed]).filter( + (entry): entry is WindowsProcess => + entry && + Number.isInteger(entry.pid) && + Number.isInteger(entry.parentPid) && + Number.isInteger(entry.createdAt), + ); + const tree = windowsUpdateTree(snapshot, identity, stoppedAt); + if (!tree.length) return; + // Pin the handle and match its creation time before killing a Windows PID. + await powershell( + tree + .map( + (entry) => + `$p = $null; try { $p = Get-Process -Id ${entry.pid} -ErrorAction Stop; $null = $p.Handle; if (([DateTimeOffset]$p.StartTime).ToUnixTimeMilliseconds() -eq ${entry.createdAt}) { $p.Kill() } } catch {} finally { if ($null -ne $p) { $p.Dispose() } }`, + ) + .join("; "), + ); + } catch { + /* A failed snapshot must not authorize terminating unrelated processes. */ + } +} + +export interface UpdateCommandResult { + ok: boolean; + stdout: string; + detail: string; +} + +/** Stop installers and their children on timeout or shutdown. */ +export function runUpdateCommand( + command: string, + args: string[], + options: { + env: NodeJS.ProcessEnv; + timeoutMs: number; + onLine?: (line: string) => void; + }, +): Promise { + if (stopping) + return Promise.resolve({ + ok: false, + stdout: "", + detail: "Studio is quitting", + }); + return new Promise((resolve) => { + let stdout = ""; + let detail = ""; + let timedOut = false; + const startedAt = Date.now(); + const child = process.platform === "win32" + ? spawn(command, args, { + env: options.env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], + }) + : spawn(process.execPath, ["-e", POSIX_SUPERVISOR], { + env: { ...options.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}) }, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdin?.on("error", () => { /* Spawn failure is handled below. */ }); + child.stdin?.end(JSON.stringify({ command, args, env: options.env })); + const spawnedAt = Date.now(); + let exitedAt: number | undefined; + child.once("exit", () => { + exitedAt = Date.now(); + }); + let finished = false; + let cleanup: Promise | undefined; + const finish = (ok: boolean, reason = detail): void => { + if (finished) return; + finished = true; + clearTimeout(timer); + running.delete(cancel); + resolve({ ok, stdout, detail: reason }); + }; + const stop = (reason: string): Promise => { + cleanup ??= (async () => { + if (child.pid) { + if (process.platform === "win32") { + await stopWindowsTree({ + pid: child.pid, + startedAt, + spawnedAt, + exitedAt, + }); + child.kill("SIGKILL"); + } else if (exitedAt === undefined) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + /* Parent/group already exited. */ + } + child.kill("SIGKILL"); + } + } + // Descendants may retain pipes even after their parent exits. + child.stdout.destroy(); + child.stderr.destroy(); + finish(false, reason); + })(); + return cleanup; + }; + const cancel = (): Promise => stop("Studio is quitting"); + running.add(cancel); + const timer = setTimeout(() => { + timedOut = true; + void stop(`Timed out after ${options.timeoutMs}ms`); + }, options.timeoutMs); + timer.unref(); + const output = (chunk: Buffer, isStdout: boolean): void => { + const text = chunk.toString("utf8"); + if (isStdout) stdout = (stdout + text).slice(-65_536); + detail = (detail + text).slice(-8_192); + for (const line of text.split(/\r?\n/).filter(Boolean)) + options.onLine?.(line); + }; + child.stdout.on("data", (chunk: Buffer) => output(chunk, true)); + child.stderr.on("data", (chunk: Buffer) => output(chunk, false)); + child.once("error", (err) => { + finish(false, err.message); + }); + child.once("close", (code) => { + if (!cleanup && !timedOut) finish(code === 0); + }); + }); +} diff --git a/packages/harness-desktop/src/main/agent-updates.test.ts b/packages/harness-desktop/src/main/agent-updates.test.ts new file mode 100644 index 00000000..2d94899f --- /dev/null +++ b/packages/harness-desktop/src/main/agent-updates.test.ts @@ -0,0 +1,284 @@ +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cliVersion, + ensureAgentUpdates, + isNewerStable, + type AgentUpdateOptions, +} from "./agent-updates.js"; +import { + AGENT_PACKAGES, + resolveAgentCommand, + type AgentCommand, + type AgentKind, +} from "./managed-agent.js"; +import { runUpdateCommand } from "./agent-update-process.js"; + +const runtime: AgentCommand = { + binary: process.execPath, + binaryArgs: [], + binaryEnv: {}, +}; +let root: string; +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "studio-agent-updates-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function fakeInstall( + spec: string, + prefix: string, + windows = false, +): Promise { + const separator = spec.lastIndexOf("@"); + const pkg = spec.slice(0, separator); + const version = spec.slice(separator + 1); + const kind = (Object.keys(AGENT_PACKAGES) as AgentKind[]).find( + (k) => AGENT_PACKAGES[k].package === pkg, + )!; + const packageDir = path.join( + prefix, + ...(windows ? [] : ["lib"]), + "node_modules", + pkg, + ); + await mkdir(packageDir, { recursive: true }); + await writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ bin: { [AGENT_PACKAGES[kind].binary]: "cli.cjs" } }), + ); + await writeFile( + path.join(packageDir, "cli.cjs"), + `console.log(${JSON.stringify(version)});`, + ); + return true; +} + +function setup( + external: Partial> = { codex: "codex-cli 0.100.0" }, +): AgentUpdateOptions { + return { + root, + runtime, + enabled: true, + latest: vi.fn(async () => "0.134.0"), + install: vi.fn((spec, prefix) => fakeInstall(spec, prefix)), + probe: vi.fn(async (command) => { + if (!command.binaryArgs.length && !path.isAbsolute(command.binary)) + return external[command.binary] ?? null; + const result = await runUpdateCommand( + command.binary, + [...command.binaryArgs, "--version"], + { + env: { ...process.env, ...command.binaryEnv }, + timeoutMs: 2_000, + }, + ); + return result.ok ? result.stdout.trim() : null; + }), + }; +} + +describe("startup CLI updates", () => { + it("adopts an updated Studio-owned Codex when the user's PATH CLI is old", async () => { + const options = setup(); + const result = await ensureAgentUpdates(options); + expect(options.install).toHaveBeenCalledWith( + "@openai/codex@0.134.0", + expect.stringMatching(/0\.134\.0-/), + expect.any(Function), + ); + expect(result.codex?.version).toBe("0.134.0"); + expect(await options.probe(result.codex!.command)).toBe("0.134.0"); + expect(result["claude-code"]).toBeUndefined(); + expect( + JSON.parse( + await readFile(path.join(root, "codex", "active.json"), "utf8"), + ).version, + ).toBe("0.134.0"); + }); + + it("updates both agents, including old Claude when Codex is already available", async () => { + const options = setup({ + claude: "2.1.0 (Claude Code)", + codex: "codex-cli 0.100.0", + }); + options.latest = vi.fn(async (kind) => + kind === "claude-code" ? "2.1.90" : "0.134.0", + ); + const result = await ensureAgentUpdates(options); + expect(result["claude-code"]?.version).toBe("2.1.90"); + expect(result["claude-code"]?.command.binaryEnv.DISABLE_AUTOUPDATER).toBe( + "1", + ); + expect(result.codex?.version).toBe("0.134.0"); + }); + + it.each(["0.134.0", "0.135.0", "0.135.0-beta.1"])( + "keeps a current/newer external Codex (%s)", + async (version) => { + const options = setup({ codex: `codex-cli ${version}` }); + expect(await ensureAgentUpdates(options)).toEqual({}); + expect(options.install).not.toHaveBeenCalled(); + }, + ); + + it("keeps an unparseable user version instead of assuming a downgrade is an update", async () => { + const options = setup({ codex: "custom build" }); + await ensureAgentUpdates(options); + expect(options.latest).not.toHaveBeenCalled(); + expect(options.install).not.toHaveBeenCalled(); + }); + + it("reuses a verified managed CLI offline without reinstalling", async () => { + const options = setup(); + const initial = await ensureAgentUpdates(options); + const pointer = await readFile( + path.join(root, "codex", "active.json"), + "utf8", + ); + options.latest = vi.fn(async () => { + throw new Error("offline"); + }); + options.install = vi.fn(); + expect(await ensureAgentUpdates(options)).toEqual(initial); + expect(options.install).not.toHaveBeenCalled(); + expect( + await readFile(path.join(root, "codex", "active.json"), "utf8"), + ).toBe(pointer); + }); + + it.each(["failure", "wrong-version", "missing-entry"])( + "retains the working executable and selector after %s", + async (failure) => { + const options = setup(); + const initial = await ensureAgentUpdates(options); + const filesBefore = await readdir(path.join(root, "codex")); + const pointer = await readFile( + path.join(root, "codex", "active.json"), + "utf8", + ); + options.latest = async () => "0.135.0"; + options.install = async (_spec, prefix) => { + if (failure === "wrong-version") + return fakeInstall("@openai/codex@0.1.0", prefix); + await writeFile(path.join(prefix, "partial-download"), "interrupted"); + return failure !== "failure"; + }; + expect(await ensureAgentUpdates(options)).toEqual(initial); + expect(await options.probe(initial.codex!.command)).toBe("0.134.0"); + expect( + await readFile(path.join(root, "codex", "active.json"), "utf8"), + ).toBe(pointer); + expect(await readdir(path.join(root, "codex"))).toEqual(filesBefore); + }, + ); + + it("does not replace a newer external beta with an older selected managed version", async () => { + await ensureAgentUpdates(setup()); + const options = setup({ codex: "codex-cli 0.140.0-beta.1" }); + expect(await ensureAgentUpdates(options)).toEqual({}); + expect(options.install).not.toHaveBeenCalled(); + }); + + it("does not activate an installation that finishes after Studio starts quitting", async () => { + const options = setup(); + const initial = await ensureAgentUpdates(options); + const filesBefore = await readdir(path.join(root, "codex")); + const pointer = await readFile( + path.join(root, "codex", "active.json"), + "utf8", + ); + const controller = new AbortController(); + options.signal = controller.signal; + options.latest = async () => "0.135.0"; + options.install = async (spec, prefix) => { + await fakeInstall(spec, prefix); + controller.abort(); + return true; + }; + expect(await ensureAgentUpdates(options)).toEqual(initial); + expect( + await readFile(path.join(root, "codex", "active.json"), "utf8"), + ).toBe(pointer); + expect(await readdir(path.join(root, "codex"))).toEqual(filesBefore); + }); + + it("reuses selected binaries in dev/smoke without registry or install calls", async () => { + const initial = await ensureAgentUpdates(setup()); + const options = { ...setup(), enabled: false }; + expect(await ensureAgentUpdates(options)).toEqual(initial); + expect(options.latest).not.toHaveBeenCalled(); + expect(options.install).not.toHaveBeenCalled(); + }); + + it("retains older prefixes when selecting a new version, for processes still using them", async () => { + const options = setup(); + const initial = await ensureAgentUpdates(options); + options.latest = async () => "0.135.0"; + const updated = await ensureAgentUpdates(options); + expect(updated.codex?.prefix).not.toBe(initial.codex?.prefix); + expect(await options.probe(initial.codex!.command)).toBe("0.134.0"); + expect(await options.probe(updated.codex!.command)).toBe("0.135.0"); + }); + + it("ignores interrupted unpublished installations", async () => { + const prefix = path.join( + root, + "codex", + "0.134.0-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + ); + await fakeInstall("@openai/codex@0.134.0", prefix); + const options = setup(); + const selected = await ensureAgentUpdates(options); + expect(options.install).toHaveBeenCalledOnce(); + expect(selected.codex?.prefix).not.toBe(prefix); + expect(await readdir(prefix)).toContain("lib"); + }); + + it("rejects a selector pointing outside the managed directory", async () => { + await mkdir(path.join(root, "codex")); + await writeFile( + path.join(root, "codex", "active.json"), + JSON.stringify({ version: "0.134.0", directory: "../../elsewhere" }), + ); + const options = { ...setup(), enabled: false }; + expect(await ensureAgentUpdates(options)).toEqual({}); + }); + + it("launches an npm JS entry through the supplied runtime on the Windows layout", async () => { + const prefix = path.join(root, "Windows prefix with spaces"); + await fakeInstall("@openai/codex@0.134.0", prefix, true); + const command = await resolveAgentCommand(prefix, "codex", { + ...runtime, + binaryEnv: { ELECTRON_RUN_AS_NODE: "1" }, + }); + expect(command?.binary).toBe(process.execPath); + expect(command?.binaryArgs.at(-1)).toBe( + path.join(prefix, "node_modules", "@openai", "codex", "cli.cjs"), + ); + expect(command?.binaryEnv.ELECTRON_RUN_AS_NODE).toBe("1"); + expect(await setup().probe(command!)).toBe("0.134.0"); + }); +}); + +describe("CLI versions", () => { + it("recognizes provider version output and stable promotion", () => { + expect(cliVersion("codex-cli 0.134.0")).toBe("0.134.0"); + expect(cliVersion("2.1.90 (Claude Code)")).toBe("2.1.90"); + expect(isNewerStable("0.134.0", "0.134.0-beta.1")).toBe(true); + expect(isNewerStable("0.134.0", "0.135.0-beta.1")).toBe(false); + expect(isNewerStable("not-a-version", "0.1.0")).toBe(false); + }); +}); diff --git a/packages/harness-desktop/src/main/agent-updates.ts b/packages/harness-desktop/src/main/agent-updates.ts new file mode 100644 index 00000000..fc13553e --- /dev/null +++ b/packages/harness-desktop/src/main/agent-updates.ts @@ -0,0 +1,194 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import * as path from "node:path"; + +import { + AGENT_PACKAGES, + resolveAgentCommand, + type AgentCommand, + type AgentKind, +} from "./managed-agent.js"; + +export interface ManagedAgent { + prefix: string; + version: string; + command: AgentCommand; +} +interface Selection { + version: string; + directory: string; +} + +const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const INSTALL_DIRECTORY = /^\d+\.\d+\.\d+-[a-f0-9-]{36}$/; + +export function cliVersion(line: string | null): string | null { + return line?.match(/\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?/)?.[0] ?? null; +} + +// Promote prereleases once stable, without downgrading newer local builds. +export function isNewerStable(latest: string, current: string): boolean { + if (!STABLE_VERSION.test(latest)) return false; + const a = latest.split(".").map(Number); + const b = current.split(/[.-]/).slice(0, 3).map(Number); + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] > b[i]; + } + return current.includes("-"); +} + +export async function latestAgentVersion(kind: AgentKind): Promise { + const response = await fetch( + `https://registry.npmjs.org/${AGENT_PACKAGES[kind].package}/latest`, + { + signal: AbortSignal.timeout(5_000), + cache: "no-store", + }, + ); + if (!response.ok) + throw new Error(`Registry returned HTTP ${response.status}`); + const data = (await response.json()) as { version?: unknown }; + if (typeof data.version !== "string" || !STABLE_VERSION.test(data.version)) { + throw new Error("Registry did not return a stable CLI version"); + } + return data.version; +} + +async function loadSelection( + root: string, + kind: AgentKind, +): Promise { + try { + const value = JSON.parse( + await readFile(path.join(root, kind, "active.json"), "utf8"), + ) as Selection; + return typeof value.version === "string" && + STABLE_VERSION.test(value.version) && + typeof value.directory === "string" && + INSTALL_DIRECTORY.test(value.directory) && + value.directory.startsWith(`${value.version}-`) + ? value + : null; + } catch { + return null; + } +} + +export interface AgentUpdateOptions { + root: string; + runtime: AgentCommand; + /** Dev/smoke may reuse a selected install but never ask the registry or install. */ + enabled: boolean; + signal?: AbortSignal; + probe: (command: AgentCommand) => Promise; + install: ( + packageSpec: string, + prefix: string, + onLine: (line: string) => void, + ) => Promise; + latest?: (kind: AgentKind) => Promise; + onLine?: (line: string) => void; +} + +/** Install and verify each update before atomically selecting its immutable prefix. */ +export async function ensureAgentUpdates( + options: AgentUpdateOptions, +): Promise>> { + const selected: Partial> = {}; + const log = options.onLine ?? (() => {}); + for (const kind of Object.keys(AGENT_PACKAGES) as AgentKind[]) { + if (options.signal?.aborted) break; + let unpublishedPrefix: string | undefined; + let unpublishedSelector: string | undefined; + try { + const agent = AGENT_PACKAGES[kind]; + const existing = await loadSelection(options.root, kind); + const external = await options.probe({ + binary: agent.binary, + binaryArgs: [], + binaryEnv: {}, + }); + let currentVersion = cliVersion(external); + if (existing) { + const prefix = path.join(options.root, kind, existing.directory); + const command = await resolveAgentCommand( + prefix, + kind, + options.runtime, + ); + const managedVersion = command + ? cliVersion(await options.probe(command)) + : null; + if ( + managedVersion === existing.version && + (!external || + (currentVersion && + (managedVersion === currentVersion || + isNewerStable(managedVersion, currentVersion)))) + ) { + selected[kind] = { + prefix, + command: command!, + version: managedVersion, + }; + currentVersion = managedVersion; + } + } + // An unrecognized local build is not evidence that an update is needed. + if ( + !options.enabled || + (!existing && external === null) || + (external !== null && !currentVersion) + ) + continue; + log( + `Checking ${agent.binary} updates${currentVersion ? ` (installed ${currentVersion})` : ""}…`, + ); + const latest = await (options.latest ?? latestAgentVersion)(kind); + if (!STABLE_VERSION.test(latest)) + throw new Error("Invalid registry version"); + if (currentVersion && !isNewerStable(latest, currentVersion)) { + log(`${agent.binary} ${currentVersion} is current.`); + continue; + } + const directory = `${latest}-${randomUUID()}`; + const parent = path.join(options.root, kind); + const prefix = path.join(parent, directory); + options.signal?.throwIfAborted(); + unpublishedPrefix = prefix; + await mkdir(prefix, { recursive: true }); + log(`Updating ${agent.binary} to ${latest}…`); + if (!(await options.install(`${agent.package}@${latest}`, prefix, log))) + throw new Error("Installation failed or timed out"); + const command = await resolveAgentCommand(prefix, kind, options.runtime); + if (!command || cliVersion(await options.probe(command)) !== latest) + throw new Error("New CLI failed its version check"); + // Publish only after verification; preserve the previous selection on failure. + const temp = path.join(parent, `active-${randomUUID()}.json`); + options.signal?.throwIfAborted(); + unpublishedSelector = temp; + await writeFile( + temp, + JSON.stringify({ version: latest, directory } satisfies Selection), + { flag: "wx" }, + ); + options.signal?.throwIfAborted(); + await rename(temp, path.join(parent, "active.json")); + unpublishedSelector = undefined; + unpublishedPrefix = undefined; + selected[kind] = { prefix, version: latest, command }; + log(`${agent.binary} updated to ${latest}.`); + } catch (err) { + log( + `${AGENT_PACKAGES[kind].binary} update deferred; keeping the installed version. ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + // Clean only this attempt's unpublished files. Previously selected + // installs can still belong to running processes. + for (const file of [unpublishedSelector, unpublishedPrefix]) { + if (file) await rm(file, { recursive: true, force: true }).catch(() => {}); + } + } + } + return selected; +} diff --git a/packages/harness-desktop/src/main/boot.ts b/packages/harness-desktop/src/main/boot.ts index 2fe07e72..544d3e54 100644 --- a/packages/harness-desktop/src/main/boot.ts +++ b/packages/harness-desktop/src/main/boot.ts @@ -25,6 +25,7 @@ import { hasStoredSettings, startServer, createClaudeCodeAdapter, + createCodexAdapter, resolveSpawnTarget, CLAUDE_INSTALL_COMMAND, CODEX_INSTALL_COMMAND, @@ -37,7 +38,9 @@ import { augmentProcessPath } from "./env.js"; import { esbuildBinaryPath } from "./esbuild-binary.js"; import { resolveWebDir } from "./paths.js"; import { createMainWindow } from "./windows.js"; -import { agentPrefixDir, ensureSapiomCli, installClaudeCode, installSapiomMcp } from "./agent-install.js"; +import { agentPrefixDir, ensureSapiomCli, installAgentVersion, installClaudeCode, installSapiomMcp } from "./agent-install.js"; +import { ensureAgentUpdates } from "./agent-updates.js"; +import { runUpdateCommand } from "./agent-update-process.js"; import { agentRepairDecision } from "./agent-repair.js"; import { ensureMinGit } from "./git-provision.js"; import { ensureSapiomMcp } from "./mcp-install.js"; @@ -266,6 +269,8 @@ async function ensureAgentAvailable(setupWin: BrowserWindow, initialReport: Doct } export interface BootMode { + /** Cancel startup when the user quits from the setup window. */ + signal?: AbortSignal; /** `--dev`: skips the consent prompt and logs the ready URL. */ devMode: boolean; /** @@ -335,6 +340,34 @@ export async function boot(setupWin: BrowserWindow, mode: BootMode): Promise { + try { + const target = resolveSpawnTarget(command.binary, [...command.binaryArgs, "--version"]); + const result = await runUpdateCommand(target.command, target.args, { + env: { ...process.env, ...command.binaryEnv, DISABLE_AUTOUPDATER: "1" }, + timeoutMs: 5_000, + }); + return result.ok ? result.stdout.trim() : null; + } catch { return null; } + }, + onLine: (line) => { + console.log(`[boot] agent-update: ${line}`); + progress(setupWin, { phase: "installing-agent", message: line, status: "active" }); + }, + }); + const managedBins = Object.values(managedAgents).map(({ prefix }) => + process.platform === "win32" ? prefix : path.join(prefix, "bin")); + if (managedBins.length) process.env.PATH = [...managedBins, process.env.PATH ?? ""].join(path.delimiter); + mode.signal?.throwIfAborted(); + // 2. Doctor. progress(setupWin, { phase: "doctor", message: "Checking your environment…", status: "active" }); let report = await runDoctor(); @@ -368,7 +401,7 @@ export async function boot(setupWin: BrowserWindow, mode: BootMode): Promise fs.existsSync(dir)); - if (!smoke && report.availableHarnesses.includes("claude-code")) { + if (!smoke && !managedAgents["claude-code"] && report.availableHarnesses.includes("claude-code")) { const decision = agentRepairDecision({ platform: process.platform, managedInstallExists: managedClaudeInstalled(), @@ -392,7 +425,7 @@ export async function boot(setupWin: BrowserWindow, mode: BootMode): Promise | null = null; function shutdownServer(): Promise { - if (!bootResult) return Promise.resolve(); // Set synchronously, before any await: the quit hook reads it to decide whether // to intercept, and a later assignment would let it intercept its own re-quit. quitting = true; - shuttingDown ??= bootResult.server.close().catch(() => { + bootAbort.abort(); + shuttingDown ??= stopAgentUpdateCommands().then(() => bootResult?.server.close()).catch(() => { /* close() is internally race-bounded to 5s; ignore errors on shutdown */ }); return shuttingDown; @@ -177,7 +179,7 @@ if (lock.action === "fail") { const coldLink = pendingDeepLink ?? deepLinkFromArgv(process.argv); pendingDeepLink = null; const coldTarget = coldLink ? parseDeepLink(coldLink) : null; - bootResult = await boot(setupWin, { devMode, smoke: smokeMode, deepLink: coldTarget ?? undefined }); + bootResult = await boot(setupWin, { devMode, smoke: smokeMode, deepLink: coldTarget ?? undefined, signal: bootAbort.signal }); if (devMode || smokeMode) { // Dev/smoke hook: print the UI-authorized launch URL so a harness can // verify the server booted without driving the GUI. @@ -247,6 +249,7 @@ if (lock.action === "fail") { if (buffered) handleDeepLink(buffered); }); } catch (err) { + if (bootAbort.signal.aborted) return; if (smokeMode) { // A boot failure IS the smoke result — report it as one and fail fast // rather than showing an error window nobody is watching. @@ -271,7 +274,7 @@ if (lock.action === "fail") { // Kill PTYs before exit: intercept quit, close the server, then really quit. app.on("before-quit", (event) => { - if (quitting || !bootResult) return; + if (quitting) return; event.preventDefault(); void shutdownServer().finally(() => app.quit()); }); diff --git a/packages/harness-desktop/src/main/managed-agent.test.ts b/packages/harness-desktop/src/main/managed-agent.test.ts new file mode 100644 index 00000000..e9f03046 --- /dev/null +++ b/packages/harness-desktop/src/main/managed-agent.test.ts @@ -0,0 +1,67 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveAgentCommand, type AgentCommand } from "./managed-agent.js"; + +const execute = promisify(execFile); +const runtime: AgentCommand = { + binary: process.execPath, + binaryArgs: ["--no-warnings"], + binaryEnv: { ELECTRON_RUN_AS_NODE: "1", STUDIO_MANAGED_TEST: "preserved" }, +}; +let root: string; +beforeEach(async () => { root = await mkdtemp(path.join(tmpdir(), "studio-managed-command-")); }); +afterEach(async () => { await rm(root, { recursive: true, force: true }); }); + +async function install(modules: string, entry: string, source: string): Promise { + const directory = path.join(root, modules, "@openai/codex"); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, "package.json"), JSON.stringify({ bin: { codex: entry } })); + await writeFile(path.resolve(directory, entry), source); + return directory; +} + +describe("managed provider command resolution", () => { + it.each(["node_modules", "lib/node_modules"])( + "clears Electron startup state before a CLI or its children run (%s)", + async (modules) => { + await install(modules, "cli.mjs", ` + import {execFileSync} from 'node:child_process'; + const child = JSON.parse(execFileSync(process.execPath, ['-e', + 'console.log(JSON.stringify({flag:process.env.ELECTRON_RUN_AS_NODE??null,retained:process.env.STUDIO_MANAGED_TEST}))' + ], {encoding:'utf8'})); + console.log(JSON.stringify({flag:process.env.ELECTRON_RUN_AS_NODE??null,child,args:process.argv.slice(2)})); + `); + const command = await resolveAgentCommand(root, "codex", runtime); + expect(command).not.toBeNull(); + const result = await execute(command!.binary, [...command!.binaryArgs, "--version"], { + env: { ...process.env, ...command!.binaryEnv }, + }); + expect(JSON.parse(result.stdout)).toEqual({ + flag: null, child: { flag: null, retained: "preserved" }, args: ["--version"], + }); + }, + ); + + it("preserves ordinary Node runtime arguments without adding Electron setup", async () => { + const directory = await install("lib/node_modules", "cli.cjs", ""); + expect(await resolveAgentCommand(root, "codex", { ...runtime, binaryEnv: {} })).toEqual({ + binary: process.execPath, binaryArgs: ["--no-warnings", path.join(directory, "cli.cjs")], binaryEnv: {}, + }); + }); + + it("launches native entries without the interpreter environment", async () => { + const directory = await install("node_modules", "codex", "native fixture"); + expect(await resolveAgentCommand(root, "codex", runtime)).toEqual({ + binary: path.join(directory, "codex"), binaryArgs: [], binaryEnv: {}, + }); + }); + + it("rejects package entries outside the installed package", async () => { + await install("node_modules", "../escaped.cjs", ""); + expect(await resolveAgentCommand(root, "codex", runtime)).toBeNull(); + }); +}); diff --git a/packages/harness-desktop/src/main/managed-agent.ts b/packages/harness-desktop/src/main/managed-agent.ts new file mode 100644 index 00000000..809b0b78 --- /dev/null +++ b/packages/harness-desktop/src/main/managed-agent.ts @@ -0,0 +1,60 @@ +import { access, readFile } from "node:fs/promises"; +import * as path from "node:path"; + +export const AGENT_PACKAGES = { + "claude-code": { binary: "claude", package: "@anthropic-ai/claude-code" }, + codex: { binary: "codex", package: "@openai/codex" }, +} as const; +export type AgentKind = keyof typeof AGENT_PACKAGES; + +export interface AgentCommand { + binary: string; + binaryArgs: string[]; + binaryEnv: Record; +} + +export async function resolveAgentCommand( + prefix: string, + kind: AgentKind, + runtime: AgentCommand, +): Promise { + const agent = AGENT_PACKAGES[kind]; + for (const modules of ["node_modules", path.join("lib", "node_modules")]) { + try { + const packageDir = path.join(prefix, modules, agent.package); + const pkg = JSON.parse( + await readFile(path.join(packageDir, "package.json"), "utf8"), + ) as { + bin?: string | Record; + }; + const bin = + typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[agent.binary]; + if (!bin) continue; + const entry = path.resolve(packageDir, bin); + const relative = path.relative(packageDir, entry); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) + continue; + await access(entry); + const binaryEnv: Record = + kind === "claude-code" ? { DISABLE_AUTOUPDATER: "1" } : {}; + return /\.[cm]?js$/i.test(entry) + ? { + binary: runtime.binary, + binaryArgs: [ + ...runtime.binaryArgs, + // Electron reads this flag at startup. Clear it before the CLI + // runs so its shell commands can launch Electron normally. + ...(runtime.binaryEnv.ELECTRON_RUN_AS_NODE === "1" + ? ["--import", "data:text/javascript,delete%20process.env.ELECTRON_RUN_AS_NODE"] + : []), + entry, + ], + binaryEnv: { ...runtime.binaryEnv, ...binaryEnv }, + } + : { binary: entry, binaryArgs: [], binaryEnv }; + } catch { + /* Try the other global npm layout. */ + } + } + return null; +} diff --git a/packages/harness-desktop/src/main/smoke.ts b/packages/harness-desktop/src/main/smoke.ts index b270faab..1e6c40f2 100644 --- a/packages/harness-desktop/src/main/smoke.ts +++ b/packages/harness-desktop/src/main/smoke.ts @@ -46,6 +46,7 @@ import { execFile } from "node:child_process"; import { chmodSync, existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -57,7 +58,8 @@ import * as path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { app } from "electron"; -import { AGENT_STUDIO_PRODUCT_NAME, resolveSpawnTarget } from "@sapiom/harness"; +import { AGENT_STUDIO_PRODUCT_NAME, createCodexAdapter, resolveSpawnTarget } from "@sapiom/harness"; +import { resolveAgentCommand } from "./managed-agent.js"; import { createSetupWindow } from "./windows.js"; import { resolveWebDir } from "./paths.js"; import { shimDir } from "./runtime-shims.js"; @@ -94,6 +96,42 @@ async function check( } } +/** Verify managed JS launchers through the packaged runtime and a real PTY. */ +async function checkManagedAgent(): Promise { + const prefix = mkdtempSync(path.join(tmpdir(), "studio managed agent ")); + try { + const modules = process.platform === "win32" ? "node_modules" : path.join("lib", "node_modules"); + const packageDir = path.join(prefix, modules, "@openai", "codex"); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ bin: { codex: "cli.cjs" } })); + writeFileSync(path.join(packageDir, "cli.cjs"), "if (process.env.ELECTRON_RUN_AS_NODE) throw new Error('Host runtime flag leaked into managed CLI');\nconsole.log(process.argv.includes('--version') ? '99.0.0' : 'managed-codex-ready');\n"); + const command = await resolveAgentCommand(prefix, "codex", { + binary: process.execPath, binaryArgs: [], binaryEnv: { ELECTRON_RUN_AS_NODE: "1" }, + }); + if (!command) throw new Error("Managed Codex entry did not resolve"); + const adapter = createCodexAdapter(command); + if (!(await adapter.doctor())[0]?.ok) throw new Error("Managed Codex version probe failed"); + const opts = { harnessSessionId: "managed-smoke", cwd: prefix }; + const pty = await import("node-pty"); + for (const spec of [adapter.launch(opts), adapter.resume("previous", opts)]) { + await new Promise((resolve, reject) => { + const child = pty.spawn(spec.command, spec.args, { + cwd: prefix, env: { ...process.env, ...command.binaryEnv }, cols: 80, rows: 24, + }); + let output = ""; + const timer = setTimeout(() => { child.kill(); reject(new Error("Managed CLI PTY timed out")); }, 5_000); + child.onData((data) => { output += data; }); + child.onExit(({ exitCode }) => { + clearTimeout(timer); + if (exitCode === 0 && output.includes("managed-codex-ready")) resolve(); + else reject(new Error(`Managed CLI PTY failed (${exitCode}): ${output}`)); + }); + }); + } + return "managed JS CLI verified, launched and resumed through the packaged runtime"; + } finally { rmSync(prefix, { recursive: true, force: true }); } +} + /** GET with the boot token, asserting status and (optionally) a body substring. */ async function fetchOk( url: string, @@ -1082,6 +1120,7 @@ export async function runSmokeChecks(boot: BootResult): Promise { }), await check("session-create", () => checkSessionCreate(base, token)), await check("agent-shim", checkAgentShim), + await check("managed-agent", checkManagedAgent), await check("preload-bridge", checkPreloadBridge), await check("node-pty", checkNodePty), await check("unpacked-deps", checkUnpackedDeps), diff --git a/packages/harness/src/cli/doctor.ts b/packages/harness/src/cli/doctor.ts index bb4ee922..ac9ebcb9 100644 --- a/packages/harness/src/cli/doctor.ts +++ b/packages/harness/src/cli/doctor.ts @@ -11,7 +11,7 @@ async function which(bin: string): Promise { bin, // windowsHide: a console child of the console-less desktop host would // otherwise open a visible window (no-op on POSIX and in a real terminal). - ], { windowsHide: true }); + ], { windowsHide: true, timeout: 5_000, killSignal: "SIGKILL" }); return stdout.trim().split("\n")[0] ?? null; } catch { return null; @@ -20,7 +20,7 @@ async function which(bin: string): Promise { async function version(bin: string, args: string[] = ["--version"]): Promise { try { - const { stdout } = await execFileAsync(bin, args, { windowsHide: true }); + const { stdout } = await execFileAsync(bin, args, { windowsHide: true, timeout: 5_000, killSignal: "SIGKILL" }); return stdout.trim().split("\n")[0] ?? null; } catch { return null; diff --git a/packages/harness/src/core/adapters/claude-code.ts b/packages/harness/src/core/adapters/claude-code.ts index 02d5e703..983139c2 100644 --- a/packages/harness/src/core/adapters/claude-code.ts +++ b/packages/harness/src/core/adapters/claude-code.ts @@ -157,6 +157,9 @@ const DEFAULT_FULL_SCAN_MAX_BYTES = 5_242_880; // 5 MiB export interface ClaudeCodeAdapterOptions { /** Overridable for tests (e.g. spawn `bash` instead of a real, auth-gated `claude`). */ binary?: string; + /** Host-supplied interpreter/entry script for a managed CLI (e.g. Electron-as-Node). */ + binaryArgs?: string[]; + binaryEnv?: Record; /** Overridable for tests. Defaults to the real home directory. */ homeDir?: string; /** Overridable for tests. Max transcript size (bytes) to read in full for an @@ -421,11 +424,15 @@ export class ClaudeCodeAdapter implements HarnessAdapter { */ readonly assumesBracketedPaste = true; private readonly binary: string; + private readonly binaryArgs: string[]; + private readonly binaryEnv: Record; private readonly homeDir: string; private readonly fullScanMaxBytes: number; constructor(options: ClaudeCodeAdapterOptions = {}) { this.binary = options.binary ?? "claude"; + this.binaryArgs = options.binaryArgs ?? []; + this.binaryEnv = options.binaryEnv ?? {}; this.homeDir = options.homeDir ?? homedir(); this.fullScanMaxBytes = options.fullScanMaxBytes ?? DEFAULT_FULL_SCAN_MAX_BYTES; } @@ -445,7 +452,9 @@ export class ClaudeCodeAdapter implements HarnessAdapter { async doctor(): Promise { let versionLine: string; try { - const { stdout } = await execFileAsync(this.binary, ["--version"], { timeout: 5_000, windowsHide: true }); + const { stdout } = await execFileAsync(this.binary, [...this.binaryArgs, "--version"], { + timeout: 5_000, windowsHide: true, env: { ...process.env, ...this.binaryEnv }, + }); versionLine = stdout.trim(); } catch { return [ @@ -481,11 +490,11 @@ export class ClaudeCodeAdapter implements HarnessAdapter { if (opts.initialPrompt) args.push("--", opts.initialPrompt); return { command: this.binary, - args, + args: [...this.binaryArgs, ...args], // Nested-agent conflict: Claude Code refuses to run "inside itself" if // CLAUDECODE is already set, which it will be if the harness server // itself was launched from within a Claude Code session. - env: { CLAUDECODE: null }, + env: { ...this.binaryEnv, CLAUDECODE: null }, cwd: opts.cwd, }; } @@ -497,8 +506,8 @@ export class ClaudeCodeAdapter implements HarnessAdapter { } return { command: this.binary, - args, - env: { CLAUDECODE: null }, + args: [...this.binaryArgs, ...args], + env: { ...this.binaryEnv, CLAUDECODE: null }, cwd: opts.cwd, }; } @@ -522,8 +531,8 @@ export class ClaudeCodeAdapter implements HarnessAdapter { throw new Error("claude-code adapter: launchTask requires opts.prompt"); } if (opts.structuredInference) { - return { command: this.binary, cwd: opts.cwd, env: { CLAUDECODE: null }, stdin: opts.prompt, - args: ["-p", "--safe-mode", "--tools", "", "--no-session-persistence", + return { command: this.binary, cwd: opts.cwd, env: { ...this.binaryEnv, CLAUDECODE: null }, stdin: opts.prompt, + args: [...this.binaryArgs, "-p", "--safe-mode", "--tools", "", "--no-session-persistence", // Safe mode retains native authentication. Disable optional executable // user helpers too; empty strings (unlike null) override native settings. "--settings", JSON.stringify({ apiKeyHelper: "", awsAuthRefresh: "", awsCredentialExport: "", @@ -542,8 +551,8 @@ export class ClaudeCodeAdapter implements HarnessAdapter { args.push("--permission-mode", "acceptEdits", "--output-format", "stream-json", "--verbose"); return { command: this.binary, - args, - env: { CLAUDECODE: null }, + args: [...this.binaryArgs, ...args], + env: { ...this.binaryEnv, CLAUDECODE: null }, cwd: opts.cwd, }; } diff --git a/packages/harness/src/core/adapters/codex.ts b/packages/harness/src/core/adapters/codex.ts index 3453193e..bab63fe7 100644 --- a/packages/harness/src/core/adapters/codex.ts +++ b/packages/harness/src/core/adapters/codex.ts @@ -149,6 +149,9 @@ function hasBlockingPromptFragment(rendered: string): boolean { export interface CodexAdapterOptions { /** Overridable for tests. */ binary?: string; + /** Host-supplied interpreter/entry script for a managed CLI (e.g. Electron-as-Node). */ + binaryArgs?: string[]; + binaryEnv?: Record; /** Overridable for tests. Defaults to the real home directory. */ homeDir?: string; } @@ -279,16 +282,22 @@ export class CodexAdapter implements HarnessAdapter { * too and neither adapter needs a rehydration-specific code path. */ readonly systemPromptDelivery = "launch-flag" as const; private readonly binary: string; + private readonly binaryArgs: string[]; + private readonly binaryEnv: Record; private readonly homeDir: string; constructor(options: CodexAdapterOptions = {}) { this.binary = options.binary ?? "codex"; + this.binaryArgs = options.binaryArgs ?? []; + this.binaryEnv = options.binaryEnv ?? {}; this.homeDir = options.homeDir ?? homedir(); } async doctor(): Promise { try { - const { stdout } = await execFileAsync(this.binary, ["--version"], { timeout: 5_000, windowsHide: true }); + const { stdout } = await execFileAsync(this.binary, [...this.binaryArgs, "--version"], { + timeout: 5_000, windowsHide: true, env: { ...process.env, ...this.binaryEnv }, + }); return [{ name: "codex", ok: true, detail: stdout.trim() || "installed" }]; } catch { return [ @@ -306,8 +315,8 @@ export class CodexAdapter implements HarnessAdapter { launchTask(opts: LaunchOpts): SpawnSpec { if (!opts.prompt || !opts.structuredInference) throw new Error("Codex background tasks require structured inference mode"); return { command: process.execPath, - args: [unpackedPath(fileURLToPath(new URL("../codex-structured-inference.js", import.meta.url))), this.binary], - cwd: opts.cwd, env: { ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}) }, + args: [unpackedPath(fileURLToPath(new URL("../codex-structured-inference.js", import.meta.url))), this.binary, ...this.binaryArgs], + cwd: opts.cwd, env: { ...this.binaryEnv, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}) }, stdin: JSON.stringify({ prompt: opts.prompt, systemPrompt: opts.structuredInference.systemPrompt, schema: opts.structuredInference.schema }) }; } @@ -316,12 +325,12 @@ export class CodexAdapter implements HarnessAdapter { if (opts.initialPrompt) args.push("--", opts.initialPrompt); return { command: this.binary, - args, + args: [...this.binaryArgs, ...args], // Codex has no analog to Claude's CLAUDECODE nested-agent guard; no env // overrides are needed for a fresh launch. - env: opts.agentMapMcp + env: { ...this.binaryEnv, ...(opts.agentMapMcp ? { SAPIOM_AGENT_MAP_CAPABILITY: opts.agentMapMcp.bearerToken } - : {}, + : {}) }, cwd: opts.cwd, }; } @@ -329,10 +338,10 @@ export class CodexAdapter implements HarnessAdapter { resume(agentSessionId: string, opts: LaunchOpts): SpawnSpec { return { command: this.binary, - args: ["resume", agentSessionId, ...buildConfigArgs(opts)], - env: opts.agentMapMcp + args: [...this.binaryArgs, "resume", agentSessionId, ...buildConfigArgs(opts)], + env: { ...this.binaryEnv, ...(opts.agentMapMcp ? { SAPIOM_AGENT_MAP_CAPABILITY: opts.agentMapMcp.bearerToken } - : {}, + : {}) }, cwd: opts.cwd, }; } diff --git a/packages/harness/src/core/adapters/managed-cli.test.ts b/packages/harness/src/core/adapters/managed-cli.test.ts new file mode 100644 index 00000000..4ac0192a --- /dev/null +++ b/packages/harness/src/core/adapters/managed-cli.test.ts @@ -0,0 +1,253 @@ +import { execFile } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ClaudeCodeAdapter } from "./claude-code.js"; +import { CodexAdapter } from "./codex.js"; + +let root: string; +let entry: string; +beforeEach(async () => { + root = await realpath( + await mkdtemp(join(tmpdir(), "managed CLI with spaces ")), + ); + entry = join(root, "cli.cjs"); + await writeFile( + entry, + `if(process.argv.includes('--version')) console.log('99.0.0'); else console.log(JSON.stringify({args:process.argv.slice(2), home:process.env.CODEX_HOME}));`, + ); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("host-managed CLI launchers", () => { + it.each(["claude", "codex"])( + "uses the supplied runtime and entry for %s doctor, fresh launch and resume", + async (kind) => { + const options = { + binary: process.execPath, + binaryArgs: [entry], + binaryEnv: { ELECTRON_RUN_AS_NODE: "1" }, + }; + const adapter = + kind === "codex" + ? new CodexAdapter(options) + : new ClaudeCodeAdapter(options); + expect((await adapter.doctor())[0].ok).toBe(true); + const launch = { + harnessSessionId: "studio-session", + cwd: root, + initialPrompt: "Plan these agents", + }; + for (const spec of [ + adapter.launch(launch), + adapter.resume("previous-conversation", launch), + ]) { + expect(spec.args[0]).toBe(entry); + expect(spec.env.ELECTRON_RUN_AS_NODE).toBe("1"); + const { stdout } = await promisify(execFile)(spec.command, spec.args, { + cwd: spec.cwd, + env: { ...process.env, CODEX_HOME: root, ELECTRON_RUN_AS_NODE: "1" }, + timeout: 5_000, + windowsHide: true, + }); + const received = JSON.parse(stdout) as { args: string[]; home: string }; + expect(received.home).toBe(root); + expect(received.args).toEqual(spec.args.slice(1)); + if (kind === "codex") { + expect(received.args).toContain("check_for_update_on_startup=false"); + expect(received.args).not.toContain("--model"); + if (!received.args.includes("resume")) + expect(received.args.slice(-2)).toEqual([ + "--", + launch.initialPrompt, + ]); + } else expect(spec.env.CLAUDECODE).toBeNull(); + } + }, + ); + + it("also sends Claude headless tasks through the selected CLI", () => { + const adapter = new ClaudeCodeAdapter({ + binary: process.execPath, + binaryArgs: [entry], + binaryEnv: { ELECTRON_RUN_AS_NODE: "1", DISABLE_AUTOUPDATER: "1" }, + }); + const spec = adapter.launchTask({ + harnessSessionId: "task", + cwd: root, + prompt: "describe", + }); + expect(spec.args.slice(0, 3)).toEqual([entry, "-p", "describe"]); + expect(spec.env).toMatchObject({ + ELECTRON_RUN_AS_NODE: "1", + DISABLE_AUTOUPDATER: "1", + CLAUDECODE: null, + }); + }); + + it("retains the managed Claude launcher and restrictions during structured inference", async () => { + const adapter = new ClaudeCodeAdapter({ + binary: process.execPath, + binaryArgs: [entry], + binaryEnv: { ELECTRON_RUN_AS_NODE: "1", DISABLE_AUTOUPDATER: "1" }, + }); + const spec = adapter.launchTask({ + harnessSessionId: "task", + cwd: root, + prompt: "Contract evidence", + structuredInference: { + projectId: "project-test", + schema: { type: "object" }, + schemaFile: join(root, "schema.json"), + systemPrompt: "Return JSON", + }, + }); + expect(spec.args.slice(0, 3)).toEqual([entry, "-p", "--safe-mode"]); + expect(spec.env).toMatchObject({ + ELECTRON_RUN_AS_NODE: "1", + DISABLE_AUTOUPDATER: "1", + CLAUDECODE: null, + }); + expect(spec.args).toContain("--no-session-persistence"); + expect(spec.args[spec.args.indexOf("--tools") + 1]).toBe(""); + const { stdout } = await promisify(execFile)(spec.command, spec.args, { + cwd: spec.cwd, + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + timeout: 5_000, + windowsHide: true, + }); + expect(JSON.parse(stdout).args).toEqual(spec.args.slice(1)); + }); + + it("runs both Codex inference subprocesses through the managed entry while isolating the worker profile", async () => { + const originalProfile = join(root, "native profile"); + const taskDir = join(root, "task"); + const callsFile = join(root, "calls.jsonl"); + await mkdir(originalProfile); + await mkdir(taskDir); + await writeFile( + join(originalProfile, "auth.json"), + JSON.stringify({ OPENAI_API_KEY: "test-only" }), + ); + await writeFile( + entry, + ` +const fs = require('node:fs'); +const record = (value) => fs.appendFileSync(process.env.MANAGED_TEST_CALLS, JSON.stringify(value) + '\\n'); +record({ args: process.argv.slice(2), profile: process.env.CODEX_HOME, managed: process.env.ELECTRON_RUN_AS_NODE }); +const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n'); +require('node:readline').createInterface({input: process.stdin}).on('line', (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + record({ method: request.method, params: request.params }); + let result = {}; + if (request.method === 'config/read') result = { config: { model: 'native-default', cli_auth_credentials_store: 'file' } }; + if (request.method === 'thread/start') result = { thread: { id: 'thread-test' } }; + if (request.method === 'turn/start') result = { turn: { id: 'turn-test' } }; + send({ id: request.id, result }); + if (request.method === 'turn/start') { + send({ method: 'item/completed', params: { threadId: 'thread-test', turnId: 'turn-test', item: { type: 'agentMessage', text: JSON.stringify({nodes: []}) } } }); + send({ method: 'turn/completed', params: { threadId: 'thread-test', turn: { id: 'turn-test', status: 'completed' } } }); + } +}); +`, + ); + const adapter = new CodexAdapter({ + binary: process.execPath, + binaryArgs: [entry], + binaryEnv: { ELECTRON_RUN_AS_NODE: "1", MANAGED_TEST_CALLS: callsFile }, + }); + const spec = adapter.launchTask({ + harnessSessionId: "task", + cwd: taskDir, + prompt: "Contract evidence", + structuredInference: { + projectId: "project-test", + schema: { type: "object" }, + schemaFile: join(taskDir, "schema.json"), + systemPrompt: "Return JSON", + }, + }); + expect(spec.args.slice(1)).toEqual([process.execPath, entry]); + // Load the source worker so this check does not require build output. + const sourceWorker = spec.args[0]!.replace(/\.js$/, ".ts"); + const loader = createRequire(import.meta.url).resolve("tsx/esm"); + const child = execFile( + spec.command, + ["--import", loader, sourceWorker, ...spec.args.slice(1)], + { + cwd: spec.cwd, + env: { + ...process.env, + CODEX_HOME: originalProfile, + ...spec.env, + } as NodeJS.ProcessEnv, + timeout: 10_000, + windowsHide: true, + }, + ); + const output = new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + child.stdout!.on("data", (data: string) => { + stdout += data; + }); + child.stderr!.on("data", (data: string) => { + stderr += data; + }); + child.once("error", reject); + child.once("close", (code) => + code === 0 + ? resolve(stdout) + : reject(new Error(`Inference exited ${code}: ${stderr}`)), + ); + }); + child.stdin!.end(spec.stdin); + expect(JSON.parse(await output)).toEqual({ + type: "result", + is_error: false, + structured_output: { nodes: [] }, + }); + const calls = (await readFile(callsFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const starts = calls.filter((call) => call.args); + expect(starts).toHaveLength(2); + expect(starts.map((call) => call.profile)).toEqual([ + originalProfile, + join(taskDir, "codex"), + ]); + for (const call of starts) { + expect(call.managed).toBe("1"); + expect(call.args[0]).toBe("app-server"); + expect(call.args).toContain("features.shell_tool=false"); + expect(call.args).toContain("features.multi_agent=false"); + } + expect(calls.filter((call) => call.method === "thread/start")).toHaveLength( + 1, + ); + expect( + calls.find((call) => call.method === "thread/start").params, + ).toMatchObject({ + ephemeral: true, + approvalPolicy: "never", + config: { features: { shell_tool: false } }, + }); + expect(await readFile(join(originalProfile, "auth.json"), "utf8")).toBe( + JSON.stringify({ OPENAI_API_KEY: "test-only" }), + ); + }, 15_000); +}); diff --git a/packages/harness/src/core/codex-inference-profile.ts b/packages/harness/src/core/codex-inference-profile.ts index 32d65a10..ea82b148 100644 --- a/packages/harness/src/core/codex-inference-profile.ts +++ b/packages/harness/src/core/codex-inference-profile.ts @@ -137,6 +137,7 @@ export async function prepareCodexInferenceProfile( binary: string, cwd: string, startupArgs: string[], + binaryArgs: readonly string[] = [], ): Promise { const originalHome = await fs.realpath( process.env.CODEX_HOME ?? join(homedir(), ".codex"), @@ -146,7 +147,7 @@ export async function prepareCodexInferenceProfile( join(originalHome, "studio-inference-auth"), { timeoutMs: 180000 }, ).acquire(); - const broker = spawn(binary, ["app-server", ...startupArgs], { + const broker = spawn(binary, [...binaryArgs, "app-server", ...startupArgs], { cwd, env: process.env, stdio: ["pipe", "pipe", "pipe"], diff --git a/packages/harness/src/core/codex-structured-inference.ts b/packages/harness/src/core/codex-structured-inference.ts index 25740695..f48ff574 100644 --- a/packages/harness/src/core/codex-structured-inference.ts +++ b/packages/harness/src/core/codex-structured-inference.ts @@ -72,6 +72,7 @@ export async function runCodexStructuredInference( systemPrompt: string; schema: Record; }, + binaryArgs: readonly string[] = [], ): Promise { const cwd = process.cwd(); const promptFile = join(cwd, "inference-instructions.txt"); @@ -103,8 +104,9 @@ export async function runCodexStructuredInference( binary, cwd, overrides, + binaryArgs, ); - const child = spawn(binary, args, { + const child = spawn(binary, [...binaryArgs, ...args], { cwd, env: { ...process.env, CODEX_HOME: isolatedHome }, stdio: ["pipe", "pipe", "pipe"], @@ -312,6 +314,8 @@ async function main(): Promise { const result = await runCodexStructuredInference( process.argv[2]!, JSON.parse(body), + // The adapter supplies the managed CLI entry separately from inference data. + process.argv.slice(3), ); await new Promise((resolve, reject) => process.stdout.write( diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index e8728f72..e67d030a 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -277,6 +277,7 @@ export type { SpawnTarget } from "./core/spawn-target.js"; // desktop app's --smoke mode to create a REAL session against a stub agent, so // per-OS session coverage doesn't require Claude Code installed on a CI runner. export { createClaudeCodeAdapter } from "./core/adapters/claude-code.js"; +export { createCodexAdapter } from "./core/adapters/codex.js"; export { loadSettings, saveSettings, diff --git a/packages/harness/src/server/agent-map-mcp-wiring.test.ts b/packages/harness/src/server/agent-map-mcp-wiring.test.ts index e6648b9e..8b30c3f2 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -604,11 +604,21 @@ it("creates one ordinary Plan Agents session for a newly opened project and neve body: JSON.stringify({ recentDirs: [freshRoot, projectRoot] }), }); expect(firstOpen.status).toBe(200); - await vi.waitFor(() => expect(server!.sessionManager.list()).toHaveLength(1)); + // Settings schedules creation asynchronously. Read the published state, as + // the app does, rather than observing the private row before its PTY exists. + await vi.waitFor(async () => { + const visibleState = await request("/state"); + expect(visibleState.status).toBe(200); + const state = await visibleState.json(); + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].status).toBe("running"); + }); expect(launches).toHaveLength(1); const [firstSession] = server.sessionManager.list(); const freshProjectId = firstSession!.agentMapIdentity!.projectId; expect(firstSession).toMatchObject({ + status: "running", + ready: false, title: "Plan Agents", cwd: freshRoot, agentMapIdentity: { diff --git a/packages/harness/web/src/components/Terminal.tsx b/packages/harness/web/src/components/Terminal.tsx index 47a6893e..cb7c47f8 100644 --- a/packages/harness/web/src/components/Terminal.tsx +++ b/packages/harness/web/src/components/Terminal.tsx @@ -217,12 +217,13 @@ export const Terminal = ({ sessionId, token }: TerminalProps): JSX.Element => { }); const sendResize = (): void => { - if (ws?.readyState === WebSocket.OPEN) { + if (!disposed && ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); } }; const resizeObserver = new ResizeObserver(() => { + if (disposed) return; fitAddon.fit(); sendResize(); }); @@ -261,6 +262,7 @@ export const Terminal = ({ sessionId, token }: TerminalProps): JSX.Element => { ws = socket; socket.onopen = () => { + if (disposed || ws !== socket) return; reconnectAttempt = 0; setStatus("connected"); setErrorMessage(null); @@ -269,13 +271,14 @@ export const Terminal = ({ sessionId, token }: TerminalProps): JSX.Element => { }; socket.onmessage = (event) => { + if (disposed || ws !== socket) return; const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); term.write(data); }; socket.onclose = (event) => { - if (disposed) return; + if (disposed || ws !== socket) return; if (PERMANENT_CLOSE_CODES.has(event.code)) { setStatus("error"); setErrorMessage(event.reason || `Connection refused (${event.code})`); @@ -307,7 +310,11 @@ export const Terminal = ({ sessionId, token }: TerminalProps): JSX.Element => { inputDisposable.dispose(); mock?.dispose(); ws?.close(); - term.dispose(); + // xterm 5.5 queues an uncancelled viewport timer in open(). StrictMode + // can clean up before that timer runs. Detach immediately, then dispose + // after the already-queued timer, while its renderer is still available. + term.element?.remove(); + setTimeout(() => term.dispose(), 0); termRef.current = null; }; }, [sessionId, token]); diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index cdb06d1a..3b2a6b91 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -1280,6 +1280,7 @@ const MOCK_LAUNCH_EDGES: StudioRailLaunchEdge[] = [ /** One key per project root, mirroring one file per project root. */ const MOCK_RAIL_STATE_PREFIX = "sapiom-mock-studio-rail:"; +const MOCK_WORKSPACE_PREFERENCE_PREFIX = "sapiom-mock-studio-workspace:"; /** * Mock mode's stand-in for the ONE settings field whose whole contract is @@ -2516,6 +2517,27 @@ export class MockApi implements HarnessApi { projectId: StudioProjectId, ): Promise { await delay(); + return this.readStudioCurrentWorkspace(projectId); + } + + private saveStudioPreference( + projectId: StudioProjectId, + selection: StudioWorkspaceSelection, + ): void { + this.studioPreferences.set(projectId, selection); + try { + window.localStorage.setItem( + `${MOCK_WORKSPACE_PREFERENCE_PREFIX}${projectId}`, + JSON.stringify(selection), + ); + } catch { + // As with mock rail state, keep live state when storage is unavailable. + } + } + + private readStudioCurrentWorkspace( + projectId: StudioProjectId, + ): StudioCurrentWorkspaceResponse { const failure = typeof window === "undefined" ? null @@ -2543,16 +2565,28 @@ export class MockApi implements HarnessApi { ] : []; }); - const requested = this.studioPreferences.get(projectId); + let requested = this.studioPreferences.get(projectId); + if (!requested) { + try { + const raw = window.localStorage.getItem( + `${MOCK_WORKSPACE_PREFERENCE_PREFIX}${projectId}`, + ); + if (raw) requested = JSON.parse(raw) as StudioWorkspaceSelection; + } catch { + // Missing or unreadable mock preferences use the default workspace. + } + } const valid = - requested?.kind !== "agent" || - agents.some((agent) => agent.agentId === requested.agentId); + requested?.projectId === projectId && + (requested.kind === "agent-map" || + (requested.kind === "agent" && + agents.some((agent) => agent.agentId === requested.agentId))); const repaired = Boolean(requested && !valid); const selection = requested && valid ? requested : { kind: "agent-map" as const, projectId }; - if (repaired) this.studioPreferences.set(projectId, selection); + if (repaired) this.saveStudioPreference(projectId, selection); return parseStudioCurrentWorkspaceResponse( { projectId, selection, agents, repaired }, projectId, @@ -2563,7 +2597,7 @@ export class MockApi implements HarnessApi { projectId: StudioProjectId, requested: StudioWorkspaceSelection, ): Promise { - const current = await this.getStudioCurrentWorkspace(projectId); + const current = this.readStudioCurrentWorkspace(projectId); const valid = requested.projectId === projectId && (requested.kind === "agent-map" || @@ -2571,7 +2605,10 @@ export class MockApi implements HarnessApi { const selection = valid ? requested : { kind: "agent-map" as const, projectId }; - this.studioPreferences.set(projectId, selection); + // Match the persisted server preference across reloads. Commit before + // artificial response latency, as saveRailState does for ordered writes. + this.saveStudioPreference(projectId, selection); + await delay(); return { ...current, selection, repaired: !valid }; } diff --git a/scripts/agent-studio-terminology-allowlist.json b/scripts/agent-studio-terminology-allowlist.json index 5d2b6f3c..722a1090 100644 --- a/scripts/agent-studio-terminology-allowlist.json +++ b/scripts/agent-studio-terminology-allowlist.json @@ -227,8 +227,8 @@ "id": "agent-map-pane-icon-identifier", "path": "packages/harness/web/src/components/AgentMapPane.tsx", "pattern": "^Workflow$", - "occurrences": 2, - "reason": "The design-system Workflow icon identifier remains private in the neutral Agent Map loading and empty states." + "occurrences": 3, + "reason": "The design-system Workflow icon identifier remains private in the Agent Map loading, generating, and empty states." }, { "id": "agent-map-canvas-icon-identifier",