authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-13 16:31:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 16:31:43-07:00
log2e42f3fdf624adb24a14cf6d2f31875014ebf914
tree2544e35c91811313e361fd242a44f29cde630e59
parent008ac5c3e5c81d297bdb7fc70bd04b7f69ca36c6
signature Signed by PGP key B5690EEEBB952194

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>

6 files changed, 325 insertions(+), 38 deletions(-)

apps/desktop/src/syncShellEnvironment.test.ts+70-4
...@@ -6,11 +6,12 @@ describe("syncShellEnvironment", () => {...@@ -6,11 +6,12 @@ describe("syncShellEnvironment", () => {
6 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {6 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
7 const env: NodeJS.ProcessEnv = {7 const env: NodeJS.ProcessEnv = {
8 SHELL: "/bin/zsh",8 SHELL: "/bin/zsh",
9 PATH: "/usr/bin",9 PATH: "/Users/test/.local/bin:/usr/bin",
10 };10 };
11 const readEnvironment = vi.fn(() => ({11 const readEnvironment = vi.fn(() => ({
12 PATH: "/opt/homebrew/bin:/usr/bin",12 PATH: "/opt/homebrew/bin:/usr/bin",
13 SSH_AUTH_SOCK: "/tmp/secretive.sock",13 SSH_AUTH_SOCK: "/tmp/secretive.sock",
14 HOMEBREW_PREFIX: "/opt/homebrew",
14 }));15 }));
1516
16 syncShellEnvironment(env, {17 syncShellEnvironment(env, {
...@@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => {...@@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => {
18 readEnvironment,19 readEnvironment,
19 });20 });
2021
21 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);22 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
22 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");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");
23 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");32 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
33 expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew");
24 });34 });
2535
26 it("preserves an inherited SSH_AUTH_SOCK value", () => {36 it("preserves an inherited SSH_AUTH_SOCK value", () => {
...@@ -77,11 +87,67 @@ describe("syncShellEnvironment", () => {...@@ -77,11 +87,67 @@ describe("syncShellEnvironment", () => {
77 readEnvironment,87 readEnvironment,
78 });88 });
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 ]);
81 expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");99 expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
82 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");100 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
83 });101 });
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
85 it("does nothing outside macOS and linux", () => {151 it("does nothing outside macOS and linux", () => {
86 const env: NodeJS.ProcessEnv = {152 const env: NodeJS.ProcessEnv = {
87 SHELL: "C:/Program Files/Git/bin/bash.exe",153 SHELL: "C:/Program Files/Git/bin/bash.exe",
apps/desktop/src/syncShellEnvironment.ts+55-12
...@@ -1,36 +1,79 @@...@@ -1,36 +1,79 @@
1import {1import {
2 listLoginShellCandidates,
3 mergePathEntries,
4 readPathFromLaunchctl,
2 readEnvironmentFromLoginShell,5 readEnvironmentFromLoginShell,
3 resolveLoginShell,
4 ShellEnvironmentReader,6 ShellEnvironmentReader,
5} from "@t3tools/shared/shell";7} from "@t3tools/shared/shell";
68
9const 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
19function logShellEnvironmentWarning(message: string, error?: unknown): void {
20 console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? ""));
21}
22
7export function syncShellEnvironment(23export function syncShellEnvironment(
8 env: NodeJS.ProcessEnv = process.env,24 env: NodeJS.ProcessEnv = process.env,
9 options: {25 options: {
10 platform?: NodeJS.Platform;26 platform?: NodeJS.Platform;
11 readEnvironment?: ShellEnvironmentReader;27 readEnvironment?: ShellEnvironmentReader;
28 readLaunchctlPath?: typeof readPathFromLaunchctl;
29 userShell?: string;
30 logWarning?: (message: string, error?: unknown) => void;
12 } = {},31 } = {},
13): void {32): void {
14 const platform = options.platform ?? process.platform;33 const platform = options.platform ?? process.platform;
15 if (platform !== "darwin" && platform !== "linux") return;34 if (platform !== "darwin" && platform !== "linux") return;
1635
17 try {36 const logWarning = options.logWarning ?? logShellEnvironmentWarning;
18 const shell = resolveLoginShell(platform, env.SHELL);37 const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
19 if (!shell) return;38 const shellEnvironment: Partial<Record<string, string>> = {};
2039
21 const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [40 try {
22 "PATH",41 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
23 "SSH_AUTH_SOCK",42 try {
24 ]);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) {52 const launchctlPath =
27 env.PATH = shellEnvironment.PATH;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;
28 }59 }
2960
30 if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {61 if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
31 env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;62 env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
32 }63 }
33 } catch {64
34 // Keep inherited environment if shell lookup fails.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);
35 }78 }
36}79}
apps/server/src/os-jank.test.ts+34-1
...@@ -6,7 +6,7 @@ describe("fixPath", () => {...@@ -6,7 +6,7 @@ describe("fixPath", () => {
6 it("hydrates PATH on linux using the resolved login shell", () => {6 it("hydrates PATH on linux using the resolved login shell", () => {
7 const env: NodeJS.ProcessEnv = {7 const env: NodeJS.ProcessEnv = {
8 SHELL: "/bin/zsh",8 SHELL: "/bin/zsh",
9 PATH: "/usr/bin",9 PATH: "/Users/test/.local/bin:/usr/bin",
10 };10 };
11 const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");11 const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
1212
...@@ -17,6 +17,39 @@ describe("fixPath", () => {...@@ -17,6 +17,39 @@ describe("fixPath", () => {
17 });17 });
1818
19 expect(readPath).toHaveBeenCalledWith("/bin/zsh");19 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 );
20 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");53 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
21 });54 });
2255
apps/server/src/os-jank.ts+37-8
...@@ -1,28 +1,57 @@...@@ -1,28 +1,57 @@
1import * as OS from "node:os";1import * as OS from "node:os";
2import { Effect, Path } from "effect";2import { Effect, Path } from "effect";
3import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell";3import {
4 listLoginShellCandidates,
5 mergePathEntries,
6 readPathFromLaunchctl,
7 readPathFromLoginShell,
8} from "@t3tools/shared/shell";
9
10function logPathHydrationWarning(message: string, error?: unknown): void {
11 console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? ""));
12}
413
5export function fixPath(14export function fixPath(
6 options: {15 options: {
7 env?: NodeJS.ProcessEnv;16 env?: NodeJS.ProcessEnv;
8 platform?: NodeJS.Platform;17 platform?: NodeJS.Platform;
9 readPath?: typeof readPathFromLoginShell;18 readPath?: typeof readPathFromLoginShell;
19 readLaunchctlPath?: typeof readPathFromLaunchctl;
20 userShell?: string;
21 logWarning?: (message: string, error?: unknown) => void;
10 } = {},22 } = {},
11): void {23): void {
12 const platform = options.platform ?? process.platform;24 const platform = options.platform ?? process.platform;
13 if (platform !== "darwin" && platform !== "linux") return;25 if (platform !== "darwin" && platform !== "linux") return;
1426
15 const env = options.env ?? process.env;27 const env = options.env ?? process.env;
28 const logWarning = options.logWarning ?? logPathHydrationWarning;
29 const readPath = options.readPath ?? readPathFromLoginShell;
1630
17 try {31 try {
18 const shell = resolveLoginShell(platform, env.SHELL);32 let shellPath: string | undefined;
19 if (!shell) return;33 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
20 const result = (options.readPath ?? readPathFromLoginShell)(shell);34 try {
21 if (result) {35 shellPath = readPath(shell);
22 env.PATH = result;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;
23 }52 }
24 } catch {53 } catch (error) {
25 // Silently ignore — keep default PATH54 logWarning("Failed to hydrate PATH from the user environment.", error);
26 }55 }
27}56}
2857
packages/shared/src/shell.test.ts+62
...@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest";...@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest";
22
3import {3import {
4 extractPathFromShellOutput,4 extractPathFromShellOutput,
5 listLoginShellCandidates,
6 mergePathEntries,
5 readEnvironmentFromLoginShell,7 readEnvironmentFromLoginShell,
8 readPathFromLaunchctl,
6 readPathFromLoginShell,9 readPathFromLoginShell,
7} from "./shell";10} from "./shell";
811
...@@ -60,6 +63,38 @@ describe("readPathFromLoginShell", () => {...@@ -60,6 +63,38 @@ describe("readPathFromLoginShell", () => {
60 });63 });
61});64});
6265
66describe("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
63describe("readEnvironmentFromLoginShell", () => {98describe("readEnvironmentFromLoginShell", () => {
64 it("extracts multiple environment variables from a login shell command", () => {99 it("extracts multiple environment variables from a login shell command", () => {
65 const execFile = vi.fn<100 const execFile = vi.fn<
...@@ -126,3 +161,30 @@ describe("readEnvironmentFromLoginShell", () => {...@@ -126,3 +161,30 @@ describe("readEnvironmentFromLoginShell", () => {
126 });161 });
127 });162 });
128});163});
164
165describe("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
178describe("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});
packages/shared/src/shell.ts+67-13
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as OS from "node:os";
1import { execFileSync } from "node:child_process";2import { execFileSync } from "node:child_process";
23
3const PATH_CAPTURE_START = "__T3CODE_PATH_START__";4const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
...@@ -10,24 +11,38 @@ type ExecFileSyncLike = (...@@ -10,24 +11,38 @@ type ExecFileSyncLike = (
10 options: { encoding: "utf8"; timeout: number },11 options: { encoding: "utf8"; timeout: number },
11) => string;12) => string;
1213
13export function resolveLoginShell(14function trimNonEmpty(value: string | null | undefined): string | undefined {
14 platform: NodeJS.Platform,15 const trimmed = value?.trim();
15 shell: string | undefined,16 return trimmed && trimmed.length > 0 ? trimmed : undefined;
16): string | undefined {17}
17 const trimmedShell = shell?.trim();
18 if (trimmedShell) {
19 return trimmedShell;
20 }
2118
22 if (platform === "darwin") {19function readUserLoginShell(): string | undefined {
23 return "/bin/zsh";20 try {
21 return trimNonEmpty(OS.userInfo().shell);
22 } catch {
23 return undefined;
24 }24 }
25}
2526
26 if (platform === "linux") {27export function listLoginShellCandidates(
27 return "/bin/bash";28 platform: NodeJS.Platform,
29 shell: string | undefined,
30 userShell = readUserLoginShell(),
31): ReadonlyArray<string> {
32 const fallbackShell =
33 platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : undefined;
34 const seen = new Set<string>();
35 const candidates: string[] = [];
36
37 for (const candidate of [trimNonEmpty(shell), trimNonEmpty(userShell), fallbackShell]) {
38 if (!candidate || seen.has(candidate)) {
39 continue;
40 }
41 seen.add(candidate);
42 candidates.push(candidate);
28 }43 }
2944
30 return undefined;45 return candidates;
31}46}
3247
33export function extractPathFromShellOutput(output: string): string | null {48export function extractPathFromShellOutput(output: string): string | null {
...@@ -49,6 +64,45 @@ export function readPathFromLoginShell(...@@ -49,6 +64,45 @@ export function readPathFromLoginShell(
49 return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH;64 return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH;
50}65}
5166
67export function readPathFromLaunchctl(
68 execFile: ExecFileSyncLike = execFileSync,
69): string | undefined {
70 try {
71 return trimNonEmpty(
72 execFile("/bin/launchctl", ["getenv", "PATH"], {
73 encoding: "utf8",
74 timeout: 2000,
75 }),
76 );
77 } catch {
78 return undefined;
79 }
80}
81
82export function mergePathEntries(
83 preferredPath: string | undefined,
84 inheritedPath: string | undefined,
85 platform: NodeJS.Platform,
86): string | undefined {
87 const delimiter = platform === "win32" ? ";" : ":";
88 const merged: string[] = [];
89 const seen = new Set<string>();
90
91 for (const pathValue of [preferredPath, inheritedPath]) {
92 if (!pathValue) continue;
93 for (const entry of pathValue.split(delimiter)) {
94 const trimmedEntry = entry.trim();
95 if (!trimmedEntry || seen.has(trimmedEntry)) {
96 continue;
97 }
98 seen.add(trimmedEntry);
99 merged.push(trimmedEntry);
100 }
101 }
102
103 return merged.length > 0 ? merged.join(delimiter) : undefined;
104}
105
52function envCaptureStart(name: string): string {106function envCaptureStart(name: string): string {
53 return `__T3CODE_ENV_${name}_START__`;107 return `__T3CODE_ENV_${name}_START__`;
54}108}