diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index c92ef48a..4426985c 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 @@ -83,6 +96,12 @@ 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, meta?: DeviceMeta) => void) => void + offDeviceConfirmRequest: () => 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 6896375f..24208586 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -233,6 +233,67 @@ 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 } + }) + + /** + * 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) } + }) + +} + +/** + * 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, meta) => { + return new Promise((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/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index 762fe18f..d0971eb0 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -3,7 +3,7 @@ */ 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' export { registerProfileIpcHandlers } from './profile.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index fb6ff0a9..bdf7168c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -47,7 +47,7 @@ process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => { 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)) @@ -105,4 +105,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 e0afbf9a..c9070f01 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -61,6 +61,19 @@ 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, 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) + }, + 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 new file mode 100644 index 00000000..81420891 --- /dev/null +++ b/apps/desktop/src/components/device-confirm-dialog.tsx @@ -0,0 +1,88 @@ +import { useState, useEffect, useCallback } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@multica/ui/components/ui/alert-dialog' +import { parseUserAgent } from '../lib/parse-user-agent' + +interface DeviceMeta { + userAgent?: string + platform?: string + language?: string +} + +interface PendingConfirm { + deviceId: string + meta?: DeviceMeta +} + +/** + * 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, meta?: DeviceMeta) => { + setPending({ deviceId, meta }) + }) + return () => { + window.electronAPI?.hub.offDeviceConfirmRequest() + } + }, []) + + 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]) + + const parsed = pending?.meta?.userAgent + ? parseUserAgent(pending.meta.userAgent) + : null + + const deviceLabel = parsed + ? `${parsed.browser} on ${parsed.os}` + : pending?.deviceId + + return ( + + + + New Device Connection + + {deviceLabel} wants to connect. + {parsed && ( + + {pending?.deviceId} + + )} + Allow this device? + + + + + Reject + + + Allow + + + + + ) +} diff --git a/apps/desktop/src/components/device-list.tsx b/apps/desktop/src/components/device-list.tsx new file mode 100644 index 00000000..5e5bc897 --- /dev/null +++ b/apps/desktop/src/components/device-list.tsx @@ -0,0 +1,128 @@ +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' +import { parseUserAgent } from '../lib/parse-user-agent' + +// ============ 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/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/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/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/apps/desktop/src/pages/home.tsx b/apps/desktop/src/pages/home.tsx index 474038e1..ba2767f0 100644 --- a/apps/desktop/src/pages/home.tsx +++ b/apps/desktop/src/pages/home.tsx @@ -10,6 +10,7 @@ import { Edit02Icon, } from '@hugeicons/core-free-icons' import { ConnectionQRCode } from '../components/qr-code' +import { DeviceList } from '../components/device-list' import { AgentSettingsDialog } from '../components/agent-settings-dialog' import { useHub } from '../hooks/use-hub' @@ -185,6 +186,11 @@ export default function HomePage() { + {/* Verified Devices */} +
+ +
+ {/* Agent Settings Dialog */} 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() { + ) } diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index d9fb2b1b..e73947a4 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -25,6 +25,9 @@ export { type DeleteAgentResult, type UpdateGatewayParams, type UpdateGatewayResult, + type DeviceMeta, + 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..1f49f1d1 100644 --- a/packages/sdk/src/actions/rpc.ts +++ b/packages/sdk/src/actions/rpc.ts @@ -145,3 +145,22 @@ export interface UpdateGatewayResult { url: string; 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 */ +export interface VerifyResult { + hubId: string; + agentId: string; +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 836708eb..fb362281 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,41 @@ 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"; + 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, meta }, + 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; 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() }, diff --git a/src/hub/device-store.ts b/src/hub/device-store.ts new file mode 100644 index 00000000..1519b812 --- /dev/null +++ b/src/hub/device-store.ts @@ -0,0 +1,127 @@ +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 DeviceMeta { + userAgent?: string; + platform?: string; + language?: string; +} + +export interface DeviceEntry { + deviceId: string; + agentId: string; + addedAt: number; + meta?: DeviceMeta; +} + +// ============ Persistence ============ + +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 { + if (!existsSync(DEVICES_DIR)) { + mkdirSync(DEVICES_DIR, { recursive: true }); + } +} + +function loadDevices(): DeviceEntry[] { + if (!existsSync(DEVICES_FILE)) return []; + try { + 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 []; + } +} + +function saveDevices(devices: DeviceEntry[]): void { + ensureDir(); + const data: WhitelistFile = { version: 1, devices }; + writeFileSync(DEVICES_FILE, JSON.stringify(data, 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 { + // 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 }); + } + + /** 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, meta?: DeviceMeta): void { + const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now(), meta }; + 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..d8c02715 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, type DeviceMeta } 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, meta?: DeviceMeta) => 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, meta) => { + if (!this._onConfirmDevice) { + // No UI confirm handler registered (CLI mode etc.) — auto-approve + return Promise.resolve(true); + } + return this._onConfirmDevice(deviceId, agentId, meta); + }, + })); this.rpc.register("getAgentMessages", createGetAgentMessagesHandler()); this.rpc.register("getHubInfo", createGetHubInfoHandler(this)); this.rpc.register("listAgents", createListAgentsHandler(this)); @@ -101,10 +117,35 @@ 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}`); + this.client.send(msg.from, "error", { + code: "UNAUTHORIZED", + message: "Device not verified. Please complete verification first.", + messageId: msg.id, + }); + return; + } + // Regular chat message const payload = msg.payload as { agentId?: string; content?: string } | undefined; const agentId = payload?.agentId; @@ -129,6 +170,16 @@ export class Hub { return client; } + /** Register a confirmation handler for new device connections (called by Desktop UI) */ + setConfirmHandler(handler: ((deviceId: string, agentId: string, meta?: DeviceMeta) => 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 +285,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..53b98735 --- /dev/null +++ b/src/hub/rpc/handlers/verify.ts @@ -0,0 +1,47 @@ +import type { RpcHandler } from "../dispatcher.js"; +import { RpcError } from "../dispatcher.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, 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) { + return { hubId: ctx.hubId, agentId: allowed.agentId }; + } + + // 2. Validate token + 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, meta); + if (!confirmed) { + throw new RpcError("REJECTED", "Connection rejected by user"); + } + + // 4. User confirmed → add to whitelist (with device metadata) + ctx.deviceStore.allowDevice(from, result.agentId, meta); + return { hubId: ctx.hubId, agentId: result.agentId }; + }; +}