hermes-agent/apps/desktop/electron/spawn-helper-perms.ts
Brooklyn Nicholson da805810d4 fix(desktop): restore exec bit on node-pty spawn-helper for dev terminals
node-pty's published npm tarball ships the POSIX `spawn-helper` with mode
0644 (no exec bit). node-pty `posix_spawnp`s that helper on macOS/Linux, so a
non-executable copy fails every embedded-terminal spawn with
`Error: posix_spawnp failed.`. Packaged builds are unaffected because
stage-native-deps.mjs chmods the staged copy, but the dev flow
(`npm run dev` -> `electron .`) resolves node-pty straight from node_modules,
which nothing chmods -- so the first terminal in dev always dies.

Restore the exec bit once, lazily, right before the first spawn, via a small
DI-testable helper. Idempotent: already-executable copies (packaged builds)
are left untouched, and stat/chmod failures are collected and logged rather
than thrown so terminal startup never breaks.
2026-07-18 01:00:48 -04:00

113 lines
3.5 KiB
TypeScript

// node-pty ships its POSIX `spawn-helper` inside the published npm tarball with
// mode 0644 (no exec bit). node-pty `posix_spawnp`s that helper on macOS/Linux,
// so a non-executable helper fails every terminal spawn with
// `Error: posix_spawnp failed.`. Packaged builds are covered because
// stage-native-deps.mjs chmods the staged copy, but the dev flow
// (`npm run dev` → `electron .`) resolves node-pty straight from
// `node_modules/`, which nobody chmods. This restores the exec bits at runtime,
// best-effort, so both dev and any environment that stripped the bit keep
// working. Idempotent: files that are already executable are left untouched.
import {
chmodSync as realChmodSync,
existsSync as realExistsSync,
readdirSync as realReaddirSync,
statSync as realStatSync
} from 'node:fs'
import { join } from 'node:path'
const EXEC_BITS = 0o111
export interface SpawnHelperFs {
existsSync(path: string): boolean
readdirSync(path: string): string[]
statSync(path: string): { mode: number }
chmodSync(path: string, mode: number): void
}
export interface EnsureSpawnHelperResult {
fixed: string[]
errors: { path: string; error: string }[]
}
const defaultFs: SpawnHelperFs = {
existsSync: realExistsSync,
readdirSync: (path: string) => realReaddirSync(path),
statSync: (path: string) => realStatSync(path),
chmodSync: realChmodSync
}
// True when any of the owner/group/other execute bits are missing.
export function needsExecBit(mode: number): boolean {
return (mode & EXEC_BITS) !== EXEC_BITS
}
// Preserve existing permission bits, adding execute for owner/group/other.
export function withExecBits(mode: number): number {
return mode | EXEC_BITS
}
// Every place a `spawn-helper` can live under a node-pty package root: one per
// bundled prebuild (`prebuilds/<platform>-<arch>/`) plus a locally compiled
// `build/Release/` copy. Windows layouts have no spawn-helper, so the list is
// naturally empty there.
export function spawnHelperCandidates(
nodePtyRoot: string,
fs: Pick<SpawnHelperFs, 'existsSync' | 'readdirSync'> = defaultFs
): string[] {
const candidates: string[] = []
const prebuilds = join(nodePtyRoot, 'prebuilds')
if (fs.existsSync(prebuilds)) {
for (const entry of fs.readdirSync(prebuilds)) {
candidates.push(join(prebuilds, entry, 'spawn-helper'))
}
}
candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper'))
return candidates
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
// Best-effort: ensure every existing spawn-helper under `nodePtyRoot` is
// executable. Never throws — missing files are skipped, and chmod/stat failures
// are collected so the caller can log them without breaking terminal startup.
export function ensureSpawnHelperExecutable(
nodePtyRoot: string,
fs: SpawnHelperFs = defaultFs
): EnsureSpawnHelperResult {
const result: EnsureSpawnHelperResult = { fixed: [], errors: [] }
for (const path of spawnHelperCandidates(nodePtyRoot, fs)) {
if (!fs.existsSync(path)) {
continue
}
let mode: number
try {
mode = fs.statSync(path).mode
} catch (error) {
result.errors.push({ path, error: errorMessage(error) })
continue
}
if (!needsExecBit(mode)) {
continue
}
try {
fs.chmodSync(path, withExecBits(mode))
result.fixed.push(path)
} catch (error) {
result.errors.push({ path, error: errorMessage(error) })
}
}
return result
}