import WebSocket from 'ws'; import type { DeviceIdentity } from '../utils/device-identity'; import { buildDeviceAuthPayload, publicKeyRawBase64UrlFromPem, signDevicePayload, } from '../utils/device-identity'; export async function probeGatewayReady( port: number, timeoutMs = 1500, ): Promise { return await new Promise((resolve) => { const testWs = new WebSocket(`ws://localhost:${port}/ws`); let settled = false; const resolveOnce = (value: boolean) => { if (settled) return; settled = true; clearTimeout(timeout); try { testWs.close(); } catch { // ignore } resolve(value); }; const timeout = setTimeout(() => { resolveOnce(false); }, timeoutMs); testWs.on('open', () => { // Do not resolve on plain socket open. The gateway can accept the TCP/WebSocket // connection before it is ready to issue protocol challenges, which previously // caused a false "ready" result and then a full connect() stall. }); testWs.on('message', (data) => { try { const message = JSON.parse(data.toString()) as { type?: string; event?: string }; if (message.type === 'event' && message.event === 'connect.challenge') { resolveOnce(true); } } catch { // ignore malformed probe payloads } }); testWs.on('error', () => { resolveOnce(false); }); testWs.on('close', () => { resolveOnce(false); }); }); } export function buildGatewayConnectFrame(options: { challengeNonce: string; token: string; deviceIdentity: DeviceIdentity | null; platform: string; }): { connectId: string; frame: Record } { const connectId = `connect-${Date.now()}`; const role = 'operator'; const scopes = ['operator.admin']; const signedAtMs = Date.now(); const clientId = 'gateway-client'; const clientMode = 'ui'; const device = (() => { if (!options.deviceIdentity) return undefined; const payload = buildDeviceAuthPayload({ deviceId: options.deviceIdentity.deviceId, clientId, clientMode, role, scopes, signedAtMs, token: options.token ?? null, nonce: options.challengeNonce, }); const signature = signDevicePayload(options.deviceIdentity.privateKeyPem, payload); return { id: options.deviceIdentity.deviceId, publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem), signature, signedAt: signedAtMs, nonce: options.challengeNonce, }; })(); return { connectId, frame: { type: 'req', id: connectId, method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: clientId, displayName: 'ClawX', version: '0.1.0', platform: options.platform, mode: clientMode, }, auth: { token: options.token, }, caps: [], role, scopes, device, }, }, }; }