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