| 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", |
| |
| |
| "--exclude-dynamic-system-prompt-sections", |
| ]); |
| const NODE_CLI_OMIT_VALUE_ARGS = new Set([ |
| "--permission-mode", |
| "--plugin-dir", |
| "--plugin-dir-no-mcp", |
| ]); |
| |
| |
| |
| const NODE_CLI_OMIT_VARIADIC_ARGS = new Set(["--mcp-config", "--allowedTools", "--allowed-tools"]); |
|
|
| |
| 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<string, unknown>; |
| 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<string, unknown>; |
| 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<T>(params: { |
| operation: Promise<T>; |
| signal?: AbortSignal; |
| }): Promise<T> { |
| if (!params.signal) { |
| return await params.operation; |
| } |
| if (params.signal.aborted) { |
| throw createCliAbortError(); |
| } |
| return await new Promise<T>((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<string, string>; |
| 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<ReturnType<typeof invokeNodeClaudeCliRun>>; |
| let skillRuntime: Awaited<ReturnType<typeof prepareNodeClaudeSkillRuntime>>; |
| 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 }; |
| } |
|
|