From 50f982ab55c1e8fc7dc36a4d3284a0e033436913 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 03:37:59 +0000 Subject: [PATCH 01/10] fix(harness-desktop): update coding agents before sessions start --- .changeset/fresh-studio-agent-clis.md | 15 + packages/harness-desktop/README.md | 43 +++ .../harness-desktop/src/main/agent-install.ts | 16 + .../src/main/agent-update-process.test.ts | 142 +++++++++ .../src/main/agent-update-process.ts | 218 ++++++++++++++ .../src/main/agent-updates.test.ts | 280 ++++++++++++++++++ .../harness-desktop/src/main/agent-updates.ts | 232 +++++++++++++++ packages/harness-desktop/src/main/boot.ts | 44 ++- packages/harness-desktop/src/main/index.ts | 11 +- packages/harness-desktop/src/main/smoke.ts | 43 ++- packages/harness/src/cli/doctor.ts | 4 +- .../harness/src/core/adapters/claude-code.ts | 23 +- packages/harness/src/core/adapters/codex.ts | 23 +- .../src/core/adapters/managed-cli.test.ts | 80 +++++ packages/harness/src/index.ts | 1 + 15 files changed, 1149 insertions(+), 26 deletions(-) create mode 100644 .changeset/fresh-studio-agent-clis.md create mode 100644 packages/harness-desktop/README.md create mode 100644 packages/harness-desktop/src/main/agent-update-process.test.ts create mode 100644 packages/harness-desktop/src/main/agent-update-process.ts create mode 100644 packages/harness-desktop/src/main/agent-updates.test.ts create mode 100644 packages/harness-desktop/src/main/agent-updates.ts create mode 100644 packages/harness/src/core/adapters/managed-cli.test.ts diff --git a/.changeset/fresh-studio-agent-clis.md b/.changeset/fresh-studio-agent-clis.md new file mode 100644 index 000000000..20c94bd05 --- /dev/null +++ b/.changeset/fresh-studio-agent-clis.md @@ -0,0 +1,15 @@ +--- +"@sapiom/harness-desktop": patch +"@sapiom/harness": patch +--- + +Check installed Claude Code and Codex CLIs for updates before desktop sessions +start, so an old local CLI does not keep Studio users on an outdated model +picker. Install updates into isolated Studio-owned directories, verify the +executable before selecting it, and preserve the working version when updates +fail or the device is offline. Existing provider configuration, sign-in, and +conversation history are preserved. + +The harness adapters now accept optional interpreter arguments and environment +overrides, and export `createCodexAdapter`, so a desktop host can launch a +managed JavaScript CLI using its bundled runtime on Windows without system Node. diff --git a/packages/harness-desktop/README.md b/packages/harness-desktop/README.md new file mode 100644 index 000000000..d3e69a31a --- /dev/null +++ b/packages/harness-desktop/README.md @@ -0,0 +1,43 @@ +# Agent Studio desktop + +The Electron app hosts `@sapiom/harness` in a native window. See +[CLAUDE.md](CLAUDE.md) for packaging and platform development instructions. + +## Coding-agent updates + +On a normal launch, Studio checks the installed Claude Code and Codex versions +against their npm `latest` releases before starting agent sessions. It updates +each installed provider independently. If neither provider is available, the +existing setup flow installs Claude Code. + +New versions install into isolated directories under the app's per-user +`agent-versions/` directory. Studio runs the new CLI's version check before +selecting it. Your Homebrew, npm-global, or native installation is not modified; +Studio can use its own newer copy. A newer version detected on PATH takes precedence, +and an unknown external version is left alone. + +Registry checks time out after five seconds. Each installation is stopped after +90 seconds, with bounded cleanup for its child processes. Offline, failed, or interrupted updates +keep the previous working CLI available. Selected installations are reused +offline. Old installation directories are retained because an existing process +may still be using them. + +Updates take effect in new processes, including resumed conversations. They do +not replace a running session. Quit and reopen Studio to check for a newly +released CLI. Authentication, provider configuration, model caches, and +conversation history stay in their existing locations. Studio does not change +your selected model. A model must also be available to your provider account; +updating the CLI does not grant access to a model. + +Studio disables Claude's self-updater for its managed copies and Codex's own +startup update check, since Studio handles their updates before sessions start. +JavaScript CLI launchers use Studio's bundled runtime, including on Windows +machines without Node installed. + +`--dev` and `--smoke` skip update traffic and installation. Setting +`SAPIOM_DISABLE_AGENT_UPDATES=1` does the same for a packaged launch; an already +selected managed installation is still usable. Update progress appears during +startup and in `main.log` under `[boot] agent-update`. + +The desktop app's own GitHub updater and the Sapiom MCP package's periodic +refresh are separate from these coding-agent updates. diff --git a/packages/harness-desktop/src/main/agent-install.ts b/packages/harness-desktop/src/main/agent-install.ts index f6ef9c26c..0a762e2d0 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,21 @@ export function installClaudeCode( return installNpmGlobal(packageSpecFromInstallCommand(CLAUDE_INSTALL_COMMAND), onLine); } +/** Update into an isolated prefix. Both npm network retries and the complete + * process tree are bounded; failure leaves the currently selected CLI intact. */ +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.test.ts b/packages/harness-desktop/src/main/agent-update-process.test.ts new file mode 100644 index 000000000..c00842a00 --- /dev/null +++ b/packages/harness-desktop/src/main/agent-update-process.test.ts @@ -0,0 +1,142 @@ +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("reports missing executables and failed commands without hanging startup", async () => { + const opts = { env: process.env, timeoutMs: 1_000 }; + expect( + ( + await runUpdateCommand( + "studio-deliberately-missing-executable", + [], + opts, + ) + ).ok, + ).toBe(false); + expect( + ( + await runUpdateCommand( + process.execPath, + ["-e", "process.exit(1)"], + opts, + ) + ).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")); + // Signal delivery and OS teardown are asynchronous. Linux may retain a + // dead grandchild as a zombie; missing or zombie both prove it stopped. + 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"); + expect( + ( + await commands.runUpdateCommand( + process.execPath, + ["-e", "process.exit(0)"], + { + env: process.env, + timeoutMs: 1_000, + }, + ) + ).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 000000000..464fadac8 --- /dev/null +++ b/packages/harness-desktop/src/main/agent-update-process.ts @@ -0,0 +1,218 @@ +import { execFile, spawn } from "node:child_process"; + +const running = new Set<() => Promise>(); +let stopping = false; + +/** Called before quitting, including while the setup window is still open. */ +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; +} + +/** Parent PIDs alone are unsafe after npm exits: Windows may reuse the PID. + * Keep only descendants consistent with the original process's 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; + // Open and retain the process handle before reading StartTime. Kill through + // that handle only if it still matches the snapshot, never by a bare PID. + // Only validated numbers enter this script, never paths or package output. + 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; +} + +/** Update work has its own process group, so a deadline stops npm's postinstall + * children too. A timeout must not leave an installer running after boot. */ +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 = spawn(command, args, { + env: options.env, + windowsHide: true, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + 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, + }); + else { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + /* Parent/group already exited. */ + } + } + child.kill("SIGKILL"); + } + // Even failed OS cleanup cannot make startup wait forever for a pipe + // retained by a descendant. The incomplete prefix is never selected. + 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 000000000..b0a9fb3ce --- /dev/null +++ b/packages/harness-desktop/src/main/agent-updates.test.ts @@ -0,0 +1,280 @@ +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 { + AGENT_PACKAGES, + cliVersion, + ensureAgentUpdates, + isNewerStable, + resolveAgentCommand, + type AgentCommand, + type AgentKind, + type AgentUpdateOptions, +} from "./agent-updates.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(); + // The published selector is the only mutable pointer; installation is not + // performed over the external binary or over a shared npm-global prefix. + 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 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); + }, + ); + + 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 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); + }); + + 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).toEqual([ + 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 000000000..0db0ae3b3 --- /dev/null +++ b/packages/harness-desktop/src/main/agent-updates.ts @@ -0,0 +1,232 @@ +import { randomUUID } from "node:crypto"; +import { access, mkdir, readFile, rename, writeFile } 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; + +/** Mirrors the adapter's optional interpreter prefix. npm's Codex entry is JS; + * on Windows a Node-less desktop cannot launch its .cmd through node-pty. */ +export interface AgentCommand { + binary: string; + binaryArgs: string[]; + binaryEnv: Record; +} +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; +} + +/** The registry target is stable. Keep a newer local/beta build; promote a + * pre-release only when the same core version has reached stable. */ +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; +} + +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, entry], + binaryEnv: { ...runtime.binaryEnv, ...binaryEnv }, + } + : { binary: entry, binaryArgs: [], binaryEnv }; + } catch { + /* Try the other global npm layout. */ + } + } + return null; +} + +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; +} + +/** Check each detected CLI on every normal boot, before any session exists. + * Install into a fresh, immutable prefix, verify the actual executable, then + * atomically select it. Never mutate a working global or managed installation. + * Old versions are retained: an external process may still be using one. */ +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; + 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; + } + } + // Missing agents still use boot's existing default-agent installation flow. + // An unknown local version is not evidence that replacing it is an upgrade. + 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(); + 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"); + // The selection is the commit point. Interrupted downloads/installs cannot + // replace it, and no version's files move after npm writes its launchers. + const temp = path.join(parent, `active-${randomUUID()}.json`); + options.signal?.throwIfAborted(); + await writeFile( + temp, + JSON.stringify({ version: latest, directory } satisfies Selection), + { flag: "wx" }, + ); + options.signal?.throwIfAborted(); + await rename(temp, path.join(parent, "active.json")); + 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)}`, + ); + } + } + return selected; +} diff --git a/packages/harness-desktop/src/main/boot.ts b/packages/harness-desktop/src/main/boot.ts index 2fe07e72a..544d3e54e 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/smoke.ts b/packages/harness-desktop/src/main/smoke.ts index 8dd4583a2..36cf2b48a 100644 --- a/packages/harness-desktop/src/main/smoke.ts +++ b/packages/harness-desktop/src/main/smoke.ts @@ -43,14 +43,15 @@ * so a CI log shows exactly which layer broke. */ import { execFile } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; 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 "./agent-updates.js"; import { createSetupWindow } from "./windows.js"; import { resolveWebDir } from "./paths.js"; import { shimDir } from "./runtime-shims.js"; @@ -80,6 +81,43 @@ async function check(name: string, fn: () => Promise): 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"), "console.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, token: string | null, expectStatus: number): Promise { const res = await fetch(url, { @@ -917,6 +955,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 bb4ee9223..ac9ebcb90 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 0f13b5991..7113da877 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 @@ -420,11 +423,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; } @@ -444,7 +451,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 [ @@ -477,11 +486,11 @@ export class ClaudeCodeAdapter implements HarnessAdapter { } 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, }; } @@ -493,8 +502,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, }; } @@ -526,8 +535,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 215e352e2..4c7d12c09 100644 --- a/packages/harness/src/core/adapters/codex.ts +++ b/packages/harness/src/core/adapters/codex.ts @@ -147,6 +147,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; } @@ -277,16 +280,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 [ @@ -302,12 +311,12 @@ export class CodexAdapter implements HarnessAdapter { launch(opts: LaunchOpts): SpawnSpec { return { command: this.binary, - args: buildConfigArgs(opts), + args: [...this.binaryArgs, ...buildConfigArgs(opts)], // 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, }; } @@ -315,10 +324,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 000000000..406c4bebe --- /dev/null +++ b/packages/harness/src/core/adapters/managed-cli.test.ts @@ -0,0 +1,80 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +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 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 }; + 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"); + } 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, + }); + }); +}); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 8330ab4f9..7d28222b8 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -82,6 +82,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, From 9fadbaef0049e064a99ddac6eebff6eb637484c2 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 05:44:43 +0000 Subject: [PATCH 02/10] feat(harness-desktop): install and launch isolated agent CLIs --- .changeset/managed-agent-runtime.md | 8 + .../harness-desktop/src/main/agent-install.ts | 15 ++ .../src/main/agent-update-process.test.ts | 132 +++++++++++ .../src/main/agent-update-process.ts | 212 ++++++++++++++++++ .../harness-desktop/src/main/managed-agent.ts | 52 +++++ packages/harness-desktop/src/main/smoke.ts | 42 +++- .../harness/src/core/adapters/claude-code.ts | 23 +- packages/harness/src/core/adapters/codex.ts | 23 +- .../src/core/adapters/managed-cli.test.ts | 80 +++++++ packages/harness/src/index.ts | 1 + 10 files changed, 572 insertions(+), 16 deletions(-) create mode 100644 .changeset/managed-agent-runtime.md create mode 100644 packages/harness-desktop/src/main/agent-update-process.test.ts create mode 100644 packages/harness-desktop/src/main/agent-update-process.ts create mode 100644 packages/harness-desktop/src/main/managed-agent.ts create mode 100644 packages/harness/src/core/adapters/managed-cli.test.ts diff --git a/.changeset/managed-agent-runtime.md b/.changeset/managed-agent-runtime.md new file mode 100644 index 000000000..9fe7e4898 --- /dev/null +++ b/.changeset/managed-agent-runtime.md @@ -0,0 +1,8 @@ +--- +"@sapiom/harness": patch +"@sapiom/harness-desktop": patch +--- + +Support isolated coding-agent installs and bundled-runtime launches, including +resume. Add optional adapter interpreter arguments/environment and export +`createCodexAdapter`. Bound installer processes and verify packaged CLI launches. diff --git a/packages/harness-desktop/src/main/agent-install.ts b/packages/harness-desktop/src/main/agent-install.ts index f6ef9c26c..9a6037f40 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.test.ts b/packages/harness-desktop/src/main/agent-update-process.test.ts new file mode 100644 index 000000000..f5b295939 --- /dev/null +++ b/packages/harness-desktop/src/main/agent-update-process.test.ts @@ -0,0 +1,132 @@ +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("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 000000000..bc2199746 --- /dev/null +++ b/packages/harness-desktop/src/main/agent-update-process.ts @@ -0,0 +1,212 @@ +import { execFile, spawn } from "node:child_process"; + +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 = spawn(command, args, { + env: options.env, + windowsHide: true, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + 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, + }); + else { + 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/managed-agent.ts b/packages/harness-desktop/src/main/managed-agent.ts new file mode 100644 index 000000000..9bfc2630c --- /dev/null +++ b/packages/harness-desktop/src/main/managed-agent.ts @@ -0,0 +1,52 @@ +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, 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 8dd4583a2..5ee90c82a 100644 --- a/packages/harness-desktop/src/main/smoke.ts +++ b/packages/harness-desktop/src/main/smoke.ts @@ -43,14 +43,15 @@ * so a CI log shows exactly which layer broke. */ import { execFile } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; 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"; @@ -80,6 +81,42 @@ async function check(name: string, fn: () => Promise): 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"), "console.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, token: string | null, expectStatus: number): Promise { const res = await fetch(url, { @@ -917,6 +954,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/core/adapters/claude-code.ts b/packages/harness/src/core/adapters/claude-code.ts index 0f13b5991..7113da877 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 @@ -420,11 +423,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; } @@ -444,7 +451,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 [ @@ -477,11 +486,11 @@ export class ClaudeCodeAdapter implements HarnessAdapter { } 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, }; } @@ -493,8 +502,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, }; } @@ -526,8 +535,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 215e352e2..4c7d12c09 100644 --- a/packages/harness/src/core/adapters/codex.ts +++ b/packages/harness/src/core/adapters/codex.ts @@ -147,6 +147,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; } @@ -277,16 +280,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 [ @@ -302,12 +311,12 @@ export class CodexAdapter implements HarnessAdapter { launch(opts: LaunchOpts): SpawnSpec { return { command: this.binary, - args: buildConfigArgs(opts), + args: [...this.binaryArgs, ...buildConfigArgs(opts)], // 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, }; } @@ -315,10 +324,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 000000000..406c4bebe --- /dev/null +++ b/packages/harness/src/core/adapters/managed-cli.test.ts @@ -0,0 +1,80 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +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 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 }; + 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"); + } 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, + }); + }); +}); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 8330ab4f9..7d28222b8 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -82,6 +82,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, From 344b61159d7df7bab5526e7c206c2e8d2803dde3 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 21:47:59 +0000 Subject: [PATCH 03/10] test(harness): account for initialization icon and canonical temp paths --- .../harness/src/core/adapters/managed-cli.test.ts | 15 ++++++++++++--- scripts/agent-studio-terminology-allowlist.json | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/harness/src/core/adapters/managed-cli.test.ts b/packages/harness/src/core/adapters/managed-cli.test.ts index d2df19d53..4ac0192af 100644 --- a/packages/harness/src/core/adapters/managed-cli.test.ts +++ b/packages/harness/src/core/adapters/managed-cli.test.ts @@ -1,5 +1,12 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +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"; @@ -11,7 +18,9 @@ import { CodexAdapter } from "./codex.js"; let root: string; let entry: string; beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), "managed CLI with spaces ")); + root = await realpath( + await mkdtemp(join(tmpdir(), "managed CLI with spaces ")), + ); entry = join(root, "cli.cjs"); await writeFile( entry, @@ -172,7 +181,7 @@ require('node:readline').createInterface({input: process.stdin}).on('line', (lin }, }); expect(spec.args.slice(1)).toEqual([process.execPath, entry]); - // Use the source worker under the test loader; packaged smoke exercises its emitted JS. + // 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( diff --git a/scripts/agent-studio-terminology-allowlist.json b/scripts/agent-studio-terminology-allowlist.json index 5d2b6f3cc..722a10901 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", From 44fb9ec823f15d8689269aec221cc8516dbe15eb Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 22:04:45 +0000 Subject: [PATCH 04/10] test(harness): await published project session before submitting input --- .../harness/src/server/agent-map-mcp-wiring.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 e6648b9e7..8b30c3f24 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: { From 40fe3e80fd5c79cd26b2caf38dee4e158a9f67cf Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 22:22:02 +0000 Subject: [PATCH 05/10] fix(harness): stabilize terminal teardown and mock workspace restoration --- .changeset/studio-terminal-teardown.md | 7 +++ .../harness/web/src/components/Terminal.tsx | 13 +++-- packages/harness/web/src/lib/api.ts | 49 ++++++++++++++++--- 3 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 .changeset/studio-terminal-teardown.md diff --git a/.changeset/studio-terminal-teardown.md b/.changeset/studio-terminal-teardown.md new file mode 100644 index 000000000..4e697f031 --- /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/packages/harness/web/src/components/Terminal.tsx b/packages/harness/web/src/components/Terminal.tsx index 47a6893e8..cb7c47f80 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 cdb06d1a4..3b2a6b91b 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 }; } From 56c4cdc8b2c9c85ba6bb8d21d225b77c18de3dbf Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 22:40:43 +0000 Subject: [PATCH 06/10] fix(harness-desktop): contain runtime state and own installer process groups --- .changeset/desktop-managed-installs.md | 7 ++ .changeset/managed-agent-runtime.md | 9 ++- .../agent-update-process-ownership.test.ts | 27 ++++++++ .../src/main/agent-update-process.test.ts | 12 ++++ .../src/main/agent-update-process.ts | 47 ++++++++++--- .../src/main/managed-agent.test.ts | 67 +++++++++++++++++++ .../harness-desktop/src/main/managed-agent.ts | 10 ++- packages/harness-desktop/src/main/smoke.ts | 2 +- 8 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 .changeset/desktop-managed-installs.md create mode 100644 packages/harness-desktop/src/main/agent-update-process-ownership.test.ts create mode 100644 packages/harness-desktop/src/main/managed-agent.test.ts diff --git a/.changeset/desktop-managed-installs.md b/.changeset/desktop-managed-installs.md new file mode 100644 index 000000000..753decdd1 --- /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/managed-agent-runtime.md b/.changeset/managed-agent-runtime.md index 612a26eb8..b58d12e7c 100644 --- a/.changeset/managed-agent-runtime.md +++ b/.changeset/managed-agent-runtime.md @@ -1,8 +1,7 @@ --- -"@sapiom/harness": patch -"@sapiom/harness-desktop": patch +"@sapiom/harness": minor --- -Support isolated coding-agent installs and bundled-runtime launches, including -resume and private Agent Map initialization. Add optional adapter interpreter arguments/environment and export -`createCodexAdapter`. Bound installer processes and verify packaged CLI launches. +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/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 000000000..6b505d9ed --- /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 index f5b295939..0bbc0e88b 100644 --- a/packages/harness-desktop/src/main/agent-update-process.test.ts +++ b/packages/harness-desktop/src/main/agent-update-process.test.ts @@ -48,6 +48,18 @@ describe("Windows update process identity", () => { }); 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( diff --git a/packages/harness-desktop/src/main/agent-update-process.ts b/packages/harness-desktop/src/main/agent-update-process.ts index bc2199746..862146015 100644 --- a/packages/harness-desktop/src/main/agent-update-process.ts +++ b/packages/harness-desktop/src/main/agent-update-process.ts @@ -1,5 +1,28 @@ 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; @@ -140,12 +163,17 @@ export function runUpdateCommand( let detail = ""; let timedOut = false; const startedAt = Date.now(); - const child = spawn(command, args, { - env: options.env, - windowsHide: true, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - }); + 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", () => { @@ -163,21 +191,22 @@ export function runUpdateCommand( const stop = (reason: string): Promise => { cleanup ??= (async () => { if (child.pid) { - if (process.platform === "win32") + if (process.platform === "win32") { await stopWindowsTree({ pid: child.pid, startedAt, spawnedAt, exitedAt, }); - else { + child.kill("SIGKILL"); + } else if (exitedAt === undefined) { try { process.kill(-child.pid, "SIGKILL"); } catch { /* Parent/group already exited. */ } + child.kill("SIGKILL"); } - child.kill("SIGKILL"); } // Descendants may retain pipes even after their parent exits. child.stdout.destroy(); 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 000000000..e9f030466 --- /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 index 9bfc2630c..809b0b78d 100644 --- a/packages/harness-desktop/src/main/managed-agent.ts +++ b/packages/harness-desktop/src/main/managed-agent.ts @@ -40,7 +40,15 @@ export async function resolveAgentCommand( return /\.[cm]?js$/i.test(entry) ? { binary: runtime.binary, - binaryArgs: [...runtime.binaryArgs, entry], + 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 }; diff --git a/packages/harness-desktop/src/main/smoke.ts b/packages/harness-desktop/src/main/smoke.ts index b702ef520..1e6c40f2f 100644 --- a/packages/harness-desktop/src/main/smoke.ts +++ b/packages/harness-desktop/src/main/smoke.ts @@ -104,7 +104,7 @@ async function checkManagedAgent(): Promise { 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"), "console.log(process.argv.includes('--version') ? '99.0.0' : 'managed-codex-ready');\n"); + 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" }, }); From 39c3e640aaff884ead51d32ee37435006c06441d Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 22:42:41 +0000 Subject: [PATCH 07/10] fix(harness-desktop): clean unpublished provider update attempts --- .changeset/fresh-studio-agent-clis.md | 1 + packages/harness-desktop/README.md | 4 +++- .../harness-desktop/src/main/agent-updates.test.ts | 8 ++++++-- packages/harness-desktop/src/main/agent-updates.ts | 14 +++++++++++++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.changeset/fresh-studio-agent-clis.md b/.changeset/fresh-studio-agent-clis.md index 932978871..0adacdd82 100644 --- a/.changeset/fresh-studio-agent-clis.md +++ b/.changeset/fresh-studio-agent-clis.md @@ -5,3 +5,4 @@ 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/packages/harness-desktop/README.md b/packages/harness-desktop/README.md index 1e8f0ba9a..731ecab06 100644 --- a/packages/harness-desktop/README.md +++ b/packages/harness-desktop/README.md @@ -9,7 +9,9 @@ On each normal launch, Studio checks installed Claude Code and Codex against npm 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 directories remain for running processes. +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 diff --git a/packages/harness-desktop/src/main/agent-updates.test.ts b/packages/harness-desktop/src/main/agent-updates.test.ts index 96dc2d5fe..2d94899fb 100644 --- a/packages/harness-desktop/src/main/agent-updates.test.ts +++ b/packages/harness-desktop/src/main/agent-updates.test.ts @@ -164,6 +164,7 @@ describe("startup CLI updates", () => { 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", @@ -180,6 +181,7 @@ describe("startup CLI updates", () => { expect( await readFile(path.join(root, "codex", "active.json"), "utf8"), ).toBe(pointer); + expect(await readdir(path.join(root, "codex"))).toEqual(filesBefore); }, ); @@ -193,6 +195,7 @@ describe("startup CLI updates", () => { 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", @@ -209,6 +212,7 @@ describe("startup CLI updates", () => { 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 () => { @@ -261,9 +265,9 @@ describe("startup CLI updates", () => { binaryEnv: { ELECTRON_RUN_AS_NODE: "1" }, }); expect(command?.binary).toBe(process.execPath); - expect(command?.binaryArgs).toEqual([ + 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"); }); diff --git a/packages/harness-desktop/src/main/agent-updates.ts b/packages/harness-desktop/src/main/agent-updates.ts index 18ad4b47c..fc13553e2 100644 --- a/packages/harness-desktop/src/main/agent-updates.ts +++ b/packages/harness-desktop/src/main/agent-updates.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import * as path from "node:path"; import { @@ -98,6 +98,8 @@ export async function ensureAgentUpdates( 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); @@ -153,6 +155,7 @@ export async function ensureAgentUpdates( 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))) @@ -163,6 +166,7 @@ export async function ensureAgentUpdates( // 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), @@ -170,12 +174,20 @@ export async function ensureAgentUpdates( ); 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; From 1aee7faf7be7c6a448fc5fbe382cc19a057ef224 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 22:45:24 +0000 Subject: [PATCH 08/10] ci(harness): allow expanded browser suites to finish --- .github/workflows/harness.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index 929258351..9e05471f3 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: 25 # 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: From 3ef5843848ab36d0b261b541b0d2d45bb570c547 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 22:50:12 +0000 Subject: [PATCH 09/10] ci(harness): keep browser checks within the repository time budget --- .github/workflows/harness.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index 9e05471f3..a68c2e55b 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -63,7 +63,7 @@ jobs: runs-on: ubuntu-latest # 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: 25 + 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: From e89c69d48923b886f81db8df2bfaa354fb82d69a Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 22:51:10 +0000 Subject: [PATCH 10/10] docs(release): align combined Studio notes with shipped behavior --- .changeset/atomic-project-state-migration.md | 2 +- .changeset/bootstrap-coordinator.md | 2 +- .changeset/bootstrap-storage.md | 2 +- .changeset/project-session-shortcut.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/atomic-project-state-migration.md b/.changeset/atomic-project-state-migration.md index 76d51cff8..493b56ccd 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 f41f8b942..1becc7796 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 8ecf9f68c..f0210715e 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/project-session-shortcut.md b/.changeset/project-session-shortcut.md index c64a29b08..0b94b59e9 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.