[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:
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user