From 1e0b9aa76a0d166578dd24acbf5133e737df38fc Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 6 Aug 2026 23:47:09 -0400 Subject: [PATCH 1/9] fix: terminate tool index process trees --- .../2026-08-06-admin-mac-rudi-cleanup.md | 237 ++++++++++++++++++ package.json | 2 +- .../src/__tests__/unit/tool-index.test.js | 138 ++++++++++ packages/core/src/tool-index.js | 179 ++++++++++--- 4 files changed, 517 insertions(+), 39 deletions(-) create mode 100644 docs/swe-compliance/2026-08-06-admin-mac-rudi-cleanup.md diff --git a/docs/swe-compliance/2026-08-06-admin-mac-rudi-cleanup.md b/docs/swe-compliance/2026-08-06-admin-mac-rudi-cleanup.md new file mode 100644 index 0000000..d2dbd32 --- /dev/null +++ b/docs/swe-compliance/2026-08-06-admin-mac-rudi-cleanup.md @@ -0,0 +1,237 @@ +# Admin Mac RUDI Cleanup And Index Lifecycle Repair + +## Phase 0: Baseline And Manual Lookup + +- Status: complete. +- Scope: execute the 2026-08-07 Admin Mac audit as an archive-first cleanup, + repair the `rudi index --force` process leak, and prove the active Service + Desk ingestion system is unchanged. +- Files to inspect before editing: + - `AGENTS.md` + - `packages/core/src/tool-index.js` + - `packages/core/src/__tests__/unit/tool-index.test.js` + - `src/commands/index-tools.js` + - existing command tests for the index lifecycle + - `packages/env/src/index.js`, `src/commands/home.js`, and their tests only + if the baseline confirms an active migration or inventory defect + - Compute, Registry, and Service Desk instructions and version files before + any release or installed-code change +- Relevant SWE manual sections: + - Appendix C7A, agent-assisted red-green-refactor. + - Appendix D, reproduce/localize/hypothesis/minimal correction. + - Appendix F3 and F9, user-only sensitive storage and least privilege. + - Appendix H4-H6, controlled deployment, rollback units, and complete + runtime lifecycle termination. + - Appendix H9-H10, post-change observability and operational safety. +- Current-state evidence to capture: + - local and Admin Mac git status, exact commits, installed CLI version, and + installed package provenance + - process tree for all stale index wrappers and descendant MCP servers + - `rudi daemon status --json`, `rudi doctor --json`, `rudi home --json`, and + LaunchAgent state + - canonical and legacy path sizes, ownership, modes, checksums, and mounts + - Service Desk checkpoint, database integrity/counts, artifact count, + ingestion timestamps, and current logs +- Risks and invariants: + - Never expose or copy secret values into logs or the manifest. + - Preserve every canonical path listed in the audit. + - Preserve the Compute external layout and MLB product state. + - Preserve unrelated dirty work in the local CLI checkout. + - Treat `rudi.db`, `rudi.db-wal`, and `rudi.db-shm` as one recovery unit. + - No live path is removed before a coherent archive is created and verified. + - A failed archive, health check, or ownership check stops the affected + cleanup step without continuing to deletion. +- Baseline evidence: + - Source checkouts are clean at CLI `b4dd2c68`, Compute `c55948da`, Service + Desk `cf53adfc`, and Registry `f73276b6`; the local CLI and Compute source + commits match the Admin Mac, while unrelated local CLI edits remain dirty. + - Installed CLI reports `1.10.14`, but its wrapper/package carries no source + commit provenance. Compute source is `c55948da` while the running release + path identifies `0.3.1-c6b020e`. + - Three stale index process groups remain: PGID 6904 (49 processes), PGID + 10271 (38), and PGID 10515 (36), for 123 processes total. Each group was + verified by ancestry and contains an old index command plus only its + wrapper, pipe, and spawned stack descendants. + - Daemon is ready on PID 46492 with 397 tools, but runs system Node 25.9.0; + managed Node is 20.10.0. + - Service Desk worker is running on PID 30490. Organization SQLite + `quick_check` is `ok` in WAL mode; baseline counts include 802 + conversations, 1,327 interactions, 3,957 email artifacts, 1,512 source + receipts, one email source checkpoint, and 3,957 artifact files. + - The redundant business intake and four editorial jobs are loaded, not + running, and repeatedly exit 1. The two kept MLB jobs were not selected. + - Canonical Service Desk/organization directories are already mode 700 and + database files mode 600. The RUDI root, runtimes, stacks, singular output, + legacy registry/automation roots, retired database files, and Compute + directories are broader than user-only and require scoped hardening. +- Exit criteria: complete; no secret values were captured, canonical state is + identified, and the exact process/file/service delta is recorded. + +## Phase 1: Scope Lock + +- Status: complete. +- In scope: + - terminate the three stale index command process trees + - fix and test complete stack-server process-tree cleanup and bounded index + command completion + - unload and archive only the five explicitly retired LaunchAgents + - archive and retire only the legacy paths approved in the audit + - consolidate recovery data under a documented retention policy + - reconcile exact Compute and CLI source/install versions where the source + state is clean and safely promotable + - pin the daemon to the managed Node runtime and harden sensitive directory + modes to user-only access + - rebuild the index once and verify it leaves no descendants +- Non-goals: + - no deletion or relocation of canonical Service Desk, organization, + registry, cache, router, bin, runtime, stack, or secret paths + - no modification of MLB state or healthy `com.hoff.mlb.*` jobs + - no manual deletion of installed stacks; capability-profile stack removal + remains a separate user decision + - no speculative shared dependency-store redesign + - no removal of the final Service Desk rollback unit during this run +- Expected files touched: + - `packages/core/src/tool-index.js` + - `packages/core/src/__tests__/unit/tool-index.test.js` + - `src/commands/index-tools.js` and a focused command test only if a + command-level bound is needed after core cleanup is fixed + - this checklist + - generated `dist/` only after protecting unrelated local changes or in an + isolated clean build tree +- External inputs and trust boundaries: + - launch configurations and child PIDs are untrusted runtime inputs + - filesystem targets must be explicit absolute paths below `/Users/admin` + - process selection must be derived from verified command ancestry, never a + broad name-only kill +- Failure behavior to define: + - timeout closes stdin/readline, terminates the complete process group, + escalates after a bounded grace period, awaits exit, rejects pending RPCs, + and resolves exactly once + - archive verification failure preserves all source paths + - failed post-change health checks trigger rollback of the affected service + or filesystem unit +- Exit criteria: complete; exact mutation targets, rollback units, test + interfaces, and deferrals are recorded before the first destructive action. + +## Phase 2: Red Tests + +- Status: complete. +- Observable behavior to prove: + - successful discovery and timeout both return only after the spawned stack + process tree has exited + - timeout cannot leave wrapper or descendant processes running + - cleanup is idempotent across process error/exit/timeout races + - the whole index command has a bounded completion path if per-stack + cleanup alone cannot guarantee it +- Test files to add or edit: + - `packages/core/src/__tests__/unit/tool-index.test.js` + - a focused `src/__tests__/unit` index-command test only if required +- Red command: + - `node scripts/run-tests.js packages/core/src/__tests__/unit/tool-index.test.js` +- Expected failure: the current implementation resolves while the wrapper or + descendant remains alive because it signals only the direct child and does + not await complete termination. +- Evidence: the new process-tree test failed with `true !== false` because both + the uncooperative stack wrapper and its descendant remained alive after + discovery returned. +- Exit criteria: complete; one deterministic behavior-level test failed for + the expected reason. + +## Phase 3: Implementation + +- Status: complete. +- Implementation rules: make the smallest lifecycle correction; add no + dependency; follow existing plain-JavaScript patterns. +- Files allowed to change: only the scope-locked source and test files. +- Validation and error handling requirements: + - validate positive finite timeout/grace values + - guard invalid/missing PIDs and already-exited children + - make signal escalation bounded and race-safe + - avoid signaling the caller's process group +- Observability requirements: timeout and termination failures must produce + actionable stack-specific error context without logging secrets. +- Implementation: POSIX stack servers now start in dedicated process groups. + Every success, error, and timeout path converges on one race-safe cleanup + routine that closes RPC resources, signals the whole group, waits, escalates + to SIGKILL after a bounded grace period, waits again, and only then resolves. + Invalid duration values fall back to finite defaults. +- Exit criteria: complete; the unchanged red command passes. + +## Phase 4: Green Tests And Refactor + +- Status: complete. +- Green command: rerun the exact Phase 2 command. +- Refactor constraints: refactor only lifecycle code exercised by the test. +- Regression checks: run existing tool-index, daemon operation, update, and + index-command tests. +- Evidence: + - unchanged focused command: 3/3 tests passed after adding the timeout + process-tree scenario + - adjacent tool-index, stack lifecycle, daemon operation, and update suites: + 34/34 passed + - full clean Admin Mac CLI suite: 617 passed, 0 failed +- Exit criteria: complete; focused and adjacent tests pass after the helper + refactor and timeout coverage. + +## Phase 5: Full Verification + +- Status: in progress. +- Targeted tests: core tool-index and index command/daemon operation suites. +- Full suite: `pnpm test` in a state that does not mix unrelated dirty work. +- Build/typecheck/lint: `pnpm build`, `npm pack --dry-run`, and + `git diff --check`; protect the pre-existing dirty `dist/index.cjs` by + building in an isolated tree if necessary. +- JS/TS debt scan: + - `node scripts/agent-debt-runner.mjs --edited ` +- Live smoke checks: + - install/promote one traceable CLI build only after source verification + - run exactly one forced index rebuild with an outer watchdog + - verify command exit, cache validity, and zero surviving descendants + - verify daemon/Compute/Service Desk/LaunchAgent health and user-only modes +- Exit criteria: tests/build/package/debt checks pass and the Admin Mac live + smoke leaves no orphaned child processes. +- Evidence so far: + - full clean Admin Mac suite: 617 passed, 0 failed + - architecture-aware edited-file debt scan: zero findings + - source/install mismatch localized: clean source declared `1.10.12`, the + installed package declared uncommitted `1.10.14`; the repaired release is + therefore versioned `1.10.15` for exact-commit traceability + +## Phase 6: Docs, Contracts, And Closure + +- Status: pending. +- Docs or API contracts to update: only lifecycle/operational documentation + whose verified behavior changed; preserve Service Desk's ingestion-only + boundary. +- Final files touched: record after implementation. +- Commands run and results: record red, green, refactor, build, debt, archive, + permission, release, and live-smoke evidence. +- Accepted debt: record any stack capability-profile decision, dependency + storage redesign, Compute lifecycle integration, or expired archive removal + intentionally deferred. +- Definition of Done: + - [ ] stale index trees are gone + - [ ] lifecycle regression test proves cleanup before resolution + - [ ] relevant source verification passes + - [ ] retired LaunchAgents are unloaded and archived + - [ ] approved legacy paths are archived, verified, and absent from live root + - [ ] canonical, Compute, and MLB state is preserved + - [ ] installed releases are traceable to exact commits or the unresolved + release is explicitly blocked with evidence + - [ ] permissions are user-only at sensitive boundaries + - [ ] one index rebuild exits cleanly without descendants + - [ ] Service Desk checkpoint, counts, artifacts, Gmail polling, and health + match the baseline + +## Rollback Units + +1. Source repair: revert only the lifecycle commit/build and reinstall the + previously captured CLI package. +2. LaunchAgents: restore the archived plist to its original path and bootstrap + it only if the corresponding retired workflow is intentionally re-enabled. +3. Legacy filesystem: restore the verified archive to its original absolute + path while affected services are stopped. +4. Retired database: restore `rudi.db`, `rudi.db-wal`, and `rudi.db-shm` + together from the same archive; never restore one member independently. +5. Permissions: restore only the recorded pre-change modes if a verified + consumer cannot operate under user-only access. diff --git a/package.json b/package.json index 82293cb..23c97f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@learnrudi/cli", - "version": "1.10.12", + "version": "1.10.15", "packageManager": "pnpm@10.22.0", "description": "RUDI CLI - Install and manage local MCP stacks, runtimes, daemon lifecycle, and agent router integrations", "type": "module", diff --git a/packages/core/src/__tests__/unit/tool-index.test.js b/packages/core/src/__tests__/unit/tool-index.test.js index c7fd83b..2ea14eb 100644 --- a/packages/core/src/__tests__/unit/tool-index.test.js +++ b/packages/core/src/__tests__/unit/tool-index.test.js @@ -10,6 +10,144 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '../../../../..'); const toolIndexUrl = pathToFileURL(path.join(repoRoot, 'packages/core/src/tool-index.js')).href; +function runProcessTreeScenario(mode, timeout) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-tool-index-tree-')); + const rudiHome = path.join(root, '.rudi'); + const fixturePath = path.join(root, 'stack-server.mjs'); + const pidPath = path.join(root, 'stack-server-pids.json'); + + try { + fs.writeFileSync(fixturePath, ` + import { spawn } from 'node:child_process'; + import fs from 'node:fs'; + import readline from 'node:readline'; + + const pidPath = process.argv[2]; + const shouldRespond = process.argv[3] === 'respond'; + const descendant = spawn(process.execPath, [ + '--input-type=module', + '-e', + 'process.on("SIGTERM", () => {}); setInterval(() => {}, 1000);', + ], { stdio: 'ignore' }); + + process.on('SIGTERM', () => {}); + fs.writeFileSync(pidPath, JSON.stringify({ + parent: process.pid, + descendant: descendant.pid, + })); + + const input = readline.createInterface({ input: process.stdin }); + input.on('line', (line) => { + if (!shouldRespond) return; + const request = JSON.parse(line); + if (request.method === 'initialize') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { protocolVersion: '2024-11-05', capabilities: {} }, + }) + '\\n'); + } else if (request.method === 'tools/list') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { tools: [{ name: 'fixture_tool' }] }, + }) + '\\n'); + } + }); + setInterval(() => {}, 1000); + `); + + const script = ` + const fs = await import('node:fs'); + const { discoverStackTools } = await import(process.argv[1]); + const fixturePath = process.argv[2]; + const pidPath = process.argv[3]; + const mode = process.argv[4]; + const timeout = Number(process.argv[5]); + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + const isAlive = pid => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw error; + } + }; + + let pids; + try { + const result = await discoverStackTools('stack:fixture', { + installed: true, + path: process.cwd(), + launch: { + bin: process.execPath, + args: [fixturePath, pidPath, mode], + cwd: process.cwd(), + }, + }, { timeout, terminationGraceMs: 100 }); + pids = JSON.parse(fs.readFileSync(pidPath, 'utf8')); + await sleep(100); + console.log(JSON.stringify({ + result, + parentAlive: isAlive(pids.parent), + descendantAlive: isAlive(pids.descendant), + })); + } finally { + if (pids) { + for (const pid of [pids.parent, pids.descendant]) { + try { process.kill(pid, 'SIGKILL'); } catch {} + } + } + } + `; + + const output = execFileSync(process.execPath, [ + '--input-type=module', + '-e', + script, + toolIndexUrl, + fixturePath, + pidPath, + mode, + String(timeout), + ], { + cwd: repoRoot, + env: { + ...process.env, + RUDI_HOME: rudiHome, + }, + encoding: 'utf8', + timeout: 10000, + }); + + const observed = JSON.parse(output); + return observed; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test('discoverStackTools waits for the complete stack process tree to exit', { + skip: process.platform === 'win32', +}, () => { + const observed = runProcessTreeScenario('respond', 2000); + assert.equal(observed.result.error, null); + assert.deepEqual(observed.result.tools.map(tool => tool.name), ['fixture_tool']); + assert.equal(observed.parentAlive, false); + assert.equal(observed.descendantAlive, false); +}); + +test('discoverStackTools timeout terminates the complete stack process tree', { + skip: process.platform === 'win32', +}, () => { + const observed = runProcessTreeScenario('timeout', 100); + assert.equal(observed.result.error, 'Timeout after 100ms'); + assert.deepEqual(observed.result.tools, []); + assert.equal(observed.parentAlive, false); + assert.equal(observed.descendantAlive, false); +}); + test('removeStackFromToolIndex prunes one cached stack entry', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-tool-index-')); const rudiHome = path.join(root, '.rudi'); diff --git a/packages/core/src/tool-index.js b/packages/core/src/tool-index.js index 5eeab11..d9436e5 100644 --- a/packages/core/src/tool-index.js +++ b/packages/core/src/tool-index.js @@ -27,6 +27,9 @@ const TOOL_INDEX_TMP = path.join(RUDI_HOME, 'cache', 'tool-index.json.tmp'); const SECRETS_PATH = path.join(RUDI_HOME, 'secrets.json'); const REQUEST_TIMEOUT_MS = 15000; +const PROCESS_TERMINATION_GRACE_MS = 500; +const PROCESS_KILL_WAIT_MS = 1000; +const PROCESS_EXIT_POLL_MS = 25; const PROTOCOL_VERSION = '2024-11-05'; // ============================================================================= @@ -136,6 +139,63 @@ function prependRudiExecutionPath(env) { env.PATH = entries.join(path.delimiter); } +function positiveDuration(value, fallback) { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +function processTreeIsAlive(childProcess) { + if (!Number.isInteger(childProcess?.pid) || childProcess.pid <= 1) return false; + + const target = process.platform === 'win32' ? childProcess.pid : -childProcess.pid; + try { + process.kill(target, 0); + return true; + } catch (error) { + if (error?.code === 'ESRCH') return false; + if (error?.code === 'EPERM') return true; + throw error; + } +} + +function signalProcessTree(childProcess, signal) { + if (!Number.isInteger(childProcess?.pid) || childProcess.pid <= 1) return false; + + const target = process.platform === 'win32' ? childProcess.pid : -childProcess.pid; + try { + process.kill(target, signal); + return true; + } catch (error) { + if (error?.code === 'ESRCH') return false; + throw error; + } +} + +async function waitForProcessTreeExit(childProcess, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (processTreeIsAlive(childProcess)) { + const remaining = deadline - Date.now(); + if (remaining <= 0) return false; + await new Promise(resolve => setTimeout(resolve, Math.min(PROCESS_EXIT_POLL_MS, remaining))); + } + return true; +} + +async function terminateProcessTree(childProcess, options = {}) { + if (!Number.isInteger(childProcess?.pid) || childProcess.pid <= 1) return; + + const graceMs = positiveDuration(options.graceMs, PROCESS_TERMINATION_GRACE_MS); + const killWaitMs = positiveDuration(options.killWaitMs, PROCESS_KILL_WAIT_MS); + + if (!processTreeIsAlive(childProcess)) return; + signalProcessTree(childProcess, 'SIGTERM'); + if (await waitForProcessTreeExit(childProcess, graceMs)) return; + + signalProcessTree(childProcess, 'SIGKILL'); + if (await waitForProcessTreeExit(childProcess, killWaitMs)) return; + + throw new Error(`process tree ${childProcess.pid} did not exit after SIGKILL`); +} + // ============================================================================= // STACK TOOL DISCOVERY // ============================================================================= @@ -146,11 +206,17 @@ function prependRudiExecutionPath(env) { * @param {Object} stackConfig - Stack config from rudi.json * @param {Object} [options] * @param {number} [options.timeout=15000] - Timeout in ms + * @param {number} [options.terminationGraceMs=500] - Grace period before SIGKILL * @param {(msg: string) => void} [options.log] - Log function * @returns {Promise<{ tools: CachedTool[], error: string|null, missingSecrets: string[] }>} */ export async function discoverStackTools(stackId, stackConfig, options = {}) { - const { timeout = REQUEST_TIMEOUT_MS, log = () => {} } = options; + const timeout = positiveDuration(options.timeout, REQUEST_TIMEOUT_MS); + const terminationGraceMs = positiveDuration( + options.terminationGraceMs, + PROCESS_TERMINATION_GRACE_MS, + ); + const log = typeof options.log === 'function' ? options.log : () => {}; // Check launch config const launch = stackConfig.launch; @@ -179,21 +245,62 @@ export async function discoverStackTools(stackId, stackConfig, options = {}) { log(` Spawning ${stackId}...`); return new Promise((resolve) => { - let resolved = false; + let finishing = false; let childProcess; + let rl; + let timeoutId; + const pending = new Map(); + + const finish = async (result) => { + if (finishing) return; + finishing = true; + clearTimeout(timeoutId); + + const interrupted = new Error(`Stack discovery finished before the RPC response for ${stackId}`); + for (const request of pending.values()) { + request.reject(interrupted); + } + pending.clear(); + + try { + rl?.close(); + } catch { + // The child may have already closed stdout. + } + try { + childProcess?.stdin?.end(); + } catch { + // The child may have already closed stdin. + } + + let cleanupError = null; + try { + await terminateProcessTree(childProcess, { graceMs: terminationGraceMs }); + } catch (error) { + cleanupError = error; + } finally { + childProcess?.stdin?.destroy(); + childProcess?.stdout?.destroy(); + childProcess?.stderr?.destroy(); + } - const cleanup = () => { - if (childProcess && !childProcess.killed) { - childProcess.kill(); + if (cleanupError) { + const prefix = result.error ? `${result.error}; ` : ''; + resolve({ + tools: [], + error: `${prefix}Process cleanup failed for ${stackId}: ${cleanupError.message}`, + missingSecrets: result.missingSecrets, + }); + return; } + + resolve(result); }; // Timeout - const timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); - resolve({ + timeoutId = setTimeout(() => { + if (!finishing) { + void finish({ tools: [], error: `Timeout after ${timeout}ms`, missingSecrets: [] @@ -205,20 +312,28 @@ export async function discoverStackTools(stackId, stackConfig, options = {}) { childProcess = spawn(launch.bin, launch.args || [], { cwd: launch.cwd || stackConfig.path, stdio: ['pipe', 'pipe', 'pipe'], - env + env, + detached: process.platform !== 'win32', }); - const rl = readline.createInterface({ + childProcess.stdin.on('error', () => { + // Process exit/error handlers provide the stack-specific result. + }); + + rl = readline.createInterface({ input: childProcess.stdout, terminal: false }); let requestId = 0; - const pending = new Map(); // Send JSON-RPC request const send = (method, params = {}) => { return new Promise((resolveReq, rejectReq) => { + if (finishing || !childProcess.stdin.writable) { + rejectReq(new Error(`Stack process is not writable for ${method}`)); + return; + } const id = ++requestId; pending.set(id, { resolve: resolveReq, reject: rejectReq }); @@ -255,11 +370,8 @@ export async function discoverStackTools(stackId, stackConfig, options = {}) { // Handle errors childProcess.on('error', (err) => { - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - cleanup(); - resolve({ + if (!finishing) { + void finish({ tools: [], error: `Spawn error: ${err.message}`, missingSecrets: [] @@ -267,13 +379,12 @@ export async function discoverStackTools(stackId, stackConfig, options = {}) { } }); - childProcess.on('exit', (code) => { - if (!resolved && code !== 0) { - resolved = true; - clearTimeout(timeoutId); - resolve({ + childProcess.on('exit', (code, signal) => { + if (!finishing) { + const detail = signal ? `signal ${signal}` : `code ${code}`; + void finish({ tools: [], - error: `Process exited with code ${code}`, + error: `Process exited before tool discovery completed with ${detail}`, missingSecrets: [] }); } @@ -303,22 +414,16 @@ export async function discoverStackTools(stackId, stackConfig, options = {}) { inputSchema: t.inputSchema || { type: 'object', properties: {} } })); - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - cleanup(); - resolve({ + if (!finishing) { + await finish({ tools, error: null, missingSecrets: [] }); } } catch (err) { - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - cleanup(); - resolve({ + if (!finishing) { + await finish({ tools: [], error: err.message, missingSecrets: [] @@ -328,10 +433,8 @@ export async function discoverStackTools(stackId, stackConfig, options = {}) { })(); } catch (err) { - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - resolve({ + if (!finishing) { + void finish({ tools: [], error: `Failed to spawn: ${err.message}`, missingSecrets: [] From 3721facc9bab1907bc4815bc725048d2abb8b337 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 6 Aug 2026 23:47:40 -0400 Subject: [PATCH 2/9] build: refresh CLI bundle for 1.10.15 --- dist/index.cjs | 164 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 123 insertions(+), 41 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index c28797c..1597e5e 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -11872,9 +11872,60 @@ function prependRudiExecutionPath(env) { } env.PATH = entries.join(path9.delimiter); } +function positiveDuration(value, fallback) { + return Number.isFinite(value) && value > 0 ? value : fallback; +} +function processTreeIsAlive(childProcess) { + if (!Number.isInteger(childProcess?.pid) || childProcess.pid <= 1) return false; + const target = process.platform === "win32" ? childProcess.pid : -childProcess.pid; + try { + process.kill(target, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + if (error?.code === "EPERM") return true; + throw error; + } +} +function signalProcessTree(childProcess, signal) { + if (!Number.isInteger(childProcess?.pid) || childProcess.pid <= 1) return false; + const target = process.platform === "win32" ? childProcess.pid : -childProcess.pid; + try { + process.kill(target, signal); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +} +async function waitForProcessTreeExit(childProcess, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (processTreeIsAlive(childProcess)) { + const remaining = deadline - Date.now(); + if (remaining <= 0) return false; + await new Promise((resolve) => setTimeout(resolve, Math.min(PROCESS_EXIT_POLL_MS, remaining))); + } + return true; +} +async function terminateProcessTree(childProcess, options = {}) { + if (!Number.isInteger(childProcess?.pid) || childProcess.pid <= 1) return; + const graceMs = positiveDuration(options.graceMs, PROCESS_TERMINATION_GRACE_MS); + const killWaitMs = positiveDuration(options.killWaitMs, PROCESS_KILL_WAIT_MS); + if (!processTreeIsAlive(childProcess)) return; + signalProcessTree(childProcess, "SIGTERM"); + if (await waitForProcessTreeExit(childProcess, graceMs)) return; + signalProcessTree(childProcess, "SIGKILL"); + if (await waitForProcessTreeExit(childProcess, killWaitMs)) return; + throw new Error(`process tree ${childProcess.pid} did not exit after SIGKILL`); +} async function discoverStackTools(stackId, stackConfig, options = {}) { - const { timeout = REQUEST_TIMEOUT_MS, log = () => { - } } = options; + const timeout = positiveDuration(options.timeout, REQUEST_TIMEOUT_MS); + const terminationGraceMs = positiveDuration( + options.terminationGraceMs, + PROCESS_TERMINATION_GRACE_MS + ); + const log = typeof options.log === "function" ? options.log : () => { + }; const launch = stackConfig.launch; if (!launch || !launch.bin) { return { @@ -11895,18 +11946,52 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { prependRudiExecutionPath(env); log(` Spawning ${stackId}...`); return new Promise((resolve) => { - let resolved = false; + let finishing = false; let childProcess; - const cleanup = () => { - if (childProcess && !childProcess.killed) { - childProcess.kill(); + let rl; + let timeoutId; + const pending = /* @__PURE__ */ new Map(); + const finish = async (result) => { + if (finishing) return; + finishing = true; + clearTimeout(timeoutId); + const interrupted = new Error(`Stack discovery finished before the RPC response for ${stackId}`); + for (const request of pending.values()) { + request.reject(interrupted); + } + pending.clear(); + try { + rl?.close(); + } catch { } - }; - const timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); + try { + childProcess?.stdin?.end(); + } catch { + } + let cleanupError = null; + try { + await terminateProcessTree(childProcess, { graceMs: terminationGraceMs }); + } catch (error) { + cleanupError = error; + } finally { + childProcess?.stdin?.destroy(); + childProcess?.stdout?.destroy(); + childProcess?.stderr?.destroy(); + } + if (cleanupError) { + const prefix = result.error ? `${result.error}; ` : ""; resolve({ + tools: [], + error: `${prefix}Process cleanup failed for ${stackId}: ${cleanupError.message}`, + missingSecrets: result.missingSecrets + }); + return; + } + resolve(result); + }; + timeoutId = setTimeout(() => { + if (!finishing) { + void finish({ tools: [], error: `Timeout after ${timeout}ms`, missingSecrets: [] @@ -11917,16 +12002,22 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { childProcess = (0, import_child_process4.spawn)(launch.bin, launch.args || [], { cwd: launch.cwd || stackConfig.path, stdio: ["pipe", "pipe", "pipe"], - env + env, + detached: process.platform !== "win32" }); - const rl = readline.createInterface({ + childProcess.stdin.on("error", () => { + }); + rl = readline.createInterface({ input: childProcess.stdout, terminal: false }); let requestId = 0; - const pending = /* @__PURE__ */ new Map(); const send = (method, params = {}) => { return new Promise((resolveReq, rejectReq) => { + if (finishing || !childProcess.stdin.writable) { + rejectReq(new Error(`Stack process is not writable for ${method}`)); + return; + } const id = ++requestId; pending.set(id, { resolve: resolveReq, reject: rejectReq }); const msg = JSON.stringify({ @@ -11956,24 +12047,20 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { } }); childProcess.on("error", (err) => { - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - cleanup(); - resolve({ + if (!finishing) { + void finish({ tools: [], error: `Spawn error: ${err.message}`, missingSecrets: [] }); } }); - childProcess.on("exit", (code) => { - if (!resolved && code !== 0) { - resolved = true; - clearTimeout(timeoutId); - resolve({ + childProcess.on("exit", (code, signal) => { + if (!finishing) { + const detail = signal ? `signal ${signal}` : `code ${code}`; + void finish({ tools: [], - error: `Process exited with code ${code}`, + error: `Process exited before tool discovery completed with ${detail}`, missingSecrets: [] }); } @@ -11995,22 +12082,16 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { description: t.description || t.name, inputSchema: t.inputSchema || { type: "object", properties: {} } })); - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - cleanup(); - resolve({ + if (!finishing) { + await finish({ tools, error: null, missingSecrets: [] }); } } catch (err) { - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - cleanup(); - resolve({ + if (!finishing) { + await finish({ tools: [], error: err.message, missingSecrets: [] @@ -12019,10 +12100,8 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { } })(); } catch (err) { - if (!resolved) { - resolved = true; - clearTimeout(timeoutId); - resolve({ + if (!finishing) { + void finish({ tools: [], error: `Failed to spawn: ${err.message}`, missingSecrets: [] @@ -12110,7 +12189,7 @@ async function indexAllStacks(options = {}) { writeToolIndex(index); return { indexed, failed, index }; } -var import_child_process4, fs8, path9, readline, TOOL_INDEX_PATH, TOOL_INDEX_TMP, SECRETS_PATH, REQUEST_TIMEOUT_MS, PROTOCOL_VERSION; +var import_child_process4, fs8, path9, readline, TOOL_INDEX_PATH, TOOL_INDEX_TMP, SECRETS_PATH, REQUEST_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS, PROCESS_KILL_WAIT_MS, PROCESS_EXIT_POLL_MS, PROTOCOL_VERSION; var init_tool_index = __esm({ "packages/core/src/tool-index.js"() { import_child_process4 = require("child_process"); @@ -12123,6 +12202,9 @@ var init_tool_index = __esm({ TOOL_INDEX_TMP = path9.join(RUDI_HOME, "cache", "tool-index.json.tmp"); SECRETS_PATH = path9.join(RUDI_HOME, "secrets.json"); REQUEST_TIMEOUT_MS = 15e3; + PROCESS_TERMINATION_GRACE_MS = 500; + PROCESS_KILL_WAIT_MS = 1e3; + PROCESS_EXIT_POLL_MS = 25; PROTOCOL_VERSION = "2024-11-05"; } }); @@ -36481,7 +36563,7 @@ async function cmdAgent(args = [], flags = {}, passthrough = [], dependencies = } // src/index.js -var VERSION = true ? "1.10.12" : process.env.npm_package_version || "0.0.0"; +var VERSION = true ? "1.10.15" : process.env.npm_package_version || "0.0.0"; var RETIRED_COMMANDS = /* @__PURE__ */ new Map([ ["apply", "Provider transcripts remain authoritative; organization-plan execution was removed."], ["database", "Use Studio only if you still need the isolated compatibility database."], From 4b0a8db6cbb77c6f1e480dc1eb0661103a72f7e5 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 7 Aug 2026 00:10:30 -0400 Subject: [PATCH 3/9] docs: record Admin Mac cleanup proof --- .../2026-08-06-admin-mac-rudi-cleanup.md | 109 ++++++++++++++---- 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/docs/swe-compliance/2026-08-06-admin-mac-rudi-cleanup.md b/docs/swe-compliance/2026-08-06-admin-mac-rudi-cleanup.md index d2dbd32..d272803 100644 --- a/docs/swe-compliance/2026-08-06-admin-mac-rudi-cleanup.md +++ b/docs/swe-compliance/2026-08-06-admin-mac-rudi-cleanup.md @@ -175,7 +175,7 @@ ## Phase 5: Full Verification -- Status: in progress. +- Status: complete. - Targeted tests: core tool-index and index command/daemon operation suites. - Full suite: `pnpm test` in a state that does not mix unrelated dirty work. - Build/typecheck/lint: `pnpm build`, `npm pack --dry-run`, and @@ -188,39 +188,106 @@ - run exactly one forced index rebuild with an outer watchdog - verify command exit, cache validity, and zero surviving descendants - verify daemon/Compute/Service Desk/LaunchAgent health and user-only modes -- Exit criteria: tests/build/package/debt checks pass and the Admin Mac live - smoke leaves no orphaned child processes. -- Evidence so far: +- Exit criteria: complete; tests/build/package/debt checks pass and the Admin + Mac live smoke leaves no orphaned child processes. +- Evidence: - full clean Admin Mac suite: 617 passed, 0 failed - architecture-aware edited-file debt scan: zero findings - source/install mismatch localized: clean source declared `1.10.12`, the installed package declared uncommitted `1.10.14`; the repaired release is therefore versioned `1.10.15` for exact-commit traceability + - `pnpm build`, bundled `rudi --version`, `npm pack --dry-run`, and + `git diff --check` passed; source commit `1e0b9aa7` and dedicated bundle + commit `3721facc` are retained on + `codex/admin-mac-index-lifecycle-20260807` + - installed CLI and clean source both report `1.10.15`; the prior installed + package, wrapper, and new tarball are checksummed in the rollback archive + - the forced all-stack rebuild completed at `2026-08-07T03:56:35Z`; after + supported stack updates and targeted repair checks, the final cache is + healthy with 30 stacks, 405 tools, and zero failures + - the forced rebuild and every targeted index returned with no surviving + process group; a fourth pre-fix index tree launched concurrently was + independently ancestry-validated and terminated, bringing total retired + stale processes to 161 across four process groups ## Phase 6: Docs, Contracts, And Closure -- Status: pending. +- Status: complete. - Docs or API contracts to update: only lifecycle/operational documentation whose verified behavior changed; preserve Service Desk's ingestion-only boundary. -- Final files touched: record after implementation. -- Commands run and results: record red, green, refactor, build, debt, archive, - permission, release, and live-smoke evidence. -- Accepted debt: record any stack capability-profile decision, dependency - storage redesign, Compute lifecycle integration, or expired archive removal - intentionally deferred. +- Final source files touched: + - `package.json` + - `packages/core/src/tool-index.js` + - `packages/core/src/__tests__/unit/tool-index.test.js` + - `dist/index.cjs` + - this checklist +- Operational closure: + - checksummed private archive root: + `/Users/admin/.rudi/archive/admin-mac-cleanup/20260807T034751Z` + (310,224 KB) + - archived/unloaded five retired LaunchAgents; preserved the three required + RUDI LaunchAgents and both healthy MLB jobs + - archived and removed the approved legacy Service Desk/runtime/output, + automation, workspace, registry, incoming-transfer, and retired-DB paths + - verified both the original three-file retired DB archive and the standalone + rollback database; both pass SQLite integrity checks + - compressed six older Service Desk recoveries; retained the required final + `cf53adfc` rollback unit; reduced Google Workspace recovery to its state + snapshot by removing only reinstallable dependencies + - removed the unowned 258,572 KB video cache and empty legacy roots + - pinned daemon and CLI wrappers to managed Node 20.10.0; daemon is ready + - hardened RUDI, organization, state, log, archive, output, recovery, and + Compute state/config/log roots to mode 700; secret files remain mode 600 + - updated Notion, Audio Tools, and Google Workspace through the installed + Registry lifecycle; rebuilt Google Workspace's omitted generated `dist/` + - synchronized only the verified canonical output-path literals in the + installed Video Editor and Web Export copies when their normal lifecycle + path was blocked; backups and exact diffs are archived +- Final Service Desk proof: + - organization SQLite `quick_check` is `ok` + - checkpoint version advanced to 5 with updated time + `2026-08-07T04:06:46.568Z` + - 802 conversations, 1,327 interactions, 1,512 source receipts + - 3,958 artifact rows exactly match 3,958 artifact files + - ingestion PID 30490 remains running; stdout advanced during the run and + stderr remains empty + - canonical organization, runtime, stack, Registry, router, bin, config, + secret, Compute, and MLB paths are present; every approved retired path is + absent +- Space result: live `~/.rudi` decreased from 6,656,188 KB to 6,063,708 KB + while retaining verified rollback archives, a reduction of 592,480 KB. +- Accepted debt and explicit deferrals: + - the 2.3 GB MLB Chrome profile remains product-owned and preserved + - the installed stack set remains unchanged pending an Admin Mac capability + profile; no stack was manually uninstalled + - the 97 MB historical Service Desk archive and final 54.7 MB rollback unit + remain until their rollback windows close + - Compute remains at exact code release `0.3.1-c6b020e`; the three later + `c55948da` source commits are documentation-only, so no risk-bearing + redeploy was performed + - the public Registry URL returned 404 and needs publication repair; this + run used the audited immutable local Registry release + - stack update packaging must build required generated output (Google + Workspace omitted `dist/`), Video Editor dependency detection must not + block same-version source refreshes, and Web Export's canonical Registry + source still needs the plural output default + - Video Editor still needs its mutable media root moved out of installed + package code; this run removed only the verified unowned cache + - `rudi home`/`doctor` still need canonical organization/Registry/recovery, + unclassified-root, and orphan-process visibility - Definition of Done: - - [ ] stale index trees are gone - - [ ] lifecycle regression test proves cleanup before resolution - - [ ] relevant source verification passes - - [ ] retired LaunchAgents are unloaded and archived - - [ ] approved legacy paths are archived, verified, and absent from live root - - [ ] canonical, Compute, and MLB state is preserved - - [ ] installed releases are traceable to exact commits or the unresolved + - [x] stale index trees are gone + - [x] lifecycle regression test proves cleanup before resolution + - [x] relevant source verification passes + - [x] retired LaunchAgents are unloaded and archived + - [x] approved legacy paths are archived, verified, and absent from live root + - [x] canonical, Compute, and MLB state is preserved + - [x] installed releases are traceable to exact commits or the unresolved release is explicitly blocked with evidence - - [ ] permissions are user-only at sensitive boundaries - - [ ] one index rebuild exits cleanly without descendants - - [ ] Service Desk checkpoint, counts, artifacts, Gmail polling, and health + - [x] permissions are user-only at sensitive boundaries + - [x] one index rebuild exits cleanly without descendants + - [x] Service Desk checkpoint, counts, artifacts, Gmail polling, and health match the baseline ## Rollback Units From b45ba72162d70c1b8748cc2746328a0d57112e6b Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 20:45:50 -0400 Subject: [PATCH 4/9] feat: add private agent automation profile --- .debt-scan.json | 3 + dist/index.cjs | 3396 ++++++++++------- docs/frontier-agent-hosts.md | 52 + .../2026-08-08-private-automation-profile.md | 97 + .../agent-host-private-automation.test.js | 821 ++++ src/agent-host/cli-inputs.js | 75 +- src/agent-host/events/stream.js | 186 +- src/agent-host/launch.js | 11 + src/agent-host/private-automation-profile.js | 348 ++ src/agent-host/providers/claude.js | 29 + src/agent-host/providers/codex.js | 33 + src/agent-host/providers/common.js | 67 +- src/agent-host/providers/config/claude.json | 8 + src/agent-host/providers/config/codex.json | 8 + src/agent-host/workspace.js | 11 +- src/commands/agent-host.js | 11 + 16 files changed, 3767 insertions(+), 1389 deletions(-) create mode 100644 docs/swe-compliance/2026-08-08-private-automation-profile.md create mode 100644 src/__tests__/unit/agent-host-private-automation.test.js create mode 100644 src/agent-host/private-automation-profile.js diff --git a/.debt-scan.json b/.debt-scan.json index 6251272..7023276 100644 --- a/.debt-scan.json +++ b/.debt-scan.json @@ -9,6 +9,9 @@ ".ignored" ], "publicAPI": [ + "src/daemon/schemas/packages.js", + "src/daemon/schemas/secrets.js", + "src/daemon/schemas/tools.js", "packages/core/src/deps.js", "packages/core/src/index.js", "packages/core/src/installer.js", diff --git a/dist/index.cjs b/dist/index.cjs index 1597e5e..9e95514 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -2266,17 +2266,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path51) { - const ctrl = callVisitor(key, node, visitor, path51); + function visit_(key, node, visitor, path52) { + const ctrl = callVisitor(key, node, visitor, path52); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path51, ctrl); - return visit_(key, ctrl, visitor, path51); + replaceNode(key, path52, ctrl); + return visit_(key, ctrl, visitor, path52); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path51 = Object.freeze(path51.concat(node)); + path52 = Object.freeze(path52.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path51); + const ci = visit_(i, node.items[i], visitor, path52); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -2287,13 +2287,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path51 = Object.freeze(path51.concat(node)); - const ck = visit_("key", node.key, visitor, path51); + path52 = Object.freeze(path52.concat(node)); + const ck = visit_("key", node.key, visitor, path52); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path51); + const cv = visit_("value", node.value, visitor, path52); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -2314,17 +2314,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path51) { - const ctrl = await callVisitor(key, node, visitor, path51); + async function visitAsync_(key, node, visitor, path52) { + const ctrl = await callVisitor(key, node, visitor, path52); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path51, ctrl); - return visitAsync_(key, ctrl, visitor, path51); + replaceNode(key, path52, ctrl); + return visitAsync_(key, ctrl, visitor, path52); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path51 = Object.freeze(path51.concat(node)); + path52 = Object.freeze(path52.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path51); + const ci = await visitAsync_(i, node.items[i], visitor, path52); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -2335,13 +2335,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path51 = Object.freeze(path51.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path51); + path52 = Object.freeze(path52.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path52); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path51); + const cv = await visitAsync_("value", node.value, visitor, path52); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -2368,23 +2368,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path51) { + function callVisitor(key, node, visitor, path52) { if (typeof visitor === "function") - return visitor(key, node, path51); + return visitor(key, node, path52); if (identity.isMap(node)) - return visitor.Map?.(key, node, path51); + return visitor.Map?.(key, node, path52); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path51); + return visitor.Seq?.(key, node, path52); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path51); + return visitor.Pair?.(key, node, path52); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path51); + return visitor.Scalar?.(key, node, path52); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path51); + return visitor.Alias?.(key, node, path52); return void 0; } - function replaceNode(key, path51, node) { - const parent = path51[path51.length - 1]; + function replaceNode(key, path52, node) { + const parent = path52[path52.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -2992,10 +2992,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path51, value) { + function collectionFromPath(schema, path52, value) { let v = value; - for (let i = path51.length - 1; i >= 0; --i) { - const k = path51[i]; + for (let i = path52.length - 1; i >= 0; --i) { + const k = path52[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -3014,7 +3014,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path51) => path51 == null || typeof path51 === "object" && !!path51[Symbol.iterator]().next().done; + var isEmptyPath = (path52) => path52 == null || typeof path52 === "object" && !!path52[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -3044,11 +3044,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path51, value) { - if (isEmptyPath(path51)) + addIn(path52, value) { + if (isEmptyPath(path52)) this.add(value); else { - const [key, ...rest] = path51; + const [key, ...rest] = path52; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -3062,8 +3062,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path51) { - const [key, ...rest] = path51; + deleteIn(path52) { + const [key, ...rest] = path52; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -3077,8 +3077,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path51, keepScalar) { - const [key, ...rest] = path51; + getIn(path52, keepScalar) { + const [key, ...rest] = path52; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -3096,8 +3096,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path51) { - const [key, ...rest] = path51; + hasIn(path52) { + const [key, ...rest] = path52; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -3107,8 +3107,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path51, value) { - const [key, ...rest] = path51; + setIn(path52, value) { + const [key, ...rest] = path52; if (rest.length === 0) { this.set(key, value); } else { @@ -5612,9 +5612,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path51, value) { + addIn(path52, value) { if (assertCollection(this.contents)) - this.contents.addIn(path51, value); + this.contents.addIn(path52, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -5689,14 +5689,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path51) { - if (Collection.isEmptyPath(path51)) { + deleteIn(path52) { + if (Collection.isEmptyPath(path52)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path51) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path52) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -5711,10 +5711,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path51, keepScalar) { - if (Collection.isEmptyPath(path51)) + getIn(path52, keepScalar) { + if (Collection.isEmptyPath(path52)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path51, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path52, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -5725,10 +5725,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path51) { - if (Collection.isEmptyPath(path51)) + hasIn(path52) { + if (Collection.isEmptyPath(path52)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path51) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path52) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -5745,13 +5745,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path51, value) { - if (Collection.isEmptyPath(path51)) { + setIn(path52, value) { + if (Collection.isEmptyPath(path52)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path51), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path52), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path51, value); + this.contents.setIn(path52, value); } } /** @@ -7703,9 +7703,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path51) => { + visit.itemAtPath = (cst, path52) => { let item = cst; - for (const [field, index] of path51) { + for (const [field, index] of path52) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -7714,23 +7714,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path51) => { - const parent = visit.itemAtPath(cst, path51.slice(0, -1)); - const field = path51[path51.length - 1][0]; + visit.parentCollection = (cst, path52) => { + const parent = visit.itemAtPath(cst, path52.slice(0, -1)); + const field = path52[path52.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path51, item, visitor) { - let ctrl = visitor(item, path51); + function _visit(path52, item, visitor) { + let ctrl = visitor(item, path52); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path51.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path52.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -7741,10 +7741,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path51); + ctrl = ctrl(item, path52); } } - return typeof ctrl === "function" ? ctrl(item, path51) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path52) : ctrl; } exports2.visit = visit; } @@ -9029,14 +9029,14 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs50 = this.flowScalar(this.type); + const fs51 = this.flowScalar(this.type); if (atNextItem || it.value) { - map.items.push({ start, key: fs50, sep: [] }); + map.items.push({ start, key: fs51, sep: [] }); this.onKeyLine = true; } else if (it.sep) { - this.stack.push(fs50); + this.stack.push(fs51); } else { - Object.assign(it, { key: fs50, sep: [] }); + Object.assign(it, { key: fs51, sep: [] }); this.onKeyLine = true; } return; @@ -9164,13 +9164,13 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs50 = this.flowScalar(this.type); + const fs51 = this.flowScalar(this.type); if (!it || it.value) - fc.items.push({ start: [], key: fs50, sep: [] }); + fc.items.push({ start: [], key: fs51, sep: [] }); else if (it.sep) - this.stack.push(fs50); + this.stack.push(fs51); else - Object.assign(it, { key: fs50, sep: [] }); + Object.assign(it, { key: fs51, sep: [] }); return; } case "flow-map-end": @@ -16074,8 +16074,8 @@ var require_utils = __commonJS({ } return ind; } - function removeDotSegments(path51) { - let input = path51; + function removeDotSegments(path52) { + let input = path52; const output = []; let nextSlash = -1; let len = 0; @@ -16274,8 +16274,8 @@ var require_schemes = __commonJS({ wsComponent.secure = void 0; } if (wsComponent.resourceName) { - const [path51, query] = wsComponent.resourceName.split("?"); - wsComponent.path = path51 && path51 !== "/" ? path51 : void 0; + const [path52, query] = wsComponent.resourceName.split("?"); + wsComponent.path = path52 && path52 !== "/" ? path52 : void 0; wsComponent.query = query; wsComponent.resourceName = void 0; } @@ -19628,12 +19628,12 @@ var require_dist2 = __commonJS({ throw new Error(`Unknown format "${name}"`); return f; }; - function addFormats2(ajv2, list, fs50, exportName) { + function addFormats2(ajv2, list, fs51, exportName) { var _a; var _b; (_a = (_b = ajv2.opts.code).formats) !== null && _a !== void 0 ? _a : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`; for (const f of list) - ajv2.addFormat(f, fs50[f]); + ajv2.addFormat(f, fs51[f]); } module2.exports = exports2 = formatsPlugin; Object.defineProperty(exports2, "__esModule", { value: true }); @@ -22377,11 +22377,11 @@ async function runStack(id, options = {}) { const startTime = Date.now(); const packagePath = getPackagePath(id); const manifestPath = import_path11.default.join(packagePath, "manifest.json"); - const { default: fs50 } = await import("fs"); - if (!fs50.existsSync(manifestPath)) { + const { default: fs51 } = await import("fs"); + if (!fs51.existsSync(manifestPath)) { throw new Error(`Stack manifest not found: ${id}`); } - const manifest = JSON.parse(fs50.readFileSync(manifestPath, "utf-8")); + const manifest = JSON.parse(fs51.readFileSync(manifestPath, "utf-8")); const { command, args } = resolveCommandFromManifest(manifest, packagePath); const secrets = await getSecrets(manifest.requires?.secrets || []); const runEnv = buildStackRunEnv({ @@ -29156,14 +29156,14 @@ function readLaunchEvents({ eventFile, limitBytes = 1024 * 1024, offset = 0 }) { } // src/agent-host/detached.js -var import_node_fs12 = __toESM(require("node:fs"), 1); -var import_node_child_process5 = require("node:child_process"); +var import_node_fs13 = __toESM(require("node:fs"), 1); +var import_node_child_process6 = require("node:child_process"); // src/agent-host/launch.js var import_node_crypto3 = __toESM(require("node:crypto"), 1); // src/agent-host/events/stream.js -var import_node_child_process2 = require("node:child_process"); +var import_node_child_process3 = require("node:child_process"); // src/agent-host/events/providers/claude.js var claude_exports = {}; @@ -29882,1027 +29882,395 @@ function renderAgentEvent(event) { return []; } -// src/agent-host/events/stream.js -function boundedAppend(current, value, maxLength = 4096) { - const combined = `${current}${value}`; - return combined.length <= maxLength ? combined : combined.slice(-maxLength); -} -function writeLine(stream, value) { - stream.write(value.endsWith("\n") ? value : `${value} -`); -} -function executeForegroundLaunch({ - eventSink = null, - jsonOutput = false, - launchId, - onSpawn = null, - plan, - spawnImpl = import_node_child_process2.spawn, - stderr = process.stderr, - stdout = process.stdout, - store, - timeoutMs = plan.timeouts.runtimeMs, - signalEmitter = process -}) { - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 24 * 60 * 60 * 1e3) { - throw new Error("timeoutMs must be an integer between 1 and 86400000"); - } - return new Promise((resolve, reject) => { - const normalizer = createAgentEventNormalizer(plan.provider); - let child; - let finalized = false; - let stdoutBuffer = ""; - let stderrTail = ""; - let sawAssistantText = false; - let timedOut = false; - let forceTimer = null; - let requestedSignal = null; - let sinkFailure = null; - function recordSinkFailure(kind, error) { - if (sinkFailure) return; - sinkFailure = `${kind} persistence failed: ${error.message}`; - try { - writeLine(stderr, sinkFailure); - } catch { - } - try { - child?.kill("SIGTERM"); - } catch { - } - } - function publishEvent(payload, persistedPayload = payload) { - try { - eventSink?.(persistedPayload); - } catch (error) { - recordSinkFailure("Agent event", error); - } - return payload; - } - const onSigint = () => { - requestedSignal = "SIGINT"; - child?.kill("SIGINT"); - }; - const onSigterm = () => { - requestedSignal = "SIGTERM"; - child?.kill("SIGTERM"); - }; - function persistNativeSession(rawEvent, normalized) { - const nativeSessionId = extractNativeSessionId(rawEvent) || normalized?.providerSessionId || null; - if (!nativeSessionId) return; - const current = store.get(launchId); - if (current?.nativeSessionId !== nativeSessionId) { - store.setNativeSessionId(launchId, nativeSessionId); - } - } - function emitEvent(normalized, rawEvent) { - persistNativeSession(rawEvent, normalized); - const isDelta = rawEvent?.type === "message" && rawEvent.delta === true || rawEvent?.event === "step_update" && rawEvent.step_update?.step_type === "agent_response"; - const persistedPayload = { - delta: isDelta, - event: normalized, - launchId, - provider: plan.provider, - type: "agent.event" - }; - const payload = publishEvent({ - event: normalized, - launchId, - provider: plan.provider, - rawEvent, - type: "agent.event" - }, persistedPayload); - if (jsonOutput) { - writeLine(stdout, JSON.stringify(payload)); - return; - } - const rendered = renderAgentEvent(normalized); - if (normalized?.type === "assistant" && rendered.length > 0) sawAssistantText = true; - if (normalized?.type === "result" && sawAssistantText) return; - for (const text of rendered) { - if (isDelta) stdout.write(text); - else writeLine(stdout, text); - } - if (normalized?.type === "error" && normalized.message) writeLine(stderr, normalized.message); +// src/agent-host/private-automation-profile.js +var import_node_fs5 = __toESM(require("node:fs"), 1); +var import_node_path4 = __toESM(require("node:path"), 1); +var import_node_child_process2 = require("node:child_process"); + +// src/agent-host/providers/catalog.js +var import_node_fs4 = require("node:fs"); +var import_node_os3 = require("node:os"); + +// src/agent-host/providers/config/claude.json +var claude_default = { + $schema: "https://learnrudi.com/schemas/headless-agent-v1.json", + id: "claude", + name: "Claude Code", + description: "Anthropic Claude Code CLI \u2014 headless mode", + version: "1.0.0", + binary: { + name: "claude", + resolvePaths: [ + "~/.local/bin/claude", + "~/.rudi/runtimes/node/{arch}/bin/claude", + "~/.rudi/runtimes/node/bin/claude", + "~/.rudi/agents/claude/node_modules/.bin/claude" + ], + fallback: "which", + checkCommand: ["claude", "--version"], + loginCommand: ["claude", "auth", "login"], + authCheck: ["claude", "auth", "status"] + }, + headless: { + command: "claude", + promptDelivery: "arg-or-stdin", + privateAutomation: { + profile: "private-automation-v1", + promptDelivery: "stdin", + sessionPersistence: false, + tools: false + }, + args: { + base: [ + "--output-format", + "stream-json", + "--verbose" + ], + conditionals: [ + { if: "print", args: ["--print"] }, + { if: "prompt", args: ["-p", "{{prompt}}"] }, + { if: "model", args: ["--model", "{{model}}"] }, + { if: "fallbackModel", args: ["--fallback-model", "{{fallbackModel}}"] }, + { if: "systemPrompt", args: ["--append-system-prompt", "{{systemPrompt}}"] }, + { if: "systemPromptFile", args: ["--append-system-prompt-file", "{{systemPromptFile}}"] }, + { if: "replaceSystemPrompt", args: ["--system-prompt", "{{replaceSystemPrompt}}"] }, + { if: "replaceSystemPromptFile", args: ["--system-prompt-file", "{{replaceSystemPromptFile}}"] }, + { if: "allowedTools", args: ["--allowedTools", "{{allowedTools|join: }}"] }, + { if: "disallowedTools", args: ["--disallowedTools", "{{disallowedTools|join: }}"] }, + { if: "tools", args: ["--tools", "{{tools|join:,}}"] }, + { if: "mcpConfig", args: ["--mcp-config", "{{mcpConfig}}"] }, + { if: "strictMcpConfig", args: ["--strict-mcp-config"] }, + { if: "resumeSessionId", args: ["--resume", "{{resumeSessionId}}"] }, + { if: "continueSession", args: ["--continue"] }, + { if: "sessionId", args: ["--session-id", "{{sessionId}}"] }, + { if: "forkSession", args: ["--fork-session"] }, + { if: "jsonSchema", args: ["--json-schema", "{{jsonSchema}}"] }, + { if: "maxTurns", args: ["--max-turns", "{{maxTurns}}"] }, + { if: "maxBudgetUsd", args: ["--max-budget-usd", "{{maxBudgetUsd}}"] }, + { if: "noSessionPersistence", args: ["--no-session-persistence"] }, + { if: "addDirs", args: ["--add-dir", "{{addDirs|join: }}"] }, + { if: "agents", args: ["--agents", "{{agents}}"] }, + { if: "agent", args: ["--agent", "{{agent}}"] }, + { if: "effort", args: ["--effort", "{{effort}}"] }, + { if: "bare", args: ["--bare"] }, + { if: "safeMode", args: ["--safe-mode"] }, + { if: "background", args: ["--background"] }, + { if: "worktree", args: ["--worktree", "{{worktree}}"] }, + { if: "tmux", args: ["--tmux", "{{tmux}}"] }, + { if: "name", args: ["--name", "{{name}}"] }, + { if: "includeHookEvents", args: ["--include-hook-events"] }, + { if: "promptSuggestions", args: ["--prompt-suggestions", "{{promptSuggestions}}"] }, + { if: "pluginUrl", args: ["--plugin-url", "{{pluginUrl}}"] }, + { if: "includePartialMessages", args: ["--include-partial-messages"] }, + { if: "inputFormat", args: ["--input-format", "{{inputFormat}}"] }, + { if: "replayUserMessages", args: ["--replay-user-messages"] }, + { if: "chrome", args: ["--chrome"] }, + { if: "noChrome", args: ["--no-chrome"] }, + { if: "debug", args: ["--debug", "{{debug}}"] }, + { if: "debugFile", args: ["--debug-file", "{{debugFile}}"] }, + { if: "betas", args: ["--betas", "{{betas|join: }}"] }, + { if: "settings", args: ["--settings", "{{settings}}"] }, + { if: "settingSources", args: ["--setting-sources", "{{settingSources}}"] }, + { if: "pluginDir", args: ["--plugin-dir", "{{pluginDir}}"] }, + { if: "disableSlashCommands", args: ["--disable-slash-commands"] }, + { if: "permissionPromptTool", args: ["--permission-prompt-tool", "{{permissionPromptTool}}"] }, + { if: "teammateMode", args: ["--teammate-mode", "{{teammateMode}}"] }, + { if: "file", args: ["--file", "{{file|join: }}"] }, + { if: "fromPr", args: ["--from-pr", "{{fromPr}}"] }, + { if: "remote", args: ["--remote", "{{remote}}"] }, + { if: "teleport", args: ["--teleport"] }, + { if: "ide", args: ["--ide"] }, + { if: "init", args: ["--init"] }, + { if: "initOnly", args: ["--init-only"] }, + { if: "maintenance", args: ["--maintenance"] }, + { if: "allowDangerouslySkipPermissions", args: ["--allow-dangerously-skip-permissions"] }, + { if: "outputFormat", args: ["--output-format", "{{outputFormat}}"] } + ] + }, + permissionModes: { + agent: ["--dangerously-skip-permissions"], + plan: ["--permission-mode", "plan"], + acceptEdits: ["--permission-mode", "acceptEdits"], + auto: ["--permission-mode", "auto"], + dontAsk: ["--permission-mode", "dontAsk"], + bypassPermissions: ["--permission-mode", "bypassPermissions"], + default: ["--permission-mode", "default"] + }, + env: { + TERM: "xterm-256color", + CI: "true", + CLAUDE_NO_UPDATE_CHECK: "true", + CLAUDE_CODE_SKIP_PROMPT_HISTORY: "1", + DISABLE_AUTOUPDATE: "1", + NO_COLOR: "1" + }, + authEnvVars: [ + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN" + ], + stdin: "pipe", + timeouts: { + startupMs: 12e4, + runtimeMs: 9e5, + shutdownGraceMs: 5e3 } - function consumeLine(line) { - if (!line.trim()) return; - try { - const rawEvent = JSON.parse(line); - for (const result of normalizer.normalize(rawEvent)) { - if (result?.normalized) emitEvent(result.normalized, result.raw || rawEvent); + }, + eventStream: { + format: "json-lines", + sessionIdExtractor: { + path: "$.session_id", + fromEventTypes: ["assistant", "result"] + }, + events: { + system: { + condition: "$.type === 'system'", + fields: { + subtype: "$.subtype", + message: "$.message", + content: "$.message.content[*]", + compactMetadata: "$.compactMetadata" + }, + subtypes: ["init", "compact_boundary"] + }, + assistant: { + condition: "$.type === 'assistant'", + fields: { + messageId: "$.message.id", + role: "$.message.role", + model: "$.message.model", + stopReason: "$.message.stop_reason", + content: "$.message.content[*]", + usage: { + inputTokens: "$.message.usage.input_tokens", + outputTokens: "$.message.usage.output_tokens", + cacheReadTokens: "$.message.usage.cache_read_input_tokens", + cacheCreationTokens: "$.message.usage.cache_creation_input_tokens" + } + }, + contentBlockTypes: { + text: { + condition: "block.type === 'text'", + fields: { text: "block.text" } + }, + tool_use: { + condition: "block.type === 'tool_use'", + fields: { + id: "block.id", + name: "block.name", + input: "block.input" + } + }, + tool_result: { + condition: "block.type === 'tool_result'", + fields: { + id: "block.id", + content: "block.content" + } + }, + thinking: { + condition: "block.type === 'thinking'", + fields: { thinking: "block.thinking" } + } } - } catch { - const payload = publishEvent({ - event: { message: line, subtype: "provider_stdout", type: "system" }, - launchId, - provider: plan.provider, - type: "agent.event" - }); - if (jsonOutput) { - writeLine(stdout, JSON.stringify(payload)); - } else { - writeLine(stdout, line); + }, + result: { + condition: "$.type === 'result'", + fields: { + sessionId: "$.session_id", + result: "$.result", + structuredOutput: "$.structured_output", + totalCostUsd: "$.total_cost_usd", + durationMs: "$.duration_ms", + numTurns: "$.num_turns", + usage: { + inputTokens: "$.usage.input_tokens", + outputTokens: "$.usage.output_tokens", + cacheReadTokens: "$.usage.cache_read_input_tokens", + cacheCreationTokens: "$.usage.cache_creation_input_tokens" + } + } + }, + error: { + condition: "$.type === 'error'", + fields: { + message: "$.result", + errorCode: "$.error_code" + } + }, + stream_event: { + condition: "$.type === 'stream_event'", + note: "Only emitted with --include-partial-messages", + fields: { + eventType: "$.event.type", + event: "$.event" + }, + innerEventTypes: { + message_start: {}, + content_block_start: { + fields: { + blockType: "$.event.content_block.type", + blockId: "$.event.content_block.id", + toolName: "$.event.content_block.name" + } + }, + content_block_delta: { + deltaTypes: { + text_delta: { fields: { text: "$.event.delta.text" } }, + input_json_delta: { fields: { partialJson: "$.event.delta.partial_json" } } + } + }, + content_block_stop: {}, + message_delta: { + fields: { + stopReason: "$.event.delta.stop_reason", + usage: "$.event.usage" + } + }, + message_stop: {} } } } - function flushStdout() { - if (stdoutBuffer.trim()) consumeLine(stdoutBuffer); - stdoutBuffer = ""; - for (const result of normalizer.flush()) { - if (result?.normalized) emitEvent(result.normalized, result.raw || {}); - } - } - function complete(status, exitCode, lastError = null) { - if (finalized) return; - finalized = true; - clearTimeout(runtimeTimer); - if (forceTimer) clearTimeout(forceTimer); - signalEmitter.removeListener("SIGINT", onSigint); - signalEmitter.removeListener("SIGTERM", onSigterm); - flushStdout(); - if (sinkFailure) { - status = "failed"; - lastError = sinkFailure; - } - const current = store.get(launchId); - if (current?.status === "starting" && status !== "failed") { - store.transition(launchId, "running", { pid: child?.pid || 0 }); - } - const updated = store.transition(launchId, status, { - exitCode, - lastError - }); - const terminalEvent = publishEvent({ launch: updated, type: `launch.${status}` }); - if (jsonOutput) { - writeLine(stdout, JSON.stringify(terminalEvent)); - } - resolve(updated); - } - const runtimeTimer = setTimeout(() => { - timedOut = true; - child?.kill("SIGTERM"); - forceTimer = setTimeout(() => child?.kill("SIGKILL"), plan.timeouts.shutdownGraceMs || 5e3); - }, timeoutMs); - try { - child = spawnImpl(plan.spawn.command, plan.args, { - cwd: plan.spawn.cwd, - env: { ...process.env, ...plan.environment }, - stdio: ["ignore", "pipe", "pipe"] - }); - } catch (error) { - clearTimeout(runtimeTimer); - reject(error); - return; - } - child.once("spawn", () => { - const current = store.get(launchId); - if (current?.status === "starting") { - const running = store.transition(launchId, "running", { pid: child.pid || 0 }); - onSpawn?.(running); - } else if (current) { - onSpawn?.(current); - } - }); - signalEmitter.once("SIGINT", onSigint); - signalEmitter.once("SIGTERM", onSigterm); - child.stdout.on("data", (chunk) => { - stdoutBuffer += chunk.toString(); - const lines = stdoutBuffer.split("\n"); - stdoutBuffer = lines.pop() || ""; - for (const line of lines) consumeLine(line); - }); - child.stderr.on("data", (chunk) => { - const text = chunk.toString(); - stderrTail = boundedAppend(stderrTail, text); - try { - stderr.write(text); - } catch (error) { - recordSinkFailure("Provider stderr", error); - } - }); - child.once("error", (error) => { - complete("failed", null, `Provider process error: ${error.message}`); - }); - child.once("close", (exitCode, signal) => { - if (sinkFailure) { - complete("failed", exitCode, sinkFailure); - return; - } - if (timedOut) { - complete("failed", exitCode, `Provider process timed out after ${timeoutMs}ms`); - return; - } - if (requestedSignal) { - complete("stopped", exitCode, `Provider process stopped by ${requestedSignal}`); - return; - } - if (exitCode === 0) { - complete("completed", 0); - return; + }, + models: { + default: "claude-opus-5", + available: [ + { + id: "claude-fable-5", + alias: "fable", + name: "Claude Fable 5", + description: "Anthropic's highest-capability widely released model for long-running agents", + tier: "frontier", + pricing: { inputPerMTok: 10, outputPerMTok: 50 }, + contextWindow: 1e6, + maxOutputTokens: 128e3, + knowledgeCutoff: "2026-01", + trainingCutoff: "2026-01", + adaptiveThinking: true + }, + { + id: "claude-opus-5", + alias: "opus", + name: "Claude Opus 5", + description: "Recommended for complex agentic coding and enterprise work", + tier: "pro", + default: true, + pricing: { inputPerMTok: 5, outputPerMTok: 25, cachedReadPerMTok: 0.5, cachedWritePerMTok: 6.25 }, + contextWindow: 1e6, + maxOutputTokens: 128e3, + knowledgeCutoff: "2026-05", + trainingCutoff: "2026-05", + adaptiveThinking: true + }, + { + id: "claude-sonnet-5", + alias: "sonnet", + name: "Claude Sonnet 5", + description: "Best combination of speed and intelligence", + tier: "pro", + pricing: { inputPerMTok: 3, outputPerMTok: 15, cachedReadPerMTok: 0.3, cachedWritePerMTok: 3.75 }, + contextWindow: 1e6, + maxOutputTokens: 128e3, + knowledgeCutoff: "2026-01", + trainingCutoff: "2026-01", + adaptiveThinking: true + }, + { + id: "claude-haiku-4-5-20251001", + alias: "haiku", + name: "Haiku 4.5", + description: "Fastest model with near-frontier intelligence", + tier: "free", + pricing: { inputPerMTok: 1, outputPerMTok: 5, cachedReadPerMTok: 0.1, cachedWritePerMTok: 1.25 }, + contextWindow: 2e5, + maxOutputTokens: 64e3, + knowledgeCutoff: "2025-02", + trainingCutoff: "2025-07" } - const detail = stderrTail.trim() || `Provider process exited with code ${exitCode}${signal ? ` (${signal})` : ""}`; - complete("failed", exitCode, detail); - }); - }); -} - -// src/agent-host/launch-store.js -var import_node_fs4 = __toESM(require("node:fs"), 1); -var import_node_path4 = __toESM(require("node:path"), 1); -var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1); -var LAUNCH_STATUSES = Object.freeze([ - "starting", - "running", - "completed", - "failed", - "stopped" -]); -var LAUNCH_DISPOSITIONS = Object.freeze(["retained", "promoted", "discarded"]); -var LAUNCH_EXECUTION_KINDS = Object.freeze(["foreground", "detached"]); -var GROUP_ID_PATTERN = /^group_[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; -var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "stopped"]); -var TRANSITIONS = Object.freeze({ - starting: /* @__PURE__ */ new Set(["running", "failed", "stopped"]), - running: /* @__PURE__ */ new Set(["completed", "failed", "stopped"]), - completed: /* @__PURE__ */ new Set(), - failed: /* @__PURE__ */ new Set(), - stopped: /* @__PURE__ */ new Set() -}); -function requiredString(value, field, maxLength = 4096) { - if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { - throw new Error(`${field} must be a non-empty string without NUL bytes`); - } - if (value.length > maxLength) { - throw new Error(`${field} exceeds ${maxLength} characters`); - } - return value; -} -function optionalString(value, field, maxLength = 4096) { - if (value == null) return null; - return requiredString(value, field, maxLength); -} -function mapLaunch(row) { - if (!row) return null; - return { - baseRef: row.base_ref, - disposition: row.disposition, - executionKind: row.execution_kind, - executionWorkspace: row.execution_workspace, - exitCode: row.exit_code, - finishedAt: row.finished_at, - lastError: row.last_error, - launchId: row.launch_id, - model: row.model, - nativeSessionId: row.native_session_id, - originDirectory: row.origin_directory, - ownerPid: row.owner_pid, - outputDestination: row.output_destination, - parentLaunchId: row.parent_launch_id, - pid: row.pid, - projectRoot: row.project_root, - provider: row.provider, - startedAt: row.started_at, - status: row.status, - updatedAt: row.updated_at, - workspaceMode: row.workspace_mode, - worktreeBranch: row.worktree_branch - }; -} -function validateStatus(status) { - if (!LAUNCH_STATUSES.includes(status)) { - throw new Error(`Unknown launch status: ${status}`); - } - return status; -} -function validateEnum(value, field, allowed) { - if (!allowed.includes(value)) { - throw new Error(`Unknown ${field}: ${value}`); - } - return value; -} -function optionalPid(value, field) { - if (value == null) return null; - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error(`${field} must be a positive integer`); - } - return parsed; -} -function assertAgentGroupId(groupId) { - if (typeof groupId !== "string" || !GROUP_ID_PATTERN.test(groupId)) { - throw new Error("Invalid Agent Host group ID"); - } - return groupId; -} -function deriveGroupStatus(launches) { - const statuses = launches.map((launch) => launch.status); - if (statuses.includes("running")) return "running"; - if (statuses.includes("starting")) return "starting"; - if (statuses.every((status) => status === "completed")) return "completed"; - if (statuses.some((status) => status === "completed")) return "partial"; - if (statuses.every((status) => status === "stopped")) return "stopped"; - return "failed"; -} -function ensureColumn(database, name, definition) { - const columns = new Set(database.prepare("PRAGMA table_info(agent_launches)").all().map((row) => row.name)); - if (!columns.has(name)) database.exec(`ALTER TABLE agent_launches ADD COLUMN ${name} ${definition}`); -} -function initialize(database) { - database.pragma("journal_mode = WAL"); - database.pragma("foreign_keys = ON"); - database.exec(` - CREATE TABLE IF NOT EXISTS agent_launches ( - launch_id TEXT PRIMARY KEY, - parent_launch_id TEXT REFERENCES agent_launches(launch_id), - provider TEXT NOT NULL, - native_session_id TEXT, - origin_directory TEXT NOT NULL, - project_root TEXT NOT NULL, - execution_workspace TEXT NOT NULL, - output_destination TEXT NOT NULL, - workspace_mode TEXT NOT NULL CHECK (workspace_mode IN ('read-only', 'worktree', 'isolated-copy')), - worktree_branch TEXT, - base_ref TEXT, - model TEXT NOT NULL, - execution_kind TEXT NOT NULL DEFAULT 'foreground' CHECK (execution_kind IN ('foreground', 'detached')), - owner_pid INTEGER, - disposition TEXT NOT NULL DEFAULT 'retained' CHECK (disposition IN ('retained', 'promoted', 'discarded')), - status TEXT NOT NULL CHECK (status IN ('starting', 'running', 'completed', 'failed', 'stopped')), - pid INTEGER, - exit_code INTEGER, - started_at TEXT NOT NULL, - finished_at TEXT, - updated_at TEXT NOT NULL, - last_error TEXT - ); - - CREATE INDEX IF NOT EXISTS idx_agent_launches_status_started - ON agent_launches(status, started_at DESC); - CREATE INDEX IF NOT EXISTS idx_agent_launches_native_session - ON agent_launches(provider, native_session_id); - - CREATE TABLE IF NOT EXISTS agent_groups ( - group_id TEXT PRIMARY KEY, - origin_directory TEXT NOT NULL, - workspace TEXT NOT NULL, - workspace_mode TEXT NOT NULL CHECK (workspace_mode IN ('auto', 'read-only', 'worktree', 'isolated-copy')), - started_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS agent_group_launches ( - group_id TEXT NOT NULL REFERENCES agent_groups(group_id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL, - launch_id TEXT NOT NULL UNIQUE, - provider TEXT NOT NULL, - last_error TEXT, - PRIMARY KEY (group_id, ordinal) - ); - - CREATE INDEX IF NOT EXISTS idx_agent_group_launches_group - ON agent_group_launches(group_id, ordinal); - `); - ensureColumn(database, "execution_kind", "TEXT NOT NULL DEFAULT 'foreground' CHECK (execution_kind IN ('foreground', 'detached'))"); - ensureColumn(database, "owner_pid", "INTEGER"); - ensureColumn(database, "disposition", "TEXT NOT NULL DEFAULT 'retained' CHECK (disposition IN ('retained', 'promoted', 'discarded'))"); -} -function createLaunchStore({ - databasePath = getAgentHostPaths().stateDatabase, - now = () => (/* @__PURE__ */ new Date()).toISOString() -} = {}) { - const resolvedPath = import_node_path4.default.resolve(databasePath); - import_node_fs4.default.mkdirSync(import_node_path4.default.dirname(resolvedPath), { recursive: true, mode: 448 }); - const database = new import_better_sqlite3.default(resolvedPath); - import_node_fs4.default.chmodSync(resolvedPath, 384); - initialize(database); - const getStatement = database.prepare("SELECT * FROM agent_launches WHERE launch_id = ?"); - function get(launchId) { - assertLaunchId(launchId); - return mapLaunch(getStatement.get(launchId)); - } - function create(projection) { - const launchId = assertLaunchId(projection?.launchId); - const status = validateStatus(projection?.status || "starting"); - if (status !== "starting") { - throw new Error("New launches must start in the starting state"); - } - const timestamp = now(); - const record = { - baseRef: optionalString(projection.baseRef, "baseRef", 512), - disposition: validateEnum(projection.disposition || "retained", "launch disposition", LAUNCH_DISPOSITIONS), - executionKind: validateEnum(projection.executionKind || "foreground", "execution kind", LAUNCH_EXECUTION_KINDS), - executionWorkspace: requiredString(projection.executionWorkspace, "executionWorkspace"), - launchId, - model: requiredString(projection.model, "model", 512), - nativeSessionId: optionalString(projection.nativeSessionId, "nativeSessionId", 1024), - originDirectory: requiredString(projection.originDirectory, "originDirectory"), - ownerPid: optionalPid(projection.ownerPid, "ownerPid"), - outputDestination: requiredString(projection.outputDestination, "outputDestination"), - parentLaunchId: projection.parentLaunchId == null ? null : assertLaunchId(projection.parentLaunchId), - projectRoot: requiredString(projection.projectRoot, "projectRoot"), - provider: requiredString(projection.provider, "provider", 64), - status, - workspaceMode: requiredString(projection.workspaceMode, "workspaceMode", 32), - worktreeBranch: optionalString(projection.worktreeBranch, "worktreeBranch", 512) - }; - database.prepare(` - INSERT INTO agent_launches ( - launch_id, parent_launch_id, provider, native_session_id, - origin_directory, project_root, execution_workspace, output_destination, - workspace_mode, worktree_branch, base_ref, model, status, - execution_kind, owner_pid, disposition, started_at, updated_at - ) VALUES ( - @launchId, @parentLaunchId, @provider, @nativeSessionId, - @originDirectory, @projectRoot, @executionWorkspace, @outputDestination, - @workspaceMode, @worktreeBranch, @baseRef, @model, @status, - @executionKind, @ownerPid, @disposition, @startedAt, @updatedAt - ) - `).run({ ...record, startedAt: timestamp, updatedAt: timestamp }); - return get(launchId); - } - function transition(launchId, nextStatus, patch = {}) { - assertLaunchId(launchId); - validateStatus(nextStatus); - const current = get(launchId); - if (!current) throw new Error(`Launch not found: ${launchId}`); - if (!TRANSITIONS[current.status].has(nextStatus)) { - throw new Error(`Invalid launch transition: ${current.status} -> ${nextStatus}`); - } - const timestamp = now(); - const pid = patch.pid == null ? current.pid : Number(patch.pid); - const exitCode = patch.exitCode == null ? current.exitCode : Number(patch.exitCode); - if (pid != null && (!Number.isSafeInteger(pid) || pid < 0)) { - throw new Error("pid must be a non-negative integer"); - } - if (exitCode != null && !Number.isSafeInteger(exitCode)) { - throw new Error("exitCode must be an integer"); - } - database.prepare(` - UPDATE agent_launches - SET status = @status, - pid = @pid, - owner_pid = @ownerPid, - exit_code = @exitCode, - native_session_id = COALESCE(@nativeSessionId, native_session_id), - last_error = @lastError, - finished_at = @finishedAt, - updated_at = @updatedAt - WHERE launch_id = @launchId - `).run({ - exitCode, - finishedAt: TERMINAL_STATUSES.has(nextStatus) ? timestamp : null, - lastError: optionalString(patch.lastError, "lastError", 4096), - launchId, - nativeSessionId: optionalString(patch.nativeSessionId, "nativeSessionId", 1024), - ownerPid: TERMINAL_STATUSES.has(nextStatus) ? null : optionalPid(patch.ownerPid == null ? current.ownerPid : patch.ownerPid, "ownerPid"), - pid, - status: nextStatus, - updatedAt: timestamp - }); - return get(launchId); - } - function setDisposition(launchId, disposition) { - assertLaunchId(launchId); - const next = validateEnum(disposition, "launch disposition", LAUNCH_DISPOSITIONS); - const current = get(launchId); - if (!current) throw new Error(`Launch not found: ${launchId}`); - if (current.disposition === next) return current; - if (current.disposition !== "retained") { - throw new Error(`Launch is already ${current.disposition}: ${launchId}`); - } - if (next === "retained") return current; - database.prepare(` - UPDATE agent_launches - SET disposition = ?, updated_at = ? - WHERE launch_id = ? - `).run(next, now(), launchId); - return get(launchId); - } - function setNativeSessionId(launchId, nativeSessionId) { - assertLaunchId(launchId); - const validNativeId = requiredString(nativeSessionId, "nativeSessionId", 1024); - const result = database.prepare(` - UPDATE agent_launches - SET native_session_id = ?, updated_at = ? - WHERE launch_id = ? - `).run(validNativeId, now(), launchId); - if (result.changes === 0) throw new Error(`Launch not found: ${launchId}`); - return get(launchId); - } - function list({ limit = 50, status = null } = {}) { - const numericLimit = Number(limit); - if (!Number.isSafeInteger(numericLimit) || numericLimit < 1 || numericLimit > 1e3) { - throw new Error("limit must be an integer between 1 and 1000"); - } - if (status != null) validateStatus(status); - const rows = status == null ? database.prepare(` - SELECT * FROM agent_launches - ORDER BY started_at DESC, rowid DESC - LIMIT ? - `).all(numericLimit) : database.prepare(` - SELECT * FROM agent_launches - WHERE status = ? - ORDER BY started_at DESC, rowid DESC - LIMIT ? - `).all(status, numericLimit); - return rows.map(mapLaunch); - } - function getGroup(groupId) { - assertAgentGroupId(groupId); - const row = database.prepare("SELECT * FROM agent_groups WHERE group_id = ?").get(groupId); - if (!row) return null; - const taskRows = database.prepare(` - SELECT launch_id, provider, last_error - FROM agent_group_launches - WHERE group_id = ? - ORDER BY ordinal ASC - `).all(groupId); - const launches = taskRows.map((task) => { - const launch = get(task.launch_id); - if (launch) return launch; - return { - lastError: task.last_error, - launchId: task.launch_id, - provider: task.provider, - status: task.last_error ? "failed" : "starting" - }; - }); - const status = deriveGroupStatus(launches); - const finishedAt = ["completed", "partial", "failed", "stopped"].includes(status) ? launches.map((launch) => launch.finishedAt).filter(Boolean).sort().at(-1) || row.updated_at : null; - return { - finishedAt, - groupId: row.group_id, - launches, - originDirectory: row.origin_directory, - startedAt: row.started_at, - status, - updatedAt: row.updated_at, - workspace: row.workspace, - workspaceMode: row.workspace_mode - }; - } - function createGroup(projection) { - const groupId = assertAgentGroupId(projection?.groupId); - const tasks = projection?.tasks; - if (!Array.isArray(tasks) || tasks.length < 2 || tasks.length > 10) { - throw new Error("Agent Host group requires between 2 and 10 tasks"); - } - const validatedTasks = tasks.map((task, ordinal) => ({ - launchId: assertLaunchId(task?.launchId), - ordinal, - provider: requiredString(task?.provider, `tasks[${ordinal}].provider`, 64) - })); - if (new Set(validatedTasks.map((task) => task.launchId)).size !== validatedTasks.length) { - throw new Error("Agent Host group launch IDs must be unique"); - } - const timestamp = now(); - const record = { - groupId, - originDirectory: requiredString(projection.originDirectory, "originDirectory"), - startedAt: timestamp, - updatedAt: timestamp, - workspace: requiredString(projection.workspace, "workspace"), - workspaceMode: validateEnum( - projection.workspaceMode || "auto", - "group workspace mode", - ["auto", "read-only", "worktree", "isolated-copy"] - ) - }; - database.transaction(() => { - database.prepare(` - INSERT INTO agent_groups ( - group_id, origin_directory, workspace, workspace_mode, started_at, updated_at - ) VALUES ( - @groupId, @originDirectory, @workspace, @workspaceMode, @startedAt, @updatedAt - ) - `).run(record); - const insertTask = database.prepare(` - INSERT INTO agent_group_launches (group_id, ordinal, launch_id, provider) - VALUES (?, ?, ?, ?) - `); - for (const task of validatedTasks) { - insertTask.run(groupId, task.ordinal, task.launchId, task.provider); - } - })(); - return getGroup(groupId); - } - function setGroupLaunchError(groupId, launchId, lastError) { - assertAgentGroupId(groupId); - assertLaunchId(launchId); - const result = database.prepare(` - UPDATE agent_group_launches - SET last_error = ? - WHERE group_id = ? AND launch_id = ? - `).run(requiredString(lastError, "lastError", 4096), groupId, launchId); - if (result.changes === 0) throw new Error(`Group launch not found: ${groupId}/${launchId}`); - database.prepare("UPDATE agent_groups SET updated_at = ? WHERE group_id = ?").run(now(), groupId); - return getGroup(groupId); - } - function listGroups({ limit = 50 } = {}) { - const numericLimit = Number(limit); - if (!Number.isSafeInteger(numericLimit) || numericLimit < 1 || numericLimit > 1e3) { - throw new Error("limit must be an integer between 1 and 1000"); - } - return database.prepare(` - SELECT group_id FROM agent_groups - ORDER BY started_at DESC, rowid DESC - LIMIT ? - `).all(numericLimit).map((row) => getGroup(row.group_id)); + ] + }, + capabilities: { + streaming: true, + partialStreaming: true, + tools: true, + thinking: true, + adaptiveThinking: true, + systemPrompt: { append: true, replace: true, fromFile: true }, + sessionResume: true, + sessionContinue: true, + forkSession: true, + conversationHistory: "server", + contextLimitTokens: 2e5, + contextLimitExtended: 1e6, + structuredOutput: true, + subagents: true, + skills: true, + plugins: true, + rawArgs: true, + chrome: true, + planMode: true, + opusPlan: true, + maxTurns: true, + maxBudget: true, + permissionPromptTool: true, + inputStreaming: true, + addDirs: true, + pluginDirs: true, + mcpConfig: true, + settingsOverride: true, + imageInput: true, + imageGeneration: { native: false, via: "RUDI image-generator stack" }, + webSearch: false, + codeReview: false, + sandbox: false, + effortLevel: true, + remote: true, + teleport: true } - return { - close() { - if (database.open) database.close(); - }, - create, - createGroup, - database, - get, - getGroup, - list, - listGroups, - setDisposition, - setGroupLaunchError, - setNativeSessionId, - transition - }; -} - -// src/agent-host/preflight.js -var import_node_fs8 = __toESM(require("node:fs"), 1); -var import_node_os5 = __toESM(require("node:os"), 1); -var import_node_path7 = __toESM(require("node:path"), 1); -var import_node_child_process3 = require("node:child_process"); - -// src/agent-host/providers/catalog.js -var import_node_fs5 = require("node:fs"); -var import_node_os3 = require("node:os"); +}; -// src/agent-host/providers/config/claude.json -var claude_default = { +// src/agent-host/providers/config/codex.json +var codex_default = { $schema: "https://learnrudi.com/schemas/headless-agent-v1.json", - id: "claude", - name: "Claude Code", - description: "Anthropic Claude Code CLI \u2014 headless mode", + id: "codex", + name: "Codex", + description: "OpenAI Codex CLI \u2014 headless mode", version: "1.0.0", binary: { - name: "claude", + name: "codex", resolvePaths: [ - "~/.local/bin/claude", - "~/.rudi/runtimes/node/{arch}/bin/claude", - "~/.rudi/runtimes/node/bin/claude", - "~/.rudi/agents/claude/node_modules/.bin/claude" + "~/.rudi/agents/codex/node_modules/.bin/codex", + "~/.rudi/runtimes/node/{arch}/bin/codex", + "~/.rudi/runtimes/node/bin/codex" ], fallback: "which", - checkCommand: ["claude", "--version"], - loginCommand: ["claude", "auth", "login"], - authCheck: ["claude", "auth", "status"] + checkCommand: ["codex", "--version"], + loginCommand: ["codex", "login"], + authCheck: ["codex", "login", "status"] }, headless: { - command: "claude", - promptDelivery: "arg-or-stdin", + command: "codex", + subcommand: "exec", + promptDelivery: "arg", + stdinPrompt: "-", + privateAutomation: { + minimumVersion: "0.146.0", + profile: "private-automation-v1", + promptDelivery: "stdin", + sessionPersistence: false, + tools: false + }, args: { + prefixConditionals: [ + { if: "approvalPolicy", args: ["--ask-for-approval", "{{approvalPolicy}}"] }, + { if: "search", args: ["--search"] } + ], base: [ - "--output-format", - "stream-json", - "--verbose" - ], - conditionals: [ - { if: "print", args: ["--print"] }, - { if: "prompt", args: ["-p", "{{prompt}}"] }, - { if: "model", args: ["--model", "{{model}}"] }, - { if: "fallbackModel", args: ["--fallback-model", "{{fallbackModel}}"] }, - { if: "systemPrompt", args: ["--append-system-prompt", "{{systemPrompt}}"] }, - { if: "systemPromptFile", args: ["--append-system-prompt-file", "{{systemPromptFile}}"] }, - { if: "replaceSystemPrompt", args: ["--system-prompt", "{{replaceSystemPrompt}}"] }, - { if: "replaceSystemPromptFile", args: ["--system-prompt-file", "{{replaceSystemPromptFile}}"] }, - { if: "allowedTools", args: ["--allowedTools", "{{allowedTools|join: }}"] }, - { if: "disallowedTools", args: ["--disallowedTools", "{{disallowedTools|join: }}"] }, - { if: "tools", args: ["--tools", "{{tools|join:,}}"] }, - { if: "mcpConfig", args: ["--mcp-config", "{{mcpConfig}}"] }, - { if: "strictMcpConfig", args: ["--strict-mcp-config"] }, - { if: "resumeSessionId", args: ["--resume", "{{resumeSessionId}}"] }, - { if: "continueSession", args: ["--continue"] }, - { if: "sessionId", args: ["--session-id", "{{sessionId}}"] }, - { if: "forkSession", args: ["--fork-session"] }, - { if: "jsonSchema", args: ["--json-schema", "{{jsonSchema}}"] }, - { if: "maxTurns", args: ["--max-turns", "{{maxTurns}}"] }, - { if: "maxBudgetUsd", args: ["--max-budget-usd", "{{maxBudgetUsd}}"] }, - { if: "noSessionPersistence", args: ["--no-session-persistence"] }, - { if: "addDirs", args: ["--add-dir", "{{addDirs|join: }}"] }, - { if: "agents", args: ["--agents", "{{agents}}"] }, - { if: "agent", args: ["--agent", "{{agent}}"] }, - { if: "effort", args: ["--effort", "{{effort}}"] }, - { if: "bare", args: ["--bare"] }, - { if: "safeMode", args: ["--safe-mode"] }, - { if: "background", args: ["--background"] }, - { if: "worktree", args: ["--worktree", "{{worktree}}"] }, - { if: "tmux", args: ["--tmux", "{{tmux}}"] }, - { if: "name", args: ["--name", "{{name}}"] }, - { if: "includeHookEvents", args: ["--include-hook-events"] }, - { if: "promptSuggestions", args: ["--prompt-suggestions", "{{promptSuggestions}}"] }, - { if: "pluginUrl", args: ["--plugin-url", "{{pluginUrl}}"] }, - { if: "includePartialMessages", args: ["--include-partial-messages"] }, - { if: "inputFormat", args: ["--input-format", "{{inputFormat}}"] }, - { if: "replayUserMessages", args: ["--replay-user-messages"] }, - { if: "chrome", args: ["--chrome"] }, - { if: "noChrome", args: ["--no-chrome"] }, - { if: "debug", args: ["--debug", "{{debug}}"] }, - { if: "debugFile", args: ["--debug-file", "{{debugFile}}"] }, - { if: "betas", args: ["--betas", "{{betas|join: }}"] }, - { if: "settings", args: ["--settings", "{{settings}}"] }, - { if: "settingSources", args: ["--setting-sources", "{{settingSources}}"] }, - { if: "pluginDir", args: ["--plugin-dir", "{{pluginDir}}"] }, - { if: "disableSlashCommands", args: ["--disable-slash-commands"] }, - { if: "permissionPromptTool", args: ["--permission-prompt-tool", "{{permissionPromptTool}}"] }, - { if: "teammateMode", args: ["--teammate-mode", "{{teammateMode}}"] }, - { if: "file", args: ["--file", "{{file|join: }}"] }, - { if: "fromPr", args: ["--from-pr", "{{fromPr}}"] }, - { if: "remote", args: ["--remote", "{{remote}}"] }, - { if: "teleport", args: ["--teleport"] }, - { if: "ide", args: ["--ide"] }, - { if: "init", args: ["--init"] }, - { if: "initOnly", args: ["--init-only"] }, - { if: "maintenance", args: ["--maintenance"] }, - { if: "allowDangerouslySkipPermissions", args: ["--allow-dangerously-skip-permissions"] }, - { if: "outputFormat", args: ["--output-format", "{{outputFormat}}"] } - ] - }, - permissionModes: { - agent: ["--dangerously-skip-permissions"], - plan: ["--permission-mode", "plan"], - acceptEdits: ["--permission-mode", "acceptEdits"], - auto: ["--permission-mode", "auto"], - dontAsk: ["--permission-mode", "dontAsk"], - bypassPermissions: ["--permission-mode", "bypassPermissions"], - default: ["--permission-mode", "default"] - }, - env: { - TERM: "xterm-256color", - CI: "true", - CLAUDE_NO_UPDATE_CHECK: "true", - DISABLE_AUTOUPDATE: "1", - NO_COLOR: "1" - }, - authEnvVars: [ - "ANTHROPIC_API_KEY", - "CLAUDE_CODE_OAUTH_TOKEN" - ], - stdin: "pipe", - timeouts: { - startupMs: 12e4, - runtimeMs: 9e5, - shutdownGraceMs: 5e3 - } - }, - eventStream: { - format: "json-lines", - sessionIdExtractor: { - path: "$.session_id", - fromEventTypes: ["assistant", "result"] - }, - events: { - system: { - condition: "$.type === 'system'", - fields: { - subtype: "$.subtype", - message: "$.message", - content: "$.message.content[*]", - compactMetadata: "$.compactMetadata" - }, - subtypes: ["init", "compact_boundary"] - }, - assistant: { - condition: "$.type === 'assistant'", - fields: { - messageId: "$.message.id", - role: "$.message.role", - model: "$.message.model", - stopReason: "$.message.stop_reason", - content: "$.message.content[*]", - usage: { - inputTokens: "$.message.usage.input_tokens", - outputTokens: "$.message.usage.output_tokens", - cacheReadTokens: "$.message.usage.cache_read_input_tokens", - cacheCreationTokens: "$.message.usage.cache_creation_input_tokens" - } - }, - contentBlockTypes: { - text: { - condition: "block.type === 'text'", - fields: { text: "block.text" } - }, - tool_use: { - condition: "block.type === 'tool_use'", - fields: { - id: "block.id", - name: "block.name", - input: "block.input" - } - }, - tool_result: { - condition: "block.type === 'tool_result'", - fields: { - id: "block.id", - content: "block.content" - } - }, - thinking: { - condition: "block.type === 'thinking'", - fields: { thinking: "block.thinking" } - } - } - }, - result: { - condition: "$.type === 'result'", - fields: { - sessionId: "$.session_id", - result: "$.result", - structuredOutput: "$.structured_output", - totalCostUsd: "$.total_cost_usd", - durationMs: "$.duration_ms", - numTurns: "$.num_turns", - usage: { - inputTokens: "$.usage.input_tokens", - outputTokens: "$.usage.output_tokens", - cacheReadTokens: "$.usage.cache_read_input_tokens", - cacheCreationTokens: "$.usage.cache_creation_input_tokens" - } - } - }, - error: { - condition: "$.type === 'error'", - fields: { - message: "$.result", - errorCode: "$.error_code" - } - }, - stream_event: { - condition: "$.type === 'stream_event'", - note: "Only emitted with --include-partial-messages", - fields: { - eventType: "$.event.type", - event: "$.event" - }, - innerEventTypes: { - message_start: {}, - content_block_start: { - fields: { - blockType: "$.event.content_block.type", - blockId: "$.event.content_block.id", - toolName: "$.event.content_block.name" - } - }, - content_block_delta: { - deltaTypes: { - text_delta: { fields: { text: "$.event.delta.text" } }, - input_json_delta: { fields: { partialJson: "$.event.delta.partial_json" } } - } - }, - content_block_stop: {}, - message_delta: { - fields: { - stopReason: "$.event.delta.stop_reason", - usage: "$.event.usage" - } - }, - message_stop: {} - } - } - } - }, - models: { - default: "claude-opus-5", - available: [ - { - id: "claude-fable-5", - alias: "fable", - name: "Claude Fable 5", - description: "Anthropic's highest-capability widely released model for long-running agents", - tier: "frontier", - pricing: { inputPerMTok: 10, outputPerMTok: 50 }, - contextWindow: 1e6, - maxOutputTokens: 128e3, - knowledgeCutoff: "2026-01", - trainingCutoff: "2026-01", - adaptiveThinking: true - }, - { - id: "claude-opus-5", - alias: "opus", - name: "Claude Opus 5", - description: "Recommended for complex agentic coding and enterprise work", - tier: "pro", - default: true, - pricing: { inputPerMTok: 5, outputPerMTok: 25, cachedReadPerMTok: 0.5, cachedWritePerMTok: 6.25 }, - contextWindow: 1e6, - maxOutputTokens: 128e3, - knowledgeCutoff: "2026-05", - trainingCutoff: "2026-05", - adaptiveThinking: true - }, - { - id: "claude-sonnet-5", - alias: "sonnet", - name: "Claude Sonnet 5", - description: "Best combination of speed and intelligence", - tier: "pro", - pricing: { inputPerMTok: 3, outputPerMTok: 15, cachedReadPerMTok: 0.3, cachedWritePerMTok: 3.75 }, - contextWindow: 1e6, - maxOutputTokens: 128e3, - knowledgeCutoff: "2026-01", - trainingCutoff: "2026-01", - adaptiveThinking: true - }, - { - id: "claude-haiku-4-5-20251001", - alias: "haiku", - name: "Haiku 4.5", - description: "Fastest model with near-frontier intelligence", - tier: "free", - pricing: { inputPerMTok: 1, outputPerMTok: 5, cachedReadPerMTok: 0.1, cachedWritePerMTok: 1.25 }, - contextWindow: 2e5, - maxOutputTokens: 64e3, - knowledgeCutoff: "2025-02", - trainingCutoff: "2025-07" - } - ] - }, - capabilities: { - streaming: true, - partialStreaming: true, - tools: true, - thinking: true, - adaptiveThinking: true, - systemPrompt: { append: true, replace: true, fromFile: true }, - sessionResume: true, - sessionContinue: true, - forkSession: true, - conversationHistory: "server", - contextLimitTokens: 2e5, - contextLimitExtended: 1e6, - structuredOutput: true, - subagents: true, - skills: true, - plugins: true, - rawArgs: true, - chrome: true, - planMode: true, - opusPlan: true, - maxTurns: true, - maxBudget: true, - permissionPromptTool: true, - inputStreaming: true, - addDirs: true, - pluginDirs: true, - mcpConfig: true, - settingsOverride: true, - imageInput: true, - imageGeneration: { native: false, via: "RUDI image-generator stack" }, - webSearch: false, - codeReview: false, - sandbox: false, - effortLevel: true, - remote: true, - teleport: true - } -}; - -// src/agent-host/providers/config/codex.json -var codex_default = { - $schema: "https://learnrudi.com/schemas/headless-agent-v1.json", - id: "codex", - name: "Codex", - description: "OpenAI Codex CLI \u2014 headless mode", - version: "1.0.0", - binary: { - name: "codex", - resolvePaths: [ - "~/.rudi/agents/codex/node_modules/.bin/codex", - "~/.rudi/runtimes/node/{arch}/bin/codex", - "~/.rudi/runtimes/node/bin/codex" - ], - fallback: "which", - checkCommand: ["codex", "--version"], - loginCommand: ["codex", "login"], - authCheck: ["codex", "login", "status"] - }, - headless: { - command: "codex", - subcommand: "exec", - promptDelivery: "arg", - stdinPrompt: "-", - args: { - prefixConditionals: [ - { if: "approvalPolicy", args: ["--ask-for-approval", "{{approvalPolicy}}"] }, - { if: "search", args: ["--search"] } - ], - base: [ - "exec", - "{{prompt}}", - "--json", - "--skip-git-repo-check", - "--color", - "never" + "exec", + "{{prompt}}", + "--json", + "--skip-git-repo-check", + "--color", + "never" ], conditionals: [ { if: "cwd", args: ["-C", "{{cwd}}"] }, @@ -31459,125 +30827,1219 @@ var PROVIDER_CONFIGS = { function listProviders() { return Object.keys(PROVIDER_CONFIGS); } -function loadProviderConfig(providerId) { - const config = PROVIDER_CONFIGS[providerId]; - if (!config) { - const available = listProviders().join(", "); - throw new Error(`Unknown agent provider: ${providerId}. Available: ${available}`); +function loadProviderConfig(providerId) { + const config = PROVIDER_CONFIGS[providerId]; + if (!config) { + const available = listProviders().join(", "); + throw new Error(`Unknown agent provider: ${providerId}. Available: ${available}`); + } + return config; +} +function resolveProviderBinary(config) { + const home = (0, import_node_os3.homedir)(); + const arch = process.arch; + for (const rawPath of config.binary.resolvePaths) { + const resolved = rawPath.replace(/^~/, home).replace(/\{arch\}/g, arch); + if ((0, import_node_fs4.existsSync)(resolved)) { + return resolved; + } + } + if (config.binary.fallback === "which") { + try { + return runCommandPlan2(createWhichCommand(config.binary.name), { encoding: "utf-8" }).trim(); + } catch { + } + } + return null; +} +function resolveModel(config, aliasOrId) { + if (!aliasOrId) return config.models.default; + for (const m of config.models.available) { + if (m.alias === aliasOrId || m.id === aliasOrId) return m.id; + } + return aliasOrId; +} +function getModelDef(config, aliasOrId) { + const id = resolveModel(config, aliasOrId); + return config.models.available.find((m) => m.id === id) || null; +} +function buildArgs(config, options = {}) { + const globalExtraArgs = normalizeExtraArgs(options.globalExtraArgs, "globalExtraArgs"); + const extraArgs = normalizeExtraArgs(options.extraArgs); + const args = [...globalExtraArgs]; + appendConditionals(args, config.headless.args.prefixConditionals || [], options); + for (const arg of config.headless.args.base) { + args.push(expandTemplate(arg, options)); + } + appendConditionals(args, config.headless.args.conditionals, options); + args.push(...extraArgs); + return args; +} +function appendConditionals(args, conditionals, options) { + for (const cond of conditionals) { + const key = cond.if; + if (options[key] == null || options[key] === false) continue; + for (const arg of cond.args) { + const expanded = expandTemplate(arg, options); + if (expanded !== arg || !arg.includes("{{")) { + args.push(expanded); + } + } + } +} +function normalizeExtraArgs(value, optionName = "extraArgs") { + if (value == null) return []; + if (!Array.isArray(value)) { + throw new TypeError(`${optionName} must be an array of strings`); + } + return value.map((arg, index) => { + if (typeof arg !== "string" || arg.trim() === "" || arg.includes("\0")) { + throw new TypeError(`${optionName}[${index}] must be a non-empty string without NUL bytes`); + } + return arg; + }); +} +function getPermissionArgs(config, mode) { + const modes = config.headless.permissionModes; + if (!modes[mode]) { + throw new Error(`Unknown permission mode: ${mode}. Available: ${Object.keys(modes).join(", ")}`); + } + return modes[mode]; +} +function buildEnv2(config, secrets = {}) { + const env = { ...config.headless.env }; + for (const key of config.headless.authEnvVars) { + if (secrets[key]) env[key] = secrets[key]; + } + return env; +} +function buildSubcommandArgs(config, subcommand, options = {}) { + const extraArgs = normalizeExtraArgs(options.extraArgs); + const subs = config.headless.subcommands; + if (!subs) return null; + if (!subs[subcommand]) { + throw new Error(`Unknown subcommand: ${subcommand}. Available: ${Object.keys(subs).join(", ")}`); + } + const sub = subs[subcommand]; + const args = [...sub.args]; + for (const cond of sub.conditionals) { + const key = cond.if; + if (options[key] == null || options[key] === false) continue; + for (const arg of cond.args) { + args.push(expandTemplate(arg, options)); + } + } + args.push(...extraArgs); + return args; +} +function expandTemplate(str, options) { + return str.replace(/\{\{(\w+)(?:\|join:(.+?))?\}\}/g, (_, key, joinSep) => { + const val = options[key]; + if (val == null) return ""; + if (Array.isArray(val) && joinSep != null) return val.join(joinSep); + if (Array.isArray(val)) return val.join(" "); + return String(val); + }); +} + +// src/agent-host/private-automation-profile.js +var PRIVATE_AUTOMATION_PROFILE_ID = "private-automation-v1"; +var PRIVATE_AUTOMATION_MAX_PROMPT_BYTES = 2e5; +var PRIVATE_AUTOMATION_MAX_FINAL_OUTPUT_BYTES = 64 * 1024; +var PRIVATE_AUTOMATION_MAX_RAW_OUTPUT_BYTES = 2 * 1024 * 1024; +var PRIVATE_AUTOMATION_MAX_SCHEMA_BYTES = 64 * 1024; +var PRIVATE_AUTOMATION_MAX_TIMEOUT_MS = 165e3; +var PRIVATE_AUTOMATION_DEFAULT_TIMEOUT_MS = 16e4; +var PRIVATE_PROVIDERS = /* @__PURE__ */ new Set(["claude", "codex"]); +var PRIVATE_RAW_EVENT_TYPES = Object.freeze({ + claude: /* @__PURE__ */ new Set(["assistant", "error", "rate_limit_event", "result", "system"]), + codex: /* @__PURE__ */ new Set([ + "error", + "item.completed", + "item.started", + "item.updated", + "thread.started", + "turn.completed", + "turn.failed", + "turn.started" + ]) +}); +var PRIVATE_CODEX_ITEM_TYPES = /* @__PURE__ */ new Set(["agent_message", "reasoning"]); +var PRIVATE_CLAUDE_ASSISTANT_BLOCK_TYPES = /* @__PURE__ */ new Set(["text", "thinking"]); +var PRIVATE_CLAUDE_SYSTEM_SUBTYPES = /* @__PURE__ */ new Set(["init"]); +var PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ + "apps", + "browser_use", + "browser_use_external", + "browser_use_full_cdp_access", + "code_mode_host", + "computer_use", + "enable_mcp_apps", + "image_generation", + "in_app_browser", + "multi_agent", + "plugins", + "remote_plugin", + "shell_snapshot", + "shell_tool", + "skill_search", + "tool_call_mcp_elicitation", + "tool_suggest", + "unified_exec" +]); +function getPrivateCodexDisabledFeatures() { + return [...PRIVATE_CODEX_DISABLED_FEATURES]; +} +function requiredText(value, field, maxBytes = 4096) { + if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { + throw new Error(`${field} must be a non-empty string without NUL bytes`); + } + if (Buffer.byteLength(value, "utf8") > maxBytes) { + throw new Error(`${field} exceeds ${maxBytes} bytes`); + } + return value; +} +function containsSchemaReference(value) { + if (Array.isArray(value)) return value.some(containsSchemaReference); + if (!value || typeof value !== "object") return false; + if (Object.hasOwn(value, "$ref")) return true; + return Object.values(value).some(containsSchemaReference); +} +function readOutputSchema(outputSchemaPath) { + const requested = import_node_path4.default.resolve(requiredText(outputSchemaPath, "output schema path")); + let stat; + try { + stat = import_node_fs5.default.lstatSync(requested); + } catch { + throw new Error(`private automation output schema does not exist: ${requested}`); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error("private automation output schema must be a regular non-symlink file"); + } + if (stat.size < 2 || stat.size > PRIVATE_AUTOMATION_MAX_SCHEMA_BYTES) { + throw new Error(`private automation output schema must be between 2 and ${PRIVATE_AUTOMATION_MAX_SCHEMA_BYTES} bytes`); + } + let schema; + try { + schema = JSON.parse(import_node_fs5.default.readFileSync(requested, "utf8")); + } catch { + throw new Error("private automation output schema must contain valid JSON"); + } + if (!schema || Array.isArray(schema) || schema.type !== "object") { + throw new Error("private automation output schema must describe an object"); + } + if (schema.additionalProperties !== false) { + throw new Error("private automation output schema must set additionalProperties to false"); + } + if (!schema.properties || typeof schema.properties !== "object" || Array.isArray(schema.properties)) { + throw new Error("private automation output schema must declare object properties"); + } + if (!Array.isArray(schema.required)) { + throw new Error("private automation output schema must declare required properties"); + } + if (containsSchemaReference(schema)) { + throw new Error("external schema references are forbidden in private automation"); + } + return Object.freeze({ + canonical: JSON.stringify(schema), + path: import_node_fs5.default.realpathSync(requested), + schema: Object.freeze(schema) + }); +} +function exactConfiguredModel(provider, model) { + if (typeof model !== "string" || model.trim() === "") { + throw new Error("private automation exact model is required"); + } + const exactModel = requiredText(model, "private automation exact model", 512); + const config = loadProviderConfig(provider); + const definition = getModelDef(config, exactModel); + if (!definition || definition.id !== exactModel) { + throw new Error(`private automation requires a canonical configured model ID for ${provider}`); + } + return exactModel; +} +function validateTimeout(timeoutMs) { + const value = timeoutMs == null ? PRIVATE_AUTOMATION_DEFAULT_TIMEOUT_MS : Number(timeoutMs); + if (!Number.isSafeInteger(value) || value < 1 || value > PRIVATE_AUTOMATION_MAX_TIMEOUT_MS) { + throw new Error(`private automation timeoutMs must be an integer between 1 and ${PRIVATE_AUTOMATION_MAX_TIMEOUT_MS}`); + } + return value; +} +function createPrivateAutomationProfile({ + fallbackModel = null, + model, + outputSchemaPath, + provider, + timeoutMs +} = {}) { + if (!PRIVATE_PROVIDERS.has(provider)) { + throw new Error("private automation provider must be codex or claude"); + } + if (fallbackModel != null) { + throw new Error("private automation fallback model is forbidden"); + } + const exactModel = exactConfiguredModel(provider, model); + const outputSchema = readOutputSchema(outputSchemaPath); + return Object.freeze({ + id: PRIVATE_AUTOMATION_PROFILE_ID, + maxFinalOutputBytes: PRIVATE_AUTOMATION_MAX_FINAL_OUTPUT_BYTES, + maxPromptBytes: PRIVATE_AUTOMATION_MAX_PROMPT_BYTES, + maxRawOutputBytes: PRIVATE_AUTOMATION_MAX_RAW_OUTPUT_BYTES, + model: exactModel, + outputSchema, + provider, + timeoutMs: validateTimeout(timeoutMs) + }); +} +function containsToolEvent(value) { + if (Array.isArray(value)) return value.some(containsToolEvent); + if (!value || typeof value !== "object") return false; + if ([ + "command_execution", + "file_change", + "mcp_tool_call", + "permission", + "permission_request", + "server_tool_use", + "tool_result", + "tool_use" + ].includes(value.type)) return true; + return Object.values(value).some(containsToolEvent); +} +function boundedUsage(usage2) { + if (!usage2 || typeof usage2 !== "object" || Array.isArray(usage2)) return void 0; + const projected = {}; + for (const [key, raw] of Object.entries(usage2)) { + const value = Number(raw); + if (Number.isSafeInteger(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER) { + projected[key] = value; + } + } + return Object.keys(projected).length > 0 ? projected : void 0; +} +function projectPrivateAutomationEventMetadata(event) { + if (!event || typeof event !== "object" || Array.isArray(event)) { + throw new Error("private automation event must be an object"); + } + if (containsToolEvent(event)) { + throw new Error("private automation tool event is forbidden"); + } + const metadata = { type: requiredText(event.type, "private automation event type", 128) }; + if (typeof event.model === "string" && event.model.length > 0) metadata.model = event.model; + if (Array.isArray(event.content)) metadata.contentBlockCount = event.content.length; + const usage2 = boundedUsage(event.usage); + if (usage2) metadata.usage = usage2; + if (typeof event.durationMs === "number" && Number.isFinite(event.durationMs) && event.durationMs >= 0) { + metadata.durationMs = Math.floor(event.durationMs); + } + if (typeof event.numTurns === "number" && Number.isSafeInteger(event.numTurns) && event.numTurns >= 0) { + metadata.numTurns = event.numTurns; + } + return Object.freeze(metadata); +} +function assertPrivateAutomationRawEvent(provider, event) { + if (!PRIVATE_PROVIDERS.has(provider) || !event || typeof event !== "object" || Array.isArray(event)) { + throw new Error("private automation provider event is invalid"); + } + if (!PRIVATE_RAW_EVENT_TYPES[provider].has(event.type)) { + throw new Error("private automation provider event type is not allowlisted"); + } + if (provider === "codex" && event.type.startsWith("item.") && !PRIVATE_CODEX_ITEM_TYPES.has(event.item?.type)) { + throw new Error("private automation Codex item type is not allowlisted"); + } + if (provider === "claude" && event.type === "system") { + if (!PRIVATE_CLAUDE_SYSTEM_SUBTYPES.has(event.subtype)) { + throw new Error("private automation Claude system subtype is not allowlisted"); + } + if (Array.isArray(event.tools) && event.tools.length > 0 || Array.isArray(event.mcp_servers) && event.mcp_servers.length > 0) { + throw new Error("private automation Claude init capabilities are not empty"); + } + } + if (provider === "claude" && event.type === "assistant") { + const message = event.message && typeof event.message === "object" ? event.message : null; + const content = Array.isArray(event.content) ? event.content : Array.isArray(message?.content) ? message.content : []; + if (content.some((block) => !block || typeof block !== "object" || !PRIVATE_CLAUDE_ASSISTANT_BLOCK_TYPES.has(block.type))) { + throw new Error("private automation Claude content block is not allowlisted"); + } + } + if (containsToolEvent(event)) { + throw new Error("private automation tool event is forbidden"); + } + return event; +} +function successfulProbe(result) { + return result && !result.error && result.status === 0; +} +function probeOutput(result) { + return `${String(result?.stdout || "")} +${String(result?.stderr || "")}`; +} +function semverAtLeast(actual, minimum) { + const actualParts = actual.split(".").map(Number); + const minimumParts = minimum.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (actualParts[index] > minimumParts[index]) return true; + if (actualParts[index] < minimumParts[index]) return false; + } + return true; +} +function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, dependencies = {}) { + const spawnSyncImpl = dependencies.spawnSyncImpl || import_node_child_process2.spawnSync; + if (!profile || profile.id !== PRIVATE_AUTOMATION_PROFILE_ID) { + throw new Error("private automation profile is required for capability preflight"); + } + if (profile.provider === "codex") { + const versionProbe = spawnSyncImpl(binaryPath, ["--version"], { + encoding: "utf8", + timeout: 5e3 + }); + const versionMatch = probeOutput(versionProbe).match(/codex-cli\s+(\d+)\.(\d+)\.(\d+)/u); + const minimumVersion = loadProviderConfig("codex").headless.privateAutomation.minimumVersion; + const versionSupported = versionMatch && semverAtLeast( + `${versionMatch[1]}.${versionMatch[2]}.${versionMatch[3]}`, + minimumVersion + ); + if (!successfulProbe(versionProbe) || !versionSupported) { + throw new Error("Codex host version does not satisfy private automation config controls"); + } + const configProbe = spawnSyncImpl(binaryPath, [ + "--strict-config", + "-c", + 'web_search="disabled"', + "-c", + "tools.view_image=false", + "exec", + "--help" + ], { encoding: "utf8", timeout: 5e3 }); + const help2 = probeOutput(configProbe); + const requiredHelp2 = [ + "--ephemeral", + "--ignore-rules", + "--ignore-user-config", + "--output-schema", + "--sandbox" + ]; + if (!successfulProbe(configProbe) || requiredHelp2.some((flag) => !help2.includes(flag))) { + throw new Error("Codex host does not satisfy private automation config and CLI capabilities"); + } + const featureProbe = spawnSyncImpl(binaryPath, ["features", "list"], { + encoding: "utf8", + timeout: 5e3 + }); + const features = probeOutput(featureProbe); + const missingFeature = PRIVATE_CODEX_DISABLED_FEATURES.some((feature) => { + const line = features.split("\n").find((candidate) => candidate.trim().startsWith(`${feature} `)); + return !line || /\bremoved\b/u.test(line); + }); + if (!successfulProbe(featureProbe) || missingFeature) { + throw new Error("Codex host does not satisfy private automation feature controls"); + } + return true; + } + const helpProbe = spawnSyncImpl(binaryPath, ["--help"], { encoding: "utf8", timeout: 5e3 }); + const help = probeOutput(helpProbe); + const requiredHelp = [ + "--disable-slash-commands", + "--input-format", + "--json-schema", + "--mcp-config", + "--no-chrome", + "--no-session-persistence", + "--safe-mode", + "--setting-sources", + "--strict-mcp-config", + "--tools" + ]; + if (!successfulProbe(helpProbe) || requiredHelp.some((flag) => !help.includes(flag))) { + throw new Error("Claude host does not satisfy private automation CLI capabilities"); + } + return true; +} + +// src/agent-host/events/stream.js +function boundedAppend(current, value, maxLength = 4096) { + const combined = `${current}${value}`; + return combined.length <= maxLength ? combined : combined.slice(-maxLength); +} +function writeLine(stream, value) { + stream.write(value.endsWith("\n") ? value : `${value} +`); +} +function executeForegroundLaunch({ + eventSink = null, + jsonOutput = false, + launchId, + onSpawn = null, + plan, + spawnImpl = import_node_child_process3.spawn, + stderr = process.stderr, + stdout = process.stdout, + store, + timeoutMs = plan.timeouts.runtimeMs, + signalEmitter = process +}) { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 24 * 60 * 60 * 1e3) { + throw new Error("timeoutMs must be an integer between 1 and 86400000"); + } + return new Promise((resolve, reject) => { + const privateAutomation = plan.privateAutomationProfile != null; + const normalizer = createAgentEventNormalizer(plan.provider); + let child; + let finalized = false; + let stdoutBuffer = ""; + let stderrTail = ""; + let sawAssistantText = false; + let timedOut = false; + let forceTimer = null; + let requestedSignal = null; + let sinkFailure = null; + let privateFailure = null; + let privateFinalOutput = null; + let privateObservedModel = null; + let privateRawOutputBytes = 0; + let privateUsage = null; + function terminateProvider(signal) { + if (privateAutomation && Number.isSafeInteger(child?.pid) && child.pid > 0) { + try { + process.kill(-child.pid, signal); + return true; + } catch { + } + } + try { + return child?.kill(signal) === true; + } catch { + return false; + } + } + function privateProviderGroupAlive() { + if (!privateAutomation || !Number.isSafeInteger(child?.pid) || child.pid < 1) { + return false; + } + try { + process.kill(-child.pid, 0); + return true; + } catch { + return false; + } + } + function recordSinkFailure(kind, error) { + if (sinkFailure) return; + sinkFailure = `${kind} persistence failed: ${error.message}`; + try { + writeLine(stderr, sinkFailure); + } catch { + } + terminateProvider("SIGTERM"); + } + function publishEvent(payload, persistedPayload = payload) { + try { + eventSink?.(persistedPayload); + } catch (error) { + recordSinkFailure("Agent event", error); + } + return payload; + } + const onSigint = () => { + requestedSignal = "SIGINT"; + terminateProvider("SIGINT"); + }; + const onSigterm = () => { + requestedSignal = "SIGTERM"; + terminateProvider("SIGTERM"); + }; + function persistNativeSession(rawEvent, normalized) { + if (privateAutomation) return; + const nativeSessionId = extractNativeSessionId(rawEvent) || normalized?.providerSessionId || null; + if (!nativeSessionId) return; + const current = store.get(launchId); + if (current?.nativeSessionId !== nativeSessionId) { + store.setNativeSessionId(launchId, nativeSessionId); + } + } + function emitEvent(normalized, rawEvent) { + persistNativeSession(rawEvent, normalized); + const isDelta = rawEvent?.type === "message" && rawEvent.delta === true || rawEvent?.event === "step_update" && rawEvent.step_update?.step_type === "agent_response"; + let persistedEvent = normalized; + if (privateAutomation) { + try { + assertPrivateAutomationRawEvent(plan.provider, rawEvent); + persistedEvent = projectPrivateAutomationEventMetadata(normalized); + } catch { + privateFailure = "private_tool_event"; + terminateProvider("SIGTERM"); + return; + } + if (normalized.model) { + if (normalized.model !== plan.model) { + privateFailure = "private_model_mismatch"; + terminateProvider("SIGTERM"); + return; + } + privateObservedModel = normalized.model; + } + if (normalized.usage) privateUsage = persistedEvent.usage || privateUsage; + const structuredOutput = rawEvent?.structured_output ?? rawEvent?.structuredOutput; + if (structuredOutput && typeof structuredOutput === "object" && !Array.isArray(structuredOutput)) { + privateFinalOutput = structuredOutput; + } else if (normalized.type === "assistant" && Array.isArray(normalized.content)) { + const text = normalized.content.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text).join(""); + if (text) privateFinalOutput = text; + } else if (normalized.type === "result" && typeof normalized.result === "string") { + privateFinalOutput = normalized.result; + } + } + const persistedPayload = { + delta: isDelta, + event: persistedEvent, + launchId, + provider: plan.provider, + type: "agent.event" + }; + const payload = privateAutomation ? publishEvent(persistedPayload) : publishEvent({ + event: normalized, + launchId, + provider: plan.provider, + rawEvent, + type: "agent.event" + }, persistedPayload); + if (privateAutomation) return; + if (jsonOutput) { + writeLine(stdout, JSON.stringify(payload)); + return; + } + const rendered = renderAgentEvent(normalized); + if (normalized?.type === "assistant" && rendered.length > 0) sawAssistantText = true; + if (normalized?.type === "result" && sawAssistantText) return; + for (const text of rendered) { + if (isDelta) stdout.write(text); + else writeLine(stdout, text); + } + if (normalized?.type === "error" && normalized.message) writeLine(stderr, normalized.message); + } + function consumeLine(line) { + if (!line.trim()) return; + try { + const rawEvent = JSON.parse(line); + for (const result of normalizer.normalize(rawEvent)) { + if (result?.normalized) emitEvent(result.normalized, result.raw || rawEvent); + } + } catch { + if (privateAutomation) { + privateFailure = "private_output_malformed"; + terminateProvider("SIGTERM"); + return; + } + const payload = publishEvent({ + event: { message: line, subtype: "provider_stdout", type: "system" }, + launchId, + provider: plan.provider, + type: "agent.event" + }); + if (jsonOutput) { + writeLine(stdout, JSON.stringify(payload)); + } else { + writeLine(stdout, line); + } + } + } + function flushStdout() { + if (stdoutBuffer.trim()) consumeLine(stdoutBuffer); + stdoutBuffer = ""; + for (const result of normalizer.flush()) { + if (result?.normalized) emitEvent(result.normalized, result.raw || {}); + } + } + function complete(status, exitCode, lastError = null) { + if (finalized) return; + clearTimeout(runtimeTimer); + if (forceTimer) clearTimeout(forceTimer); + signalEmitter.removeListener("SIGINT", onSigint); + signalEmitter.removeListener("SIGTERM", onSigterm); + flushStdout(); + finalized = true; + if (sinkFailure) { + status = "failed"; + lastError = sinkFailure; + } + if (privateAutomation) { + if (privateFailure) { + status = "failed"; + lastError = `Private automation failed: ${privateFailure}`; + } else if (status === "completed" && privateObservedModel === null) { + status = "failed"; + lastError = "Private automation failed: private_model_unobserved"; + } else if (status === "completed") { + try { + const parsed = typeof privateFinalOutput === "string" ? JSON.parse(privateFinalOutput) : privateFinalOutput; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("not_object"); + } + const serialized = JSON.stringify(parsed); + if (Buffer.byteLength(serialized, "utf8") > plan.maxFinalOutputBytes) { + throw new Error("too_large"); + } + privateFinalOutput = parsed; + } catch (error) { + status = "failed"; + lastError = `Private automation failed: ${error.message === "too_large" ? "private_final_output_overflow" : "private_final_output_invalid"}`; + } + } else { + lastError = timedOut ? "Private automation failed: private_timeout" : requestedSignal ? "Private automation failed: private_stopped" : "Private automation failed: private_provider_error"; + } + } + const current = store.get(launchId); + if (current?.status === "starting" && status !== "failed") { + store.transition(launchId, "running", { pid: child?.pid || 0 }); + } + const updated = store.transition(launchId, status, { + exitCode, + lastError + }); + const terminalEvent = publishEvent({ launch: updated, type: `launch.${status}` }); + if (privateAutomation && status === "completed") { + const privateResult = { + model: privateObservedModel, + output: privateFinalOutput, + provider: plan.provider, + type: "private-automation.result", + ...privateUsage ? { usage: privateUsage } : {} + }; + writeLine(stdout, jsonOutput ? JSON.stringify(privateResult) : JSON.stringify(privateFinalOutput)); + } + if (jsonOutput) { + if (!privateAutomation) writeLine(stdout, JSON.stringify(terminalEvent)); + } + resolve(updated); + } + const runtimeTimer = setTimeout(() => { + timedOut = true; + terminateProvider("SIGTERM"); + forceTimer = setTimeout( + () => terminateProvider("SIGKILL"), + plan.timeouts.shutdownGraceMs || 5e3 + ); + }, timeoutMs); + try { + child = spawnImpl(plan.spawn.command, plan.args, { + cwd: plan.spawn.cwd, + detached: privateAutomation, + env: privateAutomation ? plan.environment : { ...process.env, ...plan.environment }, + stdio: [privateAutomation ? "pipe" : "ignore", "pipe", "pipe"] + }); + } catch (error) { + clearTimeout(runtimeTimer); + reject(error); + return; + } + if (privateAutomation) { + child.stdin.on("error", () => { + privateFailure = "private_stdin_error"; + terminateProvider("SIGTERM"); + }); + child.stdin.end(plan.stdin); + } + child.once("spawn", () => { + const current = store.get(launchId); + if (current?.status === "starting") { + const running = store.transition(launchId, "running", { pid: child.pid || 0 }); + onSpawn?.(running); + } else if (current) { + onSpawn?.(current); + } + }); + signalEmitter.once("SIGINT", onSigint); + signalEmitter.once("SIGTERM", onSigterm); + child.stdout.on("data", (chunk) => { + if (privateAutomation) { + privateRawOutputBytes += Buffer.byteLength(chunk); + if (privateRawOutputBytes > plan.maxRawOutputBytes) { + privateFailure = "private_raw_output_overflow"; + stdoutBuffer = ""; + terminateProvider("SIGTERM"); + return; + } + } + stdoutBuffer += chunk.toString(); + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() || ""; + for (const line of lines) consumeLine(line); + }); + child.stderr.on("data", (chunk) => { + if (privateAutomation) return; + const text = chunk.toString(); + stderrTail = boundedAppend(stderrTail, text); + try { + stderr.write(text); + } catch (error) { + recordSinkFailure("Provider stderr", error); + } + }); + child.once("error", (error) => { + complete( + "failed", + null, + privateAutomation ? "Private automation failed: private_spawn_error" : `Provider process error: ${error.message}` + ); + }); + child.once("close", (exitCode, signal) => { + if (privateProviderGroupAlive()) { + terminateProvider("SIGKILL"); + privateFailure = "private_termination_unconfirmed"; + } + if (sinkFailure) { + complete("failed", exitCode, sinkFailure); + return; + } + if (timedOut) { + complete("failed", exitCode, `Provider process timed out after ${timeoutMs}ms`); + return; + } + if (requestedSignal) { + complete("stopped", exitCode, `Provider process stopped by ${requestedSignal}`); + return; + } + if (exitCode === 0) { + complete("completed", 0); + return; + } + const detail = stderrTail.trim() || `Provider process exited with code ${exitCode}${signal ? ` (${signal})` : ""}`; + complete("failed", exitCode, detail); + }); + }); +} + +// src/agent-host/launch-store.js +var import_node_fs6 = __toESM(require("node:fs"), 1); +var import_node_path5 = __toESM(require("node:path"), 1); +var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1); +var LAUNCH_STATUSES = Object.freeze([ + "starting", + "running", + "completed", + "failed", + "stopped" +]); +var LAUNCH_DISPOSITIONS = Object.freeze(["retained", "promoted", "discarded"]); +var LAUNCH_EXECUTION_KINDS = Object.freeze(["foreground", "detached"]); +var GROUP_ID_PATTERN = /^group_[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "stopped"]); +var TRANSITIONS = Object.freeze({ + starting: /* @__PURE__ */ new Set(["running", "failed", "stopped"]), + running: /* @__PURE__ */ new Set(["completed", "failed", "stopped"]), + completed: /* @__PURE__ */ new Set(), + failed: /* @__PURE__ */ new Set(), + stopped: /* @__PURE__ */ new Set() +}); +function requiredString(value, field, maxLength = 4096) { + if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { + throw new Error(`${field} must be a non-empty string without NUL bytes`); + } + if (value.length > maxLength) { + throw new Error(`${field} exceeds ${maxLength} characters`); + } + return value; +} +function optionalString(value, field, maxLength = 4096) { + if (value == null) return null; + return requiredString(value, field, maxLength); +} +function mapLaunch(row) { + if (!row) return null; + return { + baseRef: row.base_ref, + disposition: row.disposition, + executionKind: row.execution_kind, + executionWorkspace: row.execution_workspace, + exitCode: row.exit_code, + finishedAt: row.finished_at, + lastError: row.last_error, + launchId: row.launch_id, + model: row.model, + nativeSessionId: row.native_session_id, + originDirectory: row.origin_directory, + ownerPid: row.owner_pid, + outputDestination: row.output_destination, + parentLaunchId: row.parent_launch_id, + pid: row.pid, + projectRoot: row.project_root, + provider: row.provider, + startedAt: row.started_at, + status: row.status, + updatedAt: row.updated_at, + workspaceMode: row.workspace_mode, + worktreeBranch: row.worktree_branch + }; +} +function validateStatus(status) { + if (!LAUNCH_STATUSES.includes(status)) { + throw new Error(`Unknown launch status: ${status}`); } - return config; + return status; } -function resolveProviderBinary(config) { - const home = (0, import_node_os3.homedir)(); - const arch = process.arch; - for (const rawPath of config.binary.resolvePaths) { - const resolved = rawPath.replace(/^~/, home).replace(/\{arch\}/g, arch); - if ((0, import_node_fs5.existsSync)(resolved)) { - return resolved; - } +function validateEnum(value, field, allowed) { + if (!allowed.includes(value)) { + throw new Error(`Unknown ${field}: ${value}`); } - if (config.binary.fallback === "which") { - try { - return runCommandPlan2(createWhichCommand(config.binary.name), { encoding: "utf-8" }).trim(); - } catch { - } + return value; +} +function optionalPid(value, field) { + if (value == null) return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`${field} must be a positive integer`); } - return null; + return parsed; } -function resolveModel(config, aliasOrId) { - if (!aliasOrId) return config.models.default; - for (const m of config.models.available) { - if (m.alias === aliasOrId || m.id === aliasOrId) return m.id; +function assertAgentGroupId(groupId) { + if (typeof groupId !== "string" || !GROUP_ID_PATTERN.test(groupId)) { + throw new Error("Invalid Agent Host group ID"); } - return aliasOrId; + return groupId; } -function getModelDef(config, aliasOrId) { - const id = resolveModel(config, aliasOrId); - return config.models.available.find((m) => m.id === id) || null; +function deriveGroupStatus(launches) { + const statuses = launches.map((launch) => launch.status); + if (statuses.includes("running")) return "running"; + if (statuses.includes("starting")) return "starting"; + if (statuses.every((status) => status === "completed")) return "completed"; + if (statuses.some((status) => status === "completed")) return "partial"; + if (statuses.every((status) => status === "stopped")) return "stopped"; + return "failed"; } -function buildArgs(config, options = {}) { - const globalExtraArgs = normalizeExtraArgs(options.globalExtraArgs, "globalExtraArgs"); - const extraArgs = normalizeExtraArgs(options.extraArgs); - const args = [...globalExtraArgs]; - appendConditionals(args, config.headless.args.prefixConditionals || [], options); - for (const arg of config.headless.args.base) { - args.push(expandTemplate(arg, options)); - } - appendConditionals(args, config.headless.args.conditionals, options); - args.push(...extraArgs); - return args; +function ensureColumn(database, name, definition) { + const columns = new Set(database.prepare("PRAGMA table_info(agent_launches)").all().map((row) => row.name)); + if (!columns.has(name)) database.exec(`ALTER TABLE agent_launches ADD COLUMN ${name} ${definition}`); } -function appendConditionals(args, conditionals, options) { - for (const cond of conditionals) { - const key = cond.if; - if (options[key] == null || options[key] === false) continue; - for (const arg of cond.args) { - const expanded = expandTemplate(arg, options); - if (expanded !== arg || !arg.includes("{{")) { - args.push(expanded); - } +function initialize(database) { + database.pragma("journal_mode = WAL"); + database.pragma("foreign_keys = ON"); + database.exec(` + CREATE TABLE IF NOT EXISTS agent_launches ( + launch_id TEXT PRIMARY KEY, + parent_launch_id TEXT REFERENCES agent_launches(launch_id), + provider TEXT NOT NULL, + native_session_id TEXT, + origin_directory TEXT NOT NULL, + project_root TEXT NOT NULL, + execution_workspace TEXT NOT NULL, + output_destination TEXT NOT NULL, + workspace_mode TEXT NOT NULL CHECK (workspace_mode IN ('read-only', 'worktree', 'isolated-copy')), + worktree_branch TEXT, + base_ref TEXT, + model TEXT NOT NULL, + execution_kind TEXT NOT NULL DEFAULT 'foreground' CHECK (execution_kind IN ('foreground', 'detached')), + owner_pid INTEGER, + disposition TEXT NOT NULL DEFAULT 'retained' CHECK (disposition IN ('retained', 'promoted', 'discarded')), + status TEXT NOT NULL CHECK (status IN ('starting', 'running', 'completed', 'failed', 'stopped')), + pid INTEGER, + exit_code INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT, + updated_at TEXT NOT NULL, + last_error TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_agent_launches_status_started + ON agent_launches(status, started_at DESC); + CREATE INDEX IF NOT EXISTS idx_agent_launches_native_session + ON agent_launches(provider, native_session_id); + + CREATE TABLE IF NOT EXISTS agent_groups ( + group_id TEXT PRIMARY KEY, + origin_directory TEXT NOT NULL, + workspace TEXT NOT NULL, + workspace_mode TEXT NOT NULL CHECK (workspace_mode IN ('auto', 'read-only', 'worktree', 'isolated-copy')), + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS agent_group_launches ( + group_id TEXT NOT NULL REFERENCES agent_groups(group_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + launch_id TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL, + last_error TEXT, + PRIMARY KEY (group_id, ordinal) + ); + + CREATE INDEX IF NOT EXISTS idx_agent_group_launches_group + ON agent_group_launches(group_id, ordinal); + `); + ensureColumn(database, "execution_kind", "TEXT NOT NULL DEFAULT 'foreground' CHECK (execution_kind IN ('foreground', 'detached'))"); + ensureColumn(database, "owner_pid", "INTEGER"); + ensureColumn(database, "disposition", "TEXT NOT NULL DEFAULT 'retained' CHECK (disposition IN ('retained', 'promoted', 'discarded'))"); +} +function createLaunchStore({ + databasePath = getAgentHostPaths().stateDatabase, + now = () => (/* @__PURE__ */ new Date()).toISOString() +} = {}) { + const resolvedPath = import_node_path5.default.resolve(databasePath); + import_node_fs6.default.mkdirSync(import_node_path5.default.dirname(resolvedPath), { recursive: true, mode: 448 }); + const database = new import_better_sqlite3.default(resolvedPath); + import_node_fs6.default.chmodSync(resolvedPath, 384); + initialize(database); + const getStatement = database.prepare("SELECT * FROM agent_launches WHERE launch_id = ?"); + function get(launchId) { + assertLaunchId(launchId); + return mapLaunch(getStatement.get(launchId)); + } + function create(projection) { + const launchId = assertLaunchId(projection?.launchId); + const status = validateStatus(projection?.status || "starting"); + if (status !== "starting") { + throw new Error("New launches must start in the starting state"); } + const timestamp = now(); + const record = { + baseRef: optionalString(projection.baseRef, "baseRef", 512), + disposition: validateEnum(projection.disposition || "retained", "launch disposition", LAUNCH_DISPOSITIONS), + executionKind: validateEnum(projection.executionKind || "foreground", "execution kind", LAUNCH_EXECUTION_KINDS), + executionWorkspace: requiredString(projection.executionWorkspace, "executionWorkspace"), + launchId, + model: requiredString(projection.model, "model", 512), + nativeSessionId: optionalString(projection.nativeSessionId, "nativeSessionId", 1024), + originDirectory: requiredString(projection.originDirectory, "originDirectory"), + ownerPid: optionalPid(projection.ownerPid, "ownerPid"), + outputDestination: requiredString(projection.outputDestination, "outputDestination"), + parentLaunchId: projection.parentLaunchId == null ? null : assertLaunchId(projection.parentLaunchId), + projectRoot: requiredString(projection.projectRoot, "projectRoot"), + provider: requiredString(projection.provider, "provider", 64), + status, + workspaceMode: requiredString(projection.workspaceMode, "workspaceMode", 32), + worktreeBranch: optionalString(projection.worktreeBranch, "worktreeBranch", 512) + }; + database.prepare(` + INSERT INTO agent_launches ( + launch_id, parent_launch_id, provider, native_session_id, + origin_directory, project_root, execution_workspace, output_destination, + workspace_mode, worktree_branch, base_ref, model, status, + execution_kind, owner_pid, disposition, started_at, updated_at + ) VALUES ( + @launchId, @parentLaunchId, @provider, @nativeSessionId, + @originDirectory, @projectRoot, @executionWorkspace, @outputDestination, + @workspaceMode, @worktreeBranch, @baseRef, @model, @status, + @executionKind, @ownerPid, @disposition, @startedAt, @updatedAt + ) + `).run({ ...record, startedAt: timestamp, updatedAt: timestamp }); + return get(launchId); } -} -function normalizeExtraArgs(value, optionName = "extraArgs") { - if (value == null) return []; - if (!Array.isArray(value)) { - throw new TypeError(`${optionName} must be an array of strings`); + function transition(launchId, nextStatus, patch = {}) { + assertLaunchId(launchId); + validateStatus(nextStatus); + const current = get(launchId); + if (!current) throw new Error(`Launch not found: ${launchId}`); + if (!TRANSITIONS[current.status].has(nextStatus)) { + throw new Error(`Invalid launch transition: ${current.status} -> ${nextStatus}`); + } + const timestamp = now(); + const pid = patch.pid == null ? current.pid : Number(patch.pid); + const exitCode = patch.exitCode == null ? current.exitCode : Number(patch.exitCode); + if (pid != null && (!Number.isSafeInteger(pid) || pid < 0)) { + throw new Error("pid must be a non-negative integer"); + } + if (exitCode != null && !Number.isSafeInteger(exitCode)) { + throw new Error("exitCode must be an integer"); + } + database.prepare(` + UPDATE agent_launches + SET status = @status, + pid = @pid, + owner_pid = @ownerPid, + exit_code = @exitCode, + native_session_id = COALESCE(@nativeSessionId, native_session_id), + last_error = @lastError, + finished_at = @finishedAt, + updated_at = @updatedAt + WHERE launch_id = @launchId + `).run({ + exitCode, + finishedAt: TERMINAL_STATUSES.has(nextStatus) ? timestamp : null, + lastError: optionalString(patch.lastError, "lastError", 4096), + launchId, + nativeSessionId: optionalString(patch.nativeSessionId, "nativeSessionId", 1024), + ownerPid: TERMINAL_STATUSES.has(nextStatus) ? null : optionalPid(patch.ownerPid == null ? current.ownerPid : patch.ownerPid, "ownerPid"), + pid, + status: nextStatus, + updatedAt: timestamp + }); + return get(launchId); } - return value.map((arg, index) => { - if (typeof arg !== "string" || arg.trim() === "" || arg.includes("\0")) { - throw new TypeError(`${optionName}[${index}] must be a non-empty string without NUL bytes`); + function setDisposition(launchId, disposition) { + assertLaunchId(launchId); + const next = validateEnum(disposition, "launch disposition", LAUNCH_DISPOSITIONS); + const current = get(launchId); + if (!current) throw new Error(`Launch not found: ${launchId}`); + if (current.disposition === next) return current; + if (current.disposition !== "retained") { + throw new Error(`Launch is already ${current.disposition}: ${launchId}`); } - return arg; - }); -} -function getPermissionArgs(config, mode) { - const modes = config.headless.permissionModes; - if (!modes[mode]) { - throw new Error(`Unknown permission mode: ${mode}. Available: ${Object.keys(modes).join(", ")}`); + if (next === "retained") return current; + database.prepare(` + UPDATE agent_launches + SET disposition = ?, updated_at = ? + WHERE launch_id = ? + `).run(next, now(), launchId); + return get(launchId); } - return modes[mode]; -} -function buildEnv2(config, secrets = {}) { - const env = { ...config.headless.env }; - for (const key of config.headless.authEnvVars) { - if (secrets[key]) env[key] = secrets[key]; + function setNativeSessionId(launchId, nativeSessionId) { + assertLaunchId(launchId); + const validNativeId = requiredString(nativeSessionId, "nativeSessionId", 1024); + const result = database.prepare(` + UPDATE agent_launches + SET native_session_id = ?, updated_at = ? + WHERE launch_id = ? + `).run(validNativeId, now(), launchId); + if (result.changes === 0) throw new Error(`Launch not found: ${launchId}`); + return get(launchId); } - return env; -} -function buildSubcommandArgs(config, subcommand, options = {}) { - const extraArgs = normalizeExtraArgs(options.extraArgs); - const subs = config.headless.subcommands; - if (!subs) return null; - if (!subs[subcommand]) { - throw new Error(`Unknown subcommand: ${subcommand}. Available: ${Object.keys(subs).join(", ")}`); + function list({ limit = 50, status = null } = {}) { + const numericLimit = Number(limit); + if (!Number.isSafeInteger(numericLimit) || numericLimit < 1 || numericLimit > 1e3) { + throw new Error("limit must be an integer between 1 and 1000"); + } + if (status != null) validateStatus(status); + const rows = status == null ? database.prepare(` + SELECT * FROM agent_launches + ORDER BY started_at DESC, rowid DESC + LIMIT ? + `).all(numericLimit) : database.prepare(` + SELECT * FROM agent_launches + WHERE status = ? + ORDER BY started_at DESC, rowid DESC + LIMIT ? + `).all(status, numericLimit); + return rows.map(mapLaunch); } - const sub = subs[subcommand]; - const args = [...sub.args]; - for (const cond of sub.conditionals) { - const key = cond.if; - if (options[key] == null || options[key] === false) continue; - for (const arg of cond.args) { - args.push(expandTemplate(arg, options)); + function getGroup(groupId) { + assertAgentGroupId(groupId); + const row = database.prepare("SELECT * FROM agent_groups WHERE group_id = ?").get(groupId); + if (!row) return null; + const taskRows = database.prepare(` + SELECT launch_id, provider, last_error + FROM agent_group_launches + WHERE group_id = ? + ORDER BY ordinal ASC + `).all(groupId); + const launches = taskRows.map((task) => { + const launch = get(task.launch_id); + if (launch) return launch; + return { + lastError: task.last_error, + launchId: task.launch_id, + provider: task.provider, + status: task.last_error ? "failed" : "starting" + }; + }); + const status = deriveGroupStatus(launches); + const finishedAt = ["completed", "partial", "failed", "stopped"].includes(status) ? launches.map((launch) => launch.finishedAt).filter(Boolean).sort().at(-1) || row.updated_at : null; + return { + finishedAt, + groupId: row.group_id, + launches, + originDirectory: row.origin_directory, + startedAt: row.started_at, + status, + updatedAt: row.updated_at, + workspace: row.workspace, + workspaceMode: row.workspace_mode + }; + } + function createGroup(projection) { + const groupId = assertAgentGroupId(projection?.groupId); + const tasks = projection?.tasks; + if (!Array.isArray(tasks) || tasks.length < 2 || tasks.length > 10) { + throw new Error("Agent Host group requires between 2 and 10 tasks"); + } + const validatedTasks = tasks.map((task, ordinal) => ({ + launchId: assertLaunchId(task?.launchId), + ordinal, + provider: requiredString(task?.provider, `tasks[${ordinal}].provider`, 64) + })); + if (new Set(validatedTasks.map((task) => task.launchId)).size !== validatedTasks.length) { + throw new Error("Agent Host group launch IDs must be unique"); } + const timestamp = now(); + const record = { + groupId, + originDirectory: requiredString(projection.originDirectory, "originDirectory"), + startedAt: timestamp, + updatedAt: timestamp, + workspace: requiredString(projection.workspace, "workspace"), + workspaceMode: validateEnum( + projection.workspaceMode || "auto", + "group workspace mode", + ["auto", "read-only", "worktree", "isolated-copy"] + ) + }; + database.transaction(() => { + database.prepare(` + INSERT INTO agent_groups ( + group_id, origin_directory, workspace, workspace_mode, started_at, updated_at + ) VALUES ( + @groupId, @originDirectory, @workspace, @workspaceMode, @startedAt, @updatedAt + ) + `).run(record); + const insertTask = database.prepare(` + INSERT INTO agent_group_launches (group_id, ordinal, launch_id, provider) + VALUES (?, ?, ?, ?) + `); + for (const task of validatedTasks) { + insertTask.run(groupId, task.ordinal, task.launchId, task.provider); + } + })(); + return getGroup(groupId); } - args.push(...extraArgs); - return args; -} -function expandTemplate(str, options) { - return str.replace(/\{\{(\w+)(?:\|join:(.+?))?\}\}/g, (_, key, joinSep) => { - const val = options[key]; - if (val == null) return ""; - if (Array.isArray(val) && joinSep != null) return val.join(joinSep); - if (Array.isArray(val)) return val.join(" "); - return String(val); - }); + function setGroupLaunchError(groupId, launchId, lastError) { + assertAgentGroupId(groupId); + assertLaunchId(launchId); + const result = database.prepare(` + UPDATE agent_group_launches + SET last_error = ? + WHERE group_id = ? AND launch_id = ? + `).run(requiredString(lastError, "lastError", 4096), groupId, launchId); + if (result.changes === 0) throw new Error(`Group launch not found: ${groupId}/${launchId}`); + database.prepare("UPDATE agent_groups SET updated_at = ? WHERE group_id = ?").run(now(), groupId); + return getGroup(groupId); + } + function listGroups({ limit = 50 } = {}) { + const numericLimit = Number(limit); + if (!Number.isSafeInteger(numericLimit) || numericLimit < 1 || numericLimit > 1e3) { + throw new Error("limit must be an integer between 1 and 1000"); + } + return database.prepare(` + SELECT group_id FROM agent_groups + ORDER BY started_at DESC, rowid DESC + LIMIT ? + `).all(numericLimit).map((row) => getGroup(row.group_id)); + } + return { + close() { + if (database.open) database.close(); + }, + create, + createGroup, + database, + get, + getGroup, + list, + listGroups, + setDisposition, + setGroupLaunchError, + setNativeSessionId, + transition + }; } +// src/agent-host/preflight.js +var import_node_fs9 = __toESM(require("node:fs"), 1); +var import_node_os5 = __toESM(require("node:os"), 1); +var import_node_path8 = __toESM(require("node:path"), 1); +var import_node_child_process4 = require("node:child_process"); + // src/agent-host/providers/common.js -var import_node_fs6 = __toESM(require("node:fs"), 1); +var import_node_fs7 = __toESM(require("node:fs"), 1); var import_node_os4 = __toESM(require("node:os"), 1); -var import_node_path5 = __toESM(require("node:path"), 1); +var import_node_path6 = __toESM(require("node:path"), 1); var MAX_PROMPT_BYTES = 10 * 1024 * 1024; var PERMISSION_ALIASES = Object.freeze({ "accept-edits": "acceptEdits", @@ -31598,7 +32060,7 @@ var WRITABLE_PERMISSION = Object.freeze({ codex: "approve", gemini: "acceptEdits" }); -function requiredText(value, field, maxBytes = MAX_PROMPT_BYTES) { +function requiredText2(value, field, maxBytes = MAX_PROMPT_BYTES) { if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { throw new Error(`${field} must be a non-empty string without NUL bytes`); } @@ -31610,13 +32072,32 @@ function requiredText(value, field, maxBytes = MAX_PROMPT_BYTES) { function validateExtraArgs(value) { if (value == null) return []; if (!Array.isArray(value)) throw new Error("extraArgs must be an array of strings"); - return value.map((arg, index) => requiredText(arg, `extraArgs[${index}]`, 64 * 1024)); + return value.map((arg, index) => requiredText2(arg, `extraArgs[${index}]`, 64 * 1024)); } function providerContext(options, provider) { const config = loadProviderConfig(provider); - const prompt = requiredText(options.prompt, "prompt"); - const cwd = requiredText(options.cwd, "cwd", 4096); - const binaryPath = requiredText(options.binaryPath, "binaryPath", 4096); + const privateAutomationProfile = options.privateAutomationProfile || null; + if (privateAutomationProfile != null) { + if (privateAutomationProfile.id !== PRIVATE_AUTOMATION_PROFILE_ID) { + throw new Error("invalid private automation profile"); + } + if (privateAutomationProfile.provider !== provider) { + throw new Error("private automation provider does not match process plan"); + } + if (privateAutomationProfile.model !== options.model) { + throw new Error("private automation model does not match process plan"); + } + if (options.nativeSessionId != null) { + throw new Error("private automation session resume is forbidden"); + } + } + const prompt = requiredText2( + options.prompt, + "prompt", + privateAutomationProfile?.maxPromptBytes || MAX_PROMPT_BYTES + ); + const cwd = requiredText2(options.cwd, "cwd", 4096); + const binaryPath = requiredText2(options.binaryPath, "binaryPath", 4096); const requestedModel = options.model || config.models.default; const modelDefinition = getModelDef(config, requestedModel); if (!modelDefinition) { @@ -31632,10 +32113,11 @@ function providerContext(options, provider) { cwd, extraArgs: validateExtraArgs(options.extraArgs), model: resolveModel(config, requestedModel), - nativeSessionId: options.nativeSessionId == null ? null : requiredText(options.nativeSessionId, "nativeSessionId", 1024), + nativeSessionId: options.nativeSessionId == null ? null : requiredText2(options.nativeSessionId, "nativeSessionId", 1024), prompt, + privateAutomationProfile, provider, - runtimeDirectory: options.runtimeDirectory == null ? null : requiredText(options.runtimeDirectory, "runtimeDirectory", 4096), + runtimeDirectory: options.runtimeDirectory == null ? null : requiredText2(options.runtimeDirectory, "runtimeDirectory", 4096), workspaceMode }; } @@ -31656,24 +32138,47 @@ function permissionArgs(context, requestedMode) { function validateImages(images) { if (images == null) return []; if (!Array.isArray(images)) throw new Error("images must be an array of paths"); - return images.map((image, index) => requiredText(image, `images[${index}]`, 4096)); + return images.map((image, index) => requiredText2(image, `images[${index}]`, 4096)); } function buildAgentExecutableEnvironment(binaryPath, overrides = {}, baseEnvironment = process.env) { const merged = { ...baseEnvironment, ...overrides }; const entries = [ - import_node_path5.default.dirname(binaryPath), - import_node_path5.default.dirname(process.execPath), - ...String(merged.PATH || "").split(import_node_path5.default.delimiter) + import_node_path6.default.dirname(binaryPath), + import_node_path6.default.dirname(process.execPath), + ...String(merged.PATH || "").split(import_node_path6.default.delimiter) ].filter(Boolean); - merged.PATH = [...new Set(entries)].join(import_node_path5.default.delimiter); + merged.PATH = [...new Set(entries)].join(import_node_path6.default.delimiter); return merged; } +var PRIVATE_OPERATIONAL_ENVIRONMENT_KEYS = Object.freeze([ + "HOME", + "LANG", + "LC_ALL", + "LOGNAME", + "PATH", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TMPDIR", + "USER" +]); +function buildPrivateProviderEnvironment(config, binaryPath, options = {}) { + const baseEnvironment = options.baseEnvironment || process.env; + const operational = Object.fromEntries( + PRIVATE_OPERATIONAL_ENVIRONMENT_KEYS.filter((key) => typeof baseEnvironment[key] === "string" && baseEnvironment[key].length > 0).map((key) => [key, baseEnvironment[key]]) + ); + const providerEnvironment = buildProviderEnvironment(config, options); + return buildAgentExecutableEnvironment( + binaryPath, + { ...operational, ...providerEnvironment }, + {} + ); +} function buildProviderEnvironment(config, options = {}) { const baseEnvironment = options.baseEnvironment || process.env; - const rudiHome = options.rudiHome || process.env.RUDI_HOME || import_node_path5.default.join(import_node_os4.default.homedir(), ".rudi"); + const rudiHome = options.rudiHome || process.env.RUDI_HOME || import_node_path6.default.join(import_node_os4.default.homedir(), ".rudi"); let storedSecrets = {}; try { - const parsed = JSON.parse(import_node_fs6.default.readFileSync(import_node_path5.default.join(rudiHome, "secrets.json"), "utf8")); + const parsed = JSON.parse(import_node_fs7.default.readFileSync(import_node_path6.default.join(rudiHome, "secrets.json"), "utf8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { storedSecrets = Object.fromEntries( Object.entries(parsed).filter(([, value]) => typeof value === "string" && value.length > 0) @@ -31684,10 +32189,17 @@ function buildProviderEnvironment(config, options = {}) { return buildEnv2(config, { ...storedSecrets, ...baseEnvironment }); } function finishPlan(context, args, permissionMode, providerEnvironment = null) { - const resolvedProviderEnvironment = providerEnvironment || buildProviderEnvironment(context.config); + const resolvedProviderEnvironment = providerEnvironment || (context.privateAutomationProfile ? buildPrivateProviderEnvironment(context.config, context.binaryPath) : buildProviderEnvironment(context.config)); + const environment = context.privateAutomationProfile ? resolvedProviderEnvironment : buildAgentExecutableEnvironment(context.binaryPath, resolvedProviderEnvironment); return Object.freeze({ args, - environment: buildAgentExecutableEnvironment(context.binaryPath, resolvedProviderEnvironment), + environment, + ...context.privateAutomationProfile ? { + maxFinalOutputBytes: context.privateAutomationProfile.maxFinalOutputBytes, + maxRawOutputBytes: context.privateAutomationProfile.maxRawOutputBytes, + privateAutomationProfile: context.privateAutomationProfile, + stdin: context.prompt + } : {}, model: context.model, permissionMode, provider: context.provider, @@ -31719,6 +32231,43 @@ function buildAntigravityPlan(options) { // src/agent-host/providers/claude.js function buildClaudePlan(options) { const context = providerContext(options, "claude"); + if (context.privateAutomationProfile) { + if ((options.extraArgs || []).length > 0 || (options.images || []).length > 0) { + throw new Error("private automation forbids Claude passthrough arguments and images"); + } + if (options.approvalMode != null) { + throw new Error("private automation forbids Claude approval overrides"); + } + if (options.permissionMode != null && options.permissionMode !== "plan") { + throw new Error("private automation requires Claude plan permission mode"); + } + const args2 = [ + "--output-format", + "stream-json", + "--verbose", + "--print", + "--input-format", + "text", + "--model", + context.model, + "--json-schema", + context.privateAutomationProfile.outputSchema.canonical, + "--no-session-persistence", + "--safe-mode", + "--no-chrome", + "--disable-slash-commands", + "--tools", + "", + "--strict-mcp-config", + "--mcp-config", + '{"mcpServers":{}}', + "--setting-sources", + "", + "--permission-mode", + "plan" + ]; + return finishPlan(context, args2, "plan"); + } const images = validateImages(options.images); if (images.length > 0) { throw new Error("Claude local image attachments are not exposed as a headless CLI flag; reference a readable workspace file in the prompt"); @@ -31751,6 +32300,47 @@ function approvalPolicy(value) { } function buildCodexPlan(options) { const context = providerContext(options, "codex"); + if (context.privateAutomationProfile) { + if ((options.extraArgs || []).length > 0 || (options.images || []).length > 0) { + throw new Error("private automation forbids Codex passthrough arguments and images"); + } + if (options.approvalMode != null && options.approvalMode !== "never") { + throw new Error("private automation requires Codex approval mode never"); + } + if (options.permissionMode != null && !["readonly", "read-only"].includes(options.permissionMode)) { + throw new Error("private automation requires Codex read-only sandbox"); + } + const disabledFeatures = getPrivateCodexDisabledFeatures(); + const args2 = ["--ask-for-approval", "never"]; + for (const feature of disabledFeatures) args2.push("--disable", feature); + args2.push( + "-c", + "mcp_servers={}", + "-c", + 'web_search="disabled"', + "-c", + "tools.view_image=false", + "exec", + "-", + "--json", + "--skip-git-repo-check", + "--color", + "never", + "-C", + context.cwd, + "-m", + context.model, + "--output-schema", + context.privateAutomationProfile.outputSchema.path, + "--ephemeral", + "--strict-config", + "--ignore-user-config", + "--ignore-rules", + "-s", + "read-only" + ); + return finishPlan(context, args2, "readonly"); + } const images = validateImages(options.images); const permission = permissionArgs(context, options.permissionMode); const approval = approvalPolicy(options.approvalMode); @@ -31780,8 +32370,8 @@ function buildCodexPlan(options) { } // src/agent-host/providers/gemini.js -var import_node_fs7 = __toESM(require("node:fs"), 1); -var import_node_path6 = __toESM(require("node:path"), 1); +var import_node_fs8 = __toESM(require("node:fs"), 1); +var import_node_path7 = __toESM(require("node:path"), 1); function defaultSystemSettingsPath(platform = process.platform) { if (platform === "darwin") return "/Library/Application Support/GeminiCli/settings.json"; if (platform === "win32") return "C:\\ProgramData\\gemini-cli\\settings.json"; @@ -31793,9 +32383,9 @@ function buildGeminiProviderEnvironment(config, options = {}) { if (!environment.GEMINI_API_KEY || !options.runtimeDirectory) return environment; if (baseEnvironment.GEMINI_CLI_SYSTEM_SETTINGS_PATH) return environment; const systemSettingsPath = options.systemSettingsPath || defaultSystemSettingsPath(options.platform); - if (import_node_fs7.default.existsSync(systemSettingsPath)) return environment; - const settingsPath = import_node_path6.default.join(options.runtimeDirectory, "gemini-system-settings.json"); - import_node_fs7.default.writeFileSync(settingsPath, JSON.stringify({ + if (import_node_fs8.default.existsSync(systemSettingsPath)) return environment; + const settingsPath = import_node_path7.default.join(options.runtimeDirectory, "gemini-system-settings.json"); + import_node_fs8.default.writeFileSync(settingsPath, JSON.stringify({ security: { auth: { selectedType: "gemini-api-key" } } }, null, 2), { encoding: "utf8", mode: 384 }); return { @@ -31877,15 +32467,15 @@ function runCheck(binaryPath, args, spawnSyncImpl, timeout = 5e3) { }; } function skillsRoot(provider) { - if (provider === "claude") return import_node_path7.default.join(process.env.CLAUDE_HOME || import_node_path7.default.join(import_node_os5.default.homedir(), ".claude"), "skills"); - if (provider === "codex") return import_node_path7.default.join(process.env.CODEX_HOME || import_node_path7.default.join(import_node_os5.default.homedir(), ".codex"), "skills"); - if (provider === "gemini") return import_node_path7.default.join(process.env.GEMINI_HOME || import_node_path7.default.join(import_node_os5.default.homedir(), ".gemini"), "skills"); - return import_node_path7.default.join(process.env.ANTIGRAVITY_HOME || import_node_path7.default.join(import_node_os5.default.homedir(), ".gemini", "antigravity-cli"), "skills"); + if (provider === "claude") return import_node_path8.default.join(process.env.CLAUDE_HOME || import_node_path8.default.join(import_node_os5.default.homedir(), ".claude"), "skills"); + if (provider === "codex") return import_node_path8.default.join(process.env.CODEX_HOME || import_node_path8.default.join(import_node_os5.default.homedir(), ".codex"), "skills"); + if (provider === "gemini") return import_node_path8.default.join(process.env.GEMINI_HOME || import_node_path8.default.join(import_node_os5.default.homedir(), ".gemini"), "skills"); + return import_node_path8.default.join(process.env.ANTIGRAVITY_HOME || import_node_path8.default.join(import_node_os5.default.homedir(), ".gemini", "antigravity-cli"), "skills"); } function hasSyncedSkills(provider) { const root = skillsRoot(provider); try { - return import_node_fs8.default.readdirSync(root, { withFileTypes: true }).some((entry) => entry.isDirectory()); + return import_node_fs9.default.readdirSync(root, { withFileTypes: true }).some((entry) => entry.isDirectory()); } catch { return false; } @@ -31894,10 +32484,10 @@ function hasRudiRouter(provider) { const agentId = MCP_AGENT_IDS[provider] || provider; const config = AGENT_CONFIGS.find((item) => item.id === agentId); if (!config) return false; - return readAgentMcpServers(config).some((server) => server.name === "rudi" || import_node_path7.default.basename(String(server.command)) === "rudi-router"); + return readAgentMcpServers(config).some((server) => server.name === "rudi" || import_node_path8.default.basename(String(server.command)) === "rudi-router"); } async function inspectAgentHost(provider, dependencies = {}) { - const { spawnSyncImpl = import_node_child_process3.spawnSync } = dependencies; + const { spawnSyncImpl = import_node_child_process4.spawnSync } = dependencies; const canonicalProvider = resolveAgentProviderId(provider); const config = getAgentProviderConfig(canonicalProvider); const binaryPath = dependencies.binaryPath || resolveAgentProviderBinary(canonicalProvider); @@ -31940,35 +32530,35 @@ async function assertAgentHostReady({ binaryPath, provider }, dependencies = {}) } // src/agent-host/workspace.js -var import_node_fs10 = __toESM(require("node:fs"), 1); -var import_node_path9 = __toESM(require("node:path"), 1); -var import_node_child_process4 = require("node:child_process"); +var import_node_fs11 = __toESM(require("node:fs"), 1); +var import_node_path10 = __toESM(require("node:path"), 1); +var import_node_child_process5 = require("node:child_process"); // src/agent-host/workspace-manifest.js var import_node_crypto2 = __toESM(require("node:crypto"), 1); -var import_node_fs9 = __toESM(require("node:fs"), 1); -var import_node_path8 = __toESM(require("node:path"), 1); +var import_node_fs10 = __toESM(require("node:fs"), 1); +var import_node_path9 = __toESM(require("node:path"), 1); var WORKSPACE_BASELINE_FILE = "workspace-base.json"; function shouldSkip(relativePath) { - const first = relativePath.split(import_node_path8.default.sep)[0]; + const first = relativePath.split(import_node_path9.default.sep)[0]; return first === ".git" || first === ".rudi"; } function portablePath(relativePath) { - return relativePath.split(import_node_path8.default.sep).join("/"); + return relativePath.split(import_node_path9.default.sep).join("/"); } function hashFile(file) { - return import_node_crypto2.default.createHash("sha256").update(import_node_fs9.default.readFileSync(file)).digest("hex"); + return import_node_crypto2.default.createHash("sha256").update(import_node_fs10.default.readFileSync(file)).digest("hex"); } function createWorkspaceManifest(rootDirectory) { - const root = import_node_fs9.default.realpathSync(import_node_path8.default.resolve(rootDirectory)); + const root = import_node_fs10.default.realpathSync(import_node_path9.default.resolve(rootDirectory)); const entries = {}; function visit(directory, prefix = "") { - const children = import_node_fs9.default.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)); + const children = import_node_fs10.default.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)); for (const child of children) { - const relative = prefix ? import_node_path8.default.join(prefix, child.name) : child.name; + const relative = prefix ? import_node_path9.default.join(prefix, child.name) : child.name; if (shouldSkip(relative)) continue; - const absolute = import_node_path8.default.join(directory, child.name); - const stat = import_node_fs9.default.lstatSync(absolute); + const absolute = import_node_path9.default.join(directory, child.name); + const stat = import_node_fs10.default.lstatSync(absolute); const key = portablePath(relative); if (stat.isDirectory()) { entries[key] = { mode: stat.mode & 511, type: "directory" }; @@ -31983,7 +32573,7 @@ function createWorkspaceManifest(rootDirectory) { } else if (stat.isSymbolicLink()) { entries[key] = { mode: stat.mode & 511, - target: import_node_fs9.default.readlinkSync(absolute), + target: import_node_fs10.default.readlinkSync(absolute), type: "symlink" }; } else { @@ -31995,24 +32585,24 @@ function createWorkspaceManifest(rootDirectory) { return { entries, schemaVersion: 1 }; } function writeWorkspaceBaseline({ launchDirectory, workspace }) { - const destination = import_node_path8.default.join(import_node_path8.default.resolve(launchDirectory), WORKSPACE_BASELINE_FILE); + const destination = import_node_path9.default.join(import_node_path9.default.resolve(launchDirectory), WORKSPACE_BASELINE_FILE); const manifest = createWorkspaceManifest(workspace); - const handle = import_node_fs9.default.openSync(destination, "wx", 384); + const handle = import_node_fs10.default.openSync(destination, "wx", 384); try { - import_node_fs9.default.writeFileSync(handle, `${JSON.stringify(manifest)} + import_node_fs10.default.writeFileSync(handle, `${JSON.stringify(manifest)} `, "utf8"); } finally { - import_node_fs9.default.closeSync(handle); + import_node_fs10.default.closeSync(handle); } return destination; } function readWorkspaceBaseline(launchDirectory) { - const file = import_node_path8.default.join(import_node_path8.default.resolve(launchDirectory), WORKSPACE_BASELINE_FILE); + const file = import_node_path9.default.join(import_node_path9.default.resolve(launchDirectory), WORKSPACE_BASELINE_FILE); let parsed; try { - const stat = import_node_fs9.default.lstatSync(file); + const stat = import_node_fs10.default.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("baseline is not a regular file"); - parsed = JSON.parse(import_node_fs9.default.readFileSync(file, "utf8")); + parsed = JSON.parse(import_node_fs10.default.readFileSync(file, "utf8")); } catch (error) { throw new Error(`Isolated workspace baseline is unavailable: ${error.message}`); } @@ -32056,21 +32646,21 @@ var WORKSPACE_MODES = Object.freeze({ }); var VALID_MODES = new Set(Object.values(WORKSPACE_MODES)); function existingDirectory3(candidate, label) { - const resolved = import_node_path9.default.resolve(candidate); + const resolved = import_node_path10.default.resolve(candidate); let stat; try { - stat = import_node_fs10.default.statSync(resolved); + stat = import_node_fs11.default.statSync(resolved); } catch { throw new Error(`${label} does not exist: ${resolved}`); } if (!stat.isDirectory()) { throw new Error(`${label} is not a directory: ${resolved}`); } - return import_node_fs10.default.realpathSync(resolved); + return import_node_fs11.default.realpathSync(resolved); } function isInside(candidate, parent) { - const relative = import_node_path9.default.relative(parent, candidate); - return relative === "" || !relative.startsWith(`..${import_node_path9.default.sep}`) && relative !== ".." && !import_node_path9.default.isAbsolute(relative); + const relative = import_node_path10.default.relative(parent, candidate); + return relative === "" || !relative.startsWith(`..${import_node_path10.default.sep}`) && relative !== ".." && !import_node_path10.default.isAbsolute(relative); } function findGitProjectRoot(workspace, execFileSyncImpl) { try { @@ -32113,7 +32703,7 @@ function createGitWorktree({ } catch (error) { if (error?.message?.startsWith("Worktree branch already exists:")) throw error; } - import_node_fs10.default.mkdirSync(import_node_path9.default.dirname(destination), { recursive: true, mode: 448 }); + import_node_fs11.default.mkdirSync(import_node_path10.default.dirname(destination), { recursive: true, mode: 448 }); try { execFileSyncImpl("git", ["worktree", "add", "-b", branch, destination, baseRef], { cwd: projectRoot, @@ -32127,7 +32717,7 @@ function createGitWorktree({ }); } catch { } - import_node_fs10.default.rmSync(destination, { recursive: true, force: true }); + import_node_fs11.default.rmSync(destination, { recursive: true, force: true }); try { execFileSyncImpl("git", ["branch", "-D", "--", branch], { cwd: projectRoot, @@ -32144,15 +32734,15 @@ function copyIsolatedWorkspace({ destination, projectRoot }) { throw new Error("Isolated workspace destination cannot be inside the source project"); } try { - import_node_fs10.default.cpSync(projectRoot, destination, { + import_node_fs11.default.cpSync(projectRoot, destination, { errorOnExist: true, filter(candidate) { - const relative = import_node_path9.default.relative(projectRoot, candidate); - const firstPart = relative.split(import_node_path9.default.sep)[0]; + const relative = import_node_path10.default.relative(projectRoot, candidate); + const firstPart = relative.split(import_node_path10.default.sep)[0]; if (firstPart === ".git" || firstPart === ".rudi") return false; - const stat = import_node_fs10.default.lstatSync(candidate); + const stat = import_node_fs11.default.lstatSync(candidate); if (stat.isSymbolicLink()) { - const target = import_node_fs10.default.realpathSync(candidate); + const target = import_node_fs11.default.realpathSync(candidate); if (!isInside(target, projectRoot)) { throw new Error(`Workspace contains a symlink outside the project: ${candidate}`); } @@ -32163,7 +32753,7 @@ function copyIsolatedWorkspace({ destination, projectRoot }) { recursive: true }); } catch (error) { - import_node_fs10.default.rmSync(destination, { recursive: true, force: true }); + import_node_fs11.default.rmSync(destination, { recursive: true, force: true }); throw new Error(`Unable to create isolated workspace copy: ${error.message}`); } } @@ -32174,23 +32764,27 @@ function resolveAgentWorkspace(options, dependencies = {}) { mode = WORKSPACE_MODES.AUTO, originDirectory = process.cwd(), outputDirectory = null, + privateAutomation = false, workspace = null } = options || {}; - const { execFileSyncImpl = import_node_child_process4.execFileSync } = dependencies; + const { execFileSyncImpl = import_node_child_process5.execFileSync } = dependencies; assertLaunchId(launchId); if (!VALID_MODES.has(mode)) { throw new Error(`Unknown workspace mode: ${mode}. Available: ${[...VALID_MODES].join(", ")}`); } + if (privateAutomation === true && mode !== WORKSPACE_MODES.READ_ONLY) { + throw new Error("private automation requires read-only workspace mode"); + } if (typeof artifactsRoot !== "string" || artifactsRoot.trim() === "") { throw new Error("artifactsRoot is required"); } const resolvedOrigin = existingDirectory3(originDirectory, "Origin directory"); - const requestedWorkspace = workspace == null ? resolvedOrigin : import_node_path9.default.resolve(resolvedOrigin, workspace); + const requestedWorkspace = workspace == null ? resolvedOrigin : import_node_path10.default.resolve(resolvedOrigin, workspace); const validWorkspace = existingDirectory3(requestedWorkspace, "Workspace"); const gitProjectRoot = findGitProjectRoot(validWorkspace, execFileSyncImpl); const projectRoot = gitProjectRoot || validWorkspace; const isGitRepository = Boolean(gitProjectRoot); - const launchDirectory = outputDirectory == null ? import_node_path9.default.resolve(artifactsRoot, launchId) : import_node_path9.default.resolve(resolvedOrigin, outputDirectory); + const launchDirectory = outputDirectory == null ? import_node_path10.default.resolve(artifactsRoot, launchId) : import_node_path10.default.resolve(resolvedOrigin, outputDirectory); let resolvedMode = mode; if (resolvedMode === WORKSPACE_MODES.AUTO) { resolvedMode = isGitRepository ? WORKSPACE_MODES.WORKTREE : WORKSPACE_MODES.ISOLATED_COPY; @@ -32199,17 +32793,21 @@ function resolveAgentWorkspace(options, dependencies = {}) { throw new Error("Workspace mode worktree requires a Git repository"); } assertOutputOutsideProject(launchDirectory, projectRoot); - if (import_node_fs10.default.existsSync(launchDirectory)) { + if (import_node_fs11.default.existsSync(launchDirectory)) { throw new Error(`Output destination already exists: ${launchDirectory}`); } - import_node_fs10.default.mkdirSync(launchDirectory, { recursive: true, mode: 448 }); + import_node_fs11.default.mkdirSync(launchDirectory, { recursive: true, mode: 448 }); createLaunchOwnershipMarker({ launchDirectory, launchId }); let executionWorkspace = projectRoot; let worktreeBranch = null; let baseRef = null; try { - if (resolvedMode === WORKSPACE_MODES.WORKTREE) { - executionWorkspace = import_node_path9.default.join(launchDirectory, "workspace"); + if (privateAutomation === true) { + executionWorkspace = import_node_path10.default.join(launchDirectory, "private-workspace"); + import_node_fs11.default.mkdirSync(executionWorkspace, { mode: 320 }); + import_node_fs11.default.chmodSync(executionWorkspace, 320); + } else if (resolvedMode === WORKSPACE_MODES.WORKTREE) { + executionWorkspace = import_node_path10.default.join(launchDirectory, "workspace"); const created = createGitWorktree({ destination: executionWorkspace, execFileSyncImpl, @@ -32219,12 +32817,12 @@ function resolveAgentWorkspace(options, dependencies = {}) { worktreeBranch = created.branch; baseRef = created.baseRef; } else if (resolvedMode === WORKSPACE_MODES.ISOLATED_COPY) { - executionWorkspace = import_node_path9.default.join(launchDirectory, "workspace"); + executionWorkspace = import_node_path10.default.join(launchDirectory, "workspace"); copyIsolatedWorkspace({ destination: executionWorkspace, projectRoot }); writeWorkspaceBaseline({ launchDirectory, workspace: executionWorkspace }); } } catch (error) { - import_node_fs10.default.rmSync(launchDirectory, { recursive: true, force: true }); + import_node_fs11.default.rmSync(launchDirectory, { recursive: true, force: true }); throw error; } return Object.freeze({ @@ -32235,14 +32833,15 @@ function resolveAgentWorkspace(options, dependencies = {}) { originDirectory: resolvedOrigin, outputDestination: launchDirectory, projectRoot, + privateAutomation: privateAutomation === true, worktreeBranch }); } function cleanupUnstartedWorkspace(workspace, dependencies = {}) { if (!workspace || typeof workspace !== "object") return; - const { execFileSyncImpl = import_node_child_process4.execFileSync } = dependencies; - const outputDestination = import_node_path9.default.resolve(workspace.outputDestination); - const executionWorkspace = import_node_path9.default.resolve(workspace.executionWorkspace); + const { execFileSyncImpl = import_node_child_process5.execFileSync } = dependencies; + const outputDestination = import_node_path10.default.resolve(workspace.outputDestination); + const executionWorkspace = import_node_path10.default.resolve(workspace.executionWorkspace); if (!isInside(executionWorkspace, outputDestination) && workspace.mode !== WORKSPACE_MODES.READ_ONLY) { throw new Error("Refusing to clean an execution workspace outside its launch output destination"); } @@ -32265,7 +32864,7 @@ function cleanupUnstartedWorkspace(workspace, dependencies = {}) { } catch { } } - import_node_fs10.default.rmSync(outputDestination, { recursive: true, force: true }); + import_node_fs11.default.rmSync(outputDestination, { recursive: true, force: true }); } // src/agent-host/launch.js @@ -32280,6 +32879,7 @@ async function launchAgent(options, dependencies = {}) { ownerPid = null, onSpawn = null, preflightImpl = assertAgentHostReady, + privatePreflightImpl = assertPrivateAutomationHostCapabilities, resolveBinaryImpl = resolveAgentProviderBinary, spawnImpl, stderr = process.stderr, @@ -32288,18 +32888,26 @@ async function launchAgent(options, dependencies = {}) { workspaceResolver = resolveAgentWorkspace } = dependencies; const launchId = idFactory(); + const privateAutomationProfile = options?.privateAutomationProfile || null; + if (privateAutomationProfile && options?.executionKind && options.executionKind !== "foreground") { + throw new Error("private automation supports foreground execution only"); + } const provider = resolveAgentProviderId(options?.provider); const binaryPath = resolveBinaryImpl(provider); if (!binaryPath) { throw new Error(`${provider} host is not installed. Run: rudi install agent:${provider}`); } await preflightImpl({ binaryPath, provider }); + if (privateAutomationProfile) { + await privatePreflightImpl({ binaryPath, profile: privateAutomationProfile }); + } const workspace = workspaceResolver({ artifactsRoot, launchId, mode: options.workspaceMode || "auto", originDirectory: options.originDirectory || process.cwd(), outputDirectory: options.outputDirectory || null, + privateAutomation: privateAutomationProfile != null, workspace: options.workspace || null }); const resolvedEventSink = eventSink || ((event) => appendLaunchEvent( @@ -32316,6 +32924,7 @@ async function launchAgent(options, dependencies = {}) { images: options.images, model: options.model, permissionMode: options.permissionMode, + privateAutomationProfile, prompt: options.prompt, provider, runtimeDirectory: workspace.outputDestination, @@ -32370,11 +32979,11 @@ async function launchAgent(options, dependencies = {}) { } // src/agent-host/resume.js -var import_node_fs11 = __toESM(require("node:fs"), 1); -var import_node_path10 = __toESM(require("node:path"), 1); +var import_node_fs12 = __toESM(require("node:fs"), 1); +var import_node_path11 = __toESM(require("node:path"), 1); function assertWorkspaceStillExists(workspace) { try { - if (import_node_fs11.default.statSync(workspace).isDirectory()) return; + if (import_node_fs12.default.statSync(workspace).isDirectory()) return; } catch { } throw new Error(`Execution workspace no longer exists: ${workspace}`); @@ -32418,11 +33027,11 @@ async function resumeAgentWithStore(options, dependencies) { throw new Error(`${previous.provider} host is not installed. Run: rudi install agent:${previous.provider}`); } await preflightImpl({ binaryPath, provider: previous.provider }); - const outputDestination = dependencies.artifactsRoot ? import_node_path10.default.resolve(artifactsRoot, launchId) : getAgentHostPaths({ launchId, rudiHome: dependencies.rudiHome }).launchDirectory; - if (import_node_fs11.default.existsSync(outputDestination)) { + const outputDestination = dependencies.artifactsRoot ? import_node_path11.default.resolve(artifactsRoot, launchId) : getAgentHostPaths({ launchId, rudiHome: dependencies.rudiHome }).launchDirectory; + if (import_node_fs12.default.existsSync(outputDestination)) { throw new Error(`Output destination already exists: ${outputDestination}`); } - import_node_fs11.default.mkdirSync(outputDestination, { recursive: true, mode: 448 }); + import_node_fs12.default.mkdirSync(outputDestination, { recursive: true, mode: 448 }); createLaunchOwnershipMarker({ launchDirectory: outputDestination, launchId }); const resolvedEventSink = eventSink || ((event) => appendLaunchEvent( getLaunchArtifactFiles(outputDestination).events, @@ -32445,7 +33054,7 @@ async function resumeAgentWithStore(options, dependencies) { workspaceMode: previous.workspaceMode }); } catch (error) { - import_node_fs11.default.rmSync(outputDestination, { recursive: true, force: true }); + import_node_fs12.default.rmSync(outputDestination, { recursive: true, force: true }); throw error; } store.create({ @@ -32503,13 +33112,13 @@ function discardSink() { } }; } function appendPrivateText(file, value) { - const handle = import_node_fs12.default.openSync(file, "a", 384); + const handle = import_node_fs13.default.openSync(file, "a", 384); try { - import_node_fs12.default.writeFileSync(handle, String(value), "utf8"); + import_node_fs13.default.writeFileSync(handle, String(value), "utf8"); } finally { - import_node_fs12.default.closeSync(handle); + import_node_fs13.default.closeSync(handle); } - import_node_fs12.default.chmodSync(file, 384); + import_node_fs13.default.chmodSync(file, 384); } async function dispatchDetachedAgent({ launchId, operation, options }, dependencies = {}) { assertLaunchId(launchId); @@ -32520,7 +33129,7 @@ async function dispatchDetachedAgent({ launchId, operation, options }, dependenc const { entrypoint = process.argv[1], nodePath = process.execPath, - spawnImpl = import_node_child_process5.spawn, + spawnImpl = import_node_child_process6.spawn, timeoutMs = DEFAULT_START_TIMEOUT_MS } = dependencies; if (typeof entrypoint !== "string" || entrypoint.trim() === "") { @@ -32669,11 +33278,11 @@ async function readDetachedWorkerRequest(stdin = process.stdin) { var import_node_crypto4 = __toESM(require("node:crypto"), 1); // src/agent-host/process-lifecycle.js -var import_node_child_process6 = require("node:child_process"); +var import_node_child_process7 = require("node:child_process"); var TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "stopped"]); function verifyDetachedWorkerProcess(launch, dependencies = {}) { if (!launch?.ownerPid || launch.executionKind !== "detached") return false; - const execFileSyncImpl = dependencies.execFileSyncImpl || import_node_child_process6.execFileSync; + const execFileSyncImpl = dependencies.execFileSyncImpl || import_node_child_process7.execFileSync; try { const command = String(execFileSyncImpl("ps", [ "-ww", @@ -32738,9 +33347,9 @@ async function stopAgentLaunch(launchId, dependencies = {}) { } // src/agent-host/workspace-lifecycle.js -var import_node_fs13 = __toESM(require("node:fs"), 1); -var import_node_path11 = __toESM(require("node:path"), 1); -var import_node_child_process7 = require("node:child_process"); +var import_node_fs14 = __toESM(require("node:fs"), 1); +var import_node_path12 = __toESM(require("node:path"), 1); +var import_node_child_process8 = require("node:child_process"); var TERMINAL_STATUSES3 = /* @__PURE__ */ new Set(["completed", "failed", "stopped"]); var MAX_DIFF_BYTES = 20 * 1024 * 1024; function git(execFileSyncImpl, cwd, args) { @@ -32753,7 +33362,7 @@ function git(execFileSyncImpl, cwd, args) { } function noIndexDiff(execFileSyncImpl, left, right) { try { - return git(execFileSyncImpl, import_node_path11.default.dirname(left), [ + return git(execFileSyncImpl, import_node_path12.default.dirname(left), [ "diff", "--no-index", "--binary", @@ -32768,16 +33377,16 @@ function noIndexDiff(execFileSyncImpl, left, right) { } } function isInside2(candidate, parent) { - const relative = import_node_path11.default.relative(parent, candidate); - return relative === "" || !relative.startsWith(`..${import_node_path11.default.sep}`) && relative !== ".." && !import_node_path11.default.isAbsolute(relative); + const relative = import_node_path12.default.relative(parent, candidate); + return relative === "" || !relative.startsWith(`..${import_node_path12.default.sep}`) && relative !== ".." && !import_node_path12.default.isAbsolute(relative); } function safeRelative(root, relativePath) { if (typeof relativePath !== "string" || relativePath === "" || relativePath.includes("\0")) { throw new Error("Launch change contains an invalid path"); } - const platformPath = relativePath.split("/").join(import_node_path11.default.sep); - const destination = import_node_path11.default.resolve(root, platformPath); - if (!isInside2(destination, import_node_path11.default.resolve(root)) || destination === import_node_path11.default.resolve(root)) { + const platformPath = relativePath.split("/").join(import_node_path12.default.sep); + const destination = import_node_path12.default.resolve(root, platformPath); + if (!isInside2(destination, import_node_path12.default.resolve(root)) || destination === import_node_path12.default.resolve(root)) { throw new Error(`Launch change escapes the workspace: ${relativePath}`); } return destination; @@ -32802,7 +33411,7 @@ function parseNullSeparated(value) { return String(value || "").split("\0").filter(Boolean).sort(); } function getGitChangeSet(launch, execFileSyncImpl) { - if (!import_node_fs13.default.existsSync(launch.executionWorkspace)) { + if (!import_node_fs14.default.existsSync(launch.executionWorkspace)) { throw new Error(`Execution workspace no longer exists: ${launch.executionWorkspace}`); } const trackedPatch = git(execFileSyncImpl, launch.executionWorkspace, [ @@ -32838,19 +33447,19 @@ function getGitChangeSet(launch, execFileSyncImpl) { }; } function assertSafeSymlinks(workspace, relativePaths) { - const root = import_node_fs13.default.realpathSync(workspace); + const root = import_node_fs14.default.realpathSync(workspace); for (const relativePath of relativePaths) { const candidate = safeRelative(root, relativePath); let stat; try { - stat = import_node_fs13.default.lstatSync(candidate); + stat = import_node_fs14.default.lstatSync(candidate); } catch { continue; } if (!stat.isSymbolicLink()) continue; let target; try { - target = import_node_fs13.default.realpathSync(candidate); + target = import_node_fs14.default.realpathSync(candidate); } catch { throw new Error(`Launch change contains a broken symlink: ${relativePath}`); } @@ -32864,7 +33473,7 @@ function cleanupGitWorktree(launch, execFileSyncImpl) { if (launch.worktreeBranch !== expectedBranch) { throw new Error(`Refusing to clean unexpected worktree branch: ${launch.worktreeBranch || "none"}`); } - if (import_node_fs13.default.existsSync(launch.executionWorkspace)) { + if (import_node_fs14.default.existsSync(launch.executionWorkspace)) { git(execFileSyncImpl, launch.projectRoot, [ "worktree", "remove", @@ -32884,33 +33493,33 @@ function copyWorkspaceEntry(sourceRoot, destinationRoot, relativePath, entry) { const source = safeRelative(sourceRoot, relativePath); const destination = safeRelative(destinationRoot, relativePath); if (entry.type === "directory") { - import_node_fs13.default.mkdirSync(destination, { recursive: true, mode: entry.mode }); - import_node_fs13.default.chmodSync(destination, entry.mode); + import_node_fs14.default.mkdirSync(destination, { recursive: true, mode: entry.mode }); + import_node_fs14.default.chmodSync(destination, entry.mode); return; } - import_node_fs13.default.mkdirSync(import_node_path11.default.dirname(destination), { recursive: true }); - const temporary = import_node_path11.default.join( - import_node_path11.default.dirname(destination), - `.${import_node_path11.default.basename(destination)}.rudi-promote-${process.pid}` + import_node_fs14.default.mkdirSync(import_node_path12.default.dirname(destination), { recursive: true }); + const temporary = import_node_path12.default.join( + import_node_path12.default.dirname(destination), + `.${import_node_path12.default.basename(destination)}.rudi-promote-${process.pid}` ); - import_node_fs13.default.rmSync(temporary, { recursive: true, force: true }); + import_node_fs14.default.rmSync(temporary, { recursive: true, force: true }); if (entry.type === "file") { - import_node_fs13.default.copyFileSync(source, temporary, import_node_fs13.default.constants.COPYFILE_EXCL); - import_node_fs13.default.chmodSync(temporary, entry.mode); + import_node_fs14.default.copyFileSync(source, temporary, import_node_fs14.default.constants.COPYFILE_EXCL); + import_node_fs14.default.chmodSync(temporary, entry.mode); } else if (entry.type === "symlink") { - import_node_fs13.default.symlinkSync(entry.target, temporary); + import_node_fs14.default.symlinkSync(entry.target, temporary); } else { throw new Error(`Unsupported promoted entry type: ${entry.type}`); } - import_node_fs13.default.rmSync(destination, { recursive: true, force: true }); - import_node_fs13.default.renameSync(temporary, destination); + import_node_fs14.default.rmSync(destination, { recursive: true, force: true }); + import_node_fs14.default.renameSync(temporary, destination); } function restoreDirectoryFromBackup(projectRoot, backup) { - for (const entry of import_node_fs13.default.readdirSync(projectRoot)) { - import_node_fs13.default.rmSync(import_node_path11.default.join(projectRoot, entry), { recursive: true, force: true }); + for (const entry of import_node_fs14.default.readdirSync(projectRoot)) { + import_node_fs14.default.rmSync(import_node_path12.default.join(projectRoot, entry), { recursive: true, force: true }); } - for (const entry of import_node_fs13.default.readdirSync(backup)) { - import_node_fs13.default.cpSync(import_node_path11.default.join(backup, entry), import_node_path11.default.join(projectRoot, entry), { + for (const entry of import_node_fs14.default.readdirSync(backup)) { + import_node_fs14.default.cpSync(import_node_path12.default.join(backup, entry), import_node_path12.default.join(projectRoot, entry), { errorOnExist: true, force: false, recursive: true @@ -32924,13 +33533,13 @@ function applyIsolatedChanges(launch, baseline, current) { } assertSafeSymlinks(launch.executionWorkspace, Object.keys(current.entries)); const changes = compareWorkspaceManifests(baseline, current); - const backup = import_node_path11.default.join(launch.outputDestination, "promotion-backup"); - if (import_node_fs13.default.existsSync(backup)) throw new Error(`Promotion backup already exists: ${backup}`); - import_node_fs13.default.cpSync(launch.projectRoot, backup, { errorOnExist: true, force: false, recursive: true }); + const backup = import_node_path12.default.join(launch.outputDestination, "promotion-backup"); + if (import_node_fs14.default.existsSync(backup)) throw new Error(`Promotion backup already exists: ${backup}`); + import_node_fs14.default.cpSync(launch.projectRoot, backup, { errorOnExist: true, force: false, recursive: true }); try { const removals = changes.filter((change) => change.after == null).sort((left, right) => right.path.split("/").length - left.path.split("/").length); for (const change of removals) { - import_node_fs13.default.rmSync(safeRelative(launch.projectRoot, change.path), { recursive: true, force: true }); + import_node_fs14.default.rmSync(safeRelative(launch.projectRoot, change.path), { recursive: true, force: true }); } const directories = changes.filter((change) => change.after?.type === "directory"); const otherEntries = changes.filter((change) => change.after && change.after.type !== "directory"); @@ -32961,7 +33570,7 @@ function applyIsolatedChanges(launch, baseline, current) { } throw error; } finally { - import_node_fs13.default.rmSync(backup, { recursive: true, force: true }); + import_node_fs14.default.rmSync(backup, { recursive: true, force: true }); } return changes; } @@ -32977,7 +33586,7 @@ function withLaunchStore(dependencies, operation) { function diffAgentLaunch(launchId, dependencies = {}) { return withLaunchStore(dependencies, (store) => { const launch = requireManagedLaunch(store, launchId); - const execFileSyncImpl = dependencies.execFileSyncImpl || import_node_child_process7.execFileSync; + const execFileSyncImpl = dependencies.execFileSyncImpl || import_node_child_process8.execFileSync; if (launch.workspaceMode === "worktree") { return { ...getGitChangeSet(launch, execFileSyncImpl), @@ -33005,7 +33614,7 @@ function promoteAgentLaunch(launchId, dependencies = {}) { return { alreadyPromoted: true, changes: null, launch: existing }; } const launch = requireManagedLaunch(store, launchId, { terminal: true }); - const execFileSyncImpl = dependencies.execFileSyncImpl || import_node_child_process7.execFileSync; + const execFileSyncImpl = dependencies.execFileSyncImpl || import_node_child_process8.execFileSync; let changes; if (launch.workspaceMode === "worktree") { const targetStatus = git(execFileSyncImpl, launch.projectRoot, [ @@ -33031,7 +33640,7 @@ function promoteAgentLaunch(launchId, dependencies = {}) { assertSafeSymlinks(launch.executionWorkspace, [...changedTracked, ...changes.untracked]); for (const relativePath of changes.untracked) { const destination = safeRelative(launch.projectRoot, relativePath); - if (import_node_fs13.default.existsSync(destination)) { + if (import_node_fs14.default.existsSync(destination)) { throw new Error(`Cannot promote untracked file because the destination exists: ${relativePath}`); } } @@ -33054,8 +33663,8 @@ function promoteAgentLaunch(launchId, dependencies = {}) { for (const relativePath of changes.untracked) { const source = safeRelative(launch.executionWorkspace, relativePath); const destination = safeRelative(launch.projectRoot, relativePath); - import_node_fs13.default.mkdirSync(import_node_path11.default.dirname(destination), { recursive: true }); - import_node_fs13.default.cpSync(source, destination, { errorOnExist: true, force: false, recursive: true }); + import_node_fs14.default.mkdirSync(import_node_path12.default.dirname(destination), { recursive: true }); + import_node_fs14.default.cpSync(source, destination, { errorOnExist: true, force: false, recursive: true }); } const updated = store.setDisposition(launchId, "promoted"); cleanupGitWorktree(updated, execFileSyncImpl); @@ -33066,7 +33675,7 @@ function promoteAgentLaunch(launchId, dependencies = {}) { const current = createWorkspaceManifest(launch.executionWorkspace); changes = applyIsolatedChanges(launch, baseline, current); const updated = store.setDisposition(launchId, "promoted"); - import_node_fs13.default.rmSync(updated.executionWorkspace, { recursive: true, force: true }); + import_node_fs14.default.rmSync(updated.executionWorkspace, { recursive: true, force: true }); return { changes, launch: store.get(launchId) }; } throw new Error("Read-only launches have no isolated changes to promote"); @@ -33079,9 +33688,9 @@ function discardAgentLaunch(launchId, dependencies = {}) { return { alreadyDiscarded: true, launch: existing }; } const launch = requireManagedLaunch(store, launchId, { terminal: true }); - const execFileSyncImpl = dependencies.execFileSyncImpl || import_node_child_process7.execFileSync; + const execFileSyncImpl = dependencies.execFileSyncImpl || import_node_child_process8.execFileSync; if (launch.workspaceMode === "worktree") cleanupGitWorktree(launch, execFileSyncImpl); - import_node_fs13.default.rmSync(launch.outputDestination, { recursive: true, force: true }); + import_node_fs14.default.rmSync(launch.outputDestination, { recursive: true, force: true }); const updated = store.setDisposition(launchId, "discarded"); return { launch: updated }; }); @@ -33090,7 +33699,7 @@ function discardAgentLaunch(launchId, dependencies = {}) { // src/agent-host/group.js var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["starting", "running"]); var MAX_PROMPT_BYTES2 = 10 * 1024 * 1024; -function requiredText2(value, field, maxBytes = 4096) { +function requiredText3(value, field, maxBytes = 4096) { if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) { throw new Error(`${field} must be a non-empty string without NUL bytes`); } @@ -33110,7 +33719,7 @@ function validateTasks(tasks) { launchId: assertLaunchId(task.launchId), model: task.model, permissionMode: task.permissionMode, - prompt: requiredText2(task.prompt, `tasks[${index}].prompt`, MAX_PROMPT_BYTES2), + prompt: requiredText3(task.prompt, `tasks[${index}].prompt`, MAX_PROMPT_BYTES2), provider: resolveAgentProviderId(task.provider), timeoutMs: task.timeoutMs })); @@ -33124,8 +33733,8 @@ function createAgentGroupId() { } async function launchDetachedAgentGroup(request, dependencies = {}) { const groupId = assertAgentGroupId(request?.groupId); - const originDirectory = requiredText2(request?.originDirectory, "originDirectory"); - const workspace = requiredText2(request?.workspace, "workspace"); + const originDirectory = requiredText3(request?.originDirectory, "originDirectory"); + const workspace = requiredText3(request?.workspace, "workspace"); const workspaceMode = request?.workspaceMode || "auto"; const tasks = validateTasks(request?.tasks); const ownsStore = !dependencies.store; @@ -33186,7 +33795,7 @@ async function stopAgentGroup(groupId, dependencies = {}) { } // src/daemon/routes/agent-host-validation.js -var import_node_path12 = __toESM(require("node:path"), 1); +var import_node_path13 = __toESM(require("node:path"), 1); var MAX_AGENT_HOST_BODY_BYTES = 12 * 1024 * 1024; var LAUNCH_FIELDS = /* @__PURE__ */ new Set([ "approvalMode", @@ -33287,7 +33896,7 @@ function validateRequest(body, allowed, { resume = false } = {}) { } if (!resume) { Object.assign(options, { - originDirectory: import_node_path12.default.resolve(requireText(body.originDirectory, "originDirectory")), + originDirectory: import_node_path13.default.resolve(requireText(body.originDirectory, "originDirectory")), outputDirectory: body.outputDirectory == null ? void 0 : requireText(body.outputDirectory, "outputDirectory"), provider: requireText(body.provider, "provider", 64), workspace: body.workspace == null ? void 0 : requireText(body.workspace, "workspace"), @@ -33357,7 +33966,7 @@ function validateAgentGroupRequest(body) { }); return { groupId: assertAgentGroupId(body.groupId), - originDirectory: import_node_path12.default.resolve(requireText(body.originDirectory, "originDirectory")), + originDirectory: import_node_path13.default.resolve(requireText(body.originDirectory, "originDirectory")), tasks, workspace: requireText(body.workspace, "workspace"), workspaceMode: body.workspaceMode == null ? "auto" : requireText(body.workspaceMode, "workspaceMode", 32) @@ -33585,7 +34194,7 @@ function buildAgentHostRoutes(ctx, dependencies = {}) { // src/daemon/routes/packages.js var import_crypto2 = __toESM(require("crypto"), 1); -var fs44 = __toESM(require("fs/promises"), 1); +var fs45 = __toESM(require("fs/promises"), 1); var fsSync2 = __toESM(require("fs"), 1); var import_path23 = __toESM(require("path"), 1); init_src5(); @@ -33657,7 +34266,7 @@ var defaultDeps = { async function loadManifest3(installPath) { const manifestPath = import_path23.default.join(installPath, "manifest.json"); try { - const content = await fs44.readFile(manifestPath, "utf-8"); + const content = await fs45.readFile(manifestPath, "utf-8"); return JSON.parse(content); } catch { return null; @@ -33802,7 +34411,7 @@ async function checkSecrets3(manifest, deps) { async function parseEnvExample2(installPath) { const examplePath = import_path23.default.join(installPath, ".env.example"); try { - const content = await fs44.readFile(examplePath, "utf-8"); + const content = await fs45.readFile(examplePath, "utf-8"); const keys = []; for (const line of content.split("\n")) { const trimmed = line.trim(); @@ -33818,7 +34427,7 @@ async function parseEnvExample2(installPath) { async function cleanupFailedStackInstall2(stackId, stackPath, removeConfig, deps) { if (stackPath) { try { - await fs44.rm(stackPath, { recursive: true, force: true }); + await fs45.rm(stackPath, { recursive: true, force: true }); } catch { } } @@ -35407,9 +36016,9 @@ function uninstallLaunchAgent(options = {}) { } // src/daemon/runtime/lifecycle.js -var import_node_fs14 = __toESM(require("node:fs"), 1); -var import_node_path13 = __toESM(require("node:path"), 1); -var import_node_child_process8 = require("node:child_process"); +var import_node_fs15 = __toESM(require("node:fs"), 1); +var import_node_path14 = __toESM(require("node:path"), 1); +var import_node_child_process9 = require("node:child_process"); init_src(); var DEFAULT_START_TIMEOUT_MS2 = 45e3; var DEFAULT_STOP_TIMEOUT_MS = 1e4; @@ -35437,11 +36046,11 @@ function removeDaemonConnectionFiles({ tokenFile = DAEMON_TOKEN_FILE } = {}) { try { - import_node_fs14.default.unlinkSync(portFile); + import_node_fs15.default.unlinkSync(portFile); } catch { } try { - import_node_fs14.default.unlinkSync(tokenFile); + import_node_fs15.default.unlinkSync(tokenFile); } catch { } } @@ -35463,13 +36072,13 @@ function spawnDaemonProcess({ logsDir = PATHS.logs, nodePath = process.execPath, serveArgs = ["serve"], - spawnImpl = import_node_child_process8.spawn + spawnImpl = import_node_child_process9.spawn } = {}) { - import_node_fs14.default.mkdirSync(logsDir, { recursive: true }); - const stdoutPath = import_node_path13.default.join(logsDir, "daemon.out.log"); - const stderrPath = import_node_path13.default.join(logsDir, "daemon.err.log"); - const stdoutFd = import_node_fs14.default.openSync(stdoutPath, "a"); - const stderrFd = import_node_fs14.default.openSync(stderrPath, "a"); + import_node_fs15.default.mkdirSync(logsDir, { recursive: true }); + const stdoutPath = import_node_path14.default.join(logsDir, "daemon.out.log"); + const stderrPath = import_node_path14.default.join(logsDir, "daemon.err.log"); + const stdoutFd = import_node_fs15.default.openSync(stdoutPath, "a"); + const stderrFd = import_node_fs15.default.openSync(stderrPath, "a"); try { const child = spawnImpl(nodePath, [entrypoint, ...serveArgs], { detached: true, @@ -35480,11 +36089,11 @@ function spawnDaemonProcess({ return { pid: child.pid, stderrPath, stdoutPath }; } finally { try { - import_node_fs14.default.closeSync(stdoutFd); + import_node_fs15.default.closeSync(stdoutFd); } catch { } try { - import_node_fs14.default.closeSync(stderrFd); + import_node_fs15.default.closeSync(stderrFd); } catch { } } @@ -36067,8 +36676,8 @@ async function attachAgentLaunch(launchId, dependencies = {}) { } // src/agent-host/cli-inputs.js -var import_node_fs15 = __toESM(require("node:fs"), 1); -var import_node_path14 = __toESM(require("node:path"), 1); +var import_node_fs16 = __toESM(require("node:fs"), 1); +var import_node_path15 = __toESM(require("node:path"), 1); var MAX_PROMPT_BYTES3 = 10 * 1024 * 1024; function flagValue(flags, kebab, camel = null) { return flags[kebab] ?? (camel ? flags[camel] : void 0); @@ -36079,14 +36688,14 @@ function requiredFlagString(value, name) { } return value; } -async function readPromptStream(stdin) { +async function readPromptStream(stdin, maxBytes = MAX_PROMPT_BYTES3) { let value = ""; let size = 0; for await (const chunk of stdin) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); size += buffer.length; - if (size > MAX_PROMPT_BYTES3) { - throw new Error(`stdin prompt exceeds ${MAX_PROMPT_BYTES3} bytes`); + if (size > maxBytes) { + throw new Error(`stdin prompt exceeds ${maxBytes} bytes`); } value += buffer.toString("utf8"); } @@ -36098,6 +36707,10 @@ async function resolveAgentPrompt(flags, { } = {}) { const inline = flags.prompt; const promptFile = flagValue(flags, "prompt-file", "promptFile"); + const privateAutomation = flagValue(flags, "private-automation", "privateAutomation") === true; + if (privateAutomation && (inline != null || promptFile != null)) { + throw new Error("private automation prompt must be supplied through stdin"); + } if (inline != null && promptFile != null) { throw new Error("Use exactly one of --prompt or --prompt-file"); } @@ -36106,25 +36719,29 @@ async function resolveAgentPrompt(flags, { prompt = requiredFlagString(inline, "--prompt"); } else if (promptFile != null) { const fileValue = requiredFlagString(promptFile, "--prompt-file"); - const filePath = import_node_path14.default.resolve(originDirectory, fileValue); + const filePath = import_node_path15.default.resolve(originDirectory, fileValue); let stat; try { - stat = import_node_fs15.default.statSync(filePath); + stat = import_node_fs16.default.statSync(filePath); } catch { throw new Error(`Prompt file does not exist: ${filePath}`); } if (!stat.isFile()) throw new Error(`Prompt file is not a regular file: ${filePath}`); if (stat.size > MAX_PROMPT_BYTES3) throw new Error(`Prompt file exceeds ${MAX_PROMPT_BYTES3} bytes`); - prompt = import_node_fs15.default.readFileSync(filePath, "utf8"); - } else if (stdin && stdin.isTTY === false) { - prompt = await readPromptStream(stdin); + prompt = import_node_fs16.default.readFileSync(filePath, "utf8"); + } else if (stdin && stdin.isTTY !== true) { + prompt = await readPromptStream( + stdin, + privateAutomation ? PRIVATE_AUTOMATION_MAX_PROMPT_BYTES : MAX_PROMPT_BYTES3 + ); } else { throw new Error("Prompt required via --prompt, --prompt-file, or stdin"); } if (!prompt.trim()) throw new Error("Prompt must not be empty"); if (prompt.includes("\0")) throw new Error("Prompt must not contain NUL bytes"); - if (Buffer.byteLength(prompt, "utf8") > MAX_PROMPT_BYTES3) { - throw new Error(`Prompt exceeds ${MAX_PROMPT_BYTES3} bytes`); + const maxPromptBytes = privateAutomation ? PRIVATE_AUTOMATION_MAX_PROMPT_BYTES : MAX_PROMPT_BYTES3; + if (Buffer.byteLength(prompt, "utf8") > maxPromptBytes) { + throw new Error(`Prompt exceeds ${maxPromptBytes} bytes`); } return prompt; } @@ -36142,10 +36759,10 @@ function parseImages(flags, originDirectory) { const value = flags.image ?? flags.images; if (value == null) return []; return requiredFlagString(value, "--image").split(",").map((item) => item.trim()).filter(Boolean).map((item) => { - const imagePath = import_node_path14.default.resolve(originDirectory, item); + const imagePath = import_node_path15.default.resolve(originDirectory, item); let stat; try { - stat = import_node_fs15.default.statSync(imagePath); + stat = import_node_fs16.default.statSync(imagePath); } catch { throw new Error(`Image attachment does not exist: ${imagePath}`); } @@ -36163,6 +36780,55 @@ function parseTimeout2(flags) { return parsed; } function buildLaunchOptions(provider, prompt, flags, passthrough, originDirectory) { + const privateAutomation = flagValue(flags, "private-automation", "privateAutomation") === true; + if (privateAutomation) { + const forbiddenFlags = [ + ["approval-mode", "approvalMode"], + ["image", "images"], + ["mode"], + ["output-dir", "outputDirectory"], + ["permission-mode", "permissionMode"], + ["read-only", "readOnly"], + ["workspace"], + ["workspace-mode", "workspaceMode"] + ]; + for (const names of forbiddenFlags) { + if (names.some((name) => flags[name] != null)) { + throw new Error(`private automation forbids --${names[0]}`); + } + } + if (flags.detach === true) throw new Error("private automation forbids detached execution"); + if (passthrough.length > 0) throw new Error("private automation forbids provider passthrough arguments"); + const canonicalProvider = resolveAgentProviderId(provider); + const outputSchemaValue = requiredFlagString( + flagValue(flags, "output-schema", "outputSchema"), + "--output-schema" + ); + const outputSchemaPath = import_node_path15.default.resolve(originDirectory, outputSchemaValue); + const timeoutMs = parseTimeout2(flags); + const privateAutomationProfile = createPrivateAutomationProfile({ + model: flags.model, + outputSchemaPath, + provider: canonicalProvider, + timeoutMs + }); + return { + approvalMode: null, + extraArgs: [], + images: [], + json: flags.json === true, + model: privateAutomationProfile.model, + originDirectory, + outputDirectory: null, + permissionMode: canonicalProvider === "codex" ? "readonly" : "plan", + privateAutomationProfile, + prompt, + provider: canonicalProvider, + timeoutMs: privateAutomationProfile.timeoutMs, + workspace: null, + workspaceMode: "read-only" + }; + } return { approvalMode: flagValue(flags, "approval-mode", "approvalMode"), extraArgs: passthrough, @@ -36212,16 +36878,16 @@ function readGroupTaskFiles(taskFlag, originDirectory, common = {}) { } const provider = value.slice(0, separator); resolveAgentProviderId(provider); - const filePath = import_node_path14.default.resolve(originDirectory, value.slice(separator + 1)); + const filePath = import_node_path15.default.resolve(originDirectory, value.slice(separator + 1)); let stat; try { - stat = import_node_fs15.default.statSync(filePath); + stat = import_node_fs16.default.statSync(filePath); } catch { throw new Error(`Task file does not exist: ${filePath}`); } if (!stat.isFile()) throw new Error(`Task file is not a regular file: ${filePath}`); if (stat.size > MAX_PROMPT_BYTES3) throw new Error(`Task file exceeds ${MAX_PROMPT_BYTES3} bytes`); - const prompt = import_node_fs15.default.readFileSync(filePath, "utf8"); + const prompt = import_node_fs16.default.readFileSync(filePath, "utf8"); if (!prompt.trim()) throw new Error(`Task file must not be empty: ${filePath}`); if (prompt.includes("\0")) throw new Error(`Task file must not contain NUL bytes: ${filePath}`); return { ...common, prompt, provider }; @@ -36312,6 +36978,12 @@ PROVIDER OPTIONS --json Emit normalized JSONL events --detach Run through the local background service +PRIVATE AUTOMATION (FOREGROUND ONLY) + --private-automation Metadata-only, zero-tool private inference profile + --output-schema Required bounded structured-output schema + --model Required exact configured provider model ID + stdin Required prompt source; prompt flags are forbidden + Foreground execution needs neither the daemon nor Lite. Detached execution is owned by a dedicated RUDI worker and survives the invoking terminal and Lite. `); @@ -36346,6 +37018,10 @@ async function cmdAgent(args = [], flags = {}, passthrough = [], dependencies = const subcommand = args[0]; const originDirectory = dependencies.originDirectory || process.cwd(); const stdin = dependencies.stdin || process.stdin; + const privateAutomation = flagValue(flags, "private-automation", "privateAutomation") === true; + if (privateAutomation && subcommand !== "launch") { + throw new Error("private automation supports only rudi agent launch"); + } if (subcommand === "_worker") { const launchId = requiredLaunchId(args, "_worker"); const readWorkerRequestImpl = dependencies.readWorkerRequestImpl || readDetachedWorkerRequest; diff --git a/docs/frontier-agent-hosts.md b/docs/frontier-agent-hosts.md index 8bd8712..36b2a7f 100644 --- a/docs/frontier-agent-hosts.md +++ b/docs/frontier-agent-hosts.md @@ -60,6 +60,58 @@ core the CLI calls directly. Groups are projections over independent child launches, preserving each provider's native session and each launch's own workspace, events, diff, promotion, and discard lifecycle. +## Private automation profile + +`private-automation-v1` is the narrow inference-only surface for approved +private data such as email classification. It is deliberately separate from +normal Agent Host launches: + +```bash +private-input-producer | rudi agent launch codex \ + --private-automation \ + --model gpt-5.6-luna \ + --output-schema ./classification.schema.json \ + --timeout-ms 130000 \ + --json + +private-input-producer | rudi agent launch claude \ + --private-automation \ + --model claude-sonnet-5 \ + --output-schema ./classification.schema.json \ + --timeout-ms 130000 \ + --json +``` + +Do not put the private prompt in the producer's argv or shell history. The +profile accepts the prompt only from non-TTY stdin, and the provider receives +it only through child stdin. It requires a canonical configured model ID and a +self-contained, closed JSON object schema. Model defaults, aliases, fallback +models, prompt files, detach/resume/groups, workspace selection, images, +permission overrides, and native passthrough argv are rejected. + +Each launch gets a fresh empty workspace with no write bits. Codex and Claude +run without tools, MCP, browser, shell, project instructions, plugins, skills, +or session persistence. The profile has a 165-second hard maximum (160 seconds +by default), a 2-MiB raw provider-stream ceiling, and a 64-KiB final structured +result ceiling. Provider stderr is suppressed, native session IDs are not +stored, and launch artifacts receive only event/usage/status metadata. The one +structured result is returned transiently on stdout to the invoking process +only after the provider stream reports the exact requested model. Missing or +different provider-observed model identity fails closed. + +Private use still requires an organization-approved provider/model egress +contract and a synthetic no-tool launch for each exact installed provider and +model. Use this same command with a fixed benign prompt and a closed probe +schema while the empty workspace and metadata-only artifacts are inspected; +flag/help discovery alone is not activation evidence. The profile never +chooses a provider or model and never falls back to another one. + +Codex private automation currently requires Codex CLI `0.146.0` or newer. The +launcher checks that version, strict no-web/no-image configuration, all named +feature controls, and the required `exec` flags before it creates a workspace +or delivers stdin. Claude is similarly capability-probed from its installed +CLI help contract after normal installation/authentication preflight. + ## Install and update Claude and Antigravity use their vendors' native installers and update mechanisms. RUDI detects and registers those executables. Codex and Gemini CLI are RUDI-managed npm agents. diff --git a/docs/swe-compliance/2026-08-08-private-automation-profile.md b/docs/swe-compliance/2026-08-08-private-automation-profile.md new file mode 100644 index 0000000..ad25991 --- /dev/null +++ b/docs/swe-compliance/2026-08-08-private-automation-profile.md @@ -0,0 +1,97 @@ +# Private Agent Host Automation Profile + +## Phase 0: Baseline And Manual Lookup + +- Status: complete. +- Scope: add a provider-neutral, stdin-only, metadata-only Agent Host profile + for bounded private classification through exact Codex and Claude models. +- Files inspected: `AGENTS.md`, Agent Host CLI inputs, launch/workspace/event + flow, provider builders/config, artifacts/store tests, and frontier-host docs. +- Relevant SWE manual sections: F5 trust boundaries, F12 security testing, F13 + agent security, G4 side effects, H1 artifact integrity, and Testing Doctrine. +- Current risk: normal provider plans can place prompts in argv and persist + normalized content events; private email cannot use that path. +- Exit criteria: exact provider/model, prompt, workspace, tool, output, + persistence, timeout, and failure invariants are explicit before code. + +## Phase 1: Scope Lock + +- Status: complete. +- In scope: provider-neutral profile `private-automation-v1`; exact canonical + configured Codex or Claude model; prompt stdin; explicit JSON schema; empty read-only + workspace; no tools/MCP/browser/shell; ephemeral execution; one bounded + attempt; metadata-only artifacts; 165-second maximum, 2-MiB raw-stream and + 64-KiB final-result ceilings; no fallback. +- Non-goals: sessions/resume, detached/group work, writable workspaces, images, + arbitrary provider args, provider selection, business retries, or storing + prompts/model output. +- External inputs: CLI flags, stdin bytes, schema file, provider JSONL, stderr, + user/provider configuration, and provider/model catalogs. +- Failure behavior: reject conflicting flags before workspace/process creation; + fail on tool events, output overflow, model mismatch, unknown events carrying + content, unconfirmed termination, or metadata persistence failure. +- Exit criteria: one behavior test demonstrates the existing argv/content + persistence path fails the private contract. + +## Phase 2: Red Tests + +- Status: complete. +- Test: `src/__tests__/unit/agent-host-private-automation.test.js`. +- Red command: `node --test src/__tests__/unit/agent-host-private-automation.test.js`. +- Observed failure: `ERR_MODULE_NOT_FOUND` for + `src/agent-host/private-automation-profile.js`, before the guarded launch path + existed. + +## Phase 3: Implementation + +- Status: complete. +- Allowed files: the scope-locked Agent Host CLI, inputs, launch, event stream, + provider common/Codex/Claude builders/config, focused test/docs, and tracked + `dist/index.cjs` build output. +- Implemented: canonical model/schema profile validation; stdin-only provider + plans; empty launch-owned read-only workspace; explicit Codex and Claude + no-tool controls; environment allowlist; metadata-only event projection; + raw/final output bounds; safe errors; suppressed private stderr and session + identity; foreground-only command guard. + +## Phase 4: Green Tests And Refactor + +- Status: complete for focused and adjacent regression suites. +- Focused result: 16/16 passing, including pre-egress provider capability + gating and argv/stdin/env/workspace/artifact/DB + isolation, malformed output, closed Claude event types, missing/different + observed model identity, tool event, process-group termination, raw/final + overflow, timeout, and forbidden command surfaces. +- Adjacent result: 42/42 passing across Agent Host command, launch, provider, + provider-environment, workspace, and model suites. + +## Phase 5: Full Verification + +- Status: complete for source; compatible authenticated + live providers remain a deployment prerequisite. +- Required: focused test, full `pnpm test`, `pnpm build`, reproducible dist + check, changed-file debt scan, package dry-run, argv/artifact/log privacy + smoke tests, and exact provider probes with synthetic data. +- Completed evidence: + - full test: 631/631 passing outside the network-bind sandbox; the initial + sandboxed run had only the expected localhost `EPERM` smoke-test failure; + - build: passing; two builds produced identical SHA-256 hashes; + - package dry-run: six expected package entries only; + - RUDI debt scan, `pr-review` profile: zero findings; + - integrated synthetic privacy tests: prompt absent from provider argv, + environment, stderr, database, native session field, and artifacts; + - Codex 0.145.0: rejected before workspace/process/artifact creation because + it lacks the strict `tools.view_image` config control required by the + current official Codex configuration contract; deployment requires Codex + 0.146.0 or newer plus the same live capability probes; + - Claude 2.1.226: required flags are present, but the Admin Mac is currently + unauthenticated, so the synthetic private launch was rejected before + workspace/process/artifact creation. + +## Phase 6: Docs, Contracts, And Closure + +- Status: complete for source; live provider probe and deployment evidence are + still gated. +- Definition of Done: private prompts appear only on stdin; launch artifacts + and operational logs remain metadata-only; exact model/no-tool/schema/output + contracts are enforced for both providers; rollback material is recorded. diff --git a/src/__tests__/unit/agent-host-private-automation.test.js b/src/__tests__/unit/agent-host-private-automation.test.js new file mode 100644 index 0000000..506f490 --- /dev/null +++ b/src/__tests__/unit/agent-host-private-automation.test.js @@ -0,0 +1,821 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PassThrough, Readable } from 'node:stream'; +import { afterEach, describe, test } from 'node:test'; + +import { buildLaunchOptions, resolveAgentPrompt } from '../../agent-host/cli-inputs.js'; +import { getLaunchArtifactFiles } from '../../agent-host/artifacts.js'; +import { launchAgent } from '../../agent-host/launch.js'; +import { createLaunchStore } from '../../agent-host/launch-store.js'; +import { + assertPrivateAutomationRawEvent, + assertPrivateAutomationHostCapabilities, + createPrivateAutomationProfile, + PRIVATE_AUTOMATION_MAX_FINAL_OUTPUT_BYTES, + PRIVATE_AUTOMATION_MAX_RAW_OUTPUT_BYTES, + projectPrivateAutomationEventMetadata, +} from '../../agent-host/private-automation-profile.js'; +import { buildClaudePlan } from '../../agent-host/providers/claude.js'; +import { buildCodexPlan } from '../../agent-host/providers/codex.js'; +import { resolveAgentWorkspace } from '../../agent-host/workspace.js'; +import { cmdAgent } from '../../commands/agent-host.js'; + +const roots = []; +const privatePrompt = 'PRIVATE_EMAIL_SENTINEL_2f756c2d'; +const outputSchema = Object.freeze({ + additionalProperties: false, + properties: { + category: { enum: ['conversation', 'unknown'], type: 'string' }, + schemaVersion: { const: 1, type: 'integer' }, + }, + required: ['category', 'schemaVersion'], + type: 'object', +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-private-automation-')); + roots.push(root); + const originDirectory = path.join(root, 'origin'); + const artifactsRoot = path.join(root, 'artifacts'); + fs.mkdirSync(originDirectory); + fs.writeFileSync(path.join(originDirectory, 'private-origin-sentinel.txt'), privatePrompt); + fs.mkdirSync(artifactsRoot); + const outputSchemaPath = path.join(root, 'output.schema.json'); + fs.writeFileSync(outputSchemaPath, `${JSON.stringify(outputSchema)}\n`); + return { artifactsRoot, originDirectory, outputSchemaPath, root }; +} + +function providerOptions(provider, model, profile, workspace) { + return { + binaryPath: `/opt/rudi/bin/${provider}`, + cwd: workspace.executionWorkspace, + extraArgs: [], + images: [], + model, + permissionMode: provider === 'codex' ? 'readonly' : 'plan', + privateAutomationProfile: profile, + prompt: privatePrompt, + provider, + runtimeDirectory: workspace.outputDestination, + workspaceMode: workspace.mode, + }; +} + +function memorySink() { + let value = ''; + return { + sink: { write(chunk) { value += String(chunk); } }, + value() { return value; }, + }; +} + +function privateCodexSpawn(calls, { malformed = false, tool = false } = {}) { + return (command, args, options) => { + const child = new EventEmitter(); + child.pid = 9042; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + let stdin = ''; + child.stdin.on('data', chunk => { stdin += chunk.toString(); }); + child.kill = () => true; + calls.push({ args, child, command, options, stdin: () => stdin }); + child.stdin.once('finish', () => { + queueMicrotask(() => { + child.emit('spawn'); + child.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: 'private-session-id' })}\n`); + if (malformed) { + child.stdout.write(`${privatePrompt}\n`); + } else if (tool) { + child.stdout.write(`${JSON.stringify({ + item: { command: `echo ${privatePrompt}`, id: 'tool-1', type: 'command_execution' }, + type: 'item.started', + })}\n`); + } else { + child.stdout.write(`${JSON.stringify({ + item: { + id: 'message-1', + model: 'gpt-5.6-luna', + text: JSON.stringify({ category: 'conversation', schemaVersion: 1 }), + type: 'agent_message', + }, + type: 'item.completed', + })}\n`); + child.stdout.write(`${JSON.stringify({ + model: 'gpt-5.6-luna', + type: 'turn.completed', + usage: { input_tokens: 25, output_tokens: 8 }, + })}\n`); + } + child.stderr.write(`provider diagnostic ${privatePrompt}`); + child.stdout.end(); + child.stderr.end(); + child.emit('close', malformed || tool ? 1 : 0, null); + }); + }); + return child; + }; +} + +describe('private Agent Host automation profile', () => { + test('accepts private prompts only from bounded stdin', async () => { + await assert.rejects( + resolveAgentPrompt({ + 'private-automation': true, + prompt: privatePrompt, + }), + /private automation prompt must be supplied through stdin/u, + ); + await assert.rejects( + resolveAgentPrompt({ + 'private-automation': true, + 'prompt-file': 'email.txt', + }), + /private automation prompt must be supplied through stdin/u, + ); + + const stdin = Readable.from([privatePrompt]); + stdin.isTTY = false; + const prompt = await resolveAgentPrompt( + { 'private-automation': true }, + { stdin }, + ); + assert.equal(prompt, privatePrompt); + + const redirectedFileLikeStdin = Readable.from([privatePrompt]); + assert.equal( + await resolveAgentPrompt( + { 'private-automation': true }, + { stdin: redirectedFileLikeStdin }, + ), + privatePrompt, + ); + }); + + test('builds exact zero-tool Codex and Claude plans without prompt argv', () => { + const { artifactsRoot, originDirectory, outputSchemaPath } = fixture(); + const workspace = resolveAgentWorkspace({ + artifactsRoot, + launchId: 'launch_private_profile_test', + mode: 'read-only', + originDirectory, + privateAutomation: true, + }); + assert.deepEqual(fs.readdirSync(workspace.executionWorkspace), []); + assert.notEqual(workspace.executionWorkspace, fs.realpathSync(originDirectory)); + assert.equal(fs.statSync(workspace.executionWorkspace).mode & 0o222, 0); + + const codexProfile = createPrivateAutomationProfile({ + model: 'gpt-5.6-luna', + outputSchemaPath, + provider: 'codex', + timeoutMs: 160_000, + }); + const codex = buildCodexPlan(providerOptions( + 'codex', + 'gpt-5.6-luna', + codexProfile, + workspace, + )); + assert.equal(codex.stdin, privatePrompt); + assert.equal(codex.args.includes(privatePrompt), false); + const codexExecIndex = codex.args.indexOf('exec'); + assert.notEqual(codexExecIndex, -1); + assert.equal(codex.args[codexExecIndex + 1], '-'); + assert.equal(codex.args.includes('--ephemeral'), true); + assert.equal(codex.args.includes('--ignore-user-config'), true); + assert.equal(codex.args.includes('--ignore-rules'), true); + assert.equal(codex.args.includes('--output-schema'), true); + assert.equal(codex.args.includes('gpt-5.6-luna'), true); + assert.equal(codex.args.includes('--search'), false); + assert.equal(codex.args.includes('web_search="disabled"'), true); + assert.equal(codex.args.includes('tools.view_image=false'), true); + for (const feature of [ + 'apps', + 'browser_use', + 'browser_use_external', + 'browser_use_full_cdp_access', + 'code_mode_host', + 'computer_use', + 'enable_mcp_apps', + 'image_generation', + 'in_app_browser', + 'multi_agent', + 'plugins', + 'remote_plugin', + 'shell_snapshot', + 'shell_tool', + 'skill_search', + 'tool_call_mcp_elicitation', + 'tool_suggest', + 'unified_exec', + ]) { + assert.deepEqual( + codex.args.some((arg, index) => ( + arg === '--disable' && codex.args[index + 1] === feature + )), + true, + `Codex private automation must disable ${feature}`, + ); + } + + const claudeProfile = createPrivateAutomationProfile({ + model: 'claude-sonnet-5', + outputSchemaPath, + provider: 'claude', + timeoutMs: 160_000, + }); + const claude = buildClaudePlan(providerOptions( + 'claude', + 'claude-sonnet-5', + claudeProfile, + workspace, + )); + assert.equal(claude.stdin, privatePrompt); + assert.equal(claude.args.includes(privatePrompt), false); + assert.equal(claude.args.includes('--no-session-persistence'), true); + assert.equal(claude.args.includes('--safe-mode'), true); + assert.equal(claude.args.includes('--no-chrome'), true); + assert.equal(claude.args.includes('--disable-slash-commands'), true); + assert.equal(claude.args.includes('--strict-mcp-config'), true); + assert.equal(claude.args.includes('--json-schema'), true); + assert.equal(claude.args.includes('--fallback-model'), false); + const toolsIndex = claude.args.indexOf('--tools'); + assert.notEqual(toolsIndex, -1); + assert.equal(claude.args[toolsIndex + 1], ''); + + for (const plan of [codex, claude]) { + assert.equal(plan.maxFinalOutputBytes, PRIVATE_AUTOMATION_MAX_FINAL_OUTPUT_BYTES); + assert.equal(plan.maxRawOutputBytes, PRIVATE_AUTOMATION_MAX_RAW_OUTPUT_BYTES); + assert.equal(plan.privateAutomationProfile.id, 'private-automation-v1'); + assert.equal(plan.privateAutomationProfile.model, plan.model); + assert.equal(plan.privateAutomationProfile.timeoutMs, 160_000); + } + }); + + test('rejects defaults, fallback inputs, aliases, and external schema references', () => { + const { outputSchemaPath } = fixture(); + assert.throws( + () => createPrivateAutomationProfile({ + model: undefined, + outputSchemaPath, + provider: 'codex', + timeoutMs: 160_000, + }), + /exact model is required/u, + ); + assert.throws( + () => createPrivateAutomationProfile({ + fallbackModel: 'claude-opus-5', + model: 'claude-sonnet-5', + outputSchemaPath, + provider: 'claude', + timeoutMs: 160_000, + }), + /fallback model is forbidden/u, + ); + assert.throws( + () => createPrivateAutomationProfile({ + model: 'sol', + outputSchemaPath, + provider: 'codex', + timeoutMs: 160_000, + }), + /canonical configured model ID/u, + ); + + const externalSchemaPath = path.join(path.dirname(outputSchemaPath), 'external.schema.json'); + fs.writeFileSync(externalSchemaPath, JSON.stringify({ + additionalProperties: false, + properties: { value: { $ref: 'other.schema.json' } }, + required: ['value'], + type: 'object', + })); + assert.throws( + () => createPrivateAutomationProfile({ + model: 'gpt-5.6-luna', + outputSchemaPath: externalSchemaPath, + provider: 'codex', + timeoutMs: 160_000, + }), + /external schema references are forbidden/u, + ); + }); + + test('projects metadata without model content, prompt, or session identity', () => { + const metadata = projectPrivateAutomationEventMetadata({ + content: [{ text: privatePrompt, type: 'text' }], + model: 'gpt-5.6-luna', + providerSessionId: 'thread_private_123', + type: 'assistant', + usage: { inputTokens: 25, outputTokens: 8 }, + }); + assert.deepEqual(metadata, { + contentBlockCount: 1, + model: 'gpt-5.6-luna', + type: 'assistant', + usage: { inputTokens: 25, outputTokens: 8 }, + }); + const serialized = JSON.stringify(metadata); + assert.equal(serialized.includes(privatePrompt), false); + assert.equal(serialized.includes('thread_private_123'), false); + + assert.throws( + () => projectPrivateAutomationEventMetadata({ + content: [{ id: 'tool-1', input: { query: privatePrompt }, name: 'Bash', type: 'tool_use' }], + type: 'assistant', + }), + /tool event is forbidden/u, + ); + }); + + test('rejects Claude permission, tool, and unknown assistant blocks', () => { + for (const event of [ + { type: 'system', subtype: 'permission_request' }, + { type: 'assistant', message: { content: [{ type: 'server_tool_use' }] } }, + { type: 'assistant', message: { content: [{ type: 'future_block' }] } }, + { type: 'system', subtype: 'init', tools: ['Bash'] }, + ]) { + assert.throws( + () => assertPrivateAutomationRawEvent('claude', event), + /not allowlisted|not empty/u, + ); + } + assert.doesNotThrow(() => assertPrivateAutomationRawEvent('claude', { + type: 'assistant', + message: { + content: [{ text: '{"ok":true}', type: 'text' }], + model: 'claude-sonnet-5', + }, + })); + }); + + test('capability-gates exact provider controls before prompt delivery', () => { + const { outputSchemaPath } = fixture(); + const codexProfile = createPrivateAutomationProfile({ + model: 'gpt-5.6-luna', + outputSchemaPath, + provider: 'codex', + timeoutMs: 160_000, + }); + const codexHelp = [ + '--ephemeral', + '--ignore-rules', + '--ignore-user-config', + '--output-schema', + '--sandbox', + ].join('\n'); + const featureList = [ + 'apps', + 'browser_use', + 'browser_use_external', + 'browser_use_full_cdp_access', + 'code_mode_host', + 'computer_use', + 'enable_mcp_apps', + 'image_generation', + 'in_app_browser', + 'multi_agent', + 'plugins', + 'remote_plugin', + 'shell_snapshot', + 'shell_tool', + 'skill_search', + 'tool_call_mcp_elicitation', + 'tool_suggest', + 'unified_exec', + ].map(feature => `${feature} stable true`).join('\n'); + const calls = []; + assert.equal(assertPrivateAutomationHostCapabilities({ + binaryPath: '/fake/codex', + profile: codexProfile, + }, { + spawnSyncImpl(command, args) { + calls.push({ args, command }); + if (calls.length === 1) return { status: 0, stdout: 'codex-cli 0.146.0' }; + if (calls.length === 2) return { status: 0, stdout: codexHelp }; + return { status: 0, stdout: featureList }; + }, + }), true); + assert.equal(calls[1].args.includes('tools.view_image=false'), true); + assert.equal(calls[1].args.includes('web_search="disabled"'), true); + + assert.throws( + () => assertPrivateAutomationHostCapabilities({ + binaryPath: '/fake/codex', + profile: codexProfile, + }, { + spawnSyncImpl: () => ({ status: 0, stdout: 'codex-cli 0.145.0' }), + }), + /version does not satisfy private automation config/u, + ); + + const claudeProfile = createPrivateAutomationProfile({ + model: 'claude-sonnet-5', + outputSchemaPath, + provider: 'claude', + timeoutMs: 160_000, + }); + const claudeHelp = [ + '--disable-slash-commands', + '--input-format', + '--json-schema', + '--mcp-config', + '--no-chrome', + '--no-session-persistence', + '--safe-mode', + '--setting-sources', + '--strict-mcp-config', + '--tools', + ].join('\n'); + assert.equal(assertPrivateAutomationHostCapabilities({ + binaryPath: '/fake/claude', + profile: claudeProfile, + }, { + spawnSyncImpl: () => ({ status: 0, stdout: claudeHelp }), + }), true); + }); + + test('isolates the integrated spawn, transient result, database, and launch artifacts', async () => { + const { artifactsRoot, originDirectory, outputSchemaPath, root } = fixture(); + const profile = createPrivateAutomationProfile({ + model: 'gpt-5.6-luna', + outputSchemaPath, + provider: 'codex', + timeoutMs: 160_000, + }); + const store = createLaunchStore({ databasePath: path.join(root, 'agent-hosts.db') }); + const stdout = memorySink(); + const stderr = memorySink(); + const calls = []; + const previousSecret = process.env.UNRELATED_PRIVATE_AUTOMATION_SECRET; + process.env.UNRELATED_PRIVATE_AUTOMATION_SECRET = privatePrompt; + try { + const launch = await launchAgent({ + json: true, + model: profile.model, + originDirectory, + permissionMode: 'readonly', + privateAutomationProfile: profile, + prompt: privatePrompt, + provider: profile.provider, + timeoutMs: profile.timeoutMs, + workspaceMode: 'read-only', + }, { + artifactsRoot, + idFactory: () => 'launch_private_integrated', + preflightImpl: async () => ({ authenticated: true, installed: true }), + privatePreflightImpl: async () => true, + resolveBinaryImpl: () => '/fake/codex', + spawnImpl: privateCodexSpawn(calls), + stderr: stderr.sink, + stdout: stdout.sink, + store, + }); + + assert.equal(launch.status, 'completed'); + assert.equal(launch.nativeSessionId, null); + assert.equal(launch.lastError, null); + assert.equal(calls[0].stdin(), privatePrompt); + assert.equal(calls[0].args.includes(privatePrompt), false); + assert.equal(JSON.stringify(calls[0].options.env).includes(privatePrompt), false); + assert.equal(Object.hasOwn(calls[0].options.env, 'UNRELATED_PRIVATE_AUTOMATION_SECRET'), false); + assert.equal(calls[0].options.detached, true); + assert.equal(calls[0].options.stdio[0], 'pipe'); + assert.deepEqual(JSON.parse(stdout.value()), { + model: 'gpt-5.6-luna', + output: { category: 'conversation', schemaVersion: 1 }, + provider: 'codex', + type: 'private-automation.result', + usage: { inputTokens: 25, outputTokens: 8 }, + }); + assert.equal(stderr.value(), ''); + const persisted = fs.readFileSync( + getLaunchArtifactFiles(path.join(artifactsRoot, 'launch_private_integrated')).events, + 'utf8', + ); + assert.equal(persisted.includes(privatePrompt), false); + assert.equal(persisted.includes('conversation'), false); + assert.equal(persisted.includes('private-session-id'), false); + assert.equal(JSON.stringify(store.get('launch_private_integrated')).includes(privatePrompt), false); + } finally { + if (previousSecret == null) delete process.env.UNRELATED_PRIVATE_AUTOMATION_SECRET; + else process.env.UNRELATED_PRIVATE_AUTOMATION_SECRET = previousSecret; + store.close(); + } + }); + + for (const [label, spawnOptions, expectedError] of [ + ['malformed provider output', { malformed: true }, 'private_output_malformed'], + ['provider tool execution', { tool: true }, 'private_tool_event'], + ]) { + test(`fails closed on ${label} without persisting private content`, async () => { + const { artifactsRoot, originDirectory, outputSchemaPath, root } = fixture(); + const profile = createPrivateAutomationProfile({ + model: 'gpt-5.6-luna', + outputSchemaPath, + provider: 'codex', + timeoutMs: 160_000, + }); + const store = createLaunchStore({ databasePath: path.join(root, 'agent-hosts.db') }); + try { + const launch = await launchAgent({ + model: profile.model, + originDirectory, + permissionMode: 'readonly', + privateAutomationProfile: profile, + prompt: privatePrompt, + provider: profile.provider, + timeoutMs: profile.timeoutMs, + workspaceMode: 'read-only', + }, { + artifactsRoot, + idFactory: () => `launch_private_${expectedError}`, + preflightImpl: async () => ({ authenticated: true, installed: true }), + privatePreflightImpl: async () => true, + resolveBinaryImpl: () => '/fake/codex', + spawnImpl: privateCodexSpawn([], spawnOptions), + stderr: memorySink().sink, + stdout: memorySink().sink, + store, + }); + assert.equal(launch.status, 'failed'); + assert.equal(launch.lastError, `Private automation failed: ${expectedError}`); + const persisted = fs.readFileSync( + getLaunchArtifactFiles(path.join(artifactsRoot, `launch_private_${expectedError}`)).events, + 'utf8', + ); + assert.equal(persisted.includes(privatePrompt), false); + } finally { + store.close(); + } + }); + } + + test('rejects every private detached, resumed, grouped, workspace, and passthrough surface', async () => { + const { outputSchemaPath, root } = fixture(); + const baseFlags = { + model: 'gpt-5.6-luna', + 'output-schema': outputSchemaPath, + 'private-automation': true, + 'timeout-ms': 160_000, + }; + assert.throws( + () => buildLaunchOptions('codex', privatePrompt, { ...baseFlags, detach: true }, [], root), + /forbids detached execution/u, + ); + assert.throws( + () => buildLaunchOptions('codex', privatePrompt, { ...baseFlags, workspace: '.' }, [], root), + /forbids --workspace/u, + ); + assert.throws( + () => buildLaunchOptions('codex', privatePrompt, baseFlags, ['--search'], root), + /forbids provider passthrough/u, + ); + await assert.rejects( + () => cmdAgent(['resume', 'launch_prior'], baseFlags, [], {}), + /supports only rudi agent launch/u, + ); + await assert.rejects( + () => cmdAgent(['group', 'launch'], baseFlags, [], {}), + /supports only rudi agent launch/u, + ); + }); + + for (const scenario of [ + { + expected: 'private_model_unobserved', + label: 'missing provider-observed model identity', + write(child) { + child.stdout.write(`${JSON.stringify({ + item: { + id: 'message-1', + text: JSON.stringify({ category: 'conversation', schemaVersion: 1 }), + type: 'agent_message', + }, + type: 'item.completed', + })}\n`); + }, + }, + { + expected: 'private_model_mismatch', + label: 'provider model mismatch', + write(child) { + child.stdout.write(`${JSON.stringify({ + item: { + id: 'message-1', + model: 'gpt-5.6-sol', + text: JSON.stringify({ category: 'conversation', schemaVersion: 1 }), + type: 'agent_message', + }, + type: 'item.completed', + })}\n`); + }, + }, + { + expected: 'private_raw_output_overflow', + label: 'aggregate raw output overflow', + write(child) { + child.stdout.write(Buffer.alloc(PRIVATE_AUTOMATION_MAX_RAW_OUTPUT_BYTES + 1, 0x78)); + }, + }, + { + expected: 'private_final_output_overflow', + label: 'final structured output overflow', + write(child) { + child.stdout.write(`${JSON.stringify({ + item: { + id: 'message-1', + model: 'gpt-5.6-luna', + text: JSON.stringify({ value: 'x'.repeat(PRIVATE_AUTOMATION_MAX_FINAL_OUTPUT_BYTES) }), + type: 'agent_message', + }, + type: 'item.completed', + })}\n`); + }, + }, + ]) { + test(`fails closed on ${scenario.label}`, async () => { + const { artifactsRoot, originDirectory, outputSchemaPath, root } = fixture(); + const profile = createPrivateAutomationProfile({ + model: 'gpt-5.6-luna', + outputSchemaPath, + provider: 'codex', + timeoutMs: 160_000, + }); + const store = createLaunchStore({ databasePath: path.join(root, 'agent-hosts.db') }); + const spawnImpl = () => { + const child = new EventEmitter(); + child.pid = 9911; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => true; + child.stdin.once('finish', () => queueMicrotask(() => { + child.emit('spawn'); + scenario.write(child); + child.stdout.end(); + child.stderr.end(); + child.emit('close', [ + 'private_final_output_overflow', + 'private_model_unobserved', + ].includes(scenario.expected) ? 0 : 1, null); + })); + return child; + }; + try { + const launch = await launchAgent({ + model: profile.model, + originDirectory, + permissionMode: 'readonly', + privateAutomationProfile: profile, + prompt: privatePrompt, + provider: profile.provider, + timeoutMs: profile.timeoutMs, + workspaceMode: 'read-only', + }, { + artifactsRoot, + idFactory: () => `launch_${scenario.expected}`, + preflightImpl: async () => ({ authenticated: true, installed: true }), + privatePreflightImpl: async () => true, + resolveBinaryImpl: () => '/fake/codex', + spawnImpl, + stderr: memorySink().sink, + stdout: memorySink().sink, + store, + }); + assert.equal(launch.status, 'failed'); + assert.equal(launch.lastError, `Private automation failed: ${scenario.expected}`); + } finally { + store.close(); + } + }); + } + + test('fails closed when Claude omits provider-observed model identity', async () => { + const { artifactsRoot, originDirectory, outputSchemaPath, root } = fixture(); + const profile = createPrivateAutomationProfile({ + model: 'claude-sonnet-5', + outputSchemaPath, + provider: 'claude', + timeoutMs: 160_000, + }); + const store = createLaunchStore({ databasePath: path.join(root, 'agent-hosts.db') }); + const spawnImpl = () => { + const child = new EventEmitter(); + child.pid = 9921; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => true; + child.stdin.once('finish', () => queueMicrotask(() => { + child.emit('spawn'); + child.stdout.write(`${JSON.stringify({ + message: { + content: [{ + text: JSON.stringify({ category: 'conversation', schemaVersion: 1 }), + type: 'text', + }], + }, + type: 'assistant', + })}\n`); + child.stdout.end(); + child.stderr.end(); + child.emit('close', 0, null); + })); + return child; + }; + try { + const launch = await launchAgent({ + model: profile.model, + originDirectory, + permissionMode: 'plan', + privateAutomationProfile: profile, + prompt: privatePrompt, + provider: profile.provider, + timeoutMs: profile.timeoutMs, + workspaceMode: 'read-only', + }, { + artifactsRoot, + idFactory: () => 'launch_claude_model_unobserved', + preflightImpl: async () => ({ authenticated: true, installed: true }), + privatePreflightImpl: async () => true, + resolveBinaryImpl: () => '/fake/claude', + spawnImpl, + stderr: memorySink().sink, + stdout: memorySink().sink, + store, + }); + assert.equal(launch.status, 'failed'); + assert.equal( + launch.lastError, + 'Private automation failed: private_model_unobserved', + ); + } finally { + store.close(); + } + }); + + test('terminates at the private timeout with a stable metadata-only error', async () => { + const { artifactsRoot, originDirectory, outputSchemaPath, root } = fixture(); + const profile = createPrivateAutomationProfile({ + model: 'gpt-5.6-luna', + outputSchemaPath, + provider: 'codex', + timeoutMs: 5, + }); + const store = createLaunchStore({ databasePath: path.join(root, 'agent-hosts.db') }); + const spawnImpl = () => { + const child = new EventEmitter(); + child.pid = 8811; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + let closed = false; + child.kill = () => { + if (!closed) { + closed = true; + queueMicrotask(() => child.emit('close', null, 'SIGTERM')); + } + return true; + }; + child.stdin.once('finish', () => queueMicrotask(() => child.emit('spawn'))); + return child; + }; + try { + const launch = await launchAgent({ + model: profile.model, + originDirectory, + permissionMode: 'readonly', + privateAutomationProfile: profile, + prompt: privatePrompt, + provider: profile.provider, + timeoutMs: profile.timeoutMs, + workspaceMode: 'read-only', + }, { + artifactsRoot, + idFactory: () => 'launch_private_timeout', + preflightImpl: async () => ({ authenticated: true, installed: true }), + privatePreflightImpl: async () => true, + resolveBinaryImpl: () => '/fake/codex', + spawnImpl, + stderr: memorySink().sink, + stdout: memorySink().sink, + store, + }); + assert.equal(launch.status, 'failed'); + assert.equal(launch.lastError, 'Private automation failed: private_timeout'); + } finally { + store.close(); + } + }); +}); diff --git a/src/agent-host/cli-inputs.js b/src/agent-host/cli-inputs.js index f688741..e3c8422 100644 --- a/src/agent-host/cli-inputs.js +++ b/src/agent-host/cli-inputs.js @@ -1,6 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; +import { + createPrivateAutomationProfile, + PRIVATE_AUTOMATION_MAX_PROMPT_BYTES, +} from './private-automation-profile.js'; import { resolveAgentProviderId } from './providers/index.js'; export const MAX_PROMPT_BYTES = 10 * 1024 * 1024; @@ -16,14 +20,14 @@ function requiredFlagString(value, name) { return value; } -async function readPromptStream(stdin) { +async function readPromptStream(stdin, maxBytes = MAX_PROMPT_BYTES) { let value = ''; let size = 0; for await (const chunk of stdin) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); size += buffer.length; - if (size > MAX_PROMPT_BYTES) { - throw new Error(`stdin prompt exceeds ${MAX_PROMPT_BYTES} bytes`); + if (size > maxBytes) { + throw new Error(`stdin prompt exceeds ${maxBytes} bytes`); } value += buffer.toString('utf8'); } @@ -36,6 +40,10 @@ export async function resolveAgentPrompt(flags, { } = {}) { const inline = flags.prompt; const promptFile = flagValue(flags, 'prompt-file', 'promptFile'); + const privateAutomation = flagValue(flags, 'private-automation', 'privateAutomation') === true; + if (privateAutomation && (inline != null || promptFile != null)) { + throw new Error('private automation prompt must be supplied through stdin'); + } if (inline != null && promptFile != null) { throw new Error('Use exactly one of --prompt or --prompt-file'); } @@ -55,16 +63,20 @@ export async function resolveAgentPrompt(flags, { if (!stat.isFile()) throw new Error(`Prompt file is not a regular file: ${filePath}`); if (stat.size > MAX_PROMPT_BYTES) throw new Error(`Prompt file exceeds ${MAX_PROMPT_BYTES} bytes`); prompt = fs.readFileSync(filePath, 'utf8'); - } else if (stdin && stdin.isTTY === false) { - prompt = await readPromptStream(stdin); + } else if (stdin && stdin.isTTY !== true) { + prompt = await readPromptStream( + stdin, + privateAutomation ? PRIVATE_AUTOMATION_MAX_PROMPT_BYTES : MAX_PROMPT_BYTES, + ); } else { throw new Error('Prompt required via --prompt, --prompt-file, or stdin'); } if (!prompt.trim()) throw new Error('Prompt must not be empty'); if (prompt.includes('\0')) throw new Error('Prompt must not contain NUL bytes'); - if (Buffer.byteLength(prompt, 'utf8') > MAX_PROMPT_BYTES) { - throw new Error(`Prompt exceeds ${MAX_PROMPT_BYTES} bytes`); + const maxPromptBytes = privateAutomation ? PRIVATE_AUTOMATION_MAX_PROMPT_BYTES : MAX_PROMPT_BYTES; + if (Buffer.byteLength(prompt, 'utf8') > maxPromptBytes) { + throw new Error(`Prompt exceeds ${maxPromptBytes} bytes`); } return prompt; } @@ -111,6 +123,55 @@ export function parseTimeout(flags) { } export function buildLaunchOptions(provider, prompt, flags, passthrough, originDirectory) { + const privateAutomation = flagValue(flags, 'private-automation', 'privateAutomation') === true; + if (privateAutomation) { + const forbiddenFlags = [ + ['approval-mode', 'approvalMode'], + ['image', 'images'], + ['mode'], + ['output-dir', 'outputDirectory'], + ['permission-mode', 'permissionMode'], + ['read-only', 'readOnly'], + ['workspace'], + ['workspace-mode', 'workspaceMode'], + ]; + for (const names of forbiddenFlags) { + if (names.some(name => flags[name] != null)) { + throw new Error(`private automation forbids --${names[0]}`); + } + } + if (flags.detach === true) throw new Error('private automation forbids detached execution'); + if (passthrough.length > 0) throw new Error('private automation forbids provider passthrough arguments'); + const canonicalProvider = resolveAgentProviderId(provider); + const outputSchemaValue = requiredFlagString( + flagValue(flags, 'output-schema', 'outputSchema'), + '--output-schema', + ); + const outputSchemaPath = path.resolve(originDirectory, outputSchemaValue); + const timeoutMs = parseTimeout(flags); + const privateAutomationProfile = createPrivateAutomationProfile({ + model: flags.model, + outputSchemaPath, + provider: canonicalProvider, + timeoutMs, + }); + return { + approvalMode: null, + extraArgs: [], + images: [], + json: flags.json === true, + model: privateAutomationProfile.model, + originDirectory, + outputDirectory: null, + permissionMode: canonicalProvider === 'codex' ? 'readonly' : 'plan', + privateAutomationProfile, + prompt, + provider: canonicalProvider, + timeoutMs: privateAutomationProfile.timeoutMs, + workspace: null, + workspaceMode: 'read-only', + }; + } return { approvalMode: flagValue(flags, 'approval-mode', 'approvalMode'), extraArgs: passthrough, diff --git a/src/agent-host/events/stream.js b/src/agent-host/events/stream.js index 7819e79..d5b08a3 100644 --- a/src/agent-host/events/stream.js +++ b/src/agent-host/events/stream.js @@ -5,6 +5,10 @@ import { extractNativeSessionId, renderAgentEvent, } from './normalize.js'; +import { + assertPrivateAutomationRawEvent, + projectPrivateAutomationEventMetadata, +} from '../private-automation-profile.js'; function boundedAppend(current, value, maxLength = 4096) { const combined = `${current}${value}`; @@ -33,6 +37,7 @@ export function executeForegroundLaunch({ } return new Promise((resolve, reject) => { + const privateAutomation = plan.privateAutomationProfile != null; const normalizer = createAgentEventNormalizer(plan.provider); let child; let finalized = false; @@ -43,12 +48,43 @@ export function executeForegroundLaunch({ let forceTimer = null; let requestedSignal = null; let sinkFailure = null; + let privateFailure = null; + let privateFinalOutput = null; + let privateObservedModel = null; + let privateRawOutputBytes = 0; + let privateUsage = null; + + function terminateProvider(signal) { + if (privateAutomation && Number.isSafeInteger(child?.pid) && child.pid > 0) { + try { + process.kill(-child.pid, signal); + return true; + } catch {} + } + try { + return child?.kill(signal) === true; + } catch { + return false; + } + } + + function privateProviderGroupAlive() { + if (!privateAutomation || !Number.isSafeInteger(child?.pid) || child.pid < 1) { + return false; + } + try { + process.kill(-child.pid, 0); + return true; + } catch { + return false; + } + } function recordSinkFailure(kind, error) { if (sinkFailure) return; sinkFailure = `${kind} persistence failed: ${error.message}`; try { writeLine(stderr, sinkFailure); } catch {} - try { child?.kill('SIGTERM'); } catch {} + terminateProvider('SIGTERM'); } function publishEvent(payload, persistedPayload = payload) { @@ -62,14 +98,15 @@ export function executeForegroundLaunch({ const onSigint = () => { requestedSignal = 'SIGINT'; - child?.kill('SIGINT'); + terminateProvider('SIGINT'); }; const onSigterm = () => { requestedSignal = 'SIGTERM'; - child?.kill('SIGTERM'); + terminateProvider('SIGTERM'); }; function persistNativeSession(rawEvent, normalized) { + if (privateAutomation) return; const nativeSessionId = extractNativeSessionId(rawEvent) || normalized?.providerSessionId || null; @@ -87,20 +124,55 @@ export function executeForegroundLaunch({ ) || ( rawEvent?.event === 'step_update' && rawEvent.step_update?.step_type === 'agent_response' ); + let persistedEvent = normalized; + if (privateAutomation) { + try { + assertPrivateAutomationRawEvent(plan.provider, rawEvent); + persistedEvent = projectPrivateAutomationEventMetadata(normalized); + } catch { + privateFailure = 'private_tool_event'; + terminateProvider('SIGTERM'); + return; + } + if (normalized.model) { + if (normalized.model !== plan.model) { + privateFailure = 'private_model_mismatch'; + terminateProvider('SIGTERM'); + return; + } + privateObservedModel = normalized.model; + } + if (normalized.usage) privateUsage = persistedEvent.usage || privateUsage; + const structuredOutput = rawEvent?.structured_output ?? rawEvent?.structuredOutput; + if (structuredOutput && typeof structuredOutput === 'object' && !Array.isArray(structuredOutput)) { + privateFinalOutput = structuredOutput; + } else if (normalized.type === 'assistant' && Array.isArray(normalized.content)) { + const text = normalized.content + .filter(block => block?.type === 'text' && typeof block.text === 'string') + .map(block => block.text) + .join(''); + if (text) privateFinalOutput = text; + } else if (normalized.type === 'result' && typeof normalized.result === 'string') { + privateFinalOutput = normalized.result; + } + } const persistedPayload = { delta: isDelta, - event: normalized, + event: persistedEvent, launchId, provider: plan.provider, type: 'agent.event', }; - const payload = publishEvent({ - event: normalized, - launchId, - provider: plan.provider, - rawEvent, - type: 'agent.event', - }, persistedPayload); + const payload = privateAutomation + ? publishEvent(persistedPayload) + : publishEvent({ + event: normalized, + launchId, + provider: plan.provider, + rawEvent, + type: 'agent.event', + }, persistedPayload); + if (privateAutomation) return; if (jsonOutput) { writeLine(stdout, JSON.stringify(payload)); return; @@ -124,6 +196,11 @@ export function executeForegroundLaunch({ if (result?.normalized) emitEvent(result.normalized, result.raw || rawEvent); } } catch { + if (privateAutomation) { + privateFailure = 'private_output_malformed'; + terminateProvider('SIGTERM'); + return; + } const payload = publishEvent({ event: { message: line, subtype: 'provider_stdout', type: 'system' }, launchId, @@ -148,18 +225,51 @@ export function executeForegroundLaunch({ function complete(status, exitCode, lastError = null) { if (finalized) return; - finalized = true; clearTimeout(runtimeTimer); if (forceTimer) clearTimeout(forceTimer); signalEmitter.removeListener('SIGINT', onSigint); signalEmitter.removeListener('SIGTERM', onSigterm); flushStdout(); + finalized = true; if (sinkFailure) { status = 'failed'; lastError = sinkFailure; } + if (privateAutomation) { + if (privateFailure) { + status = 'failed'; + lastError = `Private automation failed: ${privateFailure}`; + } else if (status === 'completed' && privateObservedModel === null) { + status = 'failed'; + lastError = 'Private automation failed: private_model_unobserved'; + } else if (status === 'completed') { + try { + const parsed = typeof privateFinalOutput === 'string' + ? JSON.parse(privateFinalOutput) + : privateFinalOutput; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('not_object'); + } + const serialized = JSON.stringify(parsed); + if (Buffer.byteLength(serialized, 'utf8') > plan.maxFinalOutputBytes) { + throw new Error('too_large'); + } + privateFinalOutput = parsed; + } catch (error) { + status = 'failed'; + lastError = `Private automation failed: ${error.message === 'too_large' ? 'private_final_output_overflow' : 'private_final_output_invalid'}`; + } + } else { + lastError = timedOut + ? 'Private automation failed: private_timeout' + : requestedSignal + ? 'Private automation failed: private_stopped' + : 'Private automation failed: private_provider_error'; + } + } + const current = store.get(launchId); if (current?.status === 'starting' && status !== 'failed') { store.transition(launchId, 'running', { pid: child?.pid || 0 }); @@ -169,23 +279,37 @@ export function executeForegroundLaunch({ lastError, }); const terminalEvent = publishEvent({ launch: updated, type: `launch.${status}` }); + if (privateAutomation && status === 'completed') { + const privateResult = { + model: privateObservedModel, + output: privateFinalOutput, + provider: plan.provider, + type: 'private-automation.result', + ...(privateUsage ? { usage: privateUsage } : {}), + }; + writeLine(stdout, jsonOutput ? JSON.stringify(privateResult) : JSON.stringify(privateFinalOutput)); + } if (jsonOutput) { - writeLine(stdout, JSON.stringify(terminalEvent)); + if (!privateAutomation) writeLine(stdout, JSON.stringify(terminalEvent)); } resolve(updated); } const runtimeTimer = setTimeout(() => { timedOut = true; - child?.kill('SIGTERM'); - forceTimer = setTimeout(() => child?.kill('SIGKILL'), plan.timeouts.shutdownGraceMs || 5000); + terminateProvider('SIGTERM'); + forceTimer = setTimeout( + () => terminateProvider('SIGKILL'), + plan.timeouts.shutdownGraceMs || 5000, + ); }, timeoutMs); try { child = spawnImpl(plan.spawn.command, plan.args, { cwd: plan.spawn.cwd, - env: { ...process.env, ...plan.environment }, - stdio: ['ignore', 'pipe', 'pipe'], + detached: privateAutomation, + env: privateAutomation ? plan.environment : { ...process.env, ...plan.environment }, + stdio: [privateAutomation ? 'pipe' : 'ignore', 'pipe', 'pipe'], }); } catch (error) { clearTimeout(runtimeTimer); @@ -193,6 +317,14 @@ export function executeForegroundLaunch({ return; } + if (privateAutomation) { + child.stdin.on('error', () => { + privateFailure = 'private_stdin_error'; + terminateProvider('SIGTERM'); + }); + child.stdin.end(plan.stdin); + } + child.once('spawn', () => { const current = store.get(launchId); if (current?.status === 'starting') { @@ -206,6 +338,15 @@ export function executeForegroundLaunch({ signalEmitter.once('SIGTERM', onSigterm); child.stdout.on('data', (chunk) => { + if (privateAutomation) { + privateRawOutputBytes += Buffer.byteLength(chunk); + if (privateRawOutputBytes > plan.maxRawOutputBytes) { + privateFailure = 'private_raw_output_overflow'; + stdoutBuffer = ''; + terminateProvider('SIGTERM'); + return; + } + } stdoutBuffer += chunk.toString(); const lines = stdoutBuffer.split('\n'); stdoutBuffer = lines.pop() || ''; @@ -213,6 +354,7 @@ export function executeForegroundLaunch({ }); child.stderr.on('data', (chunk) => { + if (privateAutomation) return; const text = chunk.toString(); stderrTail = boundedAppend(stderrTail, text); try { @@ -223,10 +365,18 @@ export function executeForegroundLaunch({ }); child.once('error', (error) => { - complete('failed', null, `Provider process error: ${error.message}`); + complete( + 'failed', + null, + privateAutomation ? 'Private automation failed: private_spawn_error' : `Provider process error: ${error.message}`, + ); }); child.once('close', (exitCode, signal) => { + if (privateProviderGroupAlive()) { + terminateProvider('SIGKILL'); + privateFailure = 'private_termination_unconfirmed'; + } if (sinkFailure) { complete('failed', exitCode, sinkFailure); return; diff --git a/src/agent-host/launch.js b/src/agent-host/launch.js index b135a81..443d33a 100644 --- a/src/agent-host/launch.js +++ b/src/agent-host/launch.js @@ -17,6 +17,7 @@ import { cleanupUnstartedWorkspace, resolveAgentWorkspace, } from './workspace.js'; +import { assertPrivateAutomationHostCapabilities } from './private-automation-profile.js'; export function createLaunchId() { return `launch_${crypto.randomUUID().replaceAll('-', '')}`; @@ -30,6 +31,7 @@ export async function launchAgent(options, dependencies = {}) { ownerPid = null, onSpawn = null, preflightImpl = assertAgentHostReady, + privatePreflightImpl = assertPrivateAutomationHostCapabilities, resolveBinaryImpl = resolveAgentProviderBinary, spawnImpl, stderr = process.stderr, @@ -39,12 +41,19 @@ export async function launchAgent(options, dependencies = {}) { } = dependencies; const launchId = idFactory(); + const privateAutomationProfile = options?.privateAutomationProfile || null; + if (privateAutomationProfile && options?.executionKind && options.executionKind !== 'foreground') { + throw new Error('private automation supports foreground execution only'); + } const provider = resolveAgentProviderId(options?.provider); const binaryPath = resolveBinaryImpl(provider); if (!binaryPath) { throw new Error(`${provider} host is not installed. Run: rudi install agent:${provider}`); } await preflightImpl({ binaryPath, provider }); + if (privateAutomationProfile) { + await privatePreflightImpl({ binaryPath, profile: privateAutomationProfile }); + } const workspace = workspaceResolver({ artifactsRoot, @@ -52,6 +61,7 @@ export async function launchAgent(options, dependencies = {}) { mode: options.workspaceMode || 'auto', originDirectory: options.originDirectory || process.cwd(), outputDirectory: options.outputDirectory || null, + privateAutomation: privateAutomationProfile != null, workspace: options.workspace || null, }); const resolvedEventSink = eventSink || (event => appendLaunchEvent( @@ -68,6 +78,7 @@ export async function launchAgent(options, dependencies = {}) { images: options.images, model: options.model, permissionMode: options.permissionMode, + privateAutomationProfile, prompt: options.prompt, provider, runtimeDirectory: workspace.outputDestination, diff --git a/src/agent-host/private-automation-profile.js b/src/agent-host/private-automation-profile.js new file mode 100644 index 0000000..b5f3e3b --- /dev/null +++ b/src/agent-host/private-automation-profile.js @@ -0,0 +1,348 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { getModelDef, loadProviderConfig } from './providers/catalog.js'; + +export const PRIVATE_AUTOMATION_PROFILE_ID = 'private-automation-v1'; +export const PRIVATE_AUTOMATION_MAX_PROMPT_BYTES = 200_000; +export const PRIVATE_AUTOMATION_MAX_FINAL_OUTPUT_BYTES = 64 * 1024; +export const PRIVATE_AUTOMATION_MAX_RAW_OUTPUT_BYTES = 2 * 1024 * 1024; +export const PRIVATE_AUTOMATION_MAX_SCHEMA_BYTES = 64 * 1024; +export const PRIVATE_AUTOMATION_MAX_TIMEOUT_MS = 165_000; +export const PRIVATE_AUTOMATION_DEFAULT_TIMEOUT_MS = 160_000; + +const PRIVATE_PROVIDERS = new Set(['claude', 'codex']); +const PRIVATE_RAW_EVENT_TYPES = Object.freeze({ + claude: new Set(['assistant', 'error', 'rate_limit_event', 'result', 'system']), + codex: new Set([ + 'error', + 'item.completed', + 'item.started', + 'item.updated', + 'thread.started', + 'turn.completed', + 'turn.failed', + 'turn.started', + ]), +}); +const PRIVATE_CODEX_ITEM_TYPES = new Set(['agent_message', 'reasoning']); +const PRIVATE_CLAUDE_ASSISTANT_BLOCK_TYPES = new Set(['text', 'thinking']); +const PRIVATE_CLAUDE_SYSTEM_SUBTYPES = new Set(['init']); +const PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ + 'apps', + 'browser_use', + 'browser_use_external', + 'browser_use_full_cdp_access', + 'code_mode_host', + 'computer_use', + 'enable_mcp_apps', + 'image_generation', + 'in_app_browser', + 'multi_agent', + 'plugins', + 'remote_plugin', + 'shell_snapshot', + 'shell_tool', + 'skill_search', + 'tool_call_mcp_elicitation', + 'tool_suggest', + 'unified_exec', +]); + +export function getPrivateCodexDisabledFeatures() { + return [...PRIVATE_CODEX_DISABLED_FEATURES]; +} + +function requiredText(value, field, maxBytes = 4096) { + if (typeof value !== 'string' || value.trim() === '' || value.includes('\0')) { + throw new Error(`${field} must be a non-empty string without NUL bytes`); + } + if (Buffer.byteLength(value, 'utf8') > maxBytes) { + throw new Error(`${field} exceeds ${maxBytes} bytes`); + } + return value; +} + +function containsSchemaReference(value) { + if (Array.isArray(value)) return value.some(containsSchemaReference); + if (!value || typeof value !== 'object') return false; + if (Object.hasOwn(value, '$ref')) return true; + return Object.values(value).some(containsSchemaReference); +} + +function readOutputSchema(outputSchemaPath) { + const requested = path.resolve(requiredText(outputSchemaPath, 'output schema path')); + let stat; + try { + stat = fs.lstatSync(requested); + } catch { + throw new Error(`private automation output schema does not exist: ${requested}`); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error('private automation output schema must be a regular non-symlink file'); + } + if (stat.size < 2 || stat.size > PRIVATE_AUTOMATION_MAX_SCHEMA_BYTES) { + throw new Error(`private automation output schema must be between 2 and ${PRIVATE_AUTOMATION_MAX_SCHEMA_BYTES} bytes`); + } + let schema; + try { + schema = JSON.parse(fs.readFileSync(requested, 'utf8')); + } catch { + throw new Error('private automation output schema must contain valid JSON'); + } + if (!schema || Array.isArray(schema) || schema.type !== 'object') { + throw new Error('private automation output schema must describe an object'); + } + if (schema.additionalProperties !== false) { + throw new Error('private automation output schema must set additionalProperties to false'); + } + if (!schema.properties || typeof schema.properties !== 'object' || Array.isArray(schema.properties)) { + throw new Error('private automation output schema must declare object properties'); + } + if (!Array.isArray(schema.required)) { + throw new Error('private automation output schema must declare required properties'); + } + if (containsSchemaReference(schema)) { + throw new Error('external schema references are forbidden in private automation'); + } + return Object.freeze({ + canonical: JSON.stringify(schema), + path: fs.realpathSync(requested), + schema: Object.freeze(schema), + }); +} + +function exactConfiguredModel(provider, model) { + if (typeof model !== 'string' || model.trim() === '') { + throw new Error('private automation exact model is required'); + } + const exactModel = requiredText(model, 'private automation exact model', 512); + const config = loadProviderConfig(provider); + const definition = getModelDef(config, exactModel); + if (!definition || definition.id !== exactModel) { + throw new Error(`private automation requires a canonical configured model ID for ${provider}`); + } + return exactModel; +} + +function validateTimeout(timeoutMs) { + const value = timeoutMs == null ? PRIVATE_AUTOMATION_DEFAULT_TIMEOUT_MS : Number(timeoutMs); + if (!Number.isSafeInteger(value) || value < 1 || value > PRIVATE_AUTOMATION_MAX_TIMEOUT_MS) { + throw new Error(`private automation timeoutMs must be an integer between 1 and ${PRIVATE_AUTOMATION_MAX_TIMEOUT_MS}`); + } + return value; +} + +export function createPrivateAutomationProfile({ + fallbackModel = null, + model, + outputSchemaPath, + provider, + timeoutMs, +} = {}) { + if (!PRIVATE_PROVIDERS.has(provider)) { + throw new Error('private automation provider must be codex or claude'); + } + if (fallbackModel != null) { + throw new Error('private automation fallback model is forbidden'); + } + const exactModel = exactConfiguredModel(provider, model); + const outputSchema = readOutputSchema(outputSchemaPath); + return Object.freeze({ + id: PRIVATE_AUTOMATION_PROFILE_ID, + maxFinalOutputBytes: PRIVATE_AUTOMATION_MAX_FINAL_OUTPUT_BYTES, + maxPromptBytes: PRIVATE_AUTOMATION_MAX_PROMPT_BYTES, + maxRawOutputBytes: PRIVATE_AUTOMATION_MAX_RAW_OUTPUT_BYTES, + model: exactModel, + outputSchema, + provider, + timeoutMs: validateTimeout(timeoutMs), + }); +} + +function containsToolEvent(value) { + if (Array.isArray(value)) return value.some(containsToolEvent); + if (!value || typeof value !== 'object') return false; + if ([ + 'command_execution', + 'file_change', + 'mcp_tool_call', + 'permission', + 'permission_request', + 'server_tool_use', + 'tool_result', + 'tool_use', + ].includes(value.type)) return true; + return Object.values(value).some(containsToolEvent); +} + +function boundedUsage(usage) { + if (!usage || typeof usage !== 'object' || Array.isArray(usage)) return undefined; + const projected = {}; + for (const [key, raw] of Object.entries(usage)) { + const value = Number(raw); + if (Number.isSafeInteger(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER) { + projected[key] = value; + } + } + return Object.keys(projected).length > 0 ? projected : undefined; +} + +export function projectPrivateAutomationEventMetadata(event) { + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw new Error('private automation event must be an object'); + } + if (containsToolEvent(event)) { + throw new Error('private automation tool event is forbidden'); + } + const metadata = { type: requiredText(event.type, 'private automation event type', 128) }; + if (typeof event.model === 'string' && event.model.length > 0) metadata.model = event.model; + if (Array.isArray(event.content)) metadata.contentBlockCount = event.content.length; + const usage = boundedUsage(event.usage); + if (usage) metadata.usage = usage; + if (typeof event.durationMs === 'number' && Number.isFinite(event.durationMs) && event.durationMs >= 0) { + metadata.durationMs = Math.floor(event.durationMs); + } + if (typeof event.numTurns === 'number' && Number.isSafeInteger(event.numTurns) && event.numTurns >= 0) { + metadata.numTurns = event.numTurns; + } + return Object.freeze(metadata); +} + +export function assertPrivateAutomationRawEvent(provider, event) { + if (!PRIVATE_PROVIDERS.has(provider) || !event || typeof event !== 'object' || Array.isArray(event)) { + throw new Error('private automation provider event is invalid'); + } + if (!PRIVATE_RAW_EVENT_TYPES[provider].has(event.type)) { + throw new Error('private automation provider event type is not allowlisted'); + } + if ( + provider === 'codex' + && event.type.startsWith('item.') + && !PRIVATE_CODEX_ITEM_TYPES.has(event.item?.type) + ) { + throw new Error('private automation Codex item type is not allowlisted'); + } + if (provider === 'claude' && event.type === 'system') { + if (!PRIVATE_CLAUDE_SYSTEM_SUBTYPES.has(event.subtype)) { + throw new Error('private automation Claude system subtype is not allowlisted'); + } + if ( + (Array.isArray(event.tools) && event.tools.length > 0) + || (Array.isArray(event.mcp_servers) && event.mcp_servers.length > 0) + ) { + throw new Error('private automation Claude init capabilities are not empty'); + } + } + if (provider === 'claude' && event.type === 'assistant') { + const message = event.message && typeof event.message === 'object' + ? event.message + : null; + const content = Array.isArray(event.content) + ? event.content + : Array.isArray(message?.content) + ? message.content + : []; + if (content.some(block => ( + !block + || typeof block !== 'object' + || !PRIVATE_CLAUDE_ASSISTANT_BLOCK_TYPES.has(block.type) + ))) { + throw new Error('private automation Claude content block is not allowlisted'); + } + } + if (containsToolEvent(event)) { + throw new Error('private automation tool event is forbidden'); + } + return event; +} + +function successfulProbe(result) { + return result && !result.error && result.status === 0; +} + +function probeOutput(result) { + return `${String(result?.stdout || '')}\n${String(result?.stderr || '')}`; +} + +function semverAtLeast(actual, minimum) { + const actualParts = actual.split('.').map(Number); + const minimumParts = minimum.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + if (actualParts[index] > minimumParts[index]) return true; + if (actualParts[index] < minimumParts[index]) return false; + } + return true; +} + +export function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, dependencies = {}) { + const spawnSyncImpl = dependencies.spawnSyncImpl || spawnSync; + if (!profile || profile.id !== PRIVATE_AUTOMATION_PROFILE_ID) { + throw new Error('private automation profile is required for capability preflight'); + } + if (profile.provider === 'codex') { + const versionProbe = spawnSyncImpl(binaryPath, ['--version'], { + encoding: 'utf8', + timeout: 5000, + }); + const versionMatch = probeOutput(versionProbe).match(/codex-cli\s+(\d+)\.(\d+)\.(\d+)/u); + const minimumVersion = loadProviderConfig('codex').headless.privateAutomation.minimumVersion; + const versionSupported = versionMatch && semverAtLeast( + `${versionMatch[1]}.${versionMatch[2]}.${versionMatch[3]}`, + minimumVersion, + ); + if (!successfulProbe(versionProbe) || !versionSupported) { + throw new Error('Codex host version does not satisfy private automation config controls'); + } + const configProbe = spawnSyncImpl(binaryPath, [ + '--strict-config', + '-c', 'web_search="disabled"', + '-c', 'tools.view_image=false', + 'exec', '--help', + ], { encoding: 'utf8', timeout: 5000 }); + const help = probeOutput(configProbe); + const requiredHelp = [ + '--ephemeral', + '--ignore-rules', + '--ignore-user-config', + '--output-schema', + '--sandbox', + ]; + if (!successfulProbe(configProbe) || requiredHelp.some(flag => !help.includes(flag))) { + throw new Error('Codex host does not satisfy private automation config and CLI capabilities'); + } + const featureProbe = spawnSyncImpl(binaryPath, ['features', 'list'], { + encoding: 'utf8', + timeout: 5000, + }); + const features = probeOutput(featureProbe); + const missingFeature = PRIVATE_CODEX_DISABLED_FEATURES.some((feature) => { + const line = features.split('\n').find(candidate => candidate.trim().startsWith(`${feature} `)); + return !line || /\bremoved\b/u.test(line); + }); + if (!successfulProbe(featureProbe) || missingFeature) { + throw new Error('Codex host does not satisfy private automation feature controls'); + } + return true; + } + + const helpProbe = spawnSyncImpl(binaryPath, ['--help'], { encoding: 'utf8', timeout: 5000 }); + const help = probeOutput(helpProbe); + const requiredHelp = [ + '--disable-slash-commands', + '--input-format', + '--json-schema', + '--mcp-config', + '--no-chrome', + '--no-session-persistence', + '--safe-mode', + '--setting-sources', + '--strict-mcp-config', + '--tools', + ]; + if (!successfulProbe(helpProbe) || requiredHelp.some(flag => !help.includes(flag))) { + throw new Error('Claude host does not satisfy private automation CLI capabilities'); + } + return true; +} diff --git a/src/agent-host/providers/claude.js b/src/agent-host/providers/claude.js index 8860fc5..b5d2436 100644 --- a/src/agent-host/providers/claude.js +++ b/src/agent-host/providers/claude.js @@ -8,6 +8,35 @@ import { export function buildClaudePlan(options) { const context = providerContext(options, 'claude'); + if (context.privateAutomationProfile) { + if ((options.extraArgs || []).length > 0 || (options.images || []).length > 0) { + throw new Error('private automation forbids Claude passthrough arguments and images'); + } + if (options.approvalMode != null) { + throw new Error('private automation forbids Claude approval overrides'); + } + if (options.permissionMode != null && options.permissionMode !== 'plan') { + throw new Error('private automation requires Claude plan permission mode'); + } + const args = [ + '--output-format', 'stream-json', + '--verbose', + '--print', + '--input-format', 'text', + '--model', context.model, + '--json-schema', context.privateAutomationProfile.outputSchema.canonical, + '--no-session-persistence', + '--safe-mode', + '--no-chrome', + '--disable-slash-commands', + '--tools', '', + '--strict-mcp-config', + '--mcp-config', '{"mcpServers":{}}', + '--setting-sources', '', + '--permission-mode', 'plan', + ]; + return finishPlan(context, args, 'plan'); + } const images = validateImages(options.images); if (images.length > 0) { throw new Error('Claude local image attachments are not exposed as a headless CLI flag; reference a readable workspace file in the prompt'); diff --git a/src/agent-host/providers/codex.js b/src/agent-host/providers/codex.js index 81d7cea..6ba9100 100644 --- a/src/agent-host/providers/codex.js +++ b/src/agent-host/providers/codex.js @@ -8,6 +8,7 @@ import { providerContext, validateImages, } from './common.js'; +import { getPrivateCodexDisabledFeatures } from '../private-automation-profile.js'; const APPROVAL_ALIASES = Object.freeze({ onRequest: 'on-request', @@ -27,6 +28,38 @@ function approvalPolicy(value) { export function buildCodexPlan(options) { const context = providerContext(options, 'codex'); + if (context.privateAutomationProfile) { + if ((options.extraArgs || []).length > 0 || (options.images || []).length > 0) { + throw new Error('private automation forbids Codex passthrough arguments and images'); + } + if (options.approvalMode != null && options.approvalMode !== 'never') { + throw new Error('private automation requires Codex approval mode never'); + } + if (options.permissionMode != null && !['readonly', 'read-only'].includes(options.permissionMode)) { + throw new Error('private automation requires Codex read-only sandbox'); + } + const disabledFeatures = getPrivateCodexDisabledFeatures(); + const args = ['--ask-for-approval', 'never']; + for (const feature of disabledFeatures) args.push('--disable', feature); + args.push( + '-c', 'mcp_servers={}', + '-c', 'web_search="disabled"', + '-c', 'tools.view_image=false', + 'exec', '-', + '--json', + '--skip-git-repo-check', + '--color', 'never', + '-C', context.cwd, + '-m', context.model, + '--output-schema', context.privateAutomationProfile.outputSchema.path, + '--ephemeral', + '--strict-config', + '--ignore-user-config', + '--ignore-rules', + '-s', 'read-only', + ); + return finishPlan(context, args, 'readonly'); + } const images = validateImages(options.images); const permission = permissionArgs(context, options.permissionMode); const approval = approvalPolicy(options.approvalMode); diff --git a/src/agent-host/providers/common.js b/src/agent-host/providers/common.js index 7735f30..6eaf069 100644 --- a/src/agent-host/providers/common.js +++ b/src/agent-host/providers/common.js @@ -9,6 +9,7 @@ import { loadProviderConfig, resolveModel, } from './catalog.js'; +import { PRIVATE_AUTOMATION_PROFILE_ID } from '../private-automation-profile.js'; const MAX_PROMPT_BYTES = 10 * 1024 * 1024; @@ -52,7 +53,26 @@ export function validateExtraArgs(value) { export function providerContext(options, provider) { const config = loadProviderConfig(provider); - const prompt = requiredText(options.prompt, 'prompt'); + const privateAutomationProfile = options.privateAutomationProfile || null; + if (privateAutomationProfile != null) { + if (privateAutomationProfile.id !== PRIVATE_AUTOMATION_PROFILE_ID) { + throw new Error('invalid private automation profile'); + } + if (privateAutomationProfile.provider !== provider) { + throw new Error('private automation provider does not match process plan'); + } + if (privateAutomationProfile.model !== options.model) { + throw new Error('private automation model does not match process plan'); + } + if (options.nativeSessionId != null) { + throw new Error('private automation session resume is forbidden'); + } + } + const prompt = requiredText( + options.prompt, + 'prompt', + privateAutomationProfile?.maxPromptBytes || MAX_PROMPT_BYTES, + ); const cwd = requiredText(options.cwd, 'cwd', 4096); const binaryPath = requiredText(options.binaryPath, 'binaryPath', 4096); const requestedModel = options.model || config.models.default; @@ -76,6 +96,7 @@ export function providerContext(options, provider) { ? null : requiredText(options.nativeSessionId, 'nativeSessionId', 1024), prompt, + privateAutomationProfile, provider, runtimeDirectory: options.runtimeDirectory == null ? null @@ -119,6 +140,33 @@ export function buildAgentExecutableEnvironment(binaryPath, overrides = {}, base return merged; } +const PRIVATE_OPERATIONAL_ENVIRONMENT_KEYS = Object.freeze([ + 'HOME', + 'LANG', + 'LC_ALL', + 'LOGNAME', + 'PATH', + 'SSL_CERT_DIR', + 'SSL_CERT_FILE', + 'TMPDIR', + 'USER', +]); + +export function buildPrivateProviderEnvironment(config, binaryPath, options = {}) { + const baseEnvironment = options.baseEnvironment || process.env; + const operational = Object.fromEntries( + PRIVATE_OPERATIONAL_ENVIRONMENT_KEYS + .filter(key => typeof baseEnvironment[key] === 'string' && baseEnvironment[key].length > 0) + .map(key => [key, baseEnvironment[key]]), + ); + const providerEnvironment = buildProviderEnvironment(config, options); + return buildAgentExecutableEnvironment( + binaryPath, + { ...operational, ...providerEnvironment }, + {}, + ); +} + export function buildProviderEnvironment(config, options = {}) { const baseEnvironment = options.baseEnvironment || process.env; const rudiHome = options.rudiHome || process.env.RUDI_HOME || path.join(os.homedir(), '.rudi'); @@ -139,10 +187,23 @@ export function buildProviderEnvironment(config, options = {}) { } export function finishPlan(context, args, permissionMode, providerEnvironment = null) { - const resolvedProviderEnvironment = providerEnvironment || buildProviderEnvironment(context.config); + const resolvedProviderEnvironment = providerEnvironment || ( + context.privateAutomationProfile + ? buildPrivateProviderEnvironment(context.config, context.binaryPath) + : buildProviderEnvironment(context.config) + ); + const environment = context.privateAutomationProfile + ? resolvedProviderEnvironment + : buildAgentExecutableEnvironment(context.binaryPath, resolvedProviderEnvironment); return Object.freeze({ args, - environment: buildAgentExecutableEnvironment(context.binaryPath, resolvedProviderEnvironment), + environment, + ...(context.privateAutomationProfile ? { + maxFinalOutputBytes: context.privateAutomationProfile.maxFinalOutputBytes, + maxRawOutputBytes: context.privateAutomationProfile.maxRawOutputBytes, + privateAutomationProfile: context.privateAutomationProfile, + stdin: context.prompt, + } : {}), model: context.model, permissionMode, provider: context.provider, diff --git a/src/agent-host/providers/config/claude.json b/src/agent-host/providers/config/claude.json index 8477cee..4b24703 100644 --- a/src/agent-host/providers/config/claude.json +++ b/src/agent-host/providers/config/claude.json @@ -23,6 +23,13 @@ "command": "claude", "promptDelivery": "arg-or-stdin", + "privateAutomation": { + "profile": "private-automation-v1", + "promptDelivery": "stdin", + "sessionPersistence": false, + "tools": false + }, + "args": { "base": [ "--output-format", "stream-json", @@ -104,6 +111,7 @@ "TERM": "xterm-256color", "CI": "true", "CLAUDE_NO_UPDATE_CHECK": "true", + "CLAUDE_CODE_SKIP_PROMPT_HISTORY": "1", "DISABLE_AUTOUPDATE": "1", "NO_COLOR": "1" }, diff --git a/src/agent-host/providers/config/codex.json b/src/agent-host/providers/config/codex.json index 377a7f3..0a658af 100644 --- a/src/agent-host/providers/config/codex.json +++ b/src/agent-host/providers/config/codex.json @@ -24,6 +24,14 @@ "promptDelivery": "arg", "stdinPrompt": "-", + "privateAutomation": { + "minimumVersion": "0.146.0", + "profile": "private-automation-v1", + "promptDelivery": "stdin", + "sessionPersistence": false, + "tools": false + }, + "args": { "prefixConditionals": [ { "if": "approvalPolicy", "args": ["--ask-for-approval", "{{approvalPolicy}}"] }, diff --git a/src/agent-host/workspace.js b/src/agent-host/workspace.js index 02ab2e2..09c996e 100644 --- a/src/agent-host/workspace.js +++ b/src/agent-host/workspace.js @@ -143,6 +143,7 @@ export function resolveAgentWorkspace(options, dependencies = {}) { mode = WORKSPACE_MODES.AUTO, originDirectory = process.cwd(), outputDirectory = null, + privateAutomation = false, workspace = null, } = options || {}; const { execFileSyncImpl = execFileSync } = dependencies; @@ -151,6 +152,9 @@ export function resolveAgentWorkspace(options, dependencies = {}) { if (!VALID_MODES.has(mode)) { throw new Error(`Unknown workspace mode: ${mode}. Available: ${[...VALID_MODES].join(', ')}`); } + if (privateAutomation === true && mode !== WORKSPACE_MODES.READ_ONLY) { + throw new Error('private automation requires read-only workspace mode'); + } if (typeof artifactsRoot !== 'string' || artifactsRoot.trim() === '') { throw new Error('artifactsRoot is required'); } @@ -190,7 +194,11 @@ export function resolveAgentWorkspace(options, dependencies = {}) { let baseRef = null; try { - if (resolvedMode === WORKSPACE_MODES.WORKTREE) { + if (privateAutomation === true) { + executionWorkspace = path.join(launchDirectory, 'private-workspace'); + fs.mkdirSync(executionWorkspace, { mode: 0o500 }); + fs.chmodSync(executionWorkspace, 0o500); + } else if (resolvedMode === WORKSPACE_MODES.WORKTREE) { executionWorkspace = path.join(launchDirectory, 'workspace'); const created = createGitWorktree({ destination: executionWorkspace, @@ -218,6 +226,7 @@ export function resolveAgentWorkspace(options, dependencies = {}) { originDirectory: resolvedOrigin, outputDestination: launchDirectory, projectRoot, + privateAutomation: privateAutomation === true, worktreeBranch, }); } diff --git a/src/commands/agent-host.js b/src/commands/agent-host.js index f5f2b7f..54fa253 100644 --- a/src/commands/agent-host.js +++ b/src/commands/agent-host.js @@ -79,6 +79,12 @@ PROVIDER OPTIONS --json Emit normalized JSONL events --detach Run through the local background service +PRIVATE AUTOMATION (FOREGROUND ONLY) + --private-automation Metadata-only, zero-tool private inference profile + --output-schema Required bounded structured-output schema + --model Required exact configured provider model ID + stdin Required prompt source; prompt flags are forbidden + Foreground execution needs neither the daemon nor Lite. Detached execution is owned by a dedicated RUDI worker and survives the invoking terminal and Lite. `); @@ -118,6 +124,11 @@ export async function cmdAgent(args = [], flags = {}, passthrough = [], dependen const subcommand = args[0]; const originDirectory = dependencies.originDirectory || process.cwd(); const stdin = dependencies.stdin || process.stdin; + const privateAutomation = flagValue(flags, 'private-automation', 'privateAutomation') === true; + + if (privateAutomation && subcommand !== 'launch') { + throw new Error('private automation supports only rudi agent launch'); + } if (subcommand === '_worker') { const launchId = requiredLaunchId(args, '_worker'); From 165f8425f74c556522e60bd997eafb522d34f50f Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 21:14:29 -0400 Subject: [PATCH 5/9] fix: harden private provider capability gates --- dist/index.cjs | 84 +++++++++++++++---- docs/frontier-agent-hosts.md | 16 +++- .../2026-08-08-private-automation-profile.md | 20 +++-- .../agent-host-private-automation.test.js | 50 ++++++++++- src/agent-host/events/stream.js | 8 +- src/agent-host/private-automation-profile.js | 54 ++++++++++-- src/agent-host/providers/claude.js | 24 +++++- src/agent-host/providers/config/claude.json | 1 + 8 files changed, 214 insertions(+), 43 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 9e95514..391614c 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -29901,6 +29901,7 @@ var claude_default = { binary: { name: "claude", resolvePaths: [ + "~/.rudi/bins/claude", "~/.local/bin/claude", "~/.rudi/runtimes/node/{arch}/bin/claude", "~/.rudi/runtimes/node/bin/claude", @@ -31137,7 +31138,7 @@ function projectPrivateAutomationEventMetadata(event) { } return Object.freeze(metadata); } -function assertPrivateAutomationRawEvent(provider, event) { +function assertPrivateAutomationRawEvent(provider, event, expectedModel = null) { if (!PRIVATE_PROVIDERS.has(provider) || !event || typeof event !== "object" || Array.isArray(event)) { throw new Error("private automation provider event is invalid"); } @@ -31162,6 +31163,12 @@ function assertPrivateAutomationRawEvent(provider, event) { throw new Error("private automation Claude content block is not allowlisted"); } } + if (provider === "claude" && event.type === "result" && expectedModel != null) { + const observedModels = event.modelUsage && typeof event.modelUsage === "object" && !Array.isArray(event.modelUsage) ? Object.keys(event.modelUsage) : []; + if (observedModels.length !== 1 || observedModels[0] !== expectedModel) { + throw new Error("private automation Claude model usage does not match the exact model"); + } + } if (containsToolEvent(event)) { throw new Error("private automation tool event is forbidden"); } @@ -31202,16 +31209,48 @@ function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, depend if (!successfulProbe(versionProbe) || !versionSupported) { throw new Error("Codex host version does not satisfy private automation config controls"); } - const configProbe = spawnSyncImpl(binaryPath, [ - "--strict-config", + const disabledFeatures = getPrivateCodexDisabledFeatures(); + const configArgs = ["--ask-for-approval", "never"]; + for (const feature of disabledFeatures) configArgs.push("--disable", feature); + configArgs.push( + "-c", + "mcp_servers={}", "-c", 'web_search="disabled"', "-c", "tools.view_image=false", "exec", - "--help" - ], { encoding: "utf8", timeout: 5e3 }); - const help2 = probeOutput(configProbe); + "-", + "--json", + "--skip-git-repo-check", + "--color", + "never", + "-C", + import_node_path4.default.dirname(profile.outputSchema.path), + "-m", + profile.model, + "--output-schema", + profile.outputSchema.path, + "--ephemeral", + "--strict-config", + "--ignore-user-config", + "--ignore-rules", + "-s", + "read-only" + ); + const configProbe = spawnSyncImpl(binaryPath, configArgs, { + encoding: "utf8", + input: "", + timeout: 5e3 + }); + if (configProbe?.error || configProbe?.status === 0 || !probeOutput(configProbe).includes("No prompt provided via stdin.")) { + throw new Error("Codex host does not satisfy private automation config controls"); + } + const helpProbe2 = spawnSyncImpl(binaryPath, ["exec", "--help"], { + encoding: "utf8", + timeout: 5e3 + }); + const help2 = probeOutput(helpProbe2); const requiredHelp2 = [ "--ephemeral", "--ignore-rules", @@ -31219,7 +31258,7 @@ function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, depend "--output-schema", "--sandbox" ]; - if (!successfulProbe(configProbe) || requiredHelp2.some((flag) => !help2.includes(flag))) { + if (!successfulProbe(helpProbe2) || requiredHelp2.some((flag) => !help2.includes(flag))) { throw new Error("Codex host does not satisfy private automation config and CLI capabilities"); } const featureProbe = spawnSyncImpl(binaryPath, ["features", "list"], { @@ -31241,7 +31280,6 @@ function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, depend const requiredHelp = [ "--disable-slash-commands", "--input-format", - "--json-schema", "--mcp-config", "--no-chrome", "--no-session-persistence", @@ -31363,10 +31401,10 @@ function executeForegroundLaunch({ let persistedEvent = normalized; if (privateAutomation) { try { - assertPrivateAutomationRawEvent(plan.provider, rawEvent); + assertPrivateAutomationRawEvent(plan.provider, rawEvent, plan.model); persistedEvent = projectPrivateAutomationEventMetadata(normalized); - } catch { - privateFailure = "private_tool_event"; + } catch (error) { + privateFailure = String(error?.message || "").includes("model usage") ? "private_model_mismatch" : "private_tool_event"; terminateProvider("SIGTERM"); return; } @@ -32250,8 +32288,6 @@ function buildClaudePlan(options) { "text", "--model", context.model, - "--json-schema", - context.privateAutomationProfile.outputSchema.canonical, "--no-session-persistence", "--safe-mode", "--no-chrome", @@ -32266,7 +32302,27 @@ function buildClaudePlan(options) { "--permission-mode", "plan" ]; - return finishPlan(context, args2, "plan"); + const environment = buildPrivateProviderEnvironment( + context.config, + context.binaryPath + ); + return finishPlan(context, args2, "plan", { + ...environment, + CLAUDE_CODE_AUTO_MODE_MODEL: context.model, + CLAUDE_CODE_BG_CLASSIFIER_MODEL: context.model, + CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", + CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1", + CLAUDE_CODE_DISABLE_CLAUDE_API_SKILL: "1", + CLAUDE_CODE_DISABLE_CLAUDE_CODE_SKILL: "1", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + CLAUDE_CODE_DISABLE_WORKFLOWS: "1", + CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: "0", + CLAUDE_CODE_ENABLE_TELEMETRY: "0", + CLAUDE_CODE_NO_MODEL_FALLBACK: "1", + CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT: "1", + CLAUDE_CODE_SUBAGENT_MODEL: context.model + }); } const images = validateImages(options.images); if (images.length > 0) { diff --git a/docs/frontier-agent-hosts.md b/docs/frontier-agent-hosts.md index 36b2a7f..3c2a866 100644 --- a/docs/frontier-agent-hosts.md +++ b/docs/frontier-agent-hosts.md @@ -99,6 +99,13 @@ structured result is returned transiently on stdout to the invoking process only after the provider stream reports the exact requested model. Missing or different provider-observed model identity fails closed. +Claude structured output is enforced by RUDI after the provider returns plain +JSON. The private profile deliberately does not pass Claude `--json-schema`, +because that CLI surface materializes a provider `StructuredOutput` tool. The +launcher disables all Claude tools and nonessential/auxiliary model traffic, +pins classifier and subagent model variables to the requested model, and +rejects terminal model-usage metadata unless it names only that exact model. + Private use still requires an organization-approved provider/model egress contract and a synthetic no-tool launch for each exact installed provider and model. Use this same command with a fixed benign prompt and a closed probe @@ -107,10 +114,11 @@ flag/help discovery alone is not activation evidence. The profile never chooses a provider or model and never falls back to another one. Codex private automation currently requires Codex CLI `0.146.0` or newer. The -launcher checks that version, strict no-web/no-image configuration, all named -feature controls, and the required `exec` flags before it creates a workspace -or delivers stdin. Claude is similarly capability-probed from its installed -CLI help contract after normal installation/authentication preflight. +launcher checks that version, executes an empty-stdin strict-config sentinel to +prove the no-web/no-image fields are accepted, verifies all named feature +controls, and checks the required `exec` flags before it creates a workspace or +delivers the real stdin. Claude is similarly capability-probed from its +installed CLI help contract after normal installation/authentication preflight. ## Install and update diff --git a/docs/swe-compliance/2026-08-08-private-automation-profile.md b/docs/swe-compliance/2026-08-08-private-automation-profile.md index ad25991..072e0cf 100644 --- a/docs/swe-compliance/2026-08-08-private-automation-profile.md +++ b/docs/swe-compliance/2026-08-08-private-automation-profile.md @@ -57,7 +57,7 @@ ## Phase 4: Green Tests And Refactor - Status: complete for focused and adjacent regression suites. -- Focused result: 16/16 passing, including pre-egress provider capability +- Focused result: 17/17 passing, including pre-egress provider capability gating and argv/stdin/env/workspace/artifact/DB isolation, malformed output, closed Claude event types, missing/different observed model identity, tool event, process-group termination, raw/final @@ -73,20 +73,22 @@ check, changed-file debt scan, package dry-run, argv/artifact/log privacy smoke tests, and exact provider probes with synthetic data. - Completed evidence: - - full test: 631/631 passing outside the network-bind sandbox; the initial + - full test: 634/634 passing on the combined CLI 1.10.15 lineage outside the network-bind sandbox; the initial sandboxed run had only the expected localhost `EPERM` smoke-test failure; - build: passing; two builds produced identical SHA-256 hashes; - package dry-run: six expected package entries only; - RUDI debt scan, `pr-review` profile: zero findings; - integrated synthetic privacy tests: prompt absent from provider argv, environment, stderr, database, native session field, and artifacts; - - Codex 0.145.0: rejected before workspace/process/artifact creation because - it lacks the strict `tools.view_image` config control required by the - current official Codex configuration contract; deployment requires Codex - 0.146.0 or newer plus the same live capability probes; - - Claude 2.1.226: required flags are present, but the Admin Mac is currently - unauthenticated, so the synthetic private launch was rejected before - workspace/process/artifact creation. + - Codex 0.146.0-alpha.10.1: authenticated but rejected by the empty-stdin + strict-config sentinel because that published build does not recognize + `tools.view_image`; the Luna lane stays disabled until an installed build + accepts every no-tool field and passes the live probe; + - Claude 2.1.226: authenticated through the RUDI secret-mediated wrapper. A + benign live provider probe with tools empty, nonessential traffic disabled, + no fallback, simple prompt mode, and post-response schema validation + reported no tools and only `claude-sonnet-5` model usage. The installed RUDI + profile must repeat that probe after this source is packaged. ## Phase 6: Docs, Contracts, And Closure diff --git a/src/__tests__/unit/agent-host-private-automation.test.js b/src/__tests__/unit/agent-host-private-automation.test.js index 506f490..8d71435 100644 --- a/src/__tests__/unit/agent-host-private-automation.test.js +++ b/src/__tests__/unit/agent-host-private-automation.test.js @@ -20,6 +20,7 @@ import { } from '../../agent-host/private-automation-profile.js'; import { buildClaudePlan } from '../../agent-host/providers/claude.js'; import { buildCodexPlan } from '../../agent-host/providers/codex.js'; +import { getAgentProviderConfig } from '../../agent-host/providers/index.js'; import { resolveAgentWorkspace } from '../../agent-host/workspace.js'; import { cmdAgent } from '../../commands/agent-host.js'; @@ -247,11 +248,16 @@ describe('private Agent Host automation profile', () => { assert.equal(claude.args.includes('--no-chrome'), true); assert.equal(claude.args.includes('--disable-slash-commands'), true); assert.equal(claude.args.includes('--strict-mcp-config'), true); - assert.equal(claude.args.includes('--json-schema'), true); + assert.equal(claude.args.includes('--json-schema'), false); assert.equal(claude.args.includes('--fallback-model'), false); const toolsIndex = claude.args.indexOf('--tools'); assert.notEqual(toolsIndex, -1); assert.equal(claude.args[toolsIndex + 1], ''); + assert.equal(claude.environment.CLAUDE_CODE_NO_MODEL_FALLBACK, '1'); + assert.equal(claude.environment.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1'); + assert.equal(claude.environment.CLAUDE_CODE_AUTO_MODE_MODEL, 'claude-sonnet-5'); + assert.equal(claude.environment.CLAUDE_CODE_BG_CLASSIFIER_MODEL, 'claude-sonnet-5'); + assert.equal(claude.environment.CLAUDE_CODE_SUBAGENT_MODEL, 'claude-sonnet-5'); for (const plan of [codex, claude]) { assert.equal(plan.maxFinalOutputBytes, PRIVATE_AUTOMATION_MAX_FINAL_OUTPUT_BYTES); @@ -357,6 +363,17 @@ describe('private Agent Host automation profile', () => { model: 'claude-sonnet-5', }, })); + assert.doesNotThrow(() => assertPrivateAutomationRawEvent('claude', { + type: 'result', + modelUsage: { 'claude-sonnet-5': { inputTokens: 1, outputTokens: 1 } }, + }, 'claude-sonnet-5')); + assert.throws(() => assertPrivateAutomationRawEvent('claude', { + type: 'result', + modelUsage: { + 'claude-haiku-4-5': { inputTokens: 1, outputTokens: 1 }, + 'claude-sonnet-5': { inputTokens: 1, outputTokens: 1 }, + }, + }, 'claude-sonnet-5'), /model usage does not match/u); }); test('capability-gates exact provider controls before prompt delivery', () => { @@ -402,12 +419,16 @@ describe('private Agent Host automation profile', () => { spawnSyncImpl(command, args) { calls.push({ args, command }); if (calls.length === 1) return { status: 0, stdout: 'codex-cli 0.146.0' }; - if (calls.length === 2) return { status: 0, stdout: codexHelp }; + if (calls.length === 2) { + return { status: 1, stderr: 'No prompt provided via stdin.' }; + } + if (calls.length === 3) return { status: 0, stdout: codexHelp }; return { status: 0, stdout: featureList }; }, }), true); assert.equal(calls[1].args.includes('tools.view_image=false'), true); assert.equal(calls[1].args.includes('web_search="disabled"'), true); + assert.equal(calls[1].args.includes('--output-schema'), true); assert.throws( () => assertPrivateAutomationHostCapabilities({ @@ -419,6 +440,24 @@ describe('private Agent Host automation profile', () => { /version does not satisfy private automation config/u, ); + assert.throws( + () => assertPrivateAutomationHostCapabilities({ + binaryPath: '/fake/codex', + profile: codexProfile, + }, { + spawnSyncImpl(command, args) { + if (args.includes('--version')) { + return { status: 0, stdout: 'codex-cli 0.146.0-alpha.10.1' }; + } + return { + status: 1, + stderr: 'unknown configuration field `tools.view_image`', + }; + }, + }), + /does not satisfy private automation config controls/u, + ); + const claudeProfile = createPrivateAutomationProfile({ model: 'claude-sonnet-5', outputSchemaPath, @@ -428,7 +467,6 @@ describe('private Agent Host automation profile', () => { const claudeHelp = [ '--disable-slash-commands', '--input-format', - '--json-schema', '--mcp-config', '--no-chrome', '--no-session-persistence', @@ -445,6 +483,12 @@ describe('private Agent Host automation profile', () => { }), true); }); + test('prefers the RUDI Claude wrapper that mediates private authentication', () => { + const resolvePaths = getAgentProviderConfig('claude').binary.resolvePaths; + assert.equal(resolvePaths[0], '~/.rudi/bins/claude'); + assert.equal(resolvePaths.includes('~/.local/bin/claude'), true); + }); + test('isolates the integrated spawn, transient result, database, and launch artifacts', async () => { const { artifactsRoot, originDirectory, outputSchemaPath, root } = fixture(); const profile = createPrivateAutomationProfile({ diff --git a/src/agent-host/events/stream.js b/src/agent-host/events/stream.js index d5b08a3..d0b9b0b 100644 --- a/src/agent-host/events/stream.js +++ b/src/agent-host/events/stream.js @@ -127,10 +127,12 @@ export function executeForegroundLaunch({ let persistedEvent = normalized; if (privateAutomation) { try { - assertPrivateAutomationRawEvent(plan.provider, rawEvent); + assertPrivateAutomationRawEvent(plan.provider, rawEvent, plan.model); persistedEvent = projectPrivateAutomationEventMetadata(normalized); - } catch { - privateFailure = 'private_tool_event'; + } catch (error) { + privateFailure = String(error?.message || '').includes('model usage') + ? 'private_model_mismatch' + : 'private_tool_event'; terminateProvider('SIGTERM'); return; } diff --git a/src/agent-host/private-automation-profile.js b/src/agent-host/private-automation-profile.js index b5f3e3b..355d584 100644 --- a/src/agent-host/private-automation-profile.js +++ b/src/agent-host/private-automation-profile.js @@ -210,7 +210,7 @@ export function projectPrivateAutomationEventMetadata(event) { return Object.freeze(metadata); } -export function assertPrivateAutomationRawEvent(provider, event) { +export function assertPrivateAutomationRawEvent(provider, event, expectedModel = null) { if (!PRIVATE_PROVIDERS.has(provider) || !event || typeof event !== 'object' || Array.isArray(event)) { throw new Error('private automation provider event is invalid'); } @@ -252,6 +252,15 @@ export function assertPrivateAutomationRawEvent(provider, event) { throw new Error('private automation Claude content block is not allowlisted'); } } + if (provider === 'claude' && event.type === 'result' && expectedModel != null) { + const observedModels = event.modelUsage && typeof event.modelUsage === 'object' + && !Array.isArray(event.modelUsage) + ? Object.keys(event.modelUsage) + : []; + if (observedModels.length !== 1 || observedModels[0] !== expectedModel) { + throw new Error('private automation Claude model usage does not match the exact model'); + } + } if (containsToolEvent(event)) { throw new Error('private automation tool event is forbidden'); } @@ -295,13 +304,43 @@ export function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, if (!successfulProbe(versionProbe) || !versionSupported) { throw new Error('Codex host version does not satisfy private automation config controls'); } - const configProbe = spawnSyncImpl(binaryPath, [ - '--strict-config', + const disabledFeatures = getPrivateCodexDisabledFeatures(); + const configArgs = ['--ask-for-approval', 'never']; + for (const feature of disabledFeatures) configArgs.push('--disable', feature); + configArgs.push( + '-c', 'mcp_servers={}', '-c', 'web_search="disabled"', '-c', 'tools.view_image=false', - 'exec', '--help', - ], { encoding: 'utf8', timeout: 5000 }); - const help = probeOutput(configProbe); + 'exec', '-', + '--json', + '--skip-git-repo-check', + '--color', 'never', + '-C', path.dirname(profile.outputSchema.path), + '-m', profile.model, + '--output-schema', profile.outputSchema.path, + '--ephemeral', + '--strict-config', + '--ignore-user-config', + '--ignore-rules', + '-s', 'read-only', + ); + const configProbe = spawnSyncImpl(binaryPath, configArgs, { + encoding: 'utf8', + input: '', + timeout: 5000, + }); + if ( + configProbe?.error + || configProbe?.status === 0 + || !probeOutput(configProbe).includes('No prompt provided via stdin.') + ) { + throw new Error('Codex host does not satisfy private automation config controls'); + } + const helpProbe = spawnSyncImpl(binaryPath, ['exec', '--help'], { + encoding: 'utf8', + timeout: 5000, + }); + const help = probeOutput(helpProbe); const requiredHelp = [ '--ephemeral', '--ignore-rules', @@ -309,7 +348,7 @@ export function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, '--output-schema', '--sandbox', ]; - if (!successfulProbe(configProbe) || requiredHelp.some(flag => !help.includes(flag))) { + if (!successfulProbe(helpProbe) || requiredHelp.some(flag => !help.includes(flag))) { throw new Error('Codex host does not satisfy private automation config and CLI capabilities'); } const featureProbe = spawnSyncImpl(binaryPath, ['features', 'list'], { @@ -332,7 +371,6 @@ export function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, const requiredHelp = [ '--disable-slash-commands', '--input-format', - '--json-schema', '--mcp-config', '--no-chrome', '--no-session-persistence', diff --git a/src/agent-host/providers/claude.js b/src/agent-host/providers/claude.js index b5d2436..e53b27f 100644 --- a/src/agent-host/providers/claude.js +++ b/src/agent-host/providers/claude.js @@ -1,5 +1,6 @@ import { buildArgs } from './catalog.js'; import { + buildPrivateProviderEnvironment, finishPlan, permissionArgs, providerContext, @@ -24,7 +25,6 @@ export function buildClaudePlan(options) { '--print', '--input-format', 'text', '--model', context.model, - '--json-schema', context.privateAutomationProfile.outputSchema.canonical, '--no-session-persistence', '--safe-mode', '--no-chrome', @@ -35,7 +35,27 @@ export function buildClaudePlan(options) { '--setting-sources', '', '--permission-mode', 'plan', ]; - return finishPlan(context, args, 'plan'); + const environment = buildPrivateProviderEnvironment( + context.config, + context.binaryPath, + ); + return finishPlan(context, args, 'plan', { + ...environment, + CLAUDE_CODE_AUTO_MODE_MODEL: context.model, + CLAUDE_CODE_BG_CLASSIFIER_MODEL: context.model, + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1', + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1', + CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: '1', + CLAUDE_CODE_DISABLE_CLAUDE_API_SKILL: '1', + CLAUDE_CODE_DISABLE_CLAUDE_CODE_SKILL: '1', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_DISABLE_WORKFLOWS: '1', + CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: '0', + CLAUDE_CODE_ENABLE_TELEMETRY: '0', + CLAUDE_CODE_NO_MODEL_FALLBACK: '1', + CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT: '1', + CLAUDE_CODE_SUBAGENT_MODEL: context.model, + }); } const images = validateImages(options.images); if (images.length > 0) { diff --git a/src/agent-host/providers/config/claude.json b/src/agent-host/providers/config/claude.json index 4b24703..eceb1ae 100644 --- a/src/agent-host/providers/config/claude.json +++ b/src/agent-host/providers/config/claude.json @@ -8,6 +8,7 @@ "binary": { "name": "claude", "resolvePaths": [ + "~/.rudi/bins/claude", "~/.local/bin/claude", "~/.rudi/runtimes/node/{arch}/bin/claude", "~/.rudi/runtimes/node/bin/claude", From b8395825213f178d92ca808eb0518fca50ad68dd Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 21:19:09 -0400 Subject: [PATCH 6/9] fix: probe Claude private flags without help truncation --- dist/index.cjs | 46 ++++++++++++------- docs/frontier-agent-hosts.md | 5 +- .../agent-host-private-automation.test.js | 24 +++++----- src/agent-host/private-automation-profile.js | 33 ++++++++----- 4 files changed, 67 insertions(+), 41 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 391614c..085defc 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -31238,27 +31238,27 @@ function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, depend "-s", "read-only" ); - const configProbe = spawnSyncImpl(binaryPath, configArgs, { + const configProbe2 = spawnSyncImpl(binaryPath, configArgs, { encoding: "utf8", input: "", timeout: 5e3 }); - if (configProbe?.error || configProbe?.status === 0 || !probeOutput(configProbe).includes("No prompt provided via stdin.")) { + if (configProbe2?.error || configProbe2?.status === 0 || !probeOutput(configProbe2).includes("No prompt provided via stdin.")) { throw new Error("Codex host does not satisfy private automation config controls"); } - const helpProbe2 = spawnSyncImpl(binaryPath, ["exec", "--help"], { + const helpProbe = spawnSyncImpl(binaryPath, ["exec", "--help"], { encoding: "utf8", timeout: 5e3 }); - const help2 = probeOutput(helpProbe2); - const requiredHelp2 = [ + const help = probeOutput(helpProbe); + const requiredHelp = [ "--ephemeral", "--ignore-rules", "--ignore-user-config", "--output-schema", "--sandbox" ]; - if (!successfulProbe(helpProbe2) || requiredHelp2.some((flag) => !help2.includes(flag))) { + if (!successfulProbe(helpProbe) || requiredHelp.some((flag) => !help.includes(flag))) { throw new Error("Codex host does not satisfy private automation config and CLI capabilities"); } const featureProbe = spawnSyncImpl(binaryPath, ["features", "list"], { @@ -31275,20 +31275,34 @@ function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, depend } return true; } - const helpProbe = spawnSyncImpl(binaryPath, ["--help"], { encoding: "utf8", timeout: 5e3 }); - const help = probeOutput(helpProbe); - const requiredHelp = [ - "--disable-slash-commands", + const configProbe = spawnSyncImpl(binaryPath, [ + "--output-format", + "stream-json", + "--verbose", + "--print", "--input-format", - "--mcp-config", - "--no-chrome", + "text", + "--model", + profile.model, "--no-session-persistence", "--safe-mode", - "--setting-sources", + "--no-chrome", + "--disable-slash-commands", + "--tools", + "", "--strict-mcp-config", - "--tools" - ]; - if (!successfulProbe(helpProbe) || requiredHelp.some((flag) => !help.includes(flag))) { + "--mcp-config", + '{"mcpServers":{}}', + "--setting-sources", + "", + "--permission-mode", + "plan" + ], { + encoding: "utf8", + input: "", + timeout: 5e3 + }); + if (configProbe?.error || configProbe?.status === 0 || !probeOutput(configProbe).includes("Input must be provided either through stdin")) { throw new Error("Claude host does not satisfy private automation CLI capabilities"); } return true; diff --git a/docs/frontier-agent-hosts.md b/docs/frontier-agent-hosts.md index 3c2a866..0a8c1f8 100644 --- a/docs/frontier-agent-hosts.md +++ b/docs/frontier-agent-hosts.md @@ -117,8 +117,9 @@ Codex private automation currently requires Codex CLI `0.146.0` or newer. The launcher checks that version, executes an empty-stdin strict-config sentinel to prove the no-web/no-image fields are accepted, verifies all named feature controls, and checks the required `exec` flags before it creates a workspace or -delivers the real stdin. Claude is similarly capability-probed from its -installed CLI help contract after normal installation/authentication preflight. +delivers the real stdin. Claude is similarly capability-probed with an exact +empty-stdin flag-parse sentinel after normal installation/authentication +preflight. ## Install and update diff --git a/src/__tests__/unit/agent-host-private-automation.test.js b/src/__tests__/unit/agent-host-private-automation.test.js index 8d71435..a3ccb5a 100644 --- a/src/__tests__/unit/agent-host-private-automation.test.js +++ b/src/__tests__/unit/agent-host-private-automation.test.js @@ -464,23 +464,23 @@ describe('private Agent Host automation profile', () => { provider: 'claude', timeoutMs: 160_000, }); - const claudeHelp = [ - '--disable-slash-commands', - '--input-format', - '--mcp-config', - '--no-chrome', - '--no-session-persistence', - '--safe-mode', - '--setting-sources', - '--strict-mcp-config', - '--tools', - ].join('\n'); + const claudeCalls = []; assert.equal(assertPrivateAutomationHostCapabilities({ binaryPath: '/fake/claude', profile: claudeProfile, }, { - spawnSyncImpl: () => ({ status: 0, stdout: claudeHelp }), + spawnSyncImpl(command, args, options) { + claudeCalls.push({ args, command, options }); + return { + status: 1, + stderr: 'Input must be provided either through stdin or as a prompt argument when using --print', + }; + }, }), true); + assert.equal(claudeCalls[0].args.includes('--safe-mode'), true); + assert.equal(claudeCalls[0].args.includes('--tools'), true); + assert.equal(claudeCalls[0].args.includes('--json-schema'), false); + assert.equal(claudeCalls[0].options.input, ''); }); test('prefers the RUDI Claude wrapper that mediates private authentication', () => { diff --git a/src/agent-host/private-automation-profile.js b/src/agent-host/private-automation-profile.js index 355d584..7205e93 100644 --- a/src/agent-host/private-automation-profile.js +++ b/src/agent-host/private-automation-profile.js @@ -366,20 +366,31 @@ export function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, return true; } - const helpProbe = spawnSyncImpl(binaryPath, ['--help'], { encoding: 'utf8', timeout: 5000 }); - const help = probeOutput(helpProbe); - const requiredHelp = [ - '--disable-slash-commands', - '--input-format', - '--mcp-config', - '--no-chrome', + const configProbe = spawnSyncImpl(binaryPath, [ + '--output-format', 'stream-json', + '--verbose', + '--print', + '--input-format', 'text', + '--model', profile.model, '--no-session-persistence', '--safe-mode', - '--setting-sources', + '--no-chrome', + '--disable-slash-commands', + '--tools', '', '--strict-mcp-config', - '--tools', - ]; - if (!successfulProbe(helpProbe) || requiredHelp.some(flag => !help.includes(flag))) { + '--mcp-config', '{"mcpServers":{}}', + '--setting-sources', '', + '--permission-mode', 'plan', + ], { + encoding: 'utf8', + input: '', + timeout: 5000, + }); + if ( + configProbe?.error + || configProbe?.status === 0 + || !probeOutput(configProbe).includes('Input must be provided either through stdin') + ) { throw new Error('Claude host does not satisfy private automation CLI capabilities'); } return true; From f7826ff7dbf80b9f7af4b62460f5fe48ab963c64 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 21:39:45 -0400 Subject: [PATCH 7/9] fix: harden Codex private automation for 0.147 Disable view_image through the supported feature gate, require the official stable CLI, accept only the exact fail-closed Code Mode diagnostic, and preserve exact command-pinned model semantics for Codex JSONL. --- dist/index.cjs | 21 ++++--- docs/frontier-agent-hosts.md | 17 ++++-- .../2026-08-08-private-automation-profile.md | 23 +++++--- .../agent-host-private-automation.test.js | 58 +++++++++++-------- src/agent-host/events/stream.js | 8 ++- src/agent-host/private-automation-profile.js | 22 ++++--- src/agent-host/providers/codex.js | 1 - src/agent-host/providers/config/codex.json | 3 +- 8 files changed, 96 insertions(+), 57 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 085defc..5851f9f 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -30239,6 +30239,7 @@ var codex_default = { binary: { name: "codex", resolvePaths: [ + "~/.rudi/agents/codex/bin/codex", "~/.rudi/agents/codex/node_modules/.bin/codex", "~/.rudi/runtimes/node/{arch}/bin/codex", "~/.rudi/runtimes/node/bin/codex" @@ -30254,7 +30255,7 @@ var codex_default = { promptDelivery: "arg", stdinPrompt: "-", privateAutomation: { - minimumVersion: "0.146.0", + minimumVersion: "0.147.0", profile: "private-automation-v1", promptDelivery: "stdin", sessionPersistence: false, @@ -30966,6 +30967,7 @@ var PRIVATE_RAW_EVENT_TYPES = Object.freeze({ ]) }); var PRIVATE_CODEX_ITEM_TYPES = /* @__PURE__ */ new Set(["agent_message", "reasoning"]); +var PRIVATE_CODEX_DISABLED_CAPABILITY_DIAGNOSTIC = "Code Mode is unavailable because code-mode host is disabled."; var PRIVATE_CLAUDE_ASSISTANT_BLOCK_TYPES = /* @__PURE__ */ new Set(["text", "thinking"]); var PRIVATE_CLAUDE_SYSTEM_SUBTYPES = /* @__PURE__ */ new Set(["init"]); var PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ @@ -30986,7 +30988,8 @@ var PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ "skill_search", "tool_call_mcp_elicitation", "tool_suggest", - "unified_exec" + "unified_exec", + "view_image" ]); function getPrivateCodexDisabledFeatures() { return [...PRIVATE_CODEX_DISABLED_FEATURES]; @@ -31145,8 +31148,12 @@ function assertPrivateAutomationRawEvent(provider, event, expectedModel = null) if (!PRIVATE_RAW_EVENT_TYPES[provider].has(event.type)) { throw new Error("private automation provider event type is not allowlisted"); } - if (provider === "codex" && event.type.startsWith("item.") && !PRIVATE_CODEX_ITEM_TYPES.has(event.item?.type)) { - throw new Error("private automation Codex item type is not allowlisted"); + if (provider === "codex" && event.type.startsWith("item.")) { + const itemType = event.item?.type; + const isBlockedCapabilityDiagnostic = event.type === "item.completed" && itemType === "error" && typeof event.item?.message === "string" && event.item.message.startsWith(PRIVATE_CODEX_DISABLED_CAPABILITY_DIAGNOSTIC); + if (!PRIVATE_CODEX_ITEM_TYPES.has(itemType) && !isBlockedCapabilityDiagnostic) { + throw new Error("private automation Codex item type is not allowlisted"); + } } if (provider === "claude" && event.type === "system") { if (!PRIVATE_CLAUDE_SYSTEM_SUBTYPES.has(event.subtype)) { @@ -31217,8 +31224,6 @@ function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, depend "mcp_servers={}", "-c", 'web_search="disabled"', - "-c", - "tools.view_image=false", "exec", "-", "--json", @@ -31347,7 +31352,7 @@ function executeForegroundLaunch({ let sinkFailure = null; let privateFailure = null; let privateFinalOutput = null; - let privateObservedModel = null; + let privateObservedModel = privateAutomation && plan.provider === "codex" ? plan.model : null; let privateRawOutputBytes = 0; let privateUsage = null; function terminateProvider(signal) { @@ -32388,8 +32393,6 @@ function buildCodexPlan(options) { "mcp_servers={}", "-c", 'web_search="disabled"', - "-c", - "tools.view_image=false", "exec", "-", "--json", diff --git a/docs/frontier-agent-hosts.md b/docs/frontier-agent-hosts.md index 0a8c1f8..d7134c6 100644 --- a/docs/frontier-agent-hosts.md +++ b/docs/frontier-agent-hosts.md @@ -96,8 +96,12 @@ by default), a 2-MiB raw provider-stream ceiling, and a 64-KiB final structured result ceiling. Provider stderr is suppressed, native session IDs are not stored, and launch artifacts receive only event/usage/status metadata. The one structured result is returned transiently on stdout to the invoking process -only after the provider stream reports the exact requested model. Missing or -different provider-observed model identity fails closed. +only after the provider-specific exact-model contract succeeds. Codex is +command-pinned with `-m`, ignores user configuration, exposes no fallback-model +input in this profile, and rejects any contradictory model field if one appears +in its JSONL stream; Codex JSONL does not otherwise echo the selected model. +Claude must report terminal model usage containing only the requested exact +model. Missing or different Claude model identity fails closed. Claude structured output is enforced by RUDI after the provider returns plain JSON. The private profile deliberately does not pass Claude `--json-schema`, @@ -113,11 +117,12 @@ schema while the empty workspace and metadata-only artifacts are inspected; flag/help discovery alone is not activation evidence. The profile never chooses a provider or model and never falls back to another one. -Codex private automation currently requires Codex CLI `0.146.0` or newer. The +Codex private automation currently requires Codex CLI `0.147.0` or newer. The launcher checks that version, executes an empty-stdin strict-config sentinel to -prove the no-web/no-image fields are accepted, verifies all named feature -controls, and checks the required `exec` flags before it creates a workspace or -delivers the real stdin. Claude is similarly capability-probed with an exact +prove the no-web configuration and `view_image` feature disable are accepted, +verifies all named feature controls, and checks the required `exec` flags before +it creates a workspace or delivers the real stdin. Claude is similarly +capability-probed with an exact empty-stdin flag-parse sentinel after normal installation/authentication preflight. diff --git a/docs/swe-compliance/2026-08-08-private-automation-profile.md b/docs/swe-compliance/2026-08-08-private-automation-profile.md index 072e0cf..4d96a05 100644 --- a/docs/swe-compliance/2026-08-08-private-automation-profile.md +++ b/docs/swe-compliance/2026-08-08-private-automation-profile.md @@ -57,10 +57,11 @@ ## Phase 4: Green Tests And Refactor - Status: complete for focused and adjacent regression suites. -- Focused result: 17/17 passing, including pre-egress provider capability +- Focused result: 16/16 passing, including pre-egress provider capability gating and argv/stdin/env/workspace/artifact/DB - isolation, malformed output, closed Claude event types, missing/different - observed model identity, tool event, process-group termination, raw/final + isolation, malformed output, closed provider event types, Claude + missing/different observed model identity, contradictory Codex model fields, + tool events, process-group termination, raw/final overflow, timeout, and forbidden command surfaces. - Adjacent result: 42/42 passing across Agent Host command, launch, provider, provider-environment, workspace, and model suites. @@ -73,17 +74,21 @@ check, changed-file debt scan, package dry-run, argv/artifact/log privacy smoke tests, and exact provider probes with synthetic data. - Completed evidence: - - full test: 634/634 passing on the combined CLI 1.10.15 lineage outside the network-bind sandbox; the initial + - full test: 633/633 passing on the combined CLI 1.10.15 lineage outside the network-bind sandbox; the initial sandboxed run had only the expected localhost `EPERM` smoke-test failure; - - build: passing; two builds produced identical SHA-256 hashes; + - build: passing; two builds produced identical SHA-256 hashes + (`dist/index.cjs` = + `735395bb2d2e9fe6deaa9a61c8f3714c80c0da0ce546c8ba61a7f67a60c586c7`); - package dry-run: six expected package entries only; - RUDI debt scan, `pr-review` profile: zero findings; - integrated synthetic privacy tests: prompt absent from provider argv, environment, stderr, database, native session field, and artifacts; - - Codex 0.146.0-alpha.10.1: authenticated but rejected by the empty-stdin - strict-config sentinel because that published build does not recognize - `tools.view_image`; the Luna lane stays disabled until an installed build - accepts every no-tool field and passes the live probe; + - Codex 0.147.0: its official release binary accepts `view_image` as an + explicitly disabled feature in the empty-stdin strict-config sentinel. A + direct benign probe returned the requested closed JSON and emitted only a + fail-closed diagnostic that Code Mode was unavailable because its host was + disabled. The Luna lane stays disabled until that exact binary is installed + and the integrated RUDI live probe repeats that result; - Claude 2.1.226: authenticated through the RUDI secret-mediated wrapper. A benign live provider probe with tools empty, nonessential traffic disabled, no fallback, simple prompt mode, and post-response schema validation diff --git a/src/__tests__/unit/agent-host-private-automation.test.js b/src/__tests__/unit/agent-host-private-automation.test.js index a3ccb5a..0228579 100644 --- a/src/__tests__/unit/agent-host-private-automation.test.js +++ b/src/__tests__/unit/agent-host-private-automation.test.js @@ -102,17 +102,23 @@ function privateCodexSpawn(calls, { malformed = false, tool = false } = {}) { type: 'item.started', })}\n`); } else { + child.stdout.write(`${JSON.stringify({ + item: { + id: 'disabled-capability-1', + message: 'Code Mode is unavailable because code-mode host is disabled. Code mode will fail closed.', + type: 'error', + }, + type: 'item.completed', + })}\n`); child.stdout.write(`${JSON.stringify({ item: { id: 'message-1', - model: 'gpt-5.6-luna', text: JSON.stringify({ category: 'conversation', schemaVersion: 1 }), type: 'agent_message', }, type: 'item.completed', })}\n`); child.stdout.write(`${JSON.stringify({ - model: 'gpt-5.6-luna', type: 'turn.completed', usage: { input_tokens: 25, output_tokens: 8 }, })}\n`); @@ -199,7 +205,7 @@ describe('private Agent Host automation profile', () => { assert.equal(codex.args.includes('gpt-5.6-luna'), true); assert.equal(codex.args.includes('--search'), false); assert.equal(codex.args.includes('web_search="disabled"'), true); - assert.equal(codex.args.includes('tools.view_image=false'), true); + assert.equal(codex.args.includes('tools.view_image=false'), false); for (const feature of [ 'apps', 'browser_use', @@ -219,6 +225,7 @@ describe('private Agent Host automation profile', () => { 'tool_call_mcp_elicitation', 'tool_suggest', 'unified_exec', + 'view_image', ]) { assert.deepEqual( codex.args.some((arg, index) => ( @@ -344,7 +351,7 @@ describe('private Agent Host automation profile', () => { ); }); - test('rejects Claude permission, tool, and unknown assistant blocks', () => { + test('rejects unsafe raw events and accepts the exact Codex disabled-capability diagnostic', () => { for (const event of [ { type: 'system', subtype: 'permission_request' }, { type: 'assistant', message: { content: [{ type: 'server_tool_use' }] } }, @@ -374,6 +381,18 @@ describe('private Agent Host automation profile', () => { 'claude-sonnet-5': { inputTokens: 1, outputTokens: 1 }, }, }, 'claude-sonnet-5'), /model usage does not match/u); + assert.doesNotThrow(() => assertPrivateAutomationRawEvent('codex', { + item: { + id: 'disabled-capability-1', + message: 'Code Mode is unavailable because code-mode host is disabled. Code mode will fail closed.', + type: 'error', + }, + type: 'item.completed', + }, 'gpt-5.6-luna')); + assert.throws(() => assertPrivateAutomationRawEvent('codex', { + item: { id: 'provider-error-1', message: 'different provider error', type: 'error' }, + type: 'item.completed', + }, 'gpt-5.6-luna'), /not allowlisted/u); }); test('capability-gates exact provider controls before prompt delivery', () => { @@ -410,6 +429,7 @@ describe('private Agent Host automation profile', () => { 'tool_call_mcp_elicitation', 'tool_suggest', 'unified_exec', + 'view_image', ].map(feature => `${feature} stable true`).join('\n'); const calls = []; assert.equal(assertPrivateAutomationHostCapabilities({ @@ -418,7 +438,7 @@ describe('private Agent Host automation profile', () => { }, { spawnSyncImpl(command, args) { calls.push({ args, command }); - if (calls.length === 1) return { status: 0, stdout: 'codex-cli 0.146.0' }; + if (calls.length === 1) return { status: 0, stdout: 'codex-cli 0.147.0' }; if (calls.length === 2) { return { status: 1, stderr: 'No prompt provided via stdin.' }; } @@ -426,7 +446,10 @@ describe('private Agent Host automation profile', () => { return { status: 0, stdout: featureList }; }, }), true); - assert.equal(calls[1].args.includes('tools.view_image=false'), true); + assert.equal(calls[1].args.includes('tools.view_image=false'), false); + assert.equal(calls[1].args.some((arg, index) => ( + arg === '--disable' && calls[1].args[index + 1] === 'view_image' + )), true); assert.equal(calls[1].args.includes('web_search="disabled"'), true); assert.equal(calls[1].args.includes('--output-schema'), true); @@ -435,7 +458,7 @@ describe('private Agent Host automation profile', () => { binaryPath: '/fake/codex', profile: codexProfile, }, { - spawnSyncImpl: () => ({ status: 0, stdout: 'codex-cli 0.145.0' }), + spawnSyncImpl: () => ({ status: 0, stdout: 'codex-cli 0.146.0' }), }), /version does not satisfy private automation config/u, ); @@ -447,11 +470,11 @@ describe('private Agent Host automation profile', () => { }, { spawnSyncImpl(command, args) { if (args.includes('--version')) { - return { status: 0, stdout: 'codex-cli 0.146.0-alpha.10.1' }; + return { status: 0, stdout: 'codex-cli 0.147.0' }; } return { status: 1, - stderr: 'unknown configuration field `tools.view_image`', + stderr: 'unknown feature: view_image', }; }, }), @@ -484,6 +507,9 @@ describe('private Agent Host automation profile', () => { }); test('prefers the RUDI Claude wrapper that mediates private authentication', () => { + const codexResolvePaths = getAgentProviderConfig('codex').binary.resolvePaths; + assert.equal(codexResolvePaths[0], '~/.rudi/agents/codex/bin/codex'); + const resolvePaths = getAgentProviderConfig('claude').binary.resolvePaths; assert.equal(resolvePaths[0], '~/.rudi/bins/claude'); assert.equal(resolvePaths.includes('~/.local/bin/claude'), true); @@ -636,20 +662,6 @@ describe('private Agent Host automation profile', () => { }); for (const scenario of [ - { - expected: 'private_model_unobserved', - label: 'missing provider-observed model identity', - write(child) { - child.stdout.write(`${JSON.stringify({ - item: { - id: 'message-1', - text: JSON.stringify({ category: 'conversation', schemaVersion: 1 }), - type: 'agent_message', - }, - type: 'item.completed', - })}\n`); - }, - }, { expected: 'private_model_mismatch', label: 'provider model mismatch', diff --git a/src/agent-host/events/stream.js b/src/agent-host/events/stream.js index d0b9b0b..8e93a3e 100644 --- a/src/agent-host/events/stream.js +++ b/src/agent-host/events/stream.js @@ -50,7 +50,13 @@ export function executeForegroundLaunch({ let sinkFailure = null; let privateFailure = null; let privateFinalOutput = null; - let privateObservedModel = null; + // Codex's JSONL stream does not currently echo the selected model. Its + // private plan is still exact: a canonical model is supplied with `-m`, + // user config is ignored, and the CLI has no fallback-model input. Claude + // does report modelUsage, so it remains provider-observed below. + let privateObservedModel = privateAutomation && plan.provider === 'codex' + ? plan.model + : null; let privateRawOutputBytes = 0; let privateUsage = null; diff --git a/src/agent-host/private-automation-profile.js b/src/agent-host/private-automation-profile.js index 7205e93..83a33d2 100644 --- a/src/agent-host/private-automation-profile.js +++ b/src/agent-host/private-automation-profile.js @@ -27,6 +27,9 @@ const PRIVATE_RAW_EVENT_TYPES = Object.freeze({ ]), }); const PRIVATE_CODEX_ITEM_TYPES = new Set(['agent_message', 'reasoning']); +const PRIVATE_CODEX_DISABLED_CAPABILITY_DIAGNOSTIC = ( + 'Code Mode is unavailable because code-mode host is disabled.' +); const PRIVATE_CLAUDE_ASSISTANT_BLOCK_TYPES = new Set(['text', 'thinking']); const PRIVATE_CLAUDE_SYSTEM_SUBTYPES = new Set(['init']); const PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ @@ -48,6 +51,7 @@ const PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ 'tool_call_mcp_elicitation', 'tool_suggest', 'unified_exec', + 'view_image', ]); export function getPrivateCodexDisabledFeatures() { @@ -217,12 +221,17 @@ export function assertPrivateAutomationRawEvent(provider, event, expectedModel = if (!PRIVATE_RAW_EVENT_TYPES[provider].has(event.type)) { throw new Error('private automation provider event type is not allowlisted'); } - if ( - provider === 'codex' - && event.type.startsWith('item.') - && !PRIVATE_CODEX_ITEM_TYPES.has(event.item?.type) - ) { - throw new Error('private automation Codex item type is not allowlisted'); + if (provider === 'codex' && event.type.startsWith('item.')) { + const itemType = event.item?.type; + const isBlockedCapabilityDiagnostic = ( + event.type === 'item.completed' + && itemType === 'error' + && typeof event.item?.message === 'string' + && event.item.message.startsWith(PRIVATE_CODEX_DISABLED_CAPABILITY_DIAGNOSTIC) + ); + if (!PRIVATE_CODEX_ITEM_TYPES.has(itemType) && !isBlockedCapabilityDiagnostic) { + throw new Error('private automation Codex item type is not allowlisted'); + } } if (provider === 'claude' && event.type === 'system') { if (!PRIVATE_CLAUDE_SYSTEM_SUBTYPES.has(event.subtype)) { @@ -310,7 +319,6 @@ export function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, configArgs.push( '-c', 'mcp_servers={}', '-c', 'web_search="disabled"', - '-c', 'tools.view_image=false', 'exec', '-', '--json', '--skip-git-repo-check', diff --git a/src/agent-host/providers/codex.js b/src/agent-host/providers/codex.js index 6ba9100..df47708 100644 --- a/src/agent-host/providers/codex.js +++ b/src/agent-host/providers/codex.js @@ -44,7 +44,6 @@ export function buildCodexPlan(options) { args.push( '-c', 'mcp_servers={}', '-c', 'web_search="disabled"', - '-c', 'tools.view_image=false', 'exec', '-', '--json', '--skip-git-repo-check', diff --git a/src/agent-host/providers/config/codex.json b/src/agent-host/providers/config/codex.json index 0a658af..9f71612 100644 --- a/src/agent-host/providers/config/codex.json +++ b/src/agent-host/providers/config/codex.json @@ -8,6 +8,7 @@ "binary": { "name": "codex", "resolvePaths": [ + "~/.rudi/agents/codex/bin/codex", "~/.rudi/agents/codex/node_modules/.bin/codex", "~/.rudi/runtimes/node/{arch}/bin/codex", "~/.rudi/runtimes/node/bin/codex" @@ -25,7 +26,7 @@ "stdinPrompt": "-", "privateAutomation": { - "minimumVersion": "0.146.0", + "minimumVersion": "0.147.0", "profile": "private-automation-v1", "promptDelivery": "stdin", "sessionPersistence": false, From 478d5768f349e98abfc7ffe205ee94128b433494 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 21:51:15 -0400 Subject: [PATCH 8/9] fix: allow closed Claude progress metadata Accept only Claude's numeric thinking-token progress shape while excluding content and persisting metadata only, so the zero-tool Sonnet stream can complete under the private profile. --- dist/index.cjs | 13 ++++++++++++- docs/frontier-agent-hosts.md | 2 ++ .../2026-08-08-private-automation-profile.md | 8 +++++--- .../agent-host-private-automation.test.js | 15 +++++++++++++++ src/agent-host/private-automation-profile.js | 19 ++++++++++++++++++- 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 5851f9f..e7f2784 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -30969,7 +30969,15 @@ var PRIVATE_RAW_EVENT_TYPES = Object.freeze({ var PRIVATE_CODEX_ITEM_TYPES = /* @__PURE__ */ new Set(["agent_message", "reasoning"]); var PRIVATE_CODEX_DISABLED_CAPABILITY_DIAGNOSTIC = "Code Mode is unavailable because code-mode host is disabled."; var PRIVATE_CLAUDE_ASSISTANT_BLOCK_TYPES = /* @__PURE__ */ new Set(["text", "thinking"]); -var PRIVATE_CLAUDE_SYSTEM_SUBTYPES = /* @__PURE__ */ new Set(["init"]); +var PRIVATE_CLAUDE_SYSTEM_SUBTYPES = /* @__PURE__ */ new Set(["init", "thinking_tokens"]); +var PRIVATE_CLAUDE_THINKING_TOKEN_KEYS = /* @__PURE__ */ new Set([ + "estimated_tokens", + "estimated_tokens_delta", + "session_id", + "subtype", + "type", + "uuid" +]); var PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ "apps", "browser_use", @@ -31162,6 +31170,9 @@ function assertPrivateAutomationRawEvent(provider, event, expectedModel = null) if (Array.isArray(event.tools) && event.tools.length > 0 || Array.isArray(event.mcp_servers) && event.mcp_servers.length > 0) { throw new Error("private automation Claude init capabilities are not empty"); } + if (event.subtype === "thinking_tokens" && (Object.keys(event).some((key) => !PRIVATE_CLAUDE_THINKING_TOKEN_KEYS.has(key)) || !Number.isFinite(event.estimated_tokens) || event.estimated_tokens < 0 || !Number.isFinite(event.estimated_tokens_delta) || event.estimated_tokens_delta < 0)) { + throw new Error("private automation Claude thinking-token metadata is invalid"); + } } if (provider === "claude" && event.type === "assistant") { const message = event.message && typeof event.message === "object" ? event.message : null; diff --git a/docs/frontier-agent-hosts.md b/docs/frontier-agent-hosts.md index d7134c6..1a5744a 100644 --- a/docs/frontier-agent-hosts.md +++ b/docs/frontier-agent-hosts.md @@ -109,6 +109,8 @@ because that CLI surface materializes a provider `StructuredOutput` tool. The launcher disables all Claude tools and nonessential/auxiliary model traffic, pins classifier and subagent model variables to the requested model, and rejects terminal model-usage metadata unless it names only that exact model. +Claude `thinking_tokens` progress events are accepted only as a closed numeric +metadata shape; their session identifiers and token estimates are not persisted. Private use still requires an organization-approved provider/model egress contract and a synthetic no-tool launch for each exact installed provider and diff --git a/docs/swe-compliance/2026-08-08-private-automation-profile.md b/docs/swe-compliance/2026-08-08-private-automation-profile.md index 4d96a05..7d7af37 100644 --- a/docs/swe-compliance/2026-08-08-private-automation-profile.md +++ b/docs/swe-compliance/2026-08-08-private-automation-profile.md @@ -78,7 +78,7 @@ sandboxed run had only the expected localhost `EPERM` smoke-test failure; - build: passing; two builds produced identical SHA-256 hashes (`dist/index.cjs` = - `735395bb2d2e9fe6deaa9a61c8f3714c80c0da0ce546c8ba61a7f67a60c586c7`); + `b48ce66b742dbe4990939447500e2e0d6a236435964d90acc823053e72615e97`); - package dry-run: six expected package entries only; - RUDI debt scan, `pr-review` profile: zero findings; - integrated synthetic privacy tests: prompt absent from provider argv, @@ -92,8 +92,10 @@ - Claude 2.1.226: authenticated through the RUDI secret-mediated wrapper. A benign live provider probe with tools empty, nonessential traffic disabled, no fallback, simple prompt mode, and post-response schema validation - reported no tools and only `claude-sonnet-5` model usage. The installed RUDI - profile must repeat that probe after this source is packaged. + reported no tools and only `claude-sonnet-5` model usage. Its stream also + emits numeric-only `thinking_tokens` progress metadata, now closed-shape + allowlisted without persistence. The installed RUDI profile must repeat that + probe after this source is packaged. ## Phase 6: Docs, Contracts, And Closure diff --git a/src/__tests__/unit/agent-host-private-automation.test.js b/src/__tests__/unit/agent-host-private-automation.test.js index 0228579..baebb3b 100644 --- a/src/__tests__/unit/agent-host-private-automation.test.js +++ b/src/__tests__/unit/agent-host-private-automation.test.js @@ -370,6 +370,21 @@ describe('private Agent Host automation profile', () => { model: 'claude-sonnet-5', }, })); + assert.doesNotThrow(() => assertPrivateAutomationRawEvent('claude', { + estimated_tokens: 12, + estimated_tokens_delta: 3, + session_id: 'private-session-id', + subtype: 'thinking_tokens', + type: 'system', + uuid: 'private-event-id', + })); + assert.throws(() => assertPrivateAutomationRawEvent('claude', { + estimated_tokens: 12, + estimated_tokens_delta: 3, + message: privatePrompt, + subtype: 'thinking_tokens', + type: 'system', + }), /thinking-token metadata is invalid/u); assert.doesNotThrow(() => assertPrivateAutomationRawEvent('claude', { type: 'result', modelUsage: { 'claude-sonnet-5': { inputTokens: 1, outputTokens: 1 } }, diff --git a/src/agent-host/private-automation-profile.js b/src/agent-host/private-automation-profile.js index 83a33d2..674ab20 100644 --- a/src/agent-host/private-automation-profile.js +++ b/src/agent-host/private-automation-profile.js @@ -31,7 +31,15 @@ const PRIVATE_CODEX_DISABLED_CAPABILITY_DIAGNOSTIC = ( 'Code Mode is unavailable because code-mode host is disabled.' ); const PRIVATE_CLAUDE_ASSISTANT_BLOCK_TYPES = new Set(['text', 'thinking']); -const PRIVATE_CLAUDE_SYSTEM_SUBTYPES = new Set(['init']); +const PRIVATE_CLAUDE_SYSTEM_SUBTYPES = new Set(['init', 'thinking_tokens']); +const PRIVATE_CLAUDE_THINKING_TOKEN_KEYS = new Set([ + 'estimated_tokens', + 'estimated_tokens_delta', + 'session_id', + 'subtype', + 'type', + 'uuid', +]); const PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ 'apps', 'browser_use', @@ -243,6 +251,15 @@ export function assertPrivateAutomationRawEvent(provider, event, expectedModel = ) { throw new Error('private automation Claude init capabilities are not empty'); } + if (event.subtype === 'thinking_tokens' && ( + Object.keys(event).some(key => !PRIVATE_CLAUDE_THINKING_TOKEN_KEYS.has(key)) + || !Number.isFinite(event.estimated_tokens) + || event.estimated_tokens < 0 + || !Number.isFinite(event.estimated_tokens_delta) + || event.estimated_tokens_delta < 0 + )) { + throw new Error('private automation Claude thinking-token metadata is invalid'); + } } if (provider === 'claude' && event.type === 'assistant') { const message = event.message && typeof event.message === 'object' From ef80720b741b0aec715b4e7124856c683f43f29e Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 22:00:34 -0400 Subject: [PATCH 9/9] fix: validate private structured output Accept only plain JSON or one exact JSON fence, validate the parsed object against the caller's closed schema, and bound Claude synthetic control events without persisting content. --- dist/index.cjs | 70 +++++++-- docs/frontier-agent-hosts.md | 11 +- .../2026-08-08-private-automation-profile.md | 11 +- package.json | 1 + pnpm-lock.yaml | 3 + .../agent-host-private-automation.test.js | 137 ++++++++++++++++++ src/agent-host/events/stream.js | 30 +++- src/agent-host/private-automation-profile.js | 38 ++++- 8 files changed, 274 insertions(+), 27 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index e7f2784..88f5438 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -16792,7 +16792,7 @@ var require_core = __commonJS({ uriResolver }; } - var Ajv2 = class { + var Ajv3 = class { constructor(opts = {}) { this.schemas = {}; this.refs = {}; @@ -17162,9 +17162,9 @@ var require_core = __commonJS({ } } }; - Ajv2.ValidationError = validation_error_1.default; - Ajv2.MissingRefError = ref_error_1.default; - exports2.default = Ajv2; + Ajv3.ValidationError = validation_error_1.default; + Ajv3.MissingRefError = ref_error_1.default; + exports2.default = Ajv3; function checkOptions(checkOpts, options, msg, log = "error") { for (const key in checkOpts) { const opt = key; @@ -19266,7 +19266,7 @@ var require_ajv = __commonJS({ var draft7MetaSchema = require_json_schema_draft_07(); var META_SUPPORT_DATA = ["/properties"]; var META_SCHEMA_ID = "http://json-schema.org/draft-07/schema"; - var Ajv2 = class extends core_1.default { + var Ajv3 = class extends core_1.default { _addVocabularies() { super._addVocabularies(); draft7_1.default.forEach((v) => this.addVocabulary(v)); @@ -19285,11 +19285,11 @@ var require_ajv = __commonJS({ return this.opts.defaultMeta = super.defaultMeta() || (this.getSchema(META_SCHEMA_ID) ? META_SCHEMA_ID : void 0); } }; - exports2.Ajv = Ajv2; - module2.exports = exports2 = Ajv2; - module2.exports.Ajv = Ajv2; + exports2.Ajv = Ajv3; + module2.exports = exports2 = Ajv3; + module2.exports.Ajv = Ajv3; Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.default = Ajv2; + exports2.default = Ajv3; var validate_1 = require_validate(); Object.defineProperty(exports2, "KeywordCxt", { enumerable: true, get: function() { return validate_1.KeywordCxt; @@ -29886,6 +29886,7 @@ function renderAgentEvent(event) { var import_node_fs5 = __toESM(require("node:fs"), 1); var import_node_path4 = __toESM(require("node:path"), 1); var import_node_child_process2 = require("node:child_process"); +var import_ajv2 = __toESM(require_ajv(), 1); // src/agent-host/providers/catalog.js var import_node_fs4 = require("node:fs"); @@ -30954,7 +30955,7 @@ var PRIVATE_AUTOMATION_MAX_TIMEOUT_MS = 165e3; var PRIVATE_AUTOMATION_DEFAULT_TIMEOUT_MS = 16e4; var PRIVATE_PROVIDERS = /* @__PURE__ */ new Set(["claude", "codex"]); var PRIVATE_RAW_EVENT_TYPES = Object.freeze({ - claude: /* @__PURE__ */ new Set(["assistant", "error", "rate_limit_event", "result", "system"]), + claude: /* @__PURE__ */ new Set(["assistant", "error", "rate_limit_event", "result", "system", "user"]), codex: /* @__PURE__ */ new Set([ "error", "item.completed", @@ -30978,6 +30979,16 @@ var PRIVATE_CLAUDE_THINKING_TOKEN_KEYS = /* @__PURE__ */ new Set([ "type", "uuid" ]); +var PRIVATE_CLAUDE_SYNTHETIC_USER_KEYS = /* @__PURE__ */ new Set([ + "isSynthetic", + "message", + "parent_tool_use_id", + "session_id", + "timestamp", + "type", + "uuid" +]); +var PRIVATE_OUTPUT_SCHEMA_COMPILER = new import_ajv2.default({ allErrors: true, strict: false }); var PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ "apps", "browser_use", @@ -31052,10 +31063,17 @@ function readOutputSchema(outputSchemaPath) { if (containsSchemaReference(schema)) { throw new Error("external schema references are forbidden in private automation"); } + let validate; + try { + validate = PRIVATE_OUTPUT_SCHEMA_COMPILER.compile(schema); + } catch { + throw new Error("private automation output schema cannot be compiled"); + } return Object.freeze({ canonical: JSON.stringify(schema), path: import_node_fs5.default.realpathSync(requested), - schema: Object.freeze(schema) + schema: Object.freeze(schema), + validate }); } function exactConfiguredModel(provider, model) { @@ -31181,6 +31199,13 @@ function assertPrivateAutomationRawEvent(provider, event, expectedModel = null) throw new Error("private automation Claude content block is not allowlisted"); } } + if (provider === "claude" && event.type === "user") { + const content = Array.isArray(event.message?.content) ? event.message.content : []; + const textBytes = content.reduce((total, block) => total + (typeof block?.text === "string" ? Buffer.byteLength(block.text, "utf8") : 0), 0); + if (event.isSynthetic !== true || event.parent_tool_use_id != null || Object.keys(event).some((key) => !PRIVATE_CLAUDE_SYNTHETIC_USER_KEYS.has(key)) || event.message?.role !== "user" || content.length < 1 || content.length > 4 || content.some((block) => block?.type !== "text" || typeof block.text !== "string") || textBytes > 4096) { + throw new Error("private automation Claude synthetic user metadata is invalid"); + } + } if (provider === "claude" && event.type === "result" && expectedModel != null) { const observedModels = event.modelUsage && typeof event.modelUsage === "object" && !Array.isArray(event.modelUsage) ? Object.keys(event.modelUsage) : []; if (observedModels.length !== 1 || observedModels[0] !== expectedModel) { @@ -31333,6 +31358,21 @@ function writeLine(stream, value) { stream.write(value.endsWith("\n") ? value : `${value} `); } +function parsePrivateFinalOutput(value) { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + try { + return JSON.parse(trimmed); + } catch { + const fenced = trimmed.match(/^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/u); + if (!fenced) throw new Error("invalid"); + try { + return JSON.parse(fenced[1]); + } catch { + throw new Error("invalid"); + } + } +} function executeForegroundLaunch({ eventSink = null, jsonOutput = false, @@ -31539,7 +31579,7 @@ function executeForegroundLaunch({ lastError = "Private automation failed: private_model_unobserved"; } else if (status === "completed") { try { - const parsed = typeof privateFinalOutput === "string" ? JSON.parse(privateFinalOutput) : privateFinalOutput; + const parsed = parsePrivateFinalOutput(privateFinalOutput); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("not_object"); } @@ -31547,10 +31587,14 @@ function executeForegroundLaunch({ if (Buffer.byteLength(serialized, "utf8") > plan.maxFinalOutputBytes) { throw new Error("too_large"); } + if (!plan.privateAutomationProfile.outputSchema.validate(parsed)) { + throw new Error("schema"); + } privateFinalOutput = parsed; } catch (error) { status = "failed"; - lastError = `Private automation failed: ${error.message === "too_large" ? "private_final_output_overflow" : "private_final_output_invalid"}`; + const reason = error.message === "too_large" ? "private_final_output_overflow" : error.message === "schema" ? "private_final_output_schema_invalid" : "private_final_output_invalid"; + lastError = `Private automation failed: ${reason}`; } } else { lastError = timedOut ? "Private automation failed: private_timeout" : requestedSignal ? "Private automation failed: private_stopped" : "Private automation failed: private_provider_error"; diff --git a/docs/frontier-agent-hosts.md b/docs/frontier-agent-hosts.md index 1a5744a..20334e5 100644 --- a/docs/frontier-agent-hosts.md +++ b/docs/frontier-agent-hosts.md @@ -103,14 +103,17 @@ in its JSONL stream; Codex JSONL does not otherwise echo the selected model. Claude must report terminal model usage containing only the requested exact model. Missing or different Claude model identity fails closed. -Claude structured output is enforced by RUDI after the provider returns plain -JSON. The private profile deliberately does not pass Claude `--json-schema`, +Claude structured output is enforced by RUDI after the provider returns JSON. +RUDI accepts either plain JSON or exactly one JSON Markdown fence, rejects any +surrounding prose, and validates the parsed object against the caller's closed +schema. The private profile deliberately does not pass Claude `--json-schema`, because that CLI surface materializes a provider `StructuredOutput` tool. The launcher disables all Claude tools and nonessential/auxiliary model traffic, pins classifier and subagent model variables to the requested model, and rejects terminal model-usage metadata unless it names only that exact model. -Claude `thinking_tokens` progress events are accepted only as a closed numeric -metadata shape; their session identifiers and token estimates are not persisted. +Claude `thinking_tokens` progress and synthetic provider-control events are +accepted only as closed, bounded shapes; their content, session identifiers, +and token estimates are not persisted. Private use still requires an organization-approved provider/model egress contract and a synthetic no-tool launch for each exact installed provider and diff --git a/docs/swe-compliance/2026-08-08-private-automation-profile.md b/docs/swe-compliance/2026-08-08-private-automation-profile.md index 7d7af37..9fae673 100644 --- a/docs/swe-compliance/2026-08-08-private-automation-profile.md +++ b/docs/swe-compliance/2026-08-08-private-automation-profile.md @@ -57,12 +57,12 @@ ## Phase 4: Green Tests And Refactor - Status: complete for focused and adjacent regression suites. -- Focused result: 16/16 passing, including pre-egress provider capability +- Focused result: 18/18 passing, including pre-egress provider capability gating and argv/stdin/env/workspace/artifact/DB isolation, malformed output, closed provider event types, Claude missing/different observed model identity, contradictory Codex model fields, - tool events, process-group termination, raw/final - overflow, timeout, and forbidden command surfaces. + tool events, process-group termination, raw/final overflow, exact single-fence + parsing, schema rejection, timeout, and forbidden command surfaces. - Adjacent result: 42/42 passing across Agent Host command, launch, provider, provider-environment, workspace, and model suites. @@ -74,11 +74,12 @@ check, changed-file debt scan, package dry-run, argv/artifact/log privacy smoke tests, and exact provider probes with synthetic data. - Completed evidence: - - full test: 633/633 passing on the combined CLI 1.10.15 lineage outside the network-bind sandbox; the initial + - full test: 635/635 passing on the combined CLI 1.10.15 lineage outside the + network-bind sandbox; the initial sandboxed run had only the expected localhost `EPERM` smoke-test failure; - build: passing; two builds produced identical SHA-256 hashes (`dist/index.cjs` = - `b48ce66b742dbe4990939447500e2e0d6a236435964d90acc823053e72615e97`); + `e2b0986bb8f396ff829700bfa2b82bafdcdaa870bf7ca5d1f468ddf0bdac54c0`); - package dry-run: six expected package entries only; - RUDI debt scan, `pr-review` profile: zero findings; - integrated synthetic privacy tests: prompt absent from provider argv, diff --git a/package.json b/package.json index 23c97f2..1bbb094 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test": "node scripts/run-tests.js" }, "dependencies": { + "ajv": "^8.17.1", "better-sqlite3": "^12.5.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86fab8f..8c1f522 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + ajv: + specifier: ^8.17.1 + version: 8.17.1 better-sqlite3: specifier: ^12.5.0 version: 12.5.0 diff --git a/src/__tests__/unit/agent-host-private-automation.test.js b/src/__tests__/unit/agent-host-private-automation.test.js index baebb3b..978b112 100644 --- a/src/__tests__/unit/agent-host-private-automation.test.js +++ b/src/__tests__/unit/agent-host-private-automation.test.js @@ -385,6 +385,26 @@ describe('private Agent Host automation profile', () => { subtype: 'thinking_tokens', type: 'system', }), /thinking-token metadata is invalid/u); + assert.doesNotThrow(() => assertPrivateAutomationRawEvent('claude', { + isSynthetic: true, + message: { + content: [{ text: privatePrompt, type: 'text' }], + role: 'user', + }, + parent_tool_use_id: null, + session_id: 'private-session-id', + timestamp: '2026-08-08T00:00:00.000Z', + type: 'user', + uuid: 'private-event-id', + })); + assert.throws(() => assertPrivateAutomationRawEvent('claude', { + isSynthetic: false, + message: { + content: [{ text: privatePrompt, type: 'text' }], + role: 'user', + }, + type: 'user', + }), /synthetic user metadata is invalid/u); assert.doesNotThrow(() => assertPrivateAutomationRawEvent('claude', { type: 'result', modelUsage: { 'claude-sonnet-5': { inputTokens: 1, outputTokens: 1 } }, @@ -599,6 +619,107 @@ describe('private Agent Host automation profile', () => { } }); + test('validates a single fenced Claude JSON result without persisting provider content', async () => { + const { artifactsRoot, originDirectory, outputSchemaPath, root } = fixture(); + const profile = createPrivateAutomationProfile({ + model: 'claude-sonnet-5', + outputSchemaPath, + provider: 'claude', + timeoutMs: 160_000, + }); + const store = createLaunchStore({ databasePath: path.join(root, 'agent-hosts.db') }); + const stdout = memorySink(); + const fenced = '```json\n{"category":"conversation","schemaVersion":1}\n```'; + const spawnImpl = () => { + const child = new EventEmitter(); + child.pid = 9052; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => true; + child.stdin.once('finish', () => queueMicrotask(() => { + child.emit('spawn'); + for (const event of [ + { + mcp_servers: [], + model: 'claude-sonnet-5', + subtype: 'init', + tools: [], + type: 'system', + }, + { + estimated_tokens: 10, + estimated_tokens_delta: 2, + subtype: 'thinking_tokens', + type: 'system', + }, + { + isSynthetic: true, + message: { + content: [{ text: privatePrompt, type: 'text' }], + role: 'user', + }, + parent_tool_use_id: null, + type: 'user', + }, + { + message: { + content: [{ text: fenced, type: 'text' }], + model: 'claude-sonnet-5', + }, + type: 'assistant', + }, + { + modelUsage: { 'claude-sonnet-5': { inputTokens: 1, outputTokens: 1 } }, + result: fenced, + type: 'result', + usage: { input_tokens: 1, output_tokens: 1 }, + }, + ]) child.stdout.write(`${JSON.stringify(event)}\n`); + child.stdout.end(); + child.stderr.end(); + child.emit('close', 0, null); + })); + return child; + }; + try { + const launch = await launchAgent({ + json: true, + model: profile.model, + originDirectory, + permissionMode: 'plan', + privateAutomationProfile: profile, + prompt: privatePrompt, + provider: profile.provider, + timeoutMs: profile.timeoutMs, + workspaceMode: 'read-only', + }, { + artifactsRoot, + idFactory: () => 'launch_claude_fenced_json', + preflightImpl: async () => ({ authenticated: true, installed: true }), + privatePreflightImpl: async () => true, + resolveBinaryImpl: () => '/fake/claude', + spawnImpl, + stderr: memorySink().sink, + stdout: stdout.sink, + store, + }); + assert.equal(launch.status, 'completed'); + assert.deepEqual(JSON.parse(stdout.value()).output, { + category: 'conversation', + schemaVersion: 1, + }); + const persisted = fs.readFileSync( + getLaunchArtifactFiles(path.join(artifactsRoot, 'launch_claude_fenced_json')).events, + 'utf8', + ); + assert.equal(persisted.includes(privatePrompt), false); + assert.equal(persisted.includes('conversation'), false); + } finally { + store.close(); + } + }); + for (const [label, spawnOptions, expectedError] of [ ['malformed provider output', { malformed: true }, 'private_output_malformed'], ['provider tool execution', { tool: true }, 'private_tool_event'], @@ -714,6 +835,21 @@ describe('private Agent Host automation profile', () => { })}\n`); }, }, + { + expected: 'private_final_output_schema_invalid', + label: 'final structured output schema mismatch', + write(child) { + child.stdout.write(`${JSON.stringify({ + item: { + id: 'message-1', + model: 'gpt-5.6-luna', + text: JSON.stringify({ category: 'not-allowed', schemaVersion: 1 }), + type: 'agent_message', + }, + type: 'item.completed', + })}\n`); + }, + }, ]) { test(`fails closed on ${scenario.label}`, async () => { const { artifactsRoot, originDirectory, outputSchemaPath, root } = fixture(); @@ -738,6 +874,7 @@ describe('private Agent Host automation profile', () => { child.stderr.end(); child.emit('close', [ 'private_final_output_overflow', + 'private_final_output_schema_invalid', 'private_model_unobserved', ].includes(scenario.expected) ? 0 : 1, null); })); diff --git a/src/agent-host/events/stream.js b/src/agent-host/events/stream.js index 8e93a3e..dfa86a3 100644 --- a/src/agent-host/events/stream.js +++ b/src/agent-host/events/stream.js @@ -19,6 +19,22 @@ function writeLine(stream, value) { stream.write(value.endsWith('\n') ? value : `${value}\n`); } +function parsePrivateFinalOutput(value) { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + try { + return JSON.parse(trimmed); + } catch { + const fenced = trimmed.match(/^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/u); + if (!fenced) throw new Error('invalid'); + try { + return JSON.parse(fenced[1]); + } catch { + throw new Error('invalid'); + } + } +} + export function executeForegroundLaunch({ eventSink = null, jsonOutput = false, @@ -254,9 +270,7 @@ export function executeForegroundLaunch({ lastError = 'Private automation failed: private_model_unobserved'; } else if (status === 'completed') { try { - const parsed = typeof privateFinalOutput === 'string' - ? JSON.parse(privateFinalOutput) - : privateFinalOutput; + const parsed = parsePrivateFinalOutput(privateFinalOutput); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('not_object'); } @@ -264,10 +278,18 @@ export function executeForegroundLaunch({ if (Buffer.byteLength(serialized, 'utf8') > plan.maxFinalOutputBytes) { throw new Error('too_large'); } + if (!plan.privateAutomationProfile.outputSchema.validate(parsed)) { + throw new Error('schema'); + } privateFinalOutput = parsed; } catch (error) { status = 'failed'; - lastError = `Private automation failed: ${error.message === 'too_large' ? 'private_final_output_overflow' : 'private_final_output_invalid'}`; + const reason = error.message === 'too_large' + ? 'private_final_output_overflow' + : error.message === 'schema' + ? 'private_final_output_schema_invalid' + : 'private_final_output_invalid'; + lastError = `Private automation failed: ${reason}`; } } else { lastError = timedOut diff --git a/src/agent-host/private-automation-profile.js b/src/agent-host/private-automation-profile.js index 674ab20..db5b59e 100644 --- a/src/agent-host/private-automation-profile.js +++ b/src/agent-host/private-automation-profile.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import Ajv from 'ajv'; import { getModelDef, loadProviderConfig } from './providers/catalog.js'; @@ -14,7 +15,7 @@ export const PRIVATE_AUTOMATION_DEFAULT_TIMEOUT_MS = 160_000; const PRIVATE_PROVIDERS = new Set(['claude', 'codex']); const PRIVATE_RAW_EVENT_TYPES = Object.freeze({ - claude: new Set(['assistant', 'error', 'rate_limit_event', 'result', 'system']), + claude: new Set(['assistant', 'error', 'rate_limit_event', 'result', 'system', 'user']), codex: new Set([ 'error', 'item.completed', @@ -40,6 +41,16 @@ const PRIVATE_CLAUDE_THINKING_TOKEN_KEYS = new Set([ 'type', 'uuid', ]); +const PRIVATE_CLAUDE_SYNTHETIC_USER_KEYS = new Set([ + 'isSynthetic', + 'message', + 'parent_tool_use_id', + 'session_id', + 'timestamp', + 'type', + 'uuid', +]); +const PRIVATE_OUTPUT_SCHEMA_COMPILER = new Ajv({ allErrors: true, strict: false }); const PRIVATE_CODEX_DISABLED_FEATURES = Object.freeze([ 'apps', 'browser_use', @@ -118,10 +129,17 @@ function readOutputSchema(outputSchemaPath) { if (containsSchemaReference(schema)) { throw new Error('external schema references are forbidden in private automation'); } + let validate; + try { + validate = PRIVATE_OUTPUT_SCHEMA_COMPILER.compile(schema); + } catch { + throw new Error('private automation output schema cannot be compiled'); + } return Object.freeze({ canonical: JSON.stringify(schema), path: fs.realpathSync(requested), schema: Object.freeze(schema), + validate, }); } @@ -278,6 +296,24 @@ export function assertPrivateAutomationRawEvent(provider, event, expectedModel = throw new Error('private automation Claude content block is not allowlisted'); } } + if (provider === 'claude' && event.type === 'user') { + const content = Array.isArray(event.message?.content) ? event.message.content : []; + const textBytes = content.reduce((total, block) => ( + total + (typeof block?.text === 'string' ? Buffer.byteLength(block.text, 'utf8') : 0) + ), 0); + if ( + event.isSynthetic !== true + || event.parent_tool_use_id != null + || Object.keys(event).some(key => !PRIVATE_CLAUDE_SYNTHETIC_USER_KEYS.has(key)) + || event.message?.role !== 'user' + || content.length < 1 + || content.length > 4 + || content.some(block => block?.type !== 'text' || typeof block.text !== 'string') + || textBytes > 4096 + ) { + throw new Error('private automation Claude synthetic user metadata is invalid'); + } + } if (provider === 'claude' && event.type === 'result' && expectedModel != null) { const observedModels = event.modelUsage && typeof event.modelUsage === 'object' && !Array.isArray(event.modelUsage)