feat(hub): store device metadata in whitelist and pass to confirm handler

Extend DeviceEntry with optional DeviceMeta field. Verify handler
extracts meta from params and passes it through to onConfirmDevice
callback and deviceStore.allowDevice for persistence.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yushen 2026-02-04 13:43:48 +08:00
parent dd701a2472
commit c581183839
3 changed files with 22 additions and 13 deletions

View file

@ -10,10 +10,17 @@ interface TokenEntry {
expiresAt: number;
}
export interface DeviceMeta {
userAgent?: string;
platform?: string;
language?: string;
}
export interface DeviceEntry {
deviceId: string;
agentId: string;
addedAt: number;
meta?: DeviceMeta;
}
// ============ Persistence ============
@ -76,8 +83,8 @@ export class DeviceStore {
// ---- Device whitelist ----
/** Add a device to the whitelist (called after token verification + user confirmation) */
allowDevice(deviceId: string, agentId: string): void {
const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now() };
allowDevice(deviceId: string, agentId: string, meta?: DeviceMeta): void {
const entry: DeviceEntry = { deviceId, agentId, addedAt: Date.now(), meta };
this.allowedDevices.set(deviceId, entry);
this.persist();
}

View file

@ -21,7 +21,7 @@ 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 } from "./device-store.js";
import { DeviceStore, type DeviceMeta } from "./device-store.js";
import { createVerifyHandler } from "./rpc/handlers/verify.js";
export class Hub {
@ -32,7 +32,7 @@ export class Hub {
private readonly rpc: RpcDispatcher;
private client: GatewayClient;
readonly deviceStore: DeviceStore;
private _onConfirmDevice: ((deviceId: string, agentId: string) => Promise<boolean>) | null = null;
private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null = null;
url: string;
readonly path: string;
readonly hubId: string;
@ -52,12 +52,12 @@ export class Hub {
this.rpc.register("verify", createVerifyHandler({
hubId: this.hubId,
deviceStore: this.deviceStore,
onConfirmDevice: (deviceId, agentId) => {
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);
return this._onConfirmDevice(deviceId, agentId, meta);
},
}));
this.rpc.register("getAgentMessages", createGetAgentMessagesHandler());
@ -166,7 +166,7 @@ export class Hub {
}
/** Register a confirmation handler for new device connections (called by Desktop UI) */
setConfirmHandler(handler: ((deviceId: string, agentId: string) => Promise<boolean>) | null): void {
setConfirmHandler(handler: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | null): void {
this._onConfirmDevice = handler;
}

View file

@ -1,20 +1,23 @@
import type { RpcHandler } from "../dispatcher.js";
import { RpcError } from "../dispatcher.js";
import type { DeviceStore } from "../../device-store.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) => Promise<boolean>;
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) {
@ -22,7 +25,6 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
}
// 2. Validate token
const { token } = (params ?? {}) as VerifyParams;
if (!token) {
throw new RpcError("UNAUTHORIZED", "Device not authorized");
}
@ -33,13 +35,13 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
}
// 3. Token valid → await Desktop user confirmation
const confirmed = await ctx.onConfirmDevice(from, result.agentId);
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
ctx.deviceStore.allowDevice(from, result.agentId);
// 4. User confirmed → add to whitelist (with device metadata)
ctx.deviceStore.allowDevice(from, result.agentId, meta);
return { hubId: ctx.hubId, agentId: result.agentId };
};
}