|
|
|
|
@@ -2,9 +2,8 @@
|
|
|
|
|
* Gateway Process Manager
|
|
|
|
|
* Manages the OpenClaw Gateway process lifecycle
|
|
|
|
|
*/
|
|
|
|
|
import { app } from 'electron';
|
|
|
|
|
import { app, utilityProcess } from 'electron';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
import { spawn, ChildProcess } from 'child_process';
|
|
|
|
|
import { EventEmitter } from 'events';
|
|
|
|
|
import { existsSync, writeFileSync } from 'fs';
|
|
|
|
|
import WebSocket from 'ws';
|
|
|
|
|
@@ -12,10 +11,8 @@ import { PORTS } from '../utils/config';
|
|
|
|
|
import {
|
|
|
|
|
getOpenClawDir,
|
|
|
|
|
getOpenClawEntryPath,
|
|
|
|
|
isOpenClawBuilt,
|
|
|
|
|
isOpenClawPresent,
|
|
|
|
|
appendNodeRequireToNodeOptions,
|
|
|
|
|
quoteForCmd,
|
|
|
|
|
} from '../utils/paths';
|
|
|
|
|
import { getAllSettings, getSetting } from '../utils/store';
|
|
|
|
|
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
|
|
|
|
@@ -83,38 +80,8 @@ const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
|
|
|
|
maxDelay: 30000,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the Node.js-compatible executable path for spawning child processes.
|
|
|
|
|
*
|
|
|
|
|
* On macOS in packaged mode, using `process.execPath` directly causes the
|
|
|
|
|
* child process to appear as a separate dock icon (named "exec") because the
|
|
|
|
|
* binary lives inside a `.app` bundle that macOS treats as a GUI application.
|
|
|
|
|
*
|
|
|
|
|
* To avoid this, we resolve the Electron Helper binary which has
|
|
|
|
|
* `LSUIElement` set in its Info.plist, preventing dock icon creation.
|
|
|
|
|
* Falls back to `process.execPath` if the Helper binary is not found.
|
|
|
|
|
*/
|
|
|
|
|
function getNodeExecutablePath(): string {
|
|
|
|
|
if (process.platform === 'darwin' && app.isPackaged) {
|
|
|
|
|
// Electron Helper binary lives at:
|
|
|
|
|
// <App>.app/Contents/Frameworks/<ProductName> Helper.app/Contents/MacOS/<ProductName> Helper
|
|
|
|
|
const appName = app.getName();
|
|
|
|
|
const helperName = `${appName} Helper`;
|
|
|
|
|
const helperPath = path.join(
|
|
|
|
|
path.dirname(process.execPath), // .../Contents/MacOS
|
|
|
|
|
'../Frameworks',
|
|
|
|
|
`${helperName}.app`,
|
|
|
|
|
'Contents/MacOS',
|
|
|
|
|
helperName,
|
|
|
|
|
);
|
|
|
|
|
if (existsSync(helperPath)) {
|
|
|
|
|
logger.debug(`Using Electron Helper binary to avoid dock icon: ${helperPath}`);
|
|
|
|
|
return helperPath;
|
|
|
|
|
}
|
|
|
|
|
logger.debug(`Electron Helper binary not found at ${helperPath}, falling back to process.execPath`);
|
|
|
|
|
}
|
|
|
|
|
return process.execPath;
|
|
|
|
|
}
|
|
|
|
|
// getNodeExecutablePath() removed: utilityProcess.fork() handles process isolation
|
|
|
|
|
// natively on all platforms (no dock icon on macOS, no console on Windows).
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure the gateway fetch-preload script exists in userData and return
|
|
|
|
|
@@ -158,6 +125,35 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
|
|
|
|
|
}
|
|
|
|
|
return _f.call(globalThis, input, init);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Global monkey-patch for child_process to enforce windowsHide: true on Windows
|
|
|
|
|
// This prevents OpenClaw's tools (e.g. Terminal, Python) from flashing black
|
|
|
|
|
// command boxes during AI conversations, without triggering AVs.
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
try {
|
|
|
|
|
var cp = require('child_process');
|
|
|
|
|
if (!cp.__clawxPatched) {
|
|
|
|
|
cp.__clawxPatched = true;
|
|
|
|
|
['spawn', 'exec', 'execFile', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) {
|
|
|
|
|
var original = cp[method];
|
|
|
|
|
if (typeof original === 'function') {
|
|
|
|
|
cp[method] = function() {
|
|
|
|
|
var args = Array.prototype.slice.call(arguments);
|
|
|
|
|
var lastArg = args[args.length - 1];
|
|
|
|
|
if (lastArg && typeof lastArg === 'object' && !Array.isArray(lastArg)) {
|
|
|
|
|
lastArg.windowsHide = true;
|
|
|
|
|
} else {
|
|
|
|
|
args.push({ windowsHide: true });
|
|
|
|
|
}
|
|
|
|
|
return original.apply(this, args);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
@@ -179,7 +175,8 @@ class LifecycleSupersededError extends Error {
|
|
|
|
|
* Handles starting, stopping, and communicating with the OpenClaw Gateway
|
|
|
|
|
*/
|
|
|
|
|
export class GatewayManager extends EventEmitter {
|
|
|
|
|
private process: ChildProcess | null = null;
|
|
|
|
|
private process: Electron.UtilityProcess | null = null;
|
|
|
|
|
private processExitCode: number | null = null; // set by exit event, replaces exitCode/signalCode
|
|
|
|
|
private ownsProcess = false;
|
|
|
|
|
private ws: WebSocket | null = null;
|
|
|
|
|
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
|
|
|
|
@@ -461,43 +458,32 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
// Kill process
|
|
|
|
|
if (this.process && this.ownsProcess) {
|
|
|
|
|
const child = this.process;
|
|
|
|
|
// UtilityProcess doesn't expose exitCode/signalCode — track exit via event.
|
|
|
|
|
let exited = false;
|
|
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
|
// If process already exited, resolve immediately
|
|
|
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
|
|
|
return resolve();
|
|
|
|
|
}
|
|
|
|
|
child.once('exit', () => {
|
|
|
|
|
exited = true;
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Kill the entire process group so respawned children are also terminated.
|
|
|
|
|
// The gateway entry script may respawn itself; killing only the parent PID
|
|
|
|
|
// leaves the child orphaned (PPID=1) and still holding the port.
|
|
|
|
|
const pid = child.pid;
|
|
|
|
|
logger.info(`Sending SIGTERM to Gateway process group (pid=${pid ?? 'unknown'})`);
|
|
|
|
|
if (pid) {
|
|
|
|
|
try { process.kill(-pid, 'SIGTERM'); } catch { /* group kill failed, fall back */ }
|
|
|
|
|
}
|
|
|
|
|
child.kill('SIGTERM');
|
|
|
|
|
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
|
|
|
|
|
try { child.kill(); } catch { /* ignore if already exited */ }
|
|
|
|
|
|
|
|
|
|
// Force kill after timeout
|
|
|
|
|
// Force kill after timeout via OS-level kill on the PID
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
|
|
|
logger.warn(`Gateway did not exit in time, sending SIGKILL (pid=${pid ?? 'unknown'})`);
|
|
|
|
|
if (!exited) {
|
|
|
|
|
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
|
|
|
|
|
if (pid) {
|
|
|
|
|
try { process.kill(-pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
|
|
|
try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
child.kill('SIGKILL');
|
|
|
|
|
}
|
|
|
|
|
resolve();
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
|
|
|
|
child.once('exit', () => {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.once('error', () => {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@@ -721,11 +707,11 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Platform-specific command to find processes listening on the gateway port.
|
|
|
|
|
// On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead.
|
|
|
|
|
// -WindowStyle Hidden is used to prevent PowerShell from popping up a brief console window
|
|
|
|
|
// even when windowsHide: true is passed to cp.exec.
|
|
|
|
|
// We use native commands (netstat on Windows) to avoid triggering AV blocks
|
|
|
|
|
// that flag "powershell -WindowStyle Hidden" as malware behavior.
|
|
|
|
|
// windowsHide: true in cp.exec natively prevents the black command window.
|
|
|
|
|
const cmd = process.platform === 'win32'
|
|
|
|
|
? `powershell -WindowStyle Hidden -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`
|
|
|
|
|
? `netstat -ano | findstr :${port}`
|
|
|
|
|
: `lsof -i :${port} -sTCP:LISTEN -t`;
|
|
|
|
|
|
|
|
|
|
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
|
|
|
|
|
@@ -738,9 +724,23 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (stdout.trim()) {
|
|
|
|
|
const pids = stdout.trim().split(/\r?\n/)
|
|
|
|
|
.map(s => s.trim())
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
// Parse netstat or lsof output to extract PIDs
|
|
|
|
|
let pids: string[] = [];
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
// netstat -ano output format:
|
|
|
|
|
// TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 12345
|
|
|
|
|
const lines = stdout.trim().split(/\r?\n/);
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const parts = line.trim().split(/\s+/);
|
|
|
|
|
if (parts.length >= 5 && parts[3] === 'LISTENING') {
|
|
|
|
|
pids.push(parts[4]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
pids = stdout.trim().split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
// Remove duplicate PIDs
|
|
|
|
|
pids = [...new Set(pids)];
|
|
|
|
|
|
|
|
|
|
if (pids.length > 0) {
|
|
|
|
|
if (!this.process || !pids.includes(String(this.process.pid))) {
|
|
|
|
|
@@ -756,12 +756,11 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
for (const pid of pids) {
|
|
|
|
|
try {
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
// Use PowerShell with -WindowStyle Hidden to kill the process without
|
|
|
|
|
// flashing a black console window. taskkill.exe is a console app and
|
|
|
|
|
// can flash a window even when windowsHide: true is set.
|
|
|
|
|
// Use taskkill with windowsHide: true. This natively hides the console
|
|
|
|
|
// flash without needing PowerShell, avoiding AV alerts.
|
|
|
|
|
import('child_process').then(cp => {
|
|
|
|
|
cp.exec(
|
|
|
|
|
`powershell -WindowStyle Hidden -NoProfile -Command "Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue"`,
|
|
|
|
|
`taskkill /F /PID ${pid} /T`,
|
|
|
|
|
{ timeout: 5000, windowsHide: true },
|
|
|
|
|
() => { }
|
|
|
|
|
);
|
|
|
|
|
@@ -839,36 +838,23 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
: process.env.PATH || '';
|
|
|
|
|
|
|
|
|
|
const uvEnv = await getUvMirrorEnv();
|
|
|
|
|
const command = app.isPackaged ? getNodeExecutablePath() : 'node';
|
|
|
|
|
const args = [entryScript, 'doctor', '--fix', '--yes', '--non-interactive'];
|
|
|
|
|
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
|
|
|
|
|
logger.info(
|
|
|
|
|
`Running OpenClaw doctor repair (command="${command}", args="${args.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`
|
|
|
|
|
`Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
|
|
|
const spawnEnv: Record<string, string | undefined> = {
|
|
|
|
|
const forkEnv: Record<string, string | undefined> = {
|
|
|
|
|
...process.env,
|
|
|
|
|
PATH: finalPath,
|
|
|
|
|
...uvEnv,
|
|
|
|
|
OPENCLAW_NO_RESPAWN: '1',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (app.isPackaged) {
|
|
|
|
|
spawnEnv['ELECTRON_RUN_AS_NODE'] = '1';
|
|
|
|
|
spawnEnv['OPENCLAW_NO_RESPAWN'] = '1';
|
|
|
|
|
const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? '';
|
|
|
|
|
if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') &&
|
|
|
|
|
!existingNodeOpts.includes('--no-warnings')) {
|
|
|
|
|
spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const child = spawn(command, args, {
|
|
|
|
|
const child = utilityProcess.fork(entryScript, doctorArgs, {
|
|
|
|
|
cwd: openclawDir,
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
detached: false,
|
|
|
|
|
shell: false,
|
|
|
|
|
windowsHide: true,
|
|
|
|
|
env: spawnEnv,
|
|
|
|
|
stdio: 'pipe',
|
|
|
|
|
env: forkEnv as NodeJS.ProcessEnv,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let settled = false;
|
|
|
|
|
@@ -881,7 +867,7 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
logger.error('OpenClaw doctor repair timed out after 120000ms');
|
|
|
|
|
try {
|
|
|
|
|
child.kill('SIGTERM');
|
|
|
|
|
child.kill();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
@@ -912,14 +898,14 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('exit', (code, signal) => {
|
|
|
|
|
child.on('exit', (code: number) => {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
if (code === 0) {
|
|
|
|
|
logger.info('OpenClaw doctor repair completed successfully');
|
|
|
|
|
finish(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
logger.warn(`OpenClaw doctor repair exited (${this.formatExit(code, signal)})`);
|
|
|
|
|
logger.warn(`OpenClaw doctor repair exited (code=${code})`);
|
|
|
|
|
finish(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
@@ -976,40 +962,16 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
logger.warn('Failed to sync browser config to openclaw.json:', err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let command: string;
|
|
|
|
|
let args: string[];
|
|
|
|
|
let mode: 'packaged' | 'dev-built' | 'dev-pnpm';
|
|
|
|
|
|
|
|
|
|
// Determine the Node.js executable
|
|
|
|
|
// In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1
|
|
|
|
|
// which makes the Electron binary behave as plain Node.js.
|
|
|
|
|
// In development, use system 'node'.
|
|
|
|
|
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured'];
|
|
|
|
|
|
|
|
|
|
if (app.isPackaged) {
|
|
|
|
|
// Production: use Electron binary as Node.js via ELECTRON_RUN_AS_NODE
|
|
|
|
|
// On macOS, use the Electron Helper binary to avoid extra dock icons
|
|
|
|
|
if (existsSync(entryScript)) {
|
|
|
|
|
command = getNodeExecutablePath();
|
|
|
|
|
args = [entryScript, ...gatewayArgs];
|
|
|
|
|
mode = 'packaged';
|
|
|
|
|
} else {
|
|
|
|
|
const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
|
|
|
|
|
logger.error(errMsg);
|
|
|
|
|
throw new Error(errMsg);
|
|
|
|
|
}
|
|
|
|
|
} else if (isOpenClawBuilt() && existsSync(entryScript)) {
|
|
|
|
|
// Development with built package: use system node
|
|
|
|
|
command = 'node';
|
|
|
|
|
args = [entryScript, ...gatewayArgs];
|
|
|
|
|
mode = 'dev-built';
|
|
|
|
|
} else {
|
|
|
|
|
// Development without build: use pnpm dev
|
|
|
|
|
command = 'pnpm';
|
|
|
|
|
args = ['run', 'dev', ...gatewayArgs];
|
|
|
|
|
mode = 'dev-pnpm';
|
|
|
|
|
// utilityProcess.fork() works for both dev and packaged — no ELECTRON_RUN_AS_NODE needed.
|
|
|
|
|
if (!existsSync(entryScript)) {
|
|
|
|
|
const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
|
|
|
|
|
logger.error(errMsg);
|
|
|
|
|
throw new Error(errMsg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured'];
|
|
|
|
|
const mode = app.isPackaged ? 'packaged' : 'dev';
|
|
|
|
|
|
|
|
|
|
// Resolve bundled bin path for uv
|
|
|
|
|
const platform = process.platform;
|
|
|
|
|
const arch = process.arch;
|
|
|
|
|
@@ -1067,13 +1029,15 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
const proxyEnv = buildProxyEnv(appSettings);
|
|
|
|
|
const resolvedProxy = resolveProxySettings(appSettings);
|
|
|
|
|
logger.info(
|
|
|
|
|
`Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})`
|
|
|
|
|
`Starting Gateway process (mode=${mode}, port=${this.status.port}, entry="${entryScript}", args="${this.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})`
|
|
|
|
|
);
|
|
|
|
|
this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`;
|
|
|
|
|
this.lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${this.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
// Reset exit tracking for this new process instance.
|
|
|
|
|
this.processExitCode = null;
|
|
|
|
|
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
|
|
|
|
const spawnEnv: Record<string, string | undefined> = {
|
|
|
|
|
const forkEnv: Record<string, string | undefined> = {
|
|
|
|
|
...baseEnv,
|
|
|
|
|
PATH: finalPath,
|
|
|
|
|
...providerEnv,
|
|
|
|
|
@@ -1082,29 +1046,17 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
|
|
|
|
OPENCLAW_SKIP_CHANNELS: '',
|
|
|
|
|
CLAWDBOT_SKIP_CHANNELS: '',
|
|
|
|
|
// Prevent OpenClaw from respawning itself inside the utility process
|
|
|
|
|
OPENCLAW_NO_RESPAWN: '1',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Critical: In packaged mode, make Electron binary act as Node.js
|
|
|
|
|
if (app.isPackaged) {
|
|
|
|
|
spawnEnv['ELECTRON_RUN_AS_NODE'] = '1';
|
|
|
|
|
// Prevent OpenClaw entry.ts from respawning itself (which would create
|
|
|
|
|
// another child process and a second "exec" dock icon on macOS)
|
|
|
|
|
spawnEnv['OPENCLAW_NO_RESPAWN'] = '1';
|
|
|
|
|
// Pre-set the NODE_OPTIONS that entry.ts would have added via respawn
|
|
|
|
|
const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? '';
|
|
|
|
|
if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') &&
|
|
|
|
|
!existingNodeOpts.includes('--no-warnings')) {
|
|
|
|
|
spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inject fetch preload so OpenRouter requests carry ClawX headers.
|
|
|
|
|
// The preload patches globalThis.fetch before any module loads.
|
|
|
|
|
try {
|
|
|
|
|
const preloadPath = ensureGatewayFetchPreload();
|
|
|
|
|
if (existsSync(preloadPath)) {
|
|
|
|
|
spawnEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions(
|
|
|
|
|
spawnEnv['NODE_OPTIONS'],
|
|
|
|
|
forkEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions(
|
|
|
|
|
forkEnv['NODE_OPTIONS'],
|
|
|
|
|
preloadPath,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
@@ -1112,17 +1064,13 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
logger.warn('Failed to set up OpenRouter headers preload:', err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const useShell = !app.isPackaged && process.platform === 'win32';
|
|
|
|
|
const spawnCmd = useShell ? quoteForCmd(command) : command;
|
|
|
|
|
const spawnArgs = useShell ? args.map(a => quoteForCmd(a)) : args;
|
|
|
|
|
|
|
|
|
|
this.process = spawn(spawnCmd, spawnArgs, {
|
|
|
|
|
// utilityProcess.fork() runs the .mjs entry directly without spawning a
|
|
|
|
|
// shell or visible console window. Works identically in dev and packaged.
|
|
|
|
|
this.process = utilityProcess.fork(entryScript, gatewayArgs, {
|
|
|
|
|
cwd: openclawDir,
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
detached: false,
|
|
|
|
|
shell: useShell,
|
|
|
|
|
windowsHide: true,
|
|
|
|
|
env: spawnEnv,
|
|
|
|
|
stdio: 'pipe',
|
|
|
|
|
env: forkEnv as NodeJS.ProcessEnv,
|
|
|
|
|
serviceName: 'OpenClaw Gateway',
|
|
|
|
|
});
|
|
|
|
|
const child = this.process;
|
|
|
|
|
this.ownsProcess = true;
|
|
|
|
|
@@ -1133,10 +1081,11 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
reject(error);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('exit', (code, signal) => {
|
|
|
|
|
child.on('exit', (code: number) => {
|
|
|
|
|
this.processExitCode = code;
|
|
|
|
|
const expectedExit = !this.shouldReconnect || this.status.state === 'stopped';
|
|
|
|
|
const level = expectedExit ? logger.info : logger.warn;
|
|
|
|
|
level(`Gateway process exited (${this.formatExit(code, signal)}, expected=${expectedExit ? 'yes' : 'no'})`);
|
|
|
|
|
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
|
|
|
|
|
this.ownsProcess = false;
|
|
|
|
|
if (this.process === child) {
|
|
|
|
|
this.process = null;
|
|
|
|
|
@@ -1149,9 +1098,7 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on('close', (code, signal) => {
|
|
|
|
|
logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`);
|
|
|
|
|
});
|
|
|
|
|
// UtilityProcess doesn't emit 'close'; stdout/stderr end naturally on exit.
|
|
|
|
|
|
|
|
|
|
// Log stderr
|
|
|
|
|
child.stderr?.on('data', (data) => {
|
|
|
|
|
@@ -1186,12 +1133,12 @@ export class GatewayManager extends EventEmitter {
|
|
|
|
|
private async waitForReady(retries = 2400, interval = 250): Promise<void> {
|
|
|
|
|
const child = this.process;
|
|
|
|
|
for (let i = 0; i < retries; i++) {
|
|
|
|
|
// Early exit if the gateway process has already exited
|
|
|
|
|
if (child && (child.exitCode !== null || child.signalCode !== null)) {
|
|
|
|
|
const code = child.exitCode;
|
|
|
|
|
const signal = child.signalCode;
|
|
|
|
|
logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`);
|
|
|
|
|
throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`);
|
|
|
|
|
// Early exit if the gateway process has already exited.
|
|
|
|
|
// UtilityProcess has no synchronous exitCode/signalCode — use our tracked flag.
|
|
|
|
|
if (child && this.processExitCode !== null) {
|
|
|
|
|
const code = this.processExitCode;
|
|
|
|
|
logger.error(`Gateway process exited before ready (code=${code})`);
|
|
|
|
|
throw new Error(`Gateway process exited before becoming ready (code=${code})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|