From c2c57c5fd1eb8b3cfd94de897b6a8c27846acdb9 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 20:45:50 -0400 Subject: [PATCH 1/7] 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 c28797c..e5f6928 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": @@ -15992,8 +15992,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; @@ -16192,8 +16192,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; } @@ -19546,12 +19546,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 }); @@ -22295,11 +22295,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({ @@ -29074,14 +29074,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 = {}; @@ -29800,1027 +29800,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}}"] }, @@ -31377,125 +30745,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", @@ -31516,7 +31978,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`); } @@ -31528,13 +31990,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) { @@ -31550,10 +32031,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 }; } @@ -31574,24 +32056,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) @@ -31602,10 +32107,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, @@ -31637,6 +32149,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"); @@ -31669,6 +32218,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); @@ -31698,8 +32288,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"; @@ -31711,9 +32301,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 { @@ -31795,15 +32385,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; } @@ -31812,10 +32402,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); @@ -31858,35 +32448,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" }; @@ -31901,7 +32491,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 { @@ -31913,24 +32503,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}`); } @@ -31974,21 +32564,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 { @@ -32031,7 +32621,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, @@ -32045,7 +32635,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, @@ -32062,15 +32652,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}`); } @@ -32081,7 +32671,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}`); } } @@ -32092,23 +32682,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; @@ -32117,17 +32711,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, @@ -32137,12 +32735,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({ @@ -32153,14 +32751,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"); } @@ -32183,7 +32782,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 @@ -32198,6 +32797,7 @@ async function launchAgent(options, dependencies = {}) { ownerPid = null, onSpawn = null, preflightImpl = assertAgentHostReady, + privatePreflightImpl = assertPrivateAutomationHostCapabilities, resolveBinaryImpl = resolveAgentProviderBinary, spawnImpl, stderr = process.stderr, @@ -32206,18 +32806,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( @@ -32234,6 +32842,7 @@ async function launchAgent(options, dependencies = {}) { images: options.images, model: options.model, permissionMode: options.permissionMode, + privateAutomationProfile, prompt: options.prompt, provider, runtimeDirectory: workspace.outputDestination, @@ -32288,11 +32897,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}`); @@ -32336,11 +32945,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, @@ -32363,7 +32972,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({ @@ -32421,13 +33030,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); @@ -32438,7 +33047,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() === "") { @@ -32587,11 +33196,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", @@ -32656,9 +33265,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) { @@ -32671,7 +33280,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", @@ -32686,16 +33295,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; @@ -32720,7 +33329,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, [ @@ -32756,19 +33365,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}`); } @@ -32782,7 +33391,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", @@ -32802,33 +33411,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 @@ -32842,13 +33451,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"); @@ -32879,7 +33488,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; } @@ -32895,7 +33504,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), @@ -32923,7 +33532,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, [ @@ -32949,7 +33558,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}`); } } @@ -32972,8 +33581,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); @@ -32984,7 +33593,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"); @@ -32997,9 +33606,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 }; }); @@ -33008,7 +33617,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`); } @@ -33028,7 +33637,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 })); @@ -33042,8 +33651,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; @@ -33104,7 +33713,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", @@ -33205,7 +33814,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"), @@ -33275,7 +33884,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) @@ -33503,7 +34112,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(); @@ -33575,7 +34184,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; @@ -33720,7 +34329,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(); @@ -33736,7 +34345,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 { } } @@ -35325,9 +35934,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; @@ -35355,11 +35964,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 { } } @@ -35381,13 +35990,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, @@ -35398,11 +36007,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 { } } @@ -35985,8 +36594,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); @@ -35997,14 +36606,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"); } @@ -36016,6 +36625,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"); } @@ -36024,25 +36637,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; } @@ -36060,10 +36677,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}`); } @@ -36081,6 +36698,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, @@ -36130,16 +36796,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 }; @@ -36230,6 +36896,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. `); @@ -36264,6 +36936,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 8127a56f3bab29759ca8420a50d58f21a2feb88e Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 21:14:29 -0400 Subject: [PATCH 2/7] 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 e5f6928..41e3f40 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -29819,6 +29819,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", @@ -31055,7 +31056,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"); } @@ -31080,6 +31081,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"); } @@ -31120,16 +31127,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", @@ -31137,7 +31176,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"], { @@ -31159,7 +31198,6 @@ function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, depend const requiredHelp = [ "--disable-slash-commands", "--input-format", - "--json-schema", "--mcp-config", "--no-chrome", "--no-session-persistence", @@ -31281,10 +31319,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; } @@ -32168,8 +32206,6 @@ function buildClaudePlan(options) { "text", "--model", context.model, - "--json-schema", - context.privateAutomationProfile.outputSchema.canonical, "--no-session-persistence", "--safe-mode", "--no-chrome", @@ -32184,7 +32220,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 301235857cc4f3378731407d696a9a098c88a754 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 21:19:09 -0400 Subject: [PATCH 3/7] 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 41e3f40..b9a54a3 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -31156,27 +31156,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"], { @@ -31193,20 +31193,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 25541d1cf734e5748c2f90dc64704e2f67f330a8 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 21:39:45 -0400 Subject: [PATCH 4/7] 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 b9a54a3..4a04250 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -30157,6 +30157,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" @@ -30172,7 +30173,7 @@ var codex_default = { promptDelivery: "arg", stdinPrompt: "-", privateAutomation: { - minimumVersion: "0.146.0", + minimumVersion: "0.147.0", profile: "private-automation-v1", promptDelivery: "stdin", sessionPersistence: false, @@ -30884,6 +30885,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([ @@ -30904,7 +30906,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]; @@ -31063,8 +31066,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)) { @@ -31135,8 +31142,6 @@ function assertPrivateAutomationHostCapabilities({ binaryPath, profile }, depend "mcp_servers={}", "-c", 'web_search="disabled"', - "-c", - "tools.view_image=false", "exec", "-", "--json", @@ -31265,7 +31270,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) { @@ -32306,8 +32311,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 af018259e6571d80b1c0379c4d2afc1af7105db5 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 21:51:15 -0400 Subject: [PATCH 5/7] 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 4a04250..5732ca8 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -30887,7 +30887,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", @@ -31080,6 +31088,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 586fb4684988bbc39b71e36ddd602b99a5c51055 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 8 Aug 2026 22:00:34 -0400 Subject: [PATCH 6/7] 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 5732ca8..8a171de 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -16710,7 +16710,7 @@ var require_core = __commonJS({ uriResolver }; } - var Ajv2 = class { + var Ajv3 = class { constructor(opts = {}) { this.schemas = {}; this.refs = {}; @@ -17080,9 +17080,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; @@ -19184,7 +19184,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)); @@ -19203,11 +19203,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; @@ -29804,6 +29804,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"); @@ -30872,7 +30873,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", @@ -30896,6 +30897,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", @@ -30970,10 +30981,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) { @@ -31099,6 +31117,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) { @@ -31251,6 +31276,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, @@ -31457,7 +31497,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"); } @@ -31465,10 +31505,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 82293cb..3c8970e 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) From f7518b3f331bee45d25e2a9e1b18afb154f0e97a Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 9 Aug 2026 00:02:06 -0400 Subject: [PATCH 7/7] build: finalize clean private automation release --- dist/index.cjs | 2 +- docs/swe-compliance/2026-08-08-private-automation-profile.md | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dist/index.cjs b/dist/index.cjs index 8a171de..d55116a 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -37285,7 +37285,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."], 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 9fae673..7364143 100644 --- a/docs/swe-compliance/2026-08-08-private-automation-profile.md +++ b/docs/swe-compliance/2026-08-08-private-automation-profile.md @@ -74,12 +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: 635/635 passing on the combined CLI 1.10.15 lineage outside the + - full test: 633/633 passing on the clean 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` = - `e2b0986bb8f396ff829700bfa2b82bafdcdaa870bf7ca5d1f468ddf0bdac54c0`); + `1897563de9ed112628979edc49de9f2f8e3bb875a64262e40df8f2116773faeb`); - 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 3c8970e..1bbb094 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",