From 310e8cf2db0f1783c674679e8c281ed1c8e11a20 Mon Sep 17 00:00:00 2001 From: Myk <49292718+oldrobotdev@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:05:57 +0200 Subject: [PATCH] fix(plugins): spawn nodeExecution scripts via process.execPath so packaged apps work #8707 (#8708) --- electron/plugin-node-executor.test.cjs | 66 ++++++++++++++++++++++++++ electron/plugin-node-executor.ts | 9 +++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/electron/plugin-node-executor.test.cjs b/electron/plugin-node-executor.test.cjs index 320d06df1e..7f1db187b4 100644 --- a/electron/plugin-node-executor.test.cjs +++ b/electron/plugin-node-executor.test.cjs @@ -30,6 +30,8 @@ let nextDialogPromise; // In-memory stand-in for the main-owned persisted consent store (plugin-node-consent-store.ts), // so the executor's Phase 2 ask-once logic can be tested without touching the filesystem. let consentStore; +// Recorded (command, args, options) tuples from the stubbed child_process.spawn. +let spawnCalls; class FakeWebContents extends EventEmitter { constructor(id, url = 'app://index.html') { @@ -109,6 +111,33 @@ const installMocks = () => { return consentStore; } + // Stub child_process so the spawn-path tests can assert the resolved binary and + // env without launching a real process. + if ( + request === 'child_process' && + parent && + typeof parent.filename === 'string' && + parent.filename.endsWith('plugin-node-executor.ts') + ) { + return { + spawn: (command, spawnArgs, options) => { + spawnCalls.push([command, spawnArgs, options]); + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.killed = false; + child.kill = () => { + child.killed = true; + }; + setImmediate(() => { + child.stdout.emit('data', JSON.stringify({ __result: 42 })); + child.emit('close', 0); + }); + return child; + }, + }; + } + if (request === 'electron-log/main') { return { log: () => {}, error: () => {} }; } @@ -156,6 +185,7 @@ const callIpc = (channel, sender, ...args) => { test.beforeEach(() => { ipcHandlers = new Map(); dialogCalls = []; + spawnCalls = []; nextDialogResult = { response: 0 }; nextDialogPromise = undefined; const records = new Map(); @@ -660,6 +690,42 @@ test('mints the grant before persisting consent (persist is best-effort, never g assert.equal(grantsSizeAtPersist, 1); }); +test('spawn path runs the script with process.execPath, never a bare node lookup', async () => { + // REGRESSION: the spawn path used `execPath.includes('electron') ? execPath : 'node'`. + // In a packaged app the executable name contains no "electron" (e.g. "Super + // Productivity"), and the child env below has no PATH, so spawn('node') could only + // resolve through the execvp fallback (/usr/bin:/bin) — ENOENT on most machines. + // The executor must always use its own binary in node mode. + loadModule(); + const webContents = new FakeWebContents(32); + const grant = await callIpc( + 'PLUGIN_REQUEST_NODE_EXECUTION_GRANT', + webContents, + 'sync-md', + ); + + // The script must mention child_process so canExecuteDirectly() routes it to spawn. + const result = await callIpc( + 'PLUGIN_EXEC_NODE_SCRIPT', + webContents, + 'sync-md', + grant.token, + { + script: "const cp = require('child_process'); void cp; return 1;", + }, + ); + + assert.equal(result.success, true); + assert.equal(result.result, 42); // from the stubbed child's stdout + assert.equal(spawnCalls.length, 1); + const [command, spawnArgs, options] = spawnCalls[0]; + assert.equal(command, process.execPath); + assert.ok(spawnArgs.includes('-e')); + assert.equal(options.env.ELECTRON_RUN_AS_NODE, '1'); + // The env stays minimal by design — which is exactly why a bare 'node' cannot resolve. + assert.equal('PATH' in options.env, false); +}); + test('a consent persist failure still grants the approved session (best-effort)', async () => { // Persisting is best-effort: a write failure only costs a re-prompt next session, never // the grant the user just approved. Minting before the persist guarantees this. diff --git a/electron/plugin-node-executor.ts b/electron/plugin-node-executor.ts index c1d691d561..ef21a3275c 100644 --- a/electron/plugin-node-executor.ts +++ b/electron/plugin-node-executor.ts @@ -656,8 +656,13 @@ class PluginNodeExecutor { })(); `; - // Use electron's node or system node - const nodePath = process.execPath.includes('electron') ? process.execPath : 'node'; + // Run the script with our own binary in node mode (ELECTRON_RUN_AS_NODE is set + // below). In packaged builds the executable name doesn't contain "electron" + // (e.g. "Super Productivity"), and the stripped child env has no PATH, so + // falling back to spawn('node') fails with ENOENT unless node happens to live + // in /usr/bin. process.execPath works in every mode: dev (Electron from + // node_modules), packaged apps, and plain node (tests). + const nodePath = process.execPath; // Spawn process with script via -e flag (no temp files!) const child = spawn(nodePath, ['--no-warnings', '-e', wrappedScript], {