import crypto from "node:crypto"; import { spawn } from "node:child_process"; import fs from "node:fs"; import fsPromises from "node:fs/promises"; import path from "node:path"; import { addAllowlistEntry, analyzeArgvCommand, evaluateExecAllowlist, evaluateShellAllowlist, requiresExecApproval, normalizeExecApprovals, recordAllowlistUse, resolveExecApprovals, resolveSafeBins, ensureExecApprovals, readExecApprovalsSnapshot, resolveExecApprovalsSocketPath, saveExecApprovals, type ExecApprovalsFile, type ExecAllowlistEntry, type ExecCommandSegment, } from "../infra/exec-approvals.js"; import { requestExecHostViaSocket, type ExecHostRequest, type ExecHostResponse, type ExecHostRunResult, } from "../infra/exec-host.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { loadConfig } from "../config/config.js"; import { resolveBrowserConfig, shouldStartLocalBrowserServer } from "../browser/config.js"; import { detectMime } from "../media/mime.js"; import { resolveAgentConfig } from "../agents/agent-scope.js"; import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; import { VERSION } from "../version.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js"; import { GatewayClient } from "../gateway/client.js"; type NodeHostRunOptions = { gatewayHost: string; gatewayPort: number; gatewayTls?: boolean; gatewayTlsFingerprint?: string; nodeId?: string; displayName?: string; }; type SystemRunParams = { command: string[]; rawCommand?: string | null; cwd?: string | null; env?: Record; 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 BrowserProxyParams = { method?: string; path?: string; query?: Record; body?: unknown; timeoutMs?: number; profile?: string; }; type BrowserProxyFile = { path: string; base64: string; mimeType?: string; }; type BrowserProxyResult = { result: unknown; files?: BrowserProxyFile[]; }; 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; }; type NodeInvokeRequestPayload = { id: string; nodeId: string; command: string; paramsJSON?: string | null; timeoutMs?: number | null; idempotencyKey?: string | null; }; 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 BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024; const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app"; const execHostFallbackAllowed = process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0"; const blockedEnvKeys = new Set([ "NODE_OPTIONS", "PYTHONHOME", "PYTHONPATH", "PERL5LIB", "PERL5OPT", "RUBYOPT", ]); const blockedEnvPrefixes = ["DYLD_", "LD_"]; class SkillBinsCache { private bins = new Set(); private lastRefresh = 0; private readonly ttlMs = 90_000; private readonly fetch: () => Promise; constructor(fetch: () => Promise) { this.fetch = fetch; } async current(force = false): Promise> { if (force || Date.now() - this.lastRefresh > this.ttlMs) { await this.refresh(); } return this.bins; } private async refresh() { try { const bins = await this.fetch(); this.bins = new Set(bins); this.lastRefresh = Date.now(); } catch { if (!this.lastRefresh) { this.bins = new Set(); } } } } function sanitizeEnv( overrides?: Record | null, ): Record | undefined { if (!overrides) return undefined; const merged = { ...process.env } as Record; 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 normalizeProfileAllowlist(raw?: string[]): string[] { return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : []; } function resolveBrowserProxyConfig() { const cfg = loadConfig(); const proxy = cfg.nodeHost?.browserProxy; const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles); const enabled = proxy?.enabled !== false; return { enabled, allowProfiles }; } let browserControlReady: Promise | null = null; async function ensureBrowserControlServer(): Promise { if (browserControlReady) return browserControlReady; browserControlReady = (async () => { const cfg = loadConfig(); const resolved = resolveBrowserConfig(cfg.browser); if (!resolved.enabled) { throw new Error("browser control disabled"); } if (!shouldStartLocalBrowserServer(resolved)) { throw new Error("browser control URL is non-loopback"); } const mod = await import("../browser/server.js"); await mod.startBrowserControlServerFromConfig(); })(); return browserControlReady; } function isProfileAllowed(params: { allowProfiles: string[]; profile?: string | null }) { const { allowProfiles, profile } = params; if (!allowProfiles.length) return true; if (!profile) return false; return allowProfiles.includes(profile.trim()); } function collectBrowserProxyPaths(payload: unknown): string[] { const paths = new Set(); const obj = typeof payload === "object" && payload !== null ? (payload as Record) : null; if (!obj) return []; if (typeof obj.path === "string" && obj.path.trim()) paths.add(obj.path.trim()); if (typeof obj.imagePath === "string" && obj.imagePath.trim()) paths.add(obj.imagePath.trim()); const download = obj.download; if (download && typeof download === "object") { const dlPath = (download as Record).path; if (typeof dlPath === "string" && dlPath.trim()) paths.add(dlPath.trim()); } return [...paths]; } async function readBrowserProxyFile(filePath: string): Promise { const stat = await fsPromises.stat(filePath).catch(() => null); if (!stat || !stat.isFile()) return null; if (stat.size > BROWSER_PROXY_MAX_FILE_BYTES) { throw new Error( `browser proxy file exceeds ${Math.round(BROWSER_PROXY_MAX_FILE_BYTES / (1024 * 1024))}MB`, ); } const buffer = await fsPromises.readFile(filePath); const mimeType = await detectMime({ buffer, filePath }); return { path: filePath, base64: buffer.toString("base64"), mimeType }; } 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 | undefined, timeoutMs: number | undefined, ): Promise { 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[] { const raw = env?.PATH ?? (env as Record)?.Path ?? process.env.PATH ?? process.env.Path ?? DEFAULT_NODE_PATH; return raw.split(path.delimiter).filter(Boolean); } function ensureNodePathEnv(): string { ensureClawdbotCliOnPath({ pathEnv: process.env.PATH ?? "" }); const current = process.env.PATH ?? ""; if (current.trim()) return current; process.env.PATH = DEFAULT_NODE_PATH; return DEFAULT_NODE_PATH; } function resolveExecutable(bin: string, env?: Record) { 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) { const bins = params.bins.map((bin) => bin.trim()).filter(Boolean); const found: Record = {}; 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; request: ExecHostRequest; }): Promise { const { approvals, request } = params; return await requestExecHostViaSocket({ socketPath: approvals.socketPath, token: approvals.token, request, }); } export async function runNodeHost(opts: NodeHostRunOptions): Promise { const config = await ensureNodeHostConfig(); const nodeId = opts.nodeId?.trim() || config.nodeId; if (nodeId !== config.nodeId) { config.nodeId = nodeId; } const displayName = opts.displayName?.trim() || config.displayName || (await getMachineDisplayName()); config.displayName = displayName; const gateway: NodeHostGatewayConfig = { host: opts.gatewayHost, port: opts.gatewayPort, tls: opts.gatewayTls ?? loadConfig().gateway?.tls?.enabled ?? false, tlsFingerprint: opts.gatewayTlsFingerprint, }; config.gateway = gateway; await saveNodeHostConfig(config); const cfg = loadConfig(); const browserProxy = resolveBrowserProxyConfig(); const resolvedBrowser = resolveBrowserConfig(cfg.browser); const browserProxyEnabled = browserProxy.enabled && resolvedBrowser.enabled && shouldStartLocalBrowserServer(resolvedBrowser); const isRemoteMode = cfg.gateway?.mode === "remote"; const token = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || (isRemoteMode ? cfg.gateway?.remote?.token : cfg.gateway?.auth?.token); const password = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || (isRemoteMode ? cfg.gateway?.remote?.password : cfg.gateway?.auth?.password); const host = gateway.host ?? "127.0.0.1"; const port = gateway.port ?? 18789; const scheme = gateway.tls ? "wss" : "ws"; const url = `${scheme}://${host}:${port}`; const pathEnv = ensureNodePathEnv(); // eslint-disable-next-line no-console console.log(`node host PATH: ${pathEnv}`); const client = new GatewayClient({ url, token: token?.trim() || undefined, password: password?.trim() || undefined, instanceId: nodeId, clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, clientDisplayName: displayName, clientVersion: VERSION, platform: process.platform, mode: GATEWAY_CLIENT_MODES.NODE, role: "node", scopes: [], caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])], commands: [ "system.run", "system.which", "system.execApprovals.get", "system.execApprovals.set", ...(browserProxyEnabled ? ["browser.proxy"] : []), ], pathEnv, permissions: undefined, deviceIdentity: loadOrCreateDeviceIdentity(), tlsFingerprint: gateway.tlsFingerprint, onEvent: (evt) => { if (evt.event !== "node.invoke.request") return; const payload = coerceNodeInvokePayload(evt.payload); if (!payload) return; void handleInvoke(payload, client, skillBins); }, onConnectError: (err) => { // keep retrying (handled by GatewayClient) // eslint-disable-next-line no-console console.error(`node host gateway connect failed: ${err.message}`); }, onClose: (code, reason) => { // eslint-disable-next-line no-console console.error(`node host gateway closed (${code}): ${reason}`); }, }); const skillBins = new SkillBinsCache(async () => { const res = (await client.request("skills.bins", {})) as | { bins?: unknown[] } | null | undefined; const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : []; return bins; }); client.start(); await new Promise(() => {}); } async function handleInvoke( frame: NodeInvokeRequestPayload, client: GatewayClient, skillBins: SkillBinsCache, ) { 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) { await sendInvokeResult(client, frame, { ok: false, error: { code: "INVALID_REQUEST", message: String(err) }, }); } return; } if (command === "system.execApprovals.set") { try { const params = decodeParams(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(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 params = decodeParams(frame.paramsJSON); const pathValue = typeof params.path === "string" ? params.path.trim() : ""; if (!pathValue) { throw new Error("INVALID_REQUEST: path required"); } const proxyConfig = resolveBrowserProxyConfig(); if (!proxyConfig.enabled) { throw new Error("UNAVAILABLE: node browser proxy disabled"); } await ensureBrowserControlServer(); const resolved = resolveBrowserConfig(loadConfig().browser); const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : ""; const allowedProfiles = proxyConfig.allowProfiles; if (allowedProfiles.length > 0) { if (pathValue !== "/profiles") { const profileToCheck = requestedProfile || resolved.defaultProfile; if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) { throw new Error("INVALID_REQUEST: browser profile not allowed"); } } else if (requestedProfile) { if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: requestedProfile })) { throw new Error("INVALID_REQUEST: browser profile not allowed"); } } } const url = new URL( pathValue.startsWith("/") ? pathValue : `/${pathValue}`, resolved.controlUrl, ); if (requestedProfile) { url.searchParams.set("profile", requestedProfile); } const query = params.query ?? {}; for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null) continue; url.searchParams.set(key, String(value)); } const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET"; const body = params.body; const ctrl = new AbortController(); const timeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) ? Math.max(1, Math.floor(params.timeoutMs)) : 20_000; const timer = setTimeout(() => ctrl.abort(), timeoutMs); const headers = new Headers(); let bodyJson: string | undefined; if (body !== undefined) { headers.set("Content-Type", "application/json"); bodyJson = JSON.stringify(body); } const token = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || resolved.controlToken?.trim(); if (token) { headers.set("Authorization", `Bearer ${token}`); } let res: Response; try { res = await fetch(url.toString(), { method, headers, body: bodyJson, signal: ctrl.signal, }); } finally { clearTimeout(timer); } if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`); } const result = (await res.json()) as unknown; if (allowedProfiles.length > 0 && url.pathname === "/profiles") { const obj = typeof result === "object" && result !== null ? (result as Record) : {}; const profiles = Array.isArray(obj.profiles) ? obj.profiles : []; obj.profiles = profiles.filter((entry) => { if (!entry || typeof entry !== "object") return false; const name = (entry as Record).name; return typeof name === "string" && allowedProfiles.includes(name); }); } let files: BrowserProxyFile[] | undefined; const paths = collectBrowserProxyPaths(result); if (paths.length > 0) { const loaded = await Promise.all( paths.map(async (p) => { try { const file = await readBrowserProxyFile(p); if (!file) { throw new Error("file not found"); } return file; } catch (err) { throw new Error(`browser proxy file read failed for ${p}: ${String(err)}`); } }), ); if (loaded.length > 0) files = loaded; } const payload: BrowserProxyResult = files ? { result, files } : { result }; 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.run") { await sendInvokeResult(client, frame, { ok: false, error: { code: "UNAVAILABLE", message: "command not supported" }, }); return; } let params: SystemRunParams; try { params = decodeParams(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 approvals = resolveExecApprovals(agentId, { security: "allowlist" }); 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 cfg = loadConfig(); const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined; const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins); const bins = autoAllowSkills ? await skillBins.current() : new Set(); 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, }); 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 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(); 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; } const result = await runCommand( argv, 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(raw?: string | null): T { if (!raw) { throw new Error("INVALID_REQUEST: paramsJSON required"); } return JSON.parse(raw) as T; } function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayload | null { if (!payload || typeof payload !== "object") return null; const obj = payload as Record; 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 } }