fix(terminal): resolve 'posix_spawnp failed.' on macOS terminal creation - #20
Conversation
…fore spawn (macOS posix_spawnp fix)
node-pty 1.1.0's npm prebuilt tarball restores spawn-helper without its
execute bit (0644). posix_spawn of a non-executable file is exactly what
surfaces to users as 'posix_spawnp failed.' the first time they open the
terminal drawer.
TerminalService.ensureSpawnHelperExecutable now resolves every prebuilds/*
helper (including app.asar.unpacked), chmods only when the execute bit is
missing, and verifies afterward — throwing a path-bearing remediation error
instead of silently swallowing (the prior catch {}). Shell resolution now
verifies the candidate is executable with a fallback chain (/bin/zsh ->
/bin/zsh -> /bin/bash -> /bin/sh) so a stale SHELL can no longer break
terminal creation.
Packaged builds must never ship spawn-helper without its execute bit. The afterPack hook now walks app.asar.unpacked/.../node-pty/prebuilds/*, chmods each spawn-helper to 0755, and throws in CI if any is missing or still non-executable — so a broken package fails at build time rather than at the user's first terminal open.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Terminal shell resolution with fallback —
resolveShellchecks candidates for executability viaaccessSync(X_OK), falling back from$SHELLthrough/bin/zsh,/bin/bash, to/bin/sh, with a descriptive error when no candidate is viable. - Runtime spawn-helper chmod + verify —
defaultSpawnHelperPathsenumerates everyprebuilds/*/spawn-helper(includingapp.asar.unpackedmirrors),ensureHelperExecutablechmods to0755only when needed and verifies after — throwing descriptive, path-bearing errors instead of the old silentcatch {}. - Build-time afterPack hardening —
makeSpawnHelpersExecutablewalks the packagedapp.asar.unpackedtree in theafterPackhook, chmods everyspawn-helper, and fails the build if none is found or any remains non-executable. - Tests — 8 new tests (5 terminal-service, 3 fuse-script) covering shell fallback, shell rejection, chmod+verify, missing-helper no-op, already-executable no-op, and the three fuse-script scenarios.
DeepSeek Pro | 𝕏
…ation Adds TerminalHistoryStore: a debounced, line-capped, per-workspace log of terminal output rooted at <userData>/terminal-history. Output is sanitized before persisting so a replayed snapshot cannot trigger fresh shell replies — CSI cursor-position reports, device-attributes/status queries, DECRQM/PM, XTVERSION, Kitty keyboard, DCS DECRQSS/XTGETTCAP, and OSC color queries are stripped while benign SGR/cursor sequences survive. Partial sequences split across chunks are carried via a pending prefix. Ported from t3code's sanitizeTerminalHistoryChunk (Manager.ts:953).
Shell fallback: the spawn path now walks an executable candidate list ($SHELL -> /bin/zsh -> /bin/bash -> /bin/sh) and retries the next on a retryable failure (posix_spawnp failed, ENOENT, not found). A broken $SHELL self-heals instead of throwing. Non-retryable errors (EINVAL, out of fds) surface immediately. The session result gains resolvedShell and preferredShellSkipped so the renderer can tell the user which shell launched. History wiring: TerminalService now accepts an optional historyStore. On open the prior sanitized output seeds the buffer (the renderer re-hydrates xterm from snapshot, so no renderer change is needed for the seed); each PTY data event appends to the store; terminate/exit flush the final chunk.
…hell refactor The phase3 contract test asserts the terminal.ts source orders revalidate → abort-check → spawn → abort-check. The spawn call changed shape (single spawn → trySpawnShell destructure) in the shell-fallback PR; update the assertion to match while preserving the ordering invariant it protects.
…history feat(terminal): shell fallback retry + persisted sanitized history
There was a problem hiding this comment.
Important
Three new shell-retry tests fail on systems where /bin/zsh is not installed (did on this CI runner). The isExecutable filter at main/services/terminal.ts:250 runs a real accessSync(X_OK) before the spawn mock has a chance to throw, so tests that supply ["/bin/zsh", "/bin/sh"] as candidates silently test a different code path when /bin/zsh is absent on the host.
Reviewed changes
These commits since the prior Pullfrog review (350dea4) add shell retry-loop logic, a per-workspace terminal history store with escape-sequence sanitization, and the IPC/renderer surface for surfacing fallback-shell use to the user.
- Shell-candidate retry loop —
defaultShellCandidates()returns$SHELLthen/bin/zsh→/bin/bash→/bin/shwith dedup;trySpawnShell()tries each verified-executable candidate, retrying onposix_spawnp failed/ENOENTbut rethrowing non-retryable errors immediately. - Per-workspace history store with sanitization — New
terminal-history.tsmodule withTerminalHistoryStore(debounced disk writes),sanitizeTerminalHistoryChunk(strips CSI/DCS/OSC device queries from replayed output), andcapHistory(5000-line bound). - Fallback-shell surface —
TerminalSessionInfonow carriesresolvedShellandpreferredShellSkipped; the renderer shows a one-time toast when the preferred shell was unavailable. - Test registration — Terminal and terminal-history test files registered in
package.jsontestandtest:coveragescripts; subagent contract test updated for the refactored spawn line.
ℹ️ Nitpicks
TerminalHistoryStoreOptions.nowis declared but never used —main/services/terminal-history.ts:31: thenow?: () => numberfield is documented as a test seam but is never read in the constructor or anywhere in the implementation. Remove it or wire it to the schedule fallback so it isn't dead API surface.
DeepSeek Pro | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
These commits since the prior Pullfrog review (5718840) add the shellIsExecutable injectable test seam, remove dead code, and update tests for cross-host portability.
- Added
shellIsExecutabletest seam toTerminalServiceOptions—create()now resolvesthis.options.shellIsExecutable ?? isExecutableinstead of calling the module-levelisExecutabledirectly, so shell-retry tests control the executability filter alongside the spawn mock. - Removed dead
nowfield fromTerminalHistoryStoreOptions— the unusednow?: () => numberfield has been dropped. - Updated shell-retry tests for portability — four retry-loop tests now inject
shellIsExecutable: () => true, making them independent of the host's filesystem. - Adjusted subagent contract test — the source-reading assertion updated to match the refactored
const { pty,destructuring line.
DeepSeek Pro | 𝕏
3a66cc9
into
feature/playwright-e2e-lmstudio-attachments

Problem
Opening the terminal drawer fails with an opaque error on macOS:
Root cause (reproduced)
The error string
posix_spawnp failed.comes verbatim from node-pty's native C++ binding (src/unix/pty.cc), thrown whenposix_spawn()of node-pty's ownspawn-helperbinary returns non-zero. It does not indicate a bad shell.On macOS node-pty spawns
spawn-helperfirst, andspawn-helperthenexecvps the shell. node-pty 1.1.0's npm prebuilt tarball restoresspawn-helperwith mode0644(no execute bit), soposix_spawnof a non-executable file fails.Reproduced deterministically:
chmod 644 spawn-helper→posix_spawnp failed.chmod 755 spawn-helper→ spawn succeedsThe existing runtime fix (
ensureSpawnHelperExecutable)chmod\ed the helper but swallowed every failure silently (catch {}), so users got the opaque error with no clue why.Fix (two layers — works on any Mac)
Layer 1 — runtime guard (
main/services/terminal.ts):prebuilds/*helper (not just one guessed arch), including the packagedapp.asar.unpackedcopychmodonly when the execute bit is missing (never needlessly rewrites an already-good file)Run "chmod 755 <path>"…) instead of swallowing — the load-bearing change that makes this whole class of bug self-diagnosing$SHELL→/bin/zsh→/bin/bash→/bin/sh— so a stale$SHELLcan no longer break terminal creationLayer 2 — build-time hardening (
scripts/configure-electron-fuses.mjs, theafterPackhook):app.asar.unpacked/.../node-pty/prebuilds/*/spawn-helperin the packaged.app,chmodeach to0755Why this works on any Mac
.appships, regardless of how npm extracted the tarballprebuilds/*dirs$SHELLand falls back to system shellsVerification
chmod 644, ran the new logic →SPAWN OK)tsc --noEmitcleanFiles changed
main/services/terminal.ts— robust helper chmod+verify, async shell resolution with fallbackmain/services/terminal.test.ts— 4 new testsscripts/configure-electron-fuses.mjs—makeSpawnHelpersExecutableinafterPackscripts/configure-electron-fuses.test.mjs— 3 new testsWindows is unaffected (uses ConPTY, no
spawn-helper).