[codex] Centralize subprocess env boundaries (#585)

* Centralize subprocess env boundaries

* Remove env boundary static audit test

* Tighten env boundary process launches

* Fix Windows native process launches

* Inline provider env pass-throughs
This commit is contained in:
Mohamed Boudra
2026-04-27 13:24:16 +08:00
committed by GitHub
parent 90032570ec
commit 15df680a64
40 changed files with 760 additions and 304 deletions
+35 -36
View File
@@ -18,10 +18,9 @@ import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-pa
import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
import { readPaseoWorktreeMetadata } from "./worktree-metadata.js";
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
};
} as const;
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
@@ -178,7 +177,7 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
"--format=%(refname)%09%(committerdate:unix)",
refPrefix,
],
{ cwd, env: READ_ONLY_GIT_ENV },
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
return stdout
.split("\n")
@@ -326,7 +325,7 @@ export async function resolveBranchCheckout(
const localRef = `refs/heads/${normalized}`;
const localResult = await runGitCommand(["rev-parse", "--verify", "--quiet", localRef], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
acceptExitCodes: [0, 1],
});
const hasLocal = localResult.exitCode === 0;
@@ -338,7 +337,7 @@ export async function resolveBranchCheckout(
const remoteRefPath = `refs/remotes/${remoteRef}`;
const remoteResult = await runGitCommand(["rev-parse", "--verify", "--quiet", remoteRefPath], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
acceptExitCodes: [0, 1],
});
const hasRemote = remoteResult.exitCode === 0;
@@ -399,7 +398,7 @@ async function listCheckoutFileChanges(
ignoreWhitespace,
extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)],
}),
{ cwd, env: READ_ONLY_GIT_ENV },
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
for (const line of nameStatusOut
.split("\n")
@@ -441,7 +440,7 @@ async function listCheckoutFileChanges(
["ls-files", "--others", "--exclude-standard"],
{
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
},
);
for (const file of untrackedOut
@@ -481,7 +480,7 @@ async function readGitFileContentAtRef(
try {
const { stdout } = await runGitCommand(["show", `${ref}:${path}`], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
return stdout;
} catch {
@@ -493,7 +492,7 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
try {
const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const sha = stdout.trim();
return sha.length > 0 ? sha : null;
@@ -538,7 +537,7 @@ async function getTrackedNumstatByPath(
}),
{
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
maxOutputBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
acceptExitCodes: [0],
},
@@ -710,7 +709,7 @@ function isGitError(error: unknown): boolean {
async function requireGitRepo(cwd: string): Promise<void> {
try {
await runGitCommand(["rev-parse", "--git-dir"], { cwd, env: READ_ONLY_GIT_ENV });
await runGitCommand(["rev-parse", "--git-dir"], { cwd, envOverlay: READ_ONLY_GIT_ENV });
} catch {
throw new NotGitRepoError(cwd);
}
@@ -720,7 +719,7 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const branch = stdout.trim();
if (branch === "HEAD") {
@@ -739,7 +738,7 @@ async function getRebaseHeadBranch(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["rev-parse", "--git-path", path], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const headName = (await readFile(resolve(cwd, stdout.trim()), "utf8")).trim();
if (headName.startsWith("refs/heads/")) {
@@ -758,7 +757,7 @@ async function getWorktreeRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
return parseGitRevParsePath(stdout);
} catch {
@@ -769,7 +768,7 @@ async function getWorktreeRoot(cwd: string): Promise<string | null> {
export async function getMainRepoRoot(cwd: string): Promise<string> {
const { stdout: commonDirOut } = await runGitCommand(["rev-parse", "--git-common-dir"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const commonDir = resolveGitRevParsePath(cwd, commonDirOut);
if (!commonDir) {
@@ -783,7 +782,7 @@ export async function getMainRepoRoot(cwd: string): Promise<string> {
const { stdout: worktreeOut } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare && !isPaseoWorktreePath(wt.path));
@@ -849,7 +848,7 @@ async function getWorktreePathForBranch(cwd: string, branchName: string): Promis
try {
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const entries = parseWorktreeList(stdout);
const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`;
@@ -946,7 +945,7 @@ async function resolveBaseRefForCwd(
async function isWorkingTreeDirty(cwd: string): Promise<boolean> {
const { stdout } = await runGitCommand(["status", "--porcelain"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
return stdout.trim().length > 0;
}
@@ -955,7 +954,7 @@ export async function getOriginRemoteUrl(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const url = stdout.trim();
return url.length > 0 ? url : null;
@@ -973,7 +972,7 @@ async function getGitConfigValue(cwd: string, key: string): Promise<string | nul
try {
const { stdout } = await runGitCommand(["config", "--get", key], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const value = stdout.trim();
return value.length > 0 ? value : null;
@@ -1019,7 +1018,7 @@ export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null>
try {
const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const gitDir = stdout.trim();
return gitDir.length > 0 ? gitDir : null;
@@ -1061,7 +1060,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
{
cwd: repoRoot,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
},
);
const ref = stdout.trim();
@@ -1075,7 +1074,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
try {
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${localName}`], {
cwd: repoRoot,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
return localName;
} catch {
@@ -1088,7 +1087,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
const { stdout } = await runGitCommand(["branch", "--format=%(refname:short)"], {
cwd: repoRoot,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const branches = new Set(
stdout
@@ -1137,7 +1136,7 @@ function normalizeComparisonBaseRefName(input: string): ComparisonBaseRefName {
async function doesGitRefExist(cwd: string, fullRef: string): Promise<boolean> {
const result = await runGitCommand(["show-ref", "--verify", "--quiet", fullRef], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
acceptExitCodes: [0, 1],
});
return result.exitCode === 0;
@@ -1182,7 +1181,7 @@ async function resolveMostAheadBaseRef(cwd: string, normalizedBaseRef: string):
const { stdout } = await runGitCommand(
["rev-list", "--left-right", "--count", `${normalizedBaseRef}...origin/${normalizedBaseRef}`],
{ cwd, env: READ_ONLY_GIT_ENV },
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/);
const localOnly = Number.parseInt(localOnlyRaw ?? "0", 10);
@@ -1209,7 +1208,7 @@ async function getAheadBehind(
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
const { stdout } = await runGitCommand(
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
{ cwd, env: READ_ONLY_GIT_ENV },
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindRaw ?? "0", 10);
@@ -1227,7 +1226,7 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
try {
const { stdout } = await runGitCommand(
["rev-list", "--count", `origin/${currentBranch}..${currentBranch}`],
{ cwd, env: READ_ONLY_GIT_ENV },
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
const count = Number.parseInt(stdout.trim(), 10);
return Number.isNaN(count) ? null : count;
@@ -1235,7 +1234,7 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
try {
const { stdout } = await runGitCommand(["rev-list", "--count", currentBranch], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const count = Number.parseInt(stdout.trim(), 10);
return Number.isNaN(count) ? null : count;
@@ -1252,7 +1251,7 @@ async function getBehindOfOrigin(cwd: string, currentBranch: string): Promise<nu
try {
const { stdout } = await runGitCommand(
["rev-list", "--count", `${currentBranch}..origin/${currentBranch}`],
{ cwd, env: READ_ONLY_GIT_ENV },
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
const count = Number.parseInt(stdout.trim(), 10);
return Number.isNaN(count) ? null : count;
@@ -1396,7 +1395,7 @@ async function getUntrackedDiffText(
}),
{
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
maxOutputBytes: PER_FILE_DIFF_MAX_BYTES,
acceptExitCodes: [0, 1],
},
@@ -1528,7 +1527,7 @@ async function getCheckoutShortstatUncached(
try {
const { stdout: mergeBaseOut } = await runGitCommand(["merge-base", "HEAD", comparisonRef], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const mergeBase = mergeBaseOut.trim();
if (!mergeBase) {
@@ -1537,7 +1536,7 @@ async function getCheckoutShortstatUncached(
const { stdout } = await runGitCommand(["diff", "--shortstat", mergeBase], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
return parseCheckoutShortstat(stdout);
} catch {
@@ -1871,7 +1870,7 @@ export async function getCheckoutDiff(
}),
{
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
maxOutputBytes: TOTAL_DIFF_MAX_BYTES,
},
);
@@ -2113,7 +2112,7 @@ export async function mergeFromBase(
if (requireCleanTarget) {
const { stdout } = await runGitCommand(["status", "--porcelain"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
if (stdout.trim().length > 0) {
throw new Error("Working directory has uncommitted changes.");
+6 -44
View File
@@ -1,7 +1,11 @@
import { spawn, type ChildProcess } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { extname } from "node:path";
import { spawnProcess } from "./spawn.js";
import { isWindowsCommandScript } from "./windows-command.js";
export { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js";
type Which = (command: string, options: { all: true }) => Promise<string[]>;
@@ -35,11 +39,6 @@ async function enumerateCandidates(name: string): Promise<string[]> {
});
}
export function isWindowsCommandScript(executablePath: string): boolean {
const extension = extname(executablePath).toLowerCase();
return process.platform === "win32" && (extension === ".cmd" || extension === ".bat");
}
async function probeExecutable(executablePath: string): Promise<boolean> {
return await new Promise((resolve) => {
let pendingResolve: ((result: boolean) => void) | null = resolve;
@@ -60,9 +59,8 @@ async function probeExecutable(executablePath: string): Promise<boolean> {
let child: ChildProcess;
try {
child = spawn(executablePath, ["--version"], {
child = spawnProcess(executablePath, ["--version"], {
stdio: "ignore",
windowsHide: true,
// Windows batch shims (.cmd/.bat) require cmd.exe; native binaries do not.
shell: isWindowsCommandScript(executablePath),
});
@@ -130,39 +128,3 @@ export async function findExecutable(name: string): Promise<string | null> {
export async function isCommandAvailable(command: string): Promise<boolean> {
return (await findExecutable(command)) !== null;
}
function escapeWindowsCmdValue(value: string): string {
if (process.platform !== "win32") return value;
const isQuoted = value.startsWith('"') && value.endsWith('"');
const unquoted = isQuoted ? value.slice(1, -1) : value;
const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1");
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;
}
/**
* When spawning with `shell: true` on Windows, the command is passed to
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
* command path with spaces (e.g. `C:\Program Files\...`) is split at the
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
*/
export function quoteWindowsCommand(command: string): string {
return escapeWindowsCmdValue(command);
}
/**
* `spawn(..., { shell: true })` on Windows also passes argv through `cmd.exe`.
* Any argument containing spaces must be quoted or it will be split before the
* child process sees it.
*/
export function quoteWindowsArgument(argument: string): string {
return escapeWindowsCmdValue(argument);
}
+17 -2
View File
@@ -1,4 +1,5 @@
import pLimit from "p-limit";
import type { ProcessEnvRecord } from "../server/paseo-env.js";
import { spawnProcess } from "./spawn.js";
const DEFAULT_TIMEOUT_MS = 30_000;
@@ -10,7 +11,8 @@ const gitLimit = pLimit(gitConcurrency);
export interface GitCommandOptions {
cwd: string;
env?: NodeJS.ProcessEnv;
env?: ProcessEnvRecord;
envOverlay?: ProcessEnvRecord;
timeout?: number;
maxOutputBytes?: number;
acceptExitCodes?: number[];
@@ -24,6 +26,19 @@ export interface GitCommandResult {
signal: NodeJS.Signals | null;
}
function mergeEnvOverlays(
env: ProcessEnvRecord | undefined,
envOverlay: ProcessEnvRecord | undefined,
): ProcessEnvRecord | undefined {
if (!env) {
return envOverlay;
}
if (!envOverlay) {
return env;
}
return { ...env, ...envOverlay };
}
export function runGitCommand(
args: string[],
options: GitCommandOptions,
@@ -38,7 +53,7 @@ export function runGitCommand(
const child = spawnProcess("git", args, {
cwd: options.cwd,
env: options.env,
envOverlay: mergeEnvOverlays(options.env, options.envOverlay),
stdio: ["ignore", "pipe", "pipe"],
});
+130 -1
View File
@@ -3,7 +3,24 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { execCommand } from "./spawn.js";
import { execCommand, spawnProcess } from "./spawn.js";
const printEnvScript = `
const keys = [
"CUSTOM",
"ELECTRON_NO_ATTACH_CONSOLE",
"ELECTRON_RUN_AS_NODE",
"PASEO_DESKTOP_MANAGED",
"PASEO_NODE_ENV",
"PASEO_SUPERVISED",
];
const values = Object.fromEntries(keys.map((key) => [key, process.env[key] ?? null]));
console.log(JSON.stringify(values));
`;
function parsePrintedEnv(stdout: string): Record<string, string | null> {
return JSON.parse(stdout.trim()) as Record<string, string | null>;
}
describe("execCommand", () => {
const tempDirs: string[] = [];
@@ -51,4 +68,116 @@ describe("execCommand", () => {
expect(result.stdout.trim()).toBe(cwd);
expect(result.stderr).toBe("");
});
test("treats env as the replacement base and finalizes external command env", async () => {
const result = await execCommand(process.execPath, ["-e", printEnvScript], {
baseEnv: {
ELECTRON_RUN_AS_NODE: "0",
CUSTOM: "from-base",
PATH: process.env.PATH,
PASEO_NODE_ENV: "production",
PASEO_SUPERVISED: "1",
},
env: {
CUSTOM: "from-env",
ELECTRON_NO_ATTACH_CONSOLE: "1",
PASEO_DESKTOP_MANAGED: "1",
PASEO_NODE_ENV: "test",
},
envOverlay: {
CUSTOM: "from-overlay",
ELECTRON_RUN_AS_NODE: undefined,
},
});
expect(parsePrintedEnv(result.stdout)).toEqual({
CUSTOM: "from-overlay",
ELECTRON_NO_ATTACH_CONSOLE: null,
ELECTRON_RUN_AS_NODE: "1",
PASEO_DESKTOP_MANAGED: null,
PASEO_NODE_ENV: null,
PASEO_SUPERVISED: null,
});
});
test("does not inherit process.env when env replacement is supplied", async () => {
process.env.PASEO_TEST_SHOULD_NOT_LEAK = "leaked";
try {
const result = await execCommand(
process.execPath,
[
"-e",
"console.log(JSON.stringify({ leaked: process.env.PASEO_TEST_SHOULD_NOT_LEAK ?? null }))",
],
{
env: {
PATH: process.env.PATH,
},
},
);
expect(JSON.parse(result.stdout.trim())).toEqual({ leaked: null });
} finally {
delete process.env.PASEO_TEST_SHOULD_NOT_LEAK;
}
});
test("spawnProcess finalizes external command env", async () => {
const child = spawnProcess(process.execPath, ["-e", printEnvScript], {
baseEnv: {
ELECTRON_RUN_AS_NODE: "0",
PATH: process.env.PATH,
PASEO_NODE_ENV: "production",
},
envOverlay: {
CUSTOM: "spawn-overlay",
PASEO_SUPERVISED: "1",
},
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
const exitCode = await new Promise<number | null>((resolve, reject) => {
child.on("error", reject);
child.on("close", resolve);
});
expect(Buffer.concat(stderrChunks).toString()).toBe("");
expect(exitCode).toBe(0);
expect(parsePrintedEnv(Buffer.concat(stdoutChunks).toString())).toEqual({
CUSTOM: "spawn-overlay",
ELECTRON_NO_ATTACH_CONSOLE: null,
ELECTRON_RUN_AS_NODE: "1",
PASEO_DESKTOP_MANAGED: null,
PASEO_NODE_ENV: null,
PASEO_SUPERVISED: null,
});
});
test("internal env mode preserves Paseo-owned launcher env", async () => {
const result = await execCommand(process.execPath, ["-e", printEnvScript], {
envMode: "internal",
baseEnv: {
ELECTRON_RUN_AS_NODE: "1",
PATH: process.env.PATH,
PASEO_NODE_ENV: "production",
},
envOverlay: {
CUSTOM: "internal",
PASEO_SUPERVISED: "1",
},
});
expect(parsePrintedEnv(result.stdout)).toEqual({
CUSTOM: "internal",
ELECTRON_NO_ATTACH_CONSOLE: null,
ELECTRON_RUN_AS_NODE: "1",
PASEO_DESKTOP_MANAGED: null,
PASEO_NODE_ENV: "production",
PASEO_SUPERVISED: "1",
});
});
});
+60 -8
View File
@@ -1,13 +1,27 @@
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { extname } from "node:path";
import { promisify } from "node:util";
import { isWindowsCommandScript, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
import { createExternalCommandProcessEnv, type ProcessEnvRecord } from "../server/paseo-env.js";
import {
isWindowsCommandScript,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./windows-command.js";
const execFileAsync = promisify(execFile);
interface ExecCommandOptions {
interface ExternalEnvOptions {
baseEnv?: ProcessEnvRecord;
envMode?: "external" | "internal";
env?: ProcessEnvRecord;
envOverlay?: ProcessEnvRecord;
}
export type SpawnProcessOptions = Omit<SpawnOptions, "env"> & ExternalEnvOptions;
interface ExecCommandOptions extends ExternalEnvOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
encoding?: BufferEncoding;
timeout?: number;
maxBuffer?: number;
@@ -18,20 +32,48 @@ interface ExecCommandResult {
stderr: string;
}
function hasPathSeparator(value: string): boolean {
return value.includes("/") || value.includes("\\");
}
function shouldUseWindowsShell(
command: string,
requestedShell?: boolean | string,
): boolean | string {
if (isWindowsCommandScript(command)) {
return true;
}
if (requestedShell !== undefined) {
return requestedShell;
}
return process.platform === "win32" && !hasPathSeparator(command) && !extname(command);
}
export function spawnProcess(
command: string,
args: string[],
options?: SpawnOptions,
options?: SpawnProcessOptions,
): ChildProcess {
const { baseEnv, env, envOverlay, ...spawnOptions } = options ?? {};
const resolvedBaseEnv = env ?? baseEnv ?? process.env;
const isWindows = process.platform === "win32";
const shell = isWindowsCommandScript(command) ? true : (options?.shell ?? isWindows);
const shell = shouldUseWindowsShell(command, spawnOptions.shell);
const shouldQuoteForShell = isWindows && shell !== false;
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
const childEnv =
options?.envMode === "internal"
? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv)
: createExternalCommandProcessEnv(
command,
resolvedBaseEnv,
...(envOverlay ? [envOverlay] : []),
);
return spawn(resolvedCommand, resolvedArgs, {
...options,
...spawnOptions,
env: childEnv,
shell,
windowsHide: true,
});
@@ -42,15 +84,25 @@ export async function execCommand(
args: string[],
options?: ExecCommandOptions,
): Promise<ExecCommandResult> {
const { baseEnv, env, envOverlay } = options ?? {};
const resolvedBaseEnv = env ?? baseEnv ?? process.env;
const isWindows = process.platform === "win32";
const shell = isWindowsCommandScript(command) ? true : isWindows;
const shell = shouldUseWindowsShell(command);
const shouldQuoteForShell = isWindows && shell !== false;
const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command;
const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args;
const childEnv =
options?.envMode === "internal"
? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv)
: createExternalCommandProcessEnv(
command,
resolvedBaseEnv,
...(envOverlay ? [envOverlay] : []),
);
return execFileAsync(resolvedCommand, resolvedArgs, {
cwd: options?.cwd,
env: options?.env,
env: childEnv,
encoding: options?.encoding ?? "utf8",
timeout: options?.timeout,
maxBuffer: options?.maxBuffer,
@@ -0,0 +1,42 @@
import { extname } from "node:path";
export function isWindowsCommandScript(executablePath: string): boolean {
const extension = extname(executablePath).toLowerCase();
return process.platform === "win32" && (extension === ".cmd" || extension === ".bat");
}
function escapeWindowsCmdValue(value: string): string {
if (process.platform !== "win32") return value;
const isQuoted = value.startsWith('"') && value.endsWith('"');
const unquoted = isQuoted ? value.slice(1, -1) : value;
const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1");
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;
}
/**
* When spawning with `shell: true` on Windows, the command is passed to
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
* command path with spaces (e.g. `C:\Program Files\...`) is split at the
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
*/
export function quoteWindowsCommand(command: string): string {
return escapeWindowsCmdValue(command);
}
/**
* `spawn(..., { shell: true })` on Windows also passes argv through `cmd.exe`.
* Any argument containing spaces must be quoted or it will be split before the
* child process sees it.
*/
export function quoteWindowsArgument(argument: string): string {
return escapeWindowsCmdValue(argument);
}
+16 -19
View File
@@ -1,4 +1,4 @@
import { execFile, spawn } from "child_process";
import { execFile } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
import { rm, stat } from "fs/promises";
@@ -27,15 +27,16 @@ import {
writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js";
import { runGitCommand } from "./run-git-command.js";
import { spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
const execFileAsync = promisify(execFile);
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
};
} as const;
export interface WorktreeConfig {
branchName: string;
@@ -451,7 +452,7 @@ async function execSetupCommandStreamed(options: {
const spawnWithPipes = () => {
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const child = spawn(shellInvocation.shell, shellInvocation.args, {
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
@@ -562,7 +563,7 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
try {
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd: worktreePath,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const topLevel = parseGitRevParsePath(stdout);
if (topLevel) {
@@ -596,10 +597,7 @@ export async function runWorktreeSetupCommands(options: {
branchName: options.branchName,
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
}));
const setupEnv = {
...process.env,
...runtimeEnv,
};
const setupEnv = createExternalProcessEnv(process.env, runtimeEnv);
const results: WorktreeSetupCommandResult[] = [];
for (const [index, cmd] of setupCommands.entries()) {
@@ -643,7 +641,7 @@ async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<s
try {
const { stdout } = await runGitCommand(["branch", "--show-current"], {
cwd: worktreePath,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const branchName = stdout.trim();
if (branchName.length > 0) {
@@ -707,8 +705,7 @@ export async function runWorktreeTeardownCommands(options: {
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
const teardownEnv: NodeJS.ProcessEnv = {
...process.env,
const teardownEnv: NodeJS.ProcessEnv = createExternalProcessEnv(process.env, {
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
// from the source checkout.
@@ -718,7 +715,7 @@ export async function runWorktreeTeardownCommands(options: {
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName,
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
};
});
const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of teardownCommands) {
@@ -746,7 +743,7 @@ export async function runWorktreeTeardownCommands(options: {
export async function getGitCommonDir(cwd: string): Promise<string> {
const { stdout } = await runGitCommand(["rev-parse", "--git-common-dir"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const commonDir = resolveGitRevParsePath(cwd, stdout);
if (!commonDir) {
@@ -986,7 +983,7 @@ export async function listPaseoWorktrees({
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep;
@@ -1015,7 +1012,7 @@ export async function resolveExistingWorktreeForSlug({
const { stdout } = await runGitCommand(["branch", "--show-current"], {
cwd: existingWorktree.path,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
const branchName = stdout.trim();
if (!branchName) {
@@ -1046,7 +1043,7 @@ export async function resolvePaseoWorktreeRootForCwd(
try {
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
worktreeRoot = parseGitRevParsePath(stdout);
} catch {
@@ -1424,7 +1421,7 @@ async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string
async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> {
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
env: READ_ONLY_GIT_ENV,
envOverlay: READ_ONLY_GIT_ENV,
});
return parseWorktreeList(stdout).some((entry) => entry.branchName === branchName);
}