fix(server): harden findExecutable + remove gratuitous realpath from spawn paths (#787)
* Phase 0: baseline tests for probeExecutable * Phase 1: invert self node env handling * Phase 2: refactor probeExecutable to execCommand * Phase 3: push POSIX executable lookup to system which * Fix probe test spawn-failure fixture * Guard system which executable lookup
This commit is contained in:
@@ -120,6 +120,7 @@ jobs:
|
|||||||
working-directory: packages/server
|
working-directory: packages/server
|
||||||
run: >
|
run: >
|
||||||
npx vitest run
|
npx vitest run
|
||||||
|
src/utils/executable.probe.test.ts
|
||||||
src/utils/executable.test.ts
|
src/utils/executable.test.ts
|
||||||
src/utils/spawn.launch-regression.test.ts
|
src/utils/spawn.launch-regression.test.ts
|
||||||
src/utils/spawn.percent-escape.test.ts
|
src/utils/spawn.percent-escape.test.ts
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import {
|
|||||||
createProviderEnvSpec,
|
createProviderEnvSpec,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
|
import { buildSelfNodeCommand } from "../../paseo-env.js";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
||||||
@@ -342,14 +343,26 @@ function applyRuntimeSettingsToClaudeOptions(
|
|||||||
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
|
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
|
||||||
// or the user overrides the command via runtime settings, use that directly.
|
// or the user overrides the command via runtime settings, use that directly.
|
||||||
const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
|
const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
|
||||||
const command = isDefaultRuntime ? process.execPath : resolved.command;
|
const providerEnvSpec = createProviderEnvSpec({
|
||||||
const child = spawnProcess(command, resolved.args, {
|
baseEnv: spawnOptions.env,
|
||||||
|
runtimeSettings,
|
||||||
|
overlays: [launchEnv],
|
||||||
|
});
|
||||||
|
const providerEnv = createProviderEnv({
|
||||||
|
baseEnv: spawnOptions.env,
|
||||||
|
runtimeSettings,
|
||||||
|
overlays: [launchEnv],
|
||||||
|
});
|
||||||
|
const selfNodeCommand = isDefaultRuntime
|
||||||
|
? buildSelfNodeCommand(resolved.args, providerEnv)
|
||||||
|
: null;
|
||||||
|
const command = selfNodeCommand?.command ?? resolved.command;
|
||||||
|
const args = selfNodeCommand?.args ?? resolved.args;
|
||||||
|
const child = spawnProcess(command, args, {
|
||||||
cwd: spawnOptions.cwd,
|
cwd: spawnOptions.cwd,
|
||||||
...createProviderEnvSpec({
|
...(selfNodeCommand
|
||||||
baseEnv: spawnOptions.env,
|
? { env: selfNodeCommand.env, envMode: "internal" as const }
|
||||||
runtimeSettings,
|
: providerEnvSpec),
|
||||||
overlays: [launchEnv],
|
|
||||||
}),
|
|
||||||
signal: spawnOptions.signal,
|
signal: spawnOptions.signal,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
// Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
|
// Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildSelfNodeCommand,
|
||||||
createExternalCommandProcessEnv,
|
createExternalCommandProcessEnv,
|
||||||
createExternalProcessEnv,
|
createExternalProcessEnv,
|
||||||
createPaseoInternalEnv,
|
createPaseoInternalEnv,
|
||||||
@@ -69,13 +70,13 @@ describe("paseo env contract", () => {
|
|||||||
expect(env.PATH).toBe("/custom/bin");
|
expect(env.PATH).toBe("/custom/bin");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("builds process.execPath external command env with Electron node mode", () => {
|
test("builds external command env without process.execPath special-casing", () => {
|
||||||
const env = createExternalCommandProcessEnv(process.execPath, baseEnv, {
|
const env = createExternalCommandProcessEnv(process.execPath, baseEnv, {
|
||||||
ELECTRON_RUN_AS_NODE: "0",
|
ELECTRON_RUN_AS_NODE: "0",
|
||||||
PASEO_NODE_ENV: "test",
|
PASEO_NODE_ENV: "test",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(env[ELECTRON_RUN_AS_NODE]).toBe("1");
|
expect(env[ELECTRON_RUN_AS_NODE]).toBeUndefined();
|
||||||
expect(env.NODE_ENV).toBe("development");
|
expect(env.NODE_ENV).toBe("development");
|
||||||
expect(env.PASEO_AGENT_ID).toBe("agent-123");
|
expect(env.PASEO_AGENT_ID).toBe("agent-123");
|
||||||
expect(env.PATH).toBe("/usr/bin");
|
expect(env.PATH).toBe("/usr/bin");
|
||||||
@@ -85,16 +86,19 @@ describe("paseo env contract", () => {
|
|||||||
expect(env.PASEO_SUPERVISED).toBeUndefined();
|
expect(env.PASEO_SUPERVISED).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("always re-adds Electron node mode after scrubbing process.execPath overlays", () => {
|
test("builds self node command with Electron node mode", () => {
|
||||||
const env = createExternalCommandProcessEnv(process.execPath, baseEnv, {
|
const command = buildSelfNodeCommand(["script.js"], {
|
||||||
ELECTRON_RUN_AS_NODE: undefined,
|
CUSTOM: "value",
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const key of runtimeControlEnvKeys) {
|
expect(command.command).toBe(process.execPath);
|
||||||
if (key === ELECTRON_RUN_AS_NODE) continue;
|
expect(command.args).toEqual(["script.js"]);
|
||||||
expect(env[key]).toBeUndefined();
|
expect(command.env[ELECTRON_RUN_AS_NODE]).toBe("1");
|
||||||
}
|
expect(command.env.CUSTOM).toBe("value");
|
||||||
expect(env[ELECTRON_RUN_AS_NODE]).toBe("1");
|
expect(command.env.ELECTRON_NO_ATTACH_CONSOLE).toBeUndefined();
|
||||||
|
expect(command.env.PASEO_DESKTOP_MANAGED).toBeUndefined();
|
||||||
|
expect(command.env[PASEO_NODE_ENV]).toBeUndefined();
|
||||||
|
expect(command.env.PASEO_SUPERVISED).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not add Electron node mode for non-execPath commands", () => {
|
test("does not add Electron node mode for non-execPath commands", () => {
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { realpathSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const PASEO_NODE_ENV = "PASEO_NODE_ENV";
|
const PASEO_NODE_ENV = "PASEO_NODE_ENV";
|
||||||
const ELECTRON_RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
|
const ELECTRON_RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
|
||||||
|
|
||||||
@@ -14,9 +11,7 @@ const RUNTIME_CONTROL_ENV_KEYS = [
|
|||||||
|
|
||||||
export type PaseoNodeEnv = "development" | "production" | "test";
|
export type PaseoNodeEnv = "development" | "production" | "test";
|
||||||
export type ProcessEnvRecord = Record<string, string | undefined>;
|
export type ProcessEnvRecord = Record<string, string | undefined>;
|
||||||
type ExternalProcessEnv = NodeJS.ProcessEnv & Record<string, string>;
|
export type ExternalProcessEnv = NodeJS.ProcessEnv & Record<string, string>;
|
||||||
|
|
||||||
let resolvedProcessExecPath: string | undefined;
|
|
||||||
|
|
||||||
function buildInternalProcessEnv<T extends ProcessEnvRecord>(baseEnv: T): T {
|
function buildInternalProcessEnv<T extends ProcessEnvRecord>(baseEnv: T): T {
|
||||||
return { ...baseEnv };
|
return { ...baseEnv };
|
||||||
@@ -38,37 +33,6 @@ function buildExternalProcessEnv(
|
|||||||
return sanitized as ExternalProcessEnv;
|
return sanitized as ExternalProcessEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExecutablePath(executablePath: string): string {
|
|
||||||
return process.platform === "win32" ? executablePath.toLowerCase() : executablePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveExecutablePath(executablePath: string): string | undefined {
|
|
||||||
try {
|
|
||||||
return realpathSync.native(executablePath);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isProcessExecPathCommand(command: string): boolean {
|
|
||||||
if (command === process.execPath) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!path.isAbsolute(command)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedProcessExecPath ??= resolveExecutablePath(process.execPath);
|
|
||||||
const resolvedCommand = resolveExecutablePath(command);
|
|
||||||
if (!resolvedCommand || !resolvedProcessExecPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
normalizeExecutablePath(resolvedCommand) === normalizeExecutablePath(resolvedProcessExecPath)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPaseoInternalEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
export function createPaseoInternalEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
return buildInternalProcessEnv(baseEnv);
|
return buildInternalProcessEnv(baseEnv);
|
||||||
}
|
}
|
||||||
@@ -81,15 +45,34 @@ export function createExternalProcessEnv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createExternalCommandProcessEnv(
|
export function createExternalCommandProcessEnv(
|
||||||
command: string,
|
_command: string,
|
||||||
baseEnv: ProcessEnvRecord,
|
baseEnv: ProcessEnvRecord,
|
||||||
...overlays: ProcessEnvRecord[]
|
...overlays: ProcessEnvRecord[]
|
||||||
): ExternalProcessEnv {
|
): ExternalProcessEnv {
|
||||||
const env = buildExternalProcessEnv(baseEnv, overlays);
|
// Deprecated command parameter: retained while callers migrate to createExternalProcessEnv.
|
||||||
if (isProcessExecPathCommand(command)) {
|
return buildExternalProcessEnv(baseEnv, overlays);
|
||||||
env[ELECTRON_RUN_AS_NODE] = "1";
|
}
|
||||||
|
|
||||||
|
export function buildSelfNodeCommand(
|
||||||
|
args: string[],
|
||||||
|
envOverlay?: ProcessEnvRecord,
|
||||||
|
): {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env: ExternalProcessEnv;
|
||||||
|
} {
|
||||||
|
const env = buildExternalProcessEnv(process.env, []);
|
||||||
|
Object.assign(env, { [ELECTRON_RUN_AS_NODE]: "1" }, envOverlay);
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete env[key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return env;
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePaseoNodeEnv(env: NodeJS.ProcessEnv): PaseoNodeEnv | undefined {
|
export function resolvePaseoNodeEnv(env: NodeJS.ProcessEnv): PaseoNodeEnv | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { probeExecutable } from "./executable.js";
|
||||||
|
|
||||||
|
const timeoutMs = 1000;
|
||||||
|
const timeoutSlackMs = 500;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
interface ProbeFixture {
|
||||||
|
name: string;
|
||||||
|
expected: boolean;
|
||||||
|
create: (dir: string) => { executablePath: string; pidFile?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), "paseo-probe-test-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeExecutable(filePath: string, content: string | Buffer): string {
|
||||||
|
writeFileSync(filePath, content);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
chmodSync(filePath, 0o755);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptPath(dir: string, name: string): string {
|
||||||
|
return process.platform === "win32" ? path.join(dir, `${name}.cmd`) : path.join(dir, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHangingFixture(dir: string): string {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return writeExecutable(
|
||||||
|
scriptPath(dir, "hangs"),
|
||||||
|
"@echo off\r\n:loop\r\ntimeout /T 5 /NOBREAK > NUL\r\ngoto loop\r\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return writeExecutable(
|
||||||
|
scriptPath(dir, "hangs"),
|
||||||
|
`#!/bin/sh\ntrap '' TERM\necho $$ > "${path.join(dir, "hangs.pid")}"\nwhile :; do :; done\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNoVersionFixture(dir: string): string {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return writeExecutable(scriptPath(dir, "no-version"), "@echo off\r\nexit /b 0\r\n");
|
||||||
|
}
|
||||||
|
return writeExecutable(scriptPath(dir, "no-version"), "#!/bin/sh\nexit 0\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNonZeroFixture(dir: string): string {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return writeExecutable(
|
||||||
|
scriptPath(dir, "non-zero"),
|
||||||
|
"@echo off\r\necho oops 1>&2\r\nexit /b 1\r\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return writeExecutable(scriptPath(dir, "non-zero"), "#!/bin/sh\necho oops 1>&2\nexit 1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSlowSuccessFixture(dir: string): string {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return writeExecutable(
|
||||||
|
scriptPath(dir, "slow-success"),
|
||||||
|
"@echo off\r\nping -n 1 127.0.0.1 > NUL\r\nexit /b 0\r\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return writeExecutable(scriptPath(dir, "slow-success"), "#!/bin/sh\nsleep 0.05\nexit 0\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDirectoryFixture(dir: string): string {
|
||||||
|
const directoryPath = path.join(dir, "candidate-directory");
|
||||||
|
mkdirSync(directoryPath);
|
||||||
|
return directoryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingAbsolutePath(): string {
|
||||||
|
return process.platform === "win32" ? "C:\\no\\such\\path.exe" : "/no/such/path";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFile(filePath: string): Promise<void> {
|
||||||
|
const deadline = performance.now() + timeoutSlackMs;
|
||||||
|
while (!existsSync(filePath) && performance.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixtures: ProbeFixture[] = [
|
||||||
|
{
|
||||||
|
name: "hangs forever after starting",
|
||||||
|
expected: true,
|
||||||
|
create: (dir) => ({
|
||||||
|
executablePath: createHangingFixture(dir),
|
||||||
|
pidFile: process.platform === "win32" ? undefined : path.join(dir, "hangs.pid"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "does not know --version and exits zero",
|
||||||
|
expected: true,
|
||||||
|
create: (dir) => ({ executablePath: createNoVersionFixture(dir) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exits non-zero immediately",
|
||||||
|
expected: true,
|
||||||
|
create: (dir) => ({ executablePath: createNonZeroFixture(dir) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "starts slowly and exits zero",
|
||||||
|
expected: true,
|
||||||
|
create: (dir) => ({ executablePath: createSlowSuccessFixture(dir) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "points at a directory",
|
||||||
|
expected: false,
|
||||||
|
create: (dir) => ({ executablePath: createDirectoryFixture(dir) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "does not exist at an absolute path",
|
||||||
|
expected: false,
|
||||||
|
create: () => ({ executablePath: missingAbsolutePath() }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("probeExecutable", () => {
|
||||||
|
test.each(fixtures)("$name", async ({ create, expected }) => {
|
||||||
|
const { executablePath, pidFile } = create(makeTempDir());
|
||||||
|
const startedAt = performance.now();
|
||||||
|
|
||||||
|
const result = await probeExecutable(executablePath, timeoutMs);
|
||||||
|
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs);
|
||||||
|
if (pidFile) {
|
||||||
|
await waitForFile(pidFile);
|
||||||
|
const pid = Number(readFileSync(pidFile, "utf8"));
|
||||||
|
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
import { spawnProcess } from "./spawn.js";
|
import { execCommand } from "./spawn.js";
|
||||||
import { isWindowsCommandScript } from "./windows-command.js";
|
import { isWindowsCommandScript } from "./windows-command.js";
|
||||||
|
|
||||||
export { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js";
|
export { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js";
|
||||||
@@ -18,6 +17,25 @@ function hasPathSeparator(value: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function enumerateCandidates(name: string): Promise<string[]> {
|
async function enumerateCandidates(name: string): Promise<string[]> {
|
||||||
|
if (process.platform !== "win32" && existsSync("/usr/bin/which")) {
|
||||||
|
return enumerateCandidatesViaSystemWhich(name);
|
||||||
|
}
|
||||||
|
return enumerateCandidatesViaLibrary(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enumerateCandidatesViaSystemWhich(name: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execCommand("/usr/bin/which", ["-a", name], {
|
||||||
|
timeout: 3000,
|
||||||
|
killSignal: "SIGKILL",
|
||||||
|
});
|
||||||
|
return Array.from(new Set(stdout.trim().split("\n").filter(Boolean)));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enumerateCandidatesViaLibrary(name: string): Promise<string[]> {
|
||||||
let candidates: string[];
|
let candidates: string[];
|
||||||
try {
|
try {
|
||||||
candidates = await which(name, { all: true });
|
candidates = await which(name, { all: true });
|
||||||
@@ -39,57 +57,42 @@ async function enumerateCandidates(name: string): Promise<string[]> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeExecutable(executablePath: string): Promise<boolean> {
|
export async function probeExecutable(
|
||||||
return await new Promise((resolve) => {
|
executablePath: string,
|
||||||
let pendingResolve: ((result: boolean) => void) | null = resolve;
|
timeoutMs = PROBE_TIMEOUT_MS,
|
||||||
let started = false;
|
): Promise<boolean> {
|
||||||
let timer: NodeJS.Timeout | undefined;
|
try {
|
||||||
|
await execCommand(executablePath, ["--version"], {
|
||||||
const settle = (result: boolean) => {
|
timeout: timeoutMs,
|
||||||
if (!pendingResolve) {
|
killSignal: "SIGKILL",
|
||||||
return;
|
maxBuffer: 64 * 1024,
|
||||||
}
|
shell: isWindowsCommandScript(executablePath),
|
||||||
const fn = pendingResolve;
|
|
||||||
pendingResolve = null;
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
fn(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
let child: ChildProcess;
|
|
||||||
try {
|
|
||||||
child = spawnProcess(executablePath, ["--version"], {
|
|
||||||
stdio: "ignore",
|
|
||||||
// Windows batch shims (.cmd/.bat) require cmd.exe; native binaries do not.
|
|
||||||
shell: isWindowsCommandScript(executablePath),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
settle(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (started) {
|
|
||||||
child.kill();
|
|
||||||
settle(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settle(false);
|
|
||||||
}, PROBE_TIMEOUT_MS) as unknown as NodeJS.Timeout;
|
|
||||||
timer.unref();
|
|
||||||
|
|
||||||
child.once("spawn", () => {
|
|
||||||
started = true;
|
|
||||||
});
|
});
|
||||||
child.once("error", () => {
|
return true;
|
||||||
// ENOENT/EACCES/EPERM/UNKNOWN here means the OS could not start the candidate.
|
} catch (error) {
|
||||||
settle(started);
|
return classifyProbeError(error);
|
||||||
});
|
}
|
||||||
child.once("exit", () => {
|
}
|
||||||
settle(started);
|
|
||||||
});
|
function classifyProbeError(error: unknown): boolean {
|
||||||
});
|
const err = error as NodeJS.ErrnoException & {
|
||||||
|
killed?: boolean;
|
||||||
|
};
|
||||||
|
if (err.killed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof err.code === "number") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err.code === "ENOENT" ||
|
||||||
|
err.code === "EACCES" ||
|
||||||
|
err.code === "ENOEXEC" ||
|
||||||
|
err.code === "UNKNOWN"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,18 +112,23 @@ export function executableExists(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findExecutable(name: string): Promise<string | null> {
|
export async function findExecutable(
|
||||||
|
name: string,
|
||||||
|
probeTimeoutMs = PROBE_TIMEOUT_MS,
|
||||||
|
): Promise<string | null> {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPathSeparator(trimmed)) {
|
if (hasPathSeparator(trimmed)) {
|
||||||
return (await probeExecutable(trimmed)) ? trimmed : null;
|
return (await probeExecutable(trimmed, probeTimeoutMs)) ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = await enumerateCandidates(trimmed);
|
const candidates = await enumerateCandidates(trimmed);
|
||||||
const probeResults = await Promise.all(candidates.map((candidate) => probeExecutable(candidate)));
|
const probeResults = await Promise.all(
|
||||||
|
candidates.map((candidate) => probeExecutable(candidate, probeTimeoutMs)),
|
||||||
|
);
|
||||||
const firstMatch = probeResults.findIndex((result) => result);
|
const firstMatch = probeResults.findIndex((result) => result);
|
||||||
return firstMatch === -1 ? null : candidates[firstMatch];
|
return firstMatch === -1 ? null : candidates[firstMatch];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||||
|
import * as fs from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { buildSelfNodeCommand } from "../server/paseo-env.js";
|
||||||
import { execCommand, spawnProcess } from "./spawn.js";
|
import { execCommand, spawnProcess } from "./spawn.js";
|
||||||
|
|
||||||
const printEnvScript = `
|
const printEnvScript = `
|
||||||
@@ -93,7 +95,7 @@ describe("execCommand", () => {
|
|||||||
expect(parsePrintedEnv(result.stdout)).toEqual({
|
expect(parsePrintedEnv(result.stdout)).toEqual({
|
||||||
CUSTOM: "from-overlay",
|
CUSTOM: "from-overlay",
|
||||||
ELECTRON_NO_ATTACH_CONSOLE: null,
|
ELECTRON_NO_ATTACH_CONSOLE: null,
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
ELECTRON_RUN_AS_NODE: null,
|
||||||
PASEO_DESKTOP_MANAGED: null,
|
PASEO_DESKTOP_MANAGED: null,
|
||||||
PASEO_NODE_ENV: null,
|
PASEO_NODE_ENV: null,
|
||||||
PASEO_SUPERVISED: null,
|
PASEO_SUPERVISED: null,
|
||||||
@@ -150,7 +152,7 @@ describe("execCommand", () => {
|
|||||||
expect(parsePrintedEnv(Buffer.concat(stdoutChunks).toString())).toEqual({
|
expect(parsePrintedEnv(Buffer.concat(stdoutChunks).toString())).toEqual({
|
||||||
CUSTOM: "spawn-overlay",
|
CUSTOM: "spawn-overlay",
|
||||||
ELECTRON_NO_ATTACH_CONSOLE: null,
|
ELECTRON_NO_ATTACH_CONSOLE: null,
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
ELECTRON_RUN_AS_NODE: null,
|
||||||
PASEO_DESKTOP_MANAGED: null,
|
PASEO_DESKTOP_MANAGED: null,
|
||||||
PASEO_NODE_ENV: null,
|
PASEO_NODE_ENV: null,
|
||||||
PASEO_SUPERVISED: null,
|
PASEO_SUPERVISED: null,
|
||||||
@@ -180,4 +182,37 @@ describe("execCommand", () => {
|
|||||||
PASEO_SUPERVISED: "1",
|
PASEO_SUPERVISED: "1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not realpath commands while finalizing external command env", async () => {
|
||||||
|
const realpathSpy = vi.spyOn(fs.realpathSync, "native");
|
||||||
|
|
||||||
|
await execCommand("/some/random/binary", ["--version"], {
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
},
|
||||||
|
timeout: 100,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
expect(realpathSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("self node command explicitly enables Electron node mode", async () => {
|
||||||
|
const command = buildSelfNodeCommand(["-e", printEnvScript], {
|
||||||
|
CUSTOM: "from-helper",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await execCommand(command.command, command.args, {
|
||||||
|
env: command.env,
|
||||||
|
envMode: "internal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsePrintedEnv(result.stdout)).toEqual({
|
||||||
|
CUSTOM: "from-helper",
|
||||||
|
ELECTRON_NO_ATTACH_CONSOLE: null,
|
||||||
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
|
PASEO_DESKTOP_MANAGED: null,
|
||||||
|
PASEO_NODE_ENV: null,
|
||||||
|
PASEO_SUPERVISED: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ export type SpawnProcessOptions = Omit<SpawnOptions, "env"> & ExternalEnvOptions
|
|||||||
interface ExecCommandOptions extends ExternalEnvOptions {
|
interface ExecCommandOptions extends ExternalEnvOptions {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
encoding?: BufferEncoding;
|
encoding?: BufferEncoding;
|
||||||
|
killSignal?: NodeJS.Signals;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
maxBuffer?: number;
|
maxBuffer?: number;
|
||||||
|
shell?: boolean | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExecCommandResult {
|
interface ExecCommandResult {
|
||||||
@@ -87,7 +89,7 @@ export async function execCommand(
|
|||||||
const { baseEnv, env, envOverlay } = options ?? {};
|
const { baseEnv, env, envOverlay } = options ?? {};
|
||||||
const resolvedBaseEnv = env ?? baseEnv ?? process.env;
|
const resolvedBaseEnv = env ?? baseEnv ?? process.env;
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
const shell = shouldUseWindowsShell(command);
|
const shell = shouldUseWindowsShell(command, options?.shell);
|
||||||
const shouldQuoteForShell = isWindows && shell !== false;
|
const shouldQuoteForShell = isWindows && shell !== false;
|
||||||
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
|
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
|
||||||
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
|
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
|
||||||
@@ -104,6 +106,7 @@ export async function execCommand(
|
|||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
env: childEnv,
|
env: childEnv,
|
||||||
encoding: options?.encoding ?? "utf8",
|
encoding: options?.encoding ?? "utf8",
|
||||||
|
killSignal: options?.killSignal,
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
maxBuffer: options?.maxBuffer,
|
maxBuffer: options?.maxBuffer,
|
||||||
shell,
|
shell,
|
||||||
|
|||||||
Reference in New Issue
Block a user