From ac327dfa38f6418619e9d5aa72fded4319e98661 Mon Sep 17 00:00:00 2001 From: Perseus Computing <51974392+tcconnally@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:19:29 -0500 Subject: [PATCH] fix(desktop): resolve unpacked spawn-helper before chmod (#71171) --- .../electron/spawn-helper-perms.test.ts | 27 ++++++++++++++++++- apps/desktop/electron/spawn-helper-perms.ts | 11 +++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/spawn-helper-perms.test.ts b/apps/desktop/electron/spawn-helper-perms.test.ts index 26cb1fefaa40..d8c9ca034b55 100644 --- a/apps/desktop/electron/spawn-helper-perms.test.ts +++ b/apps/desktop/electron/spawn-helper-perms.test.ts @@ -8,7 +8,8 @@ import { needsExecBit, spawnHelperCandidates, type SpawnHelperFs, - withExecBits + withExecBits, + writableNodePtyRoot } from './spawn-helper-perms' interface FakeFile { @@ -56,6 +57,30 @@ function fakeFs( } } +test('rewrites an archived node-pty root to the matching unpacked tree exactly once', () => { + assert.equal( + writableNodePtyRoot('/Hermes.app/Contents/Resources/app.asar/dist/node_modules/node-pty'), + '/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/node-pty' + ) + assert.equal( + writableNodePtyRoot('/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/node-pty'), + '/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/node-pty' + ) +}) + +test('uses the unpacked helper when resolution reports an app.asar node-pty root', () => { + const archivedRoot = '/Hermes.app/Contents/Resources/app.asar/dist/node_modules/node-pty' + const unpackedRoot = writableNodePtyRoot(archivedRoot) + const helper = join(unpackedRoot, 'prebuilds', 'darwin-arm64', 'spawn-helper') + const fs = fakeFs({ [helper]: { mode: 0o644 } }, { [join(unpackedRoot, 'prebuilds')]: ['darwin-arm64'] }) + + const result = ensureSpawnHelperExecutable(archivedRoot, fs) + + assert.deepEqual(result.fixed, [helper]) + assert.deepEqual(result.errors, []) + assert.deepEqual(fs.chmods, [{ path: helper, mode: 0o755 }]) +}) + test('needsExecBit / withExecBits treat any missing exec bit as non-executable', () => { assert.equal(needsExecBit(0o644), true) assert.equal(needsExecBit(0o755), false) diff --git a/apps/desktop/electron/spawn-helper-perms.ts b/apps/desktop/electron/spawn-helper-perms.ts index 6dfd484dc808..684b77096ba5 100644 --- a/apps/desktop/electron/spawn-helper-perms.ts +++ b/apps/desktop/electron/spawn-helper-perms.ts @@ -18,6 +18,14 @@ import { join } from 'node:path' const EXEC_BITS = 0o111 +// Electron exposes module paths inside app.asar even when electron-builder has +// unpacked the native payload beside it. `stat` can read an archived path, but +// chmod cannot mutate it (ENOTDIR). Native node-pty helpers belong in the +// writable app.asar.unpacked tree; leave an already-unpacked path unchanged. +export function writableNodePtyRoot(nodePtyRoot: string): string { + return nodePtyRoot.replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked') +} + export interface SpawnHelperFs { existsSync(path: string): boolean readdirSync(path: string): string[] @@ -81,8 +89,9 @@ export function ensureSpawnHelperExecutable( fs: SpawnHelperFs = defaultFs ): EnsureSpawnHelperResult { const result: EnsureSpawnHelperResult = { fixed: [], errors: [] } + const writableRoot = writableNodePtyRoot(nodePtyRoot) - for (const path of spawnHelperCandidates(nodePtyRoot, fs)) { + for (const path of spawnHelperCandidates(writableRoot, fs)) { if (!fs.existsSync(path)) { continue }