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", () => {
6262 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
6363 });
6464
65 it("does nothing outside macOS", () => {
65 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => {
6666 const env: NodeJS.ProcessEnv = {
6767 SHELL: "/bin/zsh",
6868 PATH: "/usr/bin",
69 SSH_AUTH_SOCK: "/tmp/inherited.sock",
7069 };
7170 const readEnvironment = vi.fn(() => ({
72 PATH: "/opt/homebrew/bin:/usr/bin",
71 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
7372 SSH_AUTH_SOCK: "/tmp/secretive.sock",
7473 }));
7574
......@@ -78,8 +77,29 @@ describe("syncShellEnvironment", () => {
7877 readEnvironment,
7978 });
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
81101 expect(readEnvironment).not.toHaveBeenCalled();
82 expect(env.PATH).toBe("/usr/bin");
102 expect(env.PATH).toBe("C:\\Windows\\System32");
83103 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
84104 });
85105});
apps/desktop/src/syncShellEnvironment.ts+10-3
......@@ -1,4 +1,8 @@
1import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell";
1import {
2 readEnvironmentFromLoginShell,
3 resolveLoginShell,
4 ShellEnvironmentReader,
5} from "@t3tools/shared/shell";
26
37export function syncShellEnvironment(
48 env: NodeJS.ProcessEnv = process.env,
......@@ -7,10 +11,13 @@ export function syncShellEnvironment(
711 readEnvironment?: ShellEnvironmentReader;
812 } = {},
913): void {
10 if ((options.platform ?? process.platform) !== "darwin") return;
14 const platform = options.platform ?? process.platform;
15 if (platform !== "darwin" && platform !== "linux") return;
1116
1217 try {
13 const shell = env.SHELL ?? "/bin/zsh";
18 const shell = resolveLoginShell(platform, env.SHELL);
19 if (!shell) return;
20
1421 const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
1522 "PATH",
1623 "SSH_AUTH_SOCK",
apps/server/src/main.test.ts+18-1
......@@ -21,6 +21,7 @@ import { ServerSettingsService } from "./serverSettings";
2121
2222const start = vi.fn(() => undefined);
2323const stop = vi.fn(() => undefined);
24const fixPath = vi.fn(() => undefined);
2425let resolvedConfig: ServerConfigShape | null = null;
2526const serverStart = Effect.acquireRelease(
2627 Effect.gen(function* () {
......@@ -36,7 +37,7 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)
3637const testLayer = Layer.mergeAll(
3738 Layer.succeed(CliConfig, {
3839 cwd: "/tmp/t3-test-workspace",
39 fixPath: Effect.void,
40 fixPath: Effect.sync(fixPath),
4041 resolveStaticDir: Effect.undefined,
4142 } satisfies CliConfigShape),
4243 Layer.succeed(NetService, {
......@@ -81,6 +82,7 @@ beforeEach(() => {
8182 resolvedConfig = null;
8283 start.mockImplementation(() => undefined);
8384 stop.mockImplementation(() => undefined);
85 fixPath.mockImplementation(() => undefined);
8486 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
8587});
8688
......@@ -329,6 +331,21 @@ it.layer(testLayer)("server CLI command", (it) => {
329331 }),
330332 );
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
332349 it.effect("records a startup heartbeat with thread/project counts", () =>
333350 Effect.gen(function* () {
334351 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 @@
11import * as OS from "node:os";
22import { Effect, Path } from "effect";
3import { readPathFromLoginShell } from "@t3tools/shared/shell";
3import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell";
44
5export function fixPath(): void {
6 if (process.platform !== "darwin") return;
5export function fixPath(
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
817 try {
9 const shell = process.env.SHELL ?? "/bin/zsh";
10 const result = readPathFromLoginShell(shell);
18 const shell = resolveLoginShell(platform, env.SHELL);
19 if (!shell) return;
20 const result = (options.readPath ?? readPathFromLoginShell)(shell);
1121 if (result) {
12 process.env.PATH = result;
22 env.PATH = result;
1323 }
1424 } catch {
1525 // Silently ignore — keep default PATH
packages/shared/src/shell.ts+20
......@@ -10,6 +10,26 @@ type ExecFileSyncLike = (
1010 options: { encoding: "utf8"; timeout: number },
1111) => 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
1333export function extractPathFromShellOutput(output: string): string | null {
1434 const startIndex = output.indexOf(PATH_CAPTURE_START);
1535 if (startIndex === -1) return null;