From 191fdd5e24a8be92e12132f3bfd10adf6bedc138 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Thu, 12 Mar 2026 11:41:19 +0100 Subject: [PATCH 1/2] fix(desktop): backfill ssh auth sock from login shell Refs #971. --- apps/desktop/src/fixPath.ts | 15 ---- apps/desktop/src/main.ts | 4 +- apps/desktop/src/syncShellEnvironment.test.ts | 85 +++++++++++++++++++ apps/desktop/src/syncShellEnvironment.ts | 31 +++++++ packages/shared/src/shell.test.ts | 65 +++++++++++++- packages/shared/src/shell.ts | 69 +++++++++++++-- 6 files changed, 241 insertions(+), 28 deletions(-) delete mode 100644 apps/desktop/src/fixPath.ts create mode 100644 apps/desktop/src/syncShellEnvironment.test.ts create mode 100644 apps/desktop/src/syncShellEnvironment.ts diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts deleted file mode 100644 index 8853248b2..000000000 --- a/apps/desktop/src/fixPath.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readPathFromLoginShell } from "@t3tools/shared/shell"; - -export function fixPath(): void { - if (process.platform !== "darwin") return; - - try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = readPathFromLoginShell(shell); - if (result) { - process.env.PATH = result; - } - } catch { - // Keep inherited PATH if shell lookup fails. - } -} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 443492ada..f277cb573 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -28,7 +28,7 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; -import { fixPath } from "./fixPath"; +import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { createInitialDesktopUpdateState, @@ -44,7 +44,7 @@ import { } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; -fixPath(); +syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts new file mode 100644 index 000000000..69e73da0a --- /dev/null +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; + +import { syncShellEnvironment } from "./syncShellEnvironment"; + +describe("syncShellEnvironment", () => { + it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); + }); + + it("preserves an inherited SSH_AUTH_SOCK value", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/login-shell.sock", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); + }); + + it("preserves inherited values when the login shell omits them", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/opt/homebrew/bin:/usr/bin", + })); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + }); + + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); + }); + + it("does nothing outside macOS", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + })); + + syncShellEnvironment(env, { + platform: "linux", + readEnvironment, + }); + + expect(readEnvironment).not.toHaveBeenCalled(); + expect(env.PATH).toBe("/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); + }); +}); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts new file mode 100644 index 000000000..3c48e71a2 --- /dev/null +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -0,0 +1,31 @@ +import { readEnvironmentFromLoginShell } from "@t3tools/shared/shell"; + +type ShellEnvironmentReader = typeof readEnvironmentFromLoginShell; + +export function syncShellEnvironment( + env: NodeJS.ProcessEnv = process.env, + options: { + platform?: NodeJS.Platform; + readEnvironment?: ShellEnvironmentReader; + } = {}, +): void { + if ((options.platform ?? process.platform) !== "darwin") return; + + try { + const shell = env.SHELL ?? "/bin/zsh"; + const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ + "PATH", + "SSH_AUTH_SOCK", + ]); + + if (shellEnvironment.PATH) { + env.PATH = shellEnvironment.PATH; + } + + if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { + env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; + } + } catch { + // Keep inherited environment if shell lookup fails. + } +} diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index f659725c7..09a2bfa47 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell"; +import { + extractPathFromShellOutput, + readEnvironmentFromLoginShell, + readPathFromLoginShell, +} from "./shell"; describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { @@ -32,7 +36,7 @@ describe("readPathFromLoginShell", () => { args: ReadonlyArray, options: { encoding: "utf8"; timeout: number }, ) => string - >(() => "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n"); + >(() => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n"); expect(readPathFromLoginShell("/opt/homebrew/bin/fish", execFile)).toBe("/a:/b"); expect(execFile).toHaveBeenCalledTimes(1); @@ -50,8 +54,61 @@ describe("readPathFromLoginShell", () => { expect(args).toHaveLength(2); expect(args?.[0]).toBe("-ilc"); expect(args?.[1]).toContain("printenv PATH"); - expect(args?.[1]).toContain("__T3CODE_PATH_START__"); - expect(args?.[1]).toContain("__T3CODE_PATH_END__"); + expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__"); + expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__"); expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); }); }); + +describe("readEnvironmentFromLoginShell", () => { + it("extracts multiple environment variables from a login shell command", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + [ + "__T3CODE_ENV_PATH_START__", + "/a:/b", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "/tmp/secretive.sock", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), + ); + + expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual( + { + PATH: "/a:/b", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + }, + ); + expect(execFile).toHaveBeenCalledTimes(1); + }); + + it("omits environment variables that are missing or empty", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + [ + "__T3CODE_ENV_PATH_START__", + "/a:/b", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), + ); + + expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual( + { + PATH: "/a:/b", + }, + ); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index e6029c443..56b134b12 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -2,11 +2,7 @@ import { execFileSync } from "node:child_process"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; -const PATH_CAPTURE_COMMAND = [ - `printf '%s\n' '${PATH_CAPTURE_START}'`, - "printenv PATH", - `printf '%s\n' '${PATH_CAPTURE_END}'`, -].join("; "); +const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; type ExecFileSyncLike = ( file: string, @@ -30,9 +26,68 @@ export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = execFileSync, ): string | undefined { - const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], { + return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; +} + +function envCaptureStart(name: string): string { + return `__T3CODE_ENV_${name}_START__`; +} + +function envCaptureEnd(name: string): string { + return `__T3CODE_ENV_${name}_END__`; +} + +function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { + return names + .map((name) => { + if (!SHELL_ENV_NAME_PATTERN.test(name)) { + throw new Error(`Unsupported environment variable name: ${name}`); + } + + return [ + `printf '%s\\n' '${envCaptureStart(name)}'`, + `printenv ${name}`, + `printf '%s\\n' '${envCaptureEnd(name)}'`, + ].join("; "); + }) + .join("; "); +} + +function extractEnvironmentValue(output: string, name: string): string | undefined { + const startMarker = envCaptureStart(name); + const endMarker = envCaptureEnd(name); + const startIndex = output.indexOf(startMarker); + if (startIndex === -1) return undefined; + + const valueStartIndex = startIndex + startMarker.length; + const endIndex = output.indexOf(endMarker, valueStartIndex); + if (endIndex === -1) return undefined; + + const value = output.slice(valueStartIndex, endIndex).trim(); + return value.length > 0 ? value : undefined; +} + +export function readEnvironmentFromLoginShell( + shell: string, + names: ReadonlyArray, + execFile: ExecFileSyncLike = execFileSync, +): Partial> { + if (names.length === 0) { + return {}; + } + + const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], { encoding: "utf8", timeout: 5000, }); - return extractPathFromShellOutput(output) ?? undefined; + + const environment: Partial> = {}; + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (value !== undefined) { + environment[name] = value; + } + } + + return environment; } From a717465c6b9d271be6630cba5663951c6d8e32e2 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Thu, 12 Mar 2026 12:18:45 +0100 Subject: [PATCH 2/2] fix(shared): harden login-shell env capture --- packages/shared/src/shell.test.ts | 22 +++++++++++++++++++++- packages/shared/src/shell.ts | 11 +++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 09a2bfa47..cad7d33a8 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -53,7 +53,7 @@ describe("readPathFromLoginShell", () => { expect(shell).toBe("/opt/homebrew/bin/fish"); expect(args).toHaveLength(2); expect(args?.[0]).toBe("-ilc"); - expect(args?.[1]).toContain("printenv PATH"); + expect(args?.[1]).toContain("printenv PATH || true"); expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__"); expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__"); expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); @@ -111,4 +111,24 @@ describe("readEnvironmentFromLoginShell", () => { }, ); }); + + it("preserves surrounding whitespace in captured values", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + [ + "__T3CODE_ENV_CUSTOM_VAR_START__", + " padded value ", + "__T3CODE_ENV_CUSTOM_VAR_END__", + ].join("\n"), + ); + + expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({ + CUSTOM_VAR: " padded value ", + }); + }); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 56b134b12..578725f04 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -46,7 +46,7 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { return [ `printf '%s\\n' '${envCaptureStart(name)}'`, - `printenv ${name}`, + `printenv ${name} || true`, `printf '%s\\n' '${envCaptureEnd(name)}'`, ].join("; "); }) @@ -63,7 +63,14 @@ function extractEnvironmentValue(output: string, name: string): string | undefin const endIndex = output.indexOf(endMarker, valueStartIndex); if (endIndex === -1) return undefined; - const value = output.slice(valueStartIndex, endIndex).trim(); + let value = output.slice(valueStartIndex, endIndex); + if (value.startsWith("\n")) { + value = value.slice(1); + } + if (value.endsWith("\n")) { + value = value.slice(0, -1); + } + return value.length > 0 ? value : undefined; }