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:
Peter Steinberger
2026-08-11 18:41:35 -07:00
committed by GitHub
parent c37023ff96
commit a5420dfd91
15 changed files with 567 additions and 144 deletions
@@ -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;
}
+59 -61
View File
@@ -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);
+8 -1
View File
@@ -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);
+5 -2
View File
@@ -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)),
+46 -33
View File
@@ -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
View File
@@ -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",
+5 -1
View File
@@ -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;
+135
View File
@@ -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
View File
@@ -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);
}
+142
View File
@@ -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());
+39
View File
@@ -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",
+61 -25
View File
@@ -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
View File
@@ -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,
+8
View File
@@ -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",