Files
zclaw_openfang/desktop/src/lib/gateway-client.ts
iven 02c69bb3cf
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix: subagent unique ID matching + AgentState serialization + pre-existing TS errors
- S-3: Thread task_id (UUID) through all 6 layers (LoopEvent → StreamChatEvent → kernel-types → gateway-client → streamStore) so subtasks are matched by ID, not description string
- AgentState: Add #[serde(rename_all = "lowercase")] to fix PascalCase serialization ("Running" → "running"), update frontend matcher
- S-1: Remove unused onClose prop from ArtifactPanel + ChatArea call site
- Fix hooks/index.ts: remove orphaned useAutomationEvents re-exports (module deleted)
- Fix types/index.ts: remove orphaned automation type/value re-exports (module deleted)
- Fix ChatArea.tsx: framer-motion 12 + React 19 type compat — use createElement + explicit any return type to avoid unknown-in-JSX-child error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 22:30:16 +08:00

1287 lines
45 KiB
TypeScript

/**
* 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<string, {
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
timer: number;
}>();
private eventListeners = new Map<string, Set<EventCallback>>();
private reconnectAttempts = 0;
private reconnectTimer: number | null = null;
private deviceKeysPromise: Promise<DeviceKeys>;
private streamCallbacks = new Map<string, {
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;
}>();
// 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<void> {
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<void> {
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<string, unknown>): Promise<unknown> {
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<string | null> {
try {
// Use /api/agents endpoint which returns array of agents
const agents = await this.restGet<Array<{ id: string; name?: string; state?: string }>>('/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<string, unknown> = {
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<T>(path: string): Promise<T> {
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<T>(path: string, body?: unknown): Promise<T> {
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<T>(path: string, body?: unknown): Promise<T> {
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<T>(path: string): Promise<T> {
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<T>(path: string, body?: unknown): Promise<T> {
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<typeof GatewayClient>[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<any>;
status(): Promise<any>;
listClones(): Promise<any>;
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<any>;
updateClone(id: string, updates: Record<string, any>): Promise<any>;
deleteClone(id: string): Promise<any>;
getUsageStats(): Promise<any>;
getSessionStats(): Promise<any>;
getWorkspaceInfo(): Promise<any>;
getPluginStatus(): Promise<any>;
getQuickConfig(): Promise<any>;
saveQuickConfig(config: Record<string, any>): Promise<any>;
listSkills(): Promise<any>;
getSkill(id: string): Promise<any>;
createSkill(skill: { name: string; description?: string; triggers: Array<{ type: string; pattern?: string }>; actions: Array<{ type: string; params?: Record<string, unknown> }>; enabled?: boolean }): Promise<any>;
updateSkill(id: string, updates: { name?: string; description?: string; triggers?: Array<{ type: string; pattern?: string }>; actions?: Array<{ type: string; params?: Record<string, unknown> }>; enabled?: boolean }): Promise<any>;
deleteSkill(id: string): Promise<any>;
listChannels(): Promise<any>;
getChannel(id: string): Promise<any>;
createChannel(channel: { type: string; name: string; config: Record<string, unknown>; enabled?: boolean }): Promise<any>;
updateChannel(id: string, updates: { name?: string; config?: Record<string, unknown>; enabled?: boolean }): Promise<any>;
deleteChannel(id: string): Promise<any>;
getFeishuStatus(): Promise<any>;
listScheduledTasks(): Promise<any>;
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<void>;
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<any>;
triggerHand(name: string, params?: Record<string, unknown>, 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<string, unknown>): 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<string, unknown>; condition?: string }> }): Promise<{ id: string; name: string }>;
updateWorkflow(id: string, updates: { name?: string; description?: string; steps?: Array<{ handName: string; name?: string; params?: Record<string, unknown>; 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<any>;
createSession(opts: { agent_id: string; metadata?: Record<string, unknown> }): 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<any>;
createTrigger(trigger: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }): Promise<{ id: string }>;
updateTrigger(id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; 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<string, unknown>; 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<GatewayConfigSnapshot | Record<string, any>>;
applyConfig(raw: string, baseHash?: string, opts?: { sessionKey?: string; note?: string; restartDelayMs?: number }): Promise<any>;
}