Files
DeskClaw/electron/gateway/process-launcher.ts

190 lines
6.4 KiB
TypeScript

import { app, utilityProcess } from 'electron';
import { existsSync, writeFileSync } from 'fs';
import path from 'path';
import type { GatewayLaunchContext } from './config-sync';
import type { GatewayLifecycleState } from './process-policy';
import { logger } from '../utils/logger';
import { appendNodeRequireToNodeOptions } from '../utils/paths';
const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
(function () {
var _f = globalThis.fetch;
if (typeof _f !== 'function') return;
if (globalThis.__clawxFetchPatched) return;
globalThis.__clawxFetchPatched = true;
globalThis.fetch = function clawxFetch(input, init) {
var url =
typeof input === 'string' ? input
: input && typeof input === 'object' && typeof input.url === 'string'
? input.url : '';
if (url.indexOf('openrouter.ai') !== -1) {
init = init ? Object.assign({}, init) : {};
var prev = init.headers;
var flat = {};
if (prev && typeof prev.forEach === 'function') {
prev.forEach(function (v, k) { flat[k] = v; });
} else if (prev && typeof prev === 'object') {
Object.assign(flat, prev);
}
delete flat['http-referer'];
delete flat['HTTP-Referer'];
delete flat['x-title'];
delete flat['X-Title'];
flat['HTTP-Referer'] = 'https://claw-x.com';
flat['X-Title'] = 'ClawX';
init.headers = flat;
}
return _f.call(globalThis, input, init);
};
if (process.platform === 'win32') {
try {
var cp = require('child_process');
if (!cp.__clawxPatched) {
cp.__clawxPatched = true;
['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) {
var original = cp[method];
if (typeof original !== 'function') return;
cp[method] = function() {
var args = Array.prototype.slice.call(arguments);
var optIdx = -1;
for (var i = 1; i < args.length; i++) {
var a = args[i];
if (a && typeof a === 'object' && !Array.isArray(a)) {
optIdx = i;
break;
}
}
if (optIdx >= 0) {
args[optIdx].windowsHide = true;
} else {
var opts = { windowsHide: true };
if (typeof args[args.length - 1] === 'function') {
args.splice(args.length - 1, 0, opts);
} else {
args.push(opts);
}
}
return original.apply(this, args);
};
});
}
} catch (e) {
// ignore
}
}
})();
`;
function ensureGatewayFetchPreload(): string {
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
try {
writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8');
} catch {
// best-effort
}
return dest;
}
export async function launchGatewayProcess(options: {
port: number;
launchContext: GatewayLaunchContext;
sanitizeSpawnArgs: (args: string[]) => string[];
getCurrentState: () => GatewayLifecycleState;
getShouldReconnect: () => boolean;
onStderrLine: (line: string) => void;
onSpawn: (pid: number | undefined) => void;
onExit: (child: Electron.UtilityProcess, code: number | null) => void;
onError: (error: Error) => void;
}): Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }> {
const {
openclawDir,
entryScript,
gatewayArgs,
forkEnv,
mode,
binPathExists,
loadedProviderKeyCount,
proxySummary,
channelStartupSummary,
} = options.launchContext;
logger.info(
`Starting Gateway process (mode=${mode}, port=${options.port}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, channels=${channelStartupSummary}, proxy=${proxySummary})`,
);
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
const runtimeEnv = { ...forkEnv };
// Only apply the fetch/child_process preload in dev mode.
// In packaged builds Electron's UtilityProcess rejects NODE_OPTIONS
// with --require, logging "Most NODE_OPTIONs are not supported in
// packaged apps" and the preload never loads.
if (!app.isPackaged) {
try {
const preloadPath = ensureGatewayFetchPreload();
if (existsSync(preloadPath)) {
runtimeEnv.NODE_OPTIONS = appendNodeRequireToNodeOptions(
runtimeEnv.NODE_OPTIONS,
preloadPath,
);
}
} catch (err) {
logger.warn('Failed to set up OpenRouter headers preload:', err);
}
}
return await new Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }>((resolve, reject) => {
const child = utilityProcess.fork(entryScript, gatewayArgs, {
cwd: openclawDir,
stdio: 'pipe',
env: runtimeEnv as NodeJS.ProcessEnv,
serviceName: 'OpenClaw Gateway',
});
let settled = false;
const resolveOnce = () => {
if (settled) return;
settled = true;
resolve({ child, lastSpawnSummary });
};
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
child.on('error', (error) => {
logger.error('Gateway process spawn error:', error);
options.onError(error);
rejectOnce(error);
});
child.on('exit', (code: number) => {
// Only check shouldReconnect — not current state. On Windows the WS
// close handler fires before the process exit handler and sets state to
// 'stopped', which would make an unexpected crash look like a planned
// shutdown in logs. shouldReconnect is the reliable indicator: stop()
// sets it to false (expected), crashes leave it true (unexpected).
const expectedExit = !options.getShouldReconnect();
const level = expectedExit ? logger.info : logger.warn;
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
options.onExit(child, code);
});
child.stderr?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
options.onStderrLine(line);
}
});
child.on('spawn', () => {
logger.info(`Gateway process started (pid=${child.pid})`);
options.onSpawn(child.pid);
resolveOnce();
});
});
}