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", () => {
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, {
......@@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => {
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",
apps/desktop/src/syncShellEnvironment.ts+55-12
......@@ -1,36 +1,79 @@
11import {
2 listLoginShellCandidates,
3 mergePathEntries,
4 readPathFromLaunchctl,
25 readEnvironmentFromLoginShell,
3 resolveLoginShell,
46 ShellEnvironmentReader,
57} 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
723export 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+34-1
......@@ -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+37-8
......@@ -1,28 +1,57 @@
11import * as OS from "node:os";
22import { 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
514export 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+62
......@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest";
22
33import {
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
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
6398describe("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
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 @@
1import * as OS from "node:os";
12import { execFileSync } from "node:child_process";
23
34const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
......@@ -10,24 +11,38 @@ type ExecFileSyncLike = (
1011 options: { encoding: "utf8"; timeout: number },
1112) => string;
1213
13export function resolveLoginShell(
14 platform: NodeJS.Platform,
15 shell: string | undefined,
16): string | undefined {
17 const trimmedShell = shell?.trim();
18 if (trimmedShell) {
19 return trimmedShell;
20 }
14function trimNonEmpty(value: string | null | undefined): string | undefined {
15 const trimmed = value?.trim();
16 return trimmed && trimmed.length > 0 ? trimmed : undefined;
17}
2118
22 if (platform === "darwin") {
23 return "/bin/zsh";
19function readUserLoginShell(): string | undefined {
20 try {
21 return trimNonEmpty(OS.userInfo().shell);
22 } catch {
23 return undefined;
2424 }
25}
2526
26 if (platform === "linux") {
27 return "/bin/bash";
27export function listLoginShellCandidates(
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);
2843 }
2944
30 return undefined;
45 return candidates;
3146}
3247
3348export function extractPathFromShellOutput(output: string): string | null {
......@@ -49,6 +64,45 @@ export function readPathFromLoginShell(
4964 return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH;
5065}
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
52106function envCaptureStart(name: string): string {
53107 return `__T3CODE_ENV_${name}_START__`;
54108}