fix(node): default mac headless system.run to local host

Co-authored-by: aethnova <262512133+aethnova@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-22 22:22:16 +01:00
parent d24f5c1e3a
commit e4d67137db
5 changed files with 110 additions and 6 deletions

View File

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

View File

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

View File

@@ -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<string>(),
},
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"),
}),
);
});
});

View File

@@ -70,6 +70,7 @@ export async function handleSystemRunInvoke(opts: {
success?: boolean;
};
}) => Promise<void>;
preferMacAppExecHost: boolean;
}): Promise<void> {
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,

View File

@@ -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,
});
}