From 5874a387aefa2e73138b70b5afd30ddfffcd8a4e Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:35:50 -0700 Subject: [PATCH] fix(windows): reject unresolved cmd wrappers (#58436) * fix(windows): reject unresolved cmd wrappers * fix(windows): add wrapper policy coverage * fix(windows): document wrapper fallback migration * fix(windows): drop changelog entry from pr * chore: add changelog for Windows wrapper fail-closed behavior --------- Co-authored-by: Devin Robison Co-authored-by: Devin Robison --- CHANGELOG.md | 1 + docs/plugins/sdk-migration.md | 23 ++++++++++ src/acp/client.test.ts | 27 +++++------ src/acp/client.ts | 1 - src/plugin-sdk/windows-spawn.test.ts | 68 ++++++++++++++++++++++++++++ src/plugin-sdk/windows-spawn.ts | 3 +- 6 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 src/plugin-sdk/windows-spawn.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b0181d04d61..eda560d3225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Android/gateway: require TLS for non-loopback remote gateway endpoints while still allowing local loopback and emulator cleartext setup flows. (#58475) Thanks @eleqtrizit. - Exec/Windows: hide transient console windows for `runExec` and `runCommandWithTimeout` child-process launches, matching other Windows exec paths and stopping visible shell flashes during tool runs. (#59466) Thanks @lawrence3699. - Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly. +- ACP/Windows spawn: fail closed on unresolved `.cmd` and `.bat` OpenClaw wrappers unless a caller explicitly opts into shell fallback, so Windows ACP launches do not silently drop into shell-mediated execution when wrapper unwrapping fails. (#58436) Thanks @eleqtrizit. - Exec/Windows: prefer strict-inline-eval denial over generic allowlist prompts for interpreter carriers, while keeping persisted Windows allow-always approvals argv-bound. (#59780) Thanks @luoyanglang. ## 2026.4.2-beta.1 diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 94ea8f6f4e6..72bee4569d9 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -49,6 +49,29 @@ is a small, self-contained module with a clear purpose and documented contract. ## How to migrate + + If your plugin uses `openclaw/plugin-sdk/windows-spawn`, unresolved Windows + `.cmd`/`.bat` wrappers now fail closed unless you explicitly pass + `allowShellFallback: true`. + + ```typescript + // Before + const program = applyWindowsSpawnProgramPolicy({ candidate }); + + // After + const program = applyWindowsSpawnProgramPolicy({ + candidate, + // Only set this for trusted compatibility callers that intentionally + // accept shell-mediated fallback. + allowShellFallback: true, + }); + ``` + + If your caller does not intentionally rely on shell fallback, do not set + `allowShellFallback` and handle the thrown error instead. + + + Search your plugin for imports from either deprecated surface: diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index c30a5493979..89778da9ea5 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -272,26 +272,21 @@ describe("resolveAcpClientSpawnInvocation", () => { expect(resolved.windowsHide).toBe(true); }); - it("falls back to shell mode for unresolved wrappers on windows", async () => { + it("fails closed for unresolved wrappers on windows", async () => { const dir = await createTempDir(); const shimPath = path.join(dir, "openclaw.cmd"); await writeFile(shimPath, "@ECHO off\r\necho wrapper\r\n", "utf8"); - const resolved = resolveAcpClientSpawnInvocation( - { serverCommand: shimPath, serverArgs: ["acp"] }, - { - platform: "win32", - env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, - execPath: "C:\\node\\node.exe", - }, - ); - - expect(resolved).toEqual({ - command: shimPath, - args: ["acp"], - shell: true, - windowsHide: undefined, - }); + expect(() => + resolveAcpClientSpawnInvocation( + { serverCommand: shimPath, serverArgs: ["acp"] }, + { + platform: "win32", + env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, + execPath: "C:\\node\\node.exe", + }, + ), + ).toThrow(/without shell execution/); }); }); diff --git a/src/acp/client.ts b/src/acp/client.ts index 066c17ac81c..42274315ec6 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -260,7 +260,6 @@ export function resolveAcpClientSpawnInvocation( env: runtime.env, execPath: runtime.execPath, packageName: "openclaw", - allowShellFallback: true, }); const resolved = materializeWindowsSpawnProgram(program, params.serverArgs); return { diff --git a/src/plugin-sdk/windows-spawn.test.ts b/src/plugin-sdk/windows-spawn.test.ts new file mode 100644 index 00000000000..6e985b9504a --- /dev/null +++ b/src/plugin-sdk/windows-spawn.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram } from "./windows-spawn.js"; + +const tempDirs: string[] = []; + +async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-windows-spawn-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) { + continue; + } + await rm(dir, { + recursive: true, + force: true, + maxRetries: 8, + retryDelay: 8, + }); + } +}); + +describe("resolveWindowsSpawnProgram", () => { + it("fails closed by default for unresolved windows wrappers", async () => { + const dir = await createTempDir(); + const shimPath = path.join(dir, "wrapper.cmd"); + await writeFile(shimPath, "@ECHO off\r\necho wrapper\r\n", "utf8"); + + expect(() => + resolveWindowsSpawnProgram({ + command: shimPath, + platform: "win32", + env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, + execPath: "C:\\node\\node.exe", + }), + ).toThrow(/without shell execution/); + }); + + it("only returns shell fallback when explicitly opted in", async () => { + const dir = await createTempDir(); + const shimPath = path.join(dir, "wrapper.cmd"); + await writeFile(shimPath, "@ECHO off\r\necho wrapper\r\n", "utf8"); + + const resolved = resolveWindowsSpawnProgram({ + command: shimPath, + platform: "win32", + env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, + execPath: "C:\\node\\node.exe", + allowShellFallback: true, + }); + const invocation = materializeWindowsSpawnProgram(resolved, ["--cwd", "C:\\safe & calc.exe"]); + + expect(invocation).toEqual({ + command: shimPath, + argv: ["--cwd", "C:\\safe & calc.exe"], + resolution: "shell-fallback", + shell: true, + windowsHide: undefined, + }); + }); +}); diff --git a/src/plugin-sdk/windows-spawn.ts b/src/plugin-sdk/windows-spawn.ts index 9a296508165..f505e2d6fb1 100644 --- a/src/plugin-sdk/windows-spawn.ts +++ b/src/plugin-sdk/windows-spawn.ts @@ -37,6 +37,7 @@ export type ResolveWindowsSpawnProgramParams = { env?: NodeJS.ProcessEnv; execPath?: string; packageName?: string; + /** Trusted compatibility escape hatch for callers that intentionally accept shell-mediated wrapper execution. */ allowShellFallback?: boolean; }; export type ResolveWindowsSpawnProgramCandidateParams = Omit< @@ -265,7 +266,7 @@ export function applyWindowsSpawnProgramPolicy(params: { windowsHide: params.candidate.windowsHide, }; } - if (params.allowShellFallback !== false) { + if (params.allowShellFallback === true) { return { command: params.candidate.command, leadingArgv: [],