fix(desktop): resolve login shell environment at Electron startup

On macOS, apps launched from Finder/Dock inherit a minimal environment
(PATH is just /usr/bin:/bin:/usr/sbin:/sbin). This caused two problems:
1. Agent binaries like codex were not detected (findExecutable failed)
2. Terminals spawned by Paseo had no access to user-installed tools
   (node, bun, direnv — all "command not found")

Fix: at Electron startup, spawn the user's login shell and capture its
full environment via JSON.stringify(process.env) using UUID markers.
This is the same battle-tested approach VS Code uses. The resolved
environment is merged into process.env before the daemon starts, so
all child processes — agents, terminals, git operations — inherit the
correct environment automatically.

This replaces the previous approach of invoking resolveShellEnv() (via
the shell-env npm package) at multiple scattered call sites. Now there
is a single source of truth: process.env is enriched once at startup.

Changes:
- New: packages/desktop/src/login-shell-env.ts (VS Code approach)
- Removed: resolveShellEnv(), shell-env dependency, $SHELL -lic probes
- Simplified: applyProviderEnv() and findExecutable() now trust process.env
This commit is contained in:
Mohamed Boudra
2026-04-05 15:11:18 +07:00
parent 84638ecd9c
commit 6907f6e71d
9 changed files with 140 additions and 205 deletions
+4 -42
View File
@@ -1,15 +1,11 @@
import { execFileSync, execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import path from "node:path";
import { shellEnvSync } from "shell-env";
export interface FindExecutableDependencies {
execSync: typeof execSync;
execFileSync: typeof execFileSync;
existsSync: typeof existsSync;
platform: typeof platform;
shell: string | undefined;
}
function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
@@ -42,7 +38,7 @@ function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
function resolveExecutableFromWhichOutput(
name: string,
output: string,
source: "login-shell" | "which",
source: "which",
): string | null {
const lines = output
.split(/\r?\n/)
@@ -65,10 +61,8 @@ function resolveExecutableFromWhichOutput(
}
/**
* On Unix we first try `$SHELL -lic "which <name>"` so that rc-file PATH
* additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the
* user opened a terminal and typed the command. If that fails (e.g. the login
* shell itself errors) we fall back to a plain `which`.
* On Unix we use plain `which` — the daemon's process.env.PATH is enriched
* with the login shell environment at Electron desktop startup.
*
* On Windows we augment the daemon PATH with machine/user registry PATH values
* and return the first `where.exe` match. Launch-time execution decides whether
@@ -85,11 +79,9 @@ export function findExecutable(
}
const deps: FindExecutableDependencies = {
execSync,
execFileSync,
existsSync,
platform,
shell: process.env["SHELL"],
...dependencies,
};
@@ -125,25 +117,6 @@ export function findExecutable(
}
}
// Unix: try the user's login shell so rc-file PATH entries are visible.
const shell = deps.shell;
if (shell) {
try {
const out = deps
.execSync(`${shell} -lic "which ${trimmed}"`, {
encoding: "utf8",
timeout: 5000,
})
.trim();
const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell");
if (resolved) {
return resolved;
}
} catch {
// Login shell failed (broken rc, etc.) — fall through to plain which.
}
}
try {
return resolveExecutableFromWhichOutput(
trimmed,
@@ -184,14 +157,3 @@ export function quoteWindowsArgument(argument: string): string {
return `"${argument}"`;
}
let cachedShellEnv: Record<string, string> | null = null;
export function resolveShellEnv(): Record<string, string> {
if (cachedShellEnv) return cachedShellEnv;
try {
cachedShellEnv = shellEnvSync();
} catch {
cachedShellEnv = { ...process.env } as Record<string, string>;
}
return cachedShellEnv;
}