diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a6fee69be..ea66dd97bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,7 +99,7 @@ Docs: https://docs.openclaw.ai - ACP/Harness thread spawn routing: force ACP harness thread creation through `sessions_spawn` (`runtime: "acp"`, `thread: true`) and explicitly forbid `message action=thread-create` for ACP harness requests, avoiding misrouted `Unknown channel` errors. (#30957) Thanks @dutifulbob. - Docs/ACP permissions: document the correct `permissionMode` default (`approve-reads`) and clarify non-interactive permission failure behavior/troubleshooting guidance. (#31044) Thanks @barronlroth. - Security/Logging utility hardening: remove `eval`-based command execution from `scripts/clawlog.sh`, switch to argv-safe command construction, and escape predicate literals for user-supplied search/category filters to block local command/predicate injection paths. -- Security/ACPX Windows spawn hardening: resolve `.cmd/.bat` wrappers via PATH/PATHEXT and execute unwrapped Node/EXE entrypoints without shell parsing when possible, while preserving shell fallback for unknown custom wrappers to keep compatibility. +- Security/ACPX Windows spawn hardening: resolve `.cmd/.bat` wrappers via PATH/PATHEXT and execute unwrapped Node/EXE entrypoints without shell parsing when possible, while preserving compatibility fallback for unknown custom wrappers by default and adding an opt-in strict mode (`strictWindowsCmdWrapper`) to fail closed for unresolvable wrappers. - Security/Inbound metadata stripping: tighten sentinel matching and JSON-fence validation for inbound metadata stripping so user-authored lookalike lines no longer trigger unintended metadata removal. - Channels/Command parsing parity: align command-body parsing fields with channel command-gating text for Slack, Signal, Microsoft Teams, Mattermost, and BlueBubbles to avoid mention-strip mismatches and inconsistent command detection. - CLI/Startup (Raspberry Pi + small hosts): speed up startup by avoiding unnecessary plugin preload on fast routes, adding root `--version` fast-path bootstrap bypass, parallelizing status JSON/non-JSON scans where safe, and enabling Node compile cache at startup with env override compatibility (`NODE_COMPILE_CACHE`, `NODE_DISABLE_COMPILE_CACHE`). (#5871) Thanks @BookCatKid and @vincentkoc for raising startup reports, and @lupuletic for related startup work in #27973. diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json index 1941d3d41c8..e7280550301 100644 --- a/extensions/acpx/openclaw.plugin.json +++ b/extensions/acpx/openclaw.plugin.json @@ -24,6 +24,9 @@ "type": "string", "enum": ["deny", "fail"] }, + "strictWindowsCmdWrapper": { + "type": "boolean" + }, "timeoutSeconds": { "type": "number", "minimum": 0.001 @@ -55,6 +58,11 @@ "label": "Non-Interactive Permission Policy", "help": "acpx policy when interactive permission prompts are unavailable." }, + "strictWindowsCmdWrapper": { + "label": "Strict Windows cmd Wrapper", + "help": "When enabled on Windows, reject unresolved .cmd/.bat wrappers instead of shell fallback. Hardening-only; can break non-standard wrappers.", + "advanced": true + }, "timeoutSeconds": { "label": "Prompt Timeout Seconds", "help": "Optional acpx timeout for each runtime turn.", diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index b447aa7c7b7..3c1d1361b1e 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -20,6 +20,7 @@ describe("acpx plugin config parsing", () => { expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION); expect(resolved.allowPluginLocalInstall).toBe(true); expect(resolved.cwd).toBe(path.resolve("/tmp/workspace")); + expect(resolved.strictWindowsCmdWrapper).toBe(false); }); it("accepts command override and disables plugin-local auto-install", () => { @@ -109,4 +110,26 @@ describe("acpx plugin config parsing", () => { expect(parsed.success).toBe(false); }); + + it("accepts strictWindowsCmdWrapper override", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + strictWindowsCmdWrapper: true, + }, + workspaceDir: "/tmp/workspace", + }); + + expect(resolved.strictWindowsCmdWrapper).toBe(true); + }); + + it("rejects non-boolean strictWindowsCmdWrapper", () => { + expect(() => + resolveAcpxPluginConfig({ + rawConfig: { + strictWindowsCmdWrapper: "yes", + }, + workspaceDir: "/tmp/workspace", + }), + ).toThrow("strictWindowsCmdWrapper must be a boolean"); + }); }); diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index 0025b73caaa..51508867f59 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -24,6 +24,7 @@ export type AcpxPluginConfig = { cwd?: string; permissionMode?: AcpxPermissionMode; nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy; + strictWindowsCmdWrapper?: boolean; timeoutSeconds?: number; queueOwnerTtlSeconds?: number; }; @@ -36,6 +37,7 @@ export type ResolvedAcpxPluginConfig = { cwd: string; permissionMode: AcpxPermissionMode; nonInteractivePermissions: AcpxNonInteractivePermissionPolicy; + strictWindowsCmdWrapper: boolean; timeoutSeconds?: number; queueOwnerTtlSeconds: number; }; @@ -75,6 +77,7 @@ function parseAcpxPluginConfig(value: unknown): ParseResult { "cwd", "permissionMode", "nonInteractivePermissions", + "strictWindowsCmdWrapper", "timeoutSeconds", "queueOwnerTtlSeconds", ]); @@ -133,6 +136,11 @@ function parseAcpxPluginConfig(value: unknown): ParseResult { return { ok: false, message: "timeoutSeconds must be a positive number" }; } + const strictWindowsCmdWrapper = value.strictWindowsCmdWrapper; + if (strictWindowsCmdWrapper !== undefined && typeof strictWindowsCmdWrapper !== "boolean") { + return { ok: false, message: "strictWindowsCmdWrapper must be a boolean" }; + } + const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds; if ( queueOwnerTtlSeconds !== undefined && @@ -152,6 +160,8 @@ function parseAcpxPluginConfig(value: unknown): ParseResult { permissionMode: typeof permissionMode === "string" ? permissionMode : undefined, nonInteractivePermissions: typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined, + strictWindowsCmdWrapper: + typeof strictWindowsCmdWrapper === "boolean" ? strictWindowsCmdWrapper : undefined, timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined, queueOwnerTtlSeconds: typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined, @@ -205,6 +215,7 @@ export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema { type: "string", enum: [...ACPX_NON_INTERACTIVE_POLICIES], }, + strictWindowsCmdWrapper: { type: "boolean" }, timeoutSeconds: { type: "number", minimum: 0.001 }, queueOwnerTtlSeconds: { type: "number", minimum: 0 }, }, @@ -244,6 +255,7 @@ export function resolveAcpxPluginConfig(params: { permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE, nonInteractivePermissions: normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY, + strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper ?? false, timeoutSeconds: normalized.timeoutSeconds, queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS, }; diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts index 4fcdf6aee4e..94f0551d028 100644 --- a/extensions/acpx/src/ensure.ts +++ b/extensions/acpx/src/ensure.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import type { PluginLogger } from "openclaw/plugin-sdk"; import { ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT, buildAcpxLocalInstallCommand } from "./config.js"; -import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js"; +import { + resolveSpawnFailure, + type SpawnCommandOptions, + spawnAndCollect, +} from "./runtime-internals/process.js"; const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/; @@ -76,17 +80,32 @@ export async function checkAcpxVersion(params: { command: string; cwd?: string; expectedVersion?: string; + spawnOptions?: SpawnCommandOptions; }): Promise { const expectedVersion = params.expectedVersion?.trim() || undefined; const installCommand = buildAcpxLocalInstallCommand(expectedVersion ?? ACPX_PINNED_VERSION); const cwd = params.cwd ?? ACPX_PLUGIN_ROOT; const hasExpectedVersion = isExpectedVersionConfigured(expectedVersion); const probeArgs = hasExpectedVersion ? ["--version"] : ["--help"]; - const result = await spawnAndCollect({ + const spawnParams = { command: params.command, args: probeArgs, cwd, - }); + }; + let result: Awaited>; + try { + result = params.spawnOptions + ? await spawnAndCollect(spawnParams, params.spawnOptions) + : await spawnAndCollect(spawnParams); + } catch (error) { + return { + ok: false, + reason: "execution-failed", + message: error instanceof Error ? error.message : String(error), + expectedVersion, + installCommand, + }; + } if (result.error) { const spawnFailure = resolveSpawnFailure(result.error, cwd); @@ -186,6 +205,7 @@ export async function ensureAcpx(params: { pluginRoot?: string; expectedVersion?: string; allowInstall?: boolean; + spawnOptions?: SpawnCommandOptions; }): Promise { if (pendingEnsure) { return await pendingEnsure; @@ -201,6 +221,7 @@ export async function ensureAcpx(params: { command: params.command, cwd: pluginRoot, expectedVersion, + spawnOptions: params.spawnOptions, }); if (precheck.ok) { return; @@ -238,6 +259,7 @@ export async function ensureAcpx(params: { command: params.command, cwd: pluginRoot, expectedVersion, + spawnOptions: params.spawnOptions, }); if (!postcheck.ok) { diff --git a/extensions/acpx/src/runtime-internals/process.test.ts b/extensions/acpx/src/runtime-internals/process.test.ts index 161debb4718..6a60ed7839a 100644 --- a/extensions/acpx/src/runtime-internals/process.test.ts +++ b/extensions/acpx/src/runtime-internals/process.test.ts @@ -2,7 +2,8 @@ import { mkdir, 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 { resolveSpawnCommand } from "./process.js"; +import { createWindowsCmdShimFixture } from "../../../shared/windows-cmd-shim-test-fixtures.js"; +import { resolveSpawnCommand, type SpawnCommandCache } from "./process.js"; const tempDirs: string[] = []; @@ -42,6 +43,7 @@ describe("resolveSpawnCommand", () => { command: "acpx", args: ["--help"], }, + undefined, { platform: "darwin", env: {}, @@ -61,6 +63,7 @@ describe("resolveSpawnCommand", () => { command: "C:/tools/acpx/cli.js", args: ["--help"], }, + undefined, winRuntime({}), ); @@ -74,21 +77,19 @@ describe("resolveSpawnCommand", () => { const dir = await createTempDir(); const binDir = path.join(dir, "bin"); const scriptPath = path.join(dir, "acpx", "dist", "index.js"); - await mkdir(path.dirname(scriptPath), { recursive: true }); - await mkdir(binDir, { recursive: true }); - await writeFile(scriptPath, "console.log('ok');", "utf8"); const shimPath = path.join(binDir, "acpx.cmd"); - await writeFile( + await createWindowsCmdShimFixture({ shimPath, - ["@ECHO off", '"%~dp0\\..\\acpx\\dist\\index.js" %*', ""].join("\r\n"), - "utf8", - ); + scriptPath, + shimLine: '"%~dp0\\..\\acpx\\dist\\index.js" %*', + }); const resolved = resolveSpawnCommand( { command: "acpx", args: ["--format", "json", "agent", "status"], }, + undefined, winRuntime({ PATH: binDir, PATHEXT: ".CMD;.EXE;.BAT", @@ -114,6 +115,7 @@ describe("resolveSpawnCommand", () => { command: wrapperPath, args: ["--help"], }, + undefined, winRuntime({}), ); @@ -134,6 +136,7 @@ describe("resolveSpawnCommand", () => { command: wrapperPath, args: ["--arg", "value"], }, + undefined, winRuntime({}), ); @@ -143,4 +146,57 @@ describe("resolveSpawnCommand", () => { shell: true, }); }); + + it("fails closed in strict mode when wrapper cannot be safely unwrapped", async () => { + const dir = await createTempDir(); + const wrapperPath = path.join(dir, "strict-wrapper.cmd"); + await writeFile(wrapperPath, "@ECHO off\r\necho wrapper\r\n", "utf8"); + + expect(() => + resolveSpawnCommand( + { + command: wrapperPath, + args: ["--arg", "value"], + }, + { strictWindowsCmdWrapper: true }, + winRuntime({}), + ), + ).toThrow(/without shell execution/); + }); + + it("reuses resolved command when cache is provided", async () => { + const dir = await createTempDir(); + const wrapperPath = path.join(dir, "acpx.cmd"); + const scriptPath = path.join(dir, "acpx", "dist", "index.js"); + await createWindowsCmdShimFixture({ + shimPath: wrapperPath, + scriptPath, + shimLine: '"%~dp0\\acpx\\dist\\index.js" %*', + }); + + const cache: SpawnCommandCache = {}; + const first = resolveSpawnCommand( + { + command: wrapperPath, + args: ["--help"], + }, + { cache }, + winRuntime({}), + ); + await rm(scriptPath, { force: true }); + + const second = resolveSpawnCommand( + { + command: wrapperPath, + args: ["--version"], + }, + { cache }, + winRuntime({}), + ); + + expect(first.command).toBe("C:\\node\\node.exe"); + expect(second.command).toBe("C:\\node\\node.exe"); + expect(first.args[0]).toBe(scriptPath); + expect(second.args[0]).toBe(scriptPath); + }); }); diff --git a/extensions/acpx/src/runtime-internals/process.ts b/extensions/acpx/src/runtime-internals/process.ts index a9fb1761bf1..fb92a95572f 100644 --- a/extensions/acpx/src/runtime-internals/process.ts +++ b/extensions/acpx/src/runtime-internals/process.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { existsSync, readFileSync, statSync } from "node:fs"; -import path from "node:path"; +import { existsSync } from "node:fs"; +import type { WindowsSpawnProgram } from "openclaw/plugin-sdk"; +import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram } from "openclaw/plugin-sdk"; export type SpawnExit = { code: number | null; @@ -21,147 +22,72 @@ type SpawnRuntime = { execPath: string; }; +export type SpawnCommandCache = { + key?: string; + program?: WindowsSpawnProgram; +}; + +export type SpawnCommandOptions = { + strictWindowsCmdWrapper?: boolean; + cache?: SpawnCommandCache; +}; + const DEFAULT_RUNTIME: SpawnRuntime = { platform: process.platform, env: process.env, execPath: process.execPath, }; -function isFilePath(candidate: string): boolean { - try { - return statSync(candidate).isFile(); - } catch { - return false; - } -} - -function resolveWindowsExecutablePath(command: string, env: NodeJS.ProcessEnv): string { - if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) { - return command; - } - - const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? ""; - const pathEntries = pathValue - .split(";") - .map((entry) => entry.trim()) - .filter(Boolean); - const hasExtension = path.extname(command).length > 0; - const pathExtRaw = - env.PATHEXT ?? - env.Pathext ?? - process.env.PATHEXT ?? - process.env.Pathext ?? - ".EXE;.CMD;.BAT;.COM"; - const pathExt = hasExtension - ? [""] - : pathExtRaw - .split(";") - .map((ext) => ext.trim()) - .filter(Boolean) - .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)); - - for (const dir of pathEntries) { - for (const ext of pathExt) { - for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) { - const candidate = path.join(dir, `${command}${candidateExt}`); - if (isFilePath(candidate)) { - return candidate; - } - } - } - } - - return command; -} - -function resolveNodeEntrypointFromCmdShim(wrapperPath: string): string | null { - if (!isFilePath(wrapperPath)) { - return null; - } - try { - const content = readFileSync(wrapperPath, "utf8"); - const candidates: string[] = []; - for (const match of content.matchAll(/"([^"\r\n]*)"/g)) { - const token = match[1] ?? ""; - const relMatch = token.match(/%~?dp0%?\s*[\\/]*(.*)$/i); - const relative = relMatch?.[1]?.trim(); - if (!relative) { - continue; - } - const normalizedRelative = relative.replace(/[\\/]+/g, path.sep).replace(/^[\\/]+/, ""); - const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative); - if (isFilePath(candidate)) { - candidates.push(candidate); - } - } - const nonNode = candidates.find((candidate) => { - const base = path.basename(candidate).toLowerCase(); - return base !== "node.exe" && base !== "node"; - }); - return nonNode ?? null; - } catch { - return null; - } -} - export function resolveSpawnCommand( params: { command: string; args: string[] }, + options?: SpawnCommandOptions, runtime: SpawnRuntime = DEFAULT_RUNTIME, ): ResolvedSpawnCommand { - if (runtime.platform !== "win32") { - return { command: params.command, args: params.args }; - } + const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true; + const cacheKey = `${params.command}::${strictWindowsCmdWrapper ? "strict" : "compat"}`; + const cachedProgram = options?.cache; - const resolvedCommand = resolveWindowsExecutablePath(params.command, runtime.env); - const extension = path.extname(resolvedCommand).toLowerCase(); - if (extension === ".js" || extension === ".cjs" || extension === ".mjs") { - return { - command: runtime.execPath, - args: [resolvedCommand, ...params.args], - windowsHide: true, - }; - } - - if (extension === ".cmd" || extension === ".bat") { - const entrypoint = resolveNodeEntrypointFromCmdShim(resolvedCommand); - if (entrypoint) { - const entryExt = path.extname(entrypoint).toLowerCase(); - if (entryExt === ".exe") { - return { - command: entrypoint, - args: params.args, - windowsHide: true, - }; - } - return { - command: runtime.execPath, - args: [entrypoint, ...params.args], - windowsHide: true, - }; + let program = + cachedProgram?.key === cacheKey && cachedProgram.program ? cachedProgram.program : undefined; + if (!program) { + program = resolveWindowsSpawnProgram({ + command: params.command, + platform: runtime.platform, + env: runtime.env, + execPath: runtime.execPath, + packageName: "acpx", + allowShellFallback: !strictWindowsCmdWrapper, + }); + if (cachedProgram) { + cachedProgram.key = cacheKey; + cachedProgram.program = program; } - // Preserve compatibility for non-npm wrappers we cannot safely unwrap. - return { - command: resolvedCommand, - args: params.args, - shell: true, - }; } + const resolved = materializeWindowsSpawnProgram(program, params.args); return { - command: resolvedCommand, - args: params.args, + command: resolved.command, + args: resolved.argv, + shell: resolved.shell, + windowsHide: resolved.windowsHide, }; } -export function spawnWithResolvedCommand(params: { - command: string; - args: string[]; - cwd: string; -}): ChildProcessWithoutNullStreams { - const resolved = resolveSpawnCommand({ - command: params.command, - args: params.args, - }); +export function spawnWithResolvedCommand( + params: { + command: string; + args: string[]; + cwd: string; + }, + options?: SpawnCommandOptions, +): ChildProcessWithoutNullStreams { + const resolved = resolveSpawnCommand( + { + command: params.command, + args: params.args, + }, + options, + ); return spawn(resolved.command, resolved.args, { cwd: params.cwd, @@ -193,17 +119,20 @@ export async function waitForExit(child: ChildProcessWithoutNullStreams): Promis }); } -export async function spawnAndCollect(params: { - command: string; - args: string[]; - cwd: string; -}): Promise<{ +export async function spawnAndCollect( + params: { + command: string; + args: string[]; + cwd: string; + }, + options?: SpawnCommandOptions, +): Promise<{ stdout: string; stderr: string; code: number | null; error: Error | null; }> { - const child = spawnWithResolvedCommand(params); + const child = spawnWithResolvedCommand(params, options); child.stdin.end(); let stdout = ""; diff --git a/extensions/acpx/src/runtime-internals/test-fixtures.ts b/extensions/acpx/src/runtime-internals/test-fixtures.ts index a864311345b..be56f9a1bda 100644 --- a/extensions/acpx/src/runtime-internals/test-fixtures.ts +++ b/extensions/acpx/src/runtime-internals/test-fixtures.ts @@ -272,6 +272,7 @@ export async function createMockRuntimeFixture(params?: { cwd: dir, permissionMode: params?.permissionMode ?? "approve-all", nonInteractivePermissions: "fail", + strictWindowsCmdWrapper: false, queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1, }; diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 6539df987c6..2e078688752 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -325,6 +325,7 @@ describe("AcpxRuntime", () => { cwd: process.cwd(), permissionMode: "approve-reads", nonInteractivePermissions: "fail", + strictWindowsCmdWrapper: false, queueOwnerTtlSeconds: 0.1, }, { logger: NOOP_LOGGER }, @@ -349,6 +350,7 @@ describe("AcpxRuntime", () => { cwd: process.cwd(), permissionMode: "approve-reads", nonInteractivePermissions: "fail", + strictWindowsCmdWrapper: false, queueOwnerTtlSeconds: 0.1, }, { logger: NOOP_LOGGER }, diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index d83aacdbb24..87369515e20 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -21,6 +21,8 @@ import { } from "./runtime-internals/events.js"; import { resolveSpawnFailure, + type SpawnCommandCache, + type SpawnCommandOptions, spawnAndCollect, spawnWithResolvedCommand, waitForExit, @@ -94,6 +96,8 @@ export class AcpxRuntime implements AcpRuntime { private healthy = false; private readonly logger?: PluginLogger; private readonly queueOwnerTtlSeconds: number; + private readonly spawnCommandCache: SpawnCommandCache = {}; + private readonly spawnCommandOptions: SpawnCommandOptions; constructor( private readonly config: ResolvedAcpxPluginConfig, @@ -110,6 +114,10 @@ export class AcpxRuntime implements AcpRuntime { requestedQueueOwnerTtlSeconds >= 0 ? requestedQueueOwnerTtlSeconds : this.config.queueOwnerTtlSeconds; + this.spawnCommandOptions = { + strictWindowsCmdWrapper: this.config.strictWindowsCmdWrapper, + cache: this.spawnCommandCache, + }; } isHealthy(): boolean { @@ -121,6 +129,7 @@ export class AcpxRuntime implements AcpRuntime { command: this.config.command, cwd: this.config.cwd, expectedVersion: this.config.expectedVersion, + spawnOptions: this.spawnCommandOptions, }); if (!versionCheck.ok) { this.healthy = false; @@ -128,11 +137,14 @@ export class AcpxRuntime implements AcpRuntime { } try { - const result = await spawnAndCollect({ - command: this.config.command, - args: ["--help"], - cwd: this.config.cwd, - }); + const result = await spawnAndCollect( + { + command: this.config.command, + args: ["--help"], + cwd: this.config.cwd, + }, + this.spawnCommandOptions, + ); this.healthy = result.error == null && (result.code ?? 0) === 0; } catch { this.healthy = false; @@ -217,11 +229,14 @@ export class AcpxRuntime implements AcpRuntime { if (input.signal) { input.signal.addEventListener("abort", onAbort, { once: true }); } - const child = spawnWithResolvedCommand({ - command: this.config.command, - args, - cwd: state.cwd, - }); + const child = spawnWithResolvedCommand( + { + command: this.config.command, + args, + cwd: state.cwd, + }, + this.spawnCommandOptions, + ); child.stdin.on("error", () => { // Ignore EPIPE when the child exits before stdin flush completes. }); @@ -379,6 +394,7 @@ export class AcpxRuntime implements AcpRuntime { command: this.config.command, cwd: this.config.cwd, expectedVersion: this.config.expectedVersion, + spawnOptions: this.spawnCommandOptions, }); if (!versionCheck.ok) { this.healthy = false; @@ -396,11 +412,14 @@ export class AcpxRuntime implements AcpRuntime { } try { - const result = await spawnAndCollect({ - command: this.config.command, - args: ["--help"], - cwd: this.config.cwd, - }); + const result = await spawnAndCollect( + { + command: this.config.command, + args: ["--help"], + cwd: this.config.cwd, + }, + this.spawnCommandOptions, + ); if (result.error) { const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd); if (spawnFailure === "missing-command") { @@ -528,11 +547,14 @@ export class AcpxRuntime implements AcpRuntime { fallbackCode: AcpRuntimeErrorCode; ignoreNoSession?: boolean; }): Promise { - const result = await spawnAndCollect({ - command: this.config.command, - args: params.args, - cwd: params.cwd, - }); + const result = await spawnAndCollect( + { + command: this.config.command, + args: params.args, + cwd: params.cwd, + }, + this.spawnCommandOptions, + ); if (result.error) { const spawnFailure = resolveSpawnFailure(result.error, params.cwd); diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index 9ad3279675f..d89b9e281c7 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -72,6 +72,9 @@ export function createAcpxRuntimeService( logger: ctx.logger, expectedVersion: pluginConfig.expectedVersion, allowInstall: pluginConfig.allowPluginLocalInstall, + spawnOptions: { + strictWindowsCmdWrapper: pluginConfig.strictWindowsCmdWrapper, + }, }); if (currentRevision !== lifecycleRevision) { return; diff --git a/extensions/lobster/src/test-helpers.ts b/extensions/lobster/src/test-helpers.ts index 30f2dc81d1b..19609c0c11b 100644 --- a/extensions/lobster/src/test-helpers.ts +++ b/extensions/lobster/src/test-helpers.ts @@ -1,6 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext"; const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const; @@ -43,14 +40,4 @@ export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void process.env[key] = value; } } - -export async function createWindowsCmdShimFixture(params: { - shimPath: string; - scriptPath: string; - shimLine: string; -}): Promise { - await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); - await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8"); -} +export { createWindowsCmdShimFixture } from "../../shared/windows-cmd-shim-test-fixtures.js"; diff --git a/extensions/lobster/src/windows-spawn.ts b/extensions/lobster/src/windows-spawn.ts index a416b759c93..187e71e3521 100644 --- a/extensions/lobster/src/windows-spawn.ts +++ b/extensions/lobster/src/windows-spawn.ts @@ -1,5 +1,4 @@ -import fs from "node:fs"; -import path from "node:path"; +import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram } from "openclaw/plugin-sdk"; type SpawnTarget = { command: string; @@ -7,187 +6,24 @@ type SpawnTarget = { windowsHide?: boolean; }; -function isFilePath(value: string): boolean { - try { - const stat = fs.statSync(value); - return stat.isFile(); - } catch { - return false; - } -} - -function resolveWindowsExecutablePath(execPath: string, env: NodeJS.ProcessEnv): string { - if (execPath.includes("/") || execPath.includes("\\") || path.isAbsolute(execPath)) { - return execPath; - } - - const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? ""; - const pathEntries = pathValue - .split(";") - .map((entry) => entry.trim()) - .filter(Boolean); - - const hasExtension = path.extname(execPath).length > 0; - const pathExtRaw = - env.PATHEXT ?? - env.Pathext ?? - process.env.PATHEXT ?? - process.env.Pathext ?? - ".EXE;.CMD;.BAT;.COM"; - const pathExt = hasExtension - ? [""] - : pathExtRaw - .split(";") - .map((ext) => ext.trim()) - .filter(Boolean) - .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)); - - for (const dir of pathEntries) { - for (const ext of pathExt) { - for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) { - const candidate = path.join(dir, `${execPath}${candidateExt}`); - if (isFilePath(candidate)) { - return candidate; - } - } - } - } - - return execPath; -} - -function resolveBinEntry(binField: string | Record | undefined): string | null { - if (typeof binField === "string") { - const trimmed = binField.trim(); - return trimmed || null; - } - if (!binField || typeof binField !== "object") { - return null; - } - - const preferred = binField.lobster; - if (typeof preferred === "string" && preferred.trim()) { - return preferred.trim(); - } - - for (const value of Object.values(binField)) { - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - } - return null; -} - -function resolveLobsterScriptFromPackageJson(wrapperPath: string): string | null { - const wrapperDir = path.dirname(wrapperPath); - const packageDirs = [ - // Local install: /node_modules/.bin/lobster.cmd -> ../lobster - path.resolve(wrapperDir, "..", "lobster"), - // Global npm install: /lobster.cmd -> ./node_modules/lobster - path.resolve(wrapperDir, "node_modules", "lobster"), - ]; - - for (const packageDir of packageDirs) { - const packageJsonPath = path.join(packageDir, "package.json"); - if (!isFilePath(packageJsonPath)) { - continue; - } - - try { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - bin?: string | Record; - }; - const scriptRel = resolveBinEntry(packageJson.bin); - if (!scriptRel) { - continue; - } - const scriptPath = path.resolve(packageDir, scriptRel); - if (isFilePath(scriptPath)) { - return scriptPath; - } - } catch { - // Ignore malformed package metadata; caller will throw a guided error. - } - } - - return null; -} - -function resolveLobsterScriptFromCmdShim(wrapperPath: string): string | null { - if (!isFilePath(wrapperPath)) { - return null; - } - - try { - const content = fs.readFileSync(wrapperPath, "utf8"); - const candidates: string[] = []; - const extractRelativeFromToken = (token: string): string | null => { - const match = token.match(/%~?dp0%\s*[\\/]*(.*)$/i); - if (!match) { - return null; - } - const relative = match[1]; - if (!relative) { - return null; - } - return relative; - }; - - const matches = content.matchAll(/"([^"\r\n]*)"/g); - for (const match of matches) { - const token = match[1] ?? ""; - const relative = extractRelativeFromToken(token); - if (!relative) { - continue; - } - - const normalizedRelative = relative - .trim() - .replace(/[\\/]+/g, path.sep) - .replace(/^[\\/]+/, ""); - const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative); - if (isFilePath(candidate)) { - candidates.push(candidate); - } - } - - const nonNode = candidates.find((candidate) => { - const base = path.basename(candidate).toLowerCase(); - return base !== "node.exe" && base !== "node"; - }); - if (nonNode) { - return nonNode; - } - } catch { - // Ignore unreadable shims; caller will throw a guided error. - } - - return null; -} - export function resolveWindowsLobsterSpawn( execPath: string, argv: string[], env: NodeJS.ProcessEnv, ): SpawnTarget { - const resolvedExecPath = resolveWindowsExecutablePath(execPath, env); - const ext = path.extname(resolvedExecPath).toLowerCase(); - if (ext !== ".cmd" && ext !== ".bat") { - return { command: resolvedExecPath, argv }; + const program = resolveWindowsSpawnProgram({ + command: execPath, + env, + packageName: "lobster", + allowShellFallback: false, + }); + const resolved = materializeWindowsSpawnProgram(program, argv); + if (resolved.shell) { + throw new Error("lobster wrapper resolved to shell fallback unexpectedly"); } - - const scriptPath = - resolveLobsterScriptFromCmdShim(resolvedExecPath) ?? - resolveLobsterScriptFromPackageJson(resolvedExecPath); - if (!scriptPath) { - throw new Error( - `${path.basename(resolvedExecPath)} wrapper resolved, but no Node entrypoint could be resolved without shell execution. Ensure Lobster is installed and runnable on PATH (prefer lobster.exe).`, - ); - } - - const entryExt = path.extname(scriptPath).toLowerCase(); - if (entryExt === ".exe") { - return { command: scriptPath, argv, windowsHide: true }; - } - return { command: process.execPath, argv: [scriptPath, ...argv], windowsHide: true }; + return { + command: resolved.command, + argv: resolved.argv, + windowsHide: resolved.windowsHide, + }; } diff --git a/extensions/shared/windows-cmd-shim-test-fixtures.ts b/extensions/shared/windows-cmd-shim-test-fixtures.ts new file mode 100644 index 00000000000..ce73d0f8398 --- /dev/null +++ b/extensions/shared/windows-cmd-shim-test-fixtures.ts @@ -0,0 +1,13 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function createWindowsCmdShimFixture(params: { + shimPath: string; + scriptPath: string; + shimLine: string; +}): Promise { + await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); + await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8"); +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index b18eae6c25c..b366328456d 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -236,6 +236,17 @@ export { createLoggerBackedRuntime } from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; +export { + materializeWindowsSpawnProgram, + resolveWindowsExecutablePath, + resolveWindowsSpawnProgram, +} from "./windows-spawn.js"; +export type { + ResolveWindowsSpawnProgramParams, + WindowsSpawnInvocation, + WindowsSpawnProgram, + WindowsSpawnResolution, +} from "./windows-spawn.js"; export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export { runPluginCommandWithTimeout, diff --git a/src/plugin-sdk/windows-spawn.ts b/src/plugin-sdk/windows-spawn.ts new file mode 100644 index 00000000000..87bc2da6858 --- /dev/null +++ b/src/plugin-sdk/windows-spawn.ts @@ -0,0 +1,259 @@ +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +export type WindowsSpawnResolution = + | "direct" + | "node-entrypoint" + | "exe-entrypoint" + | "shell-fallback"; + +export type WindowsSpawnProgram = { + command: string; + leadingArgv: string[]; + resolution: WindowsSpawnResolution; + shell?: boolean; + windowsHide?: boolean; +}; + +export type WindowsSpawnInvocation = { + command: string; + argv: string[]; + resolution: WindowsSpawnResolution; + shell?: boolean; + windowsHide?: boolean; +}; + +export type ResolveWindowsSpawnProgramParams = { + command: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + execPath?: string; + packageName?: string; + allowShellFallback?: boolean; +}; + +function isFilePath(candidate: string): boolean { + try { + return statSync(candidate).isFile(); + } catch { + return false; + } +} + +export function resolveWindowsExecutablePath(command: string, env: NodeJS.ProcessEnv): string { + if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) { + return command; + } + + const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? ""; + const pathEntries = pathValue + .split(";") + .map((entry) => entry.trim()) + .filter(Boolean); + const hasExtension = path.extname(command).length > 0; + const pathExtRaw = + env.PATHEXT ?? + env.Pathext ?? + process.env.PATHEXT ?? + process.env.Pathext ?? + ".EXE;.CMD;.BAT;.COM"; + const pathExt = hasExtension + ? [""] + : pathExtRaw + .split(";") + .map((ext) => ext.trim()) + .filter(Boolean) + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)); + + for (const dir of pathEntries) { + for (const ext of pathExt) { + for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) { + const candidate = path.join(dir, `${command}${candidateExt}`); + if (isFilePath(candidate)) { + return candidate; + } + } + } + } + + return command; +} + +function resolveEntrypointFromCmdShim(wrapperPath: string): string | null { + if (!isFilePath(wrapperPath)) { + return null; + } + + try { + const content = readFileSync(wrapperPath, "utf8"); + const candidates: string[] = []; + for (const match of content.matchAll(/"([^"\r\n]*)"/g)) { + const token = match[1] ?? ""; + const relMatch = token.match(/%~?dp0%?\s*[\\/]*(.*)$/i); + const relative = relMatch?.[1]?.trim(); + if (!relative) { + continue; + } + const normalizedRelative = relative.replace(/[\\/]+/g, path.sep).replace(/^[\\/]+/, ""); + const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative); + if (isFilePath(candidate)) { + candidates.push(candidate); + } + } + const nonNode = candidates.find((candidate) => { + const base = path.basename(candidate).toLowerCase(); + return base !== "node.exe" && base !== "node"; + }); + return nonNode ?? null; + } catch { + return null; + } +} + +function resolveBinEntry( + packageName: string | undefined, + binField: string | Record | undefined, +): string | null { + if (typeof binField === "string") { + const trimmed = binField.trim(); + return trimmed || null; + } + if (!binField || typeof binField !== "object") { + return null; + } + + if (packageName) { + const preferred = binField[packageName]; + if (typeof preferred === "string" && preferred.trim()) { + return preferred.trim(); + } + } + + for (const value of Object.values(binField)) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return null; +} + +function resolveEntrypointFromPackageJson( + wrapperPath: string, + packageName?: string, +): string | null { + if (!packageName) { + return null; + } + + const wrapperDir = path.dirname(wrapperPath); + const packageDirs = [ + path.resolve(wrapperDir, "..", packageName), + path.resolve(wrapperDir, "node_modules", packageName), + ]; + + for (const packageDir of packageDirs) { + const packageJsonPath = path.join(packageDir, "package.json"); + if (!isFilePath(packageJsonPath)) { + continue; + } + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + bin?: string | Record; + }; + const entryRel = resolveBinEntry(packageName, packageJson.bin); + if (!entryRel) { + continue; + } + const entryPath = path.resolve(packageDir, entryRel); + if (isFilePath(entryPath)) { + return entryPath; + } + } catch { + // Ignore malformed package metadata. + } + } + + return null; +} + +export function resolveWindowsSpawnProgram( + params: ResolveWindowsSpawnProgramParams, +): WindowsSpawnProgram { + const platform = params.platform ?? process.platform; + const env = params.env ?? process.env; + const execPath = params.execPath ?? process.execPath; + + if (platform !== "win32") { + return { + command: params.command, + leadingArgv: [], + resolution: "direct", + }; + } + + const resolvedCommand = resolveWindowsExecutablePath(params.command, env); + const ext = path.extname(resolvedCommand).toLowerCase(); + if (ext === ".js" || ext === ".cjs" || ext === ".mjs") { + return { + command: execPath, + leadingArgv: [resolvedCommand], + resolution: "node-entrypoint", + windowsHide: true, + }; + } + + if (ext === ".cmd" || ext === ".bat") { + const entrypoint = + resolveEntrypointFromCmdShim(resolvedCommand) ?? + resolveEntrypointFromPackageJson(resolvedCommand, params.packageName); + if (entrypoint) { + const entryExt = path.extname(entrypoint).toLowerCase(); + if (entryExt === ".exe") { + return { + command: entrypoint, + leadingArgv: [], + resolution: "exe-entrypoint", + windowsHide: true, + }; + } + return { + command: execPath, + leadingArgv: [entrypoint], + resolution: "node-entrypoint", + windowsHide: true, + }; + } + + if (params.allowShellFallback !== false) { + return { + command: resolvedCommand, + leadingArgv: [], + resolution: "shell-fallback", + shell: true, + }; + } + + throw new Error( + `${path.basename(resolvedCommand)} wrapper resolved, but no executable/Node entrypoint could be resolved without shell execution.`, + ); + } + + return { + command: resolvedCommand, + leadingArgv: [], + resolution: "direct", + }; +} + +export function materializeWindowsSpawnProgram( + program: WindowsSpawnProgram, + argv: string[], +): WindowsSpawnInvocation { + return { + command: program.command, + argv: [...program.leadingArgv, ...argv], + resolution: program.resolution, + shell: program.shell, + windowsHide: program.windowsHide, + }; +}