feat(settings): auto-set default provider after successful OAuth (#254)

This commit is contained in:
paisley
2026-03-02 15:13:55 +08:00
committed by GitHub
Unverified
parent 19406757f1
commit 29ef9591cf
2 changed files with 160 additions and 146 deletions

View File

@@ -9,10 +9,10 @@ import { EventEmitter } from 'events';
import { existsSync, writeFileSync } from 'fs'; import { existsSync, writeFileSync } from 'fs';
import WebSocket from 'ws'; import WebSocket from 'ws';
import { PORTS } from '../utils/config'; import { PORTS } from '../utils/config';
import { import {
getOpenClawDir, getOpenClawDir,
getOpenClawEntryPath, getOpenClawEntryPath,
isOpenClawBuilt, isOpenClawBuilt,
isOpenClawPresent, isOpenClawPresent,
appendNodeRequireToNodeOptions, appendNodeRequireToNodeOptions,
quoteForCmd, quoteForCmd,
@@ -185,7 +185,7 @@ export class GatewayManager extends EventEmitter {
}> = new Map(); }> = new Map();
private deviceIdentity: DeviceIdentity | null = null; private deviceIdentity: DeviceIdentity | null = null;
private restartDebounceTimer: NodeJS.Timeout | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null;
constructor(config?: Partial<ReconnectConfig>) { constructor(config?: Partial<ReconnectConfig>) {
super(); super();
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config }; this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
@@ -244,21 +244,21 @@ export class GatewayManager extends EventEmitter {
this.recentStartupStderrLines.splice(0, this.recentStartupStderrLines.length - MAX_STDERR_LINES); this.recentStartupStderrLines.splice(0, this.recentStartupStderrLines.length - MAX_STDERR_LINES);
} }
} }
/** /**
* Get current Gateway status * Get current Gateway status
*/ */
getStatus(): GatewayStatus { getStatus(): GatewayStatus {
return { ...this.status }; return { ...this.status };
} }
/** /**
* Check if Gateway is connected and ready * Check if Gateway is connected and ready
*/ */
isConnected(): boolean { isConnected(): boolean {
return this.status.state === 'running' && this.ws?.readyState === WebSocket.OPEN; return this.status.state === 'running' && this.ws?.readyState === WebSocket.OPEN;
} }
/** /**
* Start Gateway process * Start Gateway process
*/ */
@@ -272,7 +272,7 @@ export class GatewayManager extends EventEmitter {
logger.debug('Gateway already running, skipping start'); logger.debug('Gateway already running, skipping start');
return; return;
} }
this.startLock = true; this.startLock = true;
logger.info(`Gateway start requested (port=${this.status.port})`); logger.info(`Gateway start requested (port=${this.status.port})`);
this.lastSpawnSummary = null; this.lastSpawnSummary = null;
@@ -305,7 +305,7 @@ export class GatewayManager extends EventEmitter {
}).catch(err => { }).catch(err => {
logger.error('Failed to check Python environment:', err); logger.error('Failed to check Python environment:', err);
}); });
try { try {
while (true) { while (true) {
this.recentStartupStderrLines = []; this.recentStartupStderrLines = [];
@@ -321,18 +321,18 @@ export class GatewayManager extends EventEmitter {
this.startHealthCheck(); this.startHealthCheck();
return; return;
} }
logger.debug('No existing Gateway found, starting new process...'); logger.debug('No existing Gateway found, starting new process...');
// Start new Gateway process // Start new Gateway process
await this.startProcess(); await this.startProcess();
// Wait for Gateway to be ready // Wait for Gateway to be ready
await this.waitForReady(); await this.waitForReady();
// Connect WebSocket // Connect WebSocket
await this.connect(this.status.port); await this.connect(this.status.port);
// Start health monitoring // Start health monitoring
this.startHealthCheck(); this.startHealthCheck();
logger.debug('Gateway started successfully'); logger.debug('Gateway started successfully');
@@ -354,7 +354,7 @@ export class GatewayManager extends EventEmitter {
throw error; throw error;
} }
} }
} catch (error) { } catch (error) {
logger.error( logger.error(
`Gateway start failed (port=${this.status.port}, reconnectAttempts=${this.reconnectAttempts}, spawn=${this.lastSpawnSummary ?? 'n/a'})`, `Gateway start failed (port=${this.status.port}, reconnectAttempts=${this.reconnectAttempts}, spawn=${this.lastSpawnSummary ?? 'n/a'})`,
@@ -366,7 +366,7 @@ export class GatewayManager extends EventEmitter {
this.startLock = false; this.startLock = false;
} }
} }
/** /**
* Stop Gateway process * Stop Gateway process
*/ */
@@ -374,10 +374,10 @@ export class GatewayManager extends EventEmitter {
logger.info('Gateway stop requested'); logger.info('Gateway stop requested');
// Disable auto-reconnect // Disable auto-reconnect
this.shouldReconnect = false; this.shouldReconnect = false;
// Clear all timers // Clear all timers
this.clearAllTimers(); this.clearAllTimers();
// If this manager is attached to an external gateway process, ask it to shut down // If this manager is attached to an external gateway process, ask it to shut down
// over protocol before closing the socket. // over protocol before closing the socket.
if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) { if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) {
@@ -393,17 +393,17 @@ export class GatewayManager extends EventEmitter {
this.ws.close(1000, 'Gateway stopped by user'); this.ws.close(1000, 'Gateway stopped by user');
this.ws = null; this.ws = null;
} }
// Kill process // Kill process
if (this.process && this.ownsProcess) { if (this.process && this.ownsProcess) {
const child = this.process; const child = this.process;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
// If process already exited, resolve immediately // If process already exited, resolve immediately
if (child.exitCode !== null || child.signalCode !== null) { if (child.exitCode !== null || child.signalCode !== null) {
return resolve(); return resolve();
} }
// Kill the entire process group so respawned children are also terminated. // Kill the entire process group so respawned children are also terminated.
// The gateway entry script may respawn itself; killing only the parent PID // The gateway entry script may respawn itself; killing only the parent PID
// leaves the child orphaned (PPID=1) and still holding the port. // leaves the child orphaned (PPID=1) and still holding the port.
@@ -413,7 +413,7 @@ export class GatewayManager extends EventEmitter {
try { process.kill(-pid, 'SIGTERM'); } catch { /* group kill failed, fall back */ } try { process.kill(-pid, 'SIGTERM'); } catch { /* group kill failed, fall back */ }
} }
child.kill('SIGTERM'); child.kill('SIGTERM');
// Force kill after timeout // Force kill after timeout
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) { if (child.exitCode === null && child.signalCode === null) {
@@ -430,29 +430,29 @@ export class GatewayManager extends EventEmitter {
clearTimeout(timeout); clearTimeout(timeout);
resolve(); resolve();
}); });
child.once('error', () => { child.once('error', () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(); resolve();
}); });
}); });
if (this.process === child) { if (this.process === child) {
this.process = null; this.process = null;
} }
} }
this.ownsProcess = false; this.ownsProcess = false;
// Reject all pending requests // Reject all pending requests
for (const [, request] of this.pendingRequests) { for (const [, request] of this.pendingRequests) {
clearTimeout(request.timeout); clearTimeout(request.timeout);
request.reject(new Error('Gateway stopped')); request.reject(new Error('Gateway stopped'));
} }
this.pendingRequests.clear(); this.pendingRequests.clear();
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined }); this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined });
} }
/** /**
* Restart Gateway process * Restart Gateway process
*/ */
@@ -481,7 +481,7 @@ export class GatewayManager extends EventEmitter {
}); });
}, delayMs); }, delayMs);
} }
/** /**
* Clear all active timers * Clear all active timers
*/ */
@@ -503,7 +503,7 @@ export class GatewayManager extends EventEmitter {
this.restartDebounceTimer = null; this.restartDebounceTimer = null;
} }
} }
/** /**
* Make an RPC call to the Gateway * Make an RPC call to the Gateway
* Uses OpenClaw protocol format: { type: "req", id: "...", method: "...", params: {...} } * Uses OpenClaw protocol format: { type: "req", id: "...", method: "...", params: {...} }
@@ -514,22 +514,22 @@ export class GatewayManager extends EventEmitter {
reject(new Error('Gateway not connected')); reject(new Error('Gateway not connected'));
return; return;
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
// Set timeout for request // Set timeout for request
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.pendingRequests.delete(id); this.pendingRequests.delete(id);
reject(new Error(`RPC timeout: ${method}`)); reject(new Error(`RPC timeout: ${method}`));
}, timeoutMs); }, timeoutMs);
// Store pending request // Store pending request
this.pendingRequests.set(id, { this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void, resolve: resolve as (value: unknown) => void,
reject, reject,
timeout, timeout,
}); });
// Send request using OpenClaw protocol format // Send request using OpenClaw protocol format
const request = { const request = {
type: 'req', type: 'req',
@@ -537,7 +537,7 @@ export class GatewayManager extends EventEmitter {
method, method,
params, params,
}; };
try { try {
this.ws.send(JSON.stringify(request)); this.ws.send(JSON.stringify(request));
} catch (error) { } catch (error) {
@@ -547,7 +547,7 @@ export class GatewayManager extends EventEmitter {
} }
}); });
} }
/** /**
* Start health check monitoring * Start health check monitoring
*/ */
@@ -555,12 +555,12 @@ export class GatewayManager extends EventEmitter {
if (this.healthCheckInterval) { if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval); clearInterval(this.healthCheckInterval);
} }
this.healthCheckInterval = setInterval(async () => { this.healthCheckInterval = setInterval(async () => {
if (this.status.state !== 'running') { if (this.status.state !== 'running') {
return; return;
} }
try { try {
const health = await this.checkHealth(); const health = await this.checkHealth();
if (!health.ok) { if (!health.ok) {
@@ -572,7 +572,7 @@ export class GatewayManager extends EventEmitter {
} }
}, 30000); // Check every 30 seconds }, 30000); // Check every 30 seconds
} }
/** /**
* Check Gateway health via WebSocket ping * Check Gateway health via WebSocket ping
* OpenClaw Gateway doesn't have an HTTP /health endpoint * OpenClaw Gateway doesn't have an HTTP /health endpoint
@@ -580,7 +580,7 @@ export class GatewayManager extends EventEmitter {
async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> { async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> {
try { try {
if (this.ws && this.ws.readyState === WebSocket.OPEN) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const uptime = this.status.connectedAt const uptime = this.status.connectedAt
? Math.floor((Date.now() - this.status.connectedAt) / 1000) ? Math.floor((Date.now() - this.status.connectedAt) / 1000)
: undefined; : undefined;
return { ok: true, uptime }; return { ok: true, uptime };
@@ -590,7 +590,7 @@ export class GatewayManager extends EventEmitter {
return { ok: false, error: String(error) }; return { ok: false, error: String(error) };
} }
} }
/** /**
* Unload the system-managed openclaw gateway launchctl service if it is * Unload the system-managed openclaw gateway launchctl service if it is
* loaded. Without this, killing the process only causes launchctl to * loaded. Without this, killing the process only causes launchctl to
@@ -654,7 +654,7 @@ export class GatewayManager extends EventEmitter {
private async findExistingGateway(): Promise<{ port: number, externalToken?: string } | null> { private async findExistingGateway(): Promise<{ port: number, externalToken?: string } | null> {
try { try {
const port = PORTS.OPENCLAW_GATEWAY; const port = PORTS.OPENCLAW_GATEWAY;
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. // On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead.
@@ -670,46 +670,46 @@ export class GatewayManager extends EventEmitter {
}); });
}).catch(reject); }).catch(reject);
}); });
if (stdout.trim()) { if (stdout.trim()) {
const pids = stdout.trim().split(/\r?\n/) const pids = stdout.trim().split(/\r?\n/)
.map(s => s.trim()) .map(s => s.trim())
.filter(Boolean); .filter(Boolean);
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))) {
logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`); logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`);
// Unload the launchctl service first so macOS doesn't auto- // Unload the launchctl service first so macOS doesn't auto-
// respawn the process we're about to kill. // respawn the process we're about to kill.
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
await this.unloadLaunchctlService(); await this.unloadLaunchctlService();
} }
// Terminate orphaned processes // Terminate orphaned processes
for (const pid of pids) { for (const pid of pids) {
try { try {
if (process.platform === 'win32') { if (process.platform === 'win32') {
// On Windows, use taskkill for reliable process group termination // On Windows, use taskkill for reliable process group termination
import('child_process').then(cp => { import('child_process').then(cp => {
cp.exec(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }, () => {}); cp.exec(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }, () => { });
}).catch(() => {}); }).catch(() => { });
} else { } else {
// SIGTERM first so the gateway can clean up its lock file. // SIGTERM first so the gateway can clean up its lock file.
process.kill(parseInt(pid), 'SIGTERM'); process.kill(parseInt(pid), 'SIGTERM');
} }
} catch { /* ignore */ } } catch { /* ignore */ }
} }
await new Promise(r => setTimeout(r, process.platform === 'win32' ? 2000 : 3000)); await new Promise(r => setTimeout(r, process.platform === 'win32' ? 2000 : 3000));
// SIGKILL any survivors (Unix only — Windows taskkill /F is already forceful) // SIGKILL any survivors (Unix only — Windows taskkill /F is already forceful)
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
for (const pid of pids) { for (const pid of pids) {
try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ } try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ }
} }
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
} }
return null; return null;
} }
} }
} }
@@ -724,13 +724,13 @@ export class GatewayManager extends EventEmitter {
testWs.close(); testWs.close();
resolve(null); resolve(null);
}, 2000); }, 2000);
testWs.on('open', () => { testWs.on('open', () => {
clearTimeout(timeout); clearTimeout(timeout);
testWs.close(); testWs.close();
resolve({ port }); resolve({ port });
}); });
testWs.on('error', () => { testWs.on('error', () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(null); resolve(null);
@@ -739,7 +739,7 @@ export class GatewayManager extends EventEmitter {
} catch { } catch {
// Gateway not running // Gateway not running
} }
return null; return null;
} }
@@ -785,7 +785,7 @@ export class GatewayManager extends EventEmitter {
spawnEnv['OPENCLAW_NO_RESPAWN'] = '1'; spawnEnv['OPENCLAW_NO_RESPAWN'] = '1';
const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? ''; const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? '';
if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') && if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') &&
!existingNodeOpts.includes('--no-warnings')) { !existingNodeOpts.includes('--no-warnings')) {
spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim(); spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim();
} }
} }
@@ -851,7 +851,7 @@ export class GatewayManager extends EventEmitter {
}); });
}); });
} }
/** /**
* Start Gateway process * Start Gateway process
* Uses OpenClaw npm package from node_modules (dev) or resources (production) * Uses OpenClaw npm package from node_modules (dev) or resources (production)
@@ -862,14 +862,14 @@ export class GatewayManager extends EventEmitter {
const openclawDir = getOpenClawDir(); const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath(); const entryScript = getOpenClawEntryPath();
// Verify OpenClaw package exists // Verify OpenClaw package exists
if (!isOpenClawPresent()) { if (!isOpenClawPresent()) {
const errMsg = `OpenClaw package not found at: ${openclawDir}`; const errMsg = `OpenClaw package not found at: ${openclawDir}`;
logger.error(errMsg); logger.error(errMsg);
throw new Error(errMsg); throw new Error(errMsg);
} }
// Get or generate gateway token // Get or generate gateway token
const gatewayToken = await getSetting('gatewayToken'); const gatewayToken = await getSetting('gatewayToken');
@@ -900,17 +900,17 @@ export class GatewayManager extends EventEmitter {
} catch (err) { } catch (err) {
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; let command: string;
let args: string[]; let args: string[];
let mode: 'packaged' | 'dev-built' | 'dev-pnpm'; let mode: 'packaged' | 'dev-built' | 'dev-pnpm';
// Determine the Node.js executable // Determine the Node.js executable
// In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1 // In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1
// which makes the Electron binary behave as plain Node.js. // which makes the Electron binary behave as plain Node.js.
// In development, use system 'node'. // In development, use system 'node'.
const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured']; const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured'];
if (app.isPackaged) { if (app.isPackaged) {
// Production: use Electron binary as Node.js via ELECTRON_RUN_AS_NODE // Production: use Electron binary as Node.js via ELECTRON_RUN_AS_NODE
// On macOS, use the Electron Helper binary to avoid extra dock icons // On macOS, use the Electron Helper binary to avoid extra dock icons
@@ -948,7 +948,7 @@ export class GatewayManager extends EventEmitter {
const finalPath = binPathExists const finalPath = binPathExists
? `${binPath}${path.delimiter}${process.env.PATH || ''}` ? `${binPath}${path.delimiter}${process.env.PATH || ''}`
: process.env.PATH || ''; : process.env.PATH || '';
// Load provider API keys from storage to pass as environment variables // Load provider API keys from storage to pass as environment variables
const providerEnv: Record<string, string> = {}; const providerEnv: Record<string, string> = {};
const providerTypes = getKeyableProviderTypes(); const providerTypes = getKeyableProviderTypes();
@@ -993,7 +993,7 @@ export class GatewayManager extends EventEmitter {
`Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount})` `Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount})`
); );
this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`; this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const spawnEnv: Record<string, string | undefined> = { const spawnEnv: Record<string, string | undefined> = {
...process.env, ...process.env,
@@ -1014,7 +1014,7 @@ export class GatewayManager extends EventEmitter {
// Pre-set the NODE_OPTIONS that entry.ts would have added via respawn // Pre-set the NODE_OPTIONS that entry.ts would have added via respawn
const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? ''; const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? '';
if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') && if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') &&
!existingNodeOpts.includes('--no-warnings')) { !existingNodeOpts.includes('--no-warnings')) {
spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim(); spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim();
} }
} }
@@ -1046,13 +1046,13 @@ export class GatewayManager extends EventEmitter {
}); });
const child = this.process; const child = this.process;
this.ownsProcess = true; this.ownsProcess = true;
child.on('error', (error) => { child.on('error', (error) => {
this.ownsProcess = false; this.ownsProcess = false;
logger.error('Gateway process spawn error:', error); logger.error('Gateway process spawn error:', error);
reject(error); reject(error);
}); });
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
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;
@@ -1062,7 +1062,7 @@ export class GatewayManager extends EventEmitter {
this.process = null; this.process = null;
} }
this.emit('exit', code); this.emit('exit', code);
if (this.status.state === 'running') { if (this.status.state === 'running') {
this.setStatus({ state: 'stopped' }); this.setStatus({ state: 'stopped' });
this.scheduleReconnect(); this.scheduleReconnect();
@@ -1072,7 +1072,7 @@ export class GatewayManager extends EventEmitter {
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`); logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`);
}); });
// Log stderr // Log stderr
child.stderr?.on('data', (data) => { child.stderr?.on('data', (data) => {
const raw = data.toString(); const raw = data.toString();
@@ -1087,7 +1087,7 @@ export class GatewayManager extends EventEmitter {
logger.warn(`[Gateway stderr] ${classified.normalized}`); logger.warn(`[Gateway stderr] ${classified.normalized}`);
} }
}); });
// Store PID // Store PID
if (child.pid) { if (child.pid) {
logger.info(`Gateway process started (pid=${child.pid})`); logger.info(`Gateway process started (pid=${child.pid})`);
@@ -1095,11 +1095,11 @@ export class GatewayManager extends EventEmitter {
} else { } else {
logger.warn('Gateway process spawned but PID is undefined'); logger.warn('Gateway process spawned but PID is undefined');
} }
resolve(); resolve();
}); });
} }
/** /**
* Wait for Gateway to be ready by checking if the port is accepting connections * Wait for Gateway to be ready by checking if the port is accepting connections
*/ */
@@ -1113,7 +1113,7 @@ export class GatewayManager extends EventEmitter {
logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`); logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`);
throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`); throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`);
} }
try { try {
const ready = await new Promise<boolean>((resolve) => { const ready = await new Promise<boolean>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`); const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`);
@@ -1121,19 +1121,19 @@ export class GatewayManager extends EventEmitter {
testWs.close(); testWs.close();
resolve(false); resolve(false);
}, 2000); }, 2000);
testWs.on('open', () => { testWs.on('open', () => {
clearTimeout(timeout); clearTimeout(timeout);
testWs.close(); testWs.close();
resolve(true); resolve(true);
}); });
testWs.on('error', () => { testWs.on('error', () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(false); resolve(false);
}); });
}); });
if (ready) { if (ready) {
logger.debug(`Gateway ready after ${i + 1} attempt(s)`); logger.debug(`Gateway ready after ${i + 1} attempt(s)`);
return; return;
@@ -1141,28 +1141,28 @@ export class GatewayManager extends EventEmitter {
} catch { } catch {
// Gateway not ready yet // Gateway not ready yet
} }
if (i > 0 && i % 10 === 0) { if (i > 0 && i % 10 === 0) {
logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`); logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
} }
await new Promise((resolve) => setTimeout(resolve, interval)); await new Promise((resolve) => setTimeout(resolve, interval));
} }
logger.error(`Gateway failed to become ready after ${retries} attempts on port ${this.status.port}`); logger.error(`Gateway failed to become ready after ${retries} attempts on port ${this.status.port}`);
throw new Error(`Gateway failed to start after ${retries} retries (port ${this.status.port})`); throw new Error(`Gateway failed to start after ${retries} retries (port ${this.status.port})`);
} }
/** /**
* Connect WebSocket to Gateway * Connect WebSocket to Gateway
*/ */
private async connect(port: number, _externalToken?: string): Promise<void> { private async connect(port: number, _externalToken?: string): Promise<void> {
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${port}/ws)`); logger.debug(`Connecting Gateway WebSocket (ws://localhost:${port}/ws)`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// WebSocket URL (token will be sent in connect handshake, not URL) // WebSocket URL (token will be sent in connect handshake, not URL)
const wsUrl = `ws://localhost:${port}/ws`; const wsUrl = `ws://localhost:${port}/ws`;
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
let handshakeComplete = false; let handshakeComplete = false;
let connectId: string | null = null; let connectId: string | null = null;
@@ -1203,7 +1203,7 @@ export class GatewayManager extends EventEmitter {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
reject(err); reject(err);
}; };
// Sends the connect frame using the server-issued challenge nonce. // Sends the connect frame using the server-issued challenge nonce.
const sendConnectHandshake = async (challengeNonce: string) => { const sendConnectHandshake = async (challengeNonce: string) => {
logger.debug('Sending connect handshake with challenge nonce'); logger.debug('Sending connect handshake with challenge nonce');
@@ -1274,7 +1274,7 @@ export class GatewayManager extends EventEmitter {
} }
}, 10000); }, 10000);
handshakeTimeout = requestTimeout; handshakeTimeout = requestTimeout;
this.pendingRequests.set(connectId, { this.pendingRequests.set(connectId, {
resolve: (_result) => { resolve: (_result) => {
handshakeComplete = true; handshakeComplete = true;
@@ -1309,7 +1309,7 @@ export class GatewayManager extends EventEmitter {
this.ws.on('open', () => { this.ws.on('open', () => {
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...'); logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
}); });
let challengeReceived = false; let challengeReceived = false;
this.ws.on('message', (data) => { this.ws.on('message', (data) => {
@@ -1342,11 +1342,13 @@ export class GatewayManager extends EventEmitter {
logger.debug('Failed to parse Gateway WebSocket message:', error); logger.debug('Failed to parse Gateway WebSocket message:', error);
} }
}); });
this.ws.on('close', (code, reason) => { this.ws.on('close', (code, reason) => {
const reasonStr = reason?.toString() || 'unknown'; const reasonStr = reason?.toString() || 'unknown';
logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`); logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`);
if (!handshakeComplete) { if (!handshakeComplete) {
// If the socket closes before the handshake completes, it usually means the server is still starting or restarting.
// Rejecting this promise will cause the caller (startProcess/reconnect logic) to retry cleanly.
rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`)); rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`));
return; return;
} }
@@ -1356,16 +1358,21 @@ export class GatewayManager extends EventEmitter {
this.scheduleReconnect(); this.scheduleReconnect();
} }
}); });
this.ws.on('error', (error) => { this.ws.on('error', (error) => {
logger.error('Gateway WebSocket error:', error); // Suppress noisy ECONNREFUSED/WebSocket handshake errors that happen during expected Gateway restarts.
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) { if (!handshakeComplete) {
rejectOnce(error); rejectOnce(error);
} }
}); });
}); });
} }
/** /**
* Handle incoming WebSocket message * Handle incoming WebSocket message
*/ */
@@ -1374,16 +1381,16 @@ export class GatewayManager extends EventEmitter {
logger.debug('Received non-object Gateway message'); logger.debug('Received non-object Gateway message');
return; return;
} }
const msg = message as Record<string, unknown>; const msg = message as Record<string, unknown>;
// Handle OpenClaw protocol response format: { type: "res", id: "...", ok: true/false, ... } // Handle OpenClaw protocol response format: { type: "res", id: "...", ok: true/false, ... }
if (msg.type === 'res' && typeof msg.id === 'string') { if (msg.type === 'res' && typeof msg.id === 'string') {
if (this.pendingRequests.has(msg.id)) { if (this.pendingRequests.has(msg.id)) {
const request = this.pendingRequests.get(msg.id)!; const request = this.pendingRequests.get(msg.id)!;
clearTimeout(request.timeout); clearTimeout(request.timeout);
this.pendingRequests.delete(msg.id); this.pendingRequests.delete(msg.id);
if (msg.ok === false || msg.error) { if (msg.ok === false || msg.error) {
const errorObj = msg.error as { message?: string; code?: number } | undefined; const errorObj = msg.error as { message?: string; code?: number } | undefined;
const errorMsg = errorObj?.message || JSON.stringify(msg.error) || 'Unknown error'; const errorMsg = errorObj?.message || JSON.stringify(msg.error) || 'Unknown error';
@@ -1394,21 +1401,21 @@ export class GatewayManager extends EventEmitter {
return; return;
} }
} }
// Handle OpenClaw protocol event format: { type: "event", event: "...", payload: {...} } // Handle OpenClaw protocol event format: { type: "event", event: "...", payload: {...} }
if (msg.type === 'event' && typeof msg.event === 'string') { if (msg.type === 'event' && typeof msg.event === 'string') {
this.handleProtocolEvent(msg.event, msg.payload); this.handleProtocolEvent(msg.event, msg.payload);
return; return;
} }
// Fallback: Check if this is a JSON-RPC 2.0 response (legacy support) // Fallback: Check if this is a JSON-RPC 2.0 response (legacy support)
if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) { if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) {
const request = this.pendingRequests.get(String(message.id))!; const request = this.pendingRequests.get(String(message.id))!;
clearTimeout(request.timeout); clearTimeout(request.timeout);
this.pendingRequests.delete(String(message.id)); this.pendingRequests.delete(String(message.id));
if (message.error) { if (message.error) {
const errorMsg = typeof message.error === 'object' const errorMsg = typeof message.error === 'object'
? (message.error as { message?: string }).message || JSON.stringify(message.error) ? (message.error as { message?: string }).message || JSON.stringify(message.error)
: String(message.error); : String(message.error);
request.reject(new Error(errorMsg)); request.reject(new Error(errorMsg));
@@ -1417,16 +1424,16 @@ export class GatewayManager extends EventEmitter {
} }
return; return;
} }
// Check if this is a JSON-RPC notification (server-initiated event) // Check if this is a JSON-RPC notification (server-initiated event)
if (isNotification(message)) { if (isNotification(message)) {
this.handleNotification(message); this.handleNotification(message);
return; return;
} }
this.emit('message', message); this.emit('message', message);
} }
/** /**
* Handle OpenClaw protocol events * Handle OpenClaw protocol events
*/ */
@@ -1462,35 +1469,35 @@ export class GatewayManager extends EventEmitter {
this.emit('notification', { method: event, params: payload }); this.emit('notification', { method: event, params: payload });
} }
} }
/** /**
* Handle server-initiated notifications * Handle server-initiated notifications
*/ */
private handleNotification(notification: JsonRpcNotification): void { private handleNotification(notification: JsonRpcNotification): void {
this.emit('notification', notification); this.emit('notification', notification);
// Route specific events // Route specific events
switch (notification.method) { switch (notification.method) {
case GatewayEventType.CHANNEL_STATUS_CHANGED: case GatewayEventType.CHANNEL_STATUS_CHANGED:
this.emit('channel:status', notification.params as { channelId: string; status: string }); this.emit('channel:status', notification.params as { channelId: string; status: string });
break; break;
case GatewayEventType.MESSAGE_RECEIVED: case GatewayEventType.MESSAGE_RECEIVED:
this.emit('chat:message', notification.params as { message: unknown }); this.emit('chat:message', notification.params as { message: unknown });
break; break;
case GatewayEventType.ERROR: { case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string }; const errorData = notification.params as { message?: string };
this.emit('error', new Error(errorData.message || 'Gateway error')); this.emit('error', new Error(errorData.message || 'Gateway error'));
break; break;
} }
default: default:
// Unknown notification type, just log it // Unknown notification type, just log it
logger.debug(`Unknown Gateway notification: ${notification.method}`); logger.debug(`Unknown Gateway notification: ${notification.method}`);
} }
} }
/** /**
* Start ping interval to keep connection alive * Start ping interval to keep connection alive
*/ */
@@ -1498,14 +1505,14 @@ export class GatewayManager extends EventEmitter {
if (this.pingInterval) { if (this.pingInterval) {
clearInterval(this.pingInterval); clearInterval(this.pingInterval);
} }
this.pingInterval = setInterval(() => { this.pingInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) { if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping(); this.ws.ping();
} }
}, 30000); }, 30000);
} }
/** /**
* Schedule reconnection attempt with exponential backoff * Schedule reconnection attempt with exponential backoff
*/ */
@@ -1514,35 +1521,35 @@ export class GatewayManager extends EventEmitter {
logger.debug('Gateway reconnect skipped (auto-reconnect disabled)'); logger.debug('Gateway reconnect skipped (auto-reconnect disabled)');
return; return;
} }
if (this.reconnectTimer) { if (this.reconnectTimer) {
return; return;
} }
if (this.reconnectAttempts >= this.reconnectConfig.maxAttempts) { if (this.reconnectAttempts >= this.reconnectConfig.maxAttempts) {
logger.error(`Gateway reconnect failed: max attempts reached (${this.reconnectConfig.maxAttempts})`); logger.error(`Gateway reconnect failed: max attempts reached (${this.reconnectConfig.maxAttempts})`);
this.setStatus({ this.setStatus({
state: 'error', state: 'error',
error: 'Failed to reconnect after maximum attempts', error: 'Failed to reconnect after maximum attempts',
reconnectAttempts: this.reconnectAttempts reconnectAttempts: this.reconnectAttempts
}); });
return; return;
} }
// Calculate delay with exponential backoff // Calculate delay with exponential backoff
const delay = Math.min( const delay = Math.min(
this.reconnectConfig.baseDelay * Math.pow(2, this.reconnectAttempts), this.reconnectConfig.baseDelay * Math.pow(2, this.reconnectAttempts),
this.reconnectConfig.maxDelay this.reconnectConfig.maxDelay
); );
this.reconnectAttempts++; this.reconnectAttempts++;
logger.warn(`Scheduling Gateway reconnect attempt ${this.reconnectAttempts}/${this.reconnectConfig.maxAttempts} in ${delay}ms`); logger.warn(`Scheduling Gateway reconnect attempt ${this.reconnectAttempts}/${this.reconnectConfig.maxAttempts} in ${delay}ms`);
this.setStatus({ this.setStatus({
state: 'reconnecting', state: 'reconnecting',
reconnectAttempts: this.reconnectAttempts reconnectAttempts: this.reconnectAttempts
}); });
this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null; this.reconnectTimer = null;
try { try {
@@ -1556,7 +1563,7 @@ export class GatewayManager extends EventEmitter {
this.startHealthCheck(); this.startHealthCheck();
return; return;
} }
// Otherwise restart the process // Otherwise restart the process
await this.startProcess(); await this.startProcess();
await this.waitForReady(); await this.waitForReady();
@@ -1569,21 +1576,21 @@ export class GatewayManager extends EventEmitter {
} }
}, delay); }, delay);
} }
/** /**
* Update status and emit event * Update status and emit event
*/ */
private setStatus(update: Partial<GatewayStatus>): void { private setStatus(update: Partial<GatewayStatus>): void {
const previousState = this.status.state; const previousState = this.status.state;
this.status = { ...this.status, ...update }; this.status = { ...this.status, ...update };
// Calculate uptime if connected // Calculate uptime if connected
if (this.status.state === 'running' && this.status.connectedAt) { if (this.status.state === 'running' && this.status.connectedAt) {
this.status.uptime = Date.now() - this.status.connectedAt; this.status.uptime = Date.now() - this.status.connectedAt;
} }
this.emit('status', this.status); this.emit('status', this.status);
// Log state transitions // Log state transitions
if (previousState !== this.status.state) { if (previousState !== this.status.state) {
logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`); logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`);

View File

@@ -516,7 +516,14 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
// If we call add() here with undefined baseUrl, it will overwrite and erase it! // If we call add() here with undefined baseUrl, it will overwrite and erase it!
// So we just fetch the latest list from the backend to update the UI. // So we just fetch the latest list from the backend to update the UI.
try { try {
await useProviderStore.getState().fetchProviders(); const store = useProviderStore.getState();
await store.fetchProviders();
// Auto-set as default if no default is currently configured
if (!store.defaultProviderId && latestRef.current.selectedType) {
// Provider type is expected to match provider ID for built-in OAuth providers
await store.setDefaultProvider(latestRef.current.selectedType);
}
} catch (err) { } catch (err) {
console.error('Failed to refresh providers after OAuth:', err); console.error('Failed to refresh providers after OAuth:', err);
} }