diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index fb37fb8e0..77f85338a 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -14,6 +14,7 @@ import { getOpenClawEntryPath, isOpenClawBuilt, isOpenClawPresent, + appendNodeRequireToNodeOptions, quoteForCmd, } from '../utils/paths'; import { getSetting } from '../utils/store'; @@ -870,9 +871,10 @@ export class GatewayManager extends EventEmitter { try { const preloadPath = ensureGatewayFetchPreload(); if (existsSync(preloadPath)) { - const quoted = `"${preloadPath}"`; - const opts = spawnEnv['NODE_OPTIONS'] ?? ''; - spawnEnv['NODE_OPTIONS'] = `${opts} --require ${quoted}`.trim(); + spawnEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions( + spawnEnv['NODE_OPTIONS'], + preloadPath, + ); } } catch (err) { logger.warn('Failed to set up OpenRouter headers preload:', err); diff --git a/electron/utils/paths.ts b/electron/utils/paths.ts index 8126bb5b4..47173ef17 100644 --- a/electron/utils/paths.ts +++ b/electron/utils/paths.ts @@ -8,7 +8,13 @@ import { homedir } from 'os'; import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs'; import { logger } from './logger'; -export { quoteForCmd, needsWinShell, prepareWinSpawn } from './win-shell'; +export { + quoteForCmd, + needsWinShell, + prepareWinSpawn, + normalizeNodeRequirePathForNodeOptions, + appendNodeRequireToNodeOptions, +} from './win-shell'; /** * Expand ~ to home directory diff --git a/electron/utils/win-shell.ts b/electron/utils/win-shell.ts index ea845a0b4..3835faf1e 100644 --- a/electron/utils/win-shell.ts +++ b/electron/utils/win-shell.ts @@ -63,3 +63,27 @@ export function prepareWinSpawn( args: args.map(a => quoteForCmd(a)), }; } + +/** + * Normalize a module path for NODE_OPTIONS `--require` usage. + * + * Node parses NODE_OPTIONS using shell-like escaping rules. On Windows, + * a quoted path with backslashes (e.g. "C:\Users\...") loses separators + * because backslashes are interpreted as escapes. Using forward slashes + * keeps the absolute path intact while still being valid on Windows. + */ +export function normalizeNodeRequirePathForNodeOptions(modulePath: string): string { + if (process.platform !== 'win32') return modulePath; + return modulePath.replace(/\\/g, '/'); +} + +/** + * Append a `--require` preload module path to NODE_OPTIONS safely. + */ +export function appendNodeRequireToNodeOptions( + nodeOptions: string | undefined, + modulePath: string, +): string { + const normalized = normalizeNodeRequirePathForNodeOptions(modulePath); + return `${nodeOptions ?? ''} --require "${normalized}"`.trim(); +} diff --git a/tests/unit/win-shell.test.ts b/tests/unit/win-shell.test.ts index 3aa48538d..1607e47bf 100644 --- a/tests/unit/win-shell.test.ts +++ b/tests/unit/win-shell.test.ts @@ -160,3 +160,63 @@ describe('prepareWinSpawn', () => { expect(relResult.shell).toBe(true); }); }); + +describe('normalizeNodeRequirePathForNodeOptions', () => { + let normalizeNodeRequirePathForNodeOptions: (modulePath: string) => string; + + beforeEach(async () => { + const mod = await import('@electron/utils/win-shell'); + normalizeNodeRequirePathForNodeOptions = mod.normalizeNodeRequirePathForNodeOptions; + }); + + it('returns path unchanged on non-Windows', () => { + setPlatform('linux'); + expect(normalizeNodeRequirePathForNodeOptions('/home/user/.config/app/preload.cjs')) + .toBe('/home/user/.config/app/preload.cjs'); + }); + + it('converts backslashes to forward slashes on Windows', () => { + setPlatform('win32'); + expect(normalizeNodeRequirePathForNodeOptions('C:\\Users\\70954\\AppData\\Roaming\\clawx\\gateway-fetch-preload.cjs')) + .toBe('C:/Users/70954/AppData/Roaming/clawx/gateway-fetch-preload.cjs'); + }); + + it('leaves forward slashes intact on Windows', () => { + setPlatform('win32'); + expect(normalizeNodeRequirePathForNodeOptions('C:/already/forward/slashes.cjs')) + .toBe('C:/already/forward/slashes.cjs'); + }); +}); + +describe('appendNodeRequireToNodeOptions', () => { + let appendNodeRequireToNodeOptions: (nodeOptions: string | undefined, modulePath: string) => string; + + beforeEach(async () => { + const mod = await import('@electron/utils/win-shell'); + appendNodeRequireToNodeOptions = mod.appendNodeRequireToNodeOptions; + }); + + it('creates NODE_OPTIONS from undefined', () => { + setPlatform('linux'); + expect(appendNodeRequireToNodeOptions(undefined, '/tmp/preload.cjs')) + .toBe('--require "/tmp/preload.cjs"'); + }); + + it('appends to existing NODE_OPTIONS', () => { + setPlatform('linux'); + expect(appendNodeRequireToNodeOptions('--disable-warning=ExperimentalWarning', '/tmp/preload.cjs')) + .toBe('--disable-warning=ExperimentalWarning --require "/tmp/preload.cjs"'); + }); + + it('normalizes Windows backslashes in the module path', () => { + setPlatform('win32'); + expect(appendNodeRequireToNodeOptions(undefined, 'C:\\Users\\test\\preload.cjs')) + .toBe('--require "C:/Users/test/preload.cjs"'); + }); + + it('appends to existing NODE_OPTIONS on Windows with normalized path', () => { + setPlatform('win32'); + expect(appendNodeRequireToNodeOptions('--max-old-space-size=4096', 'D:\\app\\data\\preload.cjs')) + .toBe('--max-old-space-size=4096 --require "D:/app/data/preload.cjs"'); + }); +});