Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
57 changes: 57 additions & 0 deletions src/apps/shell/src/runtime/pty-manager.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/apps/shell/src/runtime/shell-config.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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
});
});
5 changes: 5 additions & 0 deletions src/apps/shell/src/runtime/shell-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}): Record<string, string> {
if (process.platform === 'win32') {
const env: Record<string, string> = {};
Expand Down
Loading