Spaces:
Paused
Paused
| import { spawn } from "node:child_process"; | |
| import crypto from "node:crypto"; | |
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import { resolveAgentConfig } from "../agents/agent-scope.js"; | |
| import { loadConfig } from "../config/config.js"; | |
| import { GatewayClient } from "../gateway/client.js"; | |
| import { | |
| addAllowlistEntry, | |
| analyzeArgvCommand, | |
| evaluateExecAllowlist, | |
| evaluateShellAllowlist, | |
| requiresExecApproval, | |
| normalizeExecApprovals, | |
| recordAllowlistUse, | |
| resolveExecApprovals, | |
| resolveSafeBins, | |
| ensureExecApprovals, | |
| readExecApprovalsSnapshot, | |
| resolveExecApprovalsSocketPath, | |
| saveExecApprovals, | |
| type ExecAsk, | |
| type ExecApprovalsFile, | |
| type ExecAllowlistEntry, | |
| type ExecCommandSegment, | |
| type ExecSecurity, | |
| } from "../infra/exec-approvals.js"; | |
| import { | |
| requestExecHostViaSocket, | |
| type ExecHostRequest, | |
| type ExecHostResponse, | |
| type ExecHostRunResult, | |
| } from "../infra/exec-host.js"; | |
| import { runBrowserProxyCommand } from "./invoke-browser.js"; | |
| const OUTPUT_CAP = 200_000; | |
| const OUTPUT_EVENT_TAIL = 20_000; | |
| const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | |
| const execHostEnforced = process.env.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase() === "app"; | |
| const execHostFallbackAllowed = | |
| process.env.OPENCLAW_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0"; | |
| const blockedEnvKeys = new Set([ | |
| "NODE_OPTIONS", | |
| "PYTHONHOME", | |
| "PYTHONPATH", | |
| "PERL5LIB", | |
| "PERL5OPT", | |
| "RUBYOPT", | |
| ]); | |
| const blockedEnvPrefixes = ["DYLD_", "LD_"]; | |
| type SystemRunParams = { | |
| command: string[]; | |
| rawCommand?: string | null; | |
| cwd?: string | null; | |
| env?: Record<string, string>; | |
| timeoutMs?: number | null; | |
| needsScreenRecording?: boolean | null; | |
| agentId?: string | null; | |
| sessionKey?: string | null; | |
| approved?: boolean | null; | |
| approvalDecision?: string | null; | |
| runId?: string | null; | |
| }; | |
| type SystemWhichParams = { | |
| bins: string[]; | |
| }; | |
| type SystemExecApprovalsSetParams = { | |
| file: ExecApprovalsFile; | |
| baseHash?: string | null; | |
| }; | |
| type ExecApprovalsSnapshot = { | |
| path: string; | |
| exists: boolean; | |
| hash: string; | |
| file: ExecApprovalsFile; | |
| }; | |
| type RunResult = { | |
| exitCode?: number; | |
| timedOut: boolean; | |
| success: boolean; | |
| stdout: string; | |
| stderr: string; | |
| error?: string | null; | |
| truncated: boolean; | |
| }; | |
| type ExecEventPayload = { | |
| sessionKey: string; | |
| runId: string; | |
| host: string; | |
| command?: string; | |
| exitCode?: number; | |
| timedOut?: boolean; | |
| success?: boolean; | |
| output?: string; | |
| reason?: string; | |
| }; | |
| export type NodeInvokeRequestPayload = { | |
| id: string; | |
| nodeId: string; | |
| command: string; | |
| paramsJSON?: string | null; | |
| timeoutMs?: number | null; | |
| idempotencyKey?: string | null; | |
| }; | |
| export type SkillBinsProvider = { | |
| current(force?: boolean): Promise<Set<string>>; | |
| }; | |
| function resolveExecSecurity(value?: string): ExecSecurity { | |
| return value === "deny" || value === "allowlist" || value === "full" ? value : "allowlist"; | |
| } | |
| function isCmdExeInvocation(argv: string[]): boolean { | |
| const token = argv[0]?.trim(); | |
| if (!token) { | |
| return false; | |
| } | |
| const base = path.win32.basename(token).toLowerCase(); | |
| return base === "cmd.exe" || base === "cmd"; | |
| } | |
| function resolveExecAsk(value?: string): ExecAsk { | |
| return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss"; | |
| } | |
| function sanitizeEnv( | |
| overrides?: Record<string, string> | null, | |
| ): Record<string, string> | undefined { | |
| if (!overrides) { | |
| return undefined; | |
| } | |
| const merged = { ...process.env } as Record<string, string>; | |
| const basePath = process.env.PATH ?? DEFAULT_NODE_PATH; | |
| for (const [rawKey, value] of Object.entries(overrides)) { | |
| const key = rawKey.trim(); | |
| if (!key) { | |
| continue; | |
| } | |
| const upper = key.toUpperCase(); | |
| if (upper === "PATH") { | |
| const trimmed = value.trim(); | |
| if (!trimmed) { | |
| continue; | |
| } | |
| if (!basePath || trimmed === basePath) { | |
| merged[key] = trimmed; | |
| continue; | |
| } | |
| const suffix = `${path.delimiter}${basePath}`; | |
| if (trimmed.endsWith(suffix)) { | |
| merged[key] = trimmed; | |
| } | |
| continue; | |
| } | |
| if (blockedEnvKeys.has(upper)) { | |
| continue; | |
| } | |
| if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) { | |
| continue; | |
| } | |
| merged[key] = value; | |
| } | |
| return merged; | |
| } | |
| function formatCommand(argv: string[]): string { | |
| return argv | |
| .map((arg) => { | |
| const trimmed = arg.trim(); | |
| if (!trimmed) { | |
| return '""'; | |
| } | |
| const needsQuotes = /\s|"/.test(trimmed); | |
| if (!needsQuotes) { | |
| return trimmed; | |
| } | |
| return `"${trimmed.replace(/"/g, '\\"')}"`; | |
| }) | |
| .join(" "); | |
| } | |
| function truncateOutput(raw: string, maxChars: number): { text: string; truncated: boolean } { | |
| if (raw.length <= maxChars) { | |
| return { text: raw, truncated: false }; | |
| } | |
| return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true }; | |
| } | |
| function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { | |
| const socketPath = file.socket?.path?.trim(); | |
| return { | |
| ...file, | |
| socket: socketPath ? { path: socketPath } : undefined, | |
| }; | |
| } | |
| function requireExecApprovalsBaseHash( | |
| params: SystemExecApprovalsSetParams, | |
| snapshot: ExecApprovalsSnapshot, | |
| ) { | |
| if (!snapshot.exists) { | |
| return; | |
| } | |
| if (!snapshot.hash) { | |
| throw new Error("INVALID_REQUEST: exec approvals base hash unavailable; reload and retry"); | |
| } | |
| const baseHash = typeof params.baseHash === "string" ? params.baseHash.trim() : ""; | |
| if (!baseHash) { | |
| throw new Error("INVALID_REQUEST: exec approvals base hash required; reload and retry"); | |
| } | |
| if (baseHash !== snapshot.hash) { | |
| throw new Error("INVALID_REQUEST: exec approvals changed; reload and retry"); | |
| } | |
| } | |
| async function runCommand( | |
| argv: string[], | |
| cwd: string | undefined, | |
| env: Record<string, string> | undefined, | |
| timeoutMs: number | undefined, | |
| ): Promise<RunResult> { | |
| return await new Promise((resolve) => { | |
| let stdout = ""; | |
| let stderr = ""; | |
| let outputLen = 0; | |
| let truncated = false; | |
| let timedOut = false; | |
| let settled = false; | |
| const child = spawn(argv[0], argv.slice(1), { | |
| cwd, | |
| env, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| windowsHide: true, | |
| }); | |
| const onChunk = (chunk: Buffer, target: "stdout" | "stderr") => { | |
| if (outputLen >= OUTPUT_CAP) { | |
| truncated = true; | |
| return; | |
| } | |
| const remaining = OUTPUT_CAP - outputLen; | |
| const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk; | |
| const str = slice.toString("utf8"); | |
| outputLen += slice.length; | |
| if (target === "stdout") { | |
| stdout += str; | |
| } else { | |
| stderr += str; | |
| } | |
| if (chunk.length > remaining) { | |
| truncated = true; | |
| } | |
| }; | |
| child.stdout?.on("data", (chunk) => onChunk(chunk as Buffer, "stdout")); | |
| child.stderr?.on("data", (chunk) => onChunk(chunk as Buffer, "stderr")); | |
| let timer: NodeJS.Timeout | undefined; | |
| if (timeoutMs && timeoutMs > 0) { | |
| timer = setTimeout(() => { | |
| timedOut = true; | |
| try { | |
| child.kill("SIGKILL"); | |
| } catch { | |
| // ignore | |
| } | |
| }, timeoutMs); | |
| } | |
| const finalize = (exitCode?: number, error?: string | null) => { | |
| if (settled) { | |
| return; | |
| } | |
| settled = true; | |
| if (timer) { | |
| clearTimeout(timer); | |
| } | |
| resolve({ | |
| exitCode, | |
| timedOut, | |
| success: exitCode === 0 && !timedOut && !error, | |
| stdout, | |
| stderr, | |
| error: error ?? null, | |
| truncated, | |
| }); | |
| }; | |
| child.on("error", (err) => { | |
| finalize(undefined, err.message); | |
| }); | |
| child.on("exit", (code) => { | |
| finalize(code === null ? undefined : code, null); | |
| }); | |
| }); | |
| } | |
| function resolveEnvPath(env?: Record<string, string>): string[] { | |
| const raw = | |
| env?.PATH ?? | |
| (env as Record<string, string>)?.Path ?? | |
| process.env.PATH ?? | |
| process.env.Path ?? | |
| DEFAULT_NODE_PATH; | |
| return raw.split(path.delimiter).filter(Boolean); | |
| } | |
| function resolveExecutable(bin: string, env?: Record<string, string>) { | |
| if (bin.includes("/") || bin.includes("\\")) { | |
| return null; | |
| } | |
| const extensions = | |
| process.platform === "win32" | |
| ? (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM") | |
| .split(";") | |
| .map((ext) => ext.toLowerCase()) | |
| : [""]; | |
| for (const dir of resolveEnvPath(env)) { | |
| for (const ext of extensions) { | |
| const candidate = path.join(dir, bin + ext); | |
| if (fs.existsSync(candidate)) { | |
| return candidate; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| async function handleSystemWhich(params: SystemWhichParams, env?: Record<string, string>) { | |
| const bins = params.bins.map((bin) => bin.trim()).filter(Boolean); | |
| const found: Record<string, string> = {}; | |
| for (const bin of bins) { | |
| const path = resolveExecutable(bin, env); | |
| if (path) { | |
| found[bin] = path; | |
| } | |
| } | |
| return { bins: found }; | |
| } | |
| function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload { | |
| if (!payload.output) { | |
| return payload; | |
| } | |
| const trimmed = payload.output.trim(); | |
| if (!trimmed) { | |
| return payload; | |
| } | |
| const { text } = truncateOutput(trimmed, OUTPUT_EVENT_TAIL); | |
| return { ...payload, output: text }; | |
| } | |
| async function runViaMacAppExecHost(params: { | |
| approvals: ReturnType<typeof resolveExecApprovals>; | |
| request: ExecHostRequest; | |
| }): Promise<ExecHostResponse | null> { | |
| const { approvals, request } = params; | |
| return await requestExecHostViaSocket({ | |
| socketPath: approvals.socketPath, | |
| token: approvals.token, | |
| request, | |
| }); | |
| } | |
| export async function handleInvoke( | |
| frame: NodeInvokeRequestPayload, | |
| client: GatewayClient, | |
| skillBins: SkillBinsProvider, | |
| ) { | |
| const command = String(frame.command ?? ""); | |
| if (command === "system.execApprovals.get") { | |
| try { | |
| ensureExecApprovals(); | |
| const snapshot = readExecApprovalsSnapshot(); | |
| const payload: ExecApprovalsSnapshot = { | |
| path: snapshot.path, | |
| exists: snapshot.exists, | |
| hash: snapshot.hash, | |
| file: redactExecApprovals(snapshot.file), | |
| }; | |
| await sendInvokeResult(client, frame, { | |
| ok: true, | |
| payloadJSON: JSON.stringify(payload), | |
| }); | |
| } catch (err) { | |
| const message = String(err); | |
| const code = message.toLowerCase().includes("timed out") ? "TIMEOUT" : "INVALID_REQUEST"; | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code, message }, | |
| }); | |
| } | |
| return; | |
| } | |
| if (command === "system.execApprovals.set") { | |
| try { | |
| const params = decodeParams<SystemExecApprovalsSetParams>(frame.paramsJSON); | |
| if (!params.file || typeof params.file !== "object") { | |
| throw new Error("INVALID_REQUEST: exec approvals file required"); | |
| } | |
| ensureExecApprovals(); | |
| const snapshot = readExecApprovalsSnapshot(); | |
| requireExecApprovalsBaseHash(params, snapshot); | |
| const normalized = normalizeExecApprovals(params.file); | |
| const currentSocketPath = snapshot.file.socket?.path?.trim(); | |
| const currentToken = snapshot.file.socket?.token?.trim(); | |
| const socketPath = | |
| normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath(); | |
| const token = normalized.socket?.token?.trim() ?? currentToken ?? ""; | |
| const next: ExecApprovalsFile = { | |
| ...normalized, | |
| socket: { | |
| path: socketPath, | |
| token, | |
| }, | |
| }; | |
| saveExecApprovals(next); | |
| const nextSnapshot = readExecApprovalsSnapshot(); | |
| const payload: ExecApprovalsSnapshot = { | |
| path: nextSnapshot.path, | |
| exists: nextSnapshot.exists, | |
| hash: nextSnapshot.hash, | |
| file: redactExecApprovals(nextSnapshot.file), | |
| }; | |
| await sendInvokeResult(client, frame, { | |
| ok: true, | |
| payloadJSON: JSON.stringify(payload), | |
| }); | |
| } catch (err) { | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "INVALID_REQUEST", message: String(err) }, | |
| }); | |
| } | |
| return; | |
| } | |
| if (command === "system.which") { | |
| try { | |
| const params = decodeParams<SystemWhichParams>(frame.paramsJSON); | |
| if (!Array.isArray(params.bins)) { | |
| throw new Error("INVALID_REQUEST: bins required"); | |
| } | |
| const env = sanitizeEnv(undefined); | |
| const payload = await handleSystemWhich(params, env); | |
| await sendInvokeResult(client, frame, { | |
| ok: true, | |
| payloadJSON: JSON.stringify(payload), | |
| }); | |
| } catch (err) { | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "INVALID_REQUEST", message: String(err) }, | |
| }); | |
| } | |
| return; | |
| } | |
| if (command === "browser.proxy") { | |
| try { | |
| const payload = await runBrowserProxyCommand(frame.paramsJSON); | |
| await sendInvokeResult(client, frame, { | |
| ok: true, | |
| payloadJSON: payload, | |
| }); | |
| } catch (err) { | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "INVALID_REQUEST", message: String(err) }, | |
| }); | |
| } | |
| return; | |
| } | |
| if (command !== "system.run") { | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "UNAVAILABLE", message: "command not supported" }, | |
| }); | |
| return; | |
| } | |
| let params: SystemRunParams; | |
| try { | |
| params = decodeParams<SystemRunParams>(frame.paramsJSON); | |
| } catch (err) { | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "INVALID_REQUEST", message: String(err) }, | |
| }); | |
| return; | |
| } | |
| if (!Array.isArray(params.command) || params.command.length === 0) { | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "INVALID_REQUEST", message: "command required" }, | |
| }); | |
| return; | |
| } | |
| const argv = params.command.map((item) => String(item)); | |
| const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : ""; | |
| const cmdText = rawCommand || formatCommand(argv); | |
| const agentId = params.agentId?.trim() || undefined; | |
| const cfg = loadConfig(); | |
| const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined; | |
| const configuredSecurity = resolveExecSecurity(agentExec?.security ?? cfg.tools?.exec?.security); | |
| const configuredAsk = resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask); | |
| const approvals = resolveExecApprovals(agentId, { | |
| security: configuredSecurity, | |
| ask: configuredAsk, | |
| }); | |
| const security = approvals.agent.security; | |
| const ask = approvals.agent.ask; | |
| const autoAllowSkills = approvals.agent.autoAllowSkills; | |
| const sessionKey = params.sessionKey?.trim() || "node"; | |
| const runId = params.runId?.trim() || crypto.randomUUID(); | |
| const env = sanitizeEnv(params.env ?? undefined); | |
| const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins); | |
| const bins = autoAllowSkills ? await skillBins.current() : new Set<string>(); | |
| let analysisOk = false; | |
| let allowlistMatches: ExecAllowlistEntry[] = []; | |
| let allowlistSatisfied = false; | |
| let segments: ExecCommandSegment[] = []; | |
| if (rawCommand) { | |
| const allowlistEval = evaluateShellAllowlist({ | |
| command: rawCommand, | |
| allowlist: approvals.allowlist, | |
| safeBins, | |
| cwd: params.cwd ?? undefined, | |
| env, | |
| skillBins: bins, | |
| autoAllowSkills, | |
| platform: process.platform, | |
| }); | |
| analysisOk = allowlistEval.analysisOk; | |
| allowlistMatches = allowlistEval.allowlistMatches; | |
| allowlistSatisfied = | |
| security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false; | |
| segments = allowlistEval.segments; | |
| } else { | |
| const analysis = analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env }); | |
| const allowlistEval = evaluateExecAllowlist({ | |
| analysis, | |
| allowlist: approvals.allowlist, | |
| safeBins, | |
| cwd: params.cwd ?? undefined, | |
| skillBins: bins, | |
| autoAllowSkills, | |
| }); | |
| analysisOk = analysis.ok; | |
| allowlistMatches = allowlistEval.allowlistMatches; | |
| allowlistSatisfied = | |
| security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false; | |
| segments = analysis.segments; | |
| } | |
| const isWindows = process.platform === "win32"; | |
| const cmdInvocation = rawCommand | |
| ? isCmdExeInvocation(segments[0]?.argv ?? []) | |
| : isCmdExeInvocation(argv); | |
| if (security === "allowlist" && isWindows && cmdInvocation) { | |
| analysisOk = false; | |
| allowlistSatisfied = false; | |
| } | |
| const useMacAppExec = process.platform === "darwin"; | |
| if (useMacAppExec) { | |
| const approvalDecision = | |
| params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" | |
| ? params.approvalDecision | |
| : null; | |
| const execRequest: ExecHostRequest = { | |
| command: argv, | |
| rawCommand: rawCommand || null, | |
| cwd: params.cwd ?? null, | |
| env: params.env ?? null, | |
| timeoutMs: params.timeoutMs ?? null, | |
| needsScreenRecording: params.needsScreenRecording ?? null, | |
| agentId: agentId ?? null, | |
| sessionKey: sessionKey ?? null, | |
| approvalDecision, | |
| }; | |
| const response = await runViaMacAppExecHost({ approvals, request: execRequest }); | |
| if (!response) { | |
| if (execHostEnforced || !execHostFallbackAllowed) { | |
| await sendNodeEvent( | |
| client, | |
| "exec.denied", | |
| buildExecEventPayload({ | |
| sessionKey, | |
| runId, | |
| host: "node", | |
| command: cmdText, | |
| reason: "companion-unavailable", | |
| }), | |
| ); | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { | |
| code: "UNAVAILABLE", | |
| message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable", | |
| }, | |
| }); | |
| return; | |
| } | |
| } else if (!response.ok) { | |
| const reason = response.error.reason ?? "approval-required"; | |
| await sendNodeEvent( | |
| client, | |
| "exec.denied", | |
| buildExecEventPayload({ | |
| sessionKey, | |
| runId, | |
| host: "node", | |
| command: cmdText, | |
| reason, | |
| }), | |
| ); | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "UNAVAILABLE", message: response.error.message }, | |
| }); | |
| return; | |
| } else { | |
| const result: ExecHostRunResult = response.payload; | |
| const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n"); | |
| await sendNodeEvent( | |
| client, | |
| "exec.finished", | |
| buildExecEventPayload({ | |
| sessionKey, | |
| runId, | |
| host: "node", | |
| command: cmdText, | |
| exitCode: result.exitCode, | |
| timedOut: result.timedOut, | |
| success: result.success, | |
| output: combined, | |
| }), | |
| ); | |
| await sendInvokeResult(client, frame, { | |
| ok: true, | |
| payloadJSON: JSON.stringify(result), | |
| }); | |
| return; | |
| } | |
| } | |
| if (security === "deny") { | |
| await sendNodeEvent( | |
| client, | |
| "exec.denied", | |
| buildExecEventPayload({ | |
| sessionKey, | |
| runId, | |
| host: "node", | |
| command: cmdText, | |
| reason: "security=deny", | |
| }), | |
| ); | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" }, | |
| }); | |
| return; | |
| } | |
| const requiresAsk = requiresExecApproval({ | |
| ask, | |
| security, | |
| analysisOk, | |
| allowlistSatisfied, | |
| }); | |
| const approvalDecision = | |
| params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" | |
| ? params.approvalDecision | |
| : null; | |
| const approvedByAsk = approvalDecision !== null || params.approved === true; | |
| if (requiresAsk && !approvedByAsk) { | |
| await sendNodeEvent( | |
| client, | |
| "exec.denied", | |
| buildExecEventPayload({ | |
| sessionKey, | |
| runId, | |
| host: "node", | |
| command: cmdText, | |
| reason: "approval-required", | |
| }), | |
| ); | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" }, | |
| }); | |
| return; | |
| } | |
| if (approvalDecision === "allow-always" && security === "allowlist") { | |
| if (analysisOk) { | |
| for (const segment of segments) { | |
| const pattern = segment.resolution?.resolvedPath ?? ""; | |
| if (pattern) { | |
| addAllowlistEntry(approvals.file, agentId, pattern); | |
| } | |
| } | |
| } | |
| } | |
| if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) { | |
| await sendNodeEvent( | |
| client, | |
| "exec.denied", | |
| buildExecEventPayload({ | |
| sessionKey, | |
| runId, | |
| host: "node", | |
| command: cmdText, | |
| reason: "allowlist-miss", | |
| }), | |
| ); | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" }, | |
| }); | |
| return; | |
| } | |
| if (allowlistMatches.length > 0) { | |
| const seen = new Set<string>(); | |
| for (const match of allowlistMatches) { | |
| if (!match?.pattern || seen.has(match.pattern)) { | |
| continue; | |
| } | |
| seen.add(match.pattern); | |
| recordAllowlistUse( | |
| approvals.file, | |
| agentId, | |
| match, | |
| cmdText, | |
| segments[0]?.resolution?.resolvedPath, | |
| ); | |
| } | |
| } | |
| if (params.needsScreenRecording === true) { | |
| await sendNodeEvent( | |
| client, | |
| "exec.denied", | |
| buildExecEventPayload({ | |
| sessionKey, | |
| runId, | |
| host: "node", | |
| command: cmdText, | |
| reason: "permission:screenRecording", | |
| }), | |
| ); | |
| await sendInvokeResult(client, frame, { | |
| ok: false, | |
| error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" }, | |
| }); | |
| return; | |
| } | |
| let execArgv = argv; | |
| if ( | |
| security === "allowlist" && | |
| isWindows && | |
| !approvedByAsk && | |
| rawCommand && | |
| analysisOk && | |
| allowlistSatisfied && | |
| segments.length === 1 && | |
| segments[0]?.argv.length > 0 | |
| ) { | |
| execArgv = segments[0].argv; | |
| } | |
| const result = await runCommand( | |
| execArgv, | |
| params.cwd?.trim() || undefined, | |
| env, | |
| params.timeoutMs ?? undefined, | |
| ); | |
| if (result.truncated) { | |
| const suffix = "... (truncated)"; | |
| if (result.stderr.trim().length > 0) { | |
| result.stderr = `${result.stderr}\n${suffix}`; | |
| } else { | |
| result.stdout = `${result.stdout}\n${suffix}`; | |
| } | |
| } | |
| const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n"); | |
| await sendNodeEvent( | |
| client, | |
| "exec.finished", | |
| buildExecEventPayload({ | |
| sessionKey, | |
| runId, | |
| host: "node", | |
| command: cmdText, | |
| exitCode: result.exitCode, | |
| timedOut: result.timedOut, | |
| success: result.success, | |
| output: combined, | |
| }), | |
| ); | |
| await sendInvokeResult(client, frame, { | |
| ok: true, | |
| payloadJSON: JSON.stringify({ | |
| exitCode: result.exitCode, | |
| timedOut: result.timedOut, | |
| success: result.success, | |
| stdout: result.stdout, | |
| stderr: result.stderr, | |
| error: result.error ?? null, | |
| }), | |
| }); | |
| } | |
| function decodeParams<T>(raw?: string | null): T { | |
| if (!raw) { | |
| throw new Error("INVALID_REQUEST: paramsJSON required"); | |
| } | |
| return JSON.parse(raw) as T; | |
| } | |
| export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayload | null { | |
| if (!payload || typeof payload !== "object") { | |
| return null; | |
| } | |
| const obj = payload as Record<string, unknown>; | |
| const id = typeof obj.id === "string" ? obj.id.trim() : ""; | |
| const nodeId = typeof obj.nodeId === "string" ? obj.nodeId.trim() : ""; | |
| const command = typeof obj.command === "string" ? obj.command.trim() : ""; | |
| if (!id || !nodeId || !command) { | |
| return null; | |
| } | |
| const paramsJSON = | |
| typeof obj.paramsJSON === "string" | |
| ? obj.paramsJSON | |
| : obj.params !== undefined | |
| ? JSON.stringify(obj.params) | |
| : null; | |
| const timeoutMs = typeof obj.timeoutMs === "number" ? obj.timeoutMs : null; | |
| const idempotencyKey = typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null; | |
| return { | |
| id, | |
| nodeId, | |
| command, | |
| paramsJSON, | |
| timeoutMs, | |
| idempotencyKey, | |
| }; | |
| } | |
| async function sendInvokeResult( | |
| client: GatewayClient, | |
| frame: NodeInvokeRequestPayload, | |
| result: { | |
| ok: boolean; | |
| payload?: unknown; | |
| payloadJSON?: string | null; | |
| error?: { code?: string; message?: string } | null; | |
| }, | |
| ) { | |
| try { | |
| await client.request("node.invoke.result", buildNodeInvokeResultParams(frame, result)); | |
| } catch { | |
| // ignore: node invoke responses are best-effort | |
| } | |
| } | |
| export function buildNodeInvokeResultParams( | |
| frame: NodeInvokeRequestPayload, | |
| result: { | |
| ok: boolean; | |
| payload?: unknown; | |
| payloadJSON?: string | null; | |
| error?: { code?: string; message?: string } | null; | |
| }, | |
| ): { | |
| id: string; | |
| nodeId: string; | |
| ok: boolean; | |
| payload?: unknown; | |
| payloadJSON?: string; | |
| error?: { code?: string; message?: string }; | |
| } { | |
| const params: { | |
| id: string; | |
| nodeId: string; | |
| ok: boolean; | |
| payload?: unknown; | |
| payloadJSON?: string; | |
| error?: { code?: string; message?: string }; | |
| } = { | |
| id: frame.id, | |
| nodeId: frame.nodeId, | |
| ok: result.ok, | |
| }; | |
| if (result.payload !== undefined) { | |
| params.payload = result.payload; | |
| } | |
| if (typeof result.payloadJSON === "string") { | |
| params.payloadJSON = result.payloadJSON; | |
| } | |
| if (result.error) { | |
| params.error = result.error; | |
| } | |
| return params; | |
| } | |
| async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) { | |
| try { | |
| await client.request("node.event", { | |
| event, | |
| payloadJSON: payload ? JSON.stringify(payload) : null, | |
| }); | |
| } catch { | |
| // ignore: node events are best-effort | |
| } | |
| } | |