From a5420dfd91c16f2ddb18be8da082d7c78b86772b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 18:41:35 -0700 Subject: [PATCH] fix(windows): launch npm-installed native session CLIs (#122334) * fix(windows): resolve runnable npm launchers * fix(windows): preserve npm shim argv in terminal sessions --- .../src/pi-session-catalog.test-support.ts | 10 +- .../acpx/src/pi-session-catalog.test.ts | 120 ++++++++------- extensions/anthropic/session-catalog.test.ts | 9 +- extensions/codex/src/session-catalog.test.ts | 7 +- extensions/opencode/session-catalog.test.ts | 79 ++++++---- package.json | 2 +- scripts/ci-changed-scope.mjs | 6 +- src/plugin-sdk/node-host.test.ts | 135 +++++++++++++++++ src/plugin-sdk/node-host.ts | 34 +++-- src/process/terminal-pty.test.ts | 142 ++++++++++++++++++ src/process/terminal-pty.ts | 39 +++++ src/scripts/ci-changed-scope.windows.test.ts | 15 ++ src/tui/tui.resolve-codex-bin.test.ts | 86 ++++++++--- src/tui/tui.ts | 19 ++- test/package-scripts.test.ts | 8 + 15 files changed, 567 insertions(+), 144 deletions(-) create mode 100644 src/plugin-sdk/node-host.test.ts diff --git a/extensions/acpx/src/pi-session-catalog.test-support.ts b/extensions/acpx/src/pi-session-catalog.test-support.ts index 4586fb98140b..94eea45b5910 100644 --- a/extensions/acpx/src/pi-session-catalog.test-support.ts +++ b/extensions/acpx/src/pi-session-catalog.test-support.ts @@ -92,9 +92,13 @@ export async function installFakePiFixture( path.join(resolvePreferredOpenClawTmpDir(), "openclaw-pi-cli-"), ); temporaryDirectories.push(directory); - const executable = path.join(directory, "pi"); - await fs.writeFile(executable, "#!/bin/sh\nexit 0\n"); - await fs.chmod(executable, 0o755); + const bareExecutable = path.join(directory, "pi"); + await fs.writeFile(bareExecutable, "#!/bin/sh\nexit 0\n"); + if (process.platform === "win32") { + await fs.writeFile(path.join(directory, "pi.cmd"), "@echo off\r\nexit /b 0\r\n"); + } else { + await fs.chmod(bareExecutable, 0o755); + } process.env.PATH = `${directory}${path.delimiter}${originalPath ?? ""}`; return directory; } diff --git a/extensions/acpx/src/pi-session-catalog.test.ts b/extensions/acpx/src/pi-session-catalog.test.ts index b81e6d1f6f8a..470cf6b59bcb 100644 --- a/extensions/acpx/src/pi-session-catalog.test.ts +++ b/extensions/acpx/src/pi-session-catalog.test.ts @@ -664,69 +664,67 @@ describe("Pi session catalog", () => { ).toBe(false); }); - it.runIf(process.platform !== "win32")( - "opens validated local Pi sessions with the upstream terminal resume contract", - async () => { - await createPiStore(); - await installFakePi(); - let provider: Parameters[0] | undefined; - const commands: Parameters[0][] = []; - registerPiSessionCatalog({ - pluginConfig: {}, - runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, - registerSessionCatalog: (value: NonNullable) => { - provider = value; - }, - registerNodeHostCommand: ( - command: Parameters[0], - ) => commands.push(command), - registerNodeInvokePolicy: vi.fn(), - } as unknown as OpenClawPluginApi); + it("opens validated local Pi sessions with the upstream terminal resume contract", async () => { + await createPiStore(); + const binDirectory = await installFakePi(); + const executable = path.join(binDirectory, process.platform === "win32" ? "pi.cmd" : "pi"); + let provider: Parameters[0] | undefined; + const commands: Parameters[0][] = []; + registerPiSessionCatalog({ + pluginConfig: {}, + runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, + registerSessionCatalog: (value: NonNullable) => { + provider = value; + }, + registerNodeHostCommand: ( + command: Parameters[0], + ) => commands.push(command), + registerNodeInvokePolicy: vi.fn(), + } as unknown as OpenClawPluginApi); - await expect(provider!.list({ hostIds: ["gateway"] })).resolves.toEqual([ - expect.objectContaining({ - sessions: [expect.objectContaining({ threadId: "pi-session", canOpenTerminal: true })], - }), - ]); - await expect( - provider!.openTerminal!({ hostId: "gateway", threadId: "pi-session" }), - ).resolves.toEqual({ - kind: "local", - argv: [expect.stringMatching(/pi$/u), "--session", "pi-session"], + await expect(provider!.list({ hostIds: ["gateway"] })).resolves.toEqual([ + expect.objectContaining({ + sessions: [expect.objectContaining({ threadId: "pi-session", canOpenTerminal: true })], + }), + ]); + await expect( + provider!.openTerminal!({ hostId: "gateway", threadId: "pi-session" }), + ).resolves.toEqual({ + kind: "local", + argv: [executable, "--session", "pi-session"], + cwd: "/workspace", + title: "pi --session pi-session…", + }); + await expect( + provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }), + ).rejects.toThrow("Pi session is unavailable"); + + const terminal = commands.find((command) => command.command === PI_TERMINAL_RESUME_COMMAND)!; + const io = { + signal: new AbortController().signal, + onInput: vi.fn(), + emitChunk: vi.fn(), + }; + await expect( + terminal.handle?.( + JSON.stringify({ threadId: "pi-session", cols: 100, rows: 30 }), + io as never, + ), + ).resolves.toBe(JSON.stringify({ exitCode: 0 })); + expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith( + { + file: executable, + args: ["--session", "pi-session"], cwd: "/workspace", - title: "pi --session pi-session…", - }); - await expect( - provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }), - ).rejects.toThrow("Pi session is unavailable"); - - const terminal = commands.find((command) => command.command === PI_TERMINAL_RESUME_COMMAND)!; - const io = { - signal: new AbortController().signal, - onInput: vi.fn(), - emitChunk: vi.fn(), - }; - await expect( - terminal.handle?.( - JSON.stringify({ threadId: "pi-session", cols: 100, rows: 30 }), - io as never, - ), - ).resolves.toBe(JSON.stringify({ exitCode: 0 })); - expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith( - { - file: expect.stringMatching(/pi$/u), - args: ["--session", "pi-session"], - cwd: "/workspace", - cols: 100, - rows: 30, - }, - io, - ); - await expect( - terminal.handle?.(JSON.stringify({ threadId: "--help", cols: 100, rows: 30 }), io as never), - ).rejects.toThrow("threadId is invalid"); - }, - ); + cols: 100, + rows: 30, + }, + io, + ); + await expect( + terminal.handle?.(JSON.stringify({ threadId: "--help", cols: 100, rows: 30 }), io as never), + ).rejects.toThrow("threadId is invalid"); + }); it("hides and rejects Continue when ACP cannot resume Pi", async () => { await createPiStore("hi", "Pi catalog session", { command: "pwd" }, true); diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index 786f4fbe6b4b..d856ac314484 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -2282,7 +2282,11 @@ describe("Claude session catalog", () => { const binDir = path.join(home, "bin"); await fs.mkdir(binDir); await fs.writeFile(path.join(binDir, "claude"), "#!/bin/sh\n"); - await fs.chmod(path.join(binDir, "claude"), 0o755); + if (process.platform === "win32") { + await fs.writeFile(path.join(binDir, "claude.cmd"), "@echo off\r\n"); + } else { + await fs.chmod(path.join(binDir, "claude"), 0o755); + } expect( commands[2]?.isAvailable?.({ config: {}, env: { HOME: home, PATH: binDir } } as never), ).toBe(true); @@ -2316,6 +2320,9 @@ describe("Claude session catalog", () => { const binDir = path.join(home, "bin"); await fs.mkdir(binDir); const executable = path.join(binDir, process.platform === "win32" ? "claude.cmd" : "claude"); + if (process.platform === "win32") { + await fs.writeFile(path.join(binDir, "claude"), "#!/bin/sh\n"); + } await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); if (process.platform !== "win32") { await fs.chmod(executable, 0o755); diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index a4ba6d6ac72d..067025258d4c 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -1563,6 +1563,9 @@ describe("Codex supervision catalog", () => { const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-node-terminal-")); tempDirs.push(binDir); const executable = path.join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); + if (process.platform === "win32") { + await fs.writeFile(path.join(binDir, "codex"), "#!/bin/sh\n"); + } await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); if (process.platform !== "win32") { await fs.chmod(executable, 0o755); @@ -3887,11 +3890,11 @@ describe("Codex supervision actions", () => { getProvider()?.startTerminalSession?.({ agentId: "main", cwd: "/workspace/new", - initialMessage: "--help", + initialMessage: "Fix A&B and 100%", }), ).resolves.toEqual({ kind: "local", - argv: [executable, "--", "--help"], + argv: [executable, "--", "Fix A&B and 100%"], cwd: "/workspace/new", env: { CODEX_HOME: resolveCodexAppServerHomeDir(resolveDefaultAgentDir(config)), diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index 6eecf1d90173..13b5efce6727 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -302,9 +302,7 @@ async function installFakeOpenCode( }, ], }; - await fs.writeFile( - executable, - `#!/usr/bin/env node + const script = `#!/usr/bin/env node const args = process.argv.slice(2); if (process.env.CATALOG_UNRELATED_ENV) process.exit(3); if (args[0] === "--pure" && args[1] === "db" && args.includes("--format") && args.includes("json")) { @@ -316,9 +314,19 @@ if (args[0] === "--pure" && args[1] === "db" && args.includes("--format") && arg } else { process.exitCode = 2; } -`, - ); - await fs.chmod(executable, 0o755); +`; + await fs.writeFile(executable, script); + if (process.platform === "win32") { + await fs.writeFile(path.join(directory, "opencode.js"), script); + // This exact direct-forwarder shape is parsed into a Node entrypoint; + // the batch wrapper itself is never executed through cmd.exe. + await fs.writeFile( + path.join(directory, "opencode.cmd"), + '@echo off\r\n"%~dp0\\opencode.js" %*\r\n', + ); + } else { + await fs.chmod(executable, 0o755); + } process.env.PATH = `${directory}${path.delimiter}${originalPath ?? ""}`; process.env.CATALOG_UNRELATED_ENV = "present"; return directory; @@ -613,34 +621,39 @@ describe("OpenCode session catalog", () => { expect(commandsAvailable({}, path.join(directory, "missing"))).toBe(false); }); - itWithCli( - "opens validated local sessions with the upstream terminal resume contract", - async () => { - await installFakeOpenCode(); - const { provider } = captureOpenCodeSessionRegistrations(); + it("opens validated local sessions with the upstream terminal resume contract", async () => { + const directory = await installFakeOpenCode(); + const executable = path.join( + directory, + process.platform === "win32" ? "opencode.cmd" : "opencode", + ); + const { provider } = captureOpenCodeSessionRegistrations(); - await expect(provider!.list({ hostIds: ["gateway"] })).resolves.toEqual([ - expect.objectContaining({ - sessions: [expect.objectContaining({ threadId: "ses_test", canOpenTerminal: true })], - }), - ]); - await expect( - provider!.openTerminal!({ hostId: "gateway", threadId: "ses_test" }), - ).resolves.toEqual({ - kind: "local", - argv: [expect.stringMatching(/opencode$/u), "--session", "ses_test"], - cwd: "/workspace", - title: "opencode --session ses_test…", - }); - await expectRejects( - provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }), - "OpenCode session is unavailable", - ); - }, - ); + await expect(provider!.list({ hostIds: ["gateway"] })).resolves.toEqual([ + expect.objectContaining({ + sessions: [expect.objectContaining({ threadId: "ses_test", canOpenTerminal: true })], + }), + ]); + await expect( + provider!.openTerminal!({ hostId: "gateway", threadId: "ses_test" }), + ).resolves.toEqual({ + kind: "local", + argv: [executable, "--session", "ses_test"], + cwd: "/workspace", + title: "opencode --session ses_test…", + }); + await expectRejects( + provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }), + "OpenCode session is unavailable", + ); + }); - itWithCli("runs only catalog-validated OpenCode sessions through the node PTY", async () => { - await installFakeOpenCode(); + it("runs only catalog-validated OpenCode sessions through the node PTY", async () => { + const directory = await installFakeOpenCode(); + const executable = path.join( + directory, + process.platform === "win32" ? "opencode.cmd" : "opencode", + ); const { commands, policies } = captureOpenCodeSessionRegistrations(); const terminal = commands.find( (command) => command.command === OPENCODE_TERMINAL_RESUME_COMMAND, @@ -658,7 +671,7 @@ describe("OpenCode session catalog", () => { ).resolves.toBe(JSON.stringify({ exitCode: 0 })); expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith( { - file: expect.stringMatching(/opencode$/u), + file: executable, args: ["--session", "ses_test"], cwd: "/workspace", cols: 100, diff --git a/package.json b/package.json index f1bcead0b630..1dfaed270448 100644 --- a/package.json +++ b/package.json @@ -1966,7 +1966,7 @@ "test:unit:fast:audit": "node --import tsx scripts/test-unit-fast-audit.mts", "test:voicecall:closedloop": "node --import tsx scripts/test-voicecall-closedloop.mts", "test:watch": "node --import tsx scripts/test-projects.mts --watch", - "test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/agents/tools/media-tool-file-url.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/ports.test.ts src/infra/advertised-lan-host.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/provider-local-service.env-case.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/agents/agent-tools.read.windows.test.ts src/agents/agent-tools.read.host-operations.test.ts src/agents/sessions/tools/path-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", + "test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/agents/tools/media-tool-file-url.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/ports.test.ts src/infra/advertised-lan-host.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/provider-local-service.env-case.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/process/terminal-pty.test.ts src/plugin-sdk/node-host.test.ts src/tui/tui.resolve-codex-bin.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/agents/agent-tools.read.windows.test.ts src/agents/agent-tools.read.host-operations.test.ts src/agents/sessions/tools/path-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", "test:windows:schtasks:integration": "node --import tsx scripts/run-with-env.mts CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", "tool-display:write": "node --import tsx scripts/tool-display.ts --write", diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index 7d36088af2c1..8e16ee943297 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -64,7 +64,7 @@ const WINDOWS_FILE_URL_SCOPE_RE = const WINDOWS_SCOPE_RE = /^(extensions\/mxc\/|src\/agents\/(?:bash-tools\.exec-script-(?:preflight|target)|bash-tools\.exec\.script-preflight\.test)\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive(?:\.worker(?:\.test)?)?|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/(?:(?:advertised-lan-host|exec-allowlist-pattern|fs-safe-remove)(?:\.windows)?(?:\.test)?|ports(?:-inspect|\.test)|ssh-client(?:\.windows\.test)?|update-managed-service-handoff(?:-(?:command|lifecycle)\.test)?|windows-install-roots)\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|src\/test-utils\/openclaw-test-state(?:\.test)?\.ts$|scripts\/(?:android-(?:app-i18n|pin-version)\.ts|ci-run-timings\.mjs|e2e\/lib\/package-compat\.mjs|generate-bundled-channel-config-metadata\.ts|install\.ps1|openclaw-cross-os-release-checks\.ts|plan-release-workflow-matrix\.mjs|run-additional-boundary-checks\.mts|verify-docker-attestations\.mjs|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|mts|js)|lib\/(?:direct-run\.(?:mjs|mts)|format-generated-module\.mts|tsx-cli-shim\.mjs|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/; const WINDOWS_TEST_SCOPE_RE = - /^(extensions\/mxc\/test\/(?:mxc-backend|sandbox-policy-loader)\.test\.ts$|src\/agents\/bash-tools\.exec\.script-preflight\.test\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive\.worker|store\.session-lifecycle-mutation)\.test\.ts$|src\/process\/(?:exec\.windows|windows-command)\.test\.ts$|src\/infra\/(?:advertised-lan-host(?:\.windows)?|exec-allowlist-pattern|fs-safe-remove|ports|ssh-client\.windows|update-managed-service-handoff-(?:command|lifecycle)|windows-install-roots)\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|src\/state\/openclaw-database-paths\.windows\.test\.ts$|src\/test-utils\/openclaw-test-state\.test\.ts$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/; + /^(extensions\/mxc\/test\/(?:mxc-backend|sandbox-policy-loader)\.test\.ts$|src\/agents\/bash-tools\.exec\.script-preflight\.test\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive\.worker|store\.session-lifecycle-mutation)\.test\.ts$|src\/process\/(?:exec\.windows|terminal-pty|windows-command)\.test\.ts$|src\/infra\/(?:advertised-lan-host(?:\.windows)?|exec-allowlist-pattern|fs-safe-remove|ports|ssh-client\.windows|update-managed-service-handoff-(?:command|lifecycle)|windows-install-roots)\.test\.ts$|src\/shared\/runtime-import\.test\.ts$|src\/state\/openclaw-database-paths\.windows\.test\.ts$|src\/test-utils\/openclaw-test-state\.test\.ts$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$)/; const WINDOWS_SECRETREF_SCOPE_RE = /^(?:src\/commands\/doctor-gateway-auth-token(?:\.windows\.test)?\.ts|src\/flows\/(?:doctor-core-checks|doctor-health-contributions)\.ts|src\/gateway\/(?:auth-token-resolution|resolve-configured-secret-input-string)\.ts|src\/infra\/(?:fs-safe|fs-safe-defaults|permissions)\.ts|src\/secrets\/(?:resolve|resolve-errors)\.ts|src\/security\/audit-fs\.ts)$/; const WINDOWS_SECRETREF_TEST_SCOPE_RE = @@ -79,6 +79,8 @@ const WINDOWS_HOME_DISPLAY_SCOPE_RE = /^(?:src\/(?:utils(?:\.test)?|infra\/(?:home-display|path-guards)|commands\/agents\.commands\.list(?:\.test)?|cli\/daemon-cli\/status\.print(?:\.test)?|agents\/(?:sandbox\/fs-paths|sessions\/tools\/render-utils)(?:\.test)?)|packages\/terminal-core\/src\/display-string(?:\.test)?)\.ts$/; const WINDOWS_CHILD_ENV_SCOPE_RE = /^src\/(?:agents\/provider-local-service(?:\.env-case\.test)?|cli\/mcp-cli(?:\.path-case\.windows)?\.test|cli\/mcp-cli|infra\/process-env(?:\.test)?)\.ts$/; +const WINDOWS_NODE_HOST_EXECUTABLE_SCOPE_RE = + /^(?:src\/plugin-sdk\/node-host(?:\.test)?|src\/tui\/(?:tui|tui\.resolve-codex-bin\.test))\.ts$/; const WINDOWS_AGENT_HOME_PATH_SCOPE_RE = /^src\/(?:infra\/home-dir(?:\.test)?|agents\/(?:agent-tools\.read(?:\.host-operations|\.windows)?\.test|agent-tools\.read|sessions\/tools\/path-utils(?:\.test)?))\.ts$/; const WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE = @@ -196,6 +198,7 @@ export function detectChangedScope(changedPaths) { WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path) || WINDOWS_AGENT_HOME_PATH_SCOPE_RE.test(path) || WINDOWS_CHILD_ENV_SCOPE_RE.test(path) || + WINDOWS_NODE_HOST_EXECUTABLE_SCOPE_RE.test(path) || WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE.test(path)) && (!facts.isTestOnly || WINDOWS_TEST_SCOPE_RE.test(path) || @@ -207,6 +210,7 @@ export function detectChangedScope(changedPaths) { WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path) || WINDOWS_AGENT_HOME_PATH_SCOPE_RE.test(path) || WINDOWS_CHILD_ENV_SCOPE_RE.test(path) || + WINDOWS_NODE_HOST_EXECUTABLE_SCOPE_RE.test(path) || WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE.test(path)) ) { runWindows = true; diff --git a/src/plugin-sdk/node-host.test.ts b/src/plugin-sdk/node-host.test.ts new file mode 100644 index 000000000000..2802779ae1c0 --- /dev/null +++ b/src/plugin-sdk/node-host.test.ts @@ -0,0 +1,135 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { clearExecutablePathCache } from "../infra/executable-path.js"; +import { resolveNodeHostExecutable } from "./node-host.js"; + +const tempDirs: string[] = []; + +async function createNpmShimPair(executable: string) { + const binDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-node-host-${executable}-`)); + tempDirs.push(binDir); + const barePath = path.join(binDir, executable); + const commandPath = path.join(binDir, `${executable}.cmd`); + await fs.writeFile(barePath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.writeFile(commandPath, "@echo off\r\nexit /b 0\r\n", "utf8"); + if (process.platform !== "win32") { + await fs.chmod(barePath, 0o755); + } + return { barePath, binDir, commandPath }; +} + +async function createBareNativeHost(executable: string) { + const binDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-node-host-${executable}-`)); + tempDirs.push(binDir); + const barePath = path.join(binDir, executable); + await fs.copyFile(process.execPath, barePath); + return { barePath, binDir }; +} + +afterEach(async () => { + clearExecutablePathCache(); + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("resolveNodeHostExecutable", () => { + it.runIf(process.platform === "win32").each([ + ["codex", "fallback"], + ["claude", "prefer"], + ["opencode", "fallback"], + ["pi", "direct"], + ] as const)( + "selects the Windows npm launcher for the %s catalog", + async (executable, strategy) => { + const { binDir, commandPath } = await createNpmShimPair(executable); + + expect( + resolveNodeHostExecutable(executable, { + env: { PATH: binDir, PATHEXT: ".CMD" }, + pathEnv: binDir, + strategy, + }), + ).toEqual({ executable: commandPath }); + }, + ); + + it.runIf(process.platform === "win32")( + "preserves an explicit extensionless Windows override", + async () => { + const { barePath, binDir } = await createNpmShimPair("custom-host"); + + expect( + resolveNodeHostExecutable("custom-host", { + env: { PATH: binDir, PATHEXT: ".CMD" }, + includeExtensionless: true, + pathEnv: binDir, + strategy: "direct", + }), + ).toEqual({ executable: barePath }); + }, + ); + + it.runIf(process.platform === "win32").each([["direct"], ["fallback"], ["prefer"]] as const)( + "falls back to a bare-only Windows host for %s", + async (strategy) => { + const { barePath, binDir } = await createBareNativeHost(`bare-${strategy}`); + + const resolution = resolveNodeHostExecutable(`bare-${strategy}`, { + env: { PATH: binDir, PATHEXT: ".CMD;.EXE" }, + pathEnv: binDir, + strategy, + }); + + expect(resolution).toEqual({ executable: barePath }); + }, + ); + + it.runIf(process.platform === "win32").each([["direct"], ["fallback"], ["prefer"]] as const)( + "prefers a later Windows PATHEXT launcher over an earlier bare shim for %s", + async (strategy) => { + const { binDir: bareDir } = await createBareNativeHost(`later-${strategy}`); + const { binDir: launcherDir, commandPath } = await createNpmShimPair(`later-${strategy}`); + const pathEnv = `${bareDir};${launcherDir}`; + + expect( + resolveNodeHostExecutable(`later-${strategy}`, { + env: { PATH: pathEnv, PATHEXT: ".CMD" }, + pathEnv, + strategy, + }), + ).toEqual({ executable: commandPath }); + }, + ); + + it.runIf(process.platform === "win32")( + "preserves an explicit PATHEXT-only Windows override", + async () => { + const { binDir } = await createBareNativeHost("suffix-only-host"); + + expect( + resolveNodeHostExecutable("suffix-only-host", { + env: { PATH: binDir, PATHEXT: ".CMD" }, + includeExtensionless: false, + pathEnv: binDir, + strategy: "direct", + }), + ).toBeUndefined(); + }, + ); + + it.runIf(process.platform !== "win32")( + "keeps the extensionless npm launcher on POSIX", + async () => { + const { barePath, binDir } = await createNpmShimPair("codex"); + + expect( + resolveNodeHostExecutable("codex", { + env: { PATH: binDir }, + pathEnv: binDir, + strategy: "direct", + }), + ).toEqual({ executable: barePath }); + }, + ); +}); diff --git a/src/plugin-sdk/node-host.ts b/src/plugin-sdk/node-host.ts index b3cd1a94a395..eebbd0c6fac7 100644 --- a/src/plugin-sdk/node-host.ts +++ b/src/plugin-sdk/node-host.ts @@ -21,19 +21,27 @@ export function resolveNodeHostExecutable( }, ): { executable: string; pathEnv?: string } | undefined { const env = options.env ?? process.env; - if (options.strategy === "direct") { - const resolved = resolveExecutableFromPathEnv( - executable, - options.pathEnv ?? env.PATH ?? env.Path ?? "", + const resolve = (includeExtensionless: boolean) => { + if (options.strategy === "direct") { + const resolved = resolveExecutableFromPathEnv( + executable, + options.pathEnv ?? env.PATH ?? env.Path ?? "", + env, + { includeExtensionless }, + ); + return resolved ? { executable: resolved } : undefined; + } + return resolveExecutableFromUserShellPathInternal(executable, { env, - { includeExtensionless: options.includeExtensionless }, - ); - return resolved ? { executable: resolved } : undefined; + pathEnv: options.pathEnv, + includeExtensionless, + strategy: options.strategy, + }); + }; + if (options.includeExtensionless !== undefined || process.platform !== "win32") { + return resolve(options.includeExtensionless ?? true); } - return resolveExecutableFromUserShellPathInternal(executable, { - env, - pathEnv: options.pathEnv, - includeExtensionless: options.includeExtensionless, - strategy: options.strategy, - }); + // npm installs a non-runnable bare shim beside its .cmd launcher. Search every + // PATH source for PATHEXT launchers before retaining bare-only native hosts. + return resolve(false) ?? resolve(true); } diff --git a/src/process/terminal-pty.test.ts b/src/process/terminal-pty.test.ts index be5e6f6fbb77..1fb4052866f7 100644 --- a/src/process/terminal-pty.test.ts +++ b/src/process/terminal-pty.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -10,6 +13,26 @@ vi.mock("@lydell/node-pty", () => ({ spawn: mocks.spawn })); const { spawnTerminalPty } = await import("./terminal-pty.js"); +const tempDirs: string[] = []; + +function createWindowsNpmShim(command: string) { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-terminal-pty-shim-")); + tempDirs.push(binDir); + const entrypoint = path.join(binDir, "node_modules", "@openai", command, "bin", `${command}.js`); + fs.mkdirSync(path.dirname(entrypoint), { recursive: true }); + fs.writeFileSync(entrypoint, "", "utf8"); + const relativeEntrypoint = path.relative(binDir, entrypoint).replaceAll(path.sep, "\\"); + const shimPath = path.join(binDir, `${command}.cmd`); + fs.writeFileSync( + shimPath, + "@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n" + + 'IF EXIST "%dp0%\\node.exe" (\r\n SET "_prog=%dp0%\\node.exe"\r\n) ELSE (\r\n SET "_prog=node"\r\n)\r\n' + + `endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${relativeEntrypoint}" %*\r\n`, + "utf8", + ); + return { entrypoint, shimPath }; +} + function fakePty(pid = 4321) { return { pid, @@ -44,6 +67,9 @@ describe("terminal PTY teardown", () => { afterEach(() => { vi.restoreAllMocks(); + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { force: true, recursive: true }); + } }); it.each([undefined, "SIGTERM"] as const)("signals the process tree for %s", async (signal) => { @@ -187,6 +213,122 @@ describe("terminal PTY invocation", () => { ); }); + it.runIf(process.platform === "win32")( + "passes arbitrary Codex initial-message text literally through an npm shim", + async () => { + const { entrypoint, shimPath } = createWindowsNpmShim("codex"); + mocks.spawn.mockReturnValueOnce(fakePty()); + + await spawnTerminalPty({ + file: shimPath, + args: ["exec", "--", "Fix A&B and 100%"], + env: { PATH: path.dirname(process.execPath), PATHEXT: ".EXE;.CMD" }, + cols: 80, + rows: 24, + }); + + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + [entrypoint, "exec", "--", "Fix A&B and 100%"], + expect.objectContaining({ cols: 80, rows: 24 }), + ); + }, + ); + + it.runIf(process.platform === "win32")( + "uses PATH node.exe instead of a packaged non-Node host for an npm shim", + async () => { + const { entrypoint, shimPath } = createWindowsNpmShim("codex"); + const nodeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-terminal-pty-node-")); + tempDirs.push(nodeDir); + const nodePath = path.join(nodeDir, "node.exe"); + fs.linkSync(process.execPath, nodePath); + vi.spyOn(process, "execPath", "get").mockReturnValue( + "C:\\Program Files\\OpenClaw\\openclaw.exe", + ); + mocks.spawn.mockReturnValueOnce(fakePty()); + + await spawnTerminalPty({ + file: shimPath, + args: ["--", "literal"], + env: { PATH: nodeDir, PATHEXT: ".EXE;.CMD" }, + cols: 80, + rows: 24, + }); + + const [command, argv] = mocks.spawn.mock.calls[0] ?? []; + expect(String(command).toLowerCase()).toBe(nodePath.toLowerCase()); + expect(argv).toEqual([entrypoint, "--", "literal"]); + }, + ); + + it.runIf(process.platform === "win32")( + "fails closed when an npm shim has no Node executable", + async () => { + const { shimPath } = createWindowsNpmShim("codex"); + vi.spyOn(process, "execPath", "get").mockReturnValue( + "C:\\Program Files\\OpenClaw\\openclaw.exe", + ); + + await expect( + spawnTerminalPty({ + file: shimPath, + args: ["--", "literal"], + env: { PATH: path.dirname(shimPath), PATHEXT: ".EXE;.CMD" }, + cols: 80, + rows: 24, + }), + ).rejects.toThrow(/Node executable/); + expect(mocks.spawn).not.toHaveBeenCalled(); + }, + ); + + it.runIf(process.platform === "win32")( + "keeps unknown batch wrappers on the guarded cmd path", + async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-terminal-pty-custom-")); + tempDirs.push(tempDir); + const wrapperPath = path.join(tempDir, "custom.cmd"); + fs.writeFileSync(wrapperPath, "@ECHO off\r\necho custom\r\n", "utf8"); + + await expect( + spawnTerminalPty({ + file: wrapperPath, + args: ["Fix A&B and 100%"], + env: { COMSPEC: "C:\\Windows\\System32\\cmd.exe" }, + cols: 80, + rows: 24, + }), + ).rejects.toThrow("Unsafe Windows cmd.exe argument"); + expect(mocks.spawn).not.toHaveBeenCalled(); + }, + ); + + it.runIf(process.platform === "win32")( + "passes a bare-only native host directly to the PTY spawn owner", + async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-terminal-pty-bare-")); + tempDirs.push(tempDir); + const barePath = path.join(tempDir, "bare-host"); + fs.copyFileSync(process.execPath, barePath); + mocks.spawn.mockReturnValueOnce(fakePty()); + + await spawnTerminalPty({ + file: barePath, + args: ["--version"], + env: {}, + cols: 80, + rows: 24, + }); + + expect(mocks.spawn).toHaveBeenCalledWith( + barePath, + ["--version"], + expect.objectContaining({ cols: 80, rows: 24 }), + ); + }, + ); + it("keeps executables and non-Windows commands direct", async () => { const platform = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); mocks.spawn.mockReturnValueOnce(fakePty()); diff --git a/src/process/terminal-pty.ts b/src/process/terminal-pty.ts index 398b069222e1..14ab67b5f5e3 100644 --- a/src/process/terminal-pty.ts +++ b/src/process/terminal-pty.ts @@ -1,5 +1,11 @@ +import path from "node:path"; import type { IPty } from "@lydell/node-pty"; import { resolveEnvironmentValue } from "../infra/process-env.js"; +import { + materializeWindowsSpawnProgram, + resolveWindowsExecutablePath, + resolveWindowsSpawnProgram, +} from "../plugin-sdk/windows-spawn.js"; import { signalProcessTree } from "./kill-tree.js"; import { readPtyTerminalName, @@ -24,16 +30,48 @@ type TerminalPtyHandle = { kill(signal?: string): void; }; +function resolveTerminalNodeExecutable(env: NodeJS.ProcessEnv): string { + // Packaged OpenClaw/Bun hosts cannot interpret npm's JavaScript entrypoint. + // Use the running binary only when it is Node; otherwise require PATH node.exe. + const candidate = + path.win32.basename(process.execPath).toLowerCase() === "node.exe" + ? process.execPath + : resolveWindowsExecutablePath("node", env); + if (path.win32.basename(candidate).toLowerCase() === "node.exe") { + return candidate; + } + throw new Error( + "A Node executable is required to launch this Windows npm wrapper; add node.exe to PATH.", + ); +} + function resolveTerminalPtyInvocation(params: { file: string; args: string[]; platform?: NodeJS.Platform; comSpec?: string; + env: NodeJS.ProcessEnv; }): { file: string; args: string[] } { const platform = params.platform ?? process.platform; if (!isWindowsBatchCommand(params.file, platform)) { return { file: params.file, args: params.args }; } + const program = resolveWindowsSpawnProgram({ + command: params.file, + platform, + env: params.env, + execPath: process.execPath, + allowShellFallback: true, + }); + if (program.resolution !== "shell-fallback") { + const invocation = materializeWindowsSpawnProgram( + program.resolution === "node-entrypoint" + ? { ...program, command: resolveTerminalNodeExecutable(params.env) } + : program, + params.args, + ); + return { file: invocation.command, args: invocation.argv }; + } return { file: params.comSpec?.trim() || resolveTrustedWindowsCmdExe(platform), args: ["/d", "/s", "/c", buildWindowsCmdExeCommandLine(params.file, params.args)], @@ -58,6 +96,7 @@ export async function spawnTerminalPty(params: { const invocation = resolveTerminalPtyInvocation({ file: params.file, args: params.args, + env, ...(comSpec ? { comSpec } : {}), }); const pty = spawn(invocation.file, invocation.args, { diff --git a/src/scripts/ci-changed-scope.windows.test.ts b/src/scripts/ci-changed-scope.windows.test.ts index 3a5d4798e90f..146b5ff360c1 100644 --- a/src/scripts/ci-changed-scope.windows.test.ts +++ b/src/scripts/ci-changed-scope.windows.test.ts @@ -262,6 +262,21 @@ describe("detectChangedScope Windows routing", () => { } }); + it("routes node-host executable resolution and native coverage to Windows", () => { + for (const executablePath of [ + "src/plugin-sdk/node-host.ts", + "src/plugin-sdk/node-host.test.ts", + "src/process/terminal-pty.test.ts", + "src/tui/tui.ts", + "src/tui/tui.resolve-codex-bin.test.ts", + ]) { + expect(detectChangedScope([executablePath]), executablePath).toMatchObject({ + runNode: true, + runWindows: true, + }); + } + }); + it("routes explicit memory extra-file owners and native coverage to Windows", () => { for (const memoryPath of [ "packages/memory-host-sdk/src/host/explicit-extra-markdown.ts", diff --git a/src/tui/tui.resolve-codex-bin.test.ts b/src/tui/tui.resolve-codex-bin.test.ts index a3965d9f6d4c..0cf9a4f4511b 100644 --- a/src/tui/tui.resolve-codex-bin.test.ts +++ b/src/tui/tui.resolve-codex-bin.test.ts @@ -1,5 +1,6 @@ // Covers bounded TUI Codex CLI lookup command selection. import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { withMockedPlatform, withMockedWindowsPlatform } from "../test-utils/vitest-spies.js"; @@ -8,12 +9,17 @@ const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: runCommandWithTimeoutMock })); -import { resolveCodexCliBin } from "./tui.js"; +import { resolveCodexCliBin, resolveLocalAuthSpawnInvocation } from "./tui.js"; + +const tempDirs: string[] = []; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); runCommandWithTimeoutMock.mockReset(); + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { force: true, recursive: true }); + } }); describe("resolveCodexCliBin", () => { @@ -41,34 +47,64 @@ describe("resolveCodexCliBin", () => { termination: "timeout", }); - await expect(resolveCodexCliBin()).resolves.toBeNull(); + await withMockedPlatform("linux", async () => { + await expect(resolveCodexCliBin()).resolves.toBeNull(); + }); }); - it("uses the trusted Windows where.exe", async () => { - const accessSync = fs.accessSync.bind(fs); - vi.spyOn(fs, "accessSync").mockImplementation((filePath, mode) => { - if (String(filePath).toLowerCase() === "c:\\windows\\system32\\reg.exe") { - throw new Error("registry lookup disabled for test"); - } - return accessSync(filePath, mode); - }); - vi.stubEnv("SystemRoot", "D:\\Windows"); - runCommandWithTimeoutMock.mockResolvedValue({ - code: 0, - stdout: "D:\\Tools\\codex.exe\r\n", - termination: "exit", - }); + it("selects the Windows npm command shim from a Unicode PATH entry", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tui-codex-")); + tempDirs.push(tempDir); + const binDir = path.join(tempDir, "Codex Å tools"); + fs.mkdirSync(binDir); + fs.writeFileSync(path.join(binDir, "codex"), "#!/bin/sh\n"); + const commandPath = path.join(binDir, "codex.cmd"); + fs.writeFileSync(commandPath, "@echo off\r\n"); + vi.stubEnv("PATH", binDir); + vi.stubEnv("PATHEXT", ".CMD;.EXE"); await withMockedWindowsPlatform(async () => { - await expect(resolveCodexCliBin()).resolves.toBe("D:\\Tools\\codex.exe"); + await expect(resolveCodexCliBin()).resolves.toBe(commandPath); + expect( + resolveLocalAuthSpawnInvocation({ + command: commandPath, + args: ["login"], + platform: "win32", + }), + ).toMatchObject({ + args: ["/d", "/s", "/c", expect.stringContaining("codex.cmd")], + options: { windowsHide: true, windowsVerbatimArguments: true }, + }); }); - expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( - [path.win32.join("D:\\Windows", "System32", "where.exe"), "codex"], - { - killSignal: "SIGKILL", - maxOutputBytes: 64 * 1024, - timeoutMs: 5_000, - }, - ); + expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); + }); + + it("keeps native Windows executables and reports a missing Codex CLI", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tui-codex-native-")); + tempDirs.push(tempDir); + const executablePath = path.join(tempDir, "codex.exe"); + fs.copyFileSync(process.execPath, executablePath); + vi.stubEnv("PATH", tempDir); + vi.stubEnv("PATHEXT", ".EXE"); + + await withMockedWindowsPlatform(async () => { + await expect(resolveCodexCliBin()).resolves.toBe(executablePath); + vi.stubEnv("PATH", path.join(tempDir, "missing")); + await expect(resolveCodexCliBin()).resolves.toBeNull(); + }); + }); + + it("falls back to a bare-only native Windows Codex executable", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tui-codex-bare-")); + tempDirs.push(tempDir); + const executablePath = path.join(tempDir, "codex"); + fs.copyFileSync(process.execPath, executablePath); + vi.stubEnv("PATH", tempDir); + vi.stubEnv("PATHEXT", ".CMD;.EXE"); + + await withMockedWindowsPlatform(async () => { + await expect(resolveCodexCliBin()).resolves.toBe(executablePath); + }); + expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); }); }); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 3adcdb40c51f..c143db76dce8 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -18,11 +18,11 @@ import { formatCliCommand } from "../cli/command-format.js"; import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { resolveCanonicalMainSessionKey } from "../config/sessions/main-session-key.js"; import type { EmbeddedStateSignalProcess } from "../infra/embedded-state-lock.js"; +import { resolveExecutableFromPathEnv } from "../infra/executable-path.js"; import type { GatewayLockIdentity, GatewayLockOptions } from "../infra/gateway-lock.js"; import { resolveCurrentOpenClawCliInvocation } from "../infra/openclaw-cli-invocation.js"; import { tryProcessCwd } from "../infra/safe-cwd.js"; import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js"; -import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js"; import { setConsoleSubsystemFilter } from "../logging/console.js"; import { loggingState } from "../logging/state.js"; import { runCommandWithTimeout } from "../process/exec.js"; @@ -113,10 +113,21 @@ type RunTuiOptions = TuiOptions & { /** Resolve the absolute path to the `codex` CLI binary, or `null` if not installed. */ export async function resolveCodexCliBin(): Promise { - const lookupCommand = - process.platform === "win32" ? getWindowsSystem32ExePath("where.exe") : "which"; + if (process.platform === "win32") { + const pathEnv = process.env.PATH ?? process.env.Path ?? ""; + // Prefer npm's runnable PATHEXT launcher, but retain bare-only native installs. + return ( + resolveExecutableFromPathEnv("codex", pathEnv, process.env, { + includeExtensionless: false, + }) ?? + resolveExecutableFromPathEnv("codex", pathEnv, process.env, { + includeExtensionless: true, + }) ?? + null + ); + } try { - const result = await runCommandWithTimeout([lookupCommand, "codex"], { + const result = await runCommandWithTimeout(["which", "codex"], { killSignal: "SIGKILL", maxOutputBytes: 64 * 1024, timeoutMs: CODEX_CLI_LOOKUP_TIMEOUT_MS, diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 6adacae91c67..27dd14adc172 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -329,6 +329,14 @@ describe("package scripts", () => { ); }); + it("runs node-host npm shim and PTY launcher coverage in Windows CI", () => { + const script = readPackageJson().scripts["test:windows:ci"]; + + expect(script).toContain("src/plugin-sdk/node-host.test.ts"); + expect(script).toContain("src/process/terminal-pty.test.ts"); + expect(script).toContain("src/tui/tui.resolve-codex-bin.test.ts"); + }); + it("runs Windows-only safe removal coverage in Windows CI", () => { expect(readPackageJson().scripts["test:windows:ci"]).toContain( "src/infra/fs-safe-remove.test.ts",