mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
[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:
@@ -1,8 +1,8 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
||||
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
|
||||
export interface DaemonStartOptions {
|
||||
@@ -373,16 +373,17 @@ export async function startLocalDaemonDetached(
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv);
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const child = spawn(
|
||||
const child = spawnProcess(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
detached: true,
|
||||
envMode: "internal",
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
},
|
||||
@@ -438,8 +439,8 @@ export function startLocalDaemonForeground(options: DaemonStartOptions): number
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const childEnv = buildChildEnv(options);
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
getOrCreateServerId,
|
||||
findExecutable,
|
||||
applyProviderEnv,
|
||||
execCommand,
|
||||
} from "@getpaseo/server";
|
||||
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
@@ -177,11 +172,9 @@ async function checkProviderBinary(
|
||||
if (!binaryPath) {
|
||||
return { path: null, version: null };
|
||||
}
|
||||
const env = applyProviderEnv(process.env);
|
||||
try {
|
||||
const { stdout } = await execCommand(binaryPath, ["--version"], {
|
||||
timeout: 5000,
|
||||
env,
|
||||
});
|
||||
return { path: binaryPath, version: stdout.trim() || null };
|
||||
} catch {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnProcess } from "@getpaseo/server";
|
||||
|
||||
function findDesktopApp(): string | null {
|
||||
if (process.platform === "darwin") {
|
||||
@@ -54,11 +54,13 @@ function cleanEnvForDesktopLaunch(): NodeJS.ProcessEnv {
|
||||
// desktop process inherits the env directly, so we must strip it or the
|
||||
// desktop app would start as a bare Node process instead of Electron.
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
delete env.ELECTRON_NO_ATTACH_CONSOLE;
|
||||
delete env.PASEO_NODE_ENV;
|
||||
return env;
|
||||
}
|
||||
|
||||
function spawnDetached(command: string, args: string[]): void {
|
||||
spawn(command, args, {
|
||||
spawnProcess(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: cleanEnvForDesktopLaunch(),
|
||||
|
||||
@@ -38,4 +38,4 @@ fi
|
||||
RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js"
|
||||
CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
|
||||
|
||||
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@"
|
||||
exec env ELECTRON_RUN_AS_NODE=1 PASEO_NODE_ENV=production "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@"
|
||||
|
||||
@@ -10,5 +10,6 @@ if not exist "%APP_EXECUTABLE%" (
|
||||
)
|
||||
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
set "PASEO_NODE_ENV=production"
|
||||
"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
exit /b %errorlevel%
|
||||
|
||||
@@ -359,7 +359,9 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
|
||||
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
|
||||
detached: true,
|
||||
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
|
||||
envMode: "internal",
|
||||
env: invocation.env,
|
||||
envOverlay: { PASEO_DESKTOP_MANAGED: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("node-entrypoint-launcher", () => {
|
||||
env: {
|
||||
PATH: "/usr/bin",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
NODE_ENV: "production",
|
||||
PASEO_NODE_ENV: "production",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -150,7 +150,8 @@ describe("node-entrypoint-launcher", () => {
|
||||
).toMatchObject({
|
||||
PATH: "/usr/bin",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
NODE_ENV: "production",
|
||||
NODE_ENV: "development",
|
||||
PASEO_NODE_ENV: "production",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +179,7 @@ describe("node-entrypoint-launcher", () => {
|
||||
env: {
|
||||
PATH: "/usr/bin",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
NODE_ENV: "production",
|
||||
PASEO_NODE_ENV: "production",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
|
||||
|
||||
export const DESKTOP_CLI_ENV = "PASEO_DESKTOP_CLI";
|
||||
const PASEO_NODE_ENV = "PASEO_NODE_ENV";
|
||||
|
||||
export interface NodeEntrypointSpec {
|
||||
entryPath: string;
|
||||
@@ -38,7 +39,7 @@ export function createElectronNodeEnv(
|
||||
return {
|
||||
...baseEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
...(options?.isPackaged === true ? { NODE_ENV: "production" } : {}),
|
||||
...(options?.isPackaged === true ? { [PASEO_NODE_ENV]: "production" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@ function spawnAsync(
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnProcess(command, args, {
|
||||
envMode: "internal",
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -54,11 +54,16 @@ function resolveShellEnv(): Record<string, string> | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const shellEnv = { ...process.env };
|
||||
delete shellEnv.PASEO_NODE_ENV;
|
||||
delete shellEnv.PASEO_DESKTOP_MANAGED;
|
||||
delete shellEnv.PASEO_SUPERVISED;
|
||||
|
||||
const result = spawnSync(shell, [...shellArgs, command], {
|
||||
encoding: "utf8",
|
||||
timeout: RESOLVE_TIMEOUT_MS,
|
||||
env: {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
ELECTRON_NO_ATTACH_CONSOLE: "1",
|
||||
},
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development node --import tsx scripts/dev-runner.ts",
|
||||
"dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
"dev": "cross-env PASEO_NODE_ENV=development node --import tsx scripts/dev-runner.ts",
|
||||
"dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
|
||||
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"",
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
||||
"prepack": "npm run build",
|
||||
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
|
||||
"start": "node dist/server/server/index.js",
|
||||
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
|
||||
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
|
||||
"speech:models": "tsx scripts/list-speech-models.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
applyProviderEnv,
|
||||
createProviderEnv,
|
||||
migrateProviderSettings,
|
||||
ProviderOverrideSchema,
|
||||
resolveProviderCommandPrefix,
|
||||
@@ -55,7 +55,7 @@ describe("resolveProviderCommandPrefix", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProviderEnv", () => {
|
||||
describe("createProviderEnv", () => {
|
||||
test("merges provider env overrides", () => {
|
||||
const base = {
|
||||
PATH: "/usr/bin",
|
||||
@@ -68,7 +68,7 @@ describe("applyProviderEnv", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const env = applyProviderEnv(base, runtime);
|
||||
const env = createProviderEnv({ baseEnv: base, runtimeSettings: runtime });
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.HOME).toBe("/custom/home");
|
||||
@@ -80,7 +80,7 @@ describe("applyProviderEnv", () => {
|
||||
const base = { PATH: "/usr/bin" };
|
||||
const runtime: ProviderRuntimeSettings = { env: { PATH: "/custom/path" } };
|
||||
|
||||
const env = applyProviderEnv(base, runtime);
|
||||
const env = createProviderEnv({ baseEnv: base, runtimeSettings: runtime });
|
||||
|
||||
expect(env.PATH).toBe("/custom/path");
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe("applyProviderEnv", () => {
|
||||
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: "true",
|
||||
};
|
||||
|
||||
const env = applyProviderEnv(base);
|
||||
const env = createProviderEnv({ baseEnv: base });
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.CLAUDECODE).toBeUndefined();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { isCommandAvailable } from "../../utils/executable.js";
|
||||
import { createExternalProcessEnv, type ProcessEnvRecord } from "../paseo-env.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import { AgentProviderSchema } from "./provider-manifest.js";
|
||||
|
||||
@@ -176,18 +177,41 @@ const PARENT_SESSION_ENV_VARS = [
|
||||
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
||||
];
|
||||
|
||||
export function applyProviderEnv(
|
||||
baseEnv: Record<string, string | undefined>,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): Record<string, string | undefined> {
|
||||
const merged: Record<string, string | undefined> = {
|
||||
...baseEnv,
|
||||
...runtimeSettings?.env,
|
||||
};
|
||||
export interface ProviderEnvOptions {
|
||||
baseEnv?: ProcessEnvRecord;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
overlays?: Array<ProcessEnvRecord | undefined>;
|
||||
}
|
||||
|
||||
export interface ProviderEnvSpec {
|
||||
baseEnv?: ProcessEnvRecord;
|
||||
envOverlay: ProcessEnvRecord;
|
||||
}
|
||||
|
||||
function collectProviderEnvOverlays(
|
||||
runtimeSettings: ProviderRuntimeSettings | undefined,
|
||||
overlays: Array<ProcessEnvRecord | undefined>,
|
||||
): ProcessEnvRecord[] {
|
||||
return [runtimeSettings?.env, ...overlays].filter(
|
||||
(overlay): overlay is ProcessEnvRecord => !!overlay,
|
||||
);
|
||||
}
|
||||
|
||||
export function createProviderEnvSpec(options: ProviderEnvOptions = {}): ProviderEnvSpec {
|
||||
const overlays = collectProviderEnvOverlays(options.runtimeSettings, options.overlays ?? []);
|
||||
const envOverlay: ProcessEnvRecord = Object.assign({}, ...overlays);
|
||||
for (const key of PARENT_SESSION_ENV_VARS) {
|
||||
delete merged[key];
|
||||
envOverlay[key] = undefined;
|
||||
}
|
||||
return merged;
|
||||
return {
|
||||
...(options.baseEnv ? { baseEnv: options.baseEnv } : {}),
|
||||
envOverlay,
|
||||
};
|
||||
}
|
||||
|
||||
export function createProviderEnv(options: ProviderEnvOptions = {}): NodeJS.ProcessEnv {
|
||||
const spec = createProviderEnvSpec(options);
|
||||
return createExternalProcessEnv(spec.baseEnv ?? process.env, spec.envOverlay);
|
||||
}
|
||||
|
||||
export function findExecutable(name: string): string | null {
|
||||
@@ -203,7 +227,10 @@ export function findExecutable(name: string): string | null {
|
||||
}
|
||||
try {
|
||||
const cmd = process.platform === "win32" ? "where.exe" : "which";
|
||||
const result = execFileSync(cmd, [trimmed], { encoding: "utf8" }).trim();
|
||||
const result = execFileSync(cmd, [trimmed], {
|
||||
encoding: "utf8",
|
||||
env: createProviderEnv({ baseEnv: process.env }),
|
||||
}).trim();
|
||||
const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
|
||||
const candidate = lines.at(-1)?.trim() ?? null;
|
||||
return candidate && path.isAbsolute(candidate) ? candidate : null;
|
||||
|
||||
@@ -81,7 +81,7 @@ import type {
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
@@ -568,13 +568,10 @@ export class ACPAgentClient implements AgentClient {
|
||||
const { command, args } = await this.resolveLaunchCommand();
|
||||
const child = spawnProcess(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...applyProviderEnv(
|
||||
process.env as Record<string, string | undefined>,
|
||||
this.runtimeSettings,
|
||||
),
|
||||
...launchEnv,
|
||||
},
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
|
||||
@@ -1319,13 +1316,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
);
|
||||
const child = spawnProcess(params.command, params.args ?? [], {
|
||||
cwd: params.cwd ?? this.config.cwd,
|
||||
env: {
|
||||
...applyProviderEnv(
|
||||
process.env as Record<string, string | undefined>,
|
||||
this.runtimeSettings,
|
||||
),
|
||||
...env,
|
||||
},
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [env],
|
||||
}),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -1410,13 +1404,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const args = [...prefix.args, ...this.defaultCommand.slice(1)];
|
||||
const child = spawnProcess(command, args, {
|
||||
cwd: this.config.cwd,
|
||||
env: {
|
||||
...applyProviderEnv(
|
||||
process.env as Record<string, string | undefined>,
|
||||
this.runtimeSettings,
|
||||
),
|
||||
...this.launchEnv,
|
||||
},
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [this.launchEnv],
|
||||
}),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentLaunchContext } from "../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
|
||||
function createQueryMock(events: unknown[]) {
|
||||
function createQueryMock(events: unknown[]): Query {
|
||||
let index = 0;
|
||||
return {
|
||||
next: vi.fn(async () =>
|
||||
@@ -23,10 +24,10 @@ function createQueryMock(events: unknown[]) {
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
} as Query;
|
||||
}
|
||||
|
||||
describe("Claude agent env", () => {
|
||||
describe("Claude SDK env", () => {
|
||||
test("forwards launch-context env through Claude process env", async () => {
|
||||
let capturedEnv: Record<string, string | undefined> | undefined;
|
||||
const launchContext: AgentLaunchContext = {
|
||||
@@ -35,38 +36,36 @@ describe("Claude agent env", () => {
|
||||
PASEO_TEST_FLAG: "launch-value",
|
||||
},
|
||||
};
|
||||
const queryFactory = vi.fn(
|
||||
({ options }: { options: { env?: Record<string, string | undefined> } }) => {
|
||||
capturedEnv = options.env;
|
||||
return createQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "managed-agent-env-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||
capturedEnv = options.env;
|
||||
return createQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "managed-agent-env-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: "done" },
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: "done" },
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: queryFactory as never,
|
||||
queryFactory,
|
||||
});
|
||||
const session = await client.createSession(
|
||||
{
|
||||
@@ -94,38 +93,36 @@ describe("Claude agent env", () => {
|
||||
PASEO_TEST_FLAG: "resume-launch-value",
|
||||
},
|
||||
};
|
||||
const queryFactory = vi.fn(
|
||||
({ options }: { options: { env?: Record<string, string | undefined> } }) => {
|
||||
capturedEnv = options.env;
|
||||
return createQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "persisted-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||
capturedEnv = options.env;
|
||||
return createQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "persisted-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: "done" },
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: { content: "done" },
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: queryFactory as never,
|
||||
queryFactory,
|
||||
});
|
||||
const session = await client.resumeSession(
|
||||
{
|
||||
|
||||
@@ -35,9 +35,9 @@ function createQueryMock(events: unknown[]): Query {
|
||||
}
|
||||
|
||||
function createChildProcessStub(): ChildProcess {
|
||||
return {
|
||||
stderr: new EventEmitter(),
|
||||
} as ChildProcess;
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
child.stderr = new EventEmitter() as ChildProcess["stderr"];
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("Claude spawn override", () => {
|
||||
@@ -96,8 +96,9 @@ describe("Claude spawn override", () => {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
||||
const spawnOptions = spawnSpy.mock.calls[0]?.[2];
|
||||
const claudeSpawnCall = spawnSpy.mock.calls.find(([, args]) => args[0] === "claude.js");
|
||||
expect(claudeSpawnCall).toBeDefined();
|
||||
const spawnOptions = claudeSpawnCall?.[2];
|
||||
expect(spawnOptions?.shell).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,11 @@ import type {
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import {
|
||||
createProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
||||
@@ -256,10 +260,11 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
const command = isDefaultRuntime ? process.execPath : resolved.command;
|
||||
const child = spawnProcess(command, resolved.args, {
|
||||
cwd: spawnOptions.cwd,
|
||||
env: {
|
||||
...applyProviderEnv(spawnOptions.env, runtimeSettings),
|
||||
...launchEnv,
|
||||
},
|
||||
...createProviderEnvSpec({
|
||||
baseEnv: spawnOptions.env,
|
||||
runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
signal: spawnOptions.signal,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
// Bypass cmd.exe on Windows: the SDK passes --mcp-config with inline JSON
|
||||
@@ -1268,13 +1273,14 @@ async function resolveClaudeVersion(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): Promise<string | null> {
|
||||
const command = runtimeSettings?.command;
|
||||
const envSpec = createProviderEnvSpec({ runtimeSettings });
|
||||
|
||||
try {
|
||||
if (command?.mode === "replace") {
|
||||
const { stdout } = await execCommand(
|
||||
command.argv[0]!,
|
||||
[...command.argv.slice(1), "--version"],
|
||||
{ timeout: 5_000 },
|
||||
{ ...envSpec, timeout: 5_000 },
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
}
|
||||
@@ -1285,6 +1291,7 @@ async function resolveClaudeVersion(
|
||||
}
|
||||
|
||||
const { stdout } = await execCommand(executable, ["--version"], {
|
||||
...envSpec,
|
||||
timeout: 5_000,
|
||||
});
|
||||
return stdout.trim() || null;
|
||||
@@ -1303,7 +1310,10 @@ async function resolveClaudeAuth(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
try {
|
||||
return await execCommand(executable, args, { timeout: 5_000 });
|
||||
return await execCommand(executable, args, {
|
||||
...createProviderEnvSpec({ runtimeSettings }),
|
||||
timeout: 5_000,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as {
|
||||
stdout?: string;
|
||||
@@ -2221,6 +2231,20 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private async buildOptions(): Promise<ClaudeOptions> {
|
||||
const { thinking, effort } = this.resolveThinkingConfig();
|
||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||
const extraClaudeOptions = this.config.extra?.claude;
|
||||
const sdkEnv = createProviderEnv({
|
||||
baseEnv: process.env,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [
|
||||
extraClaudeOptions?.env,
|
||||
{
|
||||
// Increase MCP timeouts for long-running tool calls (10 minutes)
|
||||
MCP_TIMEOUT: "600000",
|
||||
MCP_TOOL_TIMEOUT: "600000",
|
||||
},
|
||||
this.launchEnv,
|
||||
],
|
||||
});
|
||||
|
||||
const claudeBinary = await findExecutable("claude");
|
||||
this.logger.debug(
|
||||
@@ -2256,13 +2280,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.captureStderr(data);
|
||||
this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr");
|
||||
},
|
||||
env: {
|
||||
...process.env,
|
||||
// Increase MCP timeouts for long-running tool calls (10 minutes)
|
||||
MCP_TIMEOUT: "600000",
|
||||
MCP_TOOL_TIMEOUT: "600000",
|
||||
...this.launchEnv,
|
||||
},
|
||||
// Required for provider-level /rewind support.
|
||||
enableFileCheckpointing: true,
|
||||
// If we have a session ID from a previous query (e.g., after interrupt),
|
||||
@@ -2270,7 +2287,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...this.config.extra?.claude,
|
||||
...extraClaudeOptions,
|
||||
env: sdkEnv,
|
||||
};
|
||||
|
||||
if (this.config.mcpServers) {
|
||||
|
||||
@@ -44,7 +44,8 @@ import {
|
||||
mapCodexToolCallFromThreadItem,
|
||||
} from "./codex/tool-call-mapper.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
createProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
@@ -2450,15 +2451,11 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
function buildCodexAppServerEnv(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
launchEnv?: Record<string, string>,
|
||||
): Record<string, string | undefined> {
|
||||
const env = applyProviderEnv(process.env, runtimeSettings);
|
||||
if (!launchEnv) {
|
||||
return env;
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
...launchEnv,
|
||||
};
|
||||
): NodeJS.ProcessEnv {
|
||||
return createProviderEnv({
|
||||
runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
}
|
||||
|
||||
function buildCodexAppServerInitializeParams(): {
|
||||
@@ -4211,7 +4208,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
return spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { createProviderEnvSpec, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
|
||||
interface DiagnosticEntry {
|
||||
@@ -45,7 +45,10 @@ export function toDiagnosticErrorMessage(error: unknown): string {
|
||||
|
||||
export async function resolveBinaryVersion(binaryPath: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execCommand(binaryPath, ["--version"], { timeout: 5_000 });
|
||||
const { stdout } = await execCommand(binaryPath, ["--version"], {
|
||||
...createProviderEnvSpec(),
|
||||
timeout: 5_000,
|
||||
});
|
||||
return stdout.trim() || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
|
||||
@@ -41,7 +41,7 @@ import type {
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
@@ -868,7 +868,7 @@ export class OpenCodeServerManager {
|
||||
[...launchPrefix.args, "serve", "--port", String(port)],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: applyProviderEnv(process.env, this.runtimeSettings),
|
||||
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
} from "../utils/checkout-git.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
|
||||
export const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
export const READ_ONLY_GIT_ENV = {
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type CheckoutErrorCode = "NOT_GIT_REPO" | "NOT_ALLOWED" | "MERGE_CONFLICT" | "UNKNOWN";
|
||||
|
||||
@@ -21,7 +20,7 @@ export async function resolveCheckoutGitDir(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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { resolvePaseoNodeEnv } from "./paseo-env.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
||||
@@ -240,7 +241,7 @@ export function loadConfig(
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
isDev: env.NODE_ENV === "development",
|
||||
isDev: resolvePaseoNodeEnv(env) === "development",
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
staticDir: "public",
|
||||
agentClients: {},
|
||||
|
||||
@@ -63,6 +63,7 @@ describe("editor-targets", () => {
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], {
|
||||
detached: true,
|
||||
env: expect.any(Object),
|
||||
shell: false,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { posix, win32 } from "node:path";
|
||||
import type {
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
EditorTargetId,
|
||||
KnownEditorTargetId,
|
||||
} from "../shared/messages.js";
|
||||
import { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
|
||||
import { createExternalProcessEnv } from "./paseo-env.js";
|
||||
import { findExecutable } from "../utils/executable.js";
|
||||
import { spawnProcess } from "../utils/spawn.js";
|
||||
|
||||
interface EditorTargetDefinition {
|
||||
id: KnownEditorTargetId;
|
||||
@@ -23,7 +25,7 @@ interface ListAvailableEditorTargetsDependencies {
|
||||
|
||||
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
|
||||
existsSync?: typeof existsSync;
|
||||
spawn?: typeof spawn;
|
||||
spawn?: typeof spawnProcess;
|
||||
};
|
||||
|
||||
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
|
||||
@@ -127,7 +129,7 @@ export async function openInEditorTarget(
|
||||
const pathToOpen = input.path.trim();
|
||||
const existsSyncFn = dependencies.existsSync ?? existsSync;
|
||||
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
|
||||
const spawnFn = dependencies.spawn ?? spawn;
|
||||
const spawnFn = dependencies.spawn ?? spawnProcess;
|
||||
|
||||
if (!pathToOpen || !isAbsolutePath(pathToOpen)) {
|
||||
throw new Error("Editor target path must be an absolute local path");
|
||||
@@ -143,17 +145,12 @@ export async function openInEditorTarget(
|
||||
findExecutableFn,
|
||||
});
|
||||
|
||||
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
|
||||
const args =
|
||||
platform === "win32"
|
||||
? launch.args.map((argument) => quoteWindowsArgument(argument))
|
||||
: launch.args;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawnFn(command, args, {
|
||||
child = spawnFn(launch.command, launch.args, {
|
||||
detached: true,
|
||||
env: createExternalProcessEnv(process.env),
|
||||
shell: platform === "win32",
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
@@ -31,11 +31,10 @@ export {
|
||||
|
||||
// Provider binary resolution
|
||||
export {
|
||||
applyProviderEnv,
|
||||
type ProviderOverride,
|
||||
type ProviderProfileModel,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
export { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
|
||||
export { findExecutable } from "../utils/executable.js";
|
||||
export { execCommand, spawnProcess } from "../utils/spawn.js";
|
||||
|
||||
// Provider manifest (source of truth for provider definitions)
|
||||
|
||||
115
packages/server/src/server/paseo-env.test.ts
Normal file
115
packages/server/src/server/paseo-env.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
createExternalCommandProcessEnv,
|
||||
createExternalProcessEnv,
|
||||
createPaseoInternalEnv,
|
||||
resolvePaseoNodeEnv,
|
||||
} from "./paseo-env.js";
|
||||
|
||||
describe("paseo env contract", () => {
|
||||
const ELECTRON_RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
|
||||
const PASEO_NODE_ENV = "PASEO_NODE_ENV";
|
||||
const baseEnv = {
|
||||
[ELECTRON_RUN_AS_NODE]: "1",
|
||||
ELECTRON_NO_ATTACH_CONSOLE: "1",
|
||||
NODE_ENV: "development",
|
||||
PATH: "/usr/bin",
|
||||
PASEO_AGENT_ID: "agent-123",
|
||||
PASEO_DESKTOP_MANAGED: "1",
|
||||
[PASEO_NODE_ENV]: "production",
|
||||
PASEO_SUPERVISED: "1",
|
||||
};
|
||||
const runtimeControlEnvKeys = [
|
||||
"ELECTRON_RUN_AS_NODE",
|
||||
"PASEO_NODE_ENV",
|
||||
"PASEO_DESKTOP_MANAGED",
|
||||
"PASEO_SUPERVISED",
|
||||
"ELECTRON_NO_ATTACH_CONSOLE",
|
||||
] as const;
|
||||
|
||||
test("builds internal daemon child env by preserving pass-through and control vars", () => {
|
||||
const env = createPaseoInternalEnv(baseEnv);
|
||||
|
||||
expect(env).toMatchObject({
|
||||
[ELECTRON_RUN_AS_NODE]: "1",
|
||||
ELECTRON_NO_ATTACH_CONSOLE: "1",
|
||||
NODE_ENV: "development",
|
||||
PATH: "/usr/bin",
|
||||
PASEO_DESKTOP_MANAGED: "1",
|
||||
[PASEO_NODE_ENV]: "production",
|
||||
PASEO_SUPERVISED: "1",
|
||||
PASEO_AGENT_ID: "agent-123",
|
||||
});
|
||||
});
|
||||
|
||||
test("builds external process env by scrubbing runtime control vars after overlays", () => {
|
||||
const env = createExternalProcessEnv(baseEnv, {
|
||||
ELECTRON_NO_ATTACH_CONSOLE: "1",
|
||||
ELECTRON_RUN_AS_NODE: "0",
|
||||
EXTRA_VALUE: "from-overlay",
|
||||
PASEO_DESKTOP_MANAGED: "1",
|
||||
PASEO_NODE_ENV: "test",
|
||||
PASEO_SUPERVISED: "1",
|
||||
PATH: "/custom/bin",
|
||||
});
|
||||
|
||||
for (const key of runtimeControlEnvKeys) {
|
||||
expect(env[key]).toBeUndefined();
|
||||
}
|
||||
expect(env.NODE_ENV).toBe("development");
|
||||
expect(env.PASEO_AGENT_ID).toBe("agent-123");
|
||||
expect(env.PATH).toBe("/custom/bin");
|
||||
});
|
||||
|
||||
test("applies non-control overlays to external process env", () => {
|
||||
const env = createExternalProcessEnv(baseEnv, { PATH: "/custom/bin" }, { CUSTOM: "value" });
|
||||
|
||||
expect(env.CUSTOM).toBe("value");
|
||||
expect(env.NODE_ENV).toBe("development");
|
||||
expect(env.PATH).toBe("/custom/bin");
|
||||
});
|
||||
|
||||
test("builds process.execPath external command env with Electron node mode", () => {
|
||||
const env = createExternalCommandProcessEnv(process.execPath, baseEnv, {
|
||||
ELECTRON_RUN_AS_NODE: "0",
|
||||
PASEO_NODE_ENV: "test",
|
||||
});
|
||||
|
||||
expect(env[ELECTRON_RUN_AS_NODE]).toBe("1");
|
||||
expect(env.NODE_ENV).toBe("development");
|
||||
expect(env.PASEO_AGENT_ID).toBe("agent-123");
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.ELECTRON_NO_ATTACH_CONSOLE).toBeUndefined();
|
||||
expect(env.PASEO_DESKTOP_MANAGED).toBeUndefined();
|
||||
expect(env[PASEO_NODE_ENV]).toBeUndefined();
|
||||
expect(env.PASEO_SUPERVISED).toBeUndefined();
|
||||
});
|
||||
|
||||
test("always re-adds Electron node mode after scrubbing process.execPath overlays", () => {
|
||||
const env = createExternalCommandProcessEnv(process.execPath, baseEnv, {
|
||||
ELECTRON_RUN_AS_NODE: undefined,
|
||||
});
|
||||
|
||||
for (const key of runtimeControlEnvKeys) {
|
||||
if (key === ELECTRON_RUN_AS_NODE) continue;
|
||||
expect(env[key]).toBeUndefined();
|
||||
}
|
||||
expect(env[ELECTRON_RUN_AS_NODE]).toBe("1");
|
||||
});
|
||||
|
||||
test("does not add Electron node mode for non-execPath commands", () => {
|
||||
const env = createExternalCommandProcessEnv("node", baseEnv, {
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
});
|
||||
|
||||
expect(env[ELECTRON_RUN_AS_NODE]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not use user NODE_ENV as Paseo runtime mode", () => {
|
||||
expect(resolvePaseoNodeEnv({ NODE_ENV: "development" })).toBeUndefined();
|
||||
expect(resolvePaseoNodeEnv({ NODE_ENV: "development", PASEO_NODE_ENV: "production" })).toBe(
|
||||
"production",
|
||||
);
|
||||
expect(resolvePaseoNodeEnv({ NODE_ENV: "test", PASEO_NODE_ENV: "local" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
98
packages/server/src/server/paseo-env.ts
Normal file
98
packages/server/src/server/paseo-env.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const PASEO_NODE_ENV = "PASEO_NODE_ENV";
|
||||
const ELECTRON_RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
|
||||
|
||||
const RUNTIME_CONTROL_ENV_KEYS = [
|
||||
PASEO_NODE_ENV,
|
||||
"PASEO_DESKTOP_MANAGED",
|
||||
"PASEO_SUPERVISED",
|
||||
ELECTRON_RUN_AS_NODE,
|
||||
"ELECTRON_NO_ATTACH_CONSOLE",
|
||||
] as const;
|
||||
|
||||
export type PaseoNodeEnv = "development" | "production" | "test";
|
||||
export type ProcessEnvRecord = Record<string, string | undefined>;
|
||||
type ExternalProcessEnv = NodeJS.ProcessEnv & Record<string, string>;
|
||||
|
||||
let resolvedProcessExecPath: string | undefined;
|
||||
|
||||
function buildInternalProcessEnv<T extends ProcessEnvRecord>(baseEnv: T): T {
|
||||
return { ...baseEnv } as T;
|
||||
}
|
||||
|
||||
function buildExternalProcessEnv(
|
||||
baseEnv: ProcessEnvRecord,
|
||||
overlays: ProcessEnvRecord[],
|
||||
): ExternalProcessEnv {
|
||||
const sanitized = Object.assign({}, baseEnv, ...overlays);
|
||||
for (const key of RUNTIME_CONTROL_ENV_KEYS) {
|
||||
delete sanitized[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(sanitized)) {
|
||||
if (value === undefined) {
|
||||
delete sanitized[key];
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return buildInternalProcessEnv(baseEnv);
|
||||
}
|
||||
|
||||
export function createExternalProcessEnv(
|
||||
baseEnv: ProcessEnvRecord,
|
||||
...overlays: ProcessEnvRecord[]
|
||||
): ExternalProcessEnv {
|
||||
return buildExternalProcessEnv(baseEnv, overlays);
|
||||
}
|
||||
|
||||
export function createExternalCommandProcessEnv(
|
||||
command: string,
|
||||
baseEnv: ProcessEnvRecord,
|
||||
...overlays: ProcessEnvRecord[]
|
||||
): ExternalProcessEnv {
|
||||
const env = buildExternalProcessEnv(baseEnv, overlays);
|
||||
if (isProcessExecPathCommand(command)) {
|
||||
env[ELECTRON_RUN_AS_NODE] = "1";
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function resolvePaseoNodeEnv(env: NodeJS.ProcessEnv): PaseoNodeEnv | undefined {
|
||||
const value = env[PASEO_NODE_ENV];
|
||||
return value === "development" || value === "production" || value === "test" ? value : undefined;
|
||||
}
|
||||
@@ -4900,7 +4900,9 @@ export class Session {
|
||||
const message = branchLabel
|
||||
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
|
||||
: `${Session.PASEO_STASH_PREFIX} unnamed`;
|
||||
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
||||
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], {
|
||||
cwd,
|
||||
});
|
||||
await this.notifyGitMutation(cwd, "stash-push");
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.emit({
|
||||
@@ -4920,7 +4922,9 @@ export class Session {
|
||||
): Promise<void> {
|
||||
const { cwd, stashIndex, requestId } = msg;
|
||||
try {
|
||||
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
||||
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], {
|
||||
cwd,
|
||||
});
|
||||
await this.notifyGitMutation(cwd, "stash-pop");
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.emit({
|
||||
|
||||
@@ -3,10 +3,10 @@ import { mkdir, rename, rm, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import type pino from "pino";
|
||||
|
||||
import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.js";
|
||||
import { spawnProcess } from "../../../../../utils/spawn.js";
|
||||
|
||||
export interface EnsureSherpaOnnxModelOptions {
|
||||
modelsDir: string;
|
||||
@@ -70,7 +70,9 @@ async function extractTarArchive(archivePath: string, destDir: string): Promise<
|
||||
await mkdir(destDir, { recursive: true });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("tar", ["xf", archivePath, "-C", destDir], { stdio: "inherit" });
|
||||
const child = spawnProcess("tar", ["xf", archivePath, "-C", destDir], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveSherpaLoaderEnv,
|
||||
sherpaPlatformPackageName,
|
||||
} from "./sherpa-runtime-env.js";
|
||||
import { createExternalCommandProcessEnv } from "../../../../paseo-env.js";
|
||||
|
||||
export interface SherpaOnnxNodeModule {
|
||||
OfflineRecognizer: new (config: unknown) => unknown;
|
||||
@@ -41,7 +42,9 @@ function maybePatchLinuxAddonRunpath(addonPath: string): void {
|
||||
if (process.platform !== "linux") {
|
||||
return;
|
||||
}
|
||||
const patchelfEnv = createExternalCommandProcessEnv("patchelf", process.env);
|
||||
const patchelfCheck = spawnSync("patchelf", ["--version"], {
|
||||
env: patchelfEnv,
|
||||
stdio: "ignore",
|
||||
});
|
||||
if (patchelfCheck.status !== 0) {
|
||||
@@ -50,6 +53,7 @@ function maybePatchLinuxAddonRunpath(addonPath: string): void {
|
||||
|
||||
const currentRpath = spawnSync("patchelf", ["--print-rpath", addonPath], {
|
||||
encoding: "utf8",
|
||||
env: patchelfEnv,
|
||||
});
|
||||
if (currentRpath.status !== 0) {
|
||||
return;
|
||||
@@ -60,6 +64,7 @@ function maybePatchLinuxAddonRunpath(addonPath: string): void {
|
||||
}
|
||||
|
||||
spawnSync("patchelf", ["--set-rpath", "$ORIGIN", addonPath], {
|
||||
env: patchelfEnv,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -491,7 +491,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return this.readAuxiliaryCache(this.localBranchCache, key, options, async () => {
|
||||
const result = await this.deps.runGitCommand(["rev-parse", "--verify", "--quiet", ref], {
|
||||
cwd: normalizedCwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
return result.exitCode === 0;
|
||||
@@ -523,7 +523,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => {
|
||||
const { stdout } = await this.deps.runGitCommand(["stash", "list", "--format=%gd%x00%s"], {
|
||||
cwd: normalizedCwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return parseWorkspaceGitStashList(stdout, { paseoOnly });
|
||||
});
|
||||
@@ -895,7 +895,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
try {
|
||||
const { stdout } = await this.deps.runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return parseGitRevParsePath(stdout);
|
||||
} catch {
|
||||
@@ -1714,10 +1714,7 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
async function runGitFetch(cwd: string): Promise<void> {
|
||||
await runGitCommand(["fetch", "origin", "--prune"], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
},
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
|
||||
export const GITHUB_POLL_FAST_INTERVAL_MS = 20_000;
|
||||
export const GITHUB_POLL_SLOW_INTERVAL_MS = 120_000;
|
||||
export const GITHUB_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||
const GITHUB_ENV: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
const GITHUB_ENV = {
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
};
|
||||
} as const;
|
||||
|
||||
const LabelSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
@@ -1165,7 +1164,7 @@ async function runGhCommand(
|
||||
): Promise<GitHubCommandResult> {
|
||||
return execCommand("gh", args, {
|
||||
cwd: options.cwd,
|
||||
env: GITHUB_ENV,
|
||||
envOverlay: GITHUB_ENV,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { basename, dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
@@ -214,11 +215,9 @@ function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegra
|
||||
export function buildTerminalEnvironment(
|
||||
input: BuildTerminalEnvironmentInput,
|
||||
): Record<string, string> {
|
||||
const baseEnv: Record<string, string> = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
const baseEnv: Record<string, string> = createExternalProcessEnv(process.env, input.env, {
|
||||
TERM: "xterm-256color",
|
||||
};
|
||||
});
|
||||
|
||||
if (basename(input.shell) !== "zsh") {
|
||||
return baseEnv;
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
packages/server/src/utils/windows-command.ts
Normal file
42
packages/server/src/utils/windows-command.ts
Normal file
@@ -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