Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions apps/desktop/src/fixPath.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { readPathFromLoginShell } from "@t3tools/shared/shell";
import { defaultShellCandidates, resolvePathFromLoginShells } from "@t3tools/shared/shell";

export function fixPath(): void {
if (process.platform !== "darwin") return;
if (process.platform !== "darwin" && process.platform !== "linux") 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.
const result = resolvePathFromLoginShells(defaultShellCandidates());
if (result) {
process.env.PATH = result;
}
Comment on lines 7 to 13
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs a synchronous PATH repair on Linux as well as macOS; with the current 5s timeout and two invocation modes per shell, Electron main startup can block for up to ~30s in the worst case. Consider reducing the timeout and/or short-circuiting when PATH is already populated enough to resolve required binaries.

Copilot uses AI. Check for mistakes.
}
17 changes: 7 additions & 10 deletions apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import * as OS from "node:os";
import { Effect, Path } from "effect";
import { readPathFromLoginShell } from "@t3tools/shared/shell";
import { defaultShellCandidates, resolvePathFromLoginShells } from "@t3tools/shared/shell";

export function fixPath(): void {
if (process.platform !== "darwin") return;
if (process.platform !== "darwin" && process.platform !== "linux") return;

try {
const shell = process.env.SHELL ?? "/bin/zsh";
const result = readPathFromLoginShell(shell);
if (result) {
process.env.PATH = result;
}
} catch {
// Silently ignore — keep default PATH
const shells = defaultShellCandidates();

const resolvedPath = resolvePathFromLoginShells(shells);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defaultShellCandidates() includes process.env.SHELL, which means on Linux this server startup path repair may execute whatever binary is pointed to by SHELL. If this code can run in environments where SHELL is not trusted/controlled (e.g., service managers, container entrypoints), consider ignoring process.env.SHELL here or validating/allowlisting shell paths before executing them.

Suggested change
const resolvedPath = resolvePathFromLoginShells(shells);
const allowedShells = new Set<string>([
"/bin/bash",
"/usr/bin/bash",
"/bin/zsh",
"/usr/bin/zsh",
"/bin/sh",
"/usr/bin/sh",
"/bin/dash",
"/usr/bin/dash",
"/bin/fish",
"/usr/bin/fish",
]);
const filteredShells = shells.filter((shell) => allowedShells.has(shell));
if (filteredShells.length === 0) return;
const resolvedPath = resolvePathFromLoginShells(filteredShells);

Copilot uses AI. Check for mistakes.
if (resolvedPath) {
process.env.PATH = resolvedPath;
}
Comment on lines 9 to 15
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On darwin/linux this runs a synchronous PATH repair that can block startup for up to timeout * argModes * shellCandidates (currently 5000ms * 2 * up to 3 = 30s on macOS; 20s on Linux). If the PR goal is to cap worst-case startup cost, consider lowering the per-invocation timeout and/or adding an overall deadline / gating the repair to cases where PATH looks incomplete.

Copilot uses AI. Check for mistakes.
}

Expand Down
114 changes: 113 additions & 1 deletion packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vitest";

import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell";
import {
defaultShellCandidates,
extractPathFromShellOutput,
readPathFromLoginShell,
resolvePathFromLoginShells,
} from "./shell";

describe("extractPathFromShellOutput", () => {
it("extracts the path between capture markers", () => {
Expand Down Expand Up @@ -54,4 +59,111 @@ describe("readPathFromLoginShell", () => {
expect(args?.[1]).toContain("__T3CODE_PATH_END__");
expect(options).toEqual({ encoding: "utf8", timeout: 5000 });
});

it("falls back to non-interactive login mode when interactive login fails", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>((_, args) => {
if (args[0] === "-ilc") {
throw new Error("interactive login unsupported");
}
return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n";
});

expect(readPathFromLoginShell("/bin/sh", execFile)).toBe("/a:/b");
expect(execFile).toHaveBeenCalledTimes(2);
expect(execFile.mock.calls[0]?.[1]?.[0]).toBe("-ilc");
expect(execFile.mock.calls[1]?.[1]?.[0]).toBe("-lc");
});

describe("resolvePathFromLoginShells", () => {
it("returns the first resolved PATH from the provided shells", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>((file) => {
if (file === "/bin/zsh") {
throw new Error("zsh unavailable");
}
return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n";
});

const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile);
expect(result).toBe("/a:/b");
expect(execFile).toHaveBeenCalledTimes(2);
});
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolvePathFromLoginShells test undercounts how many execFile calls occur. readPathFromLoginShell now tries both -ilc and -lc per shell candidate, so a shell that always throws (like /bin/zsh in this mock) will be invoked twice before moving on. Update the expectation (or the mock) so the call count matches the two-mode fallback behavior.

Copilot uses AI. Check for mistakes.

it("returns undefined when all shells fail to resolve PATH", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>(() => {
throw new Error("no shells available");
});

const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile);
expect(result).toBeUndefined();
expect(execFile).toHaveBeenCalledTimes(2);
});
});
});

describe("defaultShellCandidates", () => {
it("limits Linux candidates to the configured shell and POSIX fallback", () => {
const originalShell = process.env.SHELL;
process.env.SHELL = "/bin/bash";

try {
expect(defaultShellCandidates("linux")).toEqual(["/bin/bash", "/bin/sh"]);
} finally {
process.env.SHELL = originalShell;
}
});

it("dedupes repeated Linux shell candidates", () => {
const originalShell = process.env.SHELL;
process.env.SHELL = "/bin/sh";

try {
expect(defaultShellCandidates("linux")).toEqual(["/bin/sh"]);
} finally {
process.env.SHELL = originalShell;
}
});

it("limits macOS candidates to a small bounded fallback set", () => {
const originalShell = process.env.SHELL;
process.env.SHELL = "/opt/homebrew/bin/fish";

try {
expect(defaultShellCandidates("darwin")).toEqual([
"/opt/homebrew/bin/fish",
"/bin/zsh",
"/bin/bash",
]);
} finally {
process.env.SHELL = originalShell;
}
});

it("dedupes repeated macOS shell candidates", () => {
const originalShell = process.env.SHELL;
process.env.SHELL = "/bin/zsh";

try {
expect(defaultShellCandidates("darwin")).toEqual(["/bin/zsh", "/bin/bash"]);
} finally {
process.env.SHELL = originalShell;
}
});
});
86 changes: 81 additions & 5 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ type ExecFileSyncLike = (
options: { encoding: "utf8"; timeout: number },
) => string;

const LOGIN_SHELL_ARG_SETS = [
["-ilc", PATH_CAPTURE_COMMAND],
["-lc", PATH_CAPTURE_COMMAND],
] as const;

export function extractPathFromShellOutput(output: string): string | null {
const startIndex = output.indexOf(PATH_CAPTURE_START);
if (startIndex === -1) return null;
Expand All @@ -30,9 +35,80 @@ export function readPathFromLoginShell(
shell: string,
execFile: ExecFileSyncLike = execFileSync,
): string | undefined {
const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], {
encoding: "utf8",
timeout: 5000,
});
return extractPathFromShellOutput(output) ?? undefined;
for (const args of LOGIN_SHELL_ARG_SETS) {
try {
const output = execFile(shell, args, {
encoding: "utf8",
timeout: 5000,
});
const resolvedPath = extractPathFromShellOutput(output) ?? undefined;
if (resolvedPath) {
return resolvedPath;
}
} catch {
// Try the next shell invocation mode.
}
}

return undefined;
}
Comment on lines 38 to +64
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Carry the 2s budget into each shell attempt.

resolvePathFromLoginShells() only checks deadline before starting a shell. Once inside readPathFromLoginShell(), that shell can still spend both 750 ms fallbacks, so a probe that starts just before the cutoff can add another ~1.5 s. Because fixPath() is called during startup in apps/desktop/src/main.ts, Line 47, and apps/server/src/main.ts, Line 80, this still shows up as launch latency.

⏱️ One compatible fix
 export function readPathFromLoginShell(
   shell: string,
   execFile: ExecFileSyncLike = execFileSync,
   onError?: LoginShellErrorReporter,
+  deadline = Number.POSITIVE_INFINITY,
 ): string | undefined {
   let lastError: unknown;
   for (const args of LOGIN_SHELL_ARG_SETS) {
+    const remainingMs = deadline - Date.now();
+    if (remainingMs <= 0) break;
+
     try {
       const output = execFile(shell, args, {
         encoding: "utf8",
-        timeout: LOGIN_SHELL_TIMEOUT_MS,
+        timeout: Math.min(LOGIN_SHELL_TIMEOUT_MS, remainingMs),
       });
       const resolvedPath = extractPathFromShellOutput(output) ?? undefined;
       if (resolvedPath) {
         return resolvedPath;
       }
@@
     try {
-      const result = readPathFromLoginShell(shell, execFile, (_failedShell, _args, error) => {
-        onError?.(shell, error);
-      });
+      const result = readPathFromLoginShell(
+        shell,
+        execFile,
+        (_failedShell, _args, error) => {
+          onError?.(shell, error);
+        },
+        deadline,
+      );
       if (result) {
         return result;
       }

Also applies to: 105-123

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/shell.ts` around lines 38 - 64, readPathFromLoginShell
currently ignores the overall deadline and always uses LOGIN_SHELL_TIMEOUT_MS
for each exec, so a shell started late can exceed the remaining time budget;
modify readPathFromLoginShell (and the similar block at 105-123) to accept a
deadline (or remainingMs) parameter and before each execFile call compute
remaining = deadline - Date.now(), return/stop if remaining <= 0, and set the
execFile timeout to Math.min(LOGIN_SHELL_TIMEOUT_MS, remaining) when calling
execFile(shell, args, { encoding: "utf8", timeout: effectiveTimeout }). Ensure
resolvePathFromLoginShells passes its deadline through to readPathFromLoginShell
so the global 2s budget is respected across attempts.


function uniqueShellCandidates(candidates: ReadonlyArray<string | undefined>): string[] {
const unique = new Set<string>();

for (const candidate of candidates) {
if (typeof candidate !== "string") continue;
const normalized = candidate.trim();
if (normalized.length === 0 || unique.has(normalized)) continue;
unique.add(normalized);
}

return [...unique];
}

export function defaultShellCandidates(platform = process.platform): string[] {
if (platform === "linux") {
return uniqueShellCandidates([process.env.SHELL, "/bin/sh"]);
}

if (platform === "darwin") {
return uniqueShellCandidates([process.env.SHELL, "/bin/zsh", "/bin/bash"]);
}

return uniqueShellCandidates([
process.env.SHELL,
"/bin/zsh",
"/usr/bin/zsh",
"/bin/bash",
"/usr/bin/bash",
]);
}

type ShellPathResolveErrorReporter = (shell: string, error: unknown) => void;

const defaultShellPathErrorReporter: ShellPathResolveErrorReporter | undefined =
process.env.T3CODE_DEBUG_SHELL_PATH === "1"
? (shell, error) => {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[shell] PATH resolution failed for ${shell}: ${message}`);
}
: undefined;

export function resolvePathFromLoginShells(
shells: ReadonlyArray<string>,
execFile: ExecFileSyncLike = execFileSync,
onError: ShellPathResolveErrorReporter | undefined = defaultShellPathErrorReporter,
): string | undefined {
for (const shell of shells) {
try {
const result = readPathFromLoginShell(shell, execFile);
if (result) {
return result;
}
} catch (error) {
onError?.(shell, error);
// Try next shell candidate.
}
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolvePathFromLoginShells's onError hook (and the T3CODE_DEBUG_SHELL_PATH logging) is effectively unreachable for normal shell failures because readPathFromLoginShell swallows all execFile errors internally. If the intent is to report per-shell failures, either let errors bubble up from readPathFromLoginShell (after trying its arg fallbacks) or invoke the reporter from within readPathFromLoginShell when each mode fails.

Copilot uses AI. Check for mistakes.
return undefined;
}
Loading