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 = {};