Merge pull request #74446 from NousResearch/bb/desktop-pairing

feat(pairing): profile-correct approvals, and a desktop surface to do them from
This commit is contained in:
brooklyn! 2026-07-29 19:04:36 -05:00 committed by GitHub
commit 08e428ac8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 907 additions and 52 deletions

View file

@ -7,10 +7,16 @@ import type { MessagingPlatformInfo } from '@/types/hermes'
const getMessagingPlatforms = vi.fn()
const updateMessagingPlatform = vi.fn()
const getPairing = vi.fn()
const approvePairing = vi.fn()
const revokePairing = vi.fn()
const openExternalLink = vi.fn()
vi.mock('@/hermes', () => ({
approvePairing: (platformId: string, requestId: string) => approvePairing(platformId, requestId),
getMessagingPlatforms: () => getMessagingPlatforms(),
getPairing: () => getPairing(),
revokePairing: (platformId: string, userId: string) => revokePairing(platformId, userId),
updateMessagingPlatform: (id: string, body: unknown) => updateMessagingPlatform(id, body)
}))
@ -44,6 +50,7 @@ function platform(patch: Partial<MessagingPlatformInfo> = {}): MessagingPlatform
beforeEach(() => {
updateMessagingPlatform.mockResolvedValue({ ok: true, platform: 'teams' })
getPairing.mockResolvedValue({ approved: [], pending: [] })
})
afterEach(() => {
@ -93,3 +100,107 @@ describe('MessagingView setup-guide link', () => {
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
})
})
describe('MessagingView pairing', () => {
const pendingUser = {
age_minutes: 3,
platform: 'teams',
request_id: 'a1b2c3d4e5f60718',
user_id: '7712345',
user_name: 'Bee'
}
it('approves the listed request by its request id, never by a code', async () => {
// The whole point of the request-id grant path: the UI can only ever send
// the server-side row id, because the one-time code is never returned by
// the API. Posting anything derived from the code could not be approved.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
approvePairing.mockResolvedValue({ ok: true, user: { user_id: '7712345', user_name: 'Bee' } })
await renderMessaging()
const approve = await screen.findByRole('button', { name: 'Approve' })
await act(async () => {
fireEvent.click(approve)
})
await waitFor(() => expect(approvePairing).toHaveBeenCalledWith('teams', 'a1b2c3d4e5f60718'))
})
it('restores the pending row when approval fails', async () => {
// Optimistic removal must not silently swallow the request: a failed
// approve has to leave the operator something to retry.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
approvePairing.mockRejectedValue(new Error('500 boom'))
await renderMessaging()
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Approve' }))
})
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByText('Bee')).toBeTruthy()
})
it('shows no pairing affordance when nobody is waiting', async () => {
// Approvals are rare; an always-present empty state would be permanent
// chrome on a page that is otherwise about credentials.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [] })
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
expect(screen.queryByText(/Pending requests/)).toBeNull()
})
it('still renders platforms when the pairing endpoint fails', async () => {
// An older backend without the endpoint must not blank the page.
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockRejectedValue(new Error('404 not found'))
await renderMessaging()
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
})
it('refetches pending rows on pairing.changed, not on platforms.changed', async () => {
// The two signals are not interchangeable: platforms.changed tracks
// connect/disconnect health via gateway_state.json, which a new pairing
// request never moves. Riding it would leave someone invisible in the
// pending list until an unrelated reconnect happened to fire.
const { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } = await import(
'@/store/live-sync'
)
getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] })
getPairing.mockResolvedValue({ approved: [], pending: [] })
await renderMessaging()
await act(async () => {
$changeEventsAvailable.set(true)
})
getPairing.mockClear()
// Someone DMs the bot: the store moves, the watcher ticks pairing.changed.
getPairing.mockResolvedValue({ approved: [], pending: [pendingUser] })
await act(async () => {
$pairingChangeTick.set($pairingChangeTick.get() + 1)
})
await waitFor(() => expect(getPairing).toHaveBeenCalled())
expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy()
// A platform health tick alone must not be what fetches pairing.
getPairing.mockClear()
await act(async () => {
$platformsChangeTick.set($platformsChangeTick.get() + 1)
})
expect(getPairing).not.toHaveBeenCalled()
})
})

View file

@ -5,15 +5,20 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { ErrorBanner } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import {
approvePairing,
getMessagingPlatforms,
getPairing,
type MessagingEnvVarInfo,
type MessagingPlatformInfo,
type PairingUser,
revokePairing,
updateMessagingPlatform
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
@ -21,7 +26,7 @@ import { openExternalLink } from '@/lib/external-link'
import { ExternalLink, Save, Trash2 } from '@/lib/icons'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $changeEventsAvailable, $platformsChangeTick } from '@/store/live-sync'
import { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } from '@/store/live-sync'
import { notify, notifyError } from '@/store/notifications'
import { runGatewayRestart } from '@/store/system-actions'
@ -74,6 +79,22 @@ const trimEdits = (edits: Record<string, string>): Record<string, string> =>
.filter(([, v]) => v)
)
/** Stable row identity: a user id is only unique within its platform. */
const pairingKey = (user: PairingUser) => `${user.platform}:${user.user_id}`
const pairingLabel = (user: PairingUser) => user.user_name || user.user_id
/** Group pairing rows by platform id so a detail pane can slice its own. */
function byPlatform(rows: PairingUser[]): Record<string, PairingUser[]> {
const grouped: Record<string, PairingUser[]> = {}
for (const row of rows) {
;(grouped[row.platform] ||= []).push(row)
}
return grouped
}
const FIELD_COPY: Record<string, { advanced?: boolean }> = {
TELEGRAM_PROXY: { advanced: true },
DISCORD_REPLY_TO_MODE: { advanced: true },
@ -108,6 +129,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
// Both save/toggle toasts offer the same one-click restart.
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [pairing, setPairing] = useState<{ approved: PairingUser[]; pending: PairingUser[] }>({
approved: [],
pending: []
})
const [approving, setApproving] = useState<null | string>(null)
const [pendingRevoke, setPendingRevoke] = useState<null | PairingUser>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
@ -137,14 +166,46 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
[m]
)
useRefreshHotkey(() => void refreshPlatforms())
// Pairing has its own signal. platforms.changed tracks connect/disconnect
// health via gateway_state.json, which a new pairing request never moves —
// riding it would leave a pending row invisible until something unrelated
// reconnected. Failures stay silent: an older backend without the endpoint
// should show no rows, not an error banner over a working page.
const refreshPairing = useCallback(async () => {
try {
const result = await getPairing()
setPairing({ approved: result.approved ?? [], pending: result.pending ?? [] })
} catch {
// Leave the last known rows in place rather than blanking them.
}
}, [])
const refreshAll = useCallback(
async (silent = false) => {
await Promise.all([refreshPlatforms(silent), refreshPairing()])
},
[refreshPairing, refreshPlatforms]
)
useRefreshHotkey(() => void refreshAll())
useEffect(() => {
void refreshPlatforms()
}, [refreshPlatforms])
void refreshAll()
}, [refreshAll])
const changeEventsAvailable = useStore($changeEventsAvailable)
const platformsChangeTick = useStore($platformsChangeTick)
const pairingChangeTick = useStore($pairingChangeTick)
// A new pending request (or a grant from another surface) moves the pairing
// store on disk; the change watcher turns that into pairing.changed.
useEffect(() => {
if (!changeEventsAvailable || pairingChangeTick === 0 || document.hidden) {
return
}
void refreshPairing()
}, [changeEventsAvailable, pairingChangeTick, refreshPairing])
// Connection status updates without a manual "check" click. platforms.changed
// (the gateway persisting connect/disconnect/health to gateway_state.json)
@ -170,7 +231,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return
}
void refreshPlatforms(true)
void refreshAll(true)
}
const id = window.setInterval(tick, 6000)
@ -179,7 +240,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
cancelled = true
window.clearInterval(id)
}
}, [changeEventsAvailable, refreshPlatforms])
}, [changeEventsAvailable, refreshAll])
const selected = useMemo(() => {
if (!platforms) {
@ -189,6 +250,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return platforms.find(platform => platform.id === selectedId) || platforms[0] || null
}, [platforms, selectedId])
const pendingByPlatform = useMemo(() => byPlatform(pairing.pending), [pairing.pending])
const approvedByPlatform = useMemo(() => byPlatform(pairing.approved), [pairing.approved])
const visiblePlatforms = useMemo(() => {
if (!platforms) {
return []
@ -284,6 +348,57 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}
// Approve/revoke paint from a snapshot immediately, then let the
// authoritative refresh have the last word. A failed write restores the
// snapshot so the row never silently disappears on an error.
async function handleApprove(user: PairingUser) {
if (!user.request_id) {
return
}
const key = pairingKey(user)
const snapshot = pairing
setApproving(key)
setPairing(current => ({
approved: current.approved,
pending: current.pending.filter(row => pairingKey(row) !== key)
}))
try {
await approvePairing(user.platform, user.request_id)
notify({ kind: 'success', title: m.approvedUser(pairingLabel(user)), message: m.approvedHint })
await refreshPairing()
} catch (err) {
setPairing(snapshot)
// 429 is the code path's brute-force lockout — a distinct condition the
// operator can only wait out, so it gets its own message.
const lockedOut = err instanceof Error && err.message.includes('429')
notifyError(err, lockedOut ? m.pairingLockedOut : m.failedApprove(pairingLabel(user)))
} finally {
setApproving(null)
}
}
// ConfirmDialog owns the pending → done → close beat and shows an inline
// error when onConfirm throws, so this rethrows instead of swallowing.
async function handleRevoke(user: PairingUser) {
const key = pairingKey(user)
const snapshot = pairing
setPairing(current => ({
approved: current.approved.filter(row => pairingKey(row) !== key),
pending: current.pending
}))
try {
await revokePairing(user.platform, user.user_id)
notify({ kind: 'success', title: m.revokedUser(pairingLabel(user)), message: user.platform })
await refreshPairing()
} catch (err) {
setPairing(snapshot)
throw err
}
}
return (
<PageSearchShell
{...props}
@ -304,6 +419,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
<PlatformRow
active={selected?.id === platform.id}
onSelect={() => setSelectedId(platform.id)}
pendingCount={pendingByPlatform[platform.id]?.length ?? 0}
platform={platform}
/>
</li>
@ -326,7 +442,10 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
>
{selected && (
<PlatformDetail
approved={approvedByPlatform[selected.id] ?? []}
approving={approving}
edits={edits[selected.id] || {}}
onApprove={user => void handleApprove(user)}
onClear={key => void handleClear(selected, key)}
onEdit={(key, value) =>
setEdits(current => ({
@ -337,6 +456,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}))
}
onRevoke={setPendingRevoke}
pending={pendingByPlatform[selected.id] ?? []}
platform={selected}
saving={saving}
/>
@ -344,6 +465,18 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</DetailColumn>
</MasterDetail>
)}
<ConfirmDialog
busyLabel={m.revoking}
cancelLabel={t.common.cancel}
confirmLabel={m.revoke}
description={pendingRevoke ? m.revokeDesc(pairingLabel(pendingRevoke)) : null}
destructive
onClose={() => setPendingRevoke(null)}
onConfirm={() => (pendingRevoke ? handleRevoke(pendingRevoke) : undefined)}
open={Boolean(pendingRevoke)}
title={m.revokeTitle}
/>
</PageSearchShell>
)
}
@ -351,12 +484,16 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
function PlatformRow({
active,
onSelect,
pendingCount,
platform
}: {
active: boolean
onSelect: () => void
pendingCount: number
platform: MessagingPlatformInfo
}) {
const { t } = useI18n()
return (
<button
className={cn(
@ -369,22 +506,47 @@ function PlatformRow({
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-normal">{platform.name}</span>
<StatusDot tone={stateTone(platform)} />
<span className="flex shrink-0 items-center gap-1.5">
{/* Someone is waiting to be let in the only way this page tells
you so before you open the platform. */}
{pendingCount > 0 && (
<span
aria-label={t.messaging.pendingAria(pendingCount)}
className={cn(
'inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[0.66rem] font-medium tabular-nums',
PILL_TONE.warn
)}
>
{pendingCount}
</span>
)}
<StatusDot tone={stateTone(platform)} />
</span>
</span>
</button>
)
}
function PlatformDetail({
approved,
approving,
edits,
onApprove,
onClear,
onEdit,
onRevoke,
pending,
platform,
saving
}: {
approved: PairingUser[]
approving: null | string
edits: Record<string, string>
onApprove: (user: PairingUser) => void
onClear: (key: string) => void
onEdit: (key: string, value: string) => void
onRevoke: (user: PairingUser) => void
pending: PairingUser[]
platform: MessagingPlatformInfo
saving: string | null
}) {
@ -418,6 +580,66 @@ function PlatformDetail({
{platform.error_message && <ErrorBanner>{platform.error_message}</ErrorBanner>}
{/* Pending pairing requests. Rendered only when someone is actually
waiting an empty-state card here would be permanent chrome on a
page that is usually about credentials, not approvals. */}
{pending.length > 0 && (
<section>
<SectionTitle>{m.pendingRequests(pending.length)}</SectionTitle>
<div className="mt-1 grid gap-1">
{pending.map(user => {
const busy = approving === pairingKey(user)
const waited = typeof user.age_minutes === 'number' ? m.waitingSince(user.age_minutes) : null
return (
<ListRow
action={
<Button
disabled={busy || !user.request_id}
onClick={() => onApprove(user)}
size="sm"
variant="secondary"
>
{busy ? m.approving : m.approve}
</Button>
}
// An unnamed requester is only a user id — showing it as
// both title and description just repeats itself.
description={[user.user_name ? user.user_id : null, waited].filter(Boolean).join(' · ')}
key={pairingKey(user)}
title={pairingLabel(user)}
/>
)
})}
</div>
</section>
)}
{approved.length > 0 && (
<section>
<SectionTitle>{m.approvedUsers(approved.length)}</SectionTitle>
<div className="mt-1 grid gap-1">
{approved.map(user => (
<ListRow
action={
<Button
aria-label={m.revokeAria(pairingLabel(user))}
onClick={() => onRevoke(user)}
size="sm"
variant="ghost"
>
{m.revoke}
</Button>
}
description={user.user_name ? user.user_id : undefined}
key={pairingKey(user)}
title={pairingLabel(user)}
/>
))}
</div>
</section>
)}
<section>
<SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">

View file

@ -26,6 +26,7 @@ import { $gateway } from '@/store/gateway'
import { applyGoalStatusText } from '@/store/goals'
import {
notifyCronChanged,
notifyPairingChanged,
notifyPetChanged,
notifyPlatformsChanged,
notifySessionsChanged,
@ -303,7 +304,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
event.type === 'pet.changed' ||
event.type === 'cron.changed' ||
event.type === 'sessions.changed' ||
event.type === 'platforms.changed'
event.type === 'platforms.changed' ||
event.type === 'pairing.changed'
) {
// Change-watcher broadcasts (server._broadcast_watched_changes): the
// backend's on-disk signature moved. Route to the live-sync ticks the
@ -319,6 +321,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
notifyCronChanged()
} else if (event.type === 'platforms.changed') {
notifyPlatformsChanged()
} else if (event.type === 'pairing.changed') {
notifyPairingChanged()
} else {
notifySessionsChanged()
}

View file

@ -43,6 +43,8 @@ import type {
OAuthStartResponse,
OAuthSubmitResponse,
PaginatedSessions,
PairingResponse,
PairingUser,
ProfileCreatePayload,
ProfileSetupCommand,
ProfileSoul,
@ -180,6 +182,8 @@ export type {
ModelOptionProvider,
ModelOptionsResponse,
PaginatedSessions,
PairingResponse,
PairingUser,
ProfileCreatePayload,
ProfileInfo,
ProfileSetupCommand,
@ -1162,6 +1166,40 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
})
}
// -- Pairing (who may DM the bot) --------------------------------------------
// Unknown DMers get a one-time code and land in `pending` until an admin
// approves them. Approval grants on the row's `request_id`, never on the code:
// the code is the requester's proof that the channel is theirs and is never
// returned by the API, while an authenticated admin is only ever identifying
// a row they can already see.
export function getPairing(): Promise<PairingResponse> {
return window.hermesDesktop.api<PairingResponse>({
...profileScoped(),
path: '/api/pairing'
})
}
export function approvePairing(platform: string, requestId: string): Promise<{ ok: boolean; user: PairingUser }> {
return window.hermesDesktop.api<{ ok: boolean; user: PairingUser }>({
...profileScoped(),
path: '/api/pairing/approve',
method: 'POST',
// These endpoints read the profile off the body, not the query string —
// `profileScoped()` alone would approve into the wrong profile's store.
body: { platform, request_id: requestId, ...profileScoped() }
})
}
export function revokePairing(platform: string, userId: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: '/api/pairing/revoke',
method: 'POST',
body: { platform, user_id: userId, ...profileScoped() }
})
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// The webhook receiver is its own gateway platform; subscriptions live in a
// shared JSON store the CLI/dashboard also drive. Enable mutates config and

View file

@ -1367,6 +1367,23 @@ export const en: Translations = {
failedUpdate: name => `Failed to update ${name}`,
failedSave: name => `Failed to save ${name}`,
failedClear: key => `Failed to clear ${key}`,
pendingRequests: count => `Pending requests (${count})`,
pendingAria: count => `${count} pending pairing ${count === 1 ? 'request' : 'requests'}`,
approvedUsers: count => `Approved users (${count})`,
approve: 'Approve',
approving: 'Approving...',
revoke: 'Revoke',
revoking: 'Revoking...',
revokeAria: name => `Revoke ${name}`,
revokeTitle: 'Revoke access',
revokeDesc: (name: string) => `${name} will lose access and stop being recognized on their next message.`,
approvedUser: name => `${name} approved`,
approvedHint: 'They are recognized automatically on their next message.',
revokedUser: name => `${name} revoked`,
failedApprove: name => `Failed to approve ${name}`,
failedRevoke: name => `Failed to revoke ${name}`,
pairingLockedOut: 'Too many failed approvals — this platform is locked out. Try again later.',
waitingSince: minutes => (minutes < 1 ? 'just now' : `${minutes}m ago`),
fieldCopy: {
TELEGRAM_BOT_TOKEN: {
label: 'Bot token',

View file

@ -1204,6 +1204,23 @@ export interface Translations {
failedUpdate: (name: string) => string
failedSave: (name: string) => string
failedClear: (key: string) => string
pendingRequests: (count: number) => string
pendingAria: (count: number) => string
approvedUsers: (count: number) => string
approve: string
approving: string
revoke: string
revoking: string
revokeAria: (name: string) => string
revokeTitle: string
revokeDesc: (name: string) => string
approvedUser: (name: string) => string
approvedHint: string
revokedUser: (name: string) => string
failedApprove: (name: string) => string
failedRevoke: (name: string) => string
pairingLockedOut: string
waitingSince: (minutes: number) => string
fieldCopy: Record<string, { label?: string; help?: string; placeholder?: string }>
platformIntro: Record<string, string>
}

View file

@ -1563,6 +1563,23 @@ export const zh: Translations = {
failedUpdate: name => `更新 ${name} 失败`,
failedSave: name => `保存 ${name} 失败`,
failedClear: key => `清除 ${key} 失败`,
pendingRequests: count => `待处理请求(${count}`,
pendingAria: count => `${count} 条待处理配对请求`,
approvedUsers: count => `已批准用户(${count}`,
approve: '批准',
approving: '批准中…',
revoke: '撤销',
revoking: '撤销中…',
revokeAria: name => `撤销 ${name}`,
revokeTitle: '撤销访问权限',
revokeDesc: name => `${name} 将失去访问权限,下次发送消息时不再被识别。`,
approvedUser: name => `已批准 ${name}`,
approvedHint: '对方下次发送消息时会被自动识别。',
revokedUser: name => `已撤销 ${name}`,
failedApprove: name => `批准 ${name} 失败`,
failedRevoke: name => `撤销 ${name} 失败`,
pairingLockedOut: '批准失败次数过多,该平台已被暂时锁定,请稍后再试。',
waitingSince: minutes => (minutes < 1 ? '刚刚' : `${minutes} 分钟前`),
fieldCopy: {
TELEGRAM_BOT_TOKEN: {
label: 'Bot 令牌',

View file

@ -0,0 +1,43 @@
// Pairing writes must target the profile the user is actually looking at.
// The approve/revoke endpoints read `profile` off the BODY (a POST body is
// not touched by query-param scoping), so a request that only carried
// `profileScoped()` at the top level would approve into the default
// profile's whitelist while the operator was managing another one — a grant
// the running gateway for that profile never consults.
import { beforeEach, describe, expect, it, vi } from 'vitest'
const api = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('window', { hermesDesktop: { api } })
describe('pairing requests carry the active profile', () => {
beforeEach(() => api.mockClear())
it('scopes approve and revoke by body, and the listing by query', async () => {
const mod = await import('@/hermes')
mod.setApiRequestProfile('work')
await mod.approvePairing('telegram', 'a'.repeat(16))
await mod.revokePairing('telegram', 'U1')
await mod.getPairing()
const [approve, revoke, list] = api.mock.calls.map(call => call[0])
expect(approve.body.profile).toBe('work')
expect(revoke.body.profile).toBe('work')
expect(list.profile).toBe('work')
})
it('omits the profile entirely for single-profile users', async () => {
const mod = await import('@/hermes')
mod.setApiRequestProfile(null)
await mod.approvePairing('telegram', 'a'.repeat(16))
await mod.getPairing()
const [approve, list] = api.mock.calls.map(call => call[0])
expect(approve.body.profile).toBeUndefined()
expect(list.profile).toBeUndefined()
})
})

View file

@ -19,6 +19,7 @@ export const $changeEventsAvailable = atom(false)
export const $cronChangeTick = atom(0)
export const $sessionsChangeTick = atom(0)
export const $platformsChangeTick = atom(0)
export const $pairingChangeTick = atom(0)
/** `pet.info.meta`-shaped payload carried on `pet.changed` lets the pet skip
* the heavy sprite refetch when the broadcast already says enabled=false. */
@ -52,6 +53,10 @@ export function notifyPlatformsChanged(): void {
$platformsChangeTick.set($platformsChangeTick.get() + 1)
}
export function notifyPairingChanged(): void {
$pairingChangeTick.set($pairingChangeTick.get() + 1)
}
/** Reset on gateway wipe/reconnect a new backend re-advertises capability on
* its own gateway.ready, and stale ticks must not fire refreshes into stores
* the wipe just cleared. */

View file

@ -238,6 +238,21 @@ export interface MessagingPlatformsResponse {
platforms: MessagingPlatformInfo[]
}
/** A pending pairing request, or an already-approved user, for one platform. */
export interface PairingUser {
age_minutes?: number
platform: string
/** Present on pending rows only — the id `approvePairing` grants on. */
request_id?: string
user_id: string
user_name?: string
}
export interface PairingResponse {
approved: PairingUser[]
pending: PairingUser[]
}
export interface MessagingPlatformUpdate {
clear_env?: string[]
enabled?: boolean

View file

@ -33,7 +33,11 @@ from gateway.whatsapp_identity import (
expand_whatsapp_aliases,
normalize_whatsapp_identifier,
)
from hermes_constants import get_hermes_dir, get_hermes_home
from hermes_constants import (
get_default_hermes_root,
get_hermes_dir,
get_hermes_home,
)
from utils import atomic_replace
logger = logging.getLogger(__name__)
@ -197,11 +201,15 @@ def _merge_pairing_dir(active_dir: Path, alternate_dir: Path) -> None:
_secure_write(dest, json.dumps(merged, indent=2, ensure_ascii=False))
def _migrate_split_pairing_dirs() -> None:
home = get_hermes_home()
def _migrate_split_pairing_dirs(
*,
home: Optional[Path] = None,
active: Optional[Path] = None,
) -> None:
home = home or get_hermes_home()
old_dir = home / "pairing"
new_dir = home / "platforms" / "pairing"
active = PAIRING_DIR
active = active or PAIRING_DIR
alternate = new_dir if active.resolve() == old_dir.resolve() else old_dir
_merge_pairing_dir(active, alternate)
@ -241,26 +249,39 @@ class PairingStore:
- {platform}-approved.json : approved (paired) users
- _rate_limits.json : rate limit tracking
When constructed with ``profile="<name>"``, storage lives under
``<HERMES_HOME>/profiles/<name>/pairing/`` (per-profile, used by
multiplexing gateways so each profile has its own whitelist).
Without a profile, storage is the global ``<HERMES_HOME>/pairing/``
directory (backward-compat for the ``hermes pairing`` CLI).
When constructed with ``profile="<name>"``, storage resolves from that
profile's own HERMES_HOME using the same legacy/consolidated layout rules
as ``hermes -p <name> pairing ...``. This keeps multiplex gateways and
profile-scoped CLI approvals on one whitelist. Without a profile, storage
is the global pairing directory for the current HERMES_HOME.
"""
def __init__(self, profile: Optional[str] = None):
# Resolve storage directory lazily — tests use a temp HERMES_HOME
# and PairingStore may be constructed before the env is set.
if profile:
from hermes_constants import get_hermes_home
self._dir = get_hermes_home() / "profiles" / profile / "pairing"
root = get_default_hermes_root()
profile_home = (
root
if profile == "default"
else root / "profiles" / profile
)
self._dir = get_hermes_dir(
"platforms/pairing",
"pairing",
home=profile_home,
)
else:
self._dir = PAIRING_DIR
self._dir.mkdir(parents=True, exist_ok=True)
if not profile:
if profile:
# Explicit stores must resolve exactly as a standalone
# ``hermes -p <profile> pairing ...`` process does. Merge the
# alternate old/new layout so upgrades cannot split approvals.
_migrate_split_pairing_dirs(home=profile_home, active=self._dir)
else:
# Heal installs whose global pairing data ended up split across
# the legacy and new directories (per-profile stores never had
# the legacy/new split).
# the legacy and new directories.
_migrate_split_pairing_dirs()
# Protects all read-modify-write cycles. The gateway runs multiple
# platform adapters concurrently in threads sharing one PairingStore.
@ -727,7 +748,7 @@ class PairingStore:
def _all_platforms(self, suffix: str) -> list:
"""List all platforms that have data files of a given suffix."""
platforms = []
for f in PAIRING_DIR.iterdir():
for f in self._dir.iterdir():
if f.name.endswith(f"-{suffix}.json"):
platform = f.name.replace(f"-{suffix}.json", "")
if not platform.startswith("_"):

View file

@ -12486,11 +12486,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
served = [active] + sorted(self._profile_adapters.keys())
# Per-profile PairingStores so authz_mixin can route pairing
# checks to the right whitelist. The active profile gets a store
# at its HERMES_HOME; additional served profiles get one under
# profiles/<name>/pairing/. See gateway.pairing.PairingStore.
# at its HERMES_HOME; additional served profiles resolve from
# their own profile homes. See gateway.pairing.PairingStore.
for name in served:
if name and name not in self.pairing_stores:
self.pairing_stores[name] = PairingStore(profile=name)
self.pairing_stores[name] = (
self.pairing_store
if name == active
else PairingStore(profile=name)
)
write_runtime_status(served_profiles=served)
except Exception:
logger.debug("could not record served_profiles", exc_info=True)
@ -13677,23 +13681,39 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
== "pair"
):
platform_name = source.platform.value if source.platform else "unknown"
pairing_store = self._pairing_store_for(source)
if pairing_store is None:
logger.error(
"Cannot offer pairing code on %s: no pairing store",
platform_name,
)
return None
# Rate-limit ALL pairing responses (code or rejection) to
# prevent spamming the user with repeated messages when
# multiple DMs arrive in quick succession.
if self.pairing_store._is_rate_limited(platform_name, source.user_id):
if pairing_store._is_rate_limited(platform_name, source.user_id):
return None
code = self.pairing_store.generate_code(
code = pairing_store.generate_code(
platform_name, source.user_id, source.user_name or ""
)
if code:
adapter = self._adapter_for_source(source)
if adapter:
store_profile = getattr(pairing_store, "profile", None)
profile_arg = (
f"-p {store_profile} "
if isinstance(store_profile, str)
and store_profile
and store_profile != "default"
else ""
)
await adapter.send(
source.chat_id,
f"Hi~ I don't recognize you yet!\n\n"
f"Here's your pairing code: `{code}`\n\n"
f"Ask the bot owner to run:\n"
f"`hermes pairing approve {platform_name} {code}`"
f"`hermes {profile_arg}pairing approve "
f"{platform_name} {code}`"
)
else:
adapter = self._adapter_for_source(source)
@ -13704,7 +13724,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Please try again later!"
)
# Record rate limit so subsequent messages are silently ignored
self.pairing_store._record_rate_limit(platform_name, source.user_id)
pairing_store._record_rate_limit(platform_name, source.user_id)
return None
# Intercept messages that are responses to a pending /update prompt.

View file

@ -430,11 +430,13 @@ class PairingApprove(BaseModel):
platform: str
code: str = ""
request_id: str = ""
profile: Optional[str] = None
class PairingRevoke(BaseModel):
platform: str
user_id: str
profile: Optional[str] = None
# --- from web_server.py (originally lines 13793-13804) ---

View file

@ -12008,15 +12008,33 @@ _ACTION_LOG_FILES.setdefault("computer-use-grant", "action-computer-use-grant.lo
# ---------------------------------------------------------------------------
def _pairing_store():
def _pairing_store(profile: Optional[str] = None):
"""Pairing store for ``profile`` — the dashboard's own when unspecified.
Every other admin endpoint scopes by profile, and the gateway already
keeps one store per served profile (``gateway/run.py``). Without this the
dashboard and desktop always read the global store, so an operator on a
named profile approves into a whitelist their gateway never consults.
``PairingStore`` resolves the profile's home itself (``default`` maps back
to the global store), so this only needs to validate the name no
``_profile_scope`` needed, and nothing process-global is swapped across
the ``await`` boundary.
"""
from gateway.pairing import PairingStore
return PairingStore()
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
return PairingStore()
_resolve_profile_dir(requested) # 400/404 on an unknown profile
return PairingStore(profile=requested)
@app.get("/api/pairing")
async def list_pairing():
store = _pairing_store()
async def list_pairing(profile: Optional[str] = None):
store = _pairing_store(profile)
return {
"pending": store.list_pending(),
"approved": store.list_approved(),
@ -12025,7 +12043,7 @@ async def list_pairing():
@app.post("/api/pairing/approve")
async def approve_pairing(body: PairingApprove):
store = _pairing_store()
store = _pairing_store(body.profile)
platform = (body.platform or "").lower().strip()
# `request_id` is what an admin surface sends after listing pending
# requests; `code` is the one-time code the user relays from their DM.
@ -12061,7 +12079,7 @@ async def approve_pairing(body: PairingApprove):
@app.post("/api/pairing/revoke")
async def revoke_pairing(body: PairingRevoke):
store = _pairing_store()
store = _pairing_store(body.profile)
platform = (body.platform or "").lower().strip()
if not platform or not body.user_id:
raise HTTPException(status_code=400, detail="platform and user_id are required")
@ -12074,8 +12092,8 @@ async def revoke_pairing(body: PairingRevoke):
@app.post("/api/pairing/clear-pending")
async def clear_pending_pairing():
store = _pairing_store()
async def clear_pending_pairing(profile: Optional[str] = None):
store = _pairing_store(profile)
count = store.clear_pending()
return {"ok": True, "cleared": count}

View file

@ -236,7 +236,12 @@ def get_bundled_skills_dir(default: Path | None = None) -> Path:
return get_hermes_home() / "skills"
def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
def get_hermes_dir(
new_subpath: str,
old_name: str,
*,
home: Path | None = None,
) -> Path:
"""Resolve a Hermes subdirectory with backward compatibility.
New installs get the consolidated layout (e.g. ``cache/images``).
@ -254,12 +259,15 @@ def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
Args:
new_subpath: Preferred path relative to HERMES_HOME (e.g. ``"cache/images"``).
old_name: Legacy path relative to HERMES_HOME (e.g. ``"image_cache"``).
home: Optional explicit Hermes home. Profile-aware callers that manage
more than one home in the same process use this instead of
temporarily mutating the process or context-local HERMES_HOME.
Returns:
Absolute ``Path`` legacy location if it exists with content,
otherwise the new location.
"""
home = get_hermes_home()
home = home or get_hermes_home()
old_path = home / old_name
if _legacy_path_has_content(old_path):
return old_path

View file

@ -23,6 +23,7 @@ def _bare_runner(multiplex: bool = True):
runner.config = MagicMock(multiplex_profiles=multiplex)
runner.adapters = {}
runner._profile_adapters = {}
runner.pairing_store = MagicMock()
runner.pairing_stores = {}
return runner
@ -54,8 +55,33 @@ def test_secondary_profile_pairing_stores_created(tmp_path, monkeypatch):
assert "default" in runner.pairing_stores, (
"active profile PairingStore missing — the NameError swallow is back"
)
assert runner.pairing_stores["default"] is runner.pairing_store
assert "coder" in runner.pairing_stores, (
"secondary profile PairingStore missing — the NameError swallow is back"
)
def test_pairing_store_scoped_to_profile_dir(tmp_path, monkeypatch):
"""The created store must live under the profile's pairing directory."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir()
runner = _bare_runner()
async def _no_secondary(profile_name, profile_home, claimed):
return 0
runner._start_one_profile_adapters = _no_secondary
runner._adapter_credential_fingerprint = lambda adapter: None
with patch("hermes_cli.profiles.profiles_to_serve", return_value=[
("ops", tmp_path / ".hermes" / "profiles" / "ops"),
]), patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
runner._profile_adapters["ops"] = {}
asyncio.run(runner._start_secondary_profile_adapters())
store = runner.pairing_stores["ops"]
assert store.profile == "ops"
assert "profiles/ops/platforms/pairing" in str(store._dir).replace("\\", "/"), (
f"store not profile-scoped: {store._dir}"
)

View file

@ -46,6 +46,41 @@ class TestSplitPairingDirMigration:
assert "ou_user" in migrated
class TestProfileScopedDiscovery:
def test_list_approved_scopes_platform_discovery_to_profile_dir(self, tmp_path):
# A profile-scoped store must enumerate platforms from its own
# per-profile directory (self._dir), not the module-global PAIRING_DIR.
# Regression: _all_platforms iterated PAIRING_DIR while every per-file
# path helper routed through self._dir, so a profile store confirmed a
# user via is_approved() (reads self._dir) yet returned [] from
# list_approved() (scanned the empty global dir).
home = tmp_path / "home"
global_dir = tmp_path / "global-pairing"
global_dir.mkdir(parents=True)
# A profile's store anchors to the hermes ROOT, not the current
# HERMES_HOME — the current home may itself be a profile, and nesting
# profiles inside profiles is how a `-p work` CLI and its gateway end
# up reading different files. Patch that seam, not get_hermes_home.
with patch("gateway.pairing.PAIRING_DIR", global_dir), patch(
"gateway.pairing.get_default_hermes_root", return_value=home
):
store = PairingStore(profile="alice")
# Scoped under the mocked root's profile dir, using the same
# consolidated layout a standalone `hermes -p alice` resolves —
# and provably distinct from the module-global PAIRING_DIR.
assert store._dir == home / "profiles" / "alice" / "platforms" / "pairing"
assert store._dir != global_dir
with store._lock:
store._approve_user("telegram", "tg-456", "Bob")
assert store.is_approved("telegram", "tg-456") is True
approved = store.list_approved()
assert [r["user_id"] for r in approved] == ["tg-456"]
assert approved[0]["platform"] == "telegram"
# ---------------------------------------------------------------------------
# _secure_write
# ---------------------------------------------------------------------------
@ -521,16 +556,94 @@ class TestUnreadablePairingFile:
class TestProfileScopedStorage:
"""PairingStore(profile="<name>") should isolate per-profile whitelists
under <HERMES_HOME>/profiles/<name>/pairing/ so a multiplexing gateway
can keep each profile's allowlist separate.
under each profile's own Hermes home so a multiplexing gateway can keep
every profile's allowlist separate.
"""
def test_default_store_uses_global_dir(self, tmp_path, monkeypatch):
"""PairingStore() (no profile) keeps the legacy global path so the
``hermes pairing`` CLI continues to work without a profile context."""
from hermes_constants import get_hermes_home
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)
# Re-import PAIRING_DIR (it's a module-level constant resolved at
# import time) so the test exercises the right path. We patch it
# rather than re-importing so the assertion is unambiguous.
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
store = PairingStore()
assert store.profile is None
assert store._dir == tmp_path
assert store._approved_path("weixin") == tmp_path / "weixin-approved.json"
def test_profile_store_uses_profiles_subdir(self, tmp_path, monkeypatch):
"""Explicit profile stores use that profile's normal Hermes layout."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
store = PairingStore(profile="yangyang")
assert store.profile == "yangyang"
expected = tmp_path / "profiles" / "yangyang" / "platforms" / "pairing"
assert store._dir == expected
assert store._approved_path("weixin") == expected / "weixin-approved.json"
# Auto-creates the directory
assert expected.is_dir()
def test_profile_store_matches_profile_cli_home(self, tmp_path, monkeypatch):
"""Gateway and ``hermes -p`` must resolve the same pairing store."""
from hermes_constants import get_hermes_dir
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
profile_home = tmp_path / "profiles" / "coder"
profile_home.mkdir(parents=True)
gateway_store = PairingStore(profile="coder")
cli_dir = get_hermes_dir(
"platforms/pairing",
"pairing",
home=profile_home,
)
assert gateway_store._dir == cli_dir
def test_default_profile_store_is_global_store(self, tmp_path, monkeypatch):
"""Multiplexing must not invent a ``profiles/default`` store."""
from hermes_constants import get_hermes_dir
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expected = get_hermes_dir(
"platforms/pairing",
"pairing",
home=tmp_path,
)
with patch("gateway.pairing.PAIRING_DIR", expected):
assert PairingStore(profile="default")._dir == PairingStore()._dir
def test_profile_store_merges_split_pairing_layouts(
self, tmp_path, monkeypatch
):
"""Existing approvals survive either profile directory layout."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
profile_home = tmp_path / "profiles" / "coder"
legacy_dir = profile_home / "pairing"
consolidated_dir = profile_home / "platforms" / "pairing"
legacy_dir.mkdir(parents=True)
consolidated_dir.mkdir(parents=True)
(legacy_dir / "telegram-approved.json").write_text(
'{"legacy-user": {"user_name": "Legacy"}}',
encoding="utf-8",
)
(consolidated_dir / "telegram-approved.json").write_text(
'{"new-user": {"user_name": "New"}}',
encoding="utf-8",
)
store = PairingStore(profile="coder")
assert store.is_approved("telegram", "legacy-user")
assert store.is_approved("telegram", "new-user")
def test_profile_approval_does_not_leak_to_global(self, tmp_path, monkeypatch):
"""Approving in a profile-scoped store must not appear in the global
store and vice versa. This is the whole point of the fix."""
from hermes_constants import get_hermes_home
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
global_store = PairingStore()
profile_store = PairingStore(profile="yangyang")
@ -549,15 +662,19 @@ class TestProfileScopedStorage:
def test_profile_uses_distinct_rate_limit_file(self, tmp_path, monkeypatch):
"""Rate-limit state is per-profile, not shared globally — otherwise
one profile's flood would lock out the other profile's users."""
from hermes_constants import get_hermes_home
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
global_store = PairingStore()
profile_store = PairingStore(profile="yangyang")
assert global_store._rate_limit_path() == tmp_path / "_rate_limits.json"
assert profile_store._rate_limit_path() == (
tmp_path / "profiles" / "yangyang" / "pairing" / "_rate_limits.json"
tmp_path
/ "profiles"
/ "yangyang"
/ "platforms"
/ "pairing"
/ "_rate_limits.json"
)

View file

@ -41,7 +41,13 @@ def _clear_auth_env(monkeypatch) -> None:
monkeypatch.delenv(key, raising=False)
def _make_event(platform: Platform, user_id: str, chat_id: str) -> MessageEvent:
def _make_event(
platform: Platform,
user_id: str,
chat_id: str,
*,
profile: str | None = None,
) -> MessageEvent:
return MessageEvent(
text="hello",
message_id="m1",
@ -51,6 +57,7 @@ def _make_event(platform: Platform, user_id: str, chat_id: str) -> MessageEvent:
chat_id=chat_id,
user_name="tester",
chat_type="dm",
profile=profile,
),
)

View file

@ -273,6 +273,49 @@ class TestPairingEndpoints:
assert r.json()["user"]["user_id"] == "user1"
assert self.client.get("/api/pairing").json()["pending"] == []
def test_pairing_is_isolated_per_profile(self):
"""A named profile's approvals must land in the store its own gateway reads.
The gateway keeps one PairingStore per served profile, so an approval
written to the global store grants access the running gateway never
consults the user stays locked out with the dashboard showing them
as approved.
"""
from gateway.pairing import PairingStore
from hermes_constants import get_hermes_home
(get_hermes_home() / "profiles" / "work").mkdir(parents=True, exist_ok=True)
PairingStore().generate_code("telegram", "global-1", "GlobalGuy")
PairingStore(profile="work").generate_code("telegram", "work-1", "WorkGal")
listed = self.client.get("/api/pairing?profile=work").json()["pending"]
assert [row["user_id"] for row in listed] == ["work-1"]
r = self.client.post(
"/api/pairing/approve",
json={
"platform": "telegram",
"request_id": listed[0]["request_id"],
"profile": "work",
},
)
assert r.status_code == 200
# The grant is visible to the profile's own store — what the gateway reads.
assert PairingStore(profile="work").is_approved("telegram", "work-1") is True
# ...and it never leaked into the global store, whose own pending row
# is still waiting. (Asserted against this user rather than an empty
# list: the module-level PAIRING_DIR is bound at import, so the global
# store carries whatever earlier cases in this class approved.)
global_view = self.client.get("/api/pairing").json()
assert PairingStore().is_approved("telegram", "work-1") is False
assert "work-1" not in [row["user_id"] for row in global_view["approved"]]
assert "global-1" in [row["user_id"] for row in global_view["pending"]]
def test_unknown_profile_is_rejected(self):
assert self.client.get("/api/pairing?profile=ghost").status_code == 404
class TestWebhookEndpoints:
@pytest.fixture(autouse=True)

View file

@ -72,6 +72,54 @@ def test_gateway_state_move_broadcasts_platforms_changed(watcher_home):
assert ("platforms.changed", {}) in events
def test_pending_pairing_request_broadcasts_pairing_changed(watcher_home):
"""A new pending request must reach the Messaging page on its own signal.
The messaging gateway writes the pending code from a different process, and
it moves nothing in gateway_state.json so platforms.changed cannot stand
in for this. Without a dedicated signal the badge stays invisible until an
unrelated connect/disconnect happens to fire.
"""
home, events = watcher_home
store = home / "platforms" / "pairing"
store.mkdir(parents=True)
server._broadcast_watched_changes(now=0.0)
(store / "telegram-pending.json").write_text('{"abc": {"user_id": "1"}}')
server._broadcast_watched_changes(now=10.0)
assert ("pairing.changed", {}) in events
assert ("platforms.changed", {}) not in events
def test_pairing_signal_follows_a_profile_store(watcher_home):
"""Each profile keeps its own whitelist, and the page can be scoped to any."""
home, events = watcher_home
store = home / "profiles" / "work" / "platforms" / "pairing"
store.mkdir(parents=True)
server._broadcast_watched_changes(now=0.0)
(store / "telegram-approved.json").write_text('{"u1": {"user_id": "u1"}}')
server._broadcast_watched_changes(now=10.0)
assert ("pairing.changed", {}) in events
def test_rate_limit_churn_does_not_broadcast_pairing_changed(watcher_home):
"""_rate_limits.json moves on every unauthorized DM, including ones that
produce no new row signalling on it would refetch for nothing."""
home, events = watcher_home
store = home / "platforms" / "pairing"
store.mkdir(parents=True)
(store / "telegram-pending.json").write_text("{}")
server._broadcast_watched_changes(now=0.0)
(store / "_rate_limits.json").write_text('{"telegram:1": 123}')
server._broadcast_watched_changes(now=10.0)
assert ("pairing.changed", {}) not in events
def test_sessions_floor_coalesces_burst_but_keeps_trailing_edge(watcher_home):
home, events = watcher_home
server._broadcast_watched_changes(now=0.0)

View file

@ -3082,6 +3082,46 @@ def _platforms_sig():
return None
def _pairing_sig():
"""Newest mtime across every profile's pairing store.
An unknown DMer's pending code is written by the messaging gateway — a
DIFFERENT process that never touches this gateway's transports — so the
files are the only shared signal. ``platforms.changed`` cannot stand in
for this: it tracks connect/disconnect/health, and a pairing request
moves nothing in gateway_state.json.
"""
home = _watcher_home()
sig = None
# Global store (legacy `pairing/` and consolidated `platforms/pairing/`)
# plus every named profile's own — the Messaging page can be scoped to any
# of them, and a request landing in a profile store must still tick.
roots = [home / "pairing", home / "platforms" / "pairing"]
try:
for profile_dir in (home / "profiles").iterdir():
roots.append(profile_dir / "pairing")
roots.append(profile_dir / "platforms" / "pairing")
except OSError:
pass
for root in roots:
try:
entries = list(root.iterdir())
except OSError:
continue
for entry in entries:
# Only the pending/approved ledgers — _rate_limits.json moves on
# every unauthorized DM, including ones that produce no new row.
if not entry.name.endswith(("-pending.json", "-approved.json")):
continue
try:
mtime = entry.stat().st_mtime_ns
except OSError:
continue
sig = mtime if sig is None else max(sig, mtime)
return sig
# Watched change signals: event → (check interval, signature fn, payload fn).
# Signatures are stat/dict-lookup cheap, same bar as the skin watcher; the
# check interval keeps the pricier probes (pet resolves the active sheet off
@ -3091,6 +3131,7 @@ _CHANGE_WATCHES: dict[str, tuple[float, Any, Any]] = {
"cron.changed": (1.0, _cron_sig, lambda: {}),
"sessions.changed": (0.5, _sessions_sig, lambda: {}),
"platforms.changed": (2.0, _platforms_sig, lambda: {}),
"pairing.changed": (2.0, _pairing_sig, lambda: {}),
}
# state.db moves on every message append during a streaming turn, and the

View file

@ -61,8 +61,8 @@ export function getManagementProfile(): string {
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
// pairing, cron (which has its own per-job profile params), profiles
// themselves — is machine-global or self-scoped and must NOT be rewritten.
// cron (which has its own per-job profile params), profiles themselves — is
// machine-global or self-scoped and must NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/status",
"/api/gateway",
@ -80,6 +80,10 @@ const PROFILE_SCOPED_PREFIXES = [
"/api/model/auxiliary",
"/api/model/moa",
"/api/model/options",
// A named profile keeps its own pairing whitelist, and its gateway only
// consults that one — approving into the global store would grant access
// the running gateway never sees.
"/api/pairing",
];
function withManagementProfile(url: string): string {
@ -1090,18 +1094,29 @@ export const api = {
),
// ── Admin: Pairing ──────────────────────────────────────────────────
// The mutating endpoints read the profile off the BODY, so the query-param
// rewrite in withManagementProfile doesn't reach them — send it explicitly
// or an approval lands in the wrong profile's whitelist.
getPairing: () => fetchJSON<PairingResponse>("/api/pairing"),
approvePairing: (platform: string, request_id: string) =>
fetchJSON<{ ok: boolean; user: PairingUser }>("/api/pairing/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, request_id }),
body: JSON.stringify({
platform,
request_id,
profile: getManagementProfile() || undefined,
}),
}),
revokePairing: (platform: string, user_id: string) =>
fetchJSON<{ ok: boolean }>("/api/pairing/revoke", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, user_id }),
body: JSON.stringify({
platform,
user_id,
profile: getManagementProfile() || undefined,
}),
}),
clearPendingPairing: () =>
fetchJSON<{ ok: boolean; cleared: number }>("/api/pairing/clear-pending", {