refactor(hub): enforce conversation-scoped device authorization

This commit is contained in:
Jiayuan Zhang 2026-02-17 03:41:45 +08:00
parent 3123506657
commit a0bb88e7b7
21 changed files with 528 additions and 99 deletions

View file

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

View file

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

View file

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

View file

@ -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<PendingConfirm | null>(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()
}

View file

@ -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 (
<div className="flex flex-col items-center gap-4">

View file

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

View file

@ -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<string | null>(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 (

View file

@ -14,6 +14,7 @@ export interface DeviceMeta {
export interface DeviceEntry {
deviceId: string
agentId: string
conversationIds: string[]
addedAt: number
meta?: DeviceMeta
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<boolean>) | null = null;
private _onConfirmDevice: (
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise<boolean>
) | 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<boolean>) | null): void {
setConfirmHandler(
handler: (
(deviceId: string, agentId: string, conversationId: string, meta?: DeviceMeta) => Promise<boolean>
) | 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<ResponseSuccessPayload>(from, ResponseAction, {
requestId,
ok: true,

View file

@ -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<typeof vi.fn>;
consumeToken: ReturnType<typeof vi.fn>;
allowDevice: ReturnType<typeof vi.fn>;
};
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<typeof vi.fn>;
consumeToken: ReturnType<typeof vi.fn>;
allowDevice: ReturnType<typeof vi.fn>;
};
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<typeof vi.fn>;
consumeToken: ReturnType<typeof vi.fn>;
allowDevice: ReturnType<typeof vi.fn>;
};
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<RpcError>);
expect(storeApi.allowDevice).not.toHaveBeenCalled();
});
});

View file

@ -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<boolean>;
onConfirmDevice: (
deviceId: string,
agentId: string,
conversationId: string,
meta?: DeviceMeta,
) => Promise<boolean>;
}
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,
};

View file

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

View file

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

View file

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