fix(desktop): require exact owners in registry topology

This commit is contained in:
Deus
2026-08-25 18:02:19 +02:00
committed by Teknium
parent 07b87f1470
commit 962b308be1
6 changed files with 113 additions and 7 deletions
@@ -0,0 +1,11 @@
import { atom } from 'nanostores'
import type { DesktopConnectionsRegistry } from '@/global'
/** Null only for the legacy profile-only Desktop topology. Once Electron has
* published a registry, profile names are source-local and are not owners. */
export const $connectionsRegistry = atom<DesktopConnectionsRegistry | null>(null)
export function hasRegistryTopology(): boolean {
return $connectionsRegistry.get() !== null
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { atom, computed } from 'nanostores'
import type { DesktopConnectionsRegistry } from '@/global'
import { persistStringRecord, storedStringRecord } from '@/lib/storage'
import { isTimeoutError, withTimeout } from '@/lib/with-timeout'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import {
beginGatewaySwitch,
endGatewaySwitch,
@@ -32,7 +33,7 @@ const SWITCH_DIAL_TIMEOUT_MS = 20_000
const SWITCH_COMMIT_TIMEOUT_MS = 20_000
const SWITCH_REMEMBER_TIMEOUT_MS = 5_000
export const $connectionsRegistry = atom<DesktopConnectionsRegistry | null>(null)
export { $connectionsRegistry } from '@/store/connection-registry-state'
// Use only the resolved descriptor identity Electron publishes. `primary`
// means the registry default, not necessarily the source this window is using;
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $connectionsRegistry } from './connections'
import { $profiles } from './profile'
import {
ambientGatewayOwnsEverySession,
assertSessionOwnerResolved,
sessionOwnerIsKnown
} from './session-owner-resolution'
const registry = (...ids: string[]) =>
({
connections: ids.map(id => ({ id })),
lastUsed: ids[0] ?? null,
launchMode: 'primary',
primary: ids[0] ?? null
}) as never
beforeEach(() => {
$connectionsRegistry.set(null)
$profiles.set([])
})
describe('session owner topology', () => {
it('fails closed on an unknown owner in registry topology while preserving legacy profile routes', () => {
// A connection registry means the ambient gateway is never provably the
// sole backend, even with one profile listed: an unknown owner fails
// closed. A bare profile still names a backend — the legacy profile door
// (a pick on the primary / explicit `local` source) mints sessions owned
// by that profile's pool socket in every topology.
$connectionsRegistry.set(registry('local'))
$profiles.set([{ name: 'default' }] as never)
expect(sessionOwnerIsKnown('default')).toBe(true)
expect(ambientGatewayOwnsEverySession()).toBe(false)
expect(() =>
assertSessionOwnerResolved('default', { method: 'session.resume', sessionId: 'registry-profile' })
).not.toThrow()
expect(() => assertSessionOwnerResolved(null, { method: 'session.resume', sessionId: 'unknown-owner' })).toThrow(
/could not be resolved/i
)
$connectionsRegistry.set(registry('local', 'homelab'))
expect(sessionOwnerIsKnown(null)).toBe(false)
expect(ambientGatewayOwnsEverySession()).toBe(false)
expect(() => assertSessionOwnerResolved(null, { method: 'session.resume', sessionId: 'unknown-owner' })).toThrow(
/could not be resolved/i
)
$connectionsRegistry.set(null)
expect(sessionOwnerIsKnown('default')).toBe(true)
expect(ambientGatewayOwnsEverySession()).toBe(true)
expect(() =>
assertSessionOwnerResolved(null, { method: 'session.resume', sessionId: 'legacy-single-profile' })
).not.toThrow()
$profiles.set([{ name: 'default' }, { name: 'loki' }] as never)
expect(sessionOwnerIsKnown('loki')).toBe(true)
expect(ambientGatewayOwnsEverySession()).toBe(false)
expect(() =>
assertSessionOwnerResolved('loki', { method: 'session.resume', sessionId: 'legacy-profile-owner' })
).not.toThrow()
})
})
@@ -13,12 +13,12 @@
* session is left untouched for the next correctly-routed attempt.
*
* The ONE case where the ambient gateway is not a fallback but the owner by
* construction: no registry source is live (legacy v1 primary) AND at most
* construction: no registry topology exists (legacy v1 primary) AND at most
* one profile exists — a single backend serves every session, so there is
* nothing to misroute to. Older single-profile backends omit `profile` on
* their rows entirely; those users keep working unchanged.
*/
import { activeGatewayConnectionId } from './gateway'
import { hasRegistryTopology } from './connection-registry-state'
import { $profiles } from './profile'
import { isSessionOwnerRoute, type SessionOwnerScope } from './session-request-router'
@@ -44,13 +44,19 @@ export function isSessionOwnerResolutionError(error: unknown): error is SessionO
}
/** True when the ambient gateway is provably the only backend any session
* can live on (legacy single-backend Desktop): no registry source is active
* and there is at most one profile. Everything else has somewhere to misroute. */
* can live on (legacy single-backend Desktop): Electron has published no
* connection registry and there is at most one profile. The active route is
* presentation state; a null active connection does not prove sole topology. */
export function ambientGatewayOwnsEverySession(): boolean {
return activeGatewayConnectionId() === null && $profiles.get().length <= 1
return !hasRegistryTopology() && $profiles.get().length <= 1
}
/** True when `owner` names a backend (an exact route or a profile). */
/** True when `owner` names a backend: an exact connection route, or a bare
* profile. A bare profile stays an owner in registry topology too — a profile
* pick on the primary or the explicit `local` source takes the legacy
* profile-only door (store/profile activateOnCurrentSource, so a per-profile
* remote override resolves), and a session minted there is owned by that
* profile's pool socket, which requestForSessionProfile dials by name. */
export function sessionOwnerIsKnown(owner: SessionOwnerScope): boolean {
if (isSessionOwnerRoute(owner)) {
return Boolean(owner.connectionId.trim())
@@ -78,6 +78,7 @@ const {
} = await import('./gateway')
const { requestForSessionProfile, sessionRpcNeedsProfileRoute } = await import('./session-request-router')
const { $connectionsRegistry } = await import('./connection-registry-state')
function installDesktop(): void {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
@@ -103,6 +104,7 @@ function makePrimary() {
beforeEach(() => {
secondaryGateways.length = 0
promptAckStatus = null
$connectionsRegistry.set(null)
configureGatewayRegistry({ onEvent: vi.fn() })
closeSecondaryGateways()
})
@@ -177,6 +179,26 @@ describe('sessionRpcNeedsProfileRoute', () => {
})
describe('requestForSessionProfile', () => {
it('keeps routing a bare profile owner through its legacy profile pool when a connection registry exists', async () => {
// A profile pick on the primary or the explicit `local` source takes the
// legacy profile-only door (store/profile activateOnCurrentSource), so a
// session minted there is owned by that profile's pool socket in every
// topology — a registry does not turn the bare profile into a guess.
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
installDesktop()
$connectionsRegistry.set({ connections: [{ id: 'local' }] } as never)
const ambient = vi.fn(async () => ({ ambient: true }))
await expect(
requestForSessionProfile('loki', ambient as never, 'session.resume', { session_id: 'stored-a' })
).resolves.toEqual({ method: 'session.resume', params: { session_id: 'stored-a' } })
expect(window.hermesDesktop!.getConnection).toHaveBeenCalledWith('loki')
expect(secondaryGateways).toHaveLength(1)
expect(primary.request).not.toHaveBeenCalled()
expect(ambient).not.toHaveBeenCalled()
})
it('keeps concurrent same-name requests pinned while foreground activation changes', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
@@ -131,6 +131,8 @@ async function withRoutedTurnLease<T>(
*
* A KNOWN owner (route or profile name) always needs its own socket: the
* session belongs to that profile regardless of what the window is showing.
* A bare profile names the legacy profile door's pool socket in every
* topology (a pick on the primary / explicit `local` source dials it).
* There is deliberately NO comparison against the active profile — "active" is
* presentation state, never a routing authority. Only a null/empty owner (a
* fresh draft with no session, or global chrome) routes ambient.