From 0eac2b2a23860a77ce7792d510efa36f51c2537d Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:27:23 +0800 Subject: [PATCH 01/12] feat(sdk): add auto-verify handshake after gateway registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embed transparent verification logic in GatewayClient that automatically sends an RPC "verify" request to the Hub after REGISTERED event. Adds hubId, token, and verifyTimeout options. Upper-layer callers see no change — "registered" state means both gateway registration and Hub verification are complete. Failures surface via onError callback. Co-Authored-By: Claude Opus 4.5 --- packages/sdk/src/actions/index.ts | 2 ++ packages/sdk/src/actions/rpc.ts | 11 ++++++++ packages/sdk/src/client.ts | 43 ++++++++++++++++++++++++++++--- packages/sdk/src/types.ts | 7 +++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index d9fb2b1b..6e010e37 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -25,6 +25,8 @@ export { type DeleteAgentResult, type UpdateGatewayParams, type UpdateGatewayResult, + type VerifyParams, + type VerifyResult, } from "./rpc"; export { diff --git a/packages/sdk/src/actions/rpc.ts b/packages/sdk/src/actions/rpc.ts index a81a15b1..7837644c 100644 --- a/packages/sdk/src/actions/rpc.ts +++ b/packages/sdk/src/actions/rpc.ts @@ -145,3 +145,14 @@ export interface UpdateGatewayResult { url: string; connectionState: string; } + +/** verify - request params */ +export interface VerifyParams { + token?: string; +} + +/** verify - response payload */ +export interface VerifyResult { + hubId: string; + agentId: string; +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 836708eb..d87caffd 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -34,6 +34,9 @@ interface ResolvedOptions { deviceType: DeviceType; autoReconnect: boolean; reconnectDelay: number; + hubId: string | undefined; + token: string | undefined; + verifyTimeout: number; } export class GatewayClient { @@ -55,6 +58,9 @@ export class GatewayClient { deviceType: options.deviceType, autoReconnect: options.autoReconnect ?? true, reconnectDelay: options.reconnectDelay ?? 1000, + hubId: options.hubId, + token: options.token, + verifyTimeout: options.verifyTimeout ?? 30_000, }; } @@ -227,6 +233,12 @@ export class GatewayClient { return this; } + /** Hub 验证成功回调 */ + onVerified(callback: (result: { hubId: string; agentId: string }) => void): this { + this.callbacks.onVerified = callback; + return this; + } + /** 注册消息回调 */ onMessage(callback: (message: RoutedMessage) => void): this { this.callbacks.onMessage = callback; @@ -291,11 +303,36 @@ export class GatewayClient { this.socket.on( GatewayEvents.REGISTERED, (response: RegisteredResponse) => { - if (response.success) { + if (!response.success) { + this.callbacks.onError?.(new Error(response.error ?? "Registration failed")); + return; + } + + // If hubId is configured, auto-verify before exposing "registered" to upper layer + if (this.options.hubId) { + // Set internal state to allow send/request during verify + this._state = "registered"; + this.request<{ hubId: string; agentId: string }>( + this.options.hubId, + "verify", + { token: this.options.token }, + this.options.verifyTimeout, + ) + .then((result) => { + // Verify succeeded — now expose "registered" to upper layer + this.callbacks.onVerified?.(result); + this.callbacks.onRegistered?.(response.deviceId); + this.callbacks.onStateChange?.("registered"); + }) + .catch((err) => { + // Verify failed (UNAUTHORIZED, REJECTED, or timeout) + this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err))); + this.disconnect(); + }); + } else { + // No hubId — original behavior this.setState("registered"); this.callbacks.onRegistered?.(response.deviceId); - } else { - this.callbacks.onError?.(new Error(response.error ?? "Registration failed")); } } ); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index d0067591..88118021 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -89,6 +89,12 @@ export interface GatewayClientOptions { autoReconnect?: boolean | undefined; /** Reconnect delay (milliseconds), defaults to 1000 */ reconnectDelay?: number | undefined; + /** Hub device ID for verification (optional, enables auto-verify after gateway registration) */ + hubId?: string | undefined; + /** Token for first-time verification (optional, omit for reconnection via device whitelist) */ + token?: string | undefined; + /** Verify timeout in ms (default: 30_000, longer because user confirmation may be needed) */ + verifyTimeout?: number | undefined; } /** Connection state */ @@ -103,6 +109,7 @@ export interface GatewayClientCallbacks { onConnect?: (socketId: string) => void; onDisconnect?: (reason: string) => void; onRegistered?: (deviceId: string) => void; + onVerified?: (result: { hubId: string; agentId: string }) => void; onMessage?: (message: RoutedMessage) => void; onSendError?: (error: SendErrorResponse) => void; onPong?: (data: string) => void; From b2c0711676616c2db69c721dfd1c3f781d058083 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:27:29 +0800 Subject: [PATCH 02/12] feat(hub): add device verification with token + whitelist + user confirmation Add DeviceStore for managing one-time tokens and persistent device whitelist. Create async verify RPC handler that validates tokens and awaits Desktop user confirmation for first-time connections. Whitelisted devices (reconnections) pass through instantly. Add message guard to reject unverified device traffic. Co-Authored-By: Claude Opus 4.5 --- src/hub/device-store.ts | 106 +++++++++++++++++++++++++++++++++ src/hub/hub.ts | 48 ++++++++++++++- src/hub/rpc/dispatcher.ts | 6 +- src/hub/rpc/handlers/verify.ts | 45 ++++++++++++++ 4 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 src/hub/device-store.ts create mode 100644 src/hub/rpc/handlers/verify.ts diff --git a/src/hub/device-store.ts b/src/hub/device-store.ts new file mode 100644 index 00000000..e015b15c --- /dev/null +++ b/src/hub/device-store.ts @@ -0,0 +1,106 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { DATA_DIR } from "../shared/index.js"; + +// ============ Types ============ + +interface TokenEntry { + token: string; + agentId: string; + expiresAt: number; +} + +export interface DeviceEntry { + deviceId: string; + agentId: string; + addedAt: number; +} + +// ============ Persistence ============ + +const DEVICES_DIR = join(DATA_DIR, "devices"); +const DEVICES_FILE = join(DEVICES_DIR, "whitelist.json"); + +function ensureDir(): void { + if (!existsSync(DEVICES_DIR)) { + mkdirSync(DEVICES_DIR, { recursive: true }); + } +} + +function loadDevices(): DeviceEntry[] { + if (!existsSync(DEVICES_FILE)) return []; + try { + return JSON.parse(readFileSync(DEVICES_FILE, "utf-8")) as DeviceEntry[]; + } catch { + return []; + } +} + +function saveDevices(devices: DeviceEntry[]): void { + ensureDir(); + writeFileSync(DEVICES_FILE, JSON.stringify(devices, null, 2), "utf-8"); +} + +// ============ DeviceStore ============ + +export class DeviceStore { + /** One-time tokens (in-memory only, not persisted) */ + private readonly tokens = new Map(); + /** Allowed device IDs (persisted to disk) */ + private readonly allowedDevices = new Map(); + + constructor() { + // Restore from persistent storage + for (const entry of loadDevices()) { + this.allowedDevices.set(entry.deviceId, entry); + } + } + + // ---- Token management ---- + + /** Register a one-time token (called when QR code is generated) */ + registerToken(token: string, agentId: string, expiresAt: number): void { + this.tokens.set(token, { token, agentId, expiresAt }); + } + + /** Validate and consume a token (one-time use). Returns agentId if valid, null otherwise. */ + consumeToken(token: string): { agentId: string } | null { + const entry = this.tokens.get(token); + if (!entry) return null; + // Always delete — consumed or expired + this.tokens.delete(token); + if (Date.now() > entry.expiresAt) return null; + return { agentId: entry.agentId }; + } + + // ---- Device whitelist ---- + + /** Add a device to the whitelist (called after token verification + user confirmation) */ + allowDevice(deviceId: string, agentId: string): void { + const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now() }; + this.allowedDevices.set(deviceId, entry); + this.persist(); + } + + /** Check if a device is in the whitelist */ + isAllowed(deviceId: string): { agentId: string } | null { + const entry = this.allowedDevices.get(deviceId); + return entry ? { agentId: entry.agentId } : null; + } + + /** Remove a device from the whitelist */ + revokeDevice(deviceId: string): boolean { + const deleted = this.allowedDevices.delete(deviceId); + if (deleted) this.persist(); + return deleted; + } + + /** List all whitelisted devices */ + listDevices(): DeviceEntry[] { + return Array.from(this.allowedDevices.values()); + } + + private persist(): void { + saveDevices(Array.from(this.allowedDevices.values())); + } +} diff --git a/src/hub/hub.ts b/src/hub/hub.ts index bd5241bf..f18dbc48 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -21,6 +21,8 @@ import { createListAgentsHandler } from "./rpc/handlers/list-agents.js"; import { createCreateAgentHandler } from "./rpc/handlers/create-agent.js"; import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js"; import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js"; +import { DeviceStore } from "./device-store.js"; +import { createVerifyHandler } from "./rpc/handlers/verify.js"; export class Hub { private readonly agents = new Map(); @@ -29,6 +31,8 @@ export class Hub { private readonly agentStreamCounters = new Map(); private readonly rpc: RpcDispatcher; private client: GatewayClient; + readonly deviceStore: DeviceStore; + private _onConfirmDevice: ((deviceId: string, agentId: string) => Promise) | null = null; url: string; readonly path: string; readonly hubId: string; @@ -42,8 +46,20 @@ export class Hub { this.url = url; this.path = path ?? "/ws"; this.hubId = getHubId(); + this.deviceStore = new DeviceStore(); this.rpc = new RpcDispatcher(); + this.rpc.register("verify", createVerifyHandler({ + hubId: this.hubId, + deviceStore: this.deviceStore, + onConfirmDevice: (deviceId, agentId) => { + if (!this._onConfirmDevice) { + // No UI confirm handler registered (CLI mode etc.) — auto-approve + return Promise.resolve(true); + } + return this._onConfirmDevice(deviceId, agentId); + }, + })); this.rpc.register("getAgentMessages", createGetAgentMessagesHandler()); this.rpc.register("getHubInfo", createGetHubInfoHandler(this)); this.rpc.register("listAgents", createListAgentsHandler(this)); @@ -101,10 +117,30 @@ export class Hub { // RPC request if (msg.action === RequestAction) { const payload = msg.payload as RequestPayload; + // verify RPC is always allowed (it IS the verification step) + if (payload.method === "verify") { + void this.handleRpc(msg.from, payload); + return; + } + // Other RPCs require verified device + if (!this.deviceStore.isAllowed(msg.from)) { + this.client.send(msg.from, ResponseAction, { + requestId: payload.requestId, + ok: false, + error: { code: "UNAUTHORIZED", message: "Device not verified" }, + }); + return; + } void this.handleRpc(msg.from, payload); return; } + // Non-RPC messages also require verified device + if (!this.deviceStore.isAllowed(msg.from)) { + console.warn(`[Hub] Rejected message from unverified device: ${msg.from}`); + return; + } + // Regular chat message const payload = msg.payload as { agentId?: string; content?: string } | undefined; const agentId = payload?.agentId; @@ -129,6 +165,16 @@ export class Hub { return client; } + /** Register a confirmation handler for new device connections (called by Desktop UI) */ + setConfirmHandler(handler: ((deviceId: string, agentId: string) => Promise) | null): void { + this._onConfirmDevice = handler; + } + + /** Register a one-time token for device verification (called when QR code is generated) */ + registerToken(token: string, agentId: string, expiresAt: number): void { + this.deviceStore.registerToken(token, agentId, expiresAt); + } + /** 重连到新的 Gateway 地址 */ reconnect(url: string): void { console.log(`[Hub] Reconnecting to ${url}`); @@ -234,7 +280,7 @@ export class Hub { private async handleRpc(from: string, request: RequestPayload): Promise { const { requestId, method } = request; try { - const result = await this.rpc.dispatch(method, request.params); + const result = await this.rpc.dispatch(method, request.params, from); this.client.send(from, ResponseAction, { requestId, ok: true, diff --git a/src/hub/rpc/dispatcher.ts b/src/hub/rpc/dispatcher.ts index 1484568d..f1ae07a7 100644 --- a/src/hub/rpc/dispatcher.ts +++ b/src/hub/rpc/dispatcher.ts @@ -1,4 +1,4 @@ -export type RpcHandler = (params: unknown) => unknown | Promise; +export type RpcHandler = (params: unknown, from: string) => unknown | Promise; export class RpcError extends Error { constructor( @@ -22,11 +22,11 @@ export class RpcDispatcher { } /** Dispatch an RPC request to its handler */ - async dispatch(method: string, params: unknown): Promise { + async dispatch(method: string, params: unknown, from: string): Promise { const handler = this.handlers.get(method); if (!handler) { throw new RpcError("METHOD_NOT_FOUND", `Unknown RPC method: ${method}`); } - return handler(params); + return handler(params, from); } } diff --git a/src/hub/rpc/handlers/verify.ts b/src/hub/rpc/handlers/verify.ts new file mode 100644 index 00000000..bef35ca8 --- /dev/null +++ b/src/hub/rpc/handlers/verify.ts @@ -0,0 +1,45 @@ +import type { RpcHandler } from "../dispatcher.js"; +import { RpcError } from "../dispatcher.js"; +import type { DeviceStore } from "../../device-store.js"; + +interface VerifyContext { + hubId: string; + deviceStore: DeviceStore; + /** Called for first-time connections. Returns true if user approves, false if rejected. */ + onConfirmDevice: (deviceId: string, agentId: string) => Promise; +} + +interface VerifyParams { + token?: string; +} + +export function createVerifyHandler(ctx: VerifyContext): RpcHandler { + return async (params: unknown, from: string) => { + // 1. Already in whitelist → pass through (reconnection, no confirmation needed) + const allowed = ctx.deviceStore.isAllowed(from); + if (allowed) { + return { hubId: ctx.hubId, agentId: allowed.agentId }; + } + + // 2. Validate token + const { token } = (params ?? {}) as VerifyParams; + if (!token) { + throw new RpcError("UNAUTHORIZED", "Device not authorized"); + } + + const result = ctx.deviceStore.consumeToken(token); + if (!result) { + throw new RpcError("UNAUTHORIZED", "Invalid or expired token"); + } + + // 3. Token valid → await Desktop user confirmation + const confirmed = await ctx.onConfirmDevice(from, result.agentId); + if (!confirmed) { + throw new RpcError("REJECTED", "Connection rejected by user"); + } + + // 4. User confirmed → add to whitelist + ctx.deviceStore.allowDevice(from, result.agentId); + return { hubId: ctx.hubId, agentId: result.agentId }; + }; +} From 3d13c28cfc6b080772c0bf0317e45ea358686879 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:27:33 +0800 Subject: [PATCH 03/12] feat(store): pass hubId and token to GatewayClient for verification Co-Authored-By: Claude Opus 4.5 --- packages/store/src/connection-store.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts index 76589656..19dde590 100644 --- a/packages/store/src/connection-store.ts +++ b/packages/store/src/connection-store.ts @@ -73,6 +73,8 @@ let client: GatewayClient | null = null function createClient( url: string, deviceId: string, + hubId: string, + token: string, set: (s: Partial) => void, getState: () => ConnectionStoreState, ): GatewayClient { @@ -80,6 +82,8 @@ function createClient( url, deviceId, deviceType: "client", + hubId, + token, }) // Sync connection state changes to the store .onStateChange((connectionState) => { @@ -192,7 +196,7 @@ export const useConnectionStore = create()( agentId: code.agentId, }) - client = createClient(code.gateway, get().deviceId, set, get) + client = createClient(code.gateway, get().deviceId, code.hubId, code.token, set, get) client.connect() }, From ff827f05e47e0f231758517d9668b808e1095fb3 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:27:41 +0800 Subject: [PATCH 04/12] feat(desktop): add device confirmation dialog and token registration IPC Add DeviceConfirmDialog component that shows an AlertDialog when a new device requests connection, letting the user Allow or Reject. Wire up Electron IPC for token registration and device confirmation flow between main process and renderer. Register QR code tokens with Hub on generate and refresh. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/electron-env.d.ts | 3 + apps/desktop/electron/ipc/hub.ts | 37 +++++++++++ apps/desktop/electron/ipc/index.ts | 4 +- apps/desktop/electron/main.ts | 7 +- apps/desktop/electron/preload.ts | 8 +++ .../src/components/device-confirm-dialog.tsx | 64 +++++++++++++++++++ apps/desktop/src/components/qr-code.tsx | 11 +++- apps/desktop/src/pages/layout.tsx | 2 + 8 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/components/device-confirm-dialog.tsx diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 583a19b9..4cb63550 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -77,6 +77,9 @@ interface ElectronAPI { getAgent: (id: string) => Promise closeAgent: (id: string) => Promise sendMessage: (agentId: string, content: string) => Promise + registerToken: (token: string, agentId: string, expiresAt: number) => Promise + onDeviceConfirmRequest: (callback: (deviceId: string) => void) => void + deviceConfirmResponse: (deviceId: string, allowed: boolean) => void } tools: { list: () => Promise diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 6992d7ca..1aa9671c 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -221,6 +221,43 @@ export function registerHubIpcHandlers(): void { agent.write(content) return { ok: true } }) + + /** + * Register a one-time token for device verification. + * Called by the QR code component when a token is generated or refreshed. + */ + ipcMain.handle('hub:registerToken', async (_event, token: string, agentId: string, expiresAt: number) => { + const h = getHub() + h.registerToken(token, agentId, expiresAt) + return { ok: true } + }) + +} + +/** + * Set up device confirmation flow between Hub (main process) and renderer. + * Must be called after both Hub initialization and window creation. + */ +export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): void { + const h = getHub() + const pendingConfirms = new Map void>() + + // Listen for renderer responses to device confirm dialogs + ipcMain.on('hub:device-confirm-response', (_event, deviceId: string, allowed: boolean) => { + const resolve = pendingConfirms.get(deviceId) + if (resolve) { + pendingConfirms.delete(deviceId) + resolve(allowed) + } + }) + + // Register confirm handler on Hub — sends request to renderer, awaits response + h.setConfirmHandler((deviceId: string, _agentId: string) => { + return new Promise((resolve) => { + pendingConfirms.set(deviceId, resolve) + mainWindow.webContents.send('hub:device-confirm-request', deviceId) + }) + }) } /** diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index 71bbec88..13ad6fe4 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -3,11 +3,11 @@ */ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js' export { registerSkillsIpcHandlers } from './skills.js' -export { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' +export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' import { registerSkillsIpcHandlers } from './skills.js' -import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' +import { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' /** * Register all IPC handlers. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a360a499..193ca84d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow } from 'electron' import { fileURLToPath } from 'node:url' import path from 'node:path' -import { registerAllIpcHandlers, initializeApp, cleanupAll } from './ipc/index.js' +import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation } from './ipc/index.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -59,4 +59,9 @@ app.whenReady().then(async () => { await initializeApp() createWindow() + + // Set up device confirmation flow (requires both Hub and window) + if (win) { + setupDeviceConfirmation(win) + } }) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 8c898819..b439cd56 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -55,6 +55,14 @@ const electronAPI = { closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id), sendMessage: (agentId: string, content: string) => ipcRenderer.invoke('hub:sendMessage', agentId, content), + registerToken: (token: string, agentId: string, expiresAt: number) => + ipcRenderer.invoke('hub:registerToken', token, agentId, expiresAt), + onDeviceConfirmRequest: (callback: (deviceId: string) => void) => { + ipcRenderer.on('hub:device-confirm-request', (_event, deviceId: string) => callback(deviceId)) + }, + deviceConfirmResponse: (deviceId: string, allowed: boolean) => { + ipcRenderer.send('hub:device-confirm-response', deviceId, allowed) + }, }, // Tools management diff --git a/apps/desktop/src/components/device-confirm-dialog.tsx b/apps/desktop/src/components/device-confirm-dialog.tsx new file mode 100644 index 00000000..30e81f0f --- /dev/null +++ b/apps/desktop/src/components/device-confirm-dialog.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect, useCallback } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@multica/ui/components/ui/alert-dialog' + +interface PendingConfirm { + deviceId: string +} + +/** + * Device confirmation dialog — shown when a new device tries to connect via QR code. + * Listens for 'hub:device-confirm-request' IPC events from the main process, + * shows an AlertDialog, and sends the user's response back. + */ +export function DeviceConfirmDialog() { + const [pending, setPending] = useState(null) + + useEffect(() => { + window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string) => { + setPending({ deviceId }) + }) + }, []) + + const handleAllow = useCallback(() => { + if (!pending) return + window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, true) + setPending(null) + }, [pending]) + + const handleReject = useCallback(() => { + if (!pending) return + window.electronAPI?.hub.deviceConfirmResponse(pending.deviceId, false) + setPending(null) + }, [pending]) + + return ( + + + + New Device Connection + + Device {pending?.deviceId} wants to connect. + Allow this device? + + + + + Reject + + + Allow + + + + + ) +} diff --git a/apps/desktop/src/components/qr-code.tsx b/apps/desktop/src/components/qr-code.tsx index 1562dfd1..e2d44d47 100644 --- a/apps/desktop/src/components/qr-code.tsx +++ b/apps/desktop/src/components/qr-code.tsx @@ -54,11 +54,17 @@ export function ConnectionQRCode({ size = 180, onRefresh, }: ConnectionQRCodeProps) { - const [token, setToken] = useState(() => generateToken()) + const [token, setToken] = useState(generateToken) const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000) const [remainingSeconds, setRemainingSeconds] = useState(expirySeconds) const [copied, setCopied] = useState(false) + // Register initial token with Hub on mount + useEffect(() => { + window.electronAPI?.hub.registerToken(token, agentId, expiresAt) + // eslint-disable-next-line react-hooks/exhaustive-deps -- only on mount + }, []) + // QR code data payload const qrData: QRCodeData = useMemo( () => ({ @@ -93,6 +99,9 @@ export function ConnectionQRCode({ setExpiresAt(newExpires) setRemainingSeconds(expirySeconds) + // Register new token with Hub for verification + window.electronAPI?.hub.registerToken(newToken, agentId, newExpires) + if (onRefresh) { onRefresh({ type: 'multica-connect', diff --git a/apps/desktop/src/pages/layout.tsx b/apps/desktop/src/pages/layout.tsx index 63a536b2..8e6d472c 100644 --- a/apps/desktop/src/pages/layout.tsx +++ b/apps/desktop/src/pages/layout.tsx @@ -10,6 +10,7 @@ import { Comment01Icon, } from '@hugeicons/core-free-icons' import { cn } from '@multica/ui/lib/utils' +import { DeviceConfirmDialog } from '../components/device-confirm-dialog' const tabs = [ { path: '/', label: 'Home', icon: Home01Icon, exact: true }, @@ -62,6 +63,7 @@ export default function Layout() { + ) } From dd701a24725ce3146be372c949a85de1cadfbe96 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:43:41 +0800 Subject: [PATCH 05/12] feat(sdk): collect device metadata during verify handshake Auto-collect navigator.userAgent, platform, and language in the SDK verify RPC payload. Add DeviceMeta type. Hub receives this metadata for display in the Desktop UI device list and confirmation dialog. Co-Authored-By: Claude Opus 4.5 --- packages/sdk/src/actions/index.ts | 1 + packages/sdk/src/actions/rpc.ts | 8 ++++++++ packages/sdk/src/client.ts | 7 ++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index 6e010e37..e73947a4 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -25,6 +25,7 @@ export { type DeleteAgentResult, type UpdateGatewayParams, type UpdateGatewayResult, + type DeviceMeta, type VerifyParams, type VerifyResult, } from "./rpc"; diff --git a/packages/sdk/src/actions/rpc.ts b/packages/sdk/src/actions/rpc.ts index 7837644c..1f49f1d1 100644 --- a/packages/sdk/src/actions/rpc.ts +++ b/packages/sdk/src/actions/rpc.ts @@ -146,9 +146,17 @@ export interface UpdateGatewayResult { connectionState: string; } +/** Device metadata collected during verify handshake */ +export interface DeviceMeta { + userAgent?: string; + platform?: string; + language?: string; +} + /** verify - request params */ export interface VerifyParams { token?: string; + meta?: DeviceMeta; } /** verify - response payload */ diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index d87caffd..fb362281 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -312,10 +312,15 @@ export class GatewayClient { if (this.options.hubId) { // Set internal state to allow send/request during verify this._state = "registered"; + const meta = typeof navigator !== "undefined" ? { + userAgent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + } : undefined; this.request<{ hubId: string; agentId: string }>( this.options.hubId, "verify", - { token: this.options.token }, + { token: this.options.token, meta }, this.options.verifyTimeout, ) .then((result) => { From c581183839cf4b12de89c574a32ee35aefb640cd Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:43:48 +0800 Subject: [PATCH 06/12] feat(hub): store device metadata in whitelist and pass to confirm handler Extend DeviceEntry with optional DeviceMeta field. Verify handler extracts meta from params and passes it through to onConfirmDevice callback and deviceStore.allowDevice for persistence. Co-Authored-By: Claude Opus 4.5 --- src/hub/device-store.ts | 11 +++++++++-- src/hub/hub.ts | 10 +++++----- src/hub/rpc/handlers/verify.ts | 14 ++++++++------ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/hub/device-store.ts b/src/hub/device-store.ts index e015b15c..9711808d 100644 --- a/src/hub/device-store.ts +++ b/src/hub/device-store.ts @@ -10,10 +10,17 @@ interface TokenEntry { expiresAt: number; } +export interface DeviceMeta { + userAgent?: string; + platform?: string; + language?: string; +} + export interface DeviceEntry { deviceId: string; agentId: string; addedAt: number; + meta?: DeviceMeta; } // ============ Persistence ============ @@ -76,8 +83,8 @@ export class DeviceStore { // ---- Device whitelist ---- /** Add a device to the whitelist (called after token verification + user confirmation) */ - allowDevice(deviceId: string, agentId: string): void { - const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now() }; + allowDevice(deviceId: string, agentId: string, meta?: DeviceMeta): void { + const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now(), meta }; this.allowedDevices.set(deviceId, entry); this.persist(); } diff --git a/src/hub/hub.ts b/src/hub/hub.ts index f18dbc48..42da14c4 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -21,7 +21,7 @@ import { createListAgentsHandler } from "./rpc/handlers/list-agents.js"; import { createCreateAgentHandler } from "./rpc/handlers/create-agent.js"; import { createDeleteAgentHandler } from "./rpc/handlers/delete-agent.js"; import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js"; -import { DeviceStore } from "./device-store.js"; +import { DeviceStore, type DeviceMeta } from "./device-store.js"; import { createVerifyHandler } from "./rpc/handlers/verify.js"; export class Hub { @@ -32,7 +32,7 @@ export class Hub { private readonly rpc: RpcDispatcher; private client: GatewayClient; readonly deviceStore: DeviceStore; - private _onConfirmDevice: ((deviceId: string, agentId: string) => Promise) | null = null; + private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise) | null = null; url: string; readonly path: string; readonly hubId: string; @@ -52,12 +52,12 @@ export class Hub { this.rpc.register("verify", createVerifyHandler({ hubId: this.hubId, deviceStore: this.deviceStore, - onConfirmDevice: (deviceId, agentId) => { + onConfirmDevice: (deviceId, agentId, meta) => { if (!this._onConfirmDevice) { // No UI confirm handler registered (CLI mode etc.) — auto-approve return Promise.resolve(true); } - return this._onConfirmDevice(deviceId, agentId); + return this._onConfirmDevice(deviceId, agentId, meta); }, })); this.rpc.register("getAgentMessages", createGetAgentMessagesHandler()); @@ -166,7 +166,7 @@ export class Hub { } /** Register a confirmation handler for new device connections (called by Desktop UI) */ - setConfirmHandler(handler: ((deviceId: string, agentId: string) => Promise) | null): void { + setConfirmHandler(handler: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise) | null): void { this._onConfirmDevice = handler; } diff --git a/src/hub/rpc/handlers/verify.ts b/src/hub/rpc/handlers/verify.ts index bef35ca8..53b98735 100644 --- a/src/hub/rpc/handlers/verify.ts +++ b/src/hub/rpc/handlers/verify.ts @@ -1,20 +1,23 @@ import type { RpcHandler } from "../dispatcher.js"; import { RpcError } from "../dispatcher.js"; -import type { DeviceStore } from "../../device-store.js"; +import type { DeviceStore, DeviceMeta } from "../../device-store.js"; interface VerifyContext { hubId: string; deviceStore: DeviceStore; /** Called for first-time connections. Returns true if user approves, false if rejected. */ - onConfirmDevice: (deviceId: string, agentId: string) => Promise; + onConfirmDevice: (deviceId: string, agentId: string, meta?: DeviceMeta) => Promise; } interface VerifyParams { token?: string; + meta?: DeviceMeta; } export function createVerifyHandler(ctx: VerifyContext): RpcHandler { return async (params: unknown, from: string) => { + const { token, meta } = (params ?? {}) as VerifyParams; + // 1. Already in whitelist → pass through (reconnection, no confirmation needed) const allowed = ctx.deviceStore.isAllowed(from); if (allowed) { @@ -22,7 +25,6 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler { } // 2. Validate token - const { token } = (params ?? {}) as VerifyParams; if (!token) { throw new RpcError("UNAUTHORIZED", "Device not authorized"); } @@ -33,13 +35,13 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler { } // 3. Token valid → await Desktop user confirmation - const confirmed = await ctx.onConfirmDevice(from, result.agentId); + const confirmed = await ctx.onConfirmDevice(from, result.agentId, meta); if (!confirmed) { throw new RpcError("REJECTED", "Connection rejected by user"); } - // 4. User confirmed → add to whitelist - ctx.deviceStore.allowDevice(from, result.agentId); + // 4. User confirmed → add to whitelist (with device metadata) + ctx.deviceStore.allowDevice(from, result.agentId, meta); return { hubId: ctx.hubId, agentId: result.agentId }; }; } From fcbe96645c359332ca8f4c016ef20798b0022413 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:43:54 +0800 Subject: [PATCH 07/12] feat(desktop): add verified device list and rich confirm dialog Add DeviceList component on home page showing verified devices with parsed User-Agent info (browser, OS) and relative time. Add useDevices hook with listDevices/revokeDevice IPC. Update DeviceConfirmDialog to show human-readable device info (e.g. "Chrome on macOS") instead of raw device ID. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/electron-env.d.ts | 17 +- apps/desktop/electron/ipc/hub.ts | 20 ++- apps/desktop/electron/preload.ts | 6 +- .../src/components/device-confirm-dialog.tsx | 56 ++++++- apps/desktop/src/components/device-list.tsx | 157 ++++++++++++++++++ apps/desktop/src/hooks/use-devices.ts | 57 +++++++ apps/desktop/src/pages/home.tsx | 20 ++- 7 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/components/device-list.tsx create mode 100644 apps/desktop/src/hooks/use-devices.ts diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 4cb63550..c3626967 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -58,6 +58,19 @@ interface SkillInfo { triggers: string[] } +interface DeviceMeta { + userAgent?: string + platform?: string + language?: string +} + +interface DeviceEntryInfo { + deviceId: string + agentId: string + addedAt: number + meta?: DeviceMeta +} + interface SkillAddResult { ok: boolean message: string @@ -78,8 +91,10 @@ interface ElectronAPI { closeAgent: (id: string) => Promise sendMessage: (agentId: string, content: string) => Promise registerToken: (token: string, agentId: string, expiresAt: number) => Promise - onDeviceConfirmRequest: (callback: (deviceId: string) => void) => void + onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void deviceConfirmResponse: (deviceId: string, allowed: boolean) => void + listDevices: () => Promise + revokeDevice: (deviceId: string) => Promise<{ ok: boolean }> } tools: { list: () => Promise diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 1aa9671c..028e2015 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -232,6 +232,22 @@ export function registerHubIpcHandlers(): void { return { ok: true } }) + /** + * List all verified (whitelisted) devices. + */ + ipcMain.handle('hub:listDevices', async () => { + const h = getHub() + return h.deviceStore.listDevices() + }) + + /** + * Revoke a device from the whitelist. + */ + ipcMain.handle('hub:revokeDevice', async (_event, deviceId: string) => { + const h = getHub() + return { ok: h.deviceStore.revokeDevice(deviceId) } + }) + } /** @@ -252,10 +268,10 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi }) // Register confirm handler on Hub — sends request to renderer, awaits response - h.setConfirmHandler((deviceId: string, _agentId: string) => { + h.setConfirmHandler((deviceId: string, _agentId: string, meta) => { return new Promise((resolve) => { pendingConfirms.set(deviceId, resolve) - mainWindow.webContents.send('hub:device-confirm-request', deviceId) + mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta) }) }) } diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index b439cd56..4212c611 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -57,12 +57,14 @@ const electronAPI = { ipcRenderer.invoke('hub:sendMessage', agentId, content), registerToken: (token: string, agentId: string, expiresAt: number) => ipcRenderer.invoke('hub:registerToken', token, agentId, expiresAt), - onDeviceConfirmRequest: (callback: (deviceId: string) => void) => { - ipcRenderer.on('hub:device-confirm-request', (_event, deviceId: string) => callback(deviceId)) + onDeviceConfirmRequest: (callback: (deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => void) => { + ipcRenderer.on('hub:device-confirm-request', (_event, deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => callback(deviceId, meta)) }, deviceConfirmResponse: (deviceId: string, allowed: boolean) => { ipcRenderer.send('hub:device-confirm-response', deviceId, allowed) }, + listDevices: () => ipcRenderer.invoke('hub:listDevices'), + revokeDevice: (deviceId: string) => ipcRenderer.invoke('hub:revokeDevice', deviceId), }, // Tools management diff --git a/apps/desktop/src/components/device-confirm-dialog.tsx b/apps/desktop/src/components/device-confirm-dialog.tsx index 30e81f0f..a204e8cf 100644 --- a/apps/desktop/src/components/device-confirm-dialog.tsx +++ b/apps/desktop/src/components/device-confirm-dialog.tsx @@ -10,8 +10,37 @@ import { AlertDialogTitle, } from '@multica/ui/components/ui/alert-dialog' +interface DeviceMeta { + userAgent?: string + platform?: string + language?: string +} + interface PendingConfirm { deviceId: string + meta?: DeviceMeta +} + +function parseUserAgent(ua: string): { browser: string; os: string } { + let os = 'Unknown' + if (/Mac OS X/.test(ua)) os = 'macOS' + else if (/Windows/.test(ua)) os = 'Windows' + else if (/Android/.test(ua)) os = 'Android' + else if (/iPhone|iPad/.test(ua)) os = 'iOS' + else if (/Linux/.test(ua)) os = 'Linux' + + let browser = 'Unknown' + const edgeMatch = ua.match(/Edg\/(\d+)/) + const chromeMatch = ua.match(/Chrome\/(\d+)/) + const safariMatch = ua.match(/Version\/(\d+).*Safari/) + const firefoxMatch = ua.match(/Firefox\/(\d+)/) + + if (edgeMatch) browser = `Edge ${edgeMatch[1]}` + else if (firefoxMatch) browser = `Firefox ${firefoxMatch[1]}` + else if (chromeMatch) browser = `Chrome ${chromeMatch[1]}` + else if (safariMatch) browser = `Safari ${safariMatch[1]}` + + return { browser, os } } /** @@ -23,8 +52,8 @@ export function DeviceConfirmDialog() { const [pending, setPending] = useState(null) useEffect(() => { - window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string) => { - setPending({ deviceId }) + window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => { + setPending({ deviceId, meta }) }) }, []) @@ -40,14 +69,31 @@ export function DeviceConfirmDialog() { setPending(null) }, [pending]) + const parsed = pending?.meta?.userAgent + ? parseUserAgent(pending.meta.userAgent) + : null + + const deviceLabel = parsed + ? `${parsed.browser} on ${parsed.os}` + : pending?.deviceId + return ( New Device Connection - - Device {pending?.deviceId} wants to connect. - Allow this device? + +
+

+ {deviceLabel} wants to connect. +

+ {parsed && ( +

+ {pending?.deviceId} +

+ )} +

Allow this device?

+
diff --git a/apps/desktop/src/components/device-list.tsx b/apps/desktop/src/components/device-list.tsx new file mode 100644 index 00000000..a6bd5b2d --- /dev/null +++ b/apps/desktop/src/components/device-list.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { + SmartPhone01Icon, + Delete02Icon, + Loading03Icon, + RotateClockwiseIcon, +} from '@hugeicons/core-free-icons' +import { useDevices, type DeviceEntry } from '../hooks/use-devices' + +// ============ UA Parser ============ + +interface ParsedUA { + browser: string + os: string +} + +function parseUserAgent(ua: string): ParsedUA { + let os = 'Unknown' + if (/Mac OS X/.test(ua)) os = 'macOS' + else if (/Windows/.test(ua)) os = 'Windows' + else if (/Android/.test(ua)) os = 'Android' + else if (/iPhone|iPad/.test(ua)) os = 'iOS' + else if (/CrOS/.test(ua)) os = 'ChromeOS' + else if (/Linux/.test(ua)) os = 'Linux' + + let browser = 'Unknown' + const edgeMatch = ua.match(/Edg\/(\d+)/) + const chromeMatch = ua.match(/Chrome\/(\d+)/) + const safariMatch = ua.match(/Version\/(\d+).*Safari/) + const firefoxMatch = ua.match(/Firefox\/(\d+)/) + + if (edgeMatch) browser = `Edge ${edgeMatch[1]}` + else if (firefoxMatch) browser = `Firefox ${firefoxMatch[1]}` + else if (chromeMatch) browser = `Chrome ${chromeMatch[1]}` + else if (safariMatch) browser = `Safari ${safariMatch[1]}` + + return { browser, os } +} + +// ============ Relative Time ============ + +function relativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000) + if (seconds < 60) return 'just now' + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + + const months = Math.floor(days / 30) + return `${months}mo ago` +} + +// ============ Component ============ + +function DeviceItem({ + device, + onRevoke, +}: { + device: DeviceEntry + onRevoke: (deviceId: string) => Promise +}) { + const [revoking, setRevoking] = useState(false) + + const parsed = device.meta?.userAgent + ? parseUserAgent(device.meta.userAgent) + : null + + const displayName = parsed + ? `${parsed.browser} on ${parsed.os}` + : device.deviceId + + const handleRevoke = async () => { + setRevoking(true) + try { + await onRevoke(device.deviceId) + } finally { + setRevoking(false) + } + } + + return ( +
+
+ +
+
{displayName}
+
+ {device.deviceId} + · + {relativeTime(device.addedAt)} +
+
+
+ +
+ ) +} + +export function DeviceList() { + const { devices, loading, refresh, revokeDevice } = useDevices() + + if (loading) { + return null + } + + if (devices.length === 0) { + return null + } + + return ( +
+
+

+ Verified Devices ({devices.length}) +

+ +
+
+ {devices.map((device) => ( + + ))} +
+
+ ) +} diff --git a/apps/desktop/src/hooks/use-devices.ts b/apps/desktop/src/hooks/use-devices.ts new file mode 100644 index 00000000..189e2a67 --- /dev/null +++ b/apps/desktop/src/hooks/use-devices.ts @@ -0,0 +1,57 @@ +import { useState, useEffect, useCallback } from 'react' + +export interface DeviceMeta { + userAgent?: string + platform?: string + language?: string +} + +export interface DeviceEntry { + deviceId: string + agentId: string + addedAt: number + meta?: DeviceMeta +} + +export interface UseDevicesReturn { + devices: DeviceEntry[] + loading: boolean + refresh: () => Promise + revokeDevice: (deviceId: string) => Promise +} + +export function useDevices(): UseDevicesReturn { + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + + const refresh = useCallback(async () => { + try { + const list = await window.electronAPI?.hub.listDevices() + setDevices((list as DeviceEntry[]) ?? []) + } catch (err) { + console.error('Failed to load devices:', err) + } finally { + setLoading(false) + } + }, []) + + const revokeDevice = useCallback(async (deviceId: string): Promise => { + try { + const result = await window.electronAPI?.hub.revokeDevice(deviceId) + if (result?.ok) { + setDevices((prev) => prev.filter((d) => d.deviceId !== deviceId)) + return true + } + return false + } catch (err) { + console.error('Failed to revoke device:', err) + return false + } + }, []) + + useEffect(() => { + refresh() + }, [refresh]) + + return { devices, loading, refresh, revokeDevice } +} diff --git a/apps/desktop/src/pages/home.tsx b/apps/desktop/src/pages/home.tsx index 029bff94..be7257f7 100644 --- a/apps/desktop/src/pages/home.tsx +++ b/apps/desktop/src/pages/home.tsx @@ -8,6 +8,7 @@ import { AlertCircleIcon, } from '@hugeicons/core-free-icons' import { ConnectionQRCode } from '../components/qr-code' +import { DeviceList } from '../components/device-list' import { useHub } from '../hooks/use-hub' export default function HomePage() { @@ -53,13 +54,13 @@ export default function HomePage() {
{/* Left: QR Code */}
- +
{/* Right: Hub Status */} @@ -142,6 +143,11 @@ export default function HomePage() {
+ {/* Verified Devices */} +
+ +
+ {/* Bottom: Actions */}
From ef9b5af2214adab392358eb4811c9e25a520a089 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:46:29 +0800 Subject: [PATCH 08/12] fix(hub): send error response to unverified device instead of silent drop When an unverified device sends a non-RPC message, reply with an "error" action containing UNAUTHORIZED code so the client knows verification is required. Co-Authored-By: Claude Opus 4.5 --- src/hub/hub.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 42da14c4..d8c02715 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -138,6 +138,11 @@ export class Hub { // Non-RPC messages also require verified device if (!this.deviceStore.isAllowed(msg.from)) { console.warn(`[Hub] Rejected message from unverified device: ${msg.from}`); + this.client.send(msg.from, "error", { + code: "UNAUTHORIZED", + message: "Device not verified. Please complete verification first.", + messageId: msg.id, + }); return; } From c604085065193535841937abb4ca17a4ec76ef11 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 13:54:09 +0800 Subject: [PATCH 09/12] fix(desktop): address review issues in device verification flow - Add 60s timeout to pending device confirms to prevent Promise leaks when client disconnects before user responds - Add offDeviceConfirmRequest to preload and clean up IPC listener on component unmount to prevent duplicate listener accumulation - Extract duplicated parseUserAgent into shared lib/parse-user-agent.ts - Clean up expired tokens in DeviceStore.registerToken to prevent memory accumulation from unscanned QR codes Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/electron-env.d.ts | 1 + apps/desktop/electron/ipc/hub.ts | 10 +++++- apps/desktop/electron/preload.ts | 3 ++ .../src/components/device-confirm-dialog.tsx | 26 +++------------- apps/desktop/src/components/device-list.tsx | 31 +------------------ apps/desktop/src/lib/parse-user-agent.ts | 27 ++++++++++++++++ src/hub/device-store.ts | 5 +++ 7 files changed, 50 insertions(+), 53 deletions(-) create mode 100644 apps/desktop/src/lib/parse-user-agent.ts diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index c3626967..5f644318 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -92,6 +92,7 @@ interface ElectronAPI { sendMessage: (agentId: string, content: string) => Promise registerToken: (token: string, agentId: string, expiresAt: number) => Promise onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void + offDeviceConfirmRequest: () => void deviceConfirmResponse: (deviceId: string, allowed: boolean) => void listDevices: () => Promise revokeDevice: (deviceId: string) => Promise<{ ok: boolean }> diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 028e2015..910e8a50 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -270,7 +270,15 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi // Register confirm handler on Hub — sends request to renderer, awaits response h.setConfirmHandler((deviceId: string, _agentId: string, meta) => { return new Promise((resolve) => { - pendingConfirms.set(deviceId, resolve) + // Auto-reject if user doesn't respond within 60 seconds + const timeout = setTimeout(() => { + pendingConfirms.delete(deviceId) + resolve(false) + }, 60_000) + pendingConfirms.set(deviceId, (allowed: boolean) => { + clearTimeout(timeout) + resolve(allowed) + }) mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta) }) }) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 4212c611..6eb74ebc 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -60,6 +60,9 @@ const electronAPI = { onDeviceConfirmRequest: (callback: (deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => void) => { ipcRenderer.on('hub:device-confirm-request', (_event, deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => callback(deviceId, meta)) }, + offDeviceConfirmRequest: () => { + ipcRenderer.removeAllListeners('hub:device-confirm-request') + }, deviceConfirmResponse: (deviceId: string, allowed: boolean) => { ipcRenderer.send('hub:device-confirm-response', deviceId, allowed) }, diff --git a/apps/desktop/src/components/device-confirm-dialog.tsx b/apps/desktop/src/components/device-confirm-dialog.tsx index a204e8cf..b02d2e58 100644 --- a/apps/desktop/src/components/device-confirm-dialog.tsx +++ b/apps/desktop/src/components/device-confirm-dialog.tsx @@ -9,6 +9,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@multica/ui/components/ui/alert-dialog' +import { parseUserAgent } from '../lib/parse-user-agent' interface DeviceMeta { userAgent?: string @@ -21,28 +22,6 @@ interface PendingConfirm { meta?: DeviceMeta } -function parseUserAgent(ua: string): { browser: string; os: string } { - let os = 'Unknown' - if (/Mac OS X/.test(ua)) os = 'macOS' - else if (/Windows/.test(ua)) os = 'Windows' - else if (/Android/.test(ua)) os = 'Android' - else if (/iPhone|iPad/.test(ua)) os = 'iOS' - else if (/Linux/.test(ua)) os = 'Linux' - - let browser = 'Unknown' - const edgeMatch = ua.match(/Edg\/(\d+)/) - const chromeMatch = ua.match(/Chrome\/(\d+)/) - const safariMatch = ua.match(/Version\/(\d+).*Safari/) - const firefoxMatch = ua.match(/Firefox\/(\d+)/) - - if (edgeMatch) browser = `Edge ${edgeMatch[1]}` - else if (firefoxMatch) browser = `Firefox ${firefoxMatch[1]}` - else if (chromeMatch) browser = `Chrome ${chromeMatch[1]}` - else if (safariMatch) browser = `Safari ${safariMatch[1]}` - - return { browser, os } -} - /** * Device confirmation dialog — shown when a new device tries to connect via QR code. * Listens for 'hub:device-confirm-request' IPC events from the main process, @@ -55,6 +34,9 @@ export function DeviceConfirmDialog() { window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => { setPending({ deviceId, meta }) }) + return () => { + window.electronAPI?.hub.offDeviceConfirmRequest() + } }, []) const handleAllow = useCallback(() => { diff --git a/apps/desktop/src/components/device-list.tsx b/apps/desktop/src/components/device-list.tsx index a6bd5b2d..5e5bc897 100644 --- a/apps/desktop/src/components/device-list.tsx +++ b/apps/desktop/src/components/device-list.tsx @@ -8,36 +8,7 @@ import { RotateClockwiseIcon, } from '@hugeicons/core-free-icons' import { useDevices, type DeviceEntry } from '../hooks/use-devices' - -// ============ UA Parser ============ - -interface ParsedUA { - browser: string - os: string -} - -function parseUserAgent(ua: string): ParsedUA { - let os = 'Unknown' - if (/Mac OS X/.test(ua)) os = 'macOS' - else if (/Windows/.test(ua)) os = 'Windows' - else if (/Android/.test(ua)) os = 'Android' - else if (/iPhone|iPad/.test(ua)) os = 'iOS' - else if (/CrOS/.test(ua)) os = 'ChromeOS' - else if (/Linux/.test(ua)) os = 'Linux' - - let browser = 'Unknown' - const edgeMatch = ua.match(/Edg\/(\d+)/) - const chromeMatch = ua.match(/Chrome\/(\d+)/) - const safariMatch = ua.match(/Version\/(\d+).*Safari/) - const firefoxMatch = ua.match(/Firefox\/(\d+)/) - - if (edgeMatch) browser = `Edge ${edgeMatch[1]}` - else if (firefoxMatch) browser = `Firefox ${firefoxMatch[1]}` - else if (chromeMatch) browser = `Chrome ${chromeMatch[1]}` - else if (safariMatch) browser = `Safari ${safariMatch[1]}` - - return { browser, os } -} +import { parseUserAgent } from '../lib/parse-user-agent' // ============ Relative Time ============ diff --git a/apps/desktop/src/lib/parse-user-agent.ts b/apps/desktop/src/lib/parse-user-agent.ts new file mode 100644 index 00000000..ff6d6ba3 --- /dev/null +++ b/apps/desktop/src/lib/parse-user-agent.ts @@ -0,0 +1,27 @@ +export interface ParsedUA { + browser: string + os: string +} + +export function parseUserAgent(ua: string): ParsedUA { + let os = 'Unknown' + if (/Mac OS X/.test(ua)) os = 'macOS' + else if (/Windows/.test(ua)) os = 'Windows' + else if (/Android/.test(ua)) os = 'Android' + else if (/iPhone|iPad/.test(ua)) os = 'iOS' + else if (/CrOS/.test(ua)) os = 'ChromeOS' + else if (/Linux/.test(ua)) os = 'Linux' + + let browser = 'Unknown' + const edgeMatch = ua.match(/Edg\/(\d+)/) + const chromeMatch = ua.match(/Chrome\/(\d+)/) + const safariMatch = ua.match(/Version\/(\d+).*Safari/) + const firefoxMatch = ua.match(/Firefox\/(\d+)/) + + if (edgeMatch) browser = `Edge ${edgeMatch[1]}` + else if (firefoxMatch) browser = `Firefox ${firefoxMatch[1]}` + else if (chromeMatch) browser = `Chrome ${chromeMatch[1]}` + else if (safariMatch) browser = `Safari ${safariMatch[1]}` + + return { browser, os } +} diff --git a/src/hub/device-store.ts b/src/hub/device-store.ts index 9711808d..6ce2e324 100644 --- a/src/hub/device-store.ts +++ b/src/hub/device-store.ts @@ -67,6 +67,11 @@ export class DeviceStore { /** Register a one-time token (called when QR code is generated) */ registerToken(token: string, agentId: string, expiresAt: number): void { + // Clean up expired tokens to prevent accumulation + const now = Date.now(); + for (const [key, entry] of this.tokens) { + if (now > entry.expiresAt) this.tokens.delete(key); + } this.tokens.set(token, { token, agentId, expiresAt }); } From 240f25b2be530c50d95c9316ac4e14ab5991d4f6 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 14:07:46 +0800 Subject: [PATCH 10/12] fix(desktop): remove unused setupDeviceConfirmation import The import was redundant since it's already re-exported directly. Fixes eslint no-unused-vars error in CI. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/ipc/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index e1ac16d2..d0971eb0 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -8,7 +8,7 @@ export { registerProfileIpcHandlers } from './profile.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' import { registerSkillsIpcHandlers } from './skills.js' -import { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' +import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' import { registerProfileIpcHandlers } from './profile.js' /** From db69e0d08dae0ce643e5c7163c8254249e7352b4 Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 14:12:14 +0800 Subject: [PATCH 11/12] fix(desktop): remove unsupported asChild prop from AlertDialogDescription Radix UI version in this project doesn't support asChild on AlertDialogDescription. Use inline spans with block display instead. Co-Authored-By: Claude Opus 4.5 --- .../src/components/device-confirm-dialog.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/components/device-confirm-dialog.tsx b/apps/desktop/src/components/device-confirm-dialog.tsx index b02d2e58..81420891 100644 --- a/apps/desktop/src/components/device-confirm-dialog.tsx +++ b/apps/desktop/src/components/device-confirm-dialog.tsx @@ -64,18 +64,14 @@ export function DeviceConfirmDialog() { New Device Connection - -
-

- {deviceLabel} wants to connect. -

- {parsed && ( -

- {pending?.deviceId} -

- )} -

Allow this device?

-
+ + {deviceLabel} wants to connect. + {parsed && ( + + {pending?.deviceId} + + )} + Allow this device?
From f63a67e341453f8b786ba3c8387a0c65f5eea22d Mon Sep 17 00:00:00 2001 From: yushen Date: Wed, 4 Feb 2026 14:16:59 +0800 Subject: [PATCH 12/12] refactor(hub): improve device whitelist persistence format - Rename storage path from ~/.super-multica/devices/ to ~/.super-multica/client-devices/ for clarity - Change JSON format from bare array to { version, devices[] } dict for future extensibility - Auto-migrate legacy array format on load Co-Authored-By: Claude Opus 4.5 --- src/hub/device-store.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/hub/device-store.ts b/src/hub/device-store.ts index 6ce2e324..1519b812 100644 --- a/src/hub/device-store.ts +++ b/src/hub/device-store.ts @@ -25,7 +25,12 @@ export interface DeviceEntry { // ============ Persistence ============ -const DEVICES_DIR = join(DATA_DIR, "devices"); +interface WhitelistFile { + version: number; + devices: DeviceEntry[]; +} + +const DEVICES_DIR = join(DATA_DIR, "client-devices"); const DEVICES_FILE = join(DEVICES_DIR, "whitelist.json"); function ensureDir(): void { @@ -37,7 +42,10 @@ function ensureDir(): void { function loadDevices(): DeviceEntry[] { if (!existsSync(DEVICES_FILE)) return []; try { - return JSON.parse(readFileSync(DEVICES_FILE, "utf-8")) as DeviceEntry[]; + const raw = JSON.parse(readFileSync(DEVICES_FILE, "utf-8")); + // Migrate legacy array format + if (Array.isArray(raw)) return raw as DeviceEntry[]; + return (raw as WhitelistFile).devices ?? []; } catch { return []; } @@ -45,7 +53,8 @@ function loadDevices(): DeviceEntry[] { function saveDevices(devices: DeviceEntry[]): void { ensureDir(); - writeFileSync(DEVICES_FILE, JSON.stringify(devices, null, 2), "utf-8"); + const data: WhitelistFile = { version: 1, devices }; + writeFileSync(DEVICES_FILE, JSON.stringify(data, null, 2), "utf-8"); } // ============ DeviceStore ============