Skip to content

Commit fa574ef

Browse files
ibetitsmikeethanndickson
authored andcommitted
🤖 fix: spawn local PTY terminals on Windows (#1622)
This fixes embedded terminal creation on Windows failing with: - `Failed to spawn Local terminal: File not found:` Root cause was `PTYService` using `process.env.SHELL ?? "/bin/bash"` for *local* PTY sessions. On Windows this can be unset, invalid, or even present-but-empty (`SHELL=""`), which results in node-pty attempting to spawn an empty executable. ### What changed - Added `resolveLocalPtyShell()` to pick a sane default shell per platform. - Windows: prefer Git Bash (if available), else `pwsh` → `powershell` → `COMSPEC`/`cmd.exe` - Non-Windows: prefer non-empty `SHELL`, else `/bin/zsh` (macOS) or `/bin/bash` - `PTYService` local sessions now use the resolver and include args (e.g. `--login -i` for Git Bash). - Improved PTY spawn failures to include the attempted command/args/cwd/platform (and preserve the original error as `cause`). ### Validation - `bun test src/node/utils/main/resolveLocalPtyShell.test.ts` - `make static-check` --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh` • Cost: `$2.00`_
1 parent 182c8f3 commit fa574ef

File tree

3 files changed

+195
-10
lines changed

3 files changed

+195
-10
lines changed

src/node/services/ptyService.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { DockerRuntime } from "@/node/runtime/DockerRuntime";
2121
import { access } from "fs/promises";
2222
import { constants } from "fs";
2323
import { getControlPath, sshConnectionPool } from "@/node/runtime/sshConnectionPool";
24+
import { resolveLocalPtyShell } from "@/node/utils/main/resolveLocalPtyShell";
2425
import { expandTildeForSSH } from "@/node/runtime/tildeExpansion";
2526

2627
interface SessionData {
@@ -180,12 +181,19 @@ export class PTYService {
180181
} catch {
181182
throw new Error(`Workspace path does not exist: ${workspacePath}`);
182183
}
183-
const shell = process.env.SHELL ?? "/bin/bash";
184-
spawnConfig = { command: shell, args: [], cwd: workspacePath };
184+
const shell = resolveLocalPtyShell();
185+
spawnConfig = { command: shell.command, args: shell.args, cwd: workspacePath };
186+
187+
if (!spawnConfig.command.trim()) {
188+
throw new Error("Cannot spawn Local terminal: empty shell command");
189+
}
190+
191+
const printableArgs = spawnConfig.args.length > 0 ? ` ${spawnConfig.args.join(" ")}` : "";
185192
log.info(
186-
`Spawning PTY with shell: ${shell}, cwd: ${workspacePath}, size: ${params.cols}x${params.rows}`
193+
`Spawning PTY: ${spawnConfig.command}${printableArgs}, cwd: ${workspacePath}, size: ${params.cols}x${params.rows}`
187194
);
188-
log.debug(`PATH env: ${process.env.PATH ?? "undefined"}`);
195+
log.debug(`process.env.SHELL: ${process.env.SHELL ?? "undefined"}`);
196+
log.debug(`process.env.PATH: ${process.env.PATH ?? process.env.Path ?? "undefined"}`);
189197
} else if (runtime instanceof SSHRuntime) {
190198
const sshConfig = runtime.getConfig();
191199
// Ensure connection is healthy before spawning terminal
@@ -217,6 +225,11 @@ export class PTYService {
217225
// Load node-pty and spawn process
218226
// Local prefers node-pty (Electron rebuild), SSH/Docker prefer @lydell/node-pty (prebuilds)
219227
const isLocal = runtime instanceof LocalBaseRuntime;
228+
229+
const pathEnv =
230+
process.env.PATH ??
231+
process.env.Path ??
232+
(process.platform === "win32" ? undefined : "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin");
220233
const pty = loadNodePty(runtimeType, isLocal);
221234
let ptyProcess: IPty;
222235
try {
@@ -228,19 +241,30 @@ export class PTYService {
228241
env: {
229242
...process.env,
230243
TERM: "xterm-256color",
231-
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
244+
...(pathEnv ? { PATH: pathEnv } : {}),
232245
},
233246
});
234247
} catch (err) {
235248
log.error(`[PTY] Failed to spawn ${runtimeType} terminal ${sessionId}:`, err);
249+
250+
const printableArgs = spawnConfig.args.length > 0 ? ` ${spawnConfig.args.join(" ")}` : "";
251+
const cmd = `${spawnConfig.command}${printableArgs}`;
252+
const details = `cmd="${cmd}", cwd="${spawnConfig.cwd}", platform="${process.platform}"`;
253+
const errMessage = err instanceof Error ? err.message : String(err);
254+
236255
if (isLocal) {
237-
log.error(`Shell: ${spawnConfig.command}, CWD: ${spawnConfig.cwd}`);
256+
log.error(`Local PTY spawn config: ${cmd} (cwd: ${spawnConfig.cwd})`);
238257
log.error(`process.env.SHELL: ${process.env.SHELL ?? "undefined"}`);
239-
log.error(`process.env.PATH: ${process.env.PATH ?? "undefined"}`);
258+
log.error(`process.env.PATH: ${process.env.PATH ?? process.env.Path ?? "undefined"}`);
240259
}
241-
throw new Error(
242-
`Failed to spawn ${runtimeType} terminal: ${err instanceof Error ? err.message : String(err)}`
243-
);
260+
261+
if (err instanceof Error) {
262+
throw new Error(`Failed to spawn ${runtimeType} terminal (${details}): ${errMessage}`, {
263+
cause: err,
264+
});
265+
}
266+
267+
throw new Error(`Failed to spawn ${runtimeType} terminal (${details}): ${errMessage}`);
244268
}
245269

246270
// Wire up handlers
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { resolveLocalPtyShell } from "./resolveLocalPtyShell";
2+
3+
describe("resolveLocalPtyShell", () => {
4+
it("uses SHELL when it is set and non-empty", () => {
5+
const result = resolveLocalPtyShell({
6+
platform: "linux",
7+
env: { SHELL: " /usr/bin/fish " },
8+
isCommandAvailable: () => {
9+
throw new Error("isCommandAvailable should not be called");
10+
},
11+
getBashPath: () => {
12+
throw new Error("getBashPath should not be called");
13+
},
14+
});
15+
16+
expect(result).toEqual({ command: "/usr/bin/fish", args: [] });
17+
});
18+
19+
it("on Windows, treats empty SHELL as unset and prefers Git Bash", () => {
20+
const result = resolveLocalPtyShell({
21+
platform: "win32",
22+
env: { SHELL: "" },
23+
isCommandAvailable: () => false,
24+
getBashPath: () => "C:\\Program Files\\Git\\bin\\bash.exe",
25+
});
26+
27+
expect(result).toEqual({
28+
command: "C:\\Program Files\\Git\\bin\\bash.exe",
29+
args: ["--login", "-i"],
30+
});
31+
});
32+
33+
it("on Windows, falls back to pwsh when Git Bash is unavailable", () => {
34+
const result = resolveLocalPtyShell({
35+
platform: "win32",
36+
env: { SHELL: "" },
37+
isCommandAvailable: (command) => command === "pwsh",
38+
getBashPath: () => {
39+
throw new Error("Git Bash not installed");
40+
},
41+
});
42+
43+
expect(result).toEqual({ command: "pwsh", args: [] });
44+
});
45+
46+
it("on Windows, falls back to COMSPEC/cmd.exe when no other shells are available", () => {
47+
const result = resolveLocalPtyShell({
48+
platform: "win32",
49+
env: { SHELL: " ", COMSPEC: "C:\\Windows\\System32\\cmd.exe" },
50+
isCommandAvailable: () => false,
51+
getBashPath: () => {
52+
throw new Error("Git Bash not installed");
53+
},
54+
});
55+
56+
expect(result).toEqual({ command: "C:\\Windows\\System32\\cmd.exe", args: [] });
57+
});
58+
59+
it("on Linux, falls back to /bin/bash when SHELL is unset", () => {
60+
const result = resolveLocalPtyShell({
61+
platform: "linux",
62+
env: {},
63+
isCommandAvailable: () => false,
64+
getBashPath: () => "bash",
65+
});
66+
67+
expect(result).toEqual({ command: "/bin/bash", args: [] });
68+
});
69+
70+
it("on macOS, falls back to /bin/zsh when SHELL is unset", () => {
71+
const result = resolveLocalPtyShell({
72+
platform: "darwin",
73+
env: {},
74+
isCommandAvailable: () => false,
75+
getBashPath: () => "bash",
76+
});
77+
78+
expect(result).toEqual({ command: "/bin/zsh", args: [] });
79+
});
80+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { spawnSync } from "child_process";
2+
3+
import { getBashPath } from "@/node/utils/main/bashPath";
4+
5+
export interface ResolvedPtyShell {
6+
command: string;
7+
args: string[];
8+
}
9+
10+
export interface ResolveLocalPtyShellParams {
11+
platform: NodeJS.Platform;
12+
env: NodeJS.ProcessEnv;
13+
isCommandAvailable: (command: string) => boolean;
14+
getBashPath: () => string;
15+
}
16+
17+
function defaultIsCommandAvailable(platform: NodeJS.Platform): (command: string) => boolean {
18+
return (command: string) => {
19+
if (!command) return false;
20+
21+
try {
22+
const result = spawnSync(platform === "win32" ? "where" : "which", [command], {
23+
stdio: "ignore",
24+
});
25+
return result.status === 0;
26+
} catch {
27+
return false;
28+
}
29+
};
30+
}
31+
32+
/**
33+
* Resolve the best shell to use for a *local* PTY session.
34+
*
35+
* We keep this as a small, mostly-pure helper so it can be unit-tested without
36+
* mutating `process.platform` / `process.env`.
37+
*/
38+
export function resolveLocalPtyShell(
39+
params: Partial<ResolveLocalPtyShellParams> = {}
40+
): ResolvedPtyShell {
41+
const platform = params.platform ?? process.platform;
42+
const env = params.env ?? process.env;
43+
const isCommandAvailable = params.isCommandAvailable ?? defaultIsCommandAvailable(platform);
44+
const getBashPathFn = params.getBashPath ?? getBashPath;
45+
46+
// `process.env.SHELL` can be present-but-empty (""), especially in packaged apps.
47+
// Treat empty/whitespace as "unset".
48+
const envShell = env.SHELL?.trim();
49+
if (envShell) {
50+
return { command: envShell, args: [] };
51+
}
52+
53+
if (platform === "win32") {
54+
// Prefer Git Bash when available (works well with repo tooling).
55+
try {
56+
const bashPath = getBashPathFn().trim();
57+
if (bashPath) {
58+
return { command: bashPath, args: ["--login", "-i"] };
59+
}
60+
} catch {
61+
// Git Bash not available; fall back to PowerShell / cmd.
62+
}
63+
64+
if (isCommandAvailable("pwsh")) {
65+
return { command: "pwsh", args: [] };
66+
}
67+
68+
if (isCommandAvailable("powershell")) {
69+
return { command: "powershell", args: [] };
70+
}
71+
72+
const comspec = env.COMSPEC?.trim();
73+
return { command: comspec && comspec.length > 0 ? comspec : "cmd.exe", args: [] };
74+
}
75+
76+
if (platform === "darwin") {
77+
return { command: "/bin/zsh", args: [] };
78+
}
79+
80+
return { command: "/bin/bash", args: [] };
81+
}

0 commit comments

Comments
 (0)