diff --git a/CHANGELOG.md b/CHANGELOG.md index 335cfde35..e2db99bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc. - Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus. - Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony. +- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman. - LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob. - Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002. - Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau. diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index ec08fc7d9..fb806eed9 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -1,6 +1,13 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import type { RequestPermissionRequest } from "@agentclientprotocol/sdk"; -import { describe, expect, it, vi } from "vitest"; -import { resolveAcpClientSpawnEnv, resolvePermissionRequest } from "./client.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + resolveAcpClientSpawnEnv, + resolveAcpClientSpawnInvocation, + resolvePermissionRequest, +} from "./client.js"; import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; function makePermissionRequest( @@ -28,6 +35,24 @@ function makePermissionRequest( }; } +const tempDirs: string[] = []; + +async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-acp-client-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 }); + } +}); + describe("resolveAcpClientSpawnEnv", () => { it("sets OPENCLAW_SHELL marker and preserves existing env values", () => { const env = resolveAcpClientSpawnEnv({ @@ -48,6 +73,69 @@ describe("resolveAcpClientSpawnEnv", () => { }); }); +describe("resolveAcpClientSpawnInvocation", () => { + it("keeps non-windows invocation unchanged", () => { + const resolved = resolveAcpClientSpawnInvocation( + { serverCommand: "openclaw", serverArgs: ["acp", "--verbose"] }, + { + platform: "darwin", + env: {}, + execPath: "/usr/bin/node", + }, + ); + expect(resolved).toEqual({ + command: "openclaw", + args: ["acp", "--verbose"], + shell: undefined, + windowsHide: undefined, + }); + }); + + it("unwraps .cmd shim entrypoint on windows", async () => { + const dir = await createTempDir(); + const scriptPath = path.join(dir, "openclaw", "dist", "entry.js"); + const shimPath = path.join(dir, "openclaw.cmd"); + await mkdir(path.dirname(scriptPath), { recursive: true }); + await writeFile(scriptPath, "console.log('ok')\n", "utf8"); + await writeFile(shimPath, `@ECHO off\r\n"%~dp0\\openclaw\\dist\\entry.js" %*\r\n`, "utf8"); + + const resolved = resolveAcpClientSpawnInvocation( + { serverCommand: shimPath, serverArgs: ["acp", "--verbose"] }, + { + platform: "win32", + env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, + execPath: "C:\\node\\node.exe", + }, + ); + expect(resolved.command).toBe("C:\\node\\node.exe"); + expect(resolved.args).toEqual([scriptPath, "acp", "--verbose"]); + expect(resolved.shell).toBeUndefined(); + expect(resolved.windowsHide).toBe(true); + }); + + it("falls back to shell mode 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, + }); + }); +}); + describe("resolvePermissionRequest", () => { it("auto-approves safe tools without prompting", async () => { const prompt = vi.fn(async () => true); diff --git a/src/acp/client.ts b/src/acp/client.ts index a716c4d54..0cf9a194d 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -15,6 +15,10 @@ import { } from "@agentclientprotocol/sdk"; import { isKnownCoreToolId } from "../agents/tool-catalog.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; +import { + materializeWindowsSpawnProgram, + resolveWindowsSpawnProgram, +} from "../plugin-sdk/windows-spawn.js"; import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js"; const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]); @@ -348,6 +352,39 @@ export function resolveAcpClientSpawnEnv( return { ...baseEnv, OPENCLAW_SHELL: "acp-client" }; } +type AcpSpawnRuntime = { + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + execPath: string; +}; + +const DEFAULT_ACP_SPAWN_RUNTIME: AcpSpawnRuntime = { + platform: process.platform, + env: process.env, + execPath: process.execPath, +}; + +export function resolveAcpClientSpawnInvocation( + params: { serverCommand: string; serverArgs: string[] }, + runtime: AcpSpawnRuntime = DEFAULT_ACP_SPAWN_RUNTIME, +): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } { + const program = resolveWindowsSpawnProgram({ + command: params.serverCommand, + platform: runtime.platform, + env: runtime.env, + execPath: runtime.execPath, + packageName: "openclaw", + allowShellFallback: true, + }); + const resolved = materializeWindowsSpawnProgram(program, params.serverArgs); + return { + command: resolved.command, + args: resolved.argv, + shell: resolved.shell, + windowsHide: resolved.windowsHide, + }; +} + function resolveSelfEntryPath(): string | null { // Prefer a path relative to the built module location (dist/acp/client.js -> dist/entry.js). try { @@ -413,13 +450,24 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise { return new Promise((resolve, reject) => { - const child = spawn("docker", args, { + const spawnInvocation = resolveDockerSpawnInvocation(args); + const child = spawn(spawnInvocation.command, spawnInvocation.args, { stdio: ["pipe", "pipe", "pipe"], + shell: spawnInvocation.shell, + windowsHide: spawnInvocation.windowsHide, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; diff --git a/src/agents/sandbox/docker.windows.test.ts b/src/agents/sandbox/docker.windows.test.ts new file mode 100644 index 000000000..d9fe1d1f5 --- /dev/null +++ b/src/agents/sandbox/docker.windows.test.ts @@ -0,0 +1,79 @@ +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 { resolveDockerSpawnInvocation } from "./docker.js"; + +const tempDirs: string[] = []; + +async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-docker-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 }); + } +}); + +describe("resolveDockerSpawnInvocation", () => { + it("keeps non-windows invocation unchanged", () => { + const resolved = resolveDockerSpawnInvocation(["version"], { + platform: "darwin", + env: {}, + execPath: "/usr/bin/node", + }); + expect(resolved).toEqual({ + command: "docker", + args: ["version"], + shell: undefined, + windowsHide: undefined, + }); + }); + + it("prefers docker.exe entrypoint over cmd shell fallback on windows", async () => { + const dir = await createTempDir(); + const exePath = path.join(dir, "docker.exe"); + const cmdPath = path.join(dir, "docker.cmd"); + await writeFile(exePath, "", "utf8"); + await writeFile(cmdPath, `@ECHO off\r\n"%~dp0\\docker.exe" %*\r\n`, "utf8"); + + const resolved = resolveDockerSpawnInvocation(["version"], { + platform: "win32", + env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, + execPath: "C:\\node\\node.exe", + }); + + expect(resolved).toEqual({ + command: exePath, + args: ["version"], + shell: undefined, + windowsHide: true, + }); + }); + + it("falls back to shell mode when only unresolved docker.cmd wrapper exists", async () => { + const dir = await createTempDir(); + const cmdPath = path.join(dir, "docker.cmd"); + await mkdir(path.dirname(cmdPath), { recursive: true }); + await writeFile(cmdPath, "@ECHO off\r\necho docker\r\n", "utf8"); + + const resolved = resolveDockerSpawnInvocation(["ps"], { + platform: "win32", + env: { PATH: dir, PATHEXT: ".CMD;.EXE;.BAT" }, + execPath: "C:\\node\\node.exe", + }); + expect(path.normalize(resolved.command).toLowerCase()).toBe( + path.normalize(cmdPath).toLowerCase(), + ); + expect(resolved.args).toEqual(["ps"]); + expect(resolved.shell).toBe(true); + expect(resolved.windowsHide).toBeUndefined(); + }); +}); diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 84330534b..6324eba13 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -899,6 +899,8 @@ describe("QmdMemoryManager", () => { expect(qmdCalls.length).toBeGreaterThan(0); for (const call of qmdCalls) { expect(call[0]).toBe("qmd.cmd"); + const options = call[2] as { shell?: boolean } | undefined; + expect(options?.shell).toBe(true); } await manager.close(); @@ -1408,6 +1410,8 @@ describe("QmdMemoryManager", () => { ); expect(mcporterCall).toBeDefined(); expect(mcporterCall?.[0]).toBe("mcporter.cmd"); + const options = mcporterCall?.[2] as { shell?: boolean } | undefined; + expect(options?.shell).toBe(true); await manager.close(); } finally { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 5e3360f20..a9a4d58c3 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -7,6 +7,10 @@ import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + materializeWindowsSpawnProgram, + resolveWindowsSpawnProgram, +} from "../plugin-sdk/windows-spawn.js"; import { isFileMissingError, statRegularFile } from "./fs-utils.js"; import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js"; import { @@ -65,6 +69,23 @@ function resolveWindowsCommandShim(command: string): string { return command; } +function resolveSpawnInvocation(params: { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + packageName: string; +}) { + const program = resolveWindowsSpawnProgram({ + command: resolveWindowsCommandShim(params.command), + platform: process.platform, + env: params.env, + execPath: process.execPath, + packageName: params.packageName, + allowShellFallback: true, + }); + return materializeWindowsSpawnProgram(program, params.args); +} + function hasHanScript(value: string): boolean { return HAN_SCRIPT_RE.test(value); } @@ -1066,9 +1087,17 @@ export class QmdMemoryManager implements MemorySearchManager { opts?: { timeoutMs?: number; discardOutput?: boolean }, ): Promise<{ stdout: string; stderr: string }> { return await new Promise((resolve, reject) => { - const child = spawn(resolveWindowsCommandShim(this.qmd.command), args, { + const spawnInvocation = resolveSpawnInvocation({ + command: this.qmd.command, + args, + env: this.env, + packageName: "qmd", + }); + const child = spawn(spawnInvocation.command, spawnInvocation.argv, { env: this.env, cwd: this.workspaceDir, + shell: spawnInvocation.shell, + windowsHide: spawnInvocation.windowsHide, }); let stdout = ""; let stderr = ""; @@ -1164,10 +1193,18 @@ export class QmdMemoryManager implements MemorySearchManager { opts?: { timeoutMs?: number }, ): Promise<{ stdout: string; stderr: string }> { return await new Promise((resolve, reject) => { - const child = spawn(resolveWindowsCommandShim("mcporter"), args, { + const spawnInvocation = resolveSpawnInvocation({ + command: "mcporter", + args, + env: this.env, + packageName: "mcporter", + }); + const child = spawn(spawnInvocation.command, spawnInvocation.argv, { // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. env: this.env, cwd: this.workspaceDir, + shell: spawnInvocation.shell, + windowsHide: spawnInvocation.windowsHide, }); let stdout = ""; let stderr = "";