Files
hermes-agent/apps/desktop/electron/host-backend-singleton.test.ts
teknium1 25c74f731c fix(desktop): unscopable mutations keep a process-scoped backend; stamp shared-primary event provenance
Review round 2 on the host-backend collapse. Two defects the collapse
introduced, both fixed on top of #118275's server-side `profile` plumbing
(which this PR now requires: it must merge first).

1. The collapse ate the invariant "a route the server cannot profile-scope
   must keep a process-scoped backend". Routing case 6 sent EVERY local
   request at the shared primary, so a mutating handler that reads no
   `profile` executed against the primary's HERMES_HOME. Restored as a
   mechanical gate, not a route list: `unscopableMutatingRequest()` is true
   only when the method is unsafe AND `localPrimaryRequestScope()` returns
   null, and `sharesHostBackend()` refuses to collapse that request. The day
   a handler learns `profile`, it joins the scoped table and the gate stops
   seeing it.

   `ensureBackend()` now re-resolves with the ORIGINATING request, so its own
   route decision matches the caller's `resolveProfileApiRequest` one instead
   of silently collapsing a request routing deliberately kept pooled, and the
   spawn guard is told when a pooled spawn is the legitimate outcome.

   After #118275 the residual unscopable-mutating set is the host-path
   families (`POST /api/files/upload`, `DELETE /api/files/managed`) plus any
   unlisted mutating method (`POST /api/skills`). Session writes are scopable
   by `body.profile`, so they share the backend with their path left alone.

2. Events from non-primary local profiles arrived unowned. `sharedPrimaryRoute`
   returns the primary gateway without `createSecondary`, so nothing ran
   `stampSecondaryProfileOwner`, and use-gateway-boot captured the source
   profile ONCE in a `useEffect(..., [])` — every later profile's events were
   stamped with the BOOT-time profile. The profile is now read PER EVENT and
   the owner marker is applied whenever the active descriptor is
   `sharedPrimary`, so `runtimeSessionOwner()` resolves again and the live
   sessions/cron sync stops degrading to slow polling. The renderer is the
   right place: `_event_frame` carries no `profile`, so a server echo would be
   a cross-surface wire-contract change.

   Session PATCHes (rename/pin/archive/mark-read) now always name the owning
   profile in the body — falling back to the ACTIVE profile when the caller
   knows none — because the handler resolves its state.db from `body.profile`
   alone and no per-profile process remains to stand in for it.

Also: the routing-table docstring entries 4-6 and the `hermes:api` comment now
describe the behaviour they gate, and the spawn guard reads the same
`profileRouteOptions()` the router does so the two cannot drift (it was
dropping the per-profile SSH-override term).

(cherry picked from commit 1e2354fb15866cc24d98e8e552a59240efe97a25)
2026-09-21 10:07:03 -07:00

82 lines
3.8 KiB
TypeScript

// Multiplex-only invariants for the Desktop backend pool: one host backend
// serves every profile, and the local pooled-spawn path is unreachable.
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { resolveProfileBackendRoute, unscopableMutatingRequest } from './connection-config'
import { assertNoSecondLocalBackend, SecondLocalBackendError, sharesHostBackend } from './host-backend-singleton'
const LOCAL = { globalRemote: false, primaryProfile: 'default', profileRemoteOverride: false }
test('two local profiles connecting concurrently produce ZERO additional backends, each bound to its own profile', () => {
// The routing decision is the whole spawn decision: `ensureBackend` spawns a
// pooled child if and only if the route says `pool`. Resolve both profiles
// the way two concurrent renderer dials would.
const routes = ['worker', 'venture'].map(profile => resolveProfileBackendRoute(profile, LOCAL))
assert.deepEqual(
routes.filter(route => route.backend === 'pool'),
[],
'a local profile must never resolve to a pooled backend of its own'
)
// Both land on the SAME backend and still carry distinct wire identities, so
// each connection's turns bind to its own home (`session.create {profile}` ->
// `profile_home`; sessionless RPCs take the explicit `profile` argument).
assert.deepEqual(
routes.map(route => [route.backend, route.descriptorProfile, route.scopePath]),
[
['primary', 'worker', true],
['primary', 'venture', true]
]
)
})
test('the local pool spawn path is unreachable, and the escape hatches still reach it', () => {
assert.throws(() => assertNoSecondLocalBackend('worker', { isolated: false }), SecondLocalBackendError)
// HERMES_DESKTOP_ISOLATED_BACKEND=1: a private backend for this app.
assert.equal(sharesHostBackend({ isolated: true }), false)
assertNoSecondLocalBackend('worker', { isolated: true })
assert.equal(resolveProfileBackendRoute('worker', { ...LOCAL, isolatedBackend: true }).backend, 'pool')
// A remote/SSH backend is a DIFFERENT host: out of scope for the singleton,
// and its pooled descriptor never meant a local child anyway.
assert.equal(sharesHostBackend({ profileRemoteOverride: true }), false)
assert.equal(sharesHostBackend({ primaryRemoteActive: true }), false)
assertNoSecondLocalBackend('worker', { profileRemoteOverride: true })
assert.equal(resolveProfileBackendRoute('worker', { ...LOCAL, profileRemoteOverride: true }).backend, 'pool')
// A mutating request the server cannot profile-scope is the third way
// through: the pooled backend's HERMES_HOME is its only scope, so the guard
// must let that spawn happen instead of refusing a legitimate route.
assert.equal(sharesHostBackend({ unscopableRequest: true }), false)
assertNoSecondLocalBackend('worker', { unscopableRequest: true })
})
test('the spawn guard and the router agree on which requests keep a backend', () => {
// The guard is a backstop, not a second opinion: every request the router
// sends to the pool must be one the guard admits, or the destructive write
// fails with SecondLocalBackendError instead of reaching the right home.
const cases: Array<[string, string]> = [
['POST', '/api/files/upload'],
['DELETE', '/api/files/managed'],
['POST', '/api/skills'],
['POST', '/api/memory/reset'],
['GET', '/api/config'],
['PATCH', '/api/sessions/session-1']
]
for (const [requestMethod, requestPath] of cases) {
const opts = { ...LOCAL, requestMethod, requestPath }
const pooled = resolveProfileBackendRoute('worker', opts).backend === 'pool'
assert.equal(
sharesHostBackend({ unscopableRequest: unscopableMutatingRequest(opts) }),
!pooled,
`${requestMethod} ${requestPath}: guard and router disagree`
)
}
})