From 9fadbaef0049e064a99ddac6eebff6eb637484c2 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 05:44:43 +0000 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 1aee7faf7be7c6a448fc5fbe382cc19a057ef224 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 6 Sep 2026 22:45:24 +0000 Subject: [PATCH 6/8] 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 7/8] 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 8/8] 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.