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:
duanshuwen
2026-04-22 21:56:37 +08:00
parent 2f675afe47
commit ea1fd18e6f
22 changed files with 8947 additions and 94 deletions

View File

@@ -0,0 +1,5 @@
import { syncBrowserConfigToOpenClaw } from '@electron/utils/openclaw-auth';
export async function syncGatewayConfigBeforeLaunch(): Promise<void> {
await syncBrowserConfigToOpenClaw();
}

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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 },

View 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}`);
}
}
});
});
}

View 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;
}

View 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'));
}
});
});
}