Skip to content

fix(desktop): remote gateways stay connected instead of snapping back to a local backend - #93408

Merged
OutThisLife merged 7 commits into
mainfrom
review/desktop-source-snapback
Aug 24, 2026
Merged

OutThisLife merged 7 commits into
mainfrom
review/desktop-source-snapback

Conversation

@OutThisLife

Copy link
Copy Markdown
Contributor

Hermes Desktop connects to a remote gateway, paints its sessions, and then a
few seconds later re-homes itself onto a fresh local backend — greying out the
session list and popping the setup wizard, while the remote sits there healthy.
This consolidates the fixes for that and for the two other symptoms the same
drift produces.

What breaks

migrateV1ToRegistry imports the legacy connection.json into the v2
connections.json registry exactly once, only when the registry file does not
exist. Settings → Gateway (hermes:connection-config:apply) writes only the v1
file. So anyone who was local when the registry was born and pointed Settings at
a remote afterwards ends up with two stores that disagree, permanently, with
nothing to reconcile them.

From there:

  1. Boot dials the remote through the v1 route. It works — sessions paint.
  2. The descriptor is unqualified, so resolvedConnectionId returns null and
    $activeConnectionId is null.
  3. Boot completes, initializeConnectionsRegistry restores registry.primary
    (local). The === preferredId guard misses on null, so
    selectConnection('local') runs and wipeSessionListsForGatewaySwitch()
    clears the list.
  4. The forced-local backend has no inference provider, so onboarding opens as a
    blocking overlay.

The same force-switch is what duplicates every MCP server (#91564) and what
clobbers the persisted "Show all profiles" preference (#93197). Users have been
trading a hand-edit of connections.json as the workaround.

The fix

Heal the drift at its source. When the v1 global route names a remote the
registry has never heard of, register it and adopt it as primary/last-used, then
persist so the repair happens once. This is what the manual JSON edit was doing.
It is deliberately narrow: an already-registered route is left alone even when
primary names something else, because that is a real pick in the Connections
panel, not drift.

Stop creating new drift. Apply now reconciles the v2 registry in the same
transaction as the v1 write, so Save and reconnect publishes one identity to
both stores. Both writes plus the re-home roll back together, and an
authenticated REST + WebSocket preflight runs before either file changes.

Authenticate liveness probes. Primary and pooled remote checks went out
anonymously, so a gateway that protects /api/status returned 401 and the
tracker condemned a perfectly healthy connection. They now use the same
credentials as real traffic (native bearer, then OAuth session).

Two guards behind that. The boot restore returns early when a connection is
live but unnameable — the registry has no claim on a source it does not know.
And the launch-source toggle is no longer hidden when only one connection is
registered, which was exactly the state the drift produces.

Testing

Bug-class coverage: drift heals a v1 remote into a local-only registry and the
live descriptor resolves to it; an already-registered route is untouched; a
deliberate local primary is never overridden; local/ssh/unparseable v1 routes
are ignored; other registered sources survive the heal; boot restore leaves a
live unnameable connection alone; preflight runs before either store is written
and a rejected preflight leaves both untouched.

Electron suite 1620 passed / 3 skipped, renderer suite green, all three
typecheck projects and eslint clean.

Two salvaged tests asserted against main.ts source text (regex for
ensureNativeAccessToken(baseUrl), index arithmetic for preflight ordering).
Replaced with behavior tests: preflight is now a real option on
applyConnectionConfigAtomically, so its ordering is observable through the
seam instead of inferred from source layout.

Credit

Root cause analysis and reproduction by @ExManubis (#90174), with independent
confirmations from @TheAirick (Arch), @greetingsyi (Windows + remote gateway,
with the log timeline pinning the regression to the 8/17 build) and @iosmand,
whose fresh-install reproduction showed this is not only stale upgrade data
and whose "delete connections.json" recovery is what the reconciliation
automates. @Hemir95 reported the duplicated-MCP symptom (#91564) and @TVD7678
the browse-mode reset (#93197).

Co-authored-by: Alessandro Levantini 176400450+Allecpu@users.noreply.github.com
Co-authored-by: ClintonEmok 54935030+ClintonEmok@users.noreply.github.com

Supersedes #92274, #93279
Closes #90174, #91564, #93197

Allecpu and others added 7 commits August 23, 2026 20:13
…havior tests

The salvaged hardening tests matched main.ts source text to assert that
fetchConnectionStatus reaches for a bearer and that Apply preflights before
persisting. A rename breaks them while a real auth regression that keeps the
substrings passes.

Make the preflight a first-class option on applyConnectionConfigAtomically so
its ordering is observable, and assert it through the seam: preflight runs
before either write, and a rejected preflight leaves both stores and the
activation untouched.
…nce (#93197)

The showAllProfiles browse-mode flag is persisted to localStorage, but
every restart it was force-collapsed anyway: initializeConnectionsRegistry
restores the last-used source via selectConnection, and selectConnection's
post-activation path unconditionally ran $showAllProfiles.set(false).

That collapse is correct for a user click on the connection picker (a
concrete-source action), but the silent boot restore is not a user action.
Gate both reset sites on pendingTarget === null && activeConnectionId ===
null (the fresh-boot state) so the persisted preference survives restart,
while any user-initiated switch still collapses browse mode.

Regression tests cover both directions: boot restore preserves true, a
user switch collapses it.

Fixes #93197
…ocal

migrateV1ToRegistry runs exactly once, only when connections.json is absent.
A user who was local at that moment and pointed Settings -> Gateway at a
remote afterwards gets a live remote the registry cannot name: the descriptor
resolves to no connectionId, primary still says 'local', and the boot-time
launch pick force-switches the window onto a fresh local backend seconds after
the sessions list paints. That backend has no provider, so onboarding pops.

Reconcile on read: when the v1 global route names a remote with no matching
registry entry, register it and adopt it as primary/last-used, then persist so
the repair happens once. Narrow on purpose — an already-registered route is
left alone even when primary names something else, because that is the user's
pick in the Connections panel, not drift.

Replaces the hand-edit-connections.json workaround users have been trading.
Reconciliation repairs the drift at its source, but it can still fail to
persist (read-only or full userData), which leaves a window live on a source
the registry cannot name. $activeConnectionId is null there, the preferred-id
guard misses, and the restore re-homes a working connection.

Return early when a connection is live but unnameable. The registry has no
claim on a source it does not know about.
The toggle was gated on having 2+ registered sources, which hid it in exactly
the local-only state the drift produces — the state where a user most needs to
change what launch restores.
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 679811f — fix(desktop): show the launch-source preference for a single

⚠️ Warnings

OSV vulnerability scan · View job

7 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 1m51s vs 3m53s (-52.4%). 3 job(s) slower, 1 faster, 2 unchanged.

  • OSV scan / Scan lockfiles / osv-scan: -2.0s
  • OSV scan / Emit review status: +1.0s
  • Detect affected areas: +1.0s
  • Check no committed infographics / check-no-committed-infographics: +1.0s

@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades area/config Config system, migrations, profiles labels Aug 24, 2026
@OutThisLife
OutThisLife merged commit c25f206 into main Aug 24, 2026
31 checks passed
@OutThisLife
OutThisLife deleted the review/desktop-source-snapback branch August 24, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

4 participants