authorgravatar for 148826144+Ryan-D-Gast@users.noreply.github.comRyan Gast <148826144+Ryan-D-Gast@users.noreply.github.com> 2026-03-28 13:21:56-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 18:21:56+00:00
log32c1f98a02d5cf8e42d70dbd65822f9b08151024
tree2b1719f37fbd6fd6273de3f26c1d41bc1ab55078
parent5851b8aca4a13e1a5c6db644a93c20b779e0e04d
signature Signed by PGP key B5690EEEBB952194

Fix Linux desktop Codex CLI detection at startup (#1100)

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

6 files changed, 127 insertions(+), 14 deletions(-)

apps/desktop/src/syncShellEnvironment.test.ts+24-4
...@@ -62,14 +62,13 @@ describe("syncShellEnvironment", () => {...@@ -62,14 +62,13 @@ describe("syncShellEnvironment", () => {
62 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");62 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
63 });63 });
6464
65 it("does nothing outside macOS", () => {65 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => {
66 const env: NodeJS.ProcessEnv = {66 const env: NodeJS.ProcessEnv = {
67 SHELL: "/bin/zsh",67 SHELL: "/bin/zsh",
68 PATH: "/usr/bin",68 PATH: "/usr/bin",
69 SSH_AUTH_SOCK: "/tmp/inherited.sock",
70 };69 };
71 const readEnvironment = vi.fn(() => ({70 const readEnvironment = vi.fn(() => ({
72 PATH: "/opt/homebrew/bin:/usr/bin",71 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
73 SSH_AUTH_SOCK: "/tmp/secretive.sock",72 SSH_AUTH_SOCK: "/tmp/secretive.sock",
74 }));73 }));
7574
...@@ -78,8 +77,29 @@ describe("syncShellEnvironment", () => {...@@ -78,8 +77,29 @@ describe("syncShellEnvironment", () => {
78 readEnvironment,77 readEnvironment,
79 });78 });
8079
80 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
81 expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
82 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
83 });
84
85 it("does nothing outside macOS and linux", () => {
86 const env: NodeJS.ProcessEnv = {
87 SHELL: "C:/Program Files/Git/bin/bash.exe",
88 PATH: "C:\\Windows\\System32",
89 SSH_AUTH_SOCK: "/tmp/inherited.sock",
90 };
91 const readEnvironment = vi.fn(() => ({
92 PATH: "/usr/local/bin:/usr/bin",
93 SSH_AUTH_SOCK: "/tmp/secretive.sock",
94 }));
95
96 syncShellEnvironment(env, {
97 platform: "win32",
98 readEnvironment,
99 });
100
81 expect(readEnvironment).not.toHaveBeenCalled();101 expect(readEnvironment).not.toHaveBeenCalled();
82 expect(env.PATH).toBe("/usr/bin");102 expect(env.PATH).toBe("C:\\Windows\\System32");
83 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");103 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
84 });104 });
85});105});
apps/desktop/src/syncShellEnvironment.ts+10-3
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell";1import {
2 readEnvironmentFromLoginShell,
3 resolveLoginShell,
4 ShellEnvironmentReader,
5} from "@t3tools/shared/shell";
26
3export function syncShellEnvironment(7export function syncShellEnvironment(
4 env: NodeJS.ProcessEnv = process.env,8 env: NodeJS.ProcessEnv = process.env,
...@@ -7,10 +11,13 @@ export function syncShellEnvironment(...@@ -7,10 +11,13 @@ export function syncShellEnvironment(
7 readEnvironment?: ShellEnvironmentReader;11 readEnvironment?: ShellEnvironmentReader;
8 } = {},12 } = {},
9): void {13): void {
10 if ((options.platform ?? process.platform) !== "darwin") return;14 const platform = options.platform ?? process.platform;
15 if (platform !== "darwin" && platform !== "linux") return;
1116
12 try {17 try {
13 const shell = env.SHELL ?? "/bin/zsh";18 const shell = resolveLoginShell(platform, env.SHELL);
19 if (!shell) return;
20
14 const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [21 const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
15 "PATH",22 "PATH",
16 "SSH_AUTH_SOCK",23 "SSH_AUTH_SOCK",
apps/server/src/main.test.ts+18-1
...@@ -21,6 +21,7 @@ import { ServerSettingsService } from "./serverSettings";...@@ -21,6 +21,7 @@ import { ServerSettingsService } from "./serverSettings";
2121
22const start = vi.fn(() => undefined);22const start = vi.fn(() => undefined);
23const stop = vi.fn(() => undefined);23const stop = vi.fn(() => undefined);
24const fixPath = vi.fn(() => undefined);
24let resolvedConfig: ServerConfigShape | null = null;25let resolvedConfig: ServerConfigShape | null = null;
25const serverStart = Effect.acquireRelease(26const serverStart = Effect.acquireRelease(
26 Effect.gen(function* () {27 Effect.gen(function* () {
...@@ -36,7 +37,7 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)...@@ -36,7 +37,7 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)
36const testLayer = Layer.mergeAll(37const testLayer = Layer.mergeAll(
37 Layer.succeed(CliConfig, {38 Layer.succeed(CliConfig, {
38 cwd: "/tmp/t3-test-workspace",39 cwd: "/tmp/t3-test-workspace",
39 fixPath: Effect.void,40 fixPath: Effect.sync(fixPath),
40 resolveStaticDir: Effect.undefined,41 resolveStaticDir: Effect.undefined,
41 } satisfies CliConfigShape),42 } satisfies CliConfigShape),
42 Layer.succeed(NetService, {43 Layer.succeed(NetService, {
...@@ -81,6 +82,7 @@ beforeEach(() => {...@@ -81,6 +82,7 @@ beforeEach(() => {
81 resolvedConfig = null;82 resolvedConfig = null;
82 start.mockImplementation(() => undefined);83 start.mockImplementation(() => undefined);
83 stop.mockImplementation(() => undefined);84 stop.mockImplementation(() => undefined);
85 fixPath.mockImplementation(() => undefined);
84 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));86 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
85});87});
8688
...@@ -329,6 +331,21 @@ it.layer(testLayer)("server CLI command", (it) => {...@@ -329,6 +331,21 @@ it.layer(testLayer)("server CLI command", (it) => {
329 }),331 }),
330 );332 );
331333
334 it.effect("hydrates PATH before server startup", () =>
335 Effect.gen(function* () {
336 yield* runCli([]);
337
338 assert.equal(fixPath.mock.calls.length, 1);
339 assert.equal(start.mock.calls.length, 1);
340 const fixPathOrder = fixPath.mock.invocationCallOrder[0];
341 const startOrder = start.mock.invocationCallOrder[0];
342 if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") {
343 assert.fail("Expected fixPath and start to be called");
344 }
345 assert.isTrue(fixPathOrder < startOrder);
346 }),
347 );
348
332 it.effect("records a startup heartbeat with thread/project counts", () =>349 it.effect("records a startup heartbeat with thread/project counts", () =>
333 Effect.gen(function* () {350 Effect.gen(function* () {
334 const recordTelemetry = vi.fn(351 const recordTelemetry = vi.fn(
apps/server/src/os-jank.test.ts created+39
...@@ -0,0 +1,39 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { fixPath } from "./os-jank";
4
5describe("fixPath", () => {
6 it("hydrates PATH on linux using the resolved login shell", () => {
7 const env: NodeJS.ProcessEnv = {
8 SHELL: "/bin/zsh",
9 PATH: "/usr/bin",
10 };
11 const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
12
13 fixPath({
14 env,
15 platform: "linux",
16 readPath,
17 });
18
19 expect(readPath).toHaveBeenCalledWith("/bin/zsh");
20 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
21 });
22
23 it("does nothing outside macOS and linux even when SHELL is set", () => {
24 const env: NodeJS.ProcessEnv = {
25 SHELL: "C:/Program Files/Git/bin/bash.exe",
26 PATH: "C:\\Windows\\System32",
27 };
28 const readPath = vi.fn(() => "/usr/local/bin:/usr/bin");
29
30 fixPath({
31 env,
32 platform: "win32",
33 readPath,
34 });
35
36 expect(readPath).not.toHaveBeenCalled();
37 expect(env.PATH).toBe("C:\\Windows\\System32");
38 });
39});
apps/server/src/os-jank.ts+16-6
...@@ -1,15 +1,25 @@...@@ -1,15 +1,25 @@
1import * as OS from "node:os";1import * as OS from "node:os";
2import { Effect, Path } from "effect";2import { Effect, Path } from "effect";
3import { readPathFromLoginShell } from "@t3tools/shared/shell";3import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell";
44
5export function fixPath(): void {5export function fixPath(
6 if (process.platform !== "darwin") return;6 options: {
7 env?: NodeJS.ProcessEnv;
8 platform?: NodeJS.Platform;
9 readPath?: typeof readPathFromLoginShell;
10 } = {},
11): void {
12 const platform = options.platform ?? process.platform;
13 if (platform !== "darwin" && platform !== "linux") return;
14
15 const env = options.env ?? process.env;
716
8 try {17 try {
9 const shell = process.env.SHELL ?? "/bin/zsh";18 const shell = resolveLoginShell(platform, env.SHELL);
10 const result = readPathFromLoginShell(shell);19 if (!shell) return;
20 const result = (options.readPath ?? readPathFromLoginShell)(shell);
11 if (result) {21 if (result) {
12 process.env.PATH = result;22 env.PATH = result;
13 }23 }
14 } catch {24 } catch {
15 // Silently ignore — keep default PATH25 // Silently ignore — keep default PATH
packages/shared/src/shell.ts+20
...@@ -10,6 +10,26 @@ type ExecFileSyncLike = (...@@ -10,6 +10,26 @@ type ExecFileSyncLike = (
10 options: { encoding: "utf8"; timeout: number },10 options: { encoding: "utf8"; timeout: number },
11) => string;11) => string;
1212
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 }
21
22 if (platform === "darwin") {
23 return "/bin/zsh";
24 }
25
26 if (platform === "linux") {
27 return "/bin/bash";
28 }
29
30 return undefined;
31}
32
13export function extractPathFromShellOutput(output: string): string | null {33export function extractPathFromShellOutput(output: string): string | null {
14 const startIndex = output.indexOf(PATH_CAPTURE_START);34 const startIndex = output.indexOf(PATH_CAPTURE_START);
15 if (startIndex === -1) return null;35 if (startIndex === -1) return null;