fix: allow node exec fallback and defer node approvals

This commit is contained in:
Peter Steinberger
2026-01-21 04:46:47 +00:00
parent beec504ebd
commit 1ad4a7194e
3 changed files with 53 additions and 83 deletions

View File

@@ -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

View File

@@ -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<string, unknown> = {
nodeId,
command: "system.run",

View File

@@ -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") {