refactor(gateway): migrate from child_process.spawn to utilityProcess.fork (#290)
This commit is contained in:
committed by
GitHub
Unverified
parent
76df84e68c
commit
52748d78b5
@@ -2,9 +2,8 @@
|
|||||||
* Gateway Process Manager
|
* Gateway Process Manager
|
||||||
* Manages the OpenClaw Gateway process lifecycle
|
* Manages the OpenClaw Gateway process lifecycle
|
||||||
*/
|
*/
|
||||||
import { app } from 'electron';
|
import { app, utilityProcess } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { existsSync, writeFileSync } from 'fs';
|
import { existsSync, writeFileSync } from 'fs';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
@@ -12,10 +11,8 @@ import { PORTS } from '../utils/config';
|
|||||||
import {
|
import {
|
||||||
getOpenClawDir,
|
getOpenClawDir,
|
||||||
getOpenClawEntryPath,
|
getOpenClawEntryPath,
|
||||||
isOpenClawBuilt,
|
|
||||||
isOpenClawPresent,
|
isOpenClawPresent,
|
||||||
appendNodeRequireToNodeOptions,
|
appendNodeRequireToNodeOptions,
|
||||||
quoteForCmd,
|
|
||||||
} from '../utils/paths';
|
} from '../utils/paths';
|
||||||
import { getAllSettings, getSetting } from '../utils/store';
|
import { getAllSettings, getSetting } from '../utils/store';
|
||||||
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
||||||
@@ -83,38 +80,8 @@ const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
|||||||
maxDelay: 30000,
|
maxDelay: 30000,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// getNodeExecutablePath() removed: utilityProcess.fork() handles process isolation
|
||||||
* Get the Node.js-compatible executable path for spawning child processes.
|
// natively on all platforms (no dock icon on macOS, no console on Windows).
|
||||||
*
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the gateway fetch-preload script exists in userData and return
|
* 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);
|
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
|
* Handles starting, stopping, and communicating with the OpenClaw Gateway
|
||||||
*/
|
*/
|
||||||
export class GatewayManager extends EventEmitter {
|
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 ownsProcess = false;
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
||||||
@@ -461,43 +458,32 @@ export class GatewayManager extends EventEmitter {
|
|||||||
// Kill process
|
// Kill process
|
||||||
if (this.process && this.ownsProcess) {
|
if (this.process && this.ownsProcess) {
|
||||||
const child = this.process;
|
const child = this.process;
|
||||||
|
// UtilityProcess doesn't expose exitCode/signalCode — track exit via event.
|
||||||
|
let exited = false;
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
// If process already exited, resolve immediately
|
child.once('exit', () => {
|
||||||
if (child.exitCode !== null || child.signalCode !== null) {
|
exited = true;
|
||||||
return resolve();
|
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;
|
const pid = child.pid;
|
||||||
logger.info(`Sending SIGTERM to Gateway process group (pid=${pid ?? 'unknown'})`);
|
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
|
||||||
if (pid) {
|
try { child.kill(); } catch { /* ignore if already exited */ }
|
||||||
try { process.kill(-pid, 'SIGTERM'); } catch { /* group kill failed, fall back */ }
|
|
||||||
}
|
|
||||||
child.kill('SIGTERM');
|
|
||||||
|
|
||||||
// Force kill after timeout
|
// Force kill after timeout via OS-level kill on the PID
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
if (!exited) {
|
||||||
logger.warn(`Gateway did not exit in time, sending SIGKILL (pid=${pid ?? 'unknown'})`);
|
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
|
||||||
if (pid) {
|
if (pid) {
|
||||||
try { process.kill(-pid, 'SIGKILL'); } catch { /* ignore */ }
|
try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
child.kill('SIGKILL');
|
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
child.once('exit', () => {
|
child.once('exit', () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.once('error', () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -721,11 +707,11 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Platform-specific command to find processes listening on the gateway port.
|
// Platform-specific command to find processes listening on the gateway port.
|
||||||
// On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead.
|
// We use native commands (netstat on Windows) to avoid triggering AV blocks
|
||||||
// -WindowStyle Hidden is used to prevent PowerShell from popping up a brief console window
|
// that flag "powershell -WindowStyle Hidden" as malware behavior.
|
||||||
// even when windowsHide: true is passed to cp.exec.
|
// windowsHide: true in cp.exec natively prevents the black command window.
|
||||||
const cmd = process.platform === 'win32'
|
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`;
|
: `lsof -i :${port} -sTCP:LISTEN -t`;
|
||||||
|
|
||||||
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
|
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
|
||||||
@@ -738,9 +724,23 @@ export class GatewayManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (stdout.trim()) {
|
if (stdout.trim()) {
|
||||||
const pids = stdout.trim().split(/\r?\n/)
|
// Parse netstat or lsof output to extract PIDs
|
||||||
.map(s => s.trim())
|
let pids: string[] = [];
|
||||||
.filter(Boolean);
|
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 (pids.length > 0) {
|
||||||
if (!this.process || !pids.includes(String(this.process.pid))) {
|
if (!this.process || !pids.includes(String(this.process.pid))) {
|
||||||
@@ -756,12 +756,11 @@ export class GatewayManager extends EventEmitter {
|
|||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
// Use PowerShell with -WindowStyle Hidden to kill the process without
|
// Use taskkill with windowsHide: true. This natively hides the console
|
||||||
// flashing a black console window. taskkill.exe is a console app and
|
// flash without needing PowerShell, avoiding AV alerts.
|
||||||
// can flash a window even when windowsHide: true is set.
|
|
||||||
import('child_process').then(cp => {
|
import('child_process').then(cp => {
|
||||||
cp.exec(
|
cp.exec(
|
||||||
`powershell -WindowStyle Hidden -NoProfile -Command "Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue"`,
|
`taskkill /F /PID ${pid} /T`,
|
||||||
{ timeout: 5000, windowsHide: true },
|
{ timeout: 5000, windowsHide: true },
|
||||||
() => { }
|
() => { }
|
||||||
);
|
);
|
||||||
@@ -839,36 +838,23 @@ export class GatewayManager extends EventEmitter {
|
|||||||
: process.env.PATH || '';
|
: process.env.PATH || '';
|
||||||
|
|
||||||
const uvEnv = await getUvMirrorEnv();
|
const uvEnv = await getUvMirrorEnv();
|
||||||
const command = app.isPackaged ? getNodeExecutablePath() : 'node';
|
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
|
||||||
const args = [entryScript, 'doctor', '--fix', '--yes', '--non-interactive'];
|
|
||||||
logger.info(
|
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) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
const spawnEnv: Record<string, string | undefined> = {
|
const forkEnv: Record<string, string | undefined> = {
|
||||||
...process.env,
|
...process.env,
|
||||||
PATH: finalPath,
|
PATH: finalPath,
|
||||||
...uvEnv,
|
...uvEnv,
|
||||||
|
OPENCLAW_NO_RESPAWN: '1',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (app.isPackaged) {
|
const child = utilityProcess.fork(entryScript, doctorArgs, {
|
||||||
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, {
|
|
||||||
cwd: openclawDir,
|
cwd: openclawDir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: 'pipe',
|
||||||
detached: false,
|
env: forkEnv as NodeJS.ProcessEnv,
|
||||||
shell: false,
|
|
||||||
windowsHide: true,
|
|
||||||
env: spawnEnv,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -881,7 +867,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
logger.error('OpenClaw doctor repair timed out after 120000ms');
|
logger.error('OpenClaw doctor repair timed out after 120000ms');
|
||||||
try {
|
try {
|
||||||
child.kill('SIGTERM');
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -912,14 +898,14 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code: number) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
logger.info('OpenClaw doctor repair completed successfully');
|
logger.info('OpenClaw doctor repair completed successfully');
|
||||||
finish(true);
|
finish(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.warn(`OpenClaw doctor repair exited (${this.formatExit(code, signal)})`);
|
logger.warn(`OpenClaw doctor repair exited (code=${code})`);
|
||||||
finish(false);
|
finish(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -976,39 +962,15 @@ export class GatewayManager extends EventEmitter {
|
|||||||
logger.warn('Failed to sync browser config to openclaw.json:', err);
|
logger.warn('Failed to sync browser config to openclaw.json:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
let command: string;
|
// utilityProcess.fork() works for both dev and packaged — no ELECTRON_RUN_AS_NODE needed.
|
||||||
let args: string[];
|
if (!existsSync(entryScript)) {
|
||||||
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}`;
|
const errMsg = `OpenClaw entry script not found at: ${entryScript}`;
|
||||||
logger.error(errMsg);
|
logger.error(errMsg);
|
||||||
throw new Error(errMsg);
|
throw new Error(errMsg);
|
||||||
}
|
}
|
||||||
} else if (isOpenClawBuilt() && existsSync(entryScript)) {
|
|
||||||
// Development with built package: use system node
|
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured'];
|
||||||
command = 'node';
|
const mode = app.isPackaged ? 'packaged' : 'dev';
|
||||||
args = [entryScript, ...gatewayArgs];
|
|
||||||
mode = 'dev-built';
|
|
||||||
} else {
|
|
||||||
// Development without build: use pnpm dev
|
|
||||||
command = 'pnpm';
|
|
||||||
args = ['run', 'dev', ...gatewayArgs];
|
|
||||||
mode = 'dev-pnpm';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve bundled bin path for uv
|
// Resolve bundled bin path for uv
|
||||||
const platform = process.platform;
|
const platform = process.platform;
|
||||||
@@ -1067,13 +1029,15 @@ export class GatewayManager extends EventEmitter {
|
|||||||
const proxyEnv = buildProxyEnv(appSettings);
|
const proxyEnv = buildProxyEnv(appSettings);
|
||||||
const resolvedProxy = resolveProxySettings(appSettings);
|
const resolvedProxy = resolveProxySettings(appSettings);
|
||||||
logger.info(
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
// Reset exit tracking for this new process instance.
|
||||||
|
this.processExitCode = null;
|
||||||
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||||
const spawnEnv: Record<string, string | undefined> = {
|
const forkEnv: Record<string, string | undefined> = {
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
PATH: finalPath,
|
PATH: finalPath,
|
||||||
...providerEnv,
|
...providerEnv,
|
||||||
@@ -1082,29 +1046,17 @@ export class GatewayManager extends EventEmitter {
|
|||||||
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
||||||
OPENCLAW_SKIP_CHANNELS: '',
|
OPENCLAW_SKIP_CHANNELS: '',
|
||||||
CLAWDBOT_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.
|
// Inject fetch preload so OpenRouter requests carry ClawX headers.
|
||||||
// The preload patches globalThis.fetch before any module loads.
|
// The preload patches globalThis.fetch before any module loads.
|
||||||
try {
|
try {
|
||||||
const preloadPath = ensureGatewayFetchPreload();
|
const preloadPath = ensureGatewayFetchPreload();
|
||||||
if (existsSync(preloadPath)) {
|
if (existsSync(preloadPath)) {
|
||||||
spawnEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions(
|
forkEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions(
|
||||||
spawnEnv['NODE_OPTIONS'],
|
forkEnv['NODE_OPTIONS'],
|
||||||
preloadPath,
|
preloadPath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1112,17 +1064,13 @@ export class GatewayManager extends EventEmitter {
|
|||||||
logger.warn('Failed to set up OpenRouter headers preload:', err);
|
logger.warn('Failed to set up OpenRouter headers preload:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useShell = !app.isPackaged && process.platform === 'win32';
|
// utilityProcess.fork() runs the .mjs entry directly without spawning a
|
||||||
const spawnCmd = useShell ? quoteForCmd(command) : command;
|
// shell or visible console window. Works identically in dev and packaged.
|
||||||
const spawnArgs = useShell ? args.map(a => quoteForCmd(a)) : args;
|
this.process = utilityProcess.fork(entryScript, gatewayArgs, {
|
||||||
|
|
||||||
this.process = spawn(spawnCmd, spawnArgs, {
|
|
||||||
cwd: openclawDir,
|
cwd: openclawDir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: 'pipe',
|
||||||
detached: false,
|
env: forkEnv as NodeJS.ProcessEnv,
|
||||||
shell: useShell,
|
serviceName: 'OpenClaw Gateway',
|
||||||
windowsHide: true,
|
|
||||||
env: spawnEnv,
|
|
||||||
});
|
});
|
||||||
const child = this.process;
|
const child = this.process;
|
||||||
this.ownsProcess = true;
|
this.ownsProcess = true;
|
||||||
@@ -1133,10 +1081,11 @@ export class GatewayManager extends EventEmitter {
|
|||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code: number) => {
|
||||||
|
this.processExitCode = code;
|
||||||
const expectedExit = !this.shouldReconnect || this.status.state === 'stopped';
|
const expectedExit = !this.shouldReconnect || this.status.state === 'stopped';
|
||||||
const level = expectedExit ? logger.info : logger.warn;
|
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;
|
this.ownsProcess = false;
|
||||||
if (this.process === child) {
|
if (this.process === child) {
|
||||||
this.process = null;
|
this.process = null;
|
||||||
@@ -1149,9 +1098,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code, signal) => {
|
// UtilityProcess doesn't emit 'close'; stdout/stderr end naturally on exit.
|
||||||
logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log stderr
|
// Log stderr
|
||||||
child.stderr?.on('data', (data) => {
|
child.stderr?.on('data', (data) => {
|
||||||
@@ -1186,12 +1133,12 @@ export class GatewayManager extends EventEmitter {
|
|||||||
private async waitForReady(retries = 2400, interval = 250): Promise<void> {
|
private async waitForReady(retries = 2400, interval = 250): Promise<void> {
|
||||||
const child = this.process;
|
const child = this.process;
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
// Early exit if the gateway process has already exited
|
// Early exit if the gateway process has already exited.
|
||||||
if (child && (child.exitCode !== null || child.signalCode !== null)) {
|
// UtilityProcess has no synchronous exitCode/signalCode — use our tracked flag.
|
||||||
const code = child.exitCode;
|
if (child && this.processExitCode !== null) {
|
||||||
const signal = child.signalCode;
|
const code = this.processExitCode;
|
||||||
logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`);
|
logger.error(`Gateway process exited before ready (code=${code})`);
|
||||||
throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`);
|
throw new Error(`Gateway process exited before becoming ready (code=${code})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawx",
|
"name": "clawx",
|
||||||
"version": "0.1.22-alpha.2",
|
"version": "0.1.22-alpha.5",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@whiskeysockets/baileys",
|
"@whiskeysockets/baileys",
|
||||||
|
|||||||
@@ -3,29 +3,11 @@
|
|||||||
; Install: enables long paths, adds resources\cli to user PATH for openclaw CLI.
|
; Install: enables long paths, adds resources\cli to user PATH for openclaw CLI.
|
||||||
; Uninstall: removes the PATH entry and optionally deletes user data.
|
; Uninstall: removes the PATH entry and optionally deletes user data.
|
||||||
|
|
||||||
; When customCheckAppRunning is defined, electron-builder skips its conditional
|
!ifndef nsProcess::FindProcess
|
||||||
; !include for getProcessInfo.nsh and the "Var pid" declaration. We must do
|
|
||||||
; both ourselves so ${GetProcessInfo} and $pid are available.
|
|
||||||
!ifndef getProcessInfo_included
|
|
||||||
!define getProcessInfo_included
|
|
||||||
!include "getProcessInfo.nsh"
|
|
||||||
Var pid
|
|
||||||
!endif
|
|
||||||
!ifndef nsProcess_included
|
|
||||||
!define nsProcess_included
|
|
||||||
!include "nsProcess.nsh"
|
!include "nsProcess.nsh"
|
||||||
!endif
|
!endif
|
||||||
|
|
||||||
!macro customCheckAppRunning
|
!macro customCheckAppRunning
|
||||||
${GetProcessInfo} 0 $pid $1 $2 $3 $4
|
|
||||||
${if} $3 != "${APP_EXECUTABLE_FILENAME}"
|
|
||||||
${if} ${isUpdated}
|
|
||||||
# allow app to exit without explicit kill
|
|
||||||
Sleep 300
|
|
||||||
${endIf}
|
|
||||||
|
|
||||||
# Instead of launching cmd.exe /c tasklist, use the nsProcess plugin directly for all environments.
|
|
||||||
# This prevents the brief black cmd window from flashing.
|
|
||||||
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
|
${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0
|
||||||
|
|
||||||
${if} $R0 == 0
|
${if} $R0 == 0
|
||||||
@@ -78,8 +60,7 @@
|
|||||||
Goto loop
|
Goto loop
|
||||||
${endIf}
|
${endIf}
|
||||||
not_running:
|
not_running:
|
||||||
nsProcess::Unload
|
${nsProcess::Unload}
|
||||||
${endIf}
|
|
||||||
${endIf}
|
${endIf}
|
||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
|
|||||||
@@ -948,7 +948,8 @@ function ProviderContent({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate key if the provider requires one and a key was entered
|
// Validate key if the provider requires one and a key was entered
|
||||||
if (requiresKey && apiKey) {
|
const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey');
|
||||||
|
if (isApiKeyRequired && apiKey) {
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'provider:validateKey',
|
'provider:validateKey',
|
||||||
selectedProviderConfigId || selectedProvider,
|
selectedProviderConfigId || selectedProvider,
|
||||||
@@ -1024,9 +1025,10 @@ function ProviderContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Can the user submit?
|
// Can the user submit?
|
||||||
|
const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey');
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
selectedProvider
|
selectedProvider
|
||||||
&& (requiresKey ? apiKey.length > 0 : true)
|
&& (isApiKeyRequired ? apiKey.length > 0 : true)
|
||||||
&& (showModelIdField ? modelId.trim().length > 0 : true)
|
&& (showModelIdField ? modelId.trim().length > 0 : true)
|
||||||
&& !useOAuthFlow;
|
&& !useOAuthFlow;
|
||||||
|
|
||||||
@@ -1197,7 +1199,7 @@ function ProviderContent({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* API Key field (hidden for ollama) */}
|
{/* API Key field (hidden for ollama) */}
|
||||||
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && requiresKey && (
|
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
|
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
Reference in New Issue
Block a user