Merge remote-tracking branch 'origin/main' into dev
# Conflicts: # packages/server/src/server/session.ts
This commit is contained in:
@@ -138,7 +138,7 @@ describe("executableExists", () => {
|
||||
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
|
||||
});
|
||||
|
||||
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", () => {
|
||||
test("on Windows, falls back to .exe, then .cmd for extensionless paths", () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||
try {
|
||||
@@ -150,6 +150,18 @@ describe("executableExists", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("on Windows, ignores PowerShell scripts for extensionless paths", () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||
try {
|
||||
const exists = (candidate: string) => candidate === "C:\\tools\\codex.ps1";
|
||||
|
||||
expect(executableExists("C:\\tools\\codex", exists)).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null when no matching path exists", () => {
|
||||
expect(executableExists("/missing/codex", () => false)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ async function enumerateCandidates(name: string): Promise<string[]> {
|
||||
});
|
||||
}
|
||||
|
||||
function isWindowsCommandScript(executablePath: string): boolean {
|
||||
export function isWindowsCommandScript(executablePath: string): boolean {
|
||||
const extension = extname(executablePath).toLowerCase();
|
||||
return process.platform === "win32" && (extension === ".cmd" || extension === ".bat");
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function executableExists(
|
||||
): string | null {
|
||||
if (exists(executablePath)) return executablePath;
|
||||
if (process.platform === "win32" && !extname(executablePath)) {
|
||||
for (const ext of [".exe", ".cmd", ".ps1"]) {
|
||||
for (const ext of [".exe", ".cmd"]) {
|
||||
const candidate = executablePath + ext;
|
||||
if (exists(candidate)) return candidate;
|
||||
}
|
||||
@@ -141,8 +141,11 @@ function escapeWindowsCmdValue(value: string): string {
|
||||
const unquoted = isQuoted ? value.slice(1, -1) : value;
|
||||
const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1");
|
||||
|
||||
if (isQuoted || escaped.includes(" ")) {
|
||||
return `"${escaped}"`;
|
||||
if (isQuoted || /[\s"]/u.test(unquoted)) {
|
||||
const quoted = escaped
|
||||
.replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)
|
||||
.replace(/\\+$/u, (slashes) => `${slashes}${slashes}`);
|
||||
return `"${quoted}"`;
|
||||
}
|
||||
|
||||
return escaped;
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { findExecutable } from "./executable.js";
|
||||
import { spawnProcess } from "./spawn.js";
|
||||
|
||||
type SpawnResult = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const JSON_ARG = '{"key":"value with spaces","nested":{"quote":"\\"yes\\""}}';
|
||||
|
||||
function makeFixture(options?: { includeCmdShim?: boolean }): {
|
||||
root: string;
|
||||
fakeDaemonNode: string;
|
||||
shim: string;
|
||||
powerShellShim: string;
|
||||
assertScript: string;
|
||||
expectedArgs: string[];
|
||||
} {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo spawn regression "));
|
||||
tempDirs.push(root);
|
||||
|
||||
const fakeDaemonNode = path.join(root, "Fake Paseo.exe");
|
||||
copyFileSync(process.execPath, fakeDaemonNode);
|
||||
|
||||
const expectedArgs = ["--config", JSON_ARG];
|
||||
const assertScript = path.join(root, "assert-argv.js");
|
||||
writeFileSync(
|
||||
assertScript,
|
||||
`
|
||||
if (process.argv.includes("--version")) {
|
||||
console.log("fake-provider 1.0.0");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON);
|
||||
const actual = process.argv.slice(2);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
console.error("ARGV_MISMATCH");
|
||||
console.error(JSON.stringify({ expected, actual }));
|
||||
process.exit(42);
|
||||
}
|
||||
console.log("ARGV_OK");
|
||||
`,
|
||||
);
|
||||
|
||||
const shim = path.join(root, "claude.cmd");
|
||||
if (options?.includeCmdShim !== false) {
|
||||
writeFileSync(
|
||||
shim,
|
||||
["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const powerShellShim = path.join(root, "claude.ps1");
|
||||
writeFileSync(
|
||||
powerShellShim,
|
||||
[
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
`& '${fakeDaemonNode.replace(/'/g, "''")}' '${assertScript.replace(/'/g, "''")}' @args`,
|
||||
"exit $LASTEXITCODE",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
|
||||
return { root, fakeDaemonNode, shim, powerShellShim, assertScript, expectedArgs };
|
||||
}
|
||||
|
||||
function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise<SpawnResult> {
|
||||
return new Promise((resolve) => {
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let error: Error | null = null;
|
||||
let settled = false;
|
||||
|
||||
const settle = (result: Pick<SpawnResult, "code" | "signal">) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
...result,
|
||||
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
settle({ code: null, signal: "SIGKILL" });
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
error = err;
|
||||
settle({ code: null, signal: null });
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
settle({ code, signal });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runFixture(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
shell?: boolean;
|
||||
}): Promise<SpawnResult> {
|
||||
const child = spawnProcess(params.command, params.args, {
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...(params.shell === undefined ? {} : { shell: params.shell }),
|
||||
});
|
||||
return collectChild(child);
|
||||
}
|
||||
|
||||
function withWindowsPathEntry<T>(dir: string, run: () => Promise<T>): Promise<T> {
|
||||
const pathKey =
|
||||
process.platform === "win32"
|
||||
? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path")
|
||||
: "PATH";
|
||||
const previousPath = process.env[pathKey];
|
||||
const previousPathExt = process.env.PATHEXT;
|
||||
|
||||
process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir;
|
||||
process.env.PATHEXT = previousPathExt?.toLowerCase().includes(".ps1")
|
||||
? previousPathExt
|
||||
: `${previousPathExt ?? ""};.PS1`;
|
||||
|
||||
return run().finally(() => {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env[pathKey];
|
||||
} else {
|
||||
process.env[pathKey] = previousPath;
|
||||
}
|
||||
|
||||
if (previousPathExt === undefined) {
|
||||
delete process.env.PATHEXT;
|
||||
} else {
|
||||
process.env.PATHEXT = previousPathExt;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe.runIf(process.platform === "win32")("Windows spawn launch regression", () => {
|
||||
test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => {
|
||||
const fixture = makeFixture();
|
||||
|
||||
const result = await runFixture({
|
||||
command: fixture.shim,
|
||||
args: fixture.expectedArgs,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("ARGV_OK");
|
||||
});
|
||||
|
||||
test("launches a cmd shim even when the caller explicitly disables shell", async () => {
|
||||
const fixture = makeFixture();
|
||||
|
||||
const result = await runFixture({
|
||||
command: fixture.shim,
|
||||
args: fixture.expectedArgs,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("ARGV_OK");
|
||||
});
|
||||
|
||||
test("direct launch with a space-containing executable preserves JSON args", async () => {
|
||||
const fixture = makeFixture();
|
||||
|
||||
const result = await runFixture({
|
||||
command: fixture.fakeDaemonNode,
|
||||
args: [fixture.assertScript, ...fixture.expectedArgs],
|
||||
shell: false,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("ARGV_OK");
|
||||
});
|
||||
|
||||
test("does not detect a PowerShell shim from PATH", async () => {
|
||||
const fixture = makeFixture({ includeCmdShim: false });
|
||||
|
||||
await withWindowsPathEntry(fixture.root, async () => {
|
||||
const detected = await findExecutable("claude");
|
||||
expect(detected).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")("spawn launch regression smoke", () => {
|
||||
test("direct launch with a space-containing executable works on this platform", async () => {
|
||||
const fixture = makeFixture();
|
||||
|
||||
const result = await runFixture({
|
||||
command: fixture.fakeDaemonNode,
|
||||
args: [fixture.assertScript, ...fixture.expectedArgs],
|
||||
shell: false,
|
||||
});
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("ARGV_OK");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
|
||||
import { isWindowsCommandScript, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -24,13 +24,15 @@ export function spawnProcess(
|
||||
options?: SpawnOptions,
|
||||
): ChildProcess {
|
||||
const isWindows = process.platform === "win32";
|
||||
const shell = isWindowsCommandScript(command) ? true : (options?.shell ?? isWindows);
|
||||
|
||||
const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args;
|
||||
const shouldQuoteForShell = isWindows && shell !== false;
|
||||
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
|
||||
|
||||
return spawn(resolvedCommand, resolvedArgs, {
|
||||
...options,
|
||||
shell: options?.shell ?? isWindows,
|
||||
shell,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
@@ -41,8 +43,10 @@ export async function execCommand(
|
||||
options?: ExecCommandOptions,
|
||||
): Promise<ExecCommandResult> {
|
||||
const isWindows = process.platform === "win32";
|
||||
const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args;
|
||||
const shell = isWindowsCommandScript(command) ? true : isWindows;
|
||||
const shouldQuoteForShell = isWindows && shell !== false;
|
||||
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
|
||||
|
||||
return execFileAsync(resolvedCommand, resolvedArgs, {
|
||||
cwd: options?.cwd,
|
||||
@@ -50,7 +54,7 @@ export async function execCommand(
|
||||
encoding: options?.encoding ?? "utf8",
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
shell: isWindows,
|
||||
shell,
|
||||
windowsHide: true,
|
||||
}) as Promise<ExecCommandResult>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user