- Added telemetry utility to capture application events and metrics. - Integrated PostHog for event tracking with distinct user identification. - Implemented telemetry initialization, event capturing, and shutdown procedures. feat: add UV environment setup for Python management - Created utilities to manage Python installation and configuration. - Implemented network optimization checks for Python installation mirrors. - Added functions to set up managed Python environments with error handling. feat: enhance host API communication with token management - Introduced host API token retrieval and management for secure requests. - Updated host API fetch functions to include token in headers. - Added support for creating event sources with authentication. test: add comprehensive tests for gateway protocol and startup helpers - Implemented unit tests for gateway protocol helpers, event dispatching, and state management. - Added tests for startup recovery strategies and process policies. - Ensured coverage for connection monitoring and restart governance logic.
335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { app, utilityProcess } from 'electron';
|
|
import logManager from '@electron/service/logger';
|
|
import { appendNodeRequireToNodeOptions } from '@electron/utils/paths';
|
|
import type { GatewayLaunchContext } from './config-sync';
|
|
import { resolveGatewayLaunchStrategy, type GatewayLaunchStrategy } from './launch-strategy';
|
|
import type { GatewayProcessHandle } from './process-handle';
|
|
|
|
const GATEWAY_PRELOAD_SOURCE = `'use strict';
|
|
(function () {
|
|
if (process.platform !== 'win32') return;
|
|
try {
|
|
var cp = require('child_process');
|
|
if (cp.__znAiGatewayPatched) return;
|
|
cp.__znAiGatewayPatched = 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 candidate = args[i];
|
|
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
|
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 {
|
|
// best-effort
|
|
}
|
|
})();
|
|
`;
|
|
|
|
function ensureGatewayPreload(): string {
|
|
const destination = path.join(app.getPath('userData'), 'gateway-preload.cjs');
|
|
try {
|
|
writeFileSync(destination, GATEWAY_PRELOAD_SOURCE, 'utf-8');
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
return destination;
|
|
}
|
|
|
|
export interface LaunchGatewayProcessOptions {
|
|
port: number;
|
|
launchContext: GatewayLaunchContext;
|
|
sanitizeSpawnArgs?: (args: string[]) => string[];
|
|
onStdoutLine?: (line: string) => void;
|
|
onStderrLine?: (line: string) => void;
|
|
onSpawn?: (pid: number | undefined) => void;
|
|
onExit?: (child: GatewayProcessHandle, code: number | null) => void;
|
|
onError?: (error: Error) => void;
|
|
}
|
|
|
|
function emitProcessOutput(
|
|
child: GatewayProcessHandle,
|
|
stream: 'stdout' | 'stderr',
|
|
onLine: ((line: string) => void) | undefined,
|
|
): void {
|
|
const readable = child[stream];
|
|
if (!readable) {
|
|
return;
|
|
}
|
|
|
|
readable.on('data', (data) => {
|
|
const raw = data.toString();
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
|
|
if (onLine) {
|
|
onLine(trimmed);
|
|
continue;
|
|
}
|
|
|
|
if (stream === 'stdout') {
|
|
logManager.debug(`[OpenClaw stdout] ${trimmed}`);
|
|
continue;
|
|
}
|
|
|
|
logManager.warn(`[OpenClaw] ${trimmed}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildGatewayRuntimeEnv(
|
|
launchContext: GatewayLaunchContext,
|
|
): Record<string, string | undefined> {
|
|
const runtimeEnv = { ...launchContext.forkEnv };
|
|
if (!app.isPackaged) {
|
|
try {
|
|
const preloadPath = ensureGatewayPreload();
|
|
if (existsSync(preloadPath)) {
|
|
runtimeEnv.NODE_OPTIONS = appendNodeRequireToNodeOptions(
|
|
runtimeEnv.NODE_OPTIONS,
|
|
preloadPath,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logManager.warn('Failed to prepare Gateway preload', error);
|
|
}
|
|
}
|
|
|
|
return runtimeEnv;
|
|
}
|
|
|
|
function resolvePreferredLaunchStrategy(
|
|
launchContext: GatewayLaunchContext,
|
|
): GatewayLaunchStrategy {
|
|
return resolveGatewayLaunchStrategy({
|
|
platform: process.platform,
|
|
mode: launchContext.mode,
|
|
forced: process.env.ZN_AI_GATEWAY_LAUNCH_STRATEGY,
|
|
});
|
|
}
|
|
|
|
function resolveNodeRuntimeHost(options: {
|
|
launchContext: GatewayLaunchContext;
|
|
runtimeEnv: Record<string, string | undefined>;
|
|
}): {
|
|
command: string;
|
|
env: NodeJS.ProcessEnv;
|
|
hostKind: 'bundled-node' | 'electron-run-as-node';
|
|
} {
|
|
const bundledNodePath = process.platform === 'win32'
|
|
? path.join(options.launchContext.binDir, 'node.exe')
|
|
: '';
|
|
|
|
if (bundledNodePath && existsSync(bundledNodePath)) {
|
|
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...envWithoutElectronNode } = options.runtimeEnv;
|
|
return {
|
|
command: bundledNodePath,
|
|
env: envWithoutElectronNode as NodeJS.ProcessEnv,
|
|
hostKind: 'bundled-node',
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: process.execPath,
|
|
env: {
|
|
...options.runtimeEnv,
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
} as NodeJS.ProcessEnv,
|
|
hostKind: 'electron-run-as-node',
|
|
};
|
|
}
|
|
|
|
async function launchWithUtilityProcess(options: {
|
|
launchContext: GatewayLaunchContext;
|
|
runtimeEnv: Record<string, string | undefined>;
|
|
lastSpawnSummary: string;
|
|
onStdoutLine?: (line: string) => void;
|
|
onStderrLine?: (line: string) => void;
|
|
onSpawn?: (pid: number | undefined) => void;
|
|
onExit?: (child: GatewayProcessHandle, code: number | null) => void;
|
|
onError?: (error: Error) => void;
|
|
}): Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }> {
|
|
return await new Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }>((resolve, reject) => {
|
|
const child = utilityProcess.fork(
|
|
options.launchContext.entryScript,
|
|
options.launchContext.gatewayArgs,
|
|
{
|
|
cwd: options.launchContext.openclawDir,
|
|
stdio: 'pipe',
|
|
env: options.runtimeEnv as NodeJS.ProcessEnv,
|
|
serviceName: 'OpenClaw Gateway',
|
|
},
|
|
) as GatewayProcessHandle;
|
|
|
|
let settled = false;
|
|
|
|
const resolveOnce = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve({ child, lastSpawnSummary: options.lastSpawnSummary });
|
|
};
|
|
|
|
const rejectOnce = (error: Error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(error);
|
|
};
|
|
|
|
child.once('spawn', () => {
|
|
logManager.info('OpenClaw Gateway process spawned', {
|
|
pid: child.pid,
|
|
launchStrategy: 'utility-process',
|
|
});
|
|
options.onSpawn?.(child.pid);
|
|
resolveOnce();
|
|
});
|
|
|
|
child.once('error', (error) => {
|
|
logManager.error('OpenClaw Gateway process spawn error:', error);
|
|
options.onError?.(error);
|
|
rejectOnce(error);
|
|
});
|
|
|
|
child.once('exit', (code) => {
|
|
options.onExit?.(child, code ?? null);
|
|
if (!settled) {
|
|
rejectOnce(new Error(`OpenClaw Gateway exited before spawn completed (code=${code ?? 'unknown'})`));
|
|
}
|
|
});
|
|
|
|
emitProcessOutput(child, 'stdout', options.onStdoutLine);
|
|
emitProcessOutput(child, 'stderr', options.onStderrLine);
|
|
});
|
|
}
|
|
|
|
async function launchWithNodeRuntime(options: {
|
|
launchContext: GatewayLaunchContext;
|
|
runtimeEnv: Record<string, string | undefined>;
|
|
lastSpawnSummary: string;
|
|
onStdoutLine?: (line: string) => void;
|
|
onStderrLine?: (line: string) => void;
|
|
onSpawn?: (pid: number | undefined) => void;
|
|
onExit?: (child: GatewayProcessHandle, code: number | null) => void;
|
|
onError?: (error: Error) => void;
|
|
}): Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }> {
|
|
const runtimeHost = resolveNodeRuntimeHost({
|
|
launchContext: options.launchContext,
|
|
runtimeEnv: options.runtimeEnv,
|
|
});
|
|
|
|
return await new Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }>((resolve, reject) => {
|
|
const child = spawn(
|
|
runtimeHost.command,
|
|
[options.launchContext.entryScript, ...options.launchContext.gatewayArgs],
|
|
{
|
|
cwd: options.launchContext.openclawDir,
|
|
env: runtimeHost.env,
|
|
stdio: 'pipe',
|
|
windowsHide: true,
|
|
},
|
|
) as GatewayProcessHandle;
|
|
|
|
let settled = false;
|
|
|
|
const resolveOnce = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve({ child, lastSpawnSummary: options.lastSpawnSummary });
|
|
};
|
|
|
|
const rejectOnce = (error: Error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(error);
|
|
};
|
|
|
|
child.once('spawn', () => {
|
|
logManager.info('OpenClaw Gateway process spawned', {
|
|
pid: child.pid,
|
|
launchStrategy: 'node-runtime',
|
|
runtimeHost: runtimeHost.hostKind,
|
|
execPath: runtimeHost.command,
|
|
});
|
|
options.onSpawn?.(child.pid);
|
|
resolveOnce();
|
|
});
|
|
|
|
child.once('error', (error) => {
|
|
logManager.error('OpenClaw Gateway process spawn error:', error);
|
|
options.onError?.(error);
|
|
rejectOnce(error);
|
|
});
|
|
|
|
child.once('exit', (code) => {
|
|
options.onExit?.(child, code ?? null);
|
|
if (!settled) {
|
|
rejectOnce(new Error(`OpenClaw Gateway exited before spawn completed (code=${code ?? 'unknown'})`));
|
|
}
|
|
});
|
|
|
|
emitProcessOutput(child, 'stdout', options.onStdoutLine);
|
|
emitProcessOutput(child, 'stderr', options.onStderrLine);
|
|
});
|
|
}
|
|
|
|
export async function launchGatewayProcess(
|
|
options: LaunchGatewayProcessOptions,
|
|
): Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }> {
|
|
const { launchContext } = options;
|
|
const sanitizedArgs = options.sanitizeSpawnArgs?.(launchContext.gatewayArgs) ?? launchContext.gatewayArgs;
|
|
const launchStrategy = resolvePreferredLaunchStrategy(launchContext);
|
|
const lastSpawnSummary = `mode=${launchContext.mode}, launcher="${launchStrategy}", entry="${launchContext.entryScript}", args="${sanitizedArgs.join(' ')}", cwd="${launchContext.openclawDir}"`;
|
|
const runtimeEnv = buildGatewayRuntimeEnv(launchContext);
|
|
|
|
logManager.info('Starting OpenClaw Gateway process', {
|
|
port: options.port,
|
|
entryScript: launchContext.entryScript,
|
|
cwd: launchContext.openclawDir,
|
|
args: sanitizedArgs,
|
|
mode: launchContext.mode,
|
|
bundledBin: launchContext.binPathExists,
|
|
launchStrategy,
|
|
});
|
|
|
|
const launchOptions = {
|
|
launchContext,
|
|
runtimeEnv,
|
|
lastSpawnSummary,
|
|
onStdoutLine: options.onStdoutLine,
|
|
onStderrLine: options.onStderrLine,
|
|
onSpawn: options.onSpawn,
|
|
onExit: options.onExit,
|
|
onError: options.onError,
|
|
};
|
|
|
|
if (launchStrategy === 'node-runtime') {
|
|
return await launchWithNodeRuntime(launchOptions);
|
|
}
|
|
|
|
return await launchWithUtilityProcess(launchOptions);
|
|
}
|