fix(exec): fallback when node lacks run prepare

This commit is contained in:
Peter Steinberger
2026-04-27 01:42:53 +01:00
parent b109c1f99c
commit 3937d16c44
4 changed files with 124 additions and 14 deletions

View File

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

View File

@@ -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<string, string> | 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<PreparedNodeRun> {
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;

View File

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

View File

@@ -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 () => {