authorgravatar for 63877413+stickerdaniel@users.noreply.github.comDaniel Sticker <63877413+stickerdaniel@users.noreply.github.com> 2026-03-16 17:05:37+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-16 09:05:37-07:00
log765c1dc9e418f4ce0dbeb42c154d7ecb65a09fb6
tree686b06b03aaabcc14172880c1314d76911c4691c
parente6d9a271fcd9c6cbb7c7faeb908a17e902a97c95
signature Signed by PGP key B5690EEEBB952194

fix(desktop): backfill SSH_AUTH_SOCK from login shell on macOS (#972)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

6 files changed, 268 insertions(+), 30 deletions(-)

apps/desktop/src/fixPath.ts deleted-15
......@@ -1,15 +0,0 @@
1import { readPathFromLoginShell } from "@t3tools/shared/shell";
2
3export function fixPath(): void {
4 if (process.platform !== "darwin") return;
5
6 try {
7 const shell = process.env.SHELL ?? "/bin/zsh";
8 const result = readPathFromLoginShell(shell);
9 if (result) {
10 process.env.PATH = result;
11 }
12 } catch {
13 // Keep inherited PATH if shell lookup fails.
14 }
15}
apps/desktop/src/main.ts+2-2
......@@ -28,7 +28,7 @@ import type { ContextMenuItem } from "@t3tools/contracts";
2828import { NetService } from "@t3tools/shared/Net";
2929import { RotatingFileSink } from "@t3tools/shared/logging";
3030import { showDesktopConfirmDialog } from "./confirmDialog";
31import { fixPath } from "./fixPath";
31import { syncShellEnvironment } from "./syncShellEnvironment";
3232import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
3333import {
3434 createInitialDesktopUpdateState,
......@@ -44,7 +44,7 @@ import {
4444} from "./updateMachine";
4545import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
4646
47fixPath();
47syncShellEnvironment();
4848
4949const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
5050const CONFIRM_CHANNEL = "desktop:confirm";
apps/desktop/src/syncShellEnvironment.test.ts created+85
......@@ -0,0 +1,85 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { syncShellEnvironment } from "./syncShellEnvironment";
4
5describe("syncShellEnvironment", () => {
6 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
7 const env: NodeJS.ProcessEnv = {
8 SHELL: "/bin/zsh",
9 PATH: "/usr/bin",
10 };
11 const readEnvironment = vi.fn(() => ({
12 PATH: "/opt/homebrew/bin:/usr/bin",
13 SSH_AUTH_SOCK: "/tmp/secretive.sock",
14 }));
15
16 syncShellEnvironment(env, {
17 platform: "darwin",
18 readEnvironment,
19 });
20
21 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
22 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
23 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
24 });
25
26 it("preserves an inherited SSH_AUTH_SOCK value", () => {
27 const env: NodeJS.ProcessEnv = {
28 SHELL: "/bin/zsh",
29 PATH: "/usr/bin",
30 SSH_AUTH_SOCK: "/tmp/inherited.sock",
31 };
32 const readEnvironment = vi.fn(() => ({
33 PATH: "/opt/homebrew/bin:/usr/bin",
34 SSH_AUTH_SOCK: "/tmp/login-shell.sock",
35 }));
36
37 syncShellEnvironment(env, {
38 platform: "darwin",
39 readEnvironment,
40 });
41
42 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
43 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
44 });
45
46 it("preserves inherited values when the login shell omits them", () => {
47 const env: NodeJS.ProcessEnv = {
48 SHELL: "/bin/zsh",
49 PATH: "/usr/bin",
50 SSH_AUTH_SOCK: "/tmp/inherited.sock",
51 };
52 const readEnvironment = vi.fn(() => ({
53 PATH: "/opt/homebrew/bin:/usr/bin",
54 }));
55
56 syncShellEnvironment(env, {
57 platform: "darwin",
58 readEnvironment,
59 });
60
61 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
62 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
63 });
64
65 it("does nothing outside macOS", () => {
66 const env: NodeJS.ProcessEnv = {
67 SHELL: "/bin/zsh",
68 PATH: "/usr/bin",
69 SSH_AUTH_SOCK: "/tmp/inherited.sock",
70 };
71 const readEnvironment = vi.fn(() => ({
72 PATH: "/opt/homebrew/bin:/usr/bin",
73 SSH_AUTH_SOCK: "/tmp/secretive.sock",
74 }));
75
76 syncShellEnvironment(env, {
77 platform: "linux",
78 readEnvironment,
79 });
80
81 expect(readEnvironment).not.toHaveBeenCalled();
82 expect(env.PATH).toBe("/usr/bin");
83 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
84 });
85});
apps/desktop/src/syncShellEnvironment.ts created+29
......@@ -0,0 +1,29 @@
1import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell";
2
3export function syncShellEnvironment(
4 env: NodeJS.ProcessEnv = process.env,
5 options: {
6 platform?: NodeJS.Platform;
7 readEnvironment?: ShellEnvironmentReader;
8 } = {},
9): void {
10 if ((options.platform ?? process.platform) !== "darwin") return;
11
12 try {
13 const shell = env.SHELL ?? "/bin/zsh";
14 const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
15 "PATH",
16 "SSH_AUTH_SOCK",
17 ]);
18
19 if (shellEnvironment.PATH) {
20 env.PATH = shellEnvironment.PATH;
21 }
22
23 if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
24 env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
25 }
26 } catch {
27 // Keep inherited environment if shell lookup fails.
28 }
29}
packages/shared/src/shell.test.ts+76-5
......@@ -1,6 +1,10 @@
11import { describe, expect, it, vi } from "vitest";
22
3import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell";
3import {
4 extractPathFromShellOutput,
5 readEnvironmentFromLoginShell,
6 readPathFromLoginShell,
7} from "./shell";
48
59describe("extractPathFromShellOutput", () => {
610 it("extracts the path between capture markers", () => {
......@@ -32,7 +36,7 @@ describe("readPathFromLoginShell", () => {
3236 args: ReadonlyArray<string>,
3337 options: { encoding: "utf8"; timeout: number },
3438 ) => string
35 >(() => "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n");
39 >(() => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n");
3640
3741 expect(readPathFromLoginShell("/opt/homebrew/bin/fish", execFile)).toBe("/a:/b");
3842 expect(execFile).toHaveBeenCalledTimes(1);
......@@ -49,9 +53,76 @@ describe("readPathFromLoginShell", () => {
4953 expect(shell).toBe("/opt/homebrew/bin/fish");
5054 expect(args).toHaveLength(2);
5155 expect(args?.[0]).toBe("-ilc");
52 expect(args?.[1]).toContain("printenv PATH");
53 expect(args?.[1]).toContain("__T3CODE_PATH_START__");
54 expect(args?.[1]).toContain("__T3CODE_PATH_END__");
56 expect(args?.[1]).toContain("printenv PATH || true");
57 expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__");
58 expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__");
5559 expect(options).toEqual({ encoding: "utf8", timeout: 5000 });
5660 });
5761});
62
63describe("readEnvironmentFromLoginShell", () => {
64 it("extracts multiple environment variables from a login shell command", () => {
65 const execFile = vi.fn<
66 (
67 file: string,
68 args: ReadonlyArray<string>,
69 options: { encoding: "utf8"; timeout: number },
70 ) => string
71 >(() =>
72 [
73 "__T3CODE_ENV_PATH_START__",
74 "/a:/b",
75 "__T3CODE_ENV_PATH_END__",
76 "__T3CODE_ENV_SSH_AUTH_SOCK_START__",
77 "/tmp/secretive.sock",
78 "__T3CODE_ENV_SSH_AUTH_SOCK_END__",
79 ].join("\n"),
80 );
81
82 expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({
83 PATH: "/a:/b",
84 SSH_AUTH_SOCK: "/tmp/secretive.sock",
85 });
86 expect(execFile).toHaveBeenCalledTimes(1);
87 });
88
89 it("omits environment variables that are missing or empty", () => {
90 const execFile = vi.fn<
91 (
92 file: string,
93 args: ReadonlyArray<string>,
94 options: { encoding: "utf8"; timeout: number },
95 ) => string
96 >(() =>
97 [
98 "__T3CODE_ENV_PATH_START__",
99 "/a:/b",
100 "__T3CODE_ENV_PATH_END__",
101 "__T3CODE_ENV_SSH_AUTH_SOCK_START__",
102 "__T3CODE_ENV_SSH_AUTH_SOCK_END__",
103 ].join("\n"),
104 );
105
106 expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({
107 PATH: "/a:/b",
108 });
109 });
110
111 it("preserves surrounding whitespace in captured values", () => {
112 const execFile = vi.fn<
113 (
114 file: string,
115 args: ReadonlyArray<string>,
116 options: { encoding: "utf8"; timeout: number },
117 ) => string
118 >(() =>
119 ["__T3CODE_ENV_CUSTOM_VAR_START__", " padded value ", "__T3CODE_ENV_CUSTOM_VAR_END__"].join(
120 "\n",
121 ),
122 );
123
124 expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({
125 CUSTOM_VAR: " padded value ",
126 });
127 });
128});
packages/shared/src/shell.ts+76-8
......@@ -2,11 +2,7 @@ import { execFileSync } from "node:child_process";
22
33const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
44const PATH_CAPTURE_END = "__T3CODE_PATH_END__";
5const PATH_CAPTURE_COMMAND = [
6 `printf '%s\n' '${PATH_CAPTURE_START}'`,
7 "printenv PATH",
8 `printf '%s\n' '${PATH_CAPTURE_END}'`,
9].join("; ");
5const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;
106
117type ExecFileSyncLike = (
128 file: string,
......@@ -30,9 +26,81 @@ export function readPathFromLoginShell(
3026 shell: string,
3127 execFile: ExecFileSyncLike = execFileSync,
3228): string | undefined {
33 const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], {
29 return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH;
30}
31
32function envCaptureStart(name: string): string {
33 return `__T3CODE_ENV_${name}_START__`;
34}
35
36function envCaptureEnd(name: string): string {
37 return `__T3CODE_ENV_${name}_END__`;
38}
39
40function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {
41 return names
42 .map((name) => {
43 if (!SHELL_ENV_NAME_PATTERN.test(name)) {
44 throw new Error(`Unsupported environment variable name: ${name}`);
45 }
46
47 return [
48 `printf '%s\\n' '${envCaptureStart(name)}'`,
49 `printenv ${name} || true`,
50 `printf '%s\\n' '${envCaptureEnd(name)}'`,
51 ].join("; ");
52 })
53 .join("; ");
54}
55
56function extractEnvironmentValue(output: string, name: string): string | undefined {
57 const startMarker = envCaptureStart(name);
58 const endMarker = envCaptureEnd(name);
59 const startIndex = output.indexOf(startMarker);
60 if (startIndex === -1) return undefined;
61
62 const valueStartIndex = startIndex + startMarker.length;
63 const endIndex = output.indexOf(endMarker, valueStartIndex);
64 if (endIndex === -1) return undefined;
65
66 let value = output.slice(valueStartIndex, endIndex);
67 if (value.startsWith("\n")) {
68 value = value.slice(1);
69 }
70 if (value.endsWith("\n")) {
71 value = value.slice(0, -1);
72 }
73
74 return value.length > 0 ? value : undefined;
75}
76
77export type ShellEnvironmentReader = (
78 shell: string,
79 names: ReadonlyArray<string>,
80 execFile?: ExecFileSyncLike,
81) => Partial<Record<string, string>>;
82
83export const readEnvironmentFromLoginShell: ShellEnvironmentReader = (
84 shell,
85 names,
86 execFile = execFileSync,
87) => {
88 if (names.length === 0) {
89 return {};
90 }
91
92 const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], {
3493 encoding: "utf8",
3594 timeout: 5000,
3695 });
37 return extractPathFromShellOutput(output) ?? undefined;
38}
96
97 const environment: Partial<Record<string, string>> = {};
98 for (const name of names) {
99 const value = extractEnvironmentValue(output, name);
100 if (value !== undefined) {
101 environment[name] = value;
102 }
103 }
104
105 return environment;
106};