diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index d4a1e87c..3e56ba7a 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -70,18 +70,20 @@ interface SkillInfo { triggers: string[] } -interface DeviceMeta { - userAgent?: string - platform?: string - language?: string -} - -interface DeviceEntryInfo { - deviceId: string - agentId: string - addedAt: number - meta?: DeviceMeta -} +interface DeviceMeta { + userAgent?: string + platform?: string + language?: string + clientName?: string +} + +interface DeviceEntryInfo { + deviceId: string + agentId: string + conversationIds: string[] + addedAt: number + meta?: DeviceMeta +} interface SkillAddResult { ok: boolean @@ -188,8 +190,8 @@ interface ElectronAPI { closeAgent: (id: string) => Promise closeConversation: (id: string) => Promise sendMessage: (agentId: string, content: string, conversationId?: string) => Promise - registerToken: (token: string, agentId: string, expiresAt: number) => Promise - onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void + registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => Promise + onDeviceConfirmRequest: (callback: (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => void) => void offDeviceConfirmRequest: () => void deviceConfirmResponse: (deviceId: string, allowed: boolean) => void listDevices: () => Promise diff --git a/apps/desktop/src/main/ipc/hub.ts b/apps/desktop/src/main/ipc/hub.ts index 2937bb08..8f54b569 100644 --- a/apps/desktop/src/main/ipc/hub.ts +++ b/apps/desktop/src/main/ipc/hub.ts @@ -501,11 +501,14 @@ export function registerHubIpcHandlers(): void { * 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) => { + ipcMain.handle( + 'hub:registerToken', + async (_event, token: string, agentId: string, conversationId: string, expiresAt: number) => { const h = getHub() - h.registerToken(token, agentId, expiresAt) + h.registerToken(token, agentId, conversationId, expiresAt) return { ok: true } - }) + }, + ) /** * List all verified (whitelisted) devices. @@ -551,7 +554,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi }) // Register confirm handler on Hub — sends request to renderer, awaits response - h.setConfirmHandler((deviceId: string, _agentId: string, meta) => { + h.setConfirmHandler((deviceId: string, agentId: string, conversationId: string, meta) => { return new Promise((resolve) => { // Auto-reject if user doesn't respond within 60 seconds const timeout = setTimeout(() => { @@ -566,7 +569,7 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi mainWindow.webContents.send('hub:devices-changed') } }) - mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta) + mainWindow.webContents.send('hub:device-confirm-request', deviceId, agentId, conversationId, meta) }) }) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2bf8ec6b..c76ab2b4 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -170,11 +170,27 @@ const electronAPI = { closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id), sendMessage: (agentId: string, content: string, conversationId?: string) => ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId), - 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)) - }, + registerToken: (token: string, agentId: string, conversationId: string, expiresAt: number) => + ipcRenderer.invoke('hub:registerToken', token, agentId, conversationId, expiresAt), + onDeviceConfirmRequest: ( + callback: ( + deviceId: string, + agentId: string, + conversationId: string, + meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string }, + ) => void, + ) => { + ipcRenderer.on( + 'hub:device-confirm-request', + ( + _event, + deviceId: string, + agentId: string, + conversationId: string, + meta?: { userAgent?: string; platform?: string; language?: string; clientName?: string }, + ) => callback(deviceId, agentId, conversationId, meta), + ) + }, offDeviceConfirmRequest: () => { ipcRenderer.removeAllListeners('hub:device-confirm-request') }, diff --git a/apps/desktop/src/renderer/src/components/device-confirm-dialog.tsx b/apps/desktop/src/renderer/src/components/device-confirm-dialog.tsx index 4b49aaad..8e6e8b3c 100644 --- a/apps/desktop/src/renderer/src/components/device-confirm-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/device-confirm-dialog.tsx @@ -20,6 +20,8 @@ interface DeviceMeta { interface PendingConfirm { deviceId: string + agentId: string + conversationId: string meta?: DeviceMeta } @@ -32,9 +34,11 @@ export function DeviceConfirmDialog() { const [pending, setPending] = useState(null) useEffect(() => { - window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => { - setPending({ deviceId, meta }) - }) + window.electronAPI?.hub.onDeviceConfirmRequest( + (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => { + setPending({ deviceId, agentId, conversationId, meta }) + }, + ) return () => { window.electronAPI?.hub.offDeviceConfirmRequest() } diff --git a/apps/desktop/src/renderer/src/components/qr-code.tsx b/apps/desktop/src/renderer/src/components/qr-code.tsx index 5a586952..2d9824e0 100644 --- a/apps/desktop/src/renderer/src/components/qr-code.tsx +++ b/apps/desktop/src/renderer/src/components/qr-code.tsx @@ -131,7 +131,8 @@ export function ConnectionQRCode({ expirySeconds = 30, size = 200, }: ConnectionQRCodeProps) { - const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds) + const resolvedConversationId = conversationId ?? agentId + const { token, expiresAt, refresh } = useQRToken(agentId, resolvedConversationId, expirySeconds) const remaining = useCountdown(expiresAt, refresh) // Derive QR data and URL from current token (computed during render) @@ -141,11 +142,11 @@ export function ConnectionQRCode({ gateway, hubId, agentId, - conversationId: conversationId ?? agentId, + conversationId: resolvedConversationId, token, expires: expiresAt, }), - [gateway, hubId, agentId, conversationId, token, expiresAt] + [gateway, hubId, agentId, resolvedConversationId, token, expiresAt] ) const connectionUrl = useMemo(() => { @@ -153,12 +154,12 @@ export function ConnectionQRCode({ gateway, hub: hubId, agent: agentId, - conversation: conversationId ?? agentId, + conversation: resolvedConversationId, token, exp: expiresAt.toString(), }) return `multica://connect?${params.toString()}` - }, [gateway, hubId, agentId, conversationId, token, expiresAt]) + }, [gateway, hubId, agentId, resolvedConversationId, token, expiresAt]) return (
diff --git a/apps/desktop/src/renderer/src/components/qr-hooks.ts b/apps/desktop/src/renderer/src/components/qr-hooks.ts index 6e250335..e5e217fa 100644 --- a/apps/desktop/src/renderer/src/components/qr-hooks.ts +++ b/apps/desktop/src/renderer/src/components/qr-hooks.ts @@ -11,7 +11,7 @@ function generateToken(): string { * - Auto-refreshes when expired * - Registers token with Hub */ -export function useQRToken(agentId: string, expirySeconds: number) { +export function useQRToken(agentId: string, conversationId: string, expirySeconds: number) { const [token, setToken] = useState(generateToken) const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000) @@ -20,12 +20,12 @@ export function useQRToken(agentId: string, expirySeconds: number) { const newExpiry = Date.now() + expirySeconds * 1000 setToken(newToken) setExpiresAt(newExpiry) - window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry) - }, [agentId, expirySeconds]) + window.electronAPI?.hub.registerToken(newToken, agentId, conversationId, newExpiry) + }, [agentId, conversationId, expirySeconds]) // Register initial token useEffect(() => { - window.electronAPI?.hub.registerToken(token, agentId, expiresAt) + window.electronAPI?.hub.registerToken(token, agentId, conversationId, expiresAt) }, []) // eslint-disable-line react-hooks/exhaustive-deps return { token, expiresAt, refresh } diff --git a/apps/desktop/src/renderer/src/components/telegram-qr.tsx b/apps/desktop/src/renderer/src/components/telegram-qr.tsx index c8a077ff..3f9c6f4b 100644 --- a/apps/desktop/src/renderer/src/components/telegram-qr.tsx +++ b/apps/desktop/src/renderer/src/components/telegram-qr.tsx @@ -28,7 +28,8 @@ export function TelegramConnectQR({ expirySeconds = 30, size = 200, }: TelegramConnectQRProps) { - const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds) + const resolvedConversationId = conversationId ?? agentId + const { token, expiresAt, refresh } = useQRToken(agentId, resolvedConversationId, expirySeconds) const remaining = useCountdown(expiresAt, refresh) const [deepLink, setDeepLink] = useState(null) @@ -50,7 +51,7 @@ export function TelegramConnectQR({ gateway, hubId, agentId, - conversationId: conversationId ?? agentId, + conversationId: resolvedConversationId, token, expires: expiresAt, }), @@ -81,7 +82,7 @@ export function TelegramConnectQR({ fetchCode() return () => { cancelled = true } - }, [token, expiresAt, gateway, hubId, agentId, conversationId]) + }, [token, expiresAt, gateway, hubId, agentId, resolvedConversationId]) if (loading) { return ( diff --git a/apps/desktop/src/renderer/src/hooks/use-devices.ts b/apps/desktop/src/renderer/src/hooks/use-devices.ts index 082f8921..afd493be 100644 --- a/apps/desktop/src/renderer/src/hooks/use-devices.ts +++ b/apps/desktop/src/renderer/src/hooks/use-devices.ts @@ -14,6 +14,7 @@ export interface DeviceMeta { export interface DeviceEntry { deviceId: string agentId: string + conversationIds: string[] addedAt: number meta?: DeviceMeta } diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx index 5ad004ed..b60a9132 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx @@ -25,6 +25,8 @@ interface DeviceMeta { interface PendingConfirm { deviceId: string + agentId: string + conversationId: string meta?: DeviceMeta } @@ -42,9 +44,11 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { // Listen for device confirm requests during onboarding useEffect(() => { - window.electronAPI?.hub.onDeviceConfirmRequest((deviceId: string, meta?: DeviceMeta) => { - setPending({ deviceId, meta }) - }) + window.electronAPI?.hub.onDeviceConfirmRequest( + (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => { + setPending({ deviceId, agentId, conversationId, meta }) + }, + ) return () => { window.electronAPI?.hub.offDeviceConfirmRequest() } diff --git a/apps/gateway/telegram/telegram.service.ts b/apps/gateway/telegram/telegram.service.ts index b1090064..473dccde 100644 --- a/apps/gateway/telegram/telegram.service.ts +++ b/apps/gateway/telegram/telegram.service.ts @@ -1319,7 +1319,11 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy { ? `Telegram @${msg.from.username}` : `Telegram ${msg?.from?.first_name ?? telegramUserId}`, }); - const mainConversationId = connectionInfo.conversationId ?? result.mainConversationId ?? result.agentId; + const mainConversationId = + connectionInfo.conversationId + ?? result.conversationId + ?? result.mainConversationId + ?? result.agentId; // 5. Save to DB await this.userStore.upsert({ diff --git a/packages/core/src/client/actions/rpc.ts b/packages/core/src/client/actions/rpc.ts index 761839fb..769d58dc 100644 --- a/packages/core/src/client/actions/rpc.ts +++ b/packages/core/src/client/actions/rpc.ts @@ -177,6 +177,8 @@ export interface VerifyParams { export interface VerifyResult { hubId: string; agentId: string; - /** Main conversation for this agent. Defaults to agentId in legacy mode. */ + /** Authorized conversation scope for this device. */ + conversationId?: string; + /** Backward-compatible alias for conversationId. */ mainConversationId?: string; } diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index d03f14c0..566b0370 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -234,7 +234,17 @@ export class GatewayClient { } /** Hub 验证成功回调 */ - onVerified(callback: (result: { hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }) => void): this { + onVerified( + callback: ( + result: { + hubId: string; + agentId: string; + conversationId?: string; + mainConversationId?: string; + isNewDevice?: boolean; + } + ) => void, + ): this { this.callbacks.onVerified = callback; return this; } @@ -318,7 +328,13 @@ export class GatewayClient { platform: navigator.platform, language: navigator.language, } : undefined; - this.request<{ hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }>( + this.request<{ + hubId: string; + agentId: string; + conversationId?: string; + mainConversationId?: string; + isNewDevice?: boolean; + }>( this.options.hubId, "verify", { token: this.options.token, meta }, diff --git a/packages/core/src/client/types.ts b/packages/core/src/client/types.ts index 5eb13bc2..a996a051 100644 --- a/packages/core/src/client/types.ts +++ b/packages/core/src/client/types.ts @@ -110,7 +110,15 @@ export interface GatewayClientCallbacks { onConnect?: (socketId: string) => void; onDisconnect?: (reason: string) => void; onRegistered?: (deviceId: string) => void; - onVerified?: (result: { hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }) => void; + onVerified?: ( + result: { + hubId: string; + agentId: string; + conversationId?: string; + mainConversationId?: string; + isNewDevice?: boolean; + } + ) => void; onMessage?: (message: RoutedMessage) => void; onSendError?: (error: SendErrorResponse) => void; onPong?: (data: string) => void; diff --git a/packages/core/src/hub/device-store.test.ts b/packages/core/src/hub/device-store.test.ts new file mode 100644 index 00000000..298e5760 --- /dev/null +++ b/packages/core/src/hub/device-store.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { DeviceStore } from "./device-store.js"; + +describe("DeviceStore", () => { + const testDirs: string[] = []; + + afterEach(() => { + for (const dir of testDirs) { + rmSync(dir, { recursive: true, force: true }); + } + testDirs.length = 0; + }); + + it("stores token with conversation scope and enforces one-time consumption", () => { + const dir = mkdtempSync(join(tmpdir(), "device-store-test-")); + testDirs.push(dir); + const store = new DeviceStore({ devicesFile: join(dir, "whitelist.json") }); + + const expiresAt = Date.now() + 60_000; + store.registerToken("token-1", "agent-1", "conv-1", expiresAt); + + expect(store.consumeToken("token-1")).toEqual({ + agentId: "agent-1", + conversationId: "conv-1", + }); + expect(store.consumeToken("token-1")).toBeNull(); + }); + + it("enforces conversation-level authorization and supports adding scopes", () => { + const dir = mkdtempSync(join(tmpdir(), "device-store-test-")); + testDirs.push(dir); + const devicesFile = join(dir, "whitelist.json"); + const store = new DeviceStore({ devicesFile }); + + store.allowDevice("dev-1", "agent-1", "conv-1"); + expect(store.isAllowed("dev-1")).toEqual({ + agentId: "agent-1", + conversationIds: ["conv-1"], + }); + expect(store.isAllowed("dev-1", "conv-1")).toEqual({ + agentId: "agent-1", + conversationIds: ["conv-1"], + }); + expect(store.isAllowed("dev-1", "conv-2")).toBeNull(); + + expect(store.allowConversation("dev-1", "conv-2")).toBe(true); + expect(store.isAllowed("dev-1", "conv-2")).toEqual({ + agentId: "agent-1", + conversationIds: ["conv-1", "conv-2"], + }); + + const restored = new DeviceStore({ devicesFile }); + expect(restored.isAllowed("dev-1", "conv-1")).not.toBeNull(); + expect(restored.isAllowed("dev-1", "conv-2")).not.toBeNull(); + }); + + it("migrates legacy entries without conversationIds using agentId as fallback scope", () => { + const dir = mkdtempSync(join(tmpdir(), "device-store-test-")); + testDirs.push(dir); + const devicesFile = join(dir, "whitelist.json"); + writeFileSync( + devicesFile, + JSON.stringify({ + version: 1, + devices: [ + { + deviceId: "legacy-dev", + agentId: "legacy-agent", + addedAt: 123, + }, + ], + }), + "utf-8", + ); + + const store = new DeviceStore({ devicesFile }); + expect(store.isAllowed("legacy-dev")).toEqual({ + agentId: "legacy-agent", + conversationIds: ["legacy-agent"], + }); + expect(store.isAllowed("legacy-dev", "legacy-agent")).not.toBeNull(); + }); +}); + diff --git a/packages/core/src/hub/device-store.ts b/packages/core/src/hub/device-store.ts index ca2656af..7a5d4ccb 100644 --- a/packages/core/src/hub/device-store.ts +++ b/packages/core/src/hub/device-store.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { DATA_DIR } from "@multica/utils"; // ============ Types ============ @@ -7,6 +7,7 @@ import { DATA_DIR } from "@multica/utils"; interface TokenEntry { token: string; agentId: string; + conversationId: string; expiresAt: number; } @@ -20,6 +21,7 @@ export interface DeviceMeta { export interface DeviceEntry { deviceId: string; agentId: string; + conversationIds: string[]; addedAt: number; meta?: DeviceMeta | undefined; } @@ -34,41 +36,44 @@ interface WhitelistFile { 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 }); - } +interface DeviceStoreOptions { + devicesFile?: string; } -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 normalizeConversationIds( + input: unknown, + fallbackConversationId: string, +): string[] { + const ids = new Set(); + if (Array.isArray(input)) { + for (const item of input) { + if (typeof item !== "string") continue; + const id = item.trim(); + if (id) ids.add(id); + } } -} - -function saveDevices(devices: DeviceEntry[]): void { - ensureDir(); - const data: WhitelistFile = { version: 1, devices }; - writeFileSync(DEVICES_FILE, JSON.stringify(data, null, 2), "utf-8"); + const fallback = fallbackConversationId.trim(); + if (ids.size === 0 && fallback) { + ids.add(fallback); + } + return Array.from(ids); } // ============ DeviceStore ============ export class DeviceStore { + private readonly devicesDir: string; + private readonly devicesFile: string; /** 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() { + constructor(options?: DeviceStoreOptions) { + this.devicesFile = options?.devicesFile ?? DEVICES_FILE; + this.devicesDir = options?.devicesFile ? dirname(options.devicesFile) : DEVICES_DIR; // Restore from persistent storage - for (const entry of loadDevices()) { + for (const entry of this.loadDevices()) { this.allowedDevices.set(entry.deviceId, entry); } } @@ -76,38 +81,75 @@ export class DeviceStore { // ---- Token management ---- /** Register a one-time token (called when QR code is generated) */ - registerToken(token: string, agentId: string, expiresAt: number): void { + registerToken(token: string, agentId: string, conversationId: 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 }); + this.tokens.set(token, { token, agentId, conversationId, expiresAt }); } /** Validate and consume a token (one-time use). Returns agentId if valid, null otherwise. */ - consumeToken(token: string): { agentId: string } | null { + consumeToken(token: string): { agentId: string; conversationId: 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 }; + return { agentId: entry.agentId, conversationId: entry.conversationId }; } // ---- 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 }; + allowDevice(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta): void { + const existing = this.allowedDevices.get(deviceId); + const conversationIds = existing && existing.agentId === agentId + ? normalizeConversationIds(existing.conversationIds, conversationId) + : normalizeConversationIds([], conversationId); + if (!conversationIds.includes(conversationId)) { + conversationIds.push(conversationId); + } + + const entry: DeviceEntry = { + deviceId, + agentId, + conversationIds, + addedAt: existing?.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 { + isAllowed( + deviceId: string, + conversationId?: string, + ): { agentId: string; conversationIds: string[] } | null { const entry = this.allowedDevices.get(deviceId); - return entry ? { agentId: entry.agentId } : null; + if (!entry) return null; + + if (conversationId !== undefined && !entry.conversationIds.includes(conversationId)) { + return null; + } + + return { + agentId: entry.agentId, + conversationIds: [...entry.conversationIds], + }; + } + + /** Grant an additional conversation scope to an existing device. */ + allowConversation(deviceId: string, conversationId: string): boolean { + const entry = this.allowedDevices.get(deviceId); + if (!entry) return false; + if (entry.conversationIds.includes(conversationId)) return true; + entry.conversationIds.push(conversationId); + this.allowedDevices.set(deviceId, entry); + this.persist(); + return true; } /** Remove a device from the whitelist */ @@ -123,6 +165,56 @@ export class DeviceStore { } private persist(): void { - saveDevices(Array.from(this.allowedDevices.values())); + this.saveDevices(Array.from(this.allowedDevices.values())); + } + + private ensureDir(): void { + if (!existsSync(this.devicesDir)) { + mkdirSync(this.devicesDir, { recursive: true }); + } + } + + private loadDevices(): DeviceEntry[] { + if (!existsSync(this.devicesFile)) return []; + try { + const raw = JSON.parse(readFileSync(this.devicesFile, "utf-8")); + const devices = Array.isArray(raw) ? raw : (raw as WhitelistFile).devices ?? []; + if (!Array.isArray(devices)) return []; + + const normalized: DeviceEntry[] = []; + for (const item of devices) { + if (!item || typeof item !== "object") continue; + const rawDeviceId = (item as { deviceId?: unknown }).deviceId; + const rawAgentId = (item as { agentId?: unknown }).agentId; + if (typeof rawDeviceId !== "string" || typeof rawAgentId !== "string") continue; + const deviceId = rawDeviceId.trim(); + const agentId = rawAgentId.trim(); + if (!deviceId || !agentId) continue; + const fallbackConversationId = typeof (item as { conversationId?: unknown }).conversationId === "string" + ? (item as { conversationId: string }).conversationId + : agentId; + normalized.push({ + deviceId, + agentId, + conversationIds: normalizeConversationIds( + (item as { conversationIds?: unknown }).conversationIds, + fallbackConversationId, + ), + addedAt: typeof (item as { addedAt?: unknown }).addedAt === "number" + ? (item as { addedAt: number }).addedAt + : Date.now(), + meta: (item as { meta?: DeviceMeta }).meta, + }); + } + return normalized; + } catch { + return []; + } + } + + private saveDevices(devices: DeviceEntry[]): void { + this.ensureDir(); + const data: WhitelistFile = { version: 2, devices }; + writeFileSync(this.devicesFile, JSON.stringify(data, null, 2), "utf-8"); } } diff --git a/packages/core/src/hub/hub.ts b/packages/core/src/hub/hub.ts index 3d0968f4..5b648091 100644 --- a/packages/core/src/hub/hub.ts +++ b/packages/core/src/hub/hub.ts @@ -103,7 +103,9 @@ export class Hub { private heartbeatUnsubscribe: (() => void) | null = null; private client: GatewayClient; readonly deviceStore: DeviceStore; - private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise) | null = null; + private _onConfirmDevice: ( + (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise + ) | null = null; private _stateChangeListeners: ((state: ConnectionState) => void)[] = []; readonly channelManager: ChannelManager; url: string; @@ -126,12 +128,12 @@ export class Hub { hubId: this.hubId, deviceStore: this.deviceStore, resolveMainConversationId: (agentId) => this.getAgentMainConversationId(agentId), - onConfirmDevice: (deviceId, agentId, meta) => { + onConfirmDevice: (deviceId, agentId, conversationId, meta) => { if (!this._onConfirmDevice) { // No UI confirm handler registered (CLI mode etc.) — auto-approve return Promise.resolve(true); } - return this._onConfirmDevice(deviceId, agentId, meta); + return this._onConfirmDevice(deviceId, agentId, conversationId, meta); }, })); this.rpc.register("generateChannelWelcome", createGenerateChannelWelcomeHandler(this)); @@ -413,6 +415,7 @@ export class Hub { } // Non-RPC messages also require verified device + const payload = msg.payload as { agentId?: string; conversationId?: string; content?: string } | undefined; if (!this.deviceStore.isAllowed(msg.from)) { console.warn(`[Hub] Rejected message from unverified device: ${msg.from}`); this.client.send(msg.from, "error", { @@ -422,9 +425,6 @@ export class Hub { }); return; } - - // Regular chat message - const payload = msg.payload as { agentId?: string; conversationId?: string; content?: string } | undefined; const incomingAgentId = payload?.agentId; const conversationId = this.resolveConversationId(incomingAgentId, payload?.conversationId); const agentId = this.resolveAgentId(incomingAgentId, conversationId); @@ -433,6 +433,30 @@ export class Hub { console.warn(`[Hub] Invalid payload, missing agentId or content`); return; } + + const allowedScope = this.deviceStore.isAllowed(msg.from, conversationId); + if (!allowedScope) { + console.warn(`[Hub] Rejected message outside authorized conversation scope: ${msg.from} -> ${conversationId}`); + this.client.send(msg.from, "error", { + code: "UNAUTHORIZED", + message: "Device is not authorized for this conversation.", + messageId: msg.id, + }); + return; + } + + if (allowedScope.agentId !== agentId) { + console.warn( + `[Hub] Rejected message due to agent mismatch: device=${msg.from}, allowedAgent=${allowedScope.agentId}, targetAgent=${agentId}`, + ); + this.client.send(msg.from, "error", { + code: "UNAUTHORIZED", + message: "Device is not authorized for this agent.", + messageId: msg.id, + }); + return; + } + const agent = this.agents.get(conversationId); if (agent && !agent.closed) { this.agentSenders.set(conversationId, msg.from); @@ -459,7 +483,11 @@ export class Hub { } /** Register a confirmation handler for new device connections (called by Desktop UI) */ - setConfirmHandler(handler: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise) | null): void { + setConfirmHandler( + handler: ( + (deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise + ) | null, + ): void { this._onConfirmDevice = handler; } @@ -488,11 +516,21 @@ export class Hub { } /** Register a one-time token for device verification (called when QR code is generated) */ - registerToken(token: string, agentId: string, expiresAt: number): void { + registerToken(token: string, agentId: string, conversationId: string, expiresAt: number): void { const normalizedAgentId = this.normalizeId(agentId); - if (!normalizedAgentId) return; - const resolvedAgentId = this.conversationAgents.get(normalizedAgentId) ?? normalizedAgentId; - this.deviceStore.registerToken(token, resolvedAgentId, expiresAt); + const normalizedConversationId = this.normalizeId(conversationId); + if (!normalizedAgentId || !normalizedConversationId) return; + + const resolvedConversationId = this.resolveConversationId(normalizedAgentId, normalizedConversationId); + const ownerAgentId = this.conversationAgents.get(resolvedConversationId); + if (ownerAgentId && ownerAgentId !== normalizedAgentId) { + console.warn( + `[Hub] registerToken rejected due to agent/conversation mismatch: agent=${normalizedAgentId}, conversation=${resolvedConversationId}, owner=${ownerAgentId}`, + ); + return; + } + const resolvedAgentId = ownerAgentId ?? normalizedAgentId; + this.deviceStore.registerToken(token, resolvedAgentId, resolvedConversationId, expiresAt); } /** 重连到新的 Gateway 地址 */ @@ -777,6 +815,12 @@ export class Hub { const { requestId, method } = request; try { const result = await this.rpc.dispatch(method, request.params, from); + if (method === "createConversation") { + const createdConversationId = (result as { id?: unknown }).id; + if (typeof createdConversationId === "string" && createdConversationId) { + this.deviceStore.allowConversation(from, createdConversationId); + } + } this.client.send(from, ResponseAction, { requestId, ok: true, diff --git a/packages/core/src/hub/rpc/handlers/verify.test.ts b/packages/core/src/hub/rpc/handlers/verify.test.ts new file mode 100644 index 00000000..28d15176 --- /dev/null +++ b/packages/core/src/hub/rpc/handlers/verify.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import { createVerifyHandler } from "./verify.js"; +import { RpcError } from "../dispatcher.js"; +import type { DeviceStore } from "../../device-store.js"; + +function createDeviceStoreStub() { + return { + isAllowed: vi.fn(), + consumeToken: vi.fn(), + allowDevice: vi.fn(), + } as unknown as DeviceStore; +} + +describe("createVerifyHandler", () => { + it("returns existing authorized conversation scope without consuming token", async () => { + const deviceStore = createDeviceStoreStub(); + const storeApi = deviceStore as unknown as { + isAllowed: ReturnType; + consumeToken: ReturnType; + allowDevice: ReturnType; + }; + storeApi.isAllowed.mockReturnValue({ + agentId: "agent-1", + conversationIds: ["conv-1"], + }); + + const onConfirmDevice = vi.fn(async () => true); + const handler = createVerifyHandler({ + hubId: "hub-1", + deviceStore, + resolveMainConversationId: () => "conv-1", + onConfirmDevice, + }); + + const result = await handler({}, "dev-1"); + expect(result).toEqual({ + hubId: "hub-1", + agentId: "agent-1", + conversationId: "conv-1", + mainConversationId: "conv-1", + isNewDevice: false, + }); + expect(storeApi.consumeToken).not.toHaveBeenCalled(); + expect(onConfirmDevice).not.toHaveBeenCalled(); + }); + + it("consumes token, confirms device, and stores conversation scope", async () => { + const deviceStore = createDeviceStoreStub(); + const storeApi = deviceStore as unknown as { + isAllowed: ReturnType; + consumeToken: ReturnType; + allowDevice: ReturnType; + }; + storeApi.isAllowed.mockReturnValue(null); + storeApi.consumeToken.mockReturnValue({ + agentId: "agent-2", + conversationId: "conv-2", + }); + + const onConfirmDevice = vi.fn(async () => true); + const handler = createVerifyHandler({ + hubId: "hub-2", + deviceStore, + resolveMainConversationId: () => "conv-2", + onConfirmDevice, + }); + + const result = await handler({ token: "token-2" }, "dev-2"); + expect(result).toEqual({ + hubId: "hub-2", + agentId: "agent-2", + conversationId: "conv-2", + mainConversationId: "conv-2", + isNewDevice: true, + }); + expect(onConfirmDevice).toHaveBeenCalledWith("dev-2", "agent-2", "conv-2", undefined); + expect(storeApi.allowDevice).toHaveBeenCalledWith("dev-2", "agent-2", "conv-2", undefined); + }); + + it("throws REJECTED when user denies device confirmation", async () => { + const deviceStore = createDeviceStoreStub(); + const storeApi = deviceStore as unknown as { + isAllowed: ReturnType; + consumeToken: ReturnType; + allowDevice: ReturnType; + }; + storeApi.isAllowed.mockReturnValue(null); + storeApi.consumeToken.mockReturnValue({ + agentId: "agent-3", + conversationId: "conv-3", + }); + + const handler = createVerifyHandler({ + hubId: "hub-3", + deviceStore, + onConfirmDevice: async () => false, + }); + + await expect(handler({ token: "token-3" }, "dev-3")).rejects.toMatchObject({ + code: "REJECTED", + } satisfies Partial); + expect(storeApi.allowDevice).not.toHaveBeenCalled(); + }); +}); + diff --git a/packages/core/src/hub/rpc/handlers/verify.ts b/packages/core/src/hub/rpc/handlers/verify.ts index 8ad87d18..f79da299 100644 --- a/packages/core/src/hub/rpc/handlers/verify.ts +++ b/packages/core/src/hub/rpc/handlers/verify.ts @@ -7,7 +7,12 @@ interface VerifyContext { deviceStore: DeviceStore; resolveMainConversationId?: (agentId: string) => string | undefined; /** Called for first-time connections. Returns true if user approves, false if rejected. */ - onConfirmDevice: (deviceId: string, agentId: string, meta?: DeviceMeta) => Promise; + onConfirmDevice: ( + deviceId: string, + agentId: string, + conversationId: string, + meta?: DeviceMeta, + ) => Promise; } interface VerifyParams { @@ -22,11 +27,18 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler { // 1. Already in whitelist → pass through (reconnection, no confirmation needed) const allowed = ctx.deviceStore.isAllowed(from); if (allowed) { - const mainConversationId = ctx.resolveMainConversationId?.(allowed.agentId) ?? allowed.agentId; + const preferredConversationId = allowed.conversationIds[0]; + const mainConversationId = ctx.resolveMainConversationId?.(allowed.agentId) + ?? preferredConversationId + ?? allowed.agentId; + const conversationId = allowed.conversationIds.includes(mainConversationId) + ? mainConversationId + : preferredConversationId ?? mainConversationId; return { hubId: ctx.hubId, agentId: allowed.agentId, - mainConversationId, + conversationId, + mainConversationId: conversationId, isNewDevice: false, }; } @@ -42,17 +54,18 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler { } // 3. Token valid → await Desktop user confirmation - const confirmed = await ctx.onConfirmDevice(from, result.agentId, meta); + const confirmed = await ctx.onConfirmDevice(from, result.agentId, result.conversationId, 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); - const mainConversationId = ctx.resolveMainConversationId?.(result.agentId) ?? result.agentId; + ctx.deviceStore.allowDevice(from, result.agentId, result.conversationId, meta); + const mainConversationId = ctx.resolveMainConversationId?.(result.agentId) ?? result.conversationId; return { hubId: ctx.hubId, agentId: result.agentId, + conversationId: mainConversationId, mainConversationId, isNewDevice: true, }; diff --git a/packages/sdk/src/actions/rpc.ts b/packages/sdk/src/actions/rpc.ts index aa104da1..43ac9d76 100644 --- a/packages/sdk/src/actions/rpc.ts +++ b/packages/sdk/src/actions/rpc.ts @@ -187,6 +187,8 @@ export interface VerifyParams { export interface VerifyResult { hubId: string; agentId: string; - /** Main conversation for this agent. Defaults to agentId in legacy mode. */ + /** Authorized conversation scope for this device. */ + conversationId?: string; + /** Backward-compatible alias for conversationId. */ mainConversationId?: string; } diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index d03f14c0..566b0370 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -234,7 +234,17 @@ export class GatewayClient { } /** Hub 验证成功回调 */ - onVerified(callback: (result: { hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }) => void): this { + onVerified( + callback: ( + result: { + hubId: string; + agentId: string; + conversationId?: string; + mainConversationId?: string; + isNewDevice?: boolean; + } + ) => void, + ): this { this.callbacks.onVerified = callback; return this; } @@ -318,7 +328,13 @@ export class GatewayClient { platform: navigator.platform, language: navigator.language, } : undefined; - this.request<{ hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }>( + this.request<{ + hubId: string; + agentId: string; + conversationId?: string; + mainConversationId?: string; + isNewDevice?: boolean; + }>( this.options.hubId, "verify", { token: this.options.token, meta }, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 5eb13bc2..a996a051 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -110,7 +110,15 @@ export interface GatewayClientCallbacks { onConnect?: (socketId: string) => void; onDisconnect?: (reason: string) => void; onRegistered?: (deviceId: string) => void; - onVerified?: (result: { hubId: string; agentId: string; mainConversationId?: string; isNewDevice?: boolean }) => void; + onVerified?: ( + result: { + hubId: string; + agentId: string; + conversationId?: string; + mainConversationId?: string; + isNewDevice?: boolean; + } + ) => void; onMessage?: (message: RoutedMessage) => void; onSendError?: (error: SendErrorResponse) => void; onPong?: (data: string) => void;