fix(desktop): decide image bytes vs path from the session owner's own backend

isSessionRemote only trusted route.mode. A connection-tagged session row
(the unified Sessions list) and a bare-profile owner carry no mode, so the
check fell back to the window's ambient connection. A local-ambient window
then sent a client path via image.attach to a remote backend that can't
read it ("image not found: /Users/...").

Resolve the owner's mode in this order: route.mode, the registry
connection's kind, then the mode of the socket already dialed for that
owner (the primary for its profile, or the owner's own secondary). Fall
back to ambient only when none of those is known. registryConnectionKind
moves into connection-registry-state so profile.ts and session-states.ts
share one lookup.
This commit is contained in:
Hermes Agent
2026-09-24 23:42:58 -05:00
committed by brooklyn!
parent c19a35d8c9
commit 99a161531c
5 changed files with 101 additions and 15 deletions

View File

@@ -1,11 +1,15 @@
import { atom } from 'nanostores'
import type { DesktopConnectionsRegistry } from '@/global'
import type { DesktopConnectionKind, 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 registryConnectionKind(connectionId: string): DesktopConnectionKind | undefined {
return $connectionsRegistry.get()?.connections.find(entry => entry.id === connectionId)?.kind
}
export function hasRegistryTopology(): boolean {
// The bridge exists before its asynchronous cache load. Treat that window
// (and a failed list IPC) as registry topology so owner routing fails closed;

View File

@@ -411,6 +411,24 @@ export function setPrimaryGatewayConnectionId(
}
}
/**
* Mode of the socket this window already dialed for `(connectionId, profile)`,
* following gatewayForProfile's precedence: the primary socket when it serves
* that profile, else a secondary's own descriptor. Null until one is dialed.
*/
export function dialedGatewayModeFor(connectionId: null | string, profile: string): 'local' | 'remote' | null {
const id = String(connectionId ?? '').trim() || null
const key = normKey(profile)
if (key === g.primaryProfile && (!id || id === g.primaryConnectionId) && g.primaryConnectionMode) {
return g.primaryConnectionMode
}
const mode = g.secondaries.get(registryBackendScopeKey(id, key))?.connection?.mode
return mode === 'local' || mode === 'remote' ? mode : null
}
/** Publish the registry source owned by the window primary socket. */
export function setPrimaryGatewayConnection(connection: Pick<HermesConnection, 'connectionId' | 'mode'> | null): void {
setPrimaryGatewayConnectionId(connection?.connectionId, connection?.mode)

View File

@@ -15,7 +15,7 @@ import {
storedStringRecord
} from '@/lib/storage'
import { withTimeout } from '@/lib/with-timeout'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import { registryConnectionKind } from '@/store/connection-registry-state'
import {
$gateway,
activeGatewayConnectionId,
@@ -489,10 +489,6 @@ const PREWARM_MIN_INTERVAL_MS = 60_000
const prewarmedAt = new Map<string, number>()
function registryConnectionKind(connectionId: string): string | undefined {
return $connectionsRegistry.get()?.connections.find(entry => entry.id === connectionId)?.kind
}
export function prewarmProfileBackend(name: string, connectionId: null | string = null): void {
const key = normalizeProfileKey(name)
const connection = (connectionId ?? '').trim() || null

View File

@@ -10,6 +10,8 @@ import {
setWorkspaceScope,
workspaceScopeKey
} from '@/components/pane-shell/workspace-scope'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import { setPrimaryGateway, setPrimaryGatewayConnection } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import {
$activeSessionId,
@@ -1466,4 +1468,48 @@ describe('isSessionRemote (#94640)', () => {
expect(isSessionRemote('stored-2')).toBe(true)
})
it("reads a connection-tagged row's mode from the registry, not the ambient connection (#120730)", () => {
// A row from the unified Sessions list carries connection_id but no mode;
// its owner is { connectionId, profile }. The byte-vs-path decision must
// come from that connection's registry kind.
$connectionsRegistry.set({
version: 1,
primary: 'local',
secureTokenStorage: true,
connections: [
{ id: 'local', kind: 'local', label: 'This Mac', tokenSet: false, tokenPreview: null },
{ id: 'vps', kind: 'ssh', label: 'VPS', tokenSet: false, tokenPreview: null }
]
})
setSessions([
{ id: 'stored-ssh', profile: 'default', connection_id: 'vps' } as never,
{ id: 'stored-local', profile: 'default', connection_id: 'local' } as never
])
try {
$connection.set({ mode: 'local' } as never)
expect(isSessionRemote('stored-ssh')).toBe(true)
$connection.set({ mode: 'remote' } as never)
expect(isSessionRemote('stored-local')).toBe(false)
} finally {
$connectionsRegistry.set(null)
}
})
it('reads a bare-profile owner from the socket that serves it, not the ambient connection (#120730)', () => {
// The primary socket serving 'default' is a remote backend while the window
// shows a local source: the primary's own mode decides.
setPrimaryGateway({} as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'vps', mode: 'remote' })
$connection.set({ mode: 'local' } as never)
setSessions([{ id: 'stored-primary', profile: 'default' } as never])
try {
expect(isSessionRemote('stored-primary')).toBe(true)
} finally {
setPrimaryGateway(null)
}
})
})

View File

@@ -39,6 +39,8 @@ import { readJson, writeJson } from '@/lib/storage'
import type { SessionInfo } from '@/types/hermes'
import { dropStatusDrawersForProfile, migrateStatusDrawersForProfile } from './composer-status-drawer'
import { registryConnectionKind } from './connection-registry-state'
import { dialedGatewayModeFor } from './gateway'
import { dropPreviewTabsForProfile, migratePreviewTabsForProfile, setPreviewScope } from './preview'
import { dropPreviewArtifactsForProfile, migratePreviewArtifactsForProfile } from './preview-status'
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
@@ -1379,6 +1381,31 @@ function syncPreviewScope() {
$activeSessionId.subscribe(syncPreviewScope)
syncPreviewScope()
/** The mode of the backend that serves `owner`: the route's own `mode`, else
* its registry connection's kind, else the socket already dialed for it (a
* bare profile rides the primary or its own pool secondary). Null = unknown. */
function ownerConnectionMode(owner: SessionOwnerScope): 'local' | 'remote' | null {
if (!owner) {
return null
}
if (typeof owner === 'string') {
return dialedGatewayModeFor(null, owner)
}
if (owner.mode) {
return owner.mode
}
const kind = registryConnectionKind(owner.connectionId)
if (kind) {
return kind === 'local' ? 'local' : 'remote'
}
return dialedGatewayModeFor(owner.connectionId, owner.profile)
}
/**
* Whether the connection that OWNS `sessionId` is remote — never the ambient
* `$connection`. A session tied to a registered secondary connection (Bot
@@ -1386,18 +1413,13 @@ syncPreviewScope()
* window currently shows; its RPCs already route to their own owner via
* `requestForSessionProfile`, but a caller that instead reads ambient mode to
* decide image.attach vs image.attach_bytes ships a client-local path to a
* remote backend that can't resolve it (#94640). A bare profile name (no
* connectionId) is a pool profile of the ambient connection, so ambient mode
* still applies there.
* remote backend that can't resolve it (#94640, #120730). Only an owner whose
* backend is still unknown falls back to ambient mode.
*/
export function isSessionRemote(sessionId: null | string | undefined): boolean {
const owner = knownOwnerForSession(sessionId)
const mode = ownerConnectionMode(knownOwnerForSession(sessionId)) ?? $connection.get()?.mode
if (owner && typeof owner === 'object' && owner.mode) {
return owner.mode === 'remote'
}
return $connection.get()?.mode === 'remote'
return mode === 'remote'
}
/**