feat: enhance after-pack script to copy OpenClaw runtime dependencies
- Added a new script `bundle-openclaw.mjs` to bundle OpenClaw runtime dependencies. - Updated `after-pack.cjs` to copy bundled OpenClaw runtime and its node_modules. - Improved cleanup of unnecessary development files in node_modules. - Adjusted paths for resources in the packaging process. style: update loading indicator styles in ChatHistoryPanel - Changed the border radius and padding for the loading indicator in ChatHistoryPanel. fix: improve ProvidersSection to handle provider account syncing - Added logic to sync model configuration to provider accounts. - Introduced error handling and loading states during the sync process. - Enhanced vendor resolution and account management logic. fix: fallback session handling in chat store - Implemented fallback session logic in loadSessions to ensure a valid session is always available on error.
This commit is contained in:
5
electron/gateway/config-sync.ts
Normal file
5
electron/gateway/config-sync.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { syncBrowserConfigToOpenClaw } from '@electron/utils/openclaw-auth';
|
||||
|
||||
export async function syncGatewayConfigBeforeLaunch(): Promise<void> {
|
||||
await syncBrowserConfigToOpenClaw();
|
||||
}
|
||||
@@ -5,7 +5,10 @@ export interface GatewayHealthSnapshot {
|
||||
ok: boolean;
|
||||
status: 'connected' | 'disconnected' | 'reconnecting';
|
||||
initialized: boolean;
|
||||
mode: 'in-process';
|
||||
mode: 'in-process' | 'openclaw';
|
||||
port?: number | null;
|
||||
pid?: number | null;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDiagnosticsSummary {
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { windowManager } from '@electron/service/window-service';
|
||||
import logManager from '@electron/service/logger';
|
||||
import configManager from '@electron/service/config-service';
|
||||
import { updateTrayStatus } from '@electron/service/tray';
|
||||
import type { GatewayEvent, RuntimeRefreshTopic } from './types';
|
||||
import * as chatHandlers from './handlers/chat';
|
||||
import { getUserDataDir } from '@electron/utils/paths';
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
type DeviceIdentity,
|
||||
} from '@electron/utils/device-identity';
|
||||
import { CONFIG_KEYS } from '@runtime/lib/constants';
|
||||
import { normalizeAgentSessionKey } from '@runtime/lib/models';
|
||||
import type { ContentBlock, RawMessage } from '@runtime/shared/chat-model';
|
||||
import type { GatewayEvent, GatewayRpcParams, RuntimeRefreshTopic } from './types';
|
||||
import * as providerHandlers from './handlers/provider';
|
||||
import * as skillHandlers from './handlers/skills';
|
||||
import { OpenClawProcessOwner } from './openclaw-process-owner';
|
||||
import { launchGatewayProcess } from './process-launcher';
|
||||
import {
|
||||
clearPendingGatewayRequests,
|
||||
rejectPendingGatewayRequest,
|
||||
resolvePendingGatewayRequest,
|
||||
type PendingGatewayRequest,
|
||||
} from './request-store';
|
||||
import { connectGatewaySocket, waitForGatewayReady } from './ws-client';
|
||||
|
||||
type RuntimeChangeBroadcast = {
|
||||
topics: RuntimeRefreshTopic[];
|
||||
@@ -15,62 +35,697 @@ type RuntimeChangeBroadcast = {
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
type GatewayStatus = 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
type GatewayResponseFrame = {
|
||||
type?: string;
|
||||
id?: string;
|
||||
ok?: boolean;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
type GatewayEventFrame = {
|
||||
type?: string;
|
||||
event?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMessageRole(value: unknown): RawMessage['role'] {
|
||||
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
switch (normalized) {
|
||||
case 'user':
|
||||
case 'assistant':
|
||||
case 'system':
|
||||
case 'tool_result':
|
||||
case 'toolresult':
|
||||
return normalized === 'tool_result' ? 'tool_result' : (normalized as RawMessage['role']);
|
||||
case 'tool':
|
||||
return 'toolresult';
|
||||
default:
|
||||
return 'assistant';
|
||||
}
|
||||
}
|
||||
|
||||
function toTextContentBlocks(text: string): ContentBlock[] {
|
||||
return text
|
||||
? [{ type: 'text', text }]
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeGatewayRawMessage(
|
||||
value: unknown,
|
||||
options?: { preferContentBlocks?: boolean },
|
||||
): RawMessage | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawContent = value.content;
|
||||
let content: RawMessage['content'] = '';
|
||||
|
||||
if (typeof rawContent === 'string') {
|
||||
content = options?.preferContentBlocks ? toTextContentBlocks(rawContent) : rawContent;
|
||||
} else if (Array.isArray(rawContent)) {
|
||||
content = rawContent as ContentBlock[];
|
||||
} else if (typeof value.text === 'string') {
|
||||
content = toTextContentBlocks(value.text);
|
||||
}
|
||||
|
||||
return {
|
||||
...(value as Partial<RawMessage>),
|
||||
role: normalizeMessageRole(value.role),
|
||||
content,
|
||||
timestamp: normalizeTimestamp(value.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
function extractTextFromMessageContent(content: RawMessage['content'] | unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!isRecord(block)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof block.text === 'string') {
|
||||
return block.text;
|
||||
}
|
||||
|
||||
if (typeof block.thinking === 'string') {
|
||||
return block.thinking;
|
||||
}
|
||||
|
||||
if (typeof block.content === 'string') {
|
||||
return block.content;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function extractTextFromRawMessage(message: RawMessage): string {
|
||||
return extractTextFromMessageContent(message.content);
|
||||
}
|
||||
|
||||
function extractTextFromGatewayPayload(payload: Record<string, unknown>): string {
|
||||
if (typeof payload.delta === 'string') {
|
||||
return payload.delta;
|
||||
}
|
||||
|
||||
if (typeof payload.text === 'string') {
|
||||
return payload.text;
|
||||
}
|
||||
|
||||
const normalizedMessage = normalizeGatewayRawMessage(payload.message);
|
||||
if (normalizedMessage) {
|
||||
return extractTextFromRawMessage(normalizedMessage);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildGatewayRpcError(error: unknown, fallback: string): Error {
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
return new Error(error);
|
||||
}
|
||||
|
||||
if (isRecord(error) && typeof error.message === 'string' && error.message.trim()) {
|
||||
return new Error(error.message);
|
||||
}
|
||||
|
||||
return new Error(fallback);
|
||||
}
|
||||
|
||||
async function findAvailablePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = createServer();
|
||||
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
reject(new Error('Failed to allocate an available Gateway port'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class GatewayManager {
|
||||
private initialized = false;
|
||||
private status: 'connected' | 'disconnected' | 'reconnecting' = 'disconnected';
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private stopPromise: Promise<void> | null = null;
|
||||
private status: GatewayStatus = 'disconnected';
|
||||
private readonly mode = 'openclaw' as const;
|
||||
private readonly processOwner = new OpenClawProcessOwner();
|
||||
private readonly pendingRequests = new Map<string, PendingGatewayRequest>();
|
||||
private readonly deltaSnapshots = new Map<string, string>();
|
||||
private gatewayToken = randomUUID();
|
||||
private socket: WebSocket | null = null;
|
||||
private child: Electron.UtilityProcess | null = null;
|
||||
private port: number | null = null;
|
||||
private exitCode: number | null = null;
|
||||
private lastError?: string;
|
||||
private stopping = false;
|
||||
private deviceIdentity: DeviceIdentity | null = null;
|
||||
|
||||
private setStatus(status: 'connected' | 'disconnected' | 'reconnecting'): void {
|
||||
private setStatus(status: GatewayStatus): void {
|
||||
this.status = status;
|
||||
updateTrayStatus(status);
|
||||
this.broadcast({ type: 'gateway:status', status });
|
||||
}
|
||||
|
||||
private async initDeviceIdentity(): Promise<void> {
|
||||
if (this.deviceIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identityPath = join(getUserDataDir(), 'openclaw-device-identity.json');
|
||||
this.deviceIdentity = await loadOrCreateDeviceIdentity(identityPath);
|
||||
logManager.info('OpenClaw Gateway device identity loaded', {
|
||||
deviceId: this.deviceIdentity.deviceId,
|
||||
});
|
||||
} catch (error) {
|
||||
logManager.warn('Failed to load OpenClaw device identity; scopes may be limited:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async terminateChild(child: Electron.UtilityProcess): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
child.once('exit', () => {
|
||||
finish();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
finish();
|
||||
}, 1500);
|
||||
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async disposeTransport(reason: string): Promise<void> {
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (error) {
|
||||
logManager.warn(`Failed to close OpenClaw Gateway socket during ${reason}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const child = this.child;
|
||||
this.child = null;
|
||||
this.port = null;
|
||||
if (child && this.exitCode === null) {
|
||||
this.exitCode = -1;
|
||||
} else if (!child) {
|
||||
this.exitCode = null;
|
||||
}
|
||||
this.deltaSnapshots.clear();
|
||||
|
||||
clearPendingGatewayRequests(
|
||||
this.pendingRequests,
|
||||
new Error(`Gateway request cancelled: ${reason}`),
|
||||
);
|
||||
|
||||
if (child) {
|
||||
await this.terminateChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
private bindProcessLifecycle(child: Electron.UtilityProcess): void {
|
||||
child.on('exit', (code) => {
|
||||
if (this.child !== child) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.exitCode = code ?? -1;
|
||||
this.child = null;
|
||||
this.port = null;
|
||||
|
||||
if (this.stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastError = `OpenClaw Gateway exited unexpectedly (code=${code ?? 'unknown'})`;
|
||||
this.socket = null;
|
||||
this.deltaSnapshots.clear();
|
||||
clearPendingGatewayRequests(
|
||||
this.pendingRequests,
|
||||
new Error(this.lastError),
|
||||
);
|
||||
this.setStatus('disconnected');
|
||||
logManager.warn(this.lastError);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
this.lastError = toErrorMessage(error);
|
||||
logManager.error('OpenClaw Gateway process error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
private handleGatewaySocketClosed(socket: WebSocket, code: number): void {
|
||||
if (this.socket !== socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = null;
|
||||
this.deltaSnapshots.clear();
|
||||
this.lastError = `OpenClaw Gateway socket closed (code=${code})`;
|
||||
clearPendingGatewayRequests(
|
||||
this.pendingRequests,
|
||||
new Error(this.lastError),
|
||||
);
|
||||
this.setStatus('disconnected');
|
||||
logManager.warn(this.lastError);
|
||||
}
|
||||
|
||||
private handleGatewayFrame(frame: unknown): void {
|
||||
if (!isRecord(frame)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'res' && typeof frame.id === 'string') {
|
||||
const response = frame as GatewayResponseFrame;
|
||||
if (response.ok === false) {
|
||||
rejectPendingGatewayRequest(
|
||||
this.pendingRequests,
|
||||
response.id!,
|
||||
buildGatewayRpcError(response.error, `Gateway RPC failed: ${response.id}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolvePendingGatewayRequest(
|
||||
this.pendingRequests,
|
||||
response.id!,
|
||||
response.payload,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'event' && typeof frame.event === 'string') {
|
||||
this.handleGatewayEvent(frame as GatewayEventFrame);
|
||||
}
|
||||
}
|
||||
|
||||
private handleGatewayEvent(event: GatewayEventFrame): void {
|
||||
switch (event.event) {
|
||||
case 'chat':
|
||||
if (isRecord(event.payload)) {
|
||||
this.handleChatEvent(event.payload);
|
||||
}
|
||||
break;
|
||||
case 'gateway.ready':
|
||||
logManager.info('OpenClaw Gateway reported ready');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleChatEvent(payload: Record<string, unknown>): void {
|
||||
const sessionKey = typeof payload.sessionKey === 'string'
|
||||
? normalizeAgentSessionKey(payload.sessionKey)
|
||||
: '';
|
||||
const runId = typeof payload.runId === 'string' ? payload.runId : '';
|
||||
const state = typeof payload.state === 'string' ? payload.state : '';
|
||||
|
||||
if (!sessionKey || !runId || !state) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case 'delta': {
|
||||
const nextSnapshot = extractTextFromGatewayPayload(payload);
|
||||
if (!nextSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSnapshot = this.deltaSnapshots.get(runId) ?? '';
|
||||
const delta = nextSnapshot.startsWith(previousSnapshot)
|
||||
? nextSnapshot.slice(previousSnapshot.length)
|
||||
: nextSnapshot;
|
||||
|
||||
this.deltaSnapshots.set(runId, nextSnapshot);
|
||||
|
||||
if (delta) {
|
||||
this.broadcast({
|
||||
type: 'chat:delta',
|
||||
sessionKey,
|
||||
runId,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'final': {
|
||||
const snapshotText = this.deltaSnapshots.get(runId) ?? '';
|
||||
this.deltaSnapshots.delete(runId);
|
||||
|
||||
const message = normalizeGatewayRawMessage(payload.message, {
|
||||
preferContentBlocks: true,
|
||||
}) ?? (
|
||||
snapshotText
|
||||
? {
|
||||
role: 'assistant',
|
||||
content: toTextContentBlocks(snapshotText),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.broadcast({
|
||||
type: 'chat:final',
|
||||
sessionKey,
|
||||
runId,
|
||||
message,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
this.deltaSnapshots.delete(runId);
|
||||
this.broadcast({
|
||||
type: 'chat:error',
|
||||
sessionKey,
|
||||
runId,
|
||||
error: typeof payload.errorMessage === 'string'
|
||||
? payload.errorMessage
|
||||
: 'Gateway chat error',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'aborted': {
|
||||
this.deltaSnapshots.delete(runId);
|
||||
this.broadcast({
|
||||
type: 'chat:aborted',
|
||||
sessionKey,
|
||||
runId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async rpcGateway(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number | null },
|
||||
): Promise<unknown> {
|
||||
await this.start();
|
||||
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('OpenClaw Gateway socket is not connected');
|
||||
}
|
||||
|
||||
const requestId = `${method}-${randomUUID()}`;
|
||||
const timeoutMs = options?.timeoutMs ?? 30_000;
|
||||
|
||||
return await new Promise<unknown>((resolve, reject) => {
|
||||
const timeout = timeoutMs === null
|
||||
? null
|
||||
: setTimeout(() => {
|
||||
rejectPendingGatewayRequest(
|
||||
this.pendingRequests,
|
||||
requestId,
|
||||
new Error(`Gateway RPC timed out: ${method}`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
try {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'req',
|
||||
id: requestId,
|
||||
method,
|
||||
params,
|
||||
}));
|
||||
} catch (error) {
|
||||
rejectPendingGatewayRequest(
|
||||
this.pendingRequests,
|
||||
requestId,
|
||||
new Error(`Failed to send Gateway RPC ${method}: ${toErrorMessage(error)}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
logManager.info('GatewayManager initialized');
|
||||
this.setStatus('connected');
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
return await this.initPromise;
|
||||
}
|
||||
|
||||
this.initPromise = (async () => {
|
||||
this.initialized = true;
|
||||
logManager.info('GatewayManager initialized in OpenClaw mode');
|
||||
|
||||
const autoStart = Boolean(configManager.get(CONFIG_KEYS.GATEWAY_AUTO_START));
|
||||
if (!autoStart) {
|
||||
this.setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.start();
|
||||
} catch (error) {
|
||||
this.lastError = toErrorMessage(error);
|
||||
this.setStatus('disconnected');
|
||||
logManager.error('Failed to auto-start OpenClaw Gateway:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.initPromise;
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.init();
|
||||
if (this.status === 'connected' && this.socket?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.startPromise) {
|
||||
return await this.startPromise;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
this.startPromise = (async () => {
|
||||
if (this.stopPromise) {
|
||||
await this.stopPromise;
|
||||
}
|
||||
|
||||
this.stopping = false;
|
||||
this.lastError = undefined;
|
||||
this.setStatus('reconnecting');
|
||||
|
||||
try {
|
||||
await this.initDeviceIdentity();
|
||||
await this.processOwner.prepare();
|
||||
const runtimeStatus = this.processOwner.getStatus();
|
||||
if (!runtimeStatus.entryExists) {
|
||||
throw new Error(runtimeStatus.lastError || `OpenClaw entry not found at ${runtimeStatus.runtimePaths.entryPath}`);
|
||||
}
|
||||
|
||||
await this.disposeTransport('starting OpenClaw Gateway');
|
||||
|
||||
this.port = await findAvailablePort();
|
||||
this.exitCode = null;
|
||||
this.gatewayToken = randomUUID();
|
||||
|
||||
const child = await launchGatewayProcess({
|
||||
port: this.port,
|
||||
token: this.gatewayToken,
|
||||
openclawDir: runtimeStatus.runtimePaths.resolvedDir,
|
||||
entryScript: runtimeStatus.runtimePaths.entryPath,
|
||||
});
|
||||
|
||||
this.child = child;
|
||||
this.bindProcessLifecycle(child);
|
||||
|
||||
await waitForGatewayReady({
|
||||
port: this.port,
|
||||
getProcessExitCode: () => this.exitCode,
|
||||
});
|
||||
|
||||
this.socket = await connectGatewaySocket({
|
||||
port: this.port,
|
||||
token: this.gatewayToken,
|
||||
deviceIdentity: this.deviceIdentity,
|
||||
platform: process.platform,
|
||||
onMessage: (message) => this.handleGatewayFrame(message),
|
||||
onCloseAfterHandshake: (socket, code) => this.handleGatewaySocketClosed(socket, code),
|
||||
});
|
||||
|
||||
this.lastError = undefined;
|
||||
this.setStatus('connected');
|
||||
logManager.info('OpenClaw Gateway connected', {
|
||||
port: this.port,
|
||||
pid: this.child?.pid,
|
||||
});
|
||||
} catch (error) {
|
||||
this.lastError = toErrorMessage(error);
|
||||
await this.disposeTransport('failed OpenClaw Gateway start');
|
||||
this.setStatus('disconnected');
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.startPromise;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.setStatus('disconnected');
|
||||
if (this.stopPromise) {
|
||||
return await this.stopPromise;
|
||||
}
|
||||
|
||||
this.stopPromise = (async () => {
|
||||
this.stopping = true;
|
||||
await this.disposeTransport('stopping OpenClaw Gateway');
|
||||
this.setStatus('disconnected');
|
||||
this.stopping = false;
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.stopPromise;
|
||||
} finally {
|
||||
this.stopPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async restart(options?: RuntimeChangeBroadcast): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.setStatus('reconnecting');
|
||||
await this.init();
|
||||
await this.stop();
|
||||
await this.start();
|
||||
|
||||
if (options) {
|
||||
this.notifyRuntimeChanged(options);
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): {
|
||||
status: 'connected' | 'disconnected' | 'reconnecting';
|
||||
status: GatewayStatus;
|
||||
initialized: boolean;
|
||||
mode: 'in-process';
|
||||
mode: 'openclaw';
|
||||
port: number | null;
|
||||
pid: number | null;
|
||||
lastError?: string;
|
||||
runtime: ReturnType<OpenClawProcessOwner['getStatus']>;
|
||||
} {
|
||||
return {
|
||||
status: this.status,
|
||||
initialized: this.initialized,
|
||||
mode: 'in-process',
|
||||
mode: this.mode,
|
||||
port: this.port,
|
||||
pid: this.child?.pid ?? null,
|
||||
lastError: this.lastError,
|
||||
runtime: this.processOwner.getStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
ok: boolean;
|
||||
status: 'connected' | 'disconnected' | 'reconnecting';
|
||||
status: GatewayStatus;
|
||||
initialized: boolean;
|
||||
mode: 'in-process';
|
||||
mode: 'openclaw';
|
||||
port: number | null;
|
||||
pid: number | null;
|
||||
lastError?: string;
|
||||
runtime: ReturnType<OpenClawProcessOwner['getStatus']>;
|
||||
}> {
|
||||
const status = this.getStatus();
|
||||
|
||||
return {
|
||||
ok: this.initialized && this.status === 'connected',
|
||||
...this.getStatus(),
|
||||
ok: status.initialized && status.status === 'connected' && this.socket?.readyState === WebSocket.OPEN,
|
||||
...status,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,19 +734,76 @@ class GatewayManager {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
logManager.info(`Gateway RPC: ${method}`, params);
|
||||
|
||||
switch (method) {
|
||||
case 'chat.send':
|
||||
return chatHandlers.handleChatSend(params, (event) => this.broadcast(event));
|
||||
case 'chat.history':
|
||||
return chatHandlers.handleChatHistory(params);
|
||||
case 'chat.abort':
|
||||
return chatHandlers.handleChatAbort(params, (event) => this.broadcast(event));
|
||||
case 'session.list':
|
||||
return chatHandlers.handleSessionList();
|
||||
case 'session.delete':
|
||||
return chatHandlers.handleSessionDelete(params);
|
||||
case 'chat.send': {
|
||||
const request = params as GatewayRpcParams['chat.send'];
|
||||
const sessionKey = normalizeAgentSessionKey(request.sessionKey);
|
||||
const messageText = extractTextFromRawMessage(request.message);
|
||||
const response = await this.rpcGateway('chat.send', {
|
||||
sessionKey,
|
||||
message: messageText,
|
||||
deliver: false,
|
||||
idempotencyKey: request.message.id || randomUUID(),
|
||||
}, { timeoutMs: 30_000 });
|
||||
|
||||
const runId = (
|
||||
isRecord(response) &&
|
||||
typeof response.runId === 'string' &&
|
||||
response.runId.trim()
|
||||
)
|
||||
? response.runId
|
||||
: '';
|
||||
|
||||
if (!runId) {
|
||||
throw new Error('OpenClaw Gateway chat.send did not return a runId');
|
||||
}
|
||||
|
||||
return { runId };
|
||||
}
|
||||
case 'chat.history': {
|
||||
const request = params as GatewayRpcParams['chat.history'];
|
||||
const response = await this.rpcGateway('chat.history', {
|
||||
sessionKey: normalizeAgentSessionKey(request.sessionKey),
|
||||
limit: request.limit ?? 50,
|
||||
}, { timeoutMs: 15_000 });
|
||||
|
||||
if (!isRecord(response) || !Array.isArray(response.messages)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.messages
|
||||
.map((message) => normalizeGatewayRawMessage(message))
|
||||
.filter((message): message is RawMessage => message !== null);
|
||||
}
|
||||
case 'chat.abort': {
|
||||
const request = params as GatewayRpcParams['chat.abort'];
|
||||
await this.rpcGateway('chat.abort', {
|
||||
sessionKey: normalizeAgentSessionKey(request.sessionKey),
|
||||
}, { timeoutMs: 10_000 });
|
||||
return;
|
||||
}
|
||||
case 'session.list': {
|
||||
const response = await this.rpcGateway('sessions.list', {}, { timeoutMs: 10_000 });
|
||||
if (!isRecord(response) || !Array.isArray(response.sessions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.sessions
|
||||
.map((session) => (
|
||||
isRecord(session) && typeof session.key === 'string'
|
||||
? session.key
|
||||
: null
|
||||
))
|
||||
.filter((sessionKey): sessionKey is string => Boolean(sessionKey));
|
||||
}
|
||||
case 'session.delete': {
|
||||
const request = params as GatewayRpcParams['session.delete'];
|
||||
await this.rpcGateway('sessions.delete', {
|
||||
key: normalizeAgentSessionKey(request.sessionKey),
|
||||
deleteTranscript: true,
|
||||
}, { timeoutMs: 15_000 });
|
||||
return { success: true };
|
||||
}
|
||||
case 'provider.list':
|
||||
return providerHandlers.handleProviderList();
|
||||
case 'provider.getDefault':
|
||||
@@ -107,7 +819,7 @@ class GatewayManager {
|
||||
|
||||
broadcast(event: GatewayEvent): void {
|
||||
const mainWindow = BrowserWindow.getAllWindows().find(
|
||||
(win) => windowManager.getName(win) === 'main'
|
||||
(win) => windowManager.getName(win) === 'main',
|
||||
);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('gateway:event', event);
|
||||
@@ -132,16 +844,27 @@ class GatewayManager {
|
||||
}
|
||||
|
||||
reloadProviders(options?: RuntimeChangeBroadcast): void {
|
||||
logManager.info('GatewayManager reloading providers');
|
||||
// For now, providers are resolved on each chat.send call,
|
||||
// so no in-memory cache to invalidate. Future: notify active sessions.
|
||||
this.notifyRuntimeChanged({
|
||||
const runtimeChange: RuntimeChangeBroadcast = {
|
||||
topics: options?.topics ?? ['providers', 'models'],
|
||||
reason: options?.reason ?? 'providers:reload',
|
||||
warnings: options?.warnings,
|
||||
channelType: options?.channelType,
|
||||
accountId: options?.accountId,
|
||||
});
|
||||
};
|
||||
|
||||
if (this.initialized && (this.status === 'connected' || this.status === 'reconnecting')) {
|
||||
void this.restart(runtimeChange).catch((error) => {
|
||||
const warning = `Gateway restart after provider reload failed: ${toErrorMessage(error)}`;
|
||||
logManager.error(warning, error);
|
||||
this.notifyRuntimeChanged({
|
||||
...runtimeChange,
|
||||
warnings: [...(runtimeChange.warnings ?? []), warning],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifyRuntimeChanged(runtimeChange);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ensureOpenClawRuntimeLayout,
|
||||
getOpenClawPackageStatus,
|
||||
getOpenClawRuntimePaths,
|
||||
type OpenClawRuntimePaths,
|
||||
} from '@electron/utils/paths';
|
||||
@@ -16,6 +17,10 @@ export interface OpenClawProcessOwnerStatus {
|
||||
state: OpenClawProcessOwnerState;
|
||||
prepared: boolean;
|
||||
runtimePaths: OpenClawRuntimePaths;
|
||||
packageExists: boolean;
|
||||
entryExists: boolean;
|
||||
nodeModulesPath: string;
|
||||
nodeModulesExists: boolean;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
@@ -52,6 +57,14 @@ function mergeRuntimePaths(
|
||||
export class OpenClawProcessOwner implements OpenClawProcessOwnerLike {
|
||||
private status: OpenClawProcessOwnerStatus;
|
||||
|
||||
private syncPackageStatus(): void {
|
||||
const packageStatus = getOpenClawPackageStatus(this.status.runtimePaths);
|
||||
this.status.packageExists = packageStatus.packageExists;
|
||||
this.status.entryExists = packageStatus.entryExists;
|
||||
this.status.nodeModulesPath = packageStatus.nodeModulesDir;
|
||||
this.status.nodeModulesExists = packageStatus.nodeModulesExists;
|
||||
}
|
||||
|
||||
constructor(options?: OpenClawProcessOwnerOptions) {
|
||||
const runtimePaths = mergeRuntimePaths(
|
||||
getOpenClawRuntimePaths(),
|
||||
@@ -62,7 +75,13 @@ export class OpenClawProcessOwner implements OpenClawProcessOwnerLike {
|
||||
state: 'idle',
|
||||
prepared: false,
|
||||
runtimePaths,
|
||||
packageExists: false,
|
||||
entryExists: false,
|
||||
nodeModulesPath: '',
|
||||
nodeModulesExists: false,
|
||||
};
|
||||
|
||||
this.syncPackageStatus();
|
||||
}
|
||||
|
||||
async prepare(): Promise<void> {
|
||||
@@ -73,6 +92,10 @@ export class OpenClawProcessOwner implements OpenClawProcessOwnerLike {
|
||||
this.status.state = 'preparing';
|
||||
ensureOpenClawRuntimeLayout(this.status.runtimePaths);
|
||||
this.status.prepared = true;
|
||||
this.syncPackageStatus();
|
||||
this.status.lastError = this.status.entryExists
|
||||
? undefined
|
||||
: `OpenClaw entry not found at ${this.status.runtimePaths.entryPath}`;
|
||||
this.status.state = 'idle';
|
||||
}
|
||||
|
||||
@@ -82,6 +105,13 @@ export class OpenClawProcessOwner implements OpenClawProcessOwnerLike {
|
||||
}
|
||||
|
||||
await this.prepare();
|
||||
this.syncPackageStatus();
|
||||
if (!this.status.entryExists) {
|
||||
this.status.state = 'failed';
|
||||
throw new Error(this.status.lastError || `OpenClaw entry not found at ${this.status.runtimePaths.entryPath}`);
|
||||
}
|
||||
|
||||
this.status.lastError = undefined;
|
||||
this.status.state = 'running';
|
||||
}
|
||||
|
||||
@@ -100,6 +130,8 @@ export class OpenClawProcessOwner implements OpenClawProcessOwnerLike {
|
||||
}
|
||||
|
||||
getStatus(): OpenClawProcessOwnerStatus {
|
||||
this.syncPackageStatus();
|
||||
|
||||
return {
|
||||
...this.status,
|
||||
runtimePaths: { ...this.status.runtimePaths },
|
||||
|
||||
85
electron/gateway/process-launcher.ts
Normal file
85
electron/gateway/process-launcher.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { utilityProcess } from 'electron';
|
||||
import logManager from '@electron/service/logger';
|
||||
|
||||
export interface OpenClawGatewayLaunchContext {
|
||||
port: number;
|
||||
token: string;
|
||||
openclawDir: string;
|
||||
entryScript: string;
|
||||
}
|
||||
|
||||
export async function launchGatewayProcess(
|
||||
context: OpenClawGatewayLaunchContext,
|
||||
): Promise<Electron.UtilityProcess> {
|
||||
const gatewayArgs = [
|
||||
'gateway',
|
||||
'--port',
|
||||
String(context.port),
|
||||
'--token',
|
||||
context.token,
|
||||
'--allow-unconfigured',
|
||||
];
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
OPENCLAW_GATEWAY_TOKEN: context.token,
|
||||
OPENCLAW_SKIP_CHANNELS: '1',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
};
|
||||
|
||||
logManager.info('Starting OpenClaw Gateway process', {
|
||||
port: context.port,
|
||||
entryScript: context.entryScript,
|
||||
cwd: context.openclawDir,
|
||||
args: gatewayArgs,
|
||||
});
|
||||
|
||||
return await new Promise<Electron.UtilityProcess>((resolve, reject) => {
|
||||
const child = utilityProcess.fork(context.entryScript, gatewayArgs, {
|
||||
cwd: context.openclawDir,
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
serviceName: 'OpenClaw Gateway',
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(child);
|
||||
};
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.once('spawn', () => {
|
||||
logManager.info('OpenClaw Gateway process spawned', { pid: child.pid });
|
||||
resolveOnce();
|
||||
});
|
||||
|
||||
child.once('error', (error) => {
|
||||
logManager.error('OpenClaw Gateway process spawn error:', error);
|
||||
rejectOnce(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code) => {
|
||||
if (!settled) {
|
||||
rejectOnce(new Error(`OpenClaw Gateway exited before spawn completed (code=${code ?? 'unknown'})`));
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
logManager.warn(`[OpenClaw] ${trimmed}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
48
electron/gateway/request-store.ts
Normal file
48
electron/gateway/request-store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface PendingGatewayRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export function clearPendingGatewayRequests(
|
||||
pendingRequests: Map<string, PendingGatewayRequest>,
|
||||
error: Error,
|
||||
): void {
|
||||
for (const [, request] of pendingRequests) {
|
||||
if (request.timeout) {
|
||||
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;
|
||||
if (request.timeout) {
|
||||
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;
|
||||
if (request.timeout) {
|
||||
clearTimeout(request.timeout);
|
||||
}
|
||||
pendingRequests.delete(id);
|
||||
request.reject(error);
|
||||
return true;
|
||||
}
|
||||
346
electron/gateway/ws-client.ts
Normal file
346
electron/gateway/ws-client.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import type { DeviceIdentity } from '@electron/utils/device-identity';
|
||||
import {
|
||||
buildDeviceAuthPayload,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from '@electron/utils/device-identity';
|
||||
|
||||
export const GATEWAY_CHALLENGE_TIMEOUT_MS = 10_000;
|
||||
export const GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS = 20_000;
|
||||
|
||||
type GatewayProtocolFrame =
|
||||
| {
|
||||
type?: string;
|
||||
event?: string;
|
||||
id?: string;
|
||||
ok?: boolean;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
| null;
|
||||
|
||||
function isBlobLike(value: unknown): value is Blob {
|
||||
return typeof Blob !== 'undefined' && value instanceof Blob;
|
||||
}
|
||||
|
||||
async function dataToString(data: unknown): Promise<string> {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString('utf-8');
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf-8');
|
||||
}
|
||||
|
||||
if (isBlobLike(data)) {
|
||||
return await data.text();
|
||||
}
|
||||
|
||||
return String(data ?? '');
|
||||
}
|
||||
|
||||
async function parseGatewayFrame(data: unknown): Promise<GatewayProtocolFrame> {
|
||||
const text = await dataToString(data);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(text) as GatewayProtocolFrame;
|
||||
}
|
||||
|
||||
function buildGatewayConnectFrame(options: {
|
||||
challengeNonce: string;
|
||||
token: string;
|
||||
deviceIdentity: DeviceIdentity | null;
|
||||
platform: string;
|
||||
}): { connectId: string; frame: Record<string, unknown> } {
|
||||
const connectId = `connect-${Date.now()}`;
|
||||
const clientId = 'gateway-client';
|
||||
const clientMode = 'ui';
|
||||
const role = 'operator';
|
||||
const scopes = ['operator.admin'];
|
||||
const signedAtMs = Date.now();
|
||||
|
||||
const device = (() => {
|
||||
if (!options.deviceIdentity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: options.deviceIdentity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: options.token,
|
||||
nonce: options.challengeNonce,
|
||||
});
|
||||
|
||||
return {
|
||||
id: options.deviceIdentity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem),
|
||||
signature: signDevicePayload(options.deviceIdentity.privateKeyPem, payload),
|
||||
signedAt: signedAtMs,
|
||||
nonce: options.challengeNonce,
|
||||
};
|
||||
})();
|
||||
|
||||
return {
|
||||
connectId,
|
||||
frame: {
|
||||
type: 'req',
|
||||
id: connectId,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: clientId,
|
||||
displayName: 'zn-ai',
|
||||
version: '1.0.0',
|
||||
platform: options.platform,
|
||||
mode: clientMode,
|
||||
},
|
||||
auth: {
|
||||
token: options.token,
|
||||
},
|
||||
caps: [],
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeGatewayReady(
|
||||
port: number,
|
||||
timeoutMs = 1500,
|
||||
): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
let settled = false;
|
||||
|
||||
const resolveOnce = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore probe close errors
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
resolveOnce(false);
|
||||
}, timeoutMs);
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const message = await parseGatewayFrame(event.data);
|
||||
if (message?.type === 'event' && message.event === 'connect.challenge') {
|
||||
resolveOnce(true);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed probe payloads
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
resolveOnce(false);
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
resolveOnce(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForGatewayReady(options: {
|
||||
port: number;
|
||||
getProcessExitCode: () => number | null;
|
||||
retries?: number;
|
||||
intervalMs?: number;
|
||||
}): Promise<void> {
|
||||
const retries = options.retries ?? 300;
|
||||
const intervalMs = options.intervalMs ?? 200;
|
||||
|
||||
for (let i = 0; i < retries; i += 1) {
|
||||
const exitCode = options.getProcessExitCode();
|
||||
if (exitCode !== null) {
|
||||
throw new Error(`OpenClaw Gateway exited before becoming ready (code=${exitCode})`);
|
||||
}
|
||||
|
||||
const ready = await probeGatewayReady(options.port, 1500);
|
||||
if (ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`OpenClaw Gateway failed to become ready on port ${options.port}`);
|
||||
}
|
||||
|
||||
export async function connectGatewaySocket(options: {
|
||||
port: number;
|
||||
token: string;
|
||||
deviceIdentity: DeviceIdentity | null;
|
||||
platform: string;
|
||||
onMessage: (message: unknown) => void;
|
||||
onCloseAfterHandshake: (socket: WebSocket, code: number) => void;
|
||||
challengeTimeoutMs?: number;
|
||||
connectTimeoutMs?: number;
|
||||
}): Promise<WebSocket> {
|
||||
const challengeTimeoutMs = options.challengeTimeoutMs ?? GATEWAY_CHALLENGE_TIMEOUT_MS;
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS;
|
||||
|
||||
return await new Promise<WebSocket>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${options.port}/ws`);
|
||||
let handshakeComplete = false;
|
||||
let settled = false;
|
||||
let challengeTimer: NodeJS.Timeout | null = null;
|
||||
let handshakeTimer: NodeJS.Timeout | null = null;
|
||||
let connectId: string | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer);
|
||||
handshakeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(ws);
|
||||
};
|
||||
|
||||
const rejectOnce = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
challengeTimer = setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore close error
|
||||
}
|
||||
rejectOnce(new Error('Timed out waiting for connect.challenge from OpenClaw Gateway'));
|
||||
}, challengeTimeoutMs);
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const message = await parseGatewayFrame(event.data);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handshakeComplete && message.type === 'event' && message.event === 'connect.challenge') {
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
|
||||
const nonce = (
|
||||
typeof message.payload === 'object' &&
|
||||
message.payload !== null &&
|
||||
'nonce' in message.payload &&
|
||||
typeof (message.payload as { nonce?: unknown }).nonce === 'string'
|
||||
)
|
||||
? (message.payload as { nonce: string }).nonce
|
||||
: '';
|
||||
|
||||
if (!nonce) {
|
||||
rejectOnce(new Error('OpenClaw Gateway connect.challenge missing nonce'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildGatewayConnectFrame({
|
||||
challengeNonce: nonce,
|
||||
token: options.token,
|
||||
deviceIdentity: options.deviceIdentity,
|
||||
platform: options.platform,
|
||||
});
|
||||
connectId = payload.connectId;
|
||||
ws.send(JSON.stringify(payload.frame));
|
||||
|
||||
handshakeTimer = setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore close error
|
||||
}
|
||||
rejectOnce(new Error('Timed out waiting for OpenClaw Gateway connect response'));
|
||||
}, connectTimeoutMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handshakeComplete && message.type === 'res' && message.id === connectId) {
|
||||
if (message.ok === false) {
|
||||
const errorMessage =
|
||||
typeof message.error === 'string'
|
||||
? message.error
|
||||
: (
|
||||
typeof message.error === 'object' &&
|
||||
message.error !== null &&
|
||||
'message' in message.error &&
|
||||
typeof (message.error as { message?: unknown }).message === 'string'
|
||||
)
|
||||
? (message.error as { message: string }).message
|
||||
: 'OpenClaw Gateway connect handshake failed';
|
||||
rejectOnce(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
handshakeComplete = true;
|
||||
resolveOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handshakeComplete) {
|
||||
options.onMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
ws.addEventListener('close', (event) => {
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(new Error(`OpenClaw Gateway socket closed before handshake (code=${event.code})`));
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
options.onCloseAfterHandshake(ws, event.code);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(new Error('OpenClaw Gateway socket connection failed'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user