diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb4c9c93cd..332cc0ae88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Nodes/system.run approval hardening: use explicit argv-mutation signaling when regenerating prepared `rawCommand`, and cover the `system.run.prepare -> system.run` handoff so direct PATH-based `nodes.run` commands no longer fail with `rawCommand does not match command`. (#33137) thanks @Sid-Qin. - Models/custom provider headers: propagate `models.providers..headers` across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin. - Daemon/systemd install robustness: treat `systemctl --user is-enabled` exit-code-4 `not-found` responses as not-enabled by combining stderr/stdout detail parsing, so Ubuntu fresh installs no longer fail with `systemctl is-enabled unavailable`. (#33634) Thanks @Yuandiaodiaodiao. - Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to `agent:main`. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc. diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 3953c8f2d30..484eca04757 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { formatExecCommand } from "../infra/system-run-command.js"; import { buildSystemRunApprovalPlan, hardenApprovedExecutionPaths, @@ -18,7 +19,9 @@ type HardeningCase = { shellCommand?: string | null; withPathToken?: boolean; expectedArgv: (ctx: { pathToken: PathTokenSetup | null }) => string[]; + expectedArgvChanged?: boolean; expectedCmdText?: string; + checkRawCommandMatchesArgv?: boolean; }; describe("hardenApprovedExecutionPaths", () => { @@ -36,6 +39,7 @@ describe("hardenApprovedExecutionPaths", () => { argv: ["env", "tr", "a", "b"], shellCommand: null, expectedArgv: () => ["env", "tr", "a", "b"], + expectedArgvChanged: false, }, { name: "pins direct PATH-token executable during approval hardening", @@ -44,6 +48,7 @@ describe("hardenApprovedExecutionPaths", () => { shellCommand: null, withPathToken: true, expectedArgv: ({ pathToken }) => [pathToken!.expected, "SAFE"], + expectedArgvChanged: true, }, { name: "preserves env-wrapper PATH-token argv during approval hardening", @@ -52,6 +57,15 @@ describe("hardenApprovedExecutionPaths", () => { shellCommand: null, withPathToken: true, expectedArgv: () => ["env", "poccmd", "SAFE"], + expectedArgvChanged: false, + }, + { + name: "rawCommand matches hardened argv after executable path pinning", + mode: "build-plan", + argv: ["poccmd", "hello"], + withPathToken: true, + expectedArgv: ({ pathToken }) => [pathToken!.expected, "hello"], + checkRawCommandMatchesArgv: true, }, ]; @@ -82,6 +96,9 @@ describe("hardenApprovedExecutionPaths", () => { if (testCase.expectedCmdText) { expect(prepared.cmdText).toBe(testCase.expectedCmdText); } + if (testCase.checkRawCommandMatchesArgv) { + expect(prepared.plan.rawCommand).toBe(formatExecCommand(prepared.plan.argv)); + } return; } @@ -96,6 +113,9 @@ describe("hardenApprovedExecutionPaths", () => { throw new Error("unreachable"); } expect(hardened.argv).toEqual(testCase.expectedArgv({ pathToken })); + if (typeof testCase.expectedArgvChanged === "boolean") { + expect(hardened.argvChanged).toBe(testCase.expectedArgvChanged); + } } finally { if (testCase.withPathToken) { if (oldPath === undefined) { diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 6bb5f28034b..b434175a3d8 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -3,7 +3,7 @@ import path from "node:path"; import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js"; import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; import { sameFileIdentity } from "../infra/file-identity.js"; -import { resolveSystemRunCommand } from "../infra/system-run-command.js"; +import { formatExecCommand, resolveSystemRunCommand } from "../infra/system-run-command.js"; export type ApprovedCwdSnapshot = { cwd: string; @@ -144,6 +144,7 @@ export function hardenApprovedExecutionPaths(params: { | { ok: true; argv: string[]; + argvChanged: boolean; cwd: string | undefined; approvedCwdSnapshot: ApprovedCwdSnapshot | undefined; } @@ -152,6 +153,7 @@ export function hardenApprovedExecutionPaths(params: { return { ok: true, argv: params.argv, + argvChanged: false, cwd: params.cwd, approvedCwdSnapshot: undefined, }; @@ -172,6 +174,7 @@ export function hardenApprovedExecutionPaths(params: { return { ok: true, argv: params.argv, + argvChanged: false, cwd: hardenedCwd, approvedCwdSnapshot, }; @@ -190,6 +193,7 @@ export function hardenApprovedExecutionPaths(params: { return { ok: true, argv: params.argv, + argvChanged: false, cwd: hardenedCwd, approvedCwdSnapshot, }; @@ -203,11 +207,22 @@ export function hardenApprovedExecutionPaths(params: { }; } + if (pinnedExecutable === params.argv[0]) { + return { + ok: true, + argv: params.argv, + argvChanged: false, + cwd: hardenedCwd, + approvedCwdSnapshot, + }; + } + const argv = [...params.argv]; argv[0] = pinnedExecutable; return { ok: true, argv, + argvChanged: true, cwd: hardenedCwd, approvedCwdSnapshot, }; @@ -239,12 +254,15 @@ export function buildSystemRunApprovalPlan(params: { if (!hardening.ok) { return { ok: false, message: hardening.message }; } + const rawCommand = hardening.argvChanged + ? formatExecCommand(hardening.argv) || null + : command.cmdText.trim() || null; return { ok: true, plan: { argv: hardening.argv, cwd: hardening.cwd ?? null, - rawCommand: command.cmdText.trim() || null, + rawCommand, agentId: normalizeString(params.agentId), sessionKey: normalizeString(params.sessionKey), }, diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index a107ba24f81..b0952fb7eff 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it, type Mock, vi } from "vitest"; import { saveExecApprovals } from "../infra/exec-approvals.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; +import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js"; import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js"; import type { HandleSystemRunInvokeOptions } from "./invoke-system-run.js"; @@ -233,6 +234,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { preferMacAppExecHost: boolean; runViaResponse?: ExecHostResponse | null; command?: string[]; + rawCommand?: string | null; cwd?: string; security?: "full" | "allowlist"; ask?: "off" | "on-miss" | "always"; @@ -286,6 +288,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { client: {} as never, params: { command: params.command ?? ["echo", "ok"], + rawCommand: params.rawCommand, cwd: params.cwd, approved: params.approved ?? false, sessionKey: "agent:main:main", @@ -492,6 +495,39 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }, ); + it.runIf(process.platform !== "win32")( + "accepts prepared plans after PATH-token hardening rewrites argv", + async () => { + await withPathTokenCommand({ + tmpPrefix: "openclaw-prepare-run-path-pin-", + run: async ({ expected }) => { + const prepared = buildSystemRunApprovalPlan({ + command: ["poccmd", "hello"], + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.rawCommand, + approved: true, + security: "full", + ask: "off", + }); + expectCommandPinnedToCanonicalPath({ + runCommand, + expected, + commandTail: ["hello"], + }); + expectInvokeOk(sendInvokeResult); + }, + }); + }, + ); + it.runIf(process.platform !== "win32")( "pins PATH-token executable to canonical path for allowlist runs", async () => {