From 1ad4a7194ebbfcaa1af1e6cd616c33cc8c2dd8d8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 04:46:47 +0000 Subject: [PATCH] fix: allow node exec fallback and defer node approvals --- docs/nodes/index.md | 3 ++ src/agents/bash-tools.exec.ts | 39 ++------------- src/node-host/runner.ts | 94 +++++++++++++++++------------------ 3 files changed, 53 insertions(+), 83 deletions(-) diff --git a/docs/nodes/index.md b/docs/nodes/index.md index e41acfb09..711049e6b 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -289,6 +289,9 @@ Notes: - The node host stores its node id + pairing token in `~/.clawdbot/node.json`. - Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json` (see [Exec approvals](/tools/exec-approvals)). +- On macOS, the headless node host prefers the companion app exec host when reachable and falls + back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require + the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback. - Add `--tls` / `--tls-fingerprint` when the bridge requires TLS. ## Mac node mode diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 98496a842..7f5091d8c 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -494,12 +494,7 @@ export function createExecTool( if (nodeEnv) { applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true }); } - const resolution = resolveCommandResolution(params.command, workdir, env); - const allowlistMatch = - hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null; - const requiresAsk = - hostAsk === "always" || - (hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch); + const requiresAsk = hostAsk === "always" || hostAsk === "on-miss"; let approvedByAsk = false; let approvalDecision: "allow-once" | "allow-always" | null = null; @@ -514,7 +509,7 @@ export function createExecTool( security: hostSecurity, ask: hostAsk, agentId: defaults?.agentId, - resolvedPath: resolution?.resolvedPath ?? null, + resolvedPath: null, sessionKey: defaults?.sessionKey ?? null, timeoutMs: 120_000, }, @@ -532,11 +527,7 @@ export function createExecTool( approvedByAsk = true; approvalDecision = "allow-once"; } else if (askFallback === "allowlist") { - if (!allowlistMatch) { - throw new Error("exec denied: approval required (approval UI not available)"); - } - approvedByAsk = true; - approvalDecision = "allow-once"; + // Defer allowlist enforcement to the node host. } else { throw new Error("exec denied: approval required (approval UI not available)"); } @@ -548,32 +539,8 @@ export function createExecTool( if (decision === "allow-always") { approvedByAsk = true; approvalDecision = "allow-always"; - if (hostSecurity === "allowlist") { - const pattern = - resolution?.resolvedPath ?? - resolution?.rawExecutable ?? - params.command.split(/\s+/).shift() ?? - ""; - if (pattern) { - addAllowlistEntry(approvals.file, defaults?.agentId, pattern); - } - } } } - - if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) { - throw new Error("exec denied: allowlist miss"); - } - - if (allowlistMatch) { - recordAllowlistUse( - approvals.file, - defaults?.agentId, - allowlistMatch, - params.command, - resolution?.resolvedPath, - ); - } const invokeParams: Record = { nodeId, command: "system.run", diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index d9da98750..4ebafefb7 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -104,7 +104,8 @@ const OUTPUT_CAP = 200_000; const OUTPUT_EVENT_TAIL = 20_000; const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app"; -const execHostFallbackAllowed = process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim() === "1"; +const execHostFallbackAllowed = + process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0"; const blockedEnvKeys = new Set([ "PATH", @@ -559,8 +560,7 @@ async function handleInvoke( const skillAllow = autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false; - const useMacAppExec = - process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed); + const useMacAppExec = process.platform === "darwin"; if (useMacAppExec) { const approvalDecision = params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" @@ -579,28 +579,28 @@ async function handleInvoke( }; const response = await runViaMacAppExecHost({ approvals, request: execRequest }); if (!response) { - 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; - } - - if (!response.ok) { + 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, @@ -618,29 +618,29 @@ async function handleInvoke( 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; } - - 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") {