/** * ZCLAW Gateway Client (Browser/Tauri side) * * Core WebSocket client for ZCLAW Kernel protocol. * Handles connection management, WebSocket framing, heartbeat, * event dispatch, and chat/stream operations. * * Module structure: * - gateway-types.ts: Protocol types, stream types, ConnectionState * - gateway-auth.ts: Device authentication (Ed25519) * - gateway-storage.ts: URL/token persistence, normalization * - gateway-api.ts: REST API method implementations (installed via mixin) * - gateway-client.ts: Core client class (this file) */ // === Re-exports for backward compatibility === export type { GatewayRequest, GatewayError, GatewayResponse, GatewayEvent, GatewayPong, GatewayFrame, AgentStreamDelta, ZclawStreamEvent, ConnectionState, EventCallback, } from './gateway-types'; export { getLocalDeviceIdentity, clearDeviceKeys, } from './gateway-auth'; export type { LocalDeviceIdentity } from './gateway-auth'; export { DEFAULT_GATEWAY_URL, REST_API_URL, FALLBACK_GATEWAY_URLS, normalizeGatewayUrl, isLocalhost, getStoredGatewayUrl, setStoredGatewayUrl, getStoredGatewayToken, setStoredGatewayToken, } from './gateway-storage'; // === Internal imports === import type { GatewayRequest, GatewayFrame, GatewayResponse, GatewayEvent, ZclawStreamEvent, ConnectionState, EventCallback, AgentStreamDelta, } from './gateway-types'; import { loadDeviceKeys, signDeviceAuth, clearDeviceKeys, type DeviceKeys, } from './gateway-auth'; import { normalizeGatewayUrl, isLocalhost, getStoredGatewayUrl, getStoredGatewayToken, } from './gateway-storage'; import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config'; import { installApiMethods } from './gateway-api'; import { createLogger } from './logger'; import { GatewayHttpError } from './gateway-errors'; const log = createLogger('GatewayClient'); // === Security === /** * Security error for invalid WebSocket connections. * Thrown when non-localhost URLs use ws:// instead of wss://. */ export class SecurityError extends Error { constructor(message: string) { super(message); this.name = 'SecurityError'; } } /** * Connection error for WebSocket/HTTP connection failures. */ export class ConnectionError extends Error { public readonly code?: string; public readonly recoverable: boolean; constructor(message: string, code?: string, recoverable: boolean = true) { super(message); this.name = 'ConnectionError'; this.code = code; this.recoverable = recoverable; } } /** * Timeout error for request/response timeouts. */ export class TimeoutError extends Error { public readonly timeout: number; constructor(message: string, timeout: number) { super(message); this.name = 'TimeoutError'; this.timeout = timeout; } } /** * Authentication error for handshake/token failures. */ export class AuthenticationError extends Error { public readonly code?: string; constructor(message: string, code?: string) { super(message); this.name = 'AuthenticationError'; this.code = code; } } /** * Validate WebSocket URL security. * Ensures non-localhost connections use WSS protocol. * * @param url - The WebSocket URL to validate * @throws SecurityError if non-localhost URL uses ws:// instead of wss:// */ export function validateWebSocketSecurity(url: string): void { if (!url.startsWith('wss://') && !isLocalhost(url)) { throw new SecurityError( 'Non-localhost connections must use WSS protocol for security. ' + `URL: ${url.replace(/:[^:@]+@/, ':****@')}` ); } } function createIdempotencyKey(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } const bytes = crypto.getRandomValues(new Uint8Array(6)); const suffix = Array.from(bytes).map(b => b.toString(36).padStart(2, '0')).join(''); return `idem_${Date.now()}_${suffix}`; } // === Client === export class GatewayClient { private ws: WebSocket | null = null; private zclawWs: WebSocket | null = null; // ZCLAW stream WebSocket private state: ConnectionState = 'disconnected'; private requestId = 0; private pendingRequests = new Map void; reject: (reason: unknown) => void; timer: number; }>(); private eventListeners = new Map>(); private reconnectAttempts = 0; private reconnectTimer: number | null = null; private deviceKeysPromise: Promise; private streamCallbacks = new Map void; onThinkingDelta?: (delta: string) => void; onTool?: (tool: string, input: string, output: string) => void; onHand?: (name: string, status: string, result?: unknown) => void; onSubtaskStatus?: (taskId: string, description: string, status: string, detail?: string) => void; onComplete: (inputTokens?: number, outputTokens?: number) => void; onError: (error: string) => void; }>(); // Options private url: string; private token: string; private autoReconnect: boolean; private reconnectInterval: number; private requestTimeout: number; // Heartbeat private heartbeatInterval: number | null = null; private heartbeatTimeout: number | null = null; private missedHeartbeats: number = 0; private static readonly HEARTBEAT_INTERVAL = 30000; // 30 seconds private static readonly HEARTBEAT_TIMEOUT = 10000; // 10 seconds private static readonly MAX_MISSED_HEARTBEATS = 3; // State change callbacks onStateChange?: (state: ConnectionState) => void; onLog?: (level: string, message: string) => void; constructor(opts?: { url?: string; token?: string; autoReconnect?: boolean; reconnectInterval?: number; requestTimeout?: number; }) { this.url = normalizeGatewayUrl(opts?.url || getStoredGatewayUrl()); this.token = opts?.token ?? getStoredGatewayToken(); this.autoReconnect = opts?.autoReconnect ?? true; this.reconnectInterval = opts?.reconnectInterval || 3000; this.requestTimeout = opts?.requestTimeout || 30000; this.deviceKeysPromise = loadDeviceKeys(); } updateOptions(opts?: { url?: string; token?: string; autoReconnect?: boolean; reconnectInterval?: number; requestTimeout?: number; }) { if (!opts) return; if (opts.url) { this.url = normalizeGatewayUrl(opts.url); } if (opts.token !== undefined) { this.token = opts.token; } if (opts.autoReconnect !== undefined) { this.autoReconnect = opts.autoReconnect; } if (opts.reconnectInterval !== undefined) { this.reconnectInterval = opts.reconnectInterval; } if (opts.requestTimeout !== undefined) { this.requestTimeout = opts.requestTimeout; } } getState(): ConnectionState { return this.state; } // === Connection === /** Connect using REST API only (for ZCLAW mode) */ async connectRest(): Promise { if (this.state === 'connected') { return; } this.setState('connecting'); try { // Check if ZCLAW API is healthy const health = await this.restGet<{ status: string; version?: string }>('/api/health'); if (health.status === 'ok') { this.reconnectAttempts = 0; this.setState('connected'); this.startHeartbeat(); // Start heartbeat after successful connection this.log('info', `Connected to ZCLAW via REST API${health.version ? ` (v${health.version})` : ''}`); this.emitEvent('connected', { version: health.version }); } else { throw new Error('Health check failed'); } } catch (err: unknown) { this.setState('disconnected'); const errorMessage = err instanceof Error ? err.message : String(err); throw new Error(`Failed to connect to ZCLAW: ${errorMessage}`); } } connect(): Promise { if (this.state === 'connected' || this.state === 'connecting' || this.state === 'handshaking') { return Promise.resolve(); } // Check if URL is for ZCLAW (port 4200 or 50051) - use REST mode if (this.url.includes(':4200') || this.url.includes(':50051')) { return this.connectRest(); } // Security validation: enforce WSS for non-localhost connections validateWebSocketSecurity(this.url); this.autoReconnect = true; this.setState('connecting'); return new Promise((resolve, reject) => { let settled = false; const settleResolve = () => { if (settled) return; settled = true; resolve(); }; const settleReject = (error: Error) => { if (settled) return; settled = true; reject(error); }; const handshakeTimer = window.setTimeout(() => { this.log('error', `Handshake timed out after ${this.requestTimeout}ms`); this.cleanup(); settleReject(new Error(`Gateway handshake timed out after ${this.requestTimeout}ms`)); }, this.requestTimeout); try { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.setState('handshaking'); }; this.ws.onmessage = (evt) => { try { const frame: GatewayFrame = JSON.parse(evt.data); this.handleFrame(frame, () => { clearTimeout(handshakeTimer); settleResolve(); }, (error) => { clearTimeout(handshakeTimer); settleReject(error); }); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); this.log('error', `Parse error: ${errorMessage}`); } }; this.ws.onclose = (evt) => { const wasConnected = this.state === 'connected'; const closedDuringConnect = !wasConnected && !settled; this.cleanup(); if (wasConnected && this.autoReconnect) { this.scheduleReconnect(); } this.emitEvent('close', { code: evt.code, reason: evt.reason }); if (closedDuringConnect) { clearTimeout(handshakeTimer); settleReject(new Error(evt.reason || `WebSocket closed before handshake completed (code: ${evt.code})`)); } }; this.ws.onerror = () => { if (this.state === 'connecting' || this.state === 'handshaking') { clearTimeout(handshakeTimer); this.cleanup(); settleReject(new Error('WebSocket connection failed')); } }; } catch (err) { clearTimeout(handshakeTimer); this.cleanup(); settleReject(err instanceof Error ? err : new Error(String(err))); } }); } disconnect() { this.autoReconnect = false; this.cancelReconnect(); if (this.ws) { this.ws.close(1000, 'Client disconnect'); } this.cleanup(); } // === Request/Response === async request(method: string, params?: Record): Promise { if (this.state !== 'connected') { throw new Error(`Not connected (state: ${this.state})`); } const id = `req_${++this.requestId}`; const frame: GatewayRequest = { type: 'req', id, method, params }; return new Promise((resolve, reject) => { const timer = window.setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Request ${method} timed out`)); }, this.requestTimeout); this.pendingRequests.set(id, { resolve, reject, timer }); this.send(frame); }); } // === High-level API === // Default agent ID for ZCLAW (will be set dynamically from /api/agents) private defaultAgentId: string = ''; /** Try to fetch default agent ID from ZCLAW /api/agents endpoint */ async fetchDefaultAgentId(): Promise { try { // Use /api/agents endpoint which returns array of agents const agents = await this.restGet>('/api/agents'); if (agents && agents.length > 0) { // Prefer agent with state "Running", otherwise use first agent const runningAgent = agents.find((a: { id: string; name?: string; state?: string }) => a.state === 'running'); const defaultAgent = runningAgent || agents[0]; this.defaultAgentId = defaultAgent.id; this.log('info', `Fetched default agent from /api/agents: ${this.defaultAgentId} (${defaultAgent.name || 'unnamed'})`); return this.defaultAgentId; } } catch (err) { this.log('warn', `Failed to fetch default agent from /api/agents: ${err}`); } return null; } /** Set the default agent ID */ setDefaultAgentId(agentId: string): void { this.defaultAgentId = agentId; this.log('info', `Default agent set to: ${agentId}`); } /** Get the current default agent ID */ getDefaultAgentId(): string { return this.defaultAgentId; } /** Send message to agent (ZCLAW chat API) */ async chat(message: string, opts?: { sessionKey?: string; agentId?: string; idempotencyKey?: string; extraSystemPrompt?: string; model?: string; temperature?: number; maxTokens?: number; }): Promise<{ runId: string; sessionId?: string; response?: string }> { // ZCLAW uses /api/agents/{agentId}/message endpoint let agentId = opts?.agentId || this.defaultAgentId; // If no agent ID, try to fetch from ZCLAW status if (!agentId) { await this.fetchDefaultAgentId(); agentId = this.defaultAgentId; } if (!agentId) { throw new Error('No agent available. Please ensure ZCLAW has at least one agent.'); } const result = await this.restPost<{ response?: string; input_tokens?: number; output_tokens?: number }>(`/api/agents/${agentId}/message`, { message, session_id: opts?.sessionKey, }); // ZCLAW returns { response, input_tokens, output_tokens } return { runId: createIdempotencyKey(), sessionId: opts?.sessionKey, response: result.response, }; } /** Send message with streaming response (ZCLAW WebSocket) */ async chatStream( message: string, callbacks: { onDelta: (delta: string) => void; onThinkingDelta?: (delta: string) => void; onTool?: (tool: string, input: string, output: string) => void; onHand?: (name: string, status: string, result?: unknown) => void; onSubtaskStatus?: (taskId: string, description: string, status: string, detail?: string) => void; onComplete: (inputTokens?: number, outputTokens?: number) => void; onError: (error: string) => void; }, opts?: { sessionKey?: string; agentId?: string; thinking_enabled?: boolean; reasoning_effort?: string; plan_mode?: boolean; subagent_enabled?: boolean; } ): Promise<{ runId: string }> { const agentId = opts?.agentId || this.defaultAgentId; const runId = createIdempotencyKey(); const sessionId = opts?.sessionKey || crypto.randomUUID(); // If no agent ID, try to fetch from ZCLAW status (async, but we'll handle it in connectZclawStream) if (!agentId) { // Try to get default agent asynchronously const chatModeOpts = { thinking_enabled: opts?.thinking_enabled, reasoning_effort: opts?.reasoning_effort, plan_mode: opts?.plan_mode, subagent_enabled: opts?.subagent_enabled, }; this.fetchDefaultAgentId().then(() => { const resolvedAgentId = this.defaultAgentId; if (resolvedAgentId) { this.streamCallbacks.set(runId, callbacks); this.connectZclawStream(resolvedAgentId, runId, sessionId, message, chatModeOpts); } else { callbacks.onError('No agent available. Please ensure ZCLAW has at least one agent.'); callbacks.onComplete(); } }).catch((err) => { callbacks.onError(`Failed to get agent: ${err}`); callbacks.onComplete(); }); return { runId }; } // Store callbacks for this run this.streamCallbacks.set(runId, callbacks); // Connect to ZCLAW WebSocket if not connected this.connectZclawStream(agentId, runId, sessionId, message, { thinking_enabled: opts?.thinking_enabled, reasoning_effort: opts?.reasoning_effort, plan_mode: opts?.plan_mode, subagent_enabled: opts?.subagent_enabled, }); return { runId }; } /** Connect to ZCLAW streaming WebSocket */ private connectZclawStream( agentId: string, runId: string, sessionId: string, message: string, chatModeOpts?: { thinking_enabled?: boolean; reasoning_effort?: string; plan_mode?: boolean; subagent_enabled?: boolean; } ): void { // Close existing connection if any if (this.zclawWs && this.zclawWs.readyState !== WebSocket.CLOSED) { this.zclawWs.close(); } // Build WebSocket URL // In dev mode, use Vite proxy; in production, use direct connection let wsUrl: string; if (typeof window !== 'undefined' && window.location.port === '1420') { // Dev mode: use Vite proxy with relative path wsUrl = `ws://${window.location.host}/api/agents/${agentId}/ws`; } else { // Production: extract from stored URL const httpUrl = this.getRestBaseUrl(); wsUrl = httpUrl.replace(/^http/, 'ws') + `/api/agents/${agentId}/ws`; } this.log('info', `Connecting to ZCLAW stream: ${wsUrl}`); try { this.zclawWs = new WebSocket(wsUrl); this.zclawWs.onopen = () => { this.log('info', 'ZCLAW WebSocket connected'); // Send chat message using ZCLAW actual protocol const chatRequest: Record = { type: 'message', content: message, session_id: sessionId, }; if (chatModeOpts?.thinking_enabled !== undefined) { chatRequest.thinking_enabled = chatModeOpts.thinking_enabled; } if (chatModeOpts?.reasoning_effort !== undefined) { chatRequest.reasoning_effort = chatModeOpts.reasoning_effort; } if (chatModeOpts?.plan_mode !== undefined) { chatRequest.plan_mode = chatModeOpts.plan_mode; } if (chatModeOpts?.subagent_enabled !== undefined) { chatRequest.subagent_enabled = chatModeOpts.subagent_enabled; } this.zclawWs?.send(JSON.stringify(chatRequest)); }; this.zclawWs.onmessage = (event) => { try { const data = JSON.parse(event.data); this.handleZclawStreamEvent(runId, data, sessionId); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); this.log('error', `Failed to parse stream event: ${errorMessage}`); } }; this.zclawWs.onerror = (_event) => { this.log('error', 'ZCLAW WebSocket error'); const callbacks = this.streamCallbacks.get(runId); if (callbacks) { callbacks.onError('WebSocket connection failed'); this.streamCallbacks.delete(runId); } }; this.zclawWs.onclose = (event) => { this.log('info', `ZCLAW WebSocket closed: ${event.code} ${event.reason}`); const callbacks = this.streamCallbacks.get(runId); if (callbacks) { if (event.code !== 1000) { callbacks.onError(`Connection closed: ${event.reason || 'unknown'}`); } else { // Normal closure — ensure stream is completed even if no done event was sent callbacks.onComplete(); } } this.streamCallbacks.delete(runId); this.zclawWs = null; }; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); this.log('error', `Failed to create WebSocket: ${errorMessage}`); const callbacks = this.streamCallbacks.get(runId); if (callbacks) { callbacks.onError(errorMessage); this.streamCallbacks.delete(runId); } } } /** Handle ZCLAW stream events */ private handleZclawStreamEvent(runId: string, data: ZclawStreamEvent, sessionId: string): void { const callbacks = this.streamCallbacks.get(runId); if (!callbacks) return; switch (data.type) { // ZCLAW actual event types case 'text_delta': // Stream delta content if (data.content) { callbacks.onDelta(data.content); } break; case 'thinking_delta': // Extended thinking delta if (data.content && callbacks.onThinkingDelta) { callbacks.onThinkingDelta(data.content); } break; case 'subtask_status': // Sub-agent task status update if (callbacks.onSubtaskStatus && data.description) { callbacks.onSubtaskStatus(data.task_id || data.description, data.description, data.status || '', data.detail); } break; case 'phase': // Phase change: streaming | done if (data.phase === 'done') { const inputTokens = typeof data.input_tokens === 'number' ? data.input_tokens : undefined; const outputTokens = typeof data.output_tokens === 'number' ? data.output_tokens : undefined; callbacks.onComplete(inputTokens, outputTokens); this.streamCallbacks.delete(runId); if (this.zclawWs) { this.zclawWs.close(1000, 'Stream complete'); } } break; case 'response': // Final response with tokens info if (data.content) { // Forward the full response content via onDelta // This handles non-streaming responses from the server callbacks.onDelta(data.content); } // Mark complete if phase done wasn't sent { const inputTokens = typeof data.input_tokens === 'number' ? data.input_tokens : undefined; const outputTokens = typeof data.output_tokens === 'number' ? data.output_tokens : undefined; callbacks.onComplete(inputTokens, outputTokens); } this.streamCallbacks.delete(runId); if (this.zclawWs) { this.zclawWs.close(1000, 'Stream complete'); } break; case 'typing': // Typing indicator: { state: 'start' | 'stop' } // Can be used for UI feedback break; case 'tool_call': // Tool call event if (callbacks.onTool && data.tool) { callbacks.onTool(data.tool, JSON.stringify(data.input || {}), data.output || ''); } break; case 'tool_result': if (callbacks.onTool && data.tool) { callbacks.onTool(data.tool, '', String(data.result || data.output || '')); } break; case 'hand': if (callbacks.onHand && data.hand_name) { callbacks.onHand(data.hand_name, data.hand_status || 'triggered', data.hand_result); } break; case 'error': callbacks.onError(data.message || data.code || data.content || 'Unknown error'); this.streamCallbacks.delete(runId); if (this.zclawWs) { this.zclawWs.close(1011, 'Error'); } break; case 'connected': // Connection established this.log('info', `ZCLAW agent connected: ${data.agent_id}`); break; case 'agents_updated': // Agents list updated this.log('debug', 'Agents list updated'); break; default: // Emit unknown events for debugging this.log('debug', `Stream event: ${data.type}`); } // Also emit to general 'agent' event listeners this.emitEvent('agent', { stream: data.type === 'text_delta' ? 'assistant' : data.type, delta: data.content, content: data.content, runId, sessionId, ...data, }); } /** Cancel an ongoing stream */ cancelStream(runId: string): void { const callbacks = this.streamCallbacks.get(runId); if (callbacks) { callbacks.onError('Stream cancelled'); this.streamCallbacks.delete(runId); } if (this.zclawWs && this.zclawWs.readyState === WebSocket.OPEN) { this.zclawWs.close(1000, 'User cancelled'); } } // === REST API Helpers (ZCLAW) === public getRestBaseUrl(): string { // In browser dev mode, use Vite proxy (empty string = relative path) // In production Tauri, extract HTTP URL from WebSocket URL if (typeof window !== 'undefined' && window.location.port === '1420') { // Dev mode: use Vite proxy (requests go to /api/* which Vite proxies to backend) return ''; } // Production: extract HTTP URL from WebSocket URL const wsUrl = this.url; return wsUrl.replace(/^ws/, 'http').replace(/\/ws$/, ''); } public async restGet(path: string): Promise { const baseUrl = this.getRestBaseUrl(); const response = await fetch(`${baseUrl}${path}`); if (!response.ok) { const errorBody = await response.text().catch(() => ''); throw new GatewayHttpError(`HTTP ${response.status}: ${errorBody || response.statusText}`, response.status, errorBody); } return response.json(); } public async restPost(path: string, body?: unknown): Promise { const baseUrl = this.getRestBaseUrl(); const url = `${baseUrl}${path}`; log.debug(`POST ${url}`, body); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { const errorBody = await response.text().catch(() => ''); log.error(`POST ${url} failed: ${response.status} ${response.statusText}`, errorBody); throw new GatewayHttpError(`HTTP ${response.status}: ${errorBody || response.statusText}`, response.status, errorBody); } const result = await response.json(); log.debug(`POST ${url} response:`, result); return result; } public async restPut(path: string, body?: unknown): Promise { const baseUrl = this.getRestBaseUrl(); const response = await fetch(`${baseUrl}${path}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { throw new Error(`REST API error: ${response.status} ${response.statusText}`); } return response.json(); } public async restDelete(path: string): Promise { const baseUrl = this.getRestBaseUrl(); const response = await fetch(`${baseUrl}${path}`, { method: 'DELETE', }); if (!response.ok) { throw new Error(`REST API error: ${response.status} ${response.statusText}`); } return response.json(); } public async restPatch(path: string, body?: unknown): Promise { const baseUrl = this.getRestBaseUrl(); const response = await fetch(`${baseUrl}${path}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { throw new Error(`REST API error: ${response.status} ${response.statusText}`); } return response.json(); } // === Event Subscription === /** Subscribe to a Gateway event (e.g., 'agent', 'chat', 'heartbeat') */ on(event: string, callback: EventCallback): () => void { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event)!.add(callback); // Return unsubscribe function return () => { this.eventListeners.get(event)?.delete(callback); }; } /** Subscribe to agent stream events */ onAgentStream(callback: (delta: AgentStreamDelta) => void): () => void { return this.on('agent', (payload: unknown) => { callback(payload as AgentStreamDelta); }); } // === Internal === private handleFrame(frame: GatewayFrame, connectResolve?: () => void, connectReject?: (error: Error) => void) { // Handle pong responses for heartbeat if (frame.type === 'pong') { this.handlePong(); return; } if (frame.type === 'event') { this.handleEvent(frame, connectResolve, connectReject); } else if (frame.type === 'res') { this.handleResponse(frame); } } private handleEvent(event: GatewayEvent, connectResolve?: () => void, connectReject?: (error: Error) => void) { // Handle connect challenge if (event.event === 'connect.challenge' && this.state === 'handshaking') { const payload = event.payload as { nonce?: string } | undefined; this.performHandshake(payload?.nonce || '', connectResolve, connectReject); return; } // Dispatch to listeners this.emitEvent(event.event, event.payload); } private async performHandshake(challengeNonce: string | undefined, connectResolve?: () => void, connectReject?: (error: Error) => void) { if (!challengeNonce) { this.log('error', 'No challenge nonce received'); connectReject?.(new Error('Handshake failed: no challenge nonce')); return; } const connectId = `connect_${Date.now()}`; // Use a valid client ID from GATEWAY_CLIENT_ID_SET // Valid IDs: gateway-client, cli, webchat, node-host, test // 'cli' is for control UI / command-line clients const clientId = 'cli'; // Valid modes: cli, webchat, backend, node // 'cli' is for command-line/Control UI clients const clientMode = 'cli'; const role = 'operator'; const scopes = ['operator.read', 'operator.write', 'operator.admin', 'operator.approvals', 'operator.pairing']; // Debug: log token status this.log('debug', `Handshake token: ${this.token ? `${this.token.substring(0, 8)}... (${this.token.length} chars)` : '(empty)'}`); try { const deviceKeys = await this.deviceKeysPromise; // Debug: log device auth details this.log('debug', `Device auth: deviceId=${deviceKeys.deviceId.substring(0, 8)}..., nonce=${challengeNonce.substring(0, 8)}...`); const { signature, signedAt } = signDeviceAuth({ clientId, clientMode, deviceId: deviceKeys.deviceId, nonce: challengeNonce, role, scopes, secretKey: deviceKeys.secretKey, token: this.token, }); // Debug: log signature details this.log('debug', `Signature created: signedAt=${signedAt}, sig=${signature.substring(0, 16)}...`); const connectReq: GatewayRequest = { type: 'req', id: connectId, method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: clientId, version: '0.1.0', platform: this.detectPlatform(), mode: clientMode, }, role, scopes, auth: this.token ? { token: this.token } : {}, locale: 'zh-CN', userAgent: 'zclaw-tauri/0.1.0', device: { id: deviceKeys.deviceId, publicKey: deviceKeys.publicKeyBase64, signature, signedAt, nonce: challengeNonce, }, }, }; const originalHandler = this.ws!.onmessage; this.ws!.onmessage = (evt) => { try { const frame = JSON.parse(evt.data); if (frame.type === 'res' && frame.id === connectId) { this.ws!.onmessage = originalHandler; if (frame.ok) { this.setState('connected'); this.reconnectAttempts = 0; this.startHeartbeat(); // Start heartbeat after successful connection this.emitEvent('connected', frame.payload); this.log('info', 'Connected to Gateway'); connectResolve?.(); } else { const errorObj = frame.error; const errorMessage = errorObj?.message || errorObj?.code || JSON.stringify(errorObj); const error = new Error(`Handshake failed: ${errorMessage}`); this.log('error', error.message); // Check for signature-related errors and clear device keys if needed if (errorMessage.includes('signature') || errorMessage.includes('device')) { this.log('warn', 'Device signature failed, clearing cached keys for retry'); clearDeviceKeys(); } this.cleanup(); connectReject?.(error); } } else { originalHandler?.call(this.ws!, evt); } } catch (e) { log.debug('Parse error in handshake response handler', { error: e }); } }; this.send(connectReq); } catch (err: unknown) { const error = err instanceof Error ? err : new Error(String(err)); this.log('error', error.message); this.cleanup(); connectReject?.(error); } } private handleResponse(res: GatewayResponse) { const pending = this.pendingRequests.get(res.id); if (pending) { clearTimeout(pending.timer); this.pendingRequests.delete(res.id); if (res.ok) { pending.resolve(res.payload); } else { pending.reject(new Error(JSON.stringify(res.error))); } } } private send(frame: GatewayFrame) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(frame)); } } private emitEvent(event: string, payload: unknown) { const listeners = this.eventListeners.get(event); if (listeners) { for (const cb of listeners) { try { cb(payload); } catch (e) { log.debug('Event listener error', { error: e }); } } } // Also emit wildcard const wildcardListeners = this.eventListeners.get('*'); if (wildcardListeners) { for (const cb of wildcardListeners) { try { cb({ event, payload }); } catch (e) { log.debug('Wildcard event listener error', { error: e }); } } } } private setState(state: ConnectionState) { this.state = state; this.onStateChange?.(state); this.emitEvent('state', state); } private cleanup() { // Stop heartbeat on cleanup this.stopHeartbeat(); for (const [, pending] of this.pendingRequests) { clearTimeout(pending.timer); pending.reject(new Error('Connection closed')); } this.pendingRequests.clear(); if (this.ws) { this.ws.onopen = null; this.ws.onmessage = null; this.ws.onclose = null; this.ws.onerror = null; if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { try { this.ws.close(); } catch (e) { log.debug('WebSocket close failed during cleanup', { error: e }); } } this.ws = null; } this.setState('disconnected'); } // === Heartbeat Methods === /** * Start heartbeat to keep connection alive. * Called after successful connection. */ private startHeartbeat(): void { this.stopHeartbeat(); this.missedHeartbeats = 0; this.heartbeatInterval = window.setInterval(() => { this.sendHeartbeat(); }, GatewayClient.HEARTBEAT_INTERVAL); this.log('debug', 'Heartbeat started'); } /** * Stop heartbeat. * Called on cleanup or disconnect. */ private stopHeartbeat(): void { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } if (this.heartbeatTimeout) { clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = null; } this.log('debug', 'Heartbeat stopped'); } /** * Send a ping heartbeat to the server. */ private sendHeartbeat(): void { if (this.ws?.readyState !== WebSocket.OPEN) { this.log('debug', 'Skipping heartbeat - WebSocket not open'); return; } this.missedHeartbeats++; if (this.missedHeartbeats > GatewayClient.MAX_MISSED_HEARTBEATS) { this.log('warn', `Max missed heartbeats (${GatewayClient.MAX_MISSED_HEARTBEATS}), reconnecting`); this.stopHeartbeat(); this.ws.close(4000, 'Heartbeat timeout'); return; } // Send ping frame try { this.ws.send(JSON.stringify({ type: 'ping' })); this.log('debug', `Ping sent (missed: ${this.missedHeartbeats})`); // Set timeout for pong this.heartbeatTimeout = window.setTimeout(() => { this.log('warn', 'Heartbeat pong timeout'); // Don't reconnect immediately, let the next heartbeat check }, GatewayClient.HEARTBEAT_TIMEOUT); } catch (error) { this.log('error', `Failed to send heartbeat: ${error instanceof Error ? error.message : String(error)}`); } } /** * Handle pong response from server. */ private handlePong(): void { this.missedHeartbeats = 0; if (this.heartbeatTimeout) { clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = null; } this.log('debug', 'Pong received, heartbeat reset'); } private static readonly MAX_RECONNECT_ATTEMPTS = 10; private scheduleReconnect() { if (this.reconnectAttempts >= GatewayClient.MAX_RECONNECT_ATTEMPTS) { this.log('error', `Max reconnect attempts (${GatewayClient.MAX_RECONNECT_ATTEMPTS}) reached. Please reconnect manually.`); this.setState('disconnected'); this.emitEvent('reconnect_failed', { attempts: this.reconnectAttempts, maxAttempts: GatewayClient.MAX_RECONNECT_ATTEMPTS }); return; } this.reconnectAttempts++; this.setState('reconnecting'); const delay = Math.min(this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), 30000); this.log('info', `Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`); // Emit reconnecting event for UI this.emitEvent('reconnecting', { attempt: this.reconnectAttempts, delay, maxAttempts: GatewayClient.MAX_RECONNECT_ATTEMPTS }); this.reconnectTimer = window.setTimeout(async () => { try { await this.connect(); } catch (e) { /* close handler will trigger another reconnect */ this.log('warn', `Reconnect attempt ${this.reconnectAttempts} failed: ${e instanceof Error ? e.message : String(e)}`); } }, delay); } private cancelReconnect() { if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } private detectPlatform(): string { const ua = navigator.userAgent.toLowerCase(); if (ua.includes('win')) return 'windows'; if (ua.includes('mac')) return 'macos'; return 'linux'; } private log(level: string, message: string) { this.onLog?.(level, message); } } // Install REST API methods from gateway-api.ts onto GatewayClient prototype installApiMethods(GatewayClient); // Singleton instance let _client: GatewayClient | null = null; export function getGatewayClient(opts?: ConstructorParameters[0]): GatewayClient { if (!_client) { _client = new GatewayClient(opts); } else if (opts) { _client.updateOptions(opts); } return _client; } // === API Method Type Declarations === // These methods are installed at runtime by installApiMethods() in gateway-api.ts. // We declare them here so TypeScript knows they exist on GatewayClient. export interface GatewayClient { health(): Promise; status(): Promise; listClones(): Promise; createClone(opts: { name: string; role?: string; nickname?: string; scenarios?: string[]; model?: string; workspaceDir?: string; restrictFiles?: boolean; privacyOptIn?: boolean; userName?: string; userRole?: string; emoji?: string; personality?: string; communicationStyle?: string; notes?: string }): Promise; updateClone(id: string, updates: Record): Promise; deleteClone(id: string): Promise; getUsageStats(): Promise; getSessionStats(): Promise; getWorkspaceInfo(): Promise; getPluginStatus(): Promise; getQuickConfig(): Promise; saveQuickConfig(config: Record): Promise; listSkills(): Promise; getSkill(id: string): Promise; createSkill(skill: { name: string; description?: string; triggers: Array<{ type: string; pattern?: string }>; actions: Array<{ type: string; params?: Record }>; enabled?: boolean }): Promise; updateSkill(id: string, updates: { name?: string; description?: string; triggers?: Array<{ type: string; pattern?: string }>; actions?: Array<{ type: string; params?: Record }>; enabled?: boolean }): Promise; deleteSkill(id: string): Promise; listChannels(): Promise; getChannel(id: string): Promise; createChannel(channel: { type: string; name: string; config: Record; enabled?: boolean }): Promise; updateChannel(id: string, updates: { name?: string; config?: Record; enabled?: boolean }): Promise; deleteChannel(id: string): Promise; getFeishuStatus(): Promise; listScheduledTasks(): Promise; createScheduledTask(task: { name: string; schedule: string; scheduleType: 'cron' | 'interval' | 'once'; target?: { type: 'agent' | 'hand' | 'workflow'; id: string }; description?: string; enabled?: boolean }): Promise<{ id: string; name: string; schedule: string; status: string }>; deleteScheduledTask(id: string): Promise; toggleScheduledTask(id: string, enabled: boolean): Promise<{ id: string; enabled: boolean }>; listHands(): Promise<{ hands: { id?: string; name: string; description?: string; status?: string; requirements_met?: boolean; category?: string; icon?: string; tool_count?: number; tools?: string[]; metric_count?: number; metrics?: string[] }[] }>; getHand(name: string): Promise; triggerHand(name: string, params?: Record, autonomyLevel?: string): Promise<{ runId: string; status: string }>; getHandStatus(name: string, runId: string): Promise<{ status: string; result?: unknown }>; approveHand(name: string, runId: string, approved: boolean, reason?: string): Promise<{ status: string }>; cancelHand(name: string, runId: string): Promise<{ status: string }>; listHandRuns(name: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: { runId: string; status: string; startedAt: string }[] }>; listWorkflows(): Promise<{ workflows: { id: string; name: string; steps: number }[] }>; getWorkflow(id: string): Promise<{ id: string; name: string; steps: unknown[] }>; executeWorkflow(id: string, input?: Record): Promise<{ runId: string; status: string }>; getWorkflowRun(workflowId: string, runId: string): Promise<{ status: string; step: string; result?: unknown }>; listWorkflowRuns(workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: Array<{ runId: string; status: string; startedAt: string; completedAt?: string; step?: string; result?: unknown; error?: string }> }>; cancelWorkflow(workflowId: string, runId: string): Promise<{ status: string }>; createWorkflow(workflow: { name: string; description?: string; steps: Array<{ handName: string; name?: string; params?: Record; condition?: string }> }): Promise<{ id: string; name: string }>; updateWorkflow(id: string, updates: { name?: string; description?: string; steps?: Array<{ handName: string; name?: string; params?: Record; condition?: string }> }): Promise<{ id: string; name: string }>; deleteWorkflow(id: string): Promise<{ status: string }>; listSessions(opts?: { limit?: number; offset?: number }): Promise<{ sessions: Array<{ id: string; agent_id: string; created_at: string; updated_at?: string; message_count?: number; status?: 'active' | 'archived' | 'expired' }> }>; getSession(sessionId: string): Promise; createSession(opts: { agent_id: string; metadata?: Record }): Promise<{ id: string; agent_id: string; created_at: string }>; deleteSession(sessionId: string): Promise<{ status: string }>; getSessionMessages(sessionId: string, opts?: { limit?: number; offset?: number }): Promise<{ messages: Array<{ id: string; role: 'user' | 'assistant' | 'system'; content: string; created_at: string; tokens?: { input?: number; output?: number } }> }>; listTriggers(): Promise<{ triggers: { id: string; type: string; enabled: boolean }[] }>; getTrigger(id: string): Promise; createTrigger(trigger: { type: string; name?: string; enabled?: boolean; config?: Record; handName?: string; workflowId?: string }): Promise<{ id: string }>; updateTrigger(id: string, updates: { name?: string; enabled?: boolean; config?: Record; handName?: string; workflowId?: string }): Promise<{ id: string }>; deleteTrigger(id: string): Promise<{ status: string }>; getAuditLogs(opts?: { limit?: number; offset?: number }): Promise<{ logs: unknown[] }>; verifyAuditLogChain(logId: string): Promise<{ valid: boolean; chain_depth?: number; root_hash?: string; broken_at_index?: number }>; getSecurityStatus(): Promise<{ layers: { name: string; enabled: boolean }[] }>; getCapabilities(): Promise<{ capabilities: string[] }>; listApprovals(status?: string): Promise<{ approvals: { id: string; hand_name: string; run_id: string; status: string; requested_at: string; requested_by?: string; reason?: string; action?: string; params?: Record; responded_at?: string; responded_by?: string; response_reason?: string }[] }>; respondToApproval(approvalId: string, approved: boolean, reason?: string): Promise<{ status: string }>; listModels(): Promise<{ models: GatewayModelChoice[] }>; getConfig(): Promise>; applyConfig(raw: string, baseHash?: string, opts?: { sessionKey?: string; note?: string; restartDelayMs?: number }): Promise; }