Merge pull request #78 from multica-ai/ws-auth-handshake

feat: WebSocket auth handshake with device verification
This commit is contained in:
LinYushen 2026-02-04 14:20:02 +08:00 committed by GitHub
commit cfe2b8f2df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 726 additions and 11 deletions

View file

@ -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<unknown>
closeAgent: (id: string) => Promise<unknown>
sendMessage: (agentId: string, content: string) => Promise<unknown>
registerToken: (token: string, agentId: string, expiresAt: number) => Promise<unknown>
onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void
offDeviceConfirmRequest: () => void
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
listDevices: () => Promise<DeviceEntryInfo[]>
revokeDevice: (deviceId: string) => Promise<{ ok: boolean }>
}
tools: {
list: () => Promise<ToolInfo[]>

View file

@ -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<string, (allowed: boolean) => 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<boolean>((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)
})
})
}
/**

View file

@ -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'

View file

@ -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)
}
})

View file

@ -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

View file

@ -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<PendingConfirm | null>(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 (
<AlertDialog open={pending !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>New Device Connection</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium">{deviceLabel}</span> wants to connect.
{parsed && (
<span className="block text-xs font-mono text-muted-foreground truncate mt-1">
{pending?.deviceId}
</span>
)}
<span className="block mt-1">Allow this device?</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleReject}>
Reject
</AlertDialogCancel>
<AlertDialogAction onClick={handleAllow}>
Allow
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -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<boolean>
}) {
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 (
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-3 min-w-0 flex-1">
<HugeiconsIcon icon={SmartPhone01Icon} className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono truncate max-w-[180px]">{device.deviceId}</span>
<span>·</span>
<span>{relativeTime(device.addedAt)}</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={handleRevoke}
disabled={revoking}
>
{revoking ? (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
) : (
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
)}
</Button>
</div>
)
}
export function DeviceList() {
const { devices, loading, refresh, revokeDevice } = useDevices()
if (loading) {
return null
}
if (devices.length === 0) {
return null
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">
Verified Devices ({devices.length})
</h3>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={refresh}
>
<HugeiconsIcon icon={RotateClockwiseIcon} className="size-3" />
Refresh
</Button>
</div>
<div className="border rounded-lg divide-y overflow-hidden">
{devices.map((device) => (
<DeviceItem
key={device.deviceId}
device={device}
onRevoke={revokeDevice}
/>
))}
</div>
</div>
)
}

View file

@ -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',

View file

@ -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<void>
revokeDevice: (deviceId: string) => Promise<boolean>
}
export function useDevices(): UseDevicesReturn {
const [devices, setDevices] = useState<DeviceEntry[]>([])
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<boolean> => {
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 }
}

View file

@ -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 }
}

View file

@ -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() {
</div>
</div>
{/* Verified Devices */}
<div className="px-4 pb-2">
<DeviceList />
</div>
{/* Agent Settings Dialog */}
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />

View file

@ -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() {
<Outlet />
</main>
<Toaster />
<DeviceConfirmDialog />
</div>
)
}

View file

@ -25,6 +25,9 @@ export {
type DeleteAgentResult,
type UpdateGatewayParams,
type UpdateGatewayResult,
type DeviceMeta,
type VerifyParams,
type VerifyResult,
} from "./rpc";
export {

View file

@ -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;
}

View file

@ -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"));
}
}
);

View file

@ -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;

View file

@ -73,6 +73,8 @@ let client: GatewayClient | null = null
function createClient(
url: string,
deviceId: string,
hubId: string,
token: string,
set: (s: Partial<ConnectionStoreState>) => 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<ConnectionStore>()(
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()
},

127
src/hub/device-store.ts Normal file
View file

@ -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<string, TokenEntry>();
/** Allowed device IDs (persisted to disk) */
private readonly allowedDevices = new Map<string, DeviceEntry>();
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()));
}
}

View file

@ -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<string, AsyncAgent>();
@ -29,6 +31,8 @@ export class Hub {
private readonly agentStreamCounters = new Map<string, number>();
private readonly rpc: RpcDispatcher;
private client: GatewayClient;
readonly deviceStore: DeviceStore;
private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | 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<ResponseErrorPayload>(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<boolean>) | 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<void> {
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<ResponseSuccessPayload>(from, ResponseAction, {
requestId,
ok: true,

View file

@ -1,4 +1,4 @@
export type RpcHandler = (params: unknown) => unknown | Promise<unknown>;
export type RpcHandler = (params: unknown, from: string) => unknown | Promise<unknown>;
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<unknown> {
async dispatch(method: string, params: unknown, from: string): Promise<unknown> {
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);
}
}

View file

@ -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<boolean>;
}
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 };
};
}