Skip to content

Commit 2e42f3f

Browse files
juliusmarmingecursoragentcursor[bot]
authored
Improve shell PATH hydration and fallback detection (#1799)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
1 parent 008ac5c commit 2e42f3f

File tree

6 files changed

+325
-38
lines changed

6 files changed

+325
-38
lines changed

apps/desktop/src/syncShellEnvironment.test.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,31 @@ describe("syncShellEnvironment", () => {
66
it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
77
const env: NodeJS.ProcessEnv = {
88
SHELL: "/bin/zsh",
9-
PATH: "/usr/bin",
9+
PATH: "/Users/test/.local/bin:/usr/bin",
1010
};
1111
const readEnvironment = vi.fn(() => ({
1212
PATH: "/opt/homebrew/bin:/usr/bin",
1313
SSH_AUTH_SOCK: "/tmp/secretive.sock",
14+
HOMEBREW_PREFIX: "/opt/homebrew",
1415
}));
1516

1617
syncShellEnvironment(env, {
1718
platform: "darwin",
1819
readEnvironment,
1920
});
2021

21-
expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
22-
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
22+
expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
23+
"PATH",
24+
"SSH_AUTH_SOCK",
25+
"HOMEBREW_PREFIX",
26+
"HOMEBREW_CELLAR",
27+
"HOMEBREW_REPOSITORY",
28+
"XDG_CONFIG_HOME",
29+
"XDG_DATA_HOME",
30+
]);
31+
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
2332
expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
33+
expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew");
2434
});
2535

2636
it("preserves an inherited SSH_AUTH_SOCK value", () => {
@@ -77,11 +87,67 @@ describe("syncShellEnvironment", () => {
7787
readEnvironment,
7888
});
7989

80-
expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
90+
expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
91+
"PATH",
92+
"SSH_AUTH_SOCK",
93+
"HOMEBREW_PREFIX",
94+
"HOMEBREW_CELLAR",
95+
"HOMEBREW_REPOSITORY",
96+
"XDG_CONFIG_HOME",
97+
"XDG_DATA_HOME",
98+
]);
8199
expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
82100
expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
83101
});
84102

103+
it("falls back to launchctl PATH on macOS when shell probing does not return one", () => {
104+
const env: NodeJS.ProcessEnv = {
105+
SHELL: "/opt/homebrew/bin/nu",
106+
PATH: "/usr/bin",
107+
};
108+
const readEnvironment = vi
109+
.fn()
110+
.mockImplementationOnce(() => {
111+
throw new Error("unknown flag");
112+
})
113+
.mockImplementationOnce(() => ({}));
114+
const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
115+
const logWarning = vi.fn();
116+
117+
syncShellEnvironment(env, {
118+
platform: "darwin",
119+
readEnvironment,
120+
readLaunchctlPath,
121+
userShell: "/bin/zsh",
122+
logWarning,
123+
});
124+
125+
expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [
126+
"PATH",
127+
"SSH_AUTH_SOCK",
128+
"HOMEBREW_PREFIX",
129+
"HOMEBREW_CELLAR",
130+
"HOMEBREW_REPOSITORY",
131+
"XDG_CONFIG_HOME",
132+
"XDG_DATA_HOME",
133+
]);
134+
expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [
135+
"PATH",
136+
"SSH_AUTH_SOCK",
137+
"HOMEBREW_PREFIX",
138+
"HOMEBREW_CELLAR",
139+
"HOMEBREW_REPOSITORY",
140+
"XDG_CONFIG_HOME",
141+
"XDG_DATA_HOME",
142+
]);
143+
expect(readLaunchctlPath).toHaveBeenCalledTimes(1);
144+
expect(logWarning).toHaveBeenCalledWith(
145+
"Failed to read login shell environment from /opt/homebrew/bin/nu.",
146+
expect.any(Error),
147+
);
148+
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
149+
});
150+
85151
it("does nothing outside macOS and linux", () => {
86152
const env: NodeJS.ProcessEnv = {
87153
SHELL: "C:/Program Files/Git/bin/bash.exe",
Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,79 @@
11
import {
2+
listLoginShellCandidates,
3+
mergePathEntries,
4+
readPathFromLaunchctl,
25
readEnvironmentFromLoginShell,
3-
resolveLoginShell,
46
ShellEnvironmentReader,
57
} from "@t3tools/shared/shell";
68

9+
const LOGIN_SHELL_ENV_NAMES = [
10+
"PATH",
11+
"SSH_AUTH_SOCK",
12+
"HOMEBREW_PREFIX",
13+
"HOMEBREW_CELLAR",
14+
"HOMEBREW_REPOSITORY",
15+
"XDG_CONFIG_HOME",
16+
"XDG_DATA_HOME",
17+
] as const;
18+
19+
function logShellEnvironmentWarning(message: string, error?: unknown): void {
20+
console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? ""));
21+
}
22+
723
export function syncShellEnvironment(
824
env: NodeJS.ProcessEnv = process.env,
925
options: {
1026
platform?: NodeJS.Platform;
1127
readEnvironment?: ShellEnvironmentReader;
28+
readLaunchctlPath?: typeof readPathFromLaunchctl;
29+
userShell?: string;
30+
logWarning?: (message: string, error?: unknown) => void;
1231
} = {},
1332
): void {
1433
const platform = options.platform ?? process.platform;
1534
if (platform !== "darwin" && platform !== "linux") return;
1635

17-
try {
18-
const shell = resolveLoginShell(platform, env.SHELL);
19-
if (!shell) return;
36+
const logWarning = options.logWarning ?? logShellEnvironmentWarning;
37+
const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
38+
const shellEnvironment: Partial<Record<string, string>> = {};
2039

21-
const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
22-
"PATH",
23-
"SSH_AUTH_SOCK",
24-
]);
40+
try {
41+
for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
42+
try {
43+
Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));
44+
if (shellEnvironment.PATH) {
45+
break;
46+
}
47+
} catch (error) {
48+
logWarning(`Failed to read login shell environment from ${shell}.`, error);
49+
}
50+
}
2551

26-
if (shellEnvironment.PATH) {
27-
env.PATH = shellEnvironment.PATH;
52+
const launchctlPath =
53+
platform === "darwin" && !shellEnvironment.PATH
54+
? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
55+
: undefined;
56+
const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform);
57+
if (mergedPath) {
58+
env.PATH = mergedPath;
2859
}
2960

3061
if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
3162
env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
3263
}
33-
} catch {
34-
// Keep inherited environment if shell lookup fails.
64+
65+
for (const name of [
66+
"HOMEBREW_PREFIX",
67+
"HOMEBREW_CELLAR",
68+
"HOMEBREW_REPOSITORY",
69+
"XDG_CONFIG_HOME",
70+
"XDG_DATA_HOME",
71+
] as const) {
72+
if (!env[name] && shellEnvironment[name]) {
73+
env[name] = shellEnvironment[name];
74+
}
75+
}
76+
} catch (error) {
77+
logWarning("Failed to synchronize the desktop shell environment.", error);
3578
}
3679
}

apps/server/src/os-jank.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe("fixPath", () => {
66
it("hydrates PATH on linux using the resolved login shell", () => {
77
const env: NodeJS.ProcessEnv = {
88
SHELL: "/bin/zsh",
9-
PATH: "/usr/bin",
9+
PATH: "/Users/test/.local/bin:/usr/bin",
1010
};
1111
const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
1212

@@ -17,6 +17,39 @@ describe("fixPath", () => {
1717
});
1818

1919
expect(readPath).toHaveBeenCalledWith("/bin/zsh");
20+
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
21+
});
22+
23+
it("falls back to launchctl PATH on macOS when shell probing fails", () => {
24+
const env: NodeJS.ProcessEnv = {
25+
SHELL: "/opt/homebrew/bin/nu",
26+
PATH: "/usr/bin",
27+
};
28+
const readPath = vi
29+
.fn()
30+
.mockImplementationOnce(() => {
31+
throw new Error("unknown flag");
32+
})
33+
.mockImplementationOnce(() => undefined);
34+
const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
35+
const logWarning = vi.fn();
36+
37+
fixPath({
38+
env,
39+
platform: "darwin",
40+
readPath,
41+
readLaunchctlPath,
42+
userShell: "/bin/zsh",
43+
logWarning,
44+
});
45+
46+
expect(readPath).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu");
47+
expect(readPath).toHaveBeenNthCalledWith(2, "/bin/zsh");
48+
expect(readLaunchctlPath).toHaveBeenCalledTimes(1);
49+
expect(logWarning).toHaveBeenCalledWith(
50+
"Failed to read PATH from login shell /opt/homebrew/bin/nu.",
51+
expect.any(Error),
52+
);
2053
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
2154
});
2255

apps/server/src/os-jank.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,57 @@
11
import * as OS from "node:os";
22
import { Effect, Path } from "effect";
3-
import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell";
3+
import {
4+
listLoginShellCandidates,
5+
mergePathEntries,
6+
readPathFromLaunchctl,
7+
readPathFromLoginShell,
8+
} from "@t3tools/shared/shell";
9+
10+
function logPathHydrationWarning(message: string, error?: unknown): void {
11+
console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? ""));
12+
}
413

514
export function fixPath(
615
options: {
716
env?: NodeJS.ProcessEnv;
817
platform?: NodeJS.Platform;
918
readPath?: typeof readPathFromLoginShell;
19+
readLaunchctlPath?: typeof readPathFromLaunchctl;
20+
userShell?: string;
21+
logWarning?: (message: string, error?: unknown) => void;
1022
} = {},
1123
): void {
1224
const platform = options.platform ?? process.platform;
1325
if (platform !== "darwin" && platform !== "linux") return;
1426

1527
const env = options.env ?? process.env;
28+
const logWarning = options.logWarning ?? logPathHydrationWarning;
29+
const readPath = options.readPath ?? readPathFromLoginShell;
1630

1731
try {
18-
const shell = resolveLoginShell(platform, env.SHELL);
19-
if (!shell) return;
20-
const result = (options.readPath ?? readPathFromLoginShell)(shell);
21-
if (result) {
22-
env.PATH = result;
32+
let shellPath: string | undefined;
33+
for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
34+
try {
35+
shellPath = readPath(shell);
36+
} catch (error) {
37+
logWarning(`Failed to read PATH from login shell ${shell}.`, error);
38+
}
39+
40+
if (shellPath) {
41+
break;
42+
}
43+
}
44+
45+
const launchctlPath =
46+
platform === "darwin" && !shellPath
47+
? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
48+
: undefined;
49+
const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform);
50+
if (mergedPath) {
51+
env.PATH = mergedPath;
2352
}
24-
} catch {
25-
// Silently ignore — keep default PATH
53+
} catch (error) {
54+
logWarning("Failed to hydrate PATH from the user environment.", error);
2655
}
2756
}
2857

packages/shared/src/shell.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest";
22

33
import {
44
extractPathFromShellOutput,
5+
listLoginShellCandidates,
6+
mergePathEntries,
57
readEnvironmentFromLoginShell,
8+
readPathFromLaunchctl,
69
readPathFromLoginShell,
710
} from "./shell";
811

@@ -60,6 +63,38 @@ describe("readPathFromLoginShell", () => {
6063
});
6164
});
6265

66+
describe("readPathFromLaunchctl", () => {
67+
it("returns a trimmed PATH value from launchctl", () => {
68+
const execFile = vi.fn<
69+
(
70+
file: string,
71+
args: ReadonlyArray<string>,
72+
options: { encoding: "utf8"; timeout: number },
73+
) => string
74+
>(() => " /opt/homebrew/bin:/usr/bin \n");
75+
76+
expect(readPathFromLaunchctl(execFile)).toBe("/opt/homebrew/bin:/usr/bin");
77+
expect(execFile).toHaveBeenCalledWith("/bin/launchctl", ["getenv", "PATH"], {
78+
encoding: "utf8",
79+
timeout: 2000,
80+
});
81+
});
82+
83+
it("returns undefined when launchctl is unavailable", () => {
84+
const execFile = vi.fn<
85+
(
86+
file: string,
87+
args: ReadonlyArray<string>,
88+
options: { encoding: "utf8"; timeout: number },
89+
) => string
90+
>(() => {
91+
throw new Error("spawn /bin/launchctl ENOENT");
92+
});
93+
94+
expect(readPathFromLaunchctl(execFile)).toBeUndefined();
95+
});
96+
});
97+
6398
describe("readEnvironmentFromLoginShell", () => {
6499
it("extracts multiple environment variables from a login shell command", () => {
65100
const execFile = vi.fn<
@@ -126,3 +161,30 @@ describe("readEnvironmentFromLoginShell", () => {
126161
});
127162
});
128163
});
164+
165+
describe("listLoginShellCandidates", () => {
166+
it("returns env shell, user shell, then the platform fallback without duplicates", () => {
167+
expect(listLoginShellCandidates("darwin", " /opt/homebrew/bin/nu ", "/bin/zsh")).toEqual([
168+
"/opt/homebrew/bin/nu",
169+
"/bin/zsh",
170+
]);
171+
});
172+
173+
it("falls back to the platform default when no shells are available", () => {
174+
expect(listLoginShellCandidates("linux", undefined, "")).toEqual(["/bin/bash"]);
175+
});
176+
});
177+
178+
describe("mergePathEntries", () => {
179+
it("prefers login-shell PATH entries and keeps inherited extras", () => {
180+
expect(
181+
mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"),
182+
).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
183+
});
184+
185+
it("uses the platform-specific delimiter", () => {
186+
expect(mergePathEntries("C:\\Tools;C:\\Windows", "C:\\Windows;C:\\Git", "win32")).toBe(
187+
"C:\\Tools;C:\\Windows;C:\\Git",
188+
);
189+
});
190+
});

0 commit comments

Comments
 (0)