mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(exec): fallback when node lacks run prepare
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user