Summary
src/unixTerminal.ts#L19-L20 resolves helperPath, then runs:
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
String.prototype.replace with a string argument replaces the first occurrence. When the resolved helperPath already contains app.asar.unpacked/… (because the caller of node-pty lives inside app.asar.unpacked too, not inside app.asar), the substring app.asar matches the prefix of app.asar.unpacked and the replace produces a path like …/app.asar.unpacked.unpacked/node_modules/node-pty/build/Release/spawn-helper, which doesn't exist on disk.
posix_spawn then fails with ENOENT and node-pty throws the generic posix_spawnp failed., which is widely misdiagnosed as a code-signing / hardened-runtime / sandbox problem.
Reproduction
// simulate what unixTerminal.js does when the caller is itself unpacked
let helperPath = '/path/to/MyApp.app/Contents/Resources/app.asar.unpacked/node_modules/node-pty/build/Release/spawn-helper';
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
console.log(helperPath);
// → /path/to/MyApp.app/Contents/Resources/app.asar.unpacked.unpacked/node_modules/node-pty/build/Release/spawn-helper
// ^^^^^^^^^^^^^^^^^^^ bogus
This affects packaged Electron apps that:
- Run node-pty from a separate child process spawned with
ELECTRON_RUN_AS_NODE=1 (which strips Electron's asar shim and uses plain Node module resolution), AND
- Have that child binary itself living in
app.asar.unpacked.
In that combination __dirname resolves to the real app.asar.unpacked/… filesystem path rather than the asar-shimmed app.asar/… path, so the replace runs against a string that already contains app.asar.unpacked. VS Code, Theia, etc. don't hit this because they call node-pty from the Electron main/renderer process where the asar shim is in play and __dirname reports app.asar/….
Suggested fix
Guard each replace so it doesn't fire when the unpacked path is already present:
if (helperPath.indexOf('app.asar.unpacked') === -1) {
helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
}
if (helperPath.indexOf('node_modules.asar.unpacked') === -1) {
helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
}
Or anchor the replace with a path-separator regex, e.g. .replace(/app\.asar([\\/])/, 'app.asar.unpacked$1').
Bonus: misleading error
src/unix/pty.cc#L373 reports posix_spawnp failed. without the underlying errno. Surfacing strerror(err) (this case would say No such file or directory) would have made this trivially diagnosable instead of sending people down the code-signing rabbit hole. Happy to send a separate PR for that.
Environment
- node-pty 1.1.0
- macOS arm64, Electron 32 (also reproduces with system Node ≥18 when require'ing node-pty from a path inside
app.asar.unpacked)
Summary
src/unixTerminal.ts#L19-L20resolveshelperPath, then runs:String.prototype.replacewith a string argument replaces the first occurrence. When the resolvedhelperPathalready containsapp.asar.unpacked/…(because the caller of node-pty lives insideapp.asar.unpackedtoo, not insideapp.asar), the substringapp.asarmatches the prefix ofapp.asar.unpackedand the replace produces a path like…/app.asar.unpacked.unpacked/node_modules/node-pty/build/Release/spawn-helper, which doesn't exist on disk.posix_spawnthen fails withENOENTand node-pty throws the genericposix_spawnp failed., which is widely misdiagnosed as a code-signing / hardened-runtime / sandbox problem.Reproduction
This affects packaged Electron apps that:
ELECTRON_RUN_AS_NODE=1(which strips Electron's asar shim and uses plain Node module resolution), ANDapp.asar.unpacked.In that combination
__dirnameresolves to the realapp.asar.unpacked/…filesystem path rather than the asar-shimmedapp.asar/…path, so the replace runs against a string that already containsapp.asar.unpacked. VS Code, Theia, etc. don't hit this because they call node-pty from the Electron main/renderer process where the asar shim is in play and__dirnamereportsapp.asar/….Suggested fix
Guard each replace so it doesn't fire when the unpacked path is already present:
Or anchor the replace with a path-separator regex, e.g.
.replace(/app\.asar([\\/])/, 'app.asar.unpacked$1').Bonus: misleading error
src/unix/pty.cc#L373reportsposix_spawnp failed.without the underlyingerrno. Surfacingstrerror(err)(this case would sayNo such file or directory) would have made this trivially diagnosable instead of sending people down the code-signing rabbit hole. Happy to send a separate PR for that.Environment
app.asar.unpacked)