fix(desktop): require exact owners in registry topology
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user