Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 83 additions & 5 deletions apps/code/src/main/utils/fixPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@
* (/usr/bin:/bin:/usr/sbin:/sbin) instead of the user's shell PATH which
* includes /opt/homebrew/bin, ~/.local/bin, etc.
*
* This reads the PATH from the user's default shell (in interactive login mode)
* and applies it to process.env.PATH so child processes have access to
* This reads the PATH from the user's default shell (in login mode) and
* applies it to process.env.PATH so child processes have access to
* user-installed binaries.
*
* IMPORTANT: We use `-lc` (login, non-interactive) instead of `-ilc`
* (interactive login) to avoid loading the user's full .zshrc which may
* include heavy plugins (Oh My Zsh, NVM, thefuck, etc.) that spawn
* subprocesses and cause zombie process chains when the timeout kills
* only the parent shell.
*
* See: https://github.com/PostHog/code/issues/1399
*/

import { execSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { userInfo } from "node:os";
import { dirname, join } from "node:path";
import { app } from "electron";

const DELIMITER = "_SHELL_ENV_DELIMITER_";

Expand All @@ -25,6 +36,9 @@ const ANSI_REGEX =
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional for ANSI stripping
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;

/** Max age of cached PATH before re-resolving (1 hour) */
const CACHE_MAX_AGE_MS = 60 * 60 * 1000;

function stripAnsi(str: string): string {
return str.replace(ANSI_REGEX, "");
}
Expand All @@ -50,20 +64,75 @@ function detectDefaultShell(): string {
return process.env.SHELL || "/bin/sh";
}

function getCachePath(): string {
return join(app.getPath("userData"), "shell-env-cache.json");
}

function readCachedPath(): string | undefined {
try {
const cachePath = getCachePath();
if (!existsSync(cachePath)) {
return undefined;
}

const raw = readFileSync(cachePath, "utf-8");
const cache = JSON.parse(raw) as { path: string; timestamp: number };

if (Date.now() - cache.timestamp > CACHE_MAX_AGE_MS) {
return undefined;
}

return cache.path;
} catch {
return undefined;
}
}

function writeCachedPath(resolvedPath: string): void {
try {
const cachePath = getCachePath();
const dir = dirname(cachePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(
cachePath,
JSON.stringify({ path: resolvedPath, timestamp: Date.now() }),
"utf-8",
);
} catch {
// Cache write failure is non-fatal
}
}

function executeShell(shell: string): string | undefined {
const command = `echo -n "${DELIMITER}"; env; echo -n "${DELIMITER}"; exit`;

try {
return execSync(`${shell} -ilc '${command}'`, {
const result = spawnSync(shell, ["-lc", command], {
encoding: "utf-8",
timeout: 5000,
stdio: ["ignore", "pipe", "ignore"],
// Kill the entire process group on timeout, not just the parent shell.
// This prevents orphaned children (node -v, printf, tail, sed) from
// surviving as zombies.
killSignal: "SIGKILL",
env: {
...process.env,
// Disable Oh My Zsh auto-update which can block
DISABLE_AUTO_UPDATE: "true",
// Signal to user's shell config that we're resolving the environment.
// Users with heavy configs can check this and fast-exit:
// [[ -n "$POSTHOG_CODE_RESOLVING_ENVIRONMENT" ]] && return
POSTHOG_CODE_RESOLVING_ENVIRONMENT: "1",
},
});

if (result.status !== 0 && !result.stdout) {
return undefined;
}

return result.stdout || undefined;
} catch {
return undefined;
}
Expand Down Expand Up @@ -110,11 +179,20 @@ export function fixPath(): void {
return;
}

// Try cached PATH first (instant, no shell spawn)
const cached = readCachedPath();
if (cached) {
process.env.PATH = cached;
return;
}

const shell = detectDefaultShell();
const shellPath = getShellPath(shell);

if (shellPath) {
process.env.PATH = stripAnsi(shellPath);
const cleaned = stripAnsi(shellPath);
process.env.PATH = cleaned;
writeCachedPath(cleaned);
} else {
process.env.PATH = buildFallbackPath();
}
Expand Down
Loading