| 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, |
| mergeExecApprovalsSocketDefaults, |
| recordAllowlistUse, |
| resolveExecApprovals, |
| resolveSafeBins, |
| ensureExecApprovals, |
| readExecApprovalsSnapshot, |
| 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 { validateSystemRunCommandConsistency } from "../infra/system-run-command.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"; |
| } |
|
|
| export function sanitizeEnv( |
| overrides?: Record<string, string> | null, |
| ): Record<string, string> | undefined { |
| if (!overrides) { |
| return undefined; |
| } |
| const merged = { ...process.env } as Record<string, string>; |
| for (const [rawKey, value] of Object.entries(overrides)) { |
| const key = rawKey.trim(); |
| if (!key) { |
| continue; |
| } |
| const upper = key.toUpperCase(); |
| |
| |
| if (upper === "PATH") { |
| continue; |
| } |
| if (blockedEnvKeys.has(upper)) { |
| continue; |
| } |
| if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) { |
| continue; |
| } |
| merged[key] = value; |
| } |
| return merged; |
| } |
|
|
| 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 { |
| |
| } |
| }, 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 sendExecFinishedEvent(params: { |
| client: GatewayClient; |
| sessionKey: string; |
| runId: string; |
| cmdText: string; |
| result: { |
| stdout?: string; |
| stderr?: string; |
| error?: string | null; |
| exitCode?: number | null; |
| timedOut?: boolean; |
| success?: boolean; |
| }; |
| }) { |
| const combined = [params.result.stdout, params.result.stderr, params.result.error] |
| .filter(Boolean) |
| .join("\n"); |
| await sendNodeEvent( |
| params.client, |
| "exec.finished", |
| buildExecEventPayload({ |
| sessionKey: params.sessionKey, |
| runId: params.runId, |
| host: "node", |
| command: params.cmdText, |
| exitCode: params.result.exitCode ?? undefined, |
| timedOut: params.result.timedOut, |
| success: params.result.success, |
| output: combined, |
| }), |
| ); |
| } |
|
|
| 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 next = mergeExecApprovalsSocketDefaults({ normalized, current: snapshot.file }); |
| 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 consistency = validateSystemRunCommandConsistency({ |
| argv, |
| rawCommand: rawCommand || null, |
| }); |
| if (!consistency.ok) { |
| await sendInvokeResult(client, frame, { |
| ok: false, |
| error: { code: "INVALID_REQUEST", message: consistency.message }, |
| }); |
| return; |
| } |
|
|
| const shellCommand = consistency.shellCommand; |
| const cmdText = consistency.cmdText; |
| 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 (shellCommand) { |
| const allowlistEval = evaluateShellAllowlist({ |
| command: shellCommand, |
| 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 = shellCommand |
| ? 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 || shellCommand || 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; |
| await sendExecFinishedEvent({ client, sessionKey, runId, cmdText, result }); |
| 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 && |
| shellCommand && |
| 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}`; |
| } |
| } |
| await sendExecFinishedEvent({ client, sessionKey, runId, cmdText, result }); |
|
|
| 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 { |
| |
| } |
| } |
|
|
| 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 { |
| |
| } |
| } |
|
|