mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(windows): launch npm-installed native session CLIs (#122334)
* fix(windows): resolve runnable npm launchers * fix(windows): preserve npm shim argv in terminal sessions
This commit is contained in:
committed by
GitHub
parent
c37023ff96
commit
a5420dfd91
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
|
||||
const commands: Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0][] = [];
|
||||
registerPiSessionCatalog({
|
||||
pluginConfig: {},
|
||||
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
|
||||
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
|
||||
provider = value;
|
||||
},
|
||||
registerNodeHostCommand: (
|
||||
command: Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[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<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
|
||||
const commands: Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0][] = [];
|
||||
registerPiSessionCatalog({
|
||||
pluginConfig: {},
|
||||
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
|
||||
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
|
||||
provider = value;
|
||||
},
|
||||
registerNodeHostCommand: (
|
||||
command: Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
+21
-13
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+15
-4
@@ -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<string | null> {
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user