From 3937d16c44ff9580939a35b832d01886694f55ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 01:42:53 +0100 Subject: [PATCH] fix(exec): fallback when node lacks run prepare --- CHANGELOG.md | 1 + .../bash-tools.exec-host-node-phases.ts | 48 +++++++++++++- src/agents/bash-tools.exec-host-node.test.ts | 65 ++++++++++++++++++- .../bash-tools.exec.approval-id.test.ts | 24 ++++--- 4 files changed, 124 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9efff4662..133530152ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - macOS Gateway: write launchd services with a state-dir `WorkingDirectory`, use a durable state-dir temp path instead of freezing macOS session `TMPDIR`, create that temp directory before bootstrap, and label abort-shaped launchd exits as `SIGABRT/abort` in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius. - Exec/node: skip approval-plan preparation for full-trust `host=node` runs so interpreter and script commands no longer fail with `SYSTEM_RUN_DENIED: approval cannot safely bind` when effective policy is `security=full` and `ask=off`. Fixes #48457 and duplicate #69251. Thanks @ajtran303, @jaserNo1, @Blakeshannon, @lesliefag, and @AvIsBeastMC. +- Exec/node: synthesize a local approval plan when a paired node advertises `system.run` without `system.run.prepare`, unblocking approval-required `host=node` exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz. - Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Thanks @codex. - Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq. - Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han. diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index e2cd226b408..04a83f843b7 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -15,6 +15,8 @@ import { } from "../infra/exec-inline-eval.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js"; +import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; +import { normalizeNullableString } from "../shared/string-coerce.js"; import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import { callGatewayTool } from "./tools/gateway.js"; @@ -26,6 +28,7 @@ export type NodeExecutionTarget = { argv: string[]; env: Record | undefined; invokeTimeoutMs: number; + supportsSystemRunPrepare: boolean; }; export type PreparedNodeRun = { @@ -113,9 +116,8 @@ export async function resolveNodeExecutionTarget( throw err; } const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId); - const supportsSystemRun = Array.isArray(nodeInfo?.commands) - ? nodeInfo?.commands?.includes("system.run") - : false; + const declaredCommands = Array.isArray(nodeInfo?.commands) ? nodeInfo.commands : []; + const supportsSystemRun = declaredCommands.includes("system.run"); if (!supportsSystemRun) { throw new Error( "exec host=node requires a node that supports system.run (companion app or node host).", @@ -133,6 +135,7 @@ export async function resolveNodeExecutionTarget( 1000 + 5_000, ), + supportsSystemRunPrepare: declaredCommands.includes("system.run.prepare"), }; } @@ -199,6 +202,10 @@ export async function prepareNodeSystemRun(params: { request: ExecuteNodeHostCommandParams; target: NodeExecutionTarget; }): Promise { + if (!params.target.supportsSystemRunPrepare) { + return buildLocalPreparedNodeRun(params); + } + const prepareRaw = await callGatewayTool( "node.invoke", { timeoutMs: 15_000 }, @@ -229,6 +236,41 @@ export async function prepareNodeSystemRun(params: { }; } +function buildLocalPreparedNodeRun(params: { + request: ExecuteNodeHostCommandParams; + target: NodeExecutionTarget; +}): PreparedNodeRun { + const command = resolveSystemRunCommandRequest({ + command: params.target.argv, + rawCommand: params.request.command, + }); + if (!command.ok) { + throw new Error(command.message); + } + if (command.argv.length === 0) { + throw new Error("command required"); + } + const commandText = formatExecCommand(command.argv); + const previewText = command.previewText?.trim(); + const commandPreview = previewText && previewText !== commandText ? previewText : null; + const plan = { + argv: [...command.argv], + cwd: normalizeNullableString(params.request.workdir), + commandText, + commandPreview, + agentId: normalizeNullableString(params.request.agentId), + sessionKey: normalizeNullableString(params.request.sessionKey), + } satisfies SystemRunApprovalPlan; + return { + plan, + argv: plan.argv, + rawCommand: plan.commandText, + cwd: plan.cwd ?? params.request.workdir, + agentId: plan.agentId ?? params.request.agentId, + sessionKey: plan.sessionKey ?? params.request.sessionKey, + }; +} + export async function analyzeNodeApprovalRequirement(params: { request: ExecuteNodeHostCommandParams; target: NodeExecutionTarget; diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index d708fba4a0c..ef45f35ffbd 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -182,7 +182,11 @@ describe("executeNodeHostCommand", () => { ); listNodesMock.mockReset(); listNodesMock.mockResolvedValue([ - { nodeId: "node-1", commands: ["system.run"], platform: process.platform }, + { + nodeId: "node-1", + commands: ["system.run", "system.run.prepare"], + platform: process.platform, + }, ]); parsePreparedSystemRunPayloadMock.mockReset(); parsePreparedSystemRunPayloadMock.mockReturnValue({ plan: preparedPlan }); @@ -284,6 +288,65 @@ describe("executeNodeHostCommand", () => { ); }); + it("builds a local systemRunPlan when approval is required and the node omits prepare", async () => { + listNodesMock.mockResolvedValueOnce([ + { + nodeId: "node-1", + commands: ["system.run", "system.which", "system.notify"], + platform: "darwin", + }, + ]); + resolveExecHostApprovalContextMock.mockReturnValue({ + approvals: { allowlist: [], file: { version: 1, agents: {} } }, + hostSecurity: "full", + hostAsk: "always", + askFallback: "deny", + }); + + const result = await executeNodeHostCommand({ + command: "bun ./script.ts", + workdir: "/tmp/work", + env: {}, + security: "full", + ask: "off", + defaultTimeoutSec: 30, + approvalRunningNoticeMs: 0, + warnings: [], + agentId: "requested-agent", + sessionKey: "requested-session", + }); + + expect(result.details?.status).toBe("approval-pending"); + expect(parsePreparedSystemRunPayloadMock).not.toHaveBeenCalled(); + const expectedPlan = { + argv: ["bash", "-lc", "bun ./script.ts"], + cwd: "/tmp/work", + commandText: 'bash -lc "bun ./script.ts"', + commandPreview: "bun ./script.ts", + agentId: "requested-agent", + sessionKey: "requested-session", + }; + expect(registerExecApprovalRequestForHostOrThrowMock).toHaveBeenCalledWith( + expect.objectContaining({ + systemRunPlan: expectedPlan, + }), + ); + + await vi.waitFor(() => { + expect(callGatewayToolMock).toHaveBeenCalledWith( + "node.invoke", + expect.anything(), + expect.objectContaining({ + command: "system.run", + params: expect.objectContaining({ + rawCommand: expectedPlan.commandText, + systemRunPlan: expectedPlan, + }), + }), + ); + }); + }); + it("skips approval prepare in full/off mode", async () => { await executeNodeHostCommand({ command: "bun ./script.ts", diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 379a5238b2c..52fa7dc525c 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -15,7 +15,11 @@ vi.mock("./tools/gateway.js", () => ({ vi.mock("./tools/nodes-utils.js", () => ({ listNodes: vi.fn(async () => [ - { nodeId: "node-1", commands: ["system.run"], platform: "darwin" }, + { + nodeId: "node-1", + commands: ["system.run", "system.run.prepare"], + platform: "darwin", + }, ]), resolveNodeIdFromList: vi.fn((nodes: Array<{ nodeId: string }>) => nodes[0]?.nodeId), })); @@ -522,16 +526,16 @@ describe("exec approvals", () => { it("preserves explicit workdir for node exec", async () => { const remoteWorkdir = "/Users/vv"; - let prepareCwd: string | undefined; + let runCwd: string | undefined; vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { if (method === "node.invoke") { const invoke = params as { command?: string; params?: { cwd?: string } }; if (invoke.command === "system.run.prepare") { - prepareCwd = invoke.params?.cwd; return buildPreparedSystemRunPayload(params); } if (invoke.command === "system.run") { + runCwd = invoke.params?.cwd; return { payload: { success: true, stdout: "ok" } }; } } @@ -551,23 +555,23 @@ describe("exec approvals", () => { }); expect(result.details.status).toBe("completed"); - expect(prepareCwd).toBe(remoteWorkdir); + expect(runCwd).toBe(remoteWorkdir); }); it("does not forward the gateway default cwd to node exec when workdir is omitted", async () => { const gatewayWorkspace = "/gateway/workspace"; - let prepareHasCwd = false; - let prepareCwd: string | undefined; + let runHasCwd = false; + let runCwd: string | undefined; vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { if (method === "node.invoke") { const invoke = params as { command?: string; params?: { cwd?: string } }; if (invoke.command === "system.run.prepare") { - prepareHasCwd = Object.hasOwn(invoke.params ?? {}, "cwd"); - prepareCwd = invoke.params?.cwd; return buildPreparedSystemRunPayload(params); } if (invoke.command === "system.run") { + runHasCwd = Object.hasOwn(invoke.params ?? {}, "cwd"); + runCwd = invoke.params?.cwd; return { payload: { success: true, stdout: "ok" } }; } } @@ -587,8 +591,8 @@ describe("exec approvals", () => { }); expect(result.details.status).toBe("completed"); - expect(prepareHasCwd).toBe(false); - expect(prepareCwd).toBeUndefined(); + expect(runHasCwd).toBe(false); + expect(runCwd).toBeUndefined(); }); it("routes explicit host=node to node invoke when elevated default is on under auto host", async () => {