From 309a2032050b000bd3deeb200fd4111ea28983b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Kuty=C5=82a?= Date: Thu, 18 Jun 2026 12:52:44 +0200 Subject: [PATCH] fix(shell): repair node-pty spawn-helper perms on macOS (real posix_spawn fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent 'posix_spawnp failed' on macOS was misdiagnosed as a bare command-name issue. node-pty on macOS (#if __APPLE__) launches the shell via its build/Release/spawn-helper using posix_spawn(helper). err != 0 — surfaced as 'posix_spawnp failed.' — means the HELPER itself could not be spawned, i.e. it is missing, not executable, or quarantined. node-pty's post-install never chmods it, and pnpm's store materialization can drop the execute bit, so a fresh install throws on every pane/agent (Add LLM) launch. - ensureSpawnHelperExecutable(): darwin-only, run-once, best-effort guard invoked before the first spawn. chmod 0755 when the execute bit is missing, strip the com.apple.quarantine xattr, and log an actionable message when the helper binary is absent (arch mismatch -> rebuild node-pty). - lacksExecuteBit(): pure helper in shell-config (unit-tested). Note: posix_spawn() does not search PATH, but spawn-helper execvp()s the shell internally, so PATH lookup still happens in the child — the earlier absolute shell path / PATH hardening remains valid but was not the cause of this error. --- src/apps/shell/src/runtime/pty-manager.ts | 57 +++++++++++++++++++ .../shell/src/runtime/shell-config.test.ts | 17 +++++- src/apps/shell/src/runtime/shell-config.ts | 5 ++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/apps/shell/src/runtime/pty-manager.ts b/src/apps/shell/src/runtime/pty-manager.ts index a681dfe..45d5309 100644 --- a/src/apps/shell/src/runtime/pty-manager.ts +++ b/src/apps/shell/src/runtime/pty-manager.ts @@ -1,5 +1,9 @@ import pty, { type IPty } from 'node-pty'; import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { execFileSync } from 'child_process'; +import { lacksExecuteBit } from './shell-config.js'; import type { PtyEntry, PaneInfo, ShellEmitter } from '../shell-types.js'; import { globalPtys, @@ -9,6 +13,55 @@ import { } from './shell-state.js'; import { noteCursorReportRequests } from './cursor-report.js'; +let spawnHelperChecked = false; + +/** + * Ensure node-pty's `spawn-helper` is runnable on macOS. + * + * On macOS, node-pty's native code launches the shell via a small `spawn-helper` + * binary using posix_spawn(), which requires that file to exist AND be executable. + * The error surfaces (misleadingly) as "posix_spawnp failed." pnpm's content-addressable + * store materialization — and downloaded prebuilds — can drop the execute bit or leave a + * macOS quarantine xattr, so a freshly installed tree throws on every pane/agent launch. + * node-pty's own post-install does not chmod the helper, so we repair it here. + * + * Idempotent, darwin-only, best-effort — never throws (spawning must still proceed). + */ +export function ensureSpawnHelperExecutable(): void { + if (spawnHelperChecked) return; + spawnHelperChecked = true; + if (process.platform !== 'darwin') return; + + try { + const require_ = createRequire(import.meta.url); + const pkgJson = require_.resolve('node-pty/package.json'); + const helper = path.join(path.dirname(pkgJson), 'build', 'Release', 'spawn-helper'); + + if (!fs.existsSync(helper)) { + console.error( + `[shell] node-pty spawn-helper is missing at ${helper}. ` + + `This usually means node-pty was not built for this platform/arch — ` + + `run "pnpm rebuild node-pty" (or reinstall) on this machine.`, + ); + return; + } + + if (lacksExecuteBit(fs.statSync(helper).mode)) { + fs.chmodSync(helper, 0o755); + console.error(`[shell] restored execute permission on node-pty spawn-helper: ${helper}`); + } + + // Strip the macOS quarantine attribute if present; harmless when absent. + try { + execFileSync('xattr', ['-d', 'com.apple.quarantine', helper], { stdio: 'ignore' }); + } catch { + // not quarantined, or xattr unavailable — nothing to do + } + } catch (err) { + console.error('[shell] spawn-helper check failed:', (err as Error).message); + } +} + /** Send SIGHUP, then SIGKILL after 2 s if still alive. */ export function killPty(p: IPty): void { try { @@ -56,6 +109,10 @@ export function spawnGlobalPty( cols = Number.isInteger(cols) && cols >= 1 ? Math.min(cols, 500) : 80; rows = Number.isInteger(rows) && rows >= 1 ? Math.min(rows, 500) : 24; + // macOS: make sure node-pty's spawn-helper is executable before the first spawn, + // otherwise pty.spawn throws "posix_spawnp failed." (runs once, no-op elsewhere). + ensureSpawnHelperExecutable(); + const spawnOpts = { name: 'xterm-256color', cols, diff --git a/src/apps/shell/src/runtime/shell-config.test.ts b/src/apps/shell/src/runtime/shell-config.test.ts index 68005f2..1fdb936 100644 --- a/src/apps/shell/src/runtime/shell-config.test.ts +++ b/src/apps/shell/src/runtime/shell-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { pickUnixShell, withDefaultPath } from './shell-config.js'; +import { lacksExecuteBit, pickUnixShell, withDefaultPath } from './shell-config.js'; describe('pickUnixShell', () => { it('returns $SHELL when it exists', () => { @@ -44,3 +44,18 @@ describe('withDefaultPath', () => { expect(env.PATH).toBe('/custom/bin'); }); }); + +describe('lacksExecuteBit', () => { + it('is true when no execute bit is set (e.g. 0o644)', () => { + // Regression: a non-executable node-pty spawn-helper makes posix_spawn fail on macOS. + expect(lacksExecuteBit(0o644)).toBe(true); + expect(lacksExecuteBit(0o600)).toBe(true); + }); + + it('is false when any execute bit is set', () => { + expect(lacksExecuteBit(0o755)).toBe(false); // user+group+other + expect(lacksExecuteBit(0o744)).toBe(false); // user only + expect(lacksExecuteBit(0o711)).toBe(false); + expect(lacksExecuteBit(0o100)).toBe(false); // owner-exec only + }); +}); diff --git a/src/apps/shell/src/runtime/shell-config.ts b/src/apps/shell/src/runtime/shell-config.ts index 7f68083..2e82997 100644 --- a/src/apps/shell/src/runtime/shell-config.ts +++ b/src/apps/shell/src/runtime/shell-config.ts @@ -34,6 +34,11 @@ export function pickUnixShell(exists: (p: string) => boolean, shellEnv: string | return '/bin/sh'; } +/** True when a file mode has no execute bit set for any of user/group/other. */ +export function lacksExecuteBit(mode: number): boolean { + return (mode & 0o111) === 0; +} + export function safeEnv(extra: Record = {}): Record { if (process.platform === 'win32') { const env: Record = {};