From e4d67137db710178416a539756cac8086de3d26e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 22:22:16 +0100 Subject: [PATCH] fix(node): default mac headless system.run to local host Co-authored-by: aethnova <262512133+aethnova@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/nodes/index.md | 6 +- src/node-host/invoke-system-run.test.ts | 104 +++++++++++++++++++++++- src/node-host/invoke-system-run.ts | 3 +- src/node-host/invoke.ts | 2 + 5 files changed, 110 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfce993a0..dc533a900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) - Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560) - Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144) +- Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547) - Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle. - Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan. - Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 21dd651f5..a8cdab0de 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -333,9 +333,9 @@ Notes: - The node host stores its node id, token, display name, and gateway connection info in `~/.openclaw/node.json`. - Exec approvals are enforced locally via `~/.openclaw/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 `OPENCLAW_NODE_EXEC_HOST=app` to require - the app, or `OPENCLAW_NODE_EXEC_FALLBACK=0` to disable fallback. +- On macOS, the headless node host executes `system.run` locally by default. Set + `OPENCLAW_NODE_EXEC_HOST=app` to route `system.run` through the companion app exec host; add + `OPENCLAW_NODE_EXEC_FALLBACK=0` to require the app host and fail closed if it is unavailable. - Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS. ## Mac node mode diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 424bad83e..5a052b28b 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; -import { formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js"; +import { describe, expect, it, vi } from "vitest"; +import type { ExecHostResponse } from "../infra/exec-host.js"; +import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js"; describe("formatSystemRunAllowlistMissMessage", () => { it("returns legacy allowlist miss message by default", () => { @@ -14,3 +15,102 @@ describe("formatSystemRunAllowlistMissMessage", () => { ).toContain("Windows shell wrappers like cmd.exe /c require approval"); }); }); + +describe("handleSystemRunInvoke mac app exec host routing", () => { + async function runSystemInvoke(params: { + preferMacAppExecHost: boolean; + runViaResponse?: ExecHostResponse | null; + }) { + const runCommand = vi.fn(async () => ({ + success: true, + stdout: "local-ok", + stderr: "", + timedOut: false, + truncated: false, + exitCode: 0, + error: null, + })); + const runViaMacAppExecHost = vi.fn(async () => params.runViaResponse ?? null); + const sendInvokeResult = vi.fn(async () => {}); + const sendExecFinishedEvent = vi.fn(async () => {}); + + await handleSystemRunInvoke({ + client: {} as never, + params: { + command: ["echo", "ok"], + approved: true, + sessionKey: "agent:main:main", + }, + skillBins: { + current: async () => new Set(), + }, + execHostEnforced: false, + execHostFallbackAllowed: true, + resolveExecSecurity: () => "full", + resolveExecAsk: () => "off", + isCmdExeInvocation: () => false, + sanitizeEnv: () => undefined, + runCommand, + runViaMacAppExecHost, + sendNodeEvent: async () => {}, + buildExecEventPayload: (payload) => payload, + sendInvokeResult, + sendExecFinishedEvent, + preferMacAppExecHost: params.preferMacAppExecHost, + }); + + return { runCommand, runViaMacAppExecHost, sendInvokeResult, sendExecFinishedEvent }; + } + + it("uses local execution by default when mac app exec host preference is disabled", async () => { + const { runCommand, runViaMacAppExecHost, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + }); + + expect(runViaMacAppExecHost).not.toHaveBeenCalled(); + expect(runCommand).toHaveBeenCalledTimes(1); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: true, + payloadJSON: expect.stringContaining("local-ok"), + }), + ); + }); + + it("uses mac app exec host when explicitly preferred", async () => { + const { runCommand, runViaMacAppExecHost, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: true, + runViaResponse: { + ok: true, + payload: { + success: true, + stdout: "app-ok", + stderr: "", + timedOut: false, + truncated: false, + exitCode: 0, + error: null, + }, + }, + }); + + expect(runViaMacAppExecHost).toHaveBeenCalledWith({ + approvals: expect.objectContaining({ + agent: expect.objectContaining({ + security: "full", + ask: "off", + }), + }), + request: expect.objectContaining({ + command: ["echo", "ok"], + }), + }); + expect(runCommand).not.toHaveBeenCalled(); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: true, + payloadJSON: expect.stringContaining("app-ok"), + }), + ); + }); +}); diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index a83900b14..916d8c169 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -70,6 +70,7 @@ export async function handleSystemRunInvoke(opts: { success?: boolean; }; }) => Promise; + preferMacAppExecHost: boolean; }): Promise { const command = resolveSystemRunCommand({ command: opts.params.command, @@ -166,7 +167,7 @@ export async function handleSystemRunInvoke(opts: { ? opts.isCmdExeInvocation(segments[0]?.argv ?? []) : opts.isCmdExeInvocation(argv); - const useMacAppExec = process.platform === "darwin"; + const useMacAppExec = opts.preferMacAppExecHost; if (useMacAppExec) { const execRequest: ExecHostRequest = { command: argv, diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index f91584d0d..c6d5d2ccc 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -35,6 +35,7 @@ const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sb const execHostEnforced = process.env.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase() === "app"; const execHostFallbackAllowed = process.env.OPENCLAW_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0"; +const preferMacAppExecHost = process.platform === "darwin" && execHostEnforced; type SystemWhichParams = { bins: string[]; @@ -457,6 +458,7 @@ export async function handleInvoke( sendExecFinishedEvent: async ({ sessionKey, runId, cmdText, result }) => { await sendExecFinishedEvent({ client, sessionKey, runId, cmdText, result }); }, + preferMacAppExecHost, }); }