import crypto from "node:crypto"; import type { invokeNodeClaudeCliRun } from "../../gateway/node-agent-cli-runtime.js"; import { prepareNodeClaudeSkillRuntime } from "../../gateway/node-claude-skill-runtime.js"; import { createAbortError } from "../../infra/abort-signal.js"; import type { ExecAsk, ExecSecurity, SystemRunApprovalPlan } from "../../infra/exec-approvals.js"; import type { RunExit } from "../../process/supervisor/types.js"; import type { registerExecApprovalRequestForHostOrThrow, resolveRegisteredExecApprovalDecision, } from "../bash-tools.exec-approval-request.js"; import { createCliRunCurrentAssertion } from "./execution-target.js"; import type { NodeClaudePlacement, PreparedCliRunContext } from "./types.js"; const NODE_CLI_MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000; const NODE_CLI_MAX_IDLE_TIMEOUT_MS = 30 * 60 * 1000; const NODE_CLI_OMIT_BARE_ARGS = new Set([ "--strict-mcp-config", // The Gateway's version probe says nothing about the paired node's Claude binary. // Omit the optimization instead of making an older node reject every turn. "--exclude-dynamic-system-prompt-sections", ]); const NODE_CLI_OMIT_VALUE_ARGS = new Set([ "--permission-mode", "--plugin-dir", "--plugin-dir-no-mcp", ]); // --tools and --disallowedTools stay: they carry the gateway's native tool // policy onto the node. --allowedTools is stripped because Claude treats it as // auto-approval, which must never cross the node's own approval boundary. const NODE_CLI_OMIT_VARIADIC_ARGS = new Set(["--mcp-config", "--allowedTools", "--allowed-tools"]); /** Remove Gateway-local file, plugin, MCP, and allow-list arguments. */ export function stripGatewayLocalClaudeArgs(args: readonly string[]): string[] { const result: string[] = []; for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; const equalsIndex = arg.indexOf("="); const name = equalsIndex > 0 ? arg.slice(0, equalsIndex) : arg; if (NODE_CLI_OMIT_BARE_ARGS.has(name)) { continue; } if (NODE_CLI_OMIT_VALUE_ARGS.has(name)) { if (equalsIndex < 0) { index += 1; } continue; } if (NODE_CLI_OMIT_VARIADIC_ARGS.has(name)) { if (equalsIndex < 0) { while (typeof args[index + 1] === "string" && !args[index + 1]?.startsWith("-")) { index += 1; } } continue; } result.push(arg); } return result; } function parseNodeClaudeResultPayload(result: { payload?: unknown; payloadJSON?: string | null }): { exitCode: number; stderrTail: string; truncated: boolean; timeoutKind?: "hard" | "idle"; } { const value = result.payloadJSON ? (JSON.parse(result.payloadJSON) as unknown) : result.payload; if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("paired node returned an invalid Claude CLI result"); } const record = value as Record; if ( !Number.isInteger(record.exitCode) || typeof record.stderrTail !== "string" || typeof record.truncated !== "boolean" || (record.timeoutKind !== undefined && record.timeoutKind !== "hard" && record.timeoutKind !== "idle") ) { throw new Error("paired node returned an invalid Claude CLI result"); } return { exitCode: record.exitCode as number, stderrTail: record.stderrTail, truncated: record.truncated, ...(record.timeoutKind ? { timeoutKind: record.timeoutKind as "hard" | "idle" } : {}), }; } type NodeClaudeApprovalRequired = { systemRunPlan: SystemRunApprovalPlan; security: ExecSecurity; ask: ExecAsk; }; function parseNodeClaudeApprovalRequired(result: { ok: boolean; payload?: unknown; payloadJSON?: string | null; }): NodeClaudeApprovalRequired | null { if (!result.ok) { return null; } const value = result.payloadJSON ? (JSON.parse(result.payloadJSON) as unknown) : result.payload; if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const record = value as Record; if ( record.approvalRequired !== true || !record.systemRunPlan || typeof record.systemRunPlan !== "object" || Array.isArray(record.systemRunPlan) || (record.security !== "deny" && record.security !== "allowlist" && record.security !== "full") || (record.ask !== "off" && record.ask !== "on-miss" && record.ask !== "always") ) { return null; } return { systemRunPlan: record.systemRunPlan as SystemRunApprovalPlan, security: record.security, ask: record.ask, }; } export function createCliAbortError(): Error { return createAbortError("CLI run aborted"); } async function waitForNodeOperation(params: { operation: Promise; signal?: AbortSignal; }): Promise { if (!params.signal) { return await params.operation; } if (params.signal.aborted) { throw createCliAbortError(); } return await new Promise((resolve, reject) => { const onAbort = () => reject(createCliAbortError()); params.signal?.addEventListener("abort", onAbort, { once: true }); void params.operation.then(resolve, reject).finally(() => { params.signal?.removeEventListener("abort", onAbort); }); }); } type ExecuteNodeClaudeRunDeps = { invokeNodeClaudeCliRun: typeof invokeNodeClaudeCliRun; registerExecApprovalRequestForHostOrThrow: typeof registerExecApprovalRequestForHostOrThrow; resolveRegisteredExecApprovalDecision: typeof resolveRegisteredExecApprovalDecision; }; export async function executeNodeClaudeRun(params: { context: PreparedCliRunContext; nodePlacement: NodeClaudePlacement; executionArgs: string[]; stdinPayload: string; nodeSystemPrompt?: string; nodeEnv?: Record; nodeClearEnv?: string[]; noOutputTimeoutMs: number; consumeStdout: (chunk: string) => void; consumeStderr: (chunk: string) => void; deps: ExecuteNodeClaudeRunDeps; }): Promise<{ result: RunExit; nodeRunAbortSignal: AbortSignal; nodeRunTruncated: boolean; }> { const contextParams = params.context.params; const startedAt = Date.now(); const hardTimeoutMs = Math.min(contextParams.timeoutMs, NODE_CLI_MAX_TIMEOUT_MS); const hardDeadlineAt = startedAt + hardTimeoutMs; const nodeAbortController = new AbortController(); const nodeRunAbortSignal = nodeAbortController.signal; const assertCurrent = createCliRunCurrentAssertion(contextParams, nodeRunAbortSignal); let hardDeadlineReached = false; const hardDeadlineTimer = setTimeout(() => { hardDeadlineReached = true; nodeAbortController.abort(); }, hardTimeoutMs); const abortNodeRun = () => nodeAbortController.abort(); contextParams.abortSignal?.addEventListener("abort", abortNodeRun, { once: true }); if (contextParams.abortSignal?.aborted) { abortNodeRun(); } const replyBackendHandle = contextParams.replyOperation ? { kind: "cli" as const, runId: contextParams.runId, toolAuthorityFingerprint: contextParams.toolAuthorityFingerprint, cancel: abortNodeRun, } : undefined; if (replyBackendHandle) { contextParams.replyOperation?.attachBackend(replyBackendHandle); } let nodeResult: Awaited>; let skillRuntime: Awaited>; try { assertCurrent(); skillRuntime = await prepareNodeClaudeSkillRuntime(params.context, nodeRunAbortSignal); const invokeNode = async (approval?: { decision: "allow-once" | "allow-always"; plan: SystemRunApprovalPlan; }) => { const remainingTimeoutMs = hardDeadlineAt - Date.now(); if (remainingTimeoutMs <= 0) { hardDeadlineReached = true; nodeAbortController.abort(); return { ok: false, error: { code: "TIMEOUT", message: "paired-node Claude CLI invocation exceeded its hard timeout", }, }; } assertCurrent(); return await params.deps.invokeNodeClaudeCliRun({ assertCurrent, nodeId: params.nodePlacement.nodeId, argv: params.executionArgs, stdin: params.stdinPayload, ...(params.nodePlacement.cwd ? { cwd: params.nodePlacement.cwd } : {}), ...(params.nodeSystemPrompt !== undefined ? { systemPrompt: params.nodeSystemPrompt } : {}), ...(params.nodeEnv ? { env: params.nodeEnv } : {}), ...(params.nodeClearEnv ? { clearEnv: params.nodeClearEnv } : {}), ...(contextParams.agentId ? { agentId: contextParams.agentId } : {}), ...(contextParams.sessionKey ? { sessionKey: contextParams.sessionKey } : {}), ...(approval ? { approvalDecision: approval.decision, systemRunPlan: approval.plan, } : {}), timeoutMs: remainingTimeoutMs, idleTimeoutMs: Math.max( 1_000, Math.min(params.noOutputTimeoutMs, NODE_CLI_MAX_IDLE_TIMEOUT_MS), ), onProgress: params.consumeStdout, signal: skillRuntime?.signal ?? nodeAbortController.signal, ...(skillRuntime ? { skillRuntime } : {}), }); }; nodeResult = await invokeNode(); const approval = parseNodeClaudeApprovalRequired(nodeResult); if (approval) { skillRuntime?.assertCurrent(); const approvalId = crypto.randomUUID(); const registration = await waitForNodeOperation({ operation: params.deps.registerExecApprovalRequestForHostOrThrow({ approvalId, command: approval.systemRunPlan.commandText, commandArgv: approval.systemRunPlan.argv, systemRunPlan: approval.systemRunPlan, workdir: approval.systemRunPlan.cwd ?? undefined, host: "node", nodeId: params.nodePlacement.nodeId, security: approval.security, ask: approval.ask, unavailableDecisions: ["allow-always"], agentId: contextParams.agentId, sessionKey: contextParams.sessionKey, ...(contextParams.approvalReviewerDeviceId ? { approvalReviewerDeviceIds: [contextParams.approvalReviewerDeviceId] } : {}), }), signal: skillRuntime?.signal ?? nodeAbortController.signal, }); const decision = await waitForNodeOperation({ operation: params.deps.resolveRegisteredExecApprovalDecision({ approvalId: registration.id, preResolvedDecision: registration.finalDecision, }), signal: skillRuntime?.signal ?? nodeAbortController.signal, }); if (decision === "allow-once" || decision === "allow-always") { nodeResult = await invokeNode({ decision, plan: approval.systemRunPlan }); } else { nodeResult = { ok: false, error: { code: "PERMISSION_DENIED", message: "paired-node Claude CLI agent run was not approved", }, }; } } } catch (error) { if (!hardDeadlineReached) { throw error; } nodeResult = { ok: false, error: { code: "TIMEOUT", message: "paired-node Claude CLI invocation exceeded its hard timeout", }, }; } finally { if (skillRuntime?.signal.aborted) { nodeAbortController.abort(); } skillRuntime?.close(); clearTimeout(hardDeadlineTimer); if (replyBackendHandle) { contextParams.replyOperation?.detachBackend(replyBackendHandle); } contextParams.abortSignal?.removeEventListener("abort", abortNodeRun); } if (hardDeadlineReached) { nodeResult = { ok: false, error: { code: "TIMEOUT", message: "paired-node Claude CLI invocation exceeded its hard timeout", }, }; } if (!nodeResult.ok) { const code = nodeResult.error?.code; const timedOut = code === "TIMEOUT" || code === "IDLE_TIMEOUT"; const result: RunExit = { reason: code === "IDLE_TIMEOUT" ? "no-output-timeout" : code === "TIMEOUT" ? "overall-timeout" : code === "ABORTED" ? "manual-cancel" : "exit", exitCode: timedOut || code === "ABORTED" ? null : 1, exitSignal: null, durationMs: Date.now() - startedAt, stdout: "", stderr: nodeResult.error?.message ?? "paired-node Claude CLI invocation failed", timedOut, noOutputTimedOut: code === "IDLE_TIMEOUT", }; params.consumeStderr(result.stderr); return { result, nodeRunAbortSignal, nodeRunTruncated: false }; } const payload = parseNodeClaudeResultPayload(nodeResult); if (payload.stderrTail) { params.consumeStderr(payload.stderrTail); } const result: RunExit = { reason: payload.timeoutKind === "idle" ? "no-output-timeout" : payload.timeoutKind === "hard" ? "overall-timeout" : "exit", exitCode: payload.timeoutKind ? null : payload.exitCode, exitSignal: null, durationMs: Date.now() - startedAt, stdout: "", stderr: payload.stderrTail, timedOut: payload.timeoutKind !== undefined, noOutputTimedOut: payload.timeoutKind === "idle", }; return { result, nodeRunAbortSignal, nodeRunTruncated: payload.truncated }; }