Merge pull request #78 from multica-ai/ws-auth-handshake
feat: WebSocket auth handshake with device verification
This commit is contained in:
commit
cfe2b8f2df
21 changed files with 726 additions and 11 deletions
19
apps/desktop/electron/electron-env.d.ts
vendored
19
apps/desktop/electron/electron-env.d.ts
vendored
|
|
@ -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[]>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
88
apps/desktop/src/components/device-confirm-dialog.tsx
Normal file
88
apps/desktop/src/components/device-confirm-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
apps/desktop/src/components/device-list.tsx
Normal file
128
apps/desktop/src/components/device-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
57
apps/desktop/src/hooks/use-devices.ts
Normal file
57
apps/desktop/src/hooks/use-devices.ts
Normal 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 }
|
||||
}
|
||||
27
apps/desktop/src/lib/parse-user-agent.ts
Normal file
27
apps/desktop/src/lib/parse-user-agent.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export {
|
|||
type DeleteAgentResult,
|
||||
type UpdateGatewayParams,
|
||||
type UpdateGatewayResult,
|
||||
type DeviceMeta,
|
||||
type VerifyParams,
|
||||
type VerifyResult,
|
||||
} from "./rpc";
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
127
src/hub/device-store.ts
Normal 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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
src/hub/rpc/handlers/verify.ts
Normal file
47
src/hub/rpc/handlers/verify.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue