Files
hermes-agent/apps/desktop/electron/host-backend-singleton.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

91 lines
3.8 KiB
TypeScript

// Multiplex-only, Desktop half: ONE `hermes serve` per HOST serves every
// profile, so a local profile never gets a backend process of its own.
//
// `backend-discovery.ts` + `host-backend-attach.ts` made the PRIMARY backend
// attach to a backend the host is already running. This module removes the
// other producer of `hermes serve` children: the per-profile backend pool.
// Every local profile now resolves onto the same host backend and carries its
// own `profile` on the wire — the server binds a SESSION to a profile home
// (`session.create {profile}` -> `profile_home`) and a sessionless RPC to the
// explicit `profile` argument (`tui_gateway/server.py::@_profile_scoped`), so
// one process genuinely serves N homes.
//
// Two things are deliberately NOT collapsed:
// * `HERMES_DESKTOP_ISOLATED_BACKEND=1` — the escape hatch that gives this
// app a private backend instead of the host's.
// * Remote / SSH / Cloud backends — a DIFFERENT host. Its ownership proof
// (`remote-lifecycle.ts` isolated_count === 1) is about that machine's
// process, not ours, and stays pooled.
//
// Pure and dependency-injected so the decision tests without Electron.
/** Everything the collapse decision needs about one profile's routing. */
export interface HostBackendCollapseOptions {
/** `HERMES_DESKTOP_ISOLATED_BACKEND=1`: this app wants its own backend. */
isolated?: boolean
/** This profile points at its own remote host (connection.json / SSH). */
profileRemoteOverride?: boolean
/** The primary profile's backend is itself a remote host. */
primaryRemoteActive?: boolean
/**
* This request MUTATES state the server cannot profile-scope
* (`connection-config.ts::unscopableMutatingRequest`). The pooled backend's
* own `HERMES_HOME` is then the only scope there is, so the collapse must
* not swallow it — and the spawn guard below must not refuse it.
*/
unscopableRequest?: boolean
}
/**
* True when a LOCAL profile shares the one host backend instead of spawning
* its own. False keeps the legacy pooled descriptor — which, for a remote
* route, never meant a local child in the first place.
*/
export function sharesHostBackend(opts: HostBackendCollapseOptions = {}): boolean {
if (opts.isolated || opts.unscopableRequest) {
return false
}
return !opts.profileRemoteOverride && !opts.primaryRemoteActive
}
/** Raised when something still tries to start a second local backend. */
export class SecondLocalBackendError extends Error {
readonly poolKey: string
constructor(poolKey: string) {
super(
`Refusing to start a second local Hermes backend for "${poolKey}": one backend serves every profile on this host. ` +
'Set HERMES_DESKTOP_ISOLATED_BACKEND=1 for a private backend.'
)
this.name = 'SecondLocalBackendError'
this.poolKey = poolKey
}
}
/**
* The single enforcement point for "never spawn a second backend".
*
* Routing is what normally prevents a pooled local spawn; this guard sits at
* the one place a local `hermes serve` child is actually started, so a future
* caller that reaches it through a path routing does not cover fails loudly
* instead of quietly reintroducing a process per profile.
*/
export function assertNoSecondLocalBackend(poolKey: string, opts: HostBackendCollapseOptions = {}): void {
if (sharesHostBackend(opts)) {
throw new SecondLocalBackendError(poolKey)
}
}
/**
* A passive read (background tile reconcile, #103375) may only be served by a
* backend that already exists: it never cold-starts a child and never
* refreshes `lastActiveAt`, so an open-but-unviewed tile cannot keep the pool
* saturated. Callers treat the rejection as "nothing to refresh yet".
*/
export function assertNotPassiveSpawn(passive: boolean, poolKey: string): void {
if (passive) {
throw new Error(`Passive read: no warm backend for "${poolKey}"`)
}
}