Refactor clawx (#344)

Co-authored-by: ashione <skyzlxuan@gmail.com>
This commit is contained in:
paisley
2026-03-09 13:10:42 +08:00
committed by GitHub
Unverified
parent 3d804a9f5e
commit 2c5c82bb74
75 changed files with 7640 additions and 3106 deletions

View File

@@ -0,0 +1,182 @@
import { app } from 'electron';
import path from 'path';
import { existsSync } from 'fs';
import { getAllSettings } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { listConfiguredChannels } from '../utils/channel-config';
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
export interface GatewayLaunchContext {
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
openclawDir: string;
entryScript: string;
gatewayArgs: string[];
forkEnv: Record<string, string | undefined>;
mode: 'dev' | 'packaged';
binPathExists: boolean;
loadedProviderKeyCount: number;
proxySummary: string;
channelStartupSummary: string;
}
export async function syncGatewayConfigBeforeLaunch(
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
): Promise<void> {
await syncProxyConfigToOpenClaw(appSettings);
try {
await sanitizeOpenClawConfig();
} catch (err) {
logger.warn('Failed to sanitize openclaw.json:', err);
}
try {
await syncGatewayTokenToConfig(appSettings.gatewayToken);
} catch (err) {
logger.warn('Failed to sync gateway token to openclaw.json:', err);
}
try {
await syncBrowserConfigToOpenClaw();
} catch (err) {
logger.warn('Failed to sync browser config to openclaw.json:', err);
}
}
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
const providerEnv: Record<string, string> = {};
const providerTypes = getKeyableProviderTypes();
let loadedProviderKeyCount = 0;
try {
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId) {
const defaultProvider = await getProvider(defaultProviderId);
const defaultProviderType = defaultProvider?.type;
const defaultProviderKey = await getApiKey(defaultProviderId);
if (defaultProviderType && defaultProviderKey) {
const envVar = getProviderEnvVar(defaultProviderType);
if (envVar) {
providerEnv[envVar] = defaultProviderKey;
loadedProviderKeyCount++;
}
}
}
} catch (err) {
logger.warn('Failed to load default provider key for environment injection:', err);
}
for (const providerType of providerTypes) {
try {
const key = await getApiKey(providerType);
if (key) {
const envVar = getProviderEnvVar(providerType);
if (envVar) {
providerEnv[envVar] = key;
loadedProviderKeyCount++;
}
}
} catch (err) {
logger.warn(`Failed to load API key for ${providerType}:`, err);
}
}
return { providerEnv, loadedProviderKeyCount };
}
async function resolveChannelStartupPolicy(): Promise<{
skipChannels: boolean;
channelStartupSummary: string;
}> {
try {
const configuredChannels = await listConfiguredChannels();
if (configuredChannels.length === 0) {
return {
skipChannels: true,
channelStartupSummary: 'skipped(no configured channels)',
};
}
return {
skipChannels: false,
channelStartupSummary: `enabled(${configuredChannels.join(',')})`,
};
} catch (error) {
logger.warn('Failed to determine configured channels for gateway launch:', error);
return {
skipChannels: false,
channelStartupSummary: 'enabled(unknown)',
};
}
}
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
if (!isOpenClawPresent()) {
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
const appSettings = await getAllSettings();
await syncGatewayConfigBeforeLaunch(appSettings);
if (!existsSync(entryScript)) {
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
}
const gatewayArgs = ['gateway', '--port', String(port), '--token', appSettings.gatewayToken, '--allow-unconfigured'];
const mode = app.isPackaged ? 'packaged' : 'dev';
const platform = process.platform;
const arch = process.arch;
const target = `${platform}-${arch}`;
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || '';
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
const uvEnv = await getUvMirrorEnv();
const proxyEnv = buildProxyEnv(appSettings);
const resolvedProxy = resolveProxySettings(appSettings);
const proxySummary = appSettings.proxyEnabled
? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}`
: 'disabled';
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
const forkEnv: Record<string, string | undefined> = {
...baseEnv,
PATH: finalPath,
...providerEnv,
...uvEnv,
...proxyEnv,
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
OPENCLAW_NO_RESPAWN: '1',
};
return {
appSettings,
openclawDir,
entryScript,
gatewayArgs,
forkEnv,
mode,
binPathExists,
loadedProviderKeyCount,
proxySummary,
channelStartupSummary,
};
}

View File

@@ -0,0 +1,59 @@
import { logger } from '../utils/logger';
type HealthResult = { ok: boolean; error?: string };
export class GatewayConnectionMonitor {
private pingInterval: NodeJS.Timeout | null = null;
private healthCheckInterval: NodeJS.Timeout | null = null;
startPing(sendPing: () => void, intervalMs = 30000): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
}
this.pingInterval = setInterval(() => {
sendPing();
}, intervalMs);
}
startHealthCheck(options: {
shouldCheck: () => boolean;
checkHealth: () => Promise<HealthResult>;
onUnhealthy: (errorMessage: string) => void;
onError: (error: unknown) => void;
intervalMs?: number;
}): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
if (!options.shouldCheck()) {
return;
}
try {
const health = await options.checkHealth();
if (!health.ok) {
const errorMessage = health.error ?? 'Health check failed';
logger.warn(`Gateway health check failed: ${errorMessage}`);
options.onUnhealthy(errorMessage);
}
} catch (error) {
logger.error('Gateway health check error:', error);
options.onError(error);
}
}, options.intervalMs ?? 30000);
}
clear(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
}
}

View File

@@ -0,0 +1,63 @@
import { GatewayEventType, type JsonRpcNotification } from './protocol';
import { logger } from '../utils/logger';
type GatewayEventEmitter = {
emit: (event: string, payload: unknown) => boolean;
};
export function dispatchProtocolEvent(
emitter: GatewayEventEmitter,
event: string,
payload: unknown,
): void {
switch (event) {
case 'tick':
break;
case 'chat':
emitter.emit('chat:message', { message: payload });
break;
case 'agent': {
const p = payload as Record<string, unknown>;
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
const chatEvent: Record<string, unknown> = {
...data,
runId: p.runId ?? data.runId,
sessionKey: p.sessionKey ?? data.sessionKey,
state: p.state ?? data.state,
message: p.message ?? data.message,
};
if (chatEvent.state || chatEvent.message) {
emitter.emit('chat:message', { message: chatEvent });
}
emitter.emit('notification', { method: event, params: payload });
break;
}
case 'channel.status':
emitter.emit('channel:status', payload as { channelId: string; status: string });
break;
default:
emitter.emit('notification', { method: event, params: payload });
}
}
export function dispatchJsonRpcNotification(
emitter: GatewayEventEmitter,
notification: JsonRpcNotification,
): void {
emitter.emit('notification', notification);
switch (notification.method) {
case GatewayEventType.CHANNEL_STATUS_CHANGED:
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
break;
case GatewayEventType.MESSAGE_RECEIVED:
emitter.emit('chat:message', notification.params as { message: unknown });
break;
case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string };
emitter.emit('error', new Error(errorData.message || 'Gateway error'));
break;
}
default:
logger.debug(`Unknown Gateway notification: ${notification.method}`);
}
}

View File

@@ -0,0 +1,31 @@
import { logger } from '../utils/logger';
import { isLifecycleSuperseded, nextLifecycleEpoch } from './process-policy';
export class LifecycleSupersededError extends Error {
constructor(message: string) {
super(message);
this.name = 'LifecycleSupersededError';
}
}
export class GatewayLifecycleController {
private epoch = 0;
getCurrentEpoch(): number {
return this.epoch;
}
bump(reason: string): number {
this.epoch = nextLifecycleEpoch(this.epoch);
logger.debug(`Gateway lifecycle epoch advanced to ${this.epoch} (${reason})`);
return this.epoch;
}
assert(expectedEpoch: number, phase: string): void {
if (isLifecycleSuperseded(expectedEpoch, this.epoch)) {
throw new LifecycleSupersededError(
`Gateway ${phase} superseded (expectedEpoch=${expectedEpoch}, currentEpoch=${this.epoch})`,
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
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 };
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) => {
const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped';
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();
});
});
}

View File

@@ -1,3 +1,15 @@
export interface ReconnectConfig {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
}
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxAttempts: 10,
baseDelay: 1000,
maxDelay: 30000,
};
export function nextLifecycleEpoch(currentEpoch: number): number {
return currentEpoch + 1;
}
@@ -22,6 +34,53 @@ export function getReconnectSkipReason(context: ReconnectAttemptContext): string
return null;
}
export interface ReconnectScheduleContext {
shouldReconnect: boolean;
hasReconnectTimer: boolean;
reconnectAttempts: number;
maxAttempts: number;
baseDelay: number;
maxDelay: number;
}
export type ReconnectScheduleDecision =
| { action: 'skip'; reason: string }
| { action: 'already-scheduled' }
| { action: 'fail'; attempts: number; maxAttempts: number }
| { action: 'schedule'; nextAttempt: number; maxAttempts: number; delay: number };
export function getReconnectScheduleDecision(
context: ReconnectScheduleContext,
): ReconnectScheduleDecision {
if (!context.shouldReconnect) {
return { action: 'skip', reason: 'auto-reconnect disabled' };
}
if (context.hasReconnectTimer) {
return { action: 'already-scheduled' };
}
if (context.reconnectAttempts >= context.maxAttempts) {
return {
action: 'fail',
attempts: context.reconnectAttempts,
maxAttempts: context.maxAttempts,
};
}
const delay = Math.min(
context.baseDelay * Math.pow(2, context.reconnectAttempts),
context.maxDelay,
);
return {
action: 'schedule',
nextAttempt: context.reconnectAttempts + 1,
maxAttempts: context.maxAttempts,
delay,
};
}
export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
export interface RestartDeferralContext {

View File

@@ -0,0 +1,42 @@
export interface PendingGatewayRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
export function clearPendingGatewayRequests(
pendingRequests: Map<string, PendingGatewayRequest>,
error: Error,
): void {
for (const [, request] of pendingRequests) {
clearTimeout(request.timeout);
request.reject(error);
}
pendingRequests.clear();
}
export function resolvePendingGatewayRequest(
pendingRequests: Map<string, PendingGatewayRequest>,
id: string,
value: unknown,
): boolean {
const request = pendingRequests.get(id);
if (!request) return false;
clearTimeout(request.timeout);
pendingRequests.delete(id);
request.resolve(value);
return true;
}
export function rejectPendingGatewayRequest(
pendingRequests: Map<string, PendingGatewayRequest>,
id: string,
error: Error,
): boolean {
const request = pendingRequests.get(id);
if (!request) return false;
clearTimeout(request.timeout);
pendingRequests.delete(id);
request.reject(error);
return true;
}

View File

@@ -0,0 +1,91 @@
import { logger } from '../utils/logger';
import {
getDeferredRestartAction,
shouldDeferRestart,
type GatewayLifecycleState,
} from './process-policy';
type RestartDeferralState = {
state: GatewayLifecycleState;
startLock: boolean;
};
type DeferredRestartContext = RestartDeferralState & {
shouldReconnect: boolean;
};
export class GatewayRestartController {
private deferredRestartPending = false;
private restartDebounceTimer: NodeJS.Timeout | null = null;
isRestartDeferred(context: RestartDeferralState): boolean {
return shouldDeferRestart(context);
}
markDeferredRestart(reason: string, context: RestartDeferralState): void {
if (!this.deferredRestartPending) {
logger.info(
`Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${context.state}, startLock=${context.startLock})`,
);
} else {
logger.debug(
`Gateway restart already deferred; keeping pending request (${reason}, state=${context.state}, startLock=${context.startLock})`,
);
}
this.deferredRestartPending = true;
}
flushDeferredRestart(
trigger: string,
context: DeferredRestartContext,
executeRestart: () => void,
): void {
const action = getDeferredRestartAction({
hasPendingRestart: this.deferredRestartPending,
state: context.state,
startLock: context.startLock,
shouldReconnect: context.shouldReconnect,
});
if (action === 'none') return;
if (action === 'wait') {
logger.debug(
`Deferred Gateway restart still waiting (${trigger}, state=${context.state}, startLock=${context.startLock})`,
);
return;
}
this.deferredRestartPending = false;
if (action === 'drop') {
logger.info(
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`,
);
return;
}
logger.info(`Executing deferred Gateway restart now (${trigger})`);
executeRestart();
}
debouncedRestart(delayMs: number, executeRestart: () => void): void {
if (this.restartDebounceTimer) {
clearTimeout(this.restartDebounceTimer);
}
logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`);
this.restartDebounceTimer = setTimeout(() => {
this.restartDebounceTimer = null;
executeRestart();
}, delayMs);
}
clearDebounceTimer(): void {
if (this.restartDebounceTimer) {
clearTimeout(this.restartDebounceTimer);
this.restartDebounceTimer = null;
}
}
resetDeferredRestart(): void {
this.deferredRestartPending = false;
}
}

View File

@@ -0,0 +1,106 @@
import { logger } from '../utils/logger';
import { LifecycleSupersededError } from './lifecycle-controller';
import { getGatewayStartupRecoveryAction } from './startup-recovery';
export interface ExistingGatewayInfo {
port: number;
externalToken?: string;
}
type StartupHooks = {
port: number;
ownedPid?: number;
shouldWaitForPortFree: boolean;
maxStartAttempts?: number;
resetStartupStderrLines: () => void;
getStartupStderrLines: () => string[];
assertLifecycle: (phase: string) => void;
findExistingGateway: (port: number, ownedPid?: number) => Promise<ExistingGatewayInfo | null>;
connect: (port: number, externalToken?: string) => Promise<void>;
onConnectedToExistingGateway: () => void;
waitForPortFree: (port: number) => Promise<void>;
startProcess: () => Promise<void>;
waitForReady: (port: number) => Promise<void>;
onConnectedToManagedGateway: () => void;
runDoctorRepair: () => Promise<boolean>;
onDoctorRepairSuccess: () => void;
delay: (ms: number) => Promise<void>;
};
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
let configRepairAttempted = false;
let startAttempts = 0;
const maxStartAttempts = hooks.maxStartAttempts ?? 3;
while (true) {
startAttempts++;
hooks.assertLifecycle('start');
hooks.resetStartupStderrLines();
try {
logger.debug('Checking for existing Gateway...');
const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid);
hooks.assertLifecycle('start/find-existing');
if (existing) {
logger.debug(`Found existing Gateway on port ${existing.port}`);
await hooks.connect(existing.port, existing.externalToken);
hooks.assertLifecycle('start/connect-existing');
hooks.onConnectedToExistingGateway();
return;
}
logger.debug('No existing Gateway found, starting new process...');
if (hooks.shouldWaitForPortFree) {
await hooks.waitForPortFree(hooks.port);
hooks.assertLifecycle('start/wait-port');
}
await hooks.startProcess();
hooks.assertLifecycle('start/start-process');
await hooks.waitForReady(hooks.port);
hooks.assertLifecycle('start/wait-ready');
await hooks.connect(hooks.port);
hooks.assertLifecycle('start/connect');
hooks.onConnectedToManagedGateway();
return;
} catch (error) {
if (error instanceof LifecycleSupersededError) {
throw error;
}
const recoveryAction = getGatewayStartupRecoveryAction({
startupError: error,
startupStderrLines: hooks.getStartupStderrLines(),
configRepairAttempted,
attempt: startAttempts,
maxAttempts: maxStartAttempts,
});
if (recoveryAction === 'repair') {
configRepairAttempted = true;
logger.warn(
'Detected invalid OpenClaw config during Gateway startup; running doctor repair before retry',
);
const repaired = await hooks.runDoctorRepair();
if (repaired) {
logger.info('OpenClaw doctor repair completed; retrying Gateway startup');
hooks.onDoctorRepairSuccess();
continue;
}
logger.error('OpenClaw doctor repair failed; not retrying Gateway startup');
}
if (recoveryAction === 'retry') {
logger.warn(`Transient start error: ${String(error)}. Retrying... (${startAttempts}/${maxStartAttempts})`);
await hooks.delay(1000);
continue;
}
throw error;
}
}
}

View File

@@ -12,6 +12,14 @@ const INVALID_CONFIG_PATTERNS: RegExp[] = [
/\brun:\s*openclaw doctor --fix\b/i,
];
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
/WebSocket closed before handshake/i,
/ECONNREFUSED/i,
/Gateway process exited before becoming ready/i,
/Timed out waiting for connect\.challenge/i,
/Connect handshake timeout/i,
];
function normalizeLogLine(value: string): string {
return value.trim();
}
@@ -58,3 +66,34 @@ export function shouldAttemptConfigAutoRepair(
return hasInvalidConfigFailureSignal(startupError, startupStderrLines);
}
export function isTransientGatewayStartError(error: unknown): boolean {
const errorText = error instanceof Error
? `${error.name}: ${error.message}`
: String(error ?? '');
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
}
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
export function getGatewayStartupRecoveryAction(options: {
startupError: unknown;
startupStderrLines: string[];
configRepairAttempted: boolean;
attempt: number;
maxAttempts: number;
}): GatewayStartupRecoveryAction {
if (shouldAttemptConfigAutoRepair(
options.startupError,
options.startupStderrLines,
options.configRepairAttempted,
)) {
return 'repair';
}
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
return 'retry';
}
return 'fail';
}

View File

@@ -0,0 +1,42 @@
export type GatewayStderrClassification = {
level: 'drop' | 'debug' | 'warn';
normalized: string;
};
const MAX_STDERR_LINES = 120;
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
const msg = message.trim();
if (!msg) {
return { level: 'drop', normalized: msg };
}
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
return { level: 'drop', normalized: msg };
}
// Downgrade frequent non-fatal noise.
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
return { level: 'debug', normalized: msg };
}
return { level: 'warn', normalized: msg };
}
export function recordGatewayStartupStderrLine(lines: string[], line: string): void {
const normalized = line.trim();
if (!normalized) return;
lines.push(normalized);
if (lines.length > MAX_STDERR_LINES) {
lines.splice(0, lines.length - MAX_STDERR_LINES);
}
}

38
electron/gateway/state.ts Normal file
View File

@@ -0,0 +1,38 @@
import { PORTS } from '../utils/config';
import { logger } from '../utils/logger';
import type { GatewayStatus } from './manager';
type GatewayStateHooks = {
emitStatus: (status: GatewayStatus) => void;
onTransition?: (previousState: GatewayStatus['state'], nextState: GatewayStatus['state']) => void;
};
export class GatewayStateController {
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
constructor(private readonly hooks: GatewayStateHooks) {}
getStatus(): GatewayStatus {
return { ...this.status };
}
isConnected(isSocketOpen: boolean): boolean {
return this.status.state === 'running' && isSocketOpen;
}
setStatus(update: Partial<GatewayStatus>): void {
const previousState = this.status.state;
this.status = { ...this.status, ...update };
if (this.status.state === 'running' && this.status.connectedAt) {
this.status.uptime = Date.now() - this.status.connectedAt;
}
this.hooks.emitStatus(this.status);
if (previousState !== this.status.state) {
logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`);
this.hooks.onTransition?.(previousState, this.status.state);
}
}
}

View File

@@ -0,0 +1,348 @@
import { app, utilityProcess } from 'electron';
import path from 'path';
import { existsSync } from 'fs';
import WebSocket from 'ws';
import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
import { logger } from '../utils/logger';
export function warmupManagedPythonReadiness(): void {
void isPythonReady().then((pythonReady) => {
if (!pythonReady) {
logger.info('Python environment missing or incomplete, attempting background repair...');
void setupManagedPython().catch((err) => {
logger.error('Background Python repair failed:', err);
});
}
}).catch((err) => {
logger.error('Failed to check Python environment:', err);
});
}
export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise<void> {
let exited = false;
await new Promise<void>((resolve) => {
child.once('exit', () => {
exited = true;
resolve();
});
const pid = child.pid;
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
try {
child.kill();
} catch {
// ignore if already exited
}
const timeout = setTimeout(() => {
if (!exited) {
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// ignore
}
}
}
resolve();
}, 5000);
child.once('exit', () => {
clearTimeout(timeout);
});
});
}
export async function unloadLaunchctlGatewayService(): Promise<void> {
if (process.platform !== 'darwin') return;
try {
const uid = process.getuid?.();
if (uid === undefined) return;
const launchdLabel = 'ai.openclaw.gateway';
const serviceTarget = `gui/${uid}/${launchdLabel}`;
const cp = await import('child_process');
const fsPromises = await import('fs/promises');
const os = await import('os');
const loaded = await new Promise<boolean>((resolve) => {
cp.exec(`launchctl print ${serviceTarget}`, { timeout: 5000 }, (err) => {
resolve(!err);
});
});
if (!loaded) return;
logger.info(`Unloading launchctl service ${serviceTarget} to prevent auto-respawn`);
await new Promise<void>((resolve) => {
cp.exec(`launchctl bootout ${serviceTarget}`, { timeout: 10000 }, (err) => {
if (err) {
logger.warn(`Failed to bootout launchctl service: ${err.message}`);
} else {
logger.info('Successfully unloaded launchctl gateway service');
}
resolve();
});
});
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${launchdLabel}.plist`);
await fsPromises.access(plistPath);
await fsPromises.unlink(plistPath);
logger.info(`Removed legacy launchd plist to prevent reload on next login: ${plistPath}`);
} catch {
// File doesn't exist or can't be removed -- not fatal
}
} catch (err) {
logger.warn('Error while unloading launchctl gateway service:', err);
}
}
export async function waitForPortFree(port: number, timeoutMs = 30000): Promise<void> {
const net = await import('net');
const start = Date.now();
const pollInterval = 500;
let logged = false;
while (Date.now() - start < timeoutMs) {
const available = await new Promise<boolean>((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
if (available) {
const elapsed = Date.now() - start;
if (elapsed > pollInterval) {
logger.info(`Port ${port} became available after ${elapsed}ms`);
}
return;
}
if (!logged) {
logger.info(`Waiting for port ${port} to become available (Windows TCP TIME_WAIT)...`);
logged = true;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`);
}
async function getListeningProcessIds(port: number): Promise<string[]> {
const cmd = process.platform === 'win32'
? `netstat -ano | findstr :${port}`
: `lsof -i :${port} -sTCP:LISTEN -t`;
const cp = await import('child_process');
const { stdout } = await new Promise<{ stdout: string }>((resolve) => {
cp.exec(cmd, { timeout: 5000, windowsHide: true }, (err, stdout) => {
if (err) {
resolve({ stdout: '' });
} else {
resolve({ stdout });
}
});
});
if (!stdout.trim()) {
return [];
}
if (process.platform === 'win32') {
const pids: string[] = [];
for (const line of stdout.trim().split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 5 && parts[3] === 'LISTENING') {
pids.push(parts[4]);
}
}
return [...new Set(pids)];
}
return [...new Set(stdout.trim().split(/\r?\n/).map((value) => value.trim()).filter(Boolean))];
}
async function terminateOrphanedProcessIds(port: number, pids: string[]): Promise<void> {
logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`);
if (process.platform === 'darwin') {
await unloadLaunchctlGatewayService();
}
for (const pid of pids) {
try {
if (process.platform === 'win32') {
const cp = await import('child_process');
await new Promise<void>((resolve) => {
cp.exec(
`taskkill /F /PID ${pid} /T`,
{ timeout: 5000, windowsHide: true },
() => resolve(),
);
});
} else {
process.kill(parseInt(pid, 10), 'SIGTERM');
}
} catch {
// Ignore processes that have already exited.
}
}
await new Promise((resolve) => setTimeout(resolve, process.platform === 'win32' ? 2000 : 3000));
if (process.platform !== 'win32') {
for (const pid of pids) {
try {
process.kill(parseInt(pid, 10), 0);
process.kill(parseInt(pid, 10), 'SIGKILL');
} catch {
// Already exited.
}
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
export async function findExistingGatewayProcess(options: {
port: number;
ownedPid?: number;
}): Promise<{ port: number; externalToken?: string } | null> {
const { port, ownedPid } = options;
try {
try {
const pids = await getListeningProcessIds(port);
if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) {
await terminateOrphanedProcessIds(port, pids);
return null;
}
} catch (err) {
logger.warn('Error checking for existing process on port:', err);
}
return await new Promise<{ port: number; externalToken?: string } | null>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
const timeout = setTimeout(() => {
testWs.close();
resolve(null);
}, 2000);
testWs.on('open', () => {
clearTimeout(timeout);
testWs.close();
resolve({ port });
});
testWs.on('error', () => {
clearTimeout(timeout);
resolve(null);
});
});
} catch {
return null;
}
}
export async function runOpenClawDoctorRepair(): Promise<boolean> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
if (!existsSync(entryScript)) {
logger.error(`Cannot run OpenClaw doctor repair: entry script not found at ${entryScript}`);
return false;
}
const platform = process.platform;
const arch = process.arch;
const target = `${platform}-${arch}`;
const binPath = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(process.cwd(), 'resources', 'bin', target);
const binPathExists = existsSync(binPath);
const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || '';
const uvEnv = await getUvMirrorEnv();
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
logger.info(
`Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
);
return await new Promise<boolean>((resolve) => {
const forkEnv: Record<string, string | undefined> = {
...process.env,
PATH: finalPath,
...uvEnv,
OPENCLAW_NO_RESPAWN: '1',
};
const child = utilityProcess.fork(entryScript, doctorArgs, {
cwd: openclawDir,
stdio: 'pipe',
env: forkEnv as NodeJS.ProcessEnv,
});
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
const timeout = setTimeout(() => {
logger.error('OpenClaw doctor repair timed out after 120000ms');
try {
child.kill();
} catch {
// ignore
}
finish(false);
}, 120000);
child.on('error', (err) => {
clearTimeout(timeout);
logger.error('Failed to spawn OpenClaw doctor repair process:', err);
finish(false);
});
child.stdout?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
const normalized = line.trim();
if (!normalized) continue;
logger.debug(`[Gateway doctor stdout] ${normalized}`);
}
});
child.stderr?.on('data', (data) => {
const raw = data.toString();
for (const line of raw.split(/\r?\n/)) {
const normalized = line.trim();
if (!normalized) continue;
logger.warn(`[Gateway doctor stderr] ${normalized}`);
}
});
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 (code=${code})`);
finish(false);
});
});
}

View File

@@ -0,0 +1,318 @@
import WebSocket from 'ws';
import type { DeviceIdentity } from '../utils/device-identity';
import type { PendingGatewayRequest } from './request-store';
import {
buildDeviceAuthPayload,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from '../utils/device-identity';
import { logger } from '../utils/logger';
export async function probeGatewayReady(
port: number,
timeoutMs = 1500,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
let settled = false;
const resolveOnce = (value: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
testWs.close();
} catch {
// ignore
}
resolve(value);
};
const timeout = setTimeout(() => {
resolveOnce(false);
}, timeoutMs);
testWs.on('open', () => {
// Do not resolve on plain socket open. The gateway can accept the TCP/WebSocket
// connection before it is ready to issue protocol challenges, which previously
// caused a false "ready" result and then a full connect() stall.
});
testWs.on('message', (data) => {
try {
const message = JSON.parse(data.toString()) as { type?: string; event?: string };
if (message.type === 'event' && message.event === 'connect.challenge') {
resolveOnce(true);
}
} catch {
// ignore malformed probe payloads
}
});
testWs.on('error', () => {
resolveOnce(false);
});
testWs.on('close', () => {
resolveOnce(false);
});
});
}
export async function waitForGatewayReady(options: {
port: number;
getProcessExitCode: () => number | null;
retries?: number;
intervalMs?: number;
}): Promise<void> {
const retries = options.retries ?? 2400;
const intervalMs = options.intervalMs ?? 200;
for (let i = 0; i < retries; i++) {
const exitCode = options.getProcessExitCode();
if (exitCode !== null) {
logger.error(`Gateway process exited before ready (code=${exitCode})`);
throw new Error(`Gateway process exited before becoming ready (code=${exitCode})`);
}
try {
const ready = await probeGatewayReady(options.port, 1500);
if (ready) {
logger.debug(`Gateway ready after ${i + 1} attempt(s)`);
return;
}
} catch {
// Gateway not ready yet.
}
if (i > 0 && i % 10 === 0) {
logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
logger.error(`Gateway failed to become ready after ${retries} attempts on port ${options.port}`);
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
}
export function buildGatewayConnectFrame(options: {
challengeNonce: string;
token: string;
deviceIdentity: DeviceIdentity | null;
platform: string;
}): { connectId: string; frame: Record<string, unknown> } {
const connectId = `connect-${Date.now()}`;
const role = 'operator';
const scopes = ['operator.admin'];
const signedAtMs = Date.now();
const clientId = 'gateway-client';
const clientMode = 'ui';
const device = (() => {
if (!options.deviceIdentity) return undefined;
const payload = buildDeviceAuthPayload({
deviceId: options.deviceIdentity.deviceId,
clientId,
clientMode,
role,
scopes,
signedAtMs,
token: options.token ?? null,
nonce: options.challengeNonce,
});
const signature = signDevicePayload(options.deviceIdentity.privateKeyPem, payload);
return {
id: options.deviceIdentity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
nonce: options.challengeNonce,
};
})();
return {
connectId,
frame: {
type: 'req',
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: clientId,
displayName: 'ClawX',
version: '0.1.0',
platform: options.platform,
mode: clientMode,
},
auth: {
token: options.token,
},
caps: [],
role,
scopes,
device,
},
},
};
}
export async function connectGatewaySocket(options: {
port: number;
deviceIdentity: DeviceIdentity | null;
platform: string;
pendingRequests: Map<string, PendingGatewayRequest>;
getToken: () => Promise<string>;
onHandshakeComplete: (ws: WebSocket) => void;
onMessage: (message: unknown) => void;
onCloseAfterHandshake: () => void;
}): Promise<WebSocket> {
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
return await new Promise<WebSocket>((resolve, reject) => {
const wsUrl = `ws://localhost:${options.port}/ws`;
const ws = new WebSocket(wsUrl);
let handshakeComplete = false;
let connectId: string | null = null;
let handshakeTimeout: NodeJS.Timeout | null = null;
let challengeTimer: NodeJS.Timeout | null = null;
let challengeReceived = false;
let settled = false;
const cleanupHandshakeRequest = () => {
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
if (handshakeTimeout) {
clearTimeout(handshakeTimeout);
handshakeTimeout = null;
}
if (connectId && options.pendingRequests.has(connectId)) {
const request = options.pendingRequests.get(connectId);
if (request) {
clearTimeout(request.timeout);
}
options.pendingRequests.delete(connectId);
}
};
const resolveOnce = () => {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
resolve(ws);
};
const rejectOnce = (error: unknown) => {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
reject(error instanceof Error ? error : new Error(String(error)));
};
const sendConnectHandshake = async (challengeNonce: string) => {
logger.debug('Sending connect handshake with challenge nonce');
const currentToken = await options.getToken();
const connectPayload = buildGatewayConnectFrame({
challengeNonce,
token: currentToken,
deviceIdentity: options.deviceIdentity,
platform: options.platform,
});
connectId = connectPayload.connectId;
ws.send(JSON.stringify(connectPayload.frame));
const requestTimeout = setTimeout(() => {
if (!handshakeComplete) {
logger.error('Gateway connect handshake timed out');
ws.close();
rejectOnce(new Error('Connect handshake timeout'));
}
}, 10000);
handshakeTimeout = requestTimeout;
options.pendingRequests.set(connectId, {
resolve: () => {
handshakeComplete = true;
logger.debug('Gateway connect handshake completed');
options.onHandshakeComplete(ws);
resolveOnce();
},
reject: (error) => {
logger.error('Gateway connect handshake failed:', error);
rejectOnce(error);
},
timeout: requestTimeout,
});
};
challengeTimer = setTimeout(() => {
if (!challengeReceived && !settled) {
logger.error('Gateway connect.challenge not received within timeout');
ws.close();
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
}
}, 10000);
ws.on('open', () => {
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (
!challengeReceived &&
typeof message === 'object' && message !== null &&
message.type === 'event' && message.event === 'connect.challenge'
) {
challengeReceived = true;
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
const nonce = message.payload?.nonce as string | undefined;
if (!nonce) {
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
return;
}
logger.debug('Received connect.challenge, sending handshake');
void sendConnectHandshake(nonce);
return;
}
options.onMessage(message);
} catch (error) {
logger.debug('Failed to parse Gateway WebSocket message:', error);
}
});
ws.on('close', (code, reason) => {
const reasonStr = reason?.toString() || 'unknown';
logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`);
if (!handshakeComplete) {
rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`));
return;
}
cleanupHandshakeRequest();
options.onCloseAfterHandshake();
});
ws.on('error', (error) => {
if (error.message?.includes('closed before handshake') || (error as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
logger.debug(`Gateway WebSocket connection error (transient): ${error.message}`);
} else {
logger.error('Gateway WebSocket error:', error);
}
if (!handshakeComplete) {
rejectOnce(error);
}
});
});
}