fix(desktop): scope remote-profile session list reads and stop relabeling rows (#64999)

Per-profile remote overrides stripped ?profile= before reading the
remote's /api/sessions and then stamped every row with the local
connection-scope name. Against a multi-profile backend (one remote URL
shared by several Desktop profiles) the unscoped read returned the
backend's launch-profile sessions, relabeled as whichever local scope
asked.

fetchRemoteProfileSessions now names the profile scope on the wire
(the managed-SSH remoteProfile alias when configured, else the Desktop
profile name). A remote that rejects the scope (400/404 — the legacy
single-launch-profile shape) falls back to the unscoped read exactly as
before; auth/transport/5xx failures still propagate.

The list splice no longer manufactures labels: the remote's own
profile stamp is authoritative, and the legacy label only backfills
rows an older remote returned unowned.
This commit is contained in:
Hermes Agent
2026-09-25 13:58:21 -05:00
committed by brooklyn!
parent f9b991f918
commit 5ba3dfceab
2 changed files with 329 additions and 13 deletions

View File

@@ -10,8 +10,11 @@ import {
fetchRemoteProfileSessions,
findRemoteOwnerProfileForSession,
mergeProfileSessionWindow,
pathWithRemoteOwnerScope,
remoteProfileQueryScope,
spliceRegistrySessionRows,
tagRegistrySessionResponse
tagRegistrySessionResponse,
tagRemoteSessionRows
} from './profile-session-routing'
test('remote sidebar slices all follow the selected profile', () => {
@@ -138,9 +141,9 @@ test('remote session reads split oversized sidebar windows into API-safe pages',
)
assert.deepEqual(calls, [
{ profile: 'remote-work', path: '/api/sessions?limit=100&offset=0&order=updated' },
{ profile: 'remote-work', path: '/api/sessions?limit=100&offset=100&order=updated' },
{ profile: 'remote-work', path: '/api/sessions?limit=50&offset=200&order=updated' }
{ profile: 'remote-work', path: '/api/sessions?limit=100&offset=0&order=updated&profile=remote-work' },
{ profile: 'remote-work', path: '/api/sessions?limit=100&offset=100&order=updated&profile=remote-work' },
{ profile: 'remote-work', path: '/api/sessions?limit=50&offset=200&order=updated&profile=remote-work' }
])
assert.equal(result.sessions.length, 250)
assert.equal(result.total, 250)
@@ -182,7 +185,10 @@ test('remote paging preserves offsets and deduplicates pinned backfill rows', as
}
)
assert.deepEqual(calls, ['/api/sessions?limit=100&offset=80', '/api/sessions?limit=50&offset=180'])
assert.deepEqual(calls, [
'/api/sessions?limit=100&offset=80&profile=remote-work',
'/api/sessions?limit=50&offset=180&profile=remote-work'
])
assert.deepEqual(
result.sessions.map(row => (row as { id: string }).id),
[...rows.slice(80, 230).map(row => row.id), 'session-20']
@@ -214,9 +220,9 @@ test('remote paging treats malformed totals as unknown instead of truncating the
)
assert.deepEqual(calls, [
'/api/sessions?limit=100&offset=0',
'/api/sessions?limit=100&offset=100',
'/api/sessions?limit=100&offset=200'
'/api/sessions?limit=100&offset=0&profile=remote-work',
'/api/sessions?limit=100&offset=100&profile=remote-work',
'/api/sessions?limit=100&offset=200&profile=remote-work'
])
assert.equal(result.sessions.length, 250)
assert.equal(result.total, 250)
@@ -250,7 +256,9 @@ test('remote session reads keep small requests on one call', async () => {
}
)
assert.deepEqual(calls, [{ profile: 'remote-work', path: '/api/sessions?limit=20&offset=0' }])
assert.deepEqual(calls, [
{ profile: 'remote-work', path: '/api/sessions?limit=20&offset=0&profile=remote-work' }
])
assert.equal(result, expected)
})
@@ -486,3 +494,189 @@ test('remote owner lookup returns null when no remote lists the id or remotes fa
assert.equal(noRemotes, null)
})
// #64999: two Desktop profile scopes can point at the SAME multi-profile
// remote backend. The list read must name the requested profile scope so the
// backend opens that profile's state.db — an unscoped read returns the
// backend's launch-profile rows, and the old unconditional relabel stamped
// them as whichever local scope happened to ask.
test('remote session reads carry the profile scope against a multi-profile backend', async () => {
const calls: Array<{ profile: string | null; path: string }> = []
await fetchRemoteProfileSessions(
'wife',
new URLSearchParams({ profile: 'wife', limit: '20', offset: '0' }),
async (profile, path) => {
calls.push({ profile, path })
return { sessions: [{ id: 's-1', profile: 'wife' }], total: 1, limit: 20, offset: 0 }
}
)
assert.deepEqual(calls, [
{ profile: 'wife', path: '/api/sessions?limit=20&offset=0&profile=wife' }
])
})
// The same read for the OTHER scope sharing the backend names ITS scope — the
// backend's launch profile (say `dad`) is never read for a `wife` request.
test('two scopes sharing one multi-profile backend each read their own profile', async () => {
const seenProfiles: string[] = []
for (const scope of ['wife', 'dad']) {
const result = await fetchRemoteProfileSessions(
scope,
new URLSearchParams({ profile: scope, limit: '20', offset: '0' }),
async (_profile, path) => {
const url = new URL(path, 'http://desktop.test')
const servedProfile = url.searchParams.get('profile') || 'launch-profile'
seenProfiles.push(servedProfile)
return { sessions: [{ id: `s-${servedProfile}`, profile: servedProfile }], total: 1 }
}
)
assert.equal((result.sessions[0] as { profile: string }).profile, scope)
}
assert.deepEqual(seenProfiles, ['wife', 'dad'])
})
// A remote that rejects the scope (400/404: profile does not exist there) is
// the legacy single-launch-profile shape — fall back to its own database.
test('remote session reads fall back to the unscoped list when the remote rejects the profile scope', async () => {
const calls: string[] = []
const result = await fetchRemoteProfileSessions(
'wife',
new URLSearchParams({ profile: 'wife', limit: '20', offset: '0' }),
async (_profile, path) => {
calls.push(path)
if (path.includes('profile=wife')) {
const error: any = new Error('404: Profile \'wife\' does not exist.')
error.statusCode = 404
throw error
}
return { sessions: [{ id: 's-1' }], total: 1 }
}
)
assert.deepEqual(calls, [
'/api/sessions?limit=20&offset=0&profile=wife',
'/api/sessions?limit=20&offset=0'
])
assert.equal((result.sessions[0] as { id: string }).id, 's-1')
})
// Auth/transport/5xx failures are real errors — the unscoped fallback must
// not swallow them.
test('remote session reads propagate non-scope errors without the fallback', async () => {
const calls: string[] = []
await assert.rejects(
fetchRemoteProfileSessions(
'wife',
new URLSearchParams({ profile: 'wife', limit: '20', offset: '0' }),
async (_profile, path) => {
calls.push(path)
const error: any = new Error('503: Service Unavailable')
error.statusCode = 503
throw error
}
),
/503/
)
assert.deepEqual(calls, ['/api/sessions?limit=20&offset=0&profile=wife'])
})
// A managed-SSH override can map the Desktop label to the remote's own
// profile name (remoteProfile); the scope sent on the wire is the alias.
test('remote session reads use the managed-SSH remoteProfile alias as the scope', async () => {
const calls: string[] = []
await fetchRemoteProfileSessions(
'mara',
new URLSearchParams({ profile: 'mara', limit: '20', offset: '0' }),
async (_profile, path) => {
calls.push(path)
return { sessions: [], total: 0 }
},
{ remoteProfileAlias: 'dixie' }
)
assert.deepEqual(calls, ['/api/sessions?limit=20&offset=0&profile=dixie'])
})
// Every concrete scope — `default` included — is named on the wire: a
// multi-profile backend's launch profile is not necessarily `default`, so an
// unscoped read could relaunch the bug for the default scope too.
test('remote session reads for the default scope name it explicitly', async () => {
const calls: string[] = []
await fetchRemoteProfileSessions(
'default',
new URLSearchParams({ profile: 'default', limit: '20', offset: '0' }),
async (_profile, path) => {
calls.push(path)
return { sessions: [], total: 0 }
}
)
assert.deepEqual(calls, ['/api/sessions?limit=20&offset=0&profile=default'])
})
// The remote's own profile stamp is authoritative; the splice must only
// backfill unowned rows instead of relabeling every one (#64999).
test('remote list rows keep the remote profile stamp instead of the desktop scope label', () => {
const rows: Array<Record<string, unknown>> = [
{ id: 'w-1', profile: 'wife' },
{ id: 'd-1', profile: 'dad' },
{ id: 'legacy-1' },
{ id: 'legacy-2', profile: '' }
]
tagRemoteSessionRows(rows as unknown[], 'wife')
assert.equal(rows[0].profile, 'wife')
assert.equal(rows[0].is_default_profile, false)
assert.equal(rows[1].profile, 'dad') // NOT relabeled to wife
assert.equal(rows[1].is_default_profile, undefined)
assert.equal(rows[2].profile, 'wife') // unowned row backfilled
assert.equal(rows[2].is_default_profile, false)
assert.equal(rows[3].profile, 'wife')
})
// A per-session read on a shared multi-profile backend must open the owning
// profile's state.db — the same scope the list read sends.
test('per-session remote reads carry the owner profile scope', () => {
assert.equal(
pathWithRemoteOwnerScope('/api/sessions/s-1?limit=50', 'wife'),
'/api/sessions/s-1?limit=50&profile=wife'
)
assert.equal(
pathWithRemoteOwnerScope('/api/sessions/s-1', 'wife'),
'/api/sessions/s-1?profile=wife'
)
// Existing pagination params survive the scope.
assert.equal(
pathWithRemoteOwnerScope('/api/sessions/s-1/messages?limit=100&offset=200', 'wife'),
'/api/sessions/s-1/messages?limit=100&offset=200&profile=wife'
)
// A legacy single-profile scope keeps the path bare.
assert.equal(pathWithRemoteOwnerScope('/api/sessions/s-1', ''), '/api/sessions/s-1')
})
// The scope helper itself: alias wins, every concrete scope is named.
test('remoteProfileQueryScope resolves the wire scope for a profile override', () => {
assert.equal(remoteProfileQueryScope('wife'), 'wife')
assert.equal(remoteProfileQueryScope('mara', 'dixie'), 'dixie')
assert.equal(remoteProfileQueryScope('mara', 'default'), 'mara')
assert.equal(remoteProfileQueryScope('default'), 'default')
assert.equal(remoteProfileQueryScope('default', 'dixie'), 'dixie')
assert.equal(remoteProfileQueryScope(''), '')
})

View File

@@ -4,6 +4,21 @@ interface SessionListResponse {
[key: string]: unknown
}
/** HTTP status an error carries (the REST helpers stamp `err.statusCode`), NaN when none. */
function httpStatusOf(error: unknown): number {
return Number(error && typeof error === 'object' ? (error as { statusCode?: unknown }).statusCode : NaN)
}
/** True when a remote rejected a profile-scoped read because it cannot serve
* that scope: 400 (invalid profile param) or 404 (profile does not exist).
* Auth (401/403), transport, and 5xx failures are real errors — retrying
* those would just relabel the failure. */
export function isRemoteProfileScopeError(error: unknown): boolean {
const status = httpStatusOf(error)
return status === 400 || status === 404
}
export interface ProfileSessionsResponse extends SessionListResponse {
profile_totals: Record<string, number>
}
@@ -367,13 +382,120 @@ export function spliceRegistrySessionRows(
return { added }
}
/**
* The remote-profile query scope for a per-profile override read: the remote
* alias when the override is a managed SSH connection with a configured
* `remoteProfile` (the remote knows THAT name, not the Desktop label), else
* the Desktop profile name itself. Empty only for an empty profile — every
* concrete scope, `default` included, is named on the wire so a multi-profile
* backend opens that profile's state.db instead of its launch profile's.
*/
export function remoteProfileQueryScope(profile: string, remoteProfileAlias?: null | string): string {
const configured = String(remoteProfileAlias || '').trim()
if (configured && configured !== 'default') {
return configured
}
return String(profile || '').trim()
}
/** Options for {@link fetchRemoteProfileSessions}. */
export interface RemoteProfileSessionsOptions {
/** Managed-SSH `remoteProfile` mapping, when the remote knows the profile
* under a different name than the Desktop label. */
remoteProfileAlias?: null | string
}
/**
* #64999: stamp a remote session list's rows WITHOUT manufacturing labels.
* The remote's own `profile` stamp is authoritative — overwriting it with the
* Desktop connection-scope name relabeled rows from the backend's other
* profiles (one remote URL shared by `wife` and `dad` scopes showed the launch
* profile's sessions under both). The legacy label only backfills rows an
* older remote returned unowned.
*
* Mutates and returns `rows` in place, mirroring the old splice behavior.
*/
export function tagRemoteSessionRows(rows: unknown[], scope: string): unknown[] {
for (const row of rows) {
if (!row || typeof row !== 'object') {
continue
}
const session = row as Record<string, unknown>
const owned = typeof session.profile === 'string' && session.profile.trim() !== ''
if (!owned) {
session.profile = scope
}
if (session.profile === scope) {
session.is_default_profile = false
}
}
return rows
}
/**
* #64999: a per-session read/mutation against a per-profile override must be
* scoped for a multi-profile remote (an unscoped /api/sessions/{id} opens the
* backend's launch-profile state.db, so a resume 4007s even though the row
* exists — under its real owner). Returns the path with the owner scope
* applied; a legacy single-profile scope (`''`) keeps the path bare.
*/
export function pathWithRemoteOwnerScope(path: string, scope: string): string {
const scoped = String(scope || '').trim()
if (!scoped) {
return path
}
const url = new URL(path, 'http://hermes.local')
url.searchParams.set('profile', scoped)
return `${url.pathname}${url.search}${url.hash}`
}
export async function fetchRemoteProfileSessions(
profile: string,
searchParams: URLSearchParams,
fetchJsonForProfile: FetchJsonForProfile
fetchJsonForProfile: FetchJsonForProfile,
options: RemoteProfileSessionsOptions = {}
): Promise<SessionListResponse> {
const params = new URLSearchParams(searchParams)
params.delete('profile') // the remote serves its own database
// #64999: a per-profile override can point at a MULTI-profile backend — one
// `hermes serve` hosting several profiles — and an unscoped /api/sessions
// reads whichever profile the backend process was launched under. Name the
// scope so the rows provably belong to it; the remote's own stamps then
// carry the authoritative identity (main.ts no longer relabels).
const scope = remoteProfileQueryScope(profile, options.remoteProfileAlias)
const fetchPage = async (pageParams: URLSearchParams): Promise<SessionListResponse> => {
pageParams.delete('profile')
if (scope) {
pageParams.set('profile', scope)
try {
return (await fetchJsonForProfile(profile, `/api/sessions?${pageParams}`)) as SessionListResponse
} catch (error) {
// A remote that rejects the scope (400 unknown profile / 404 profile
// does not exist) proves it serves a single launch profile natively —
// the legacy shape this code was written for. Fall back to the
// remote's own database exactly as before; auth/transport/5xx errors
// are real failures and propagate.
if (!isRemoteProfileScopeError(error)) {
throw error
}
pageParams.delete('profile')
}
}
return (await fetchJsonForProfile(profile, `/api/sessions?${pageParams}`)) as SessionListResponse
}
const requestedLimit = Number(params.get('limit'))
const requestedOffset = Number(params.get('offset') || '0')
@@ -385,7 +507,7 @@ export async function fetchRemoteProfileSessions(
requestedOffset >= 0
if (!needsPaging) {
return (await fetchJsonForProfile(profile, `/api/sessions?${params}`)) as SessionListResponse
return fetchPage(params)
}
const sessions: unknown[] = []
@@ -402,7 +524,7 @@ export async function fetchRemoteProfileSessions(
pageParams.set('limit', String(pageLimit))
pageParams.set('offset', String(pageOffset))
const page = (await fetchJsonForProfile(profile, `/api/sessions?${pageParams}`)) as SessionListResponse
const page = await fetchPage(pageParams)
firstPage ??= page
const total = nonNegativeNumber(page.total)