Skip to content

feat(desktop): OAuth sign-in for registry connections; keep profile picks on the browsed source - #92194

Merged
OutThisLife merged 3 commits into
NousResearch:mainfrom
jonpol01:feat/desktop-multi-source-agents
Aug 24, 2026
Merged

OutThisLife merged 3 commits into
NousResearch:mainfrom
jonpol01:feat/desktop-multi-source-agents

Conversation

@jonpol01

@jonpol01 jonpol01 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Rescoped after #90006 merged (connection-bound Bot Mode actions): this PR originally carried a third change — open Connections bots on their own source by activating the owner gateway. #90006 solves that same problem connection-bound, without moving the window, which is strictly better and consistent with f89f884's intent. That commit and its tests are dropped; what remains is orthogonal to #90006.

Problem

Two gaps around registered connections:

  1. A gated remote can't be authenticated at all. The connections registry editor renders a token
    field for authMode: 'token' and nothing for 'oauth'. A gateway behind a login (OAuth, or
    username/password) never accepts X-Hermes-Session-Token_require_token allows exactly one
    scheme per bind — so the connection can be created and then never signed in. Selecting OAuth left
    an empty row and Test failed with no way to fix it.

  2. Profile picks snap back to the primary. $profiles is the active gateway's list, so a pick
    made while a registry source is live names one of that source's profiles — but selectProfile and
    newSessionInProfile routed it through the profile-only path, whose resolveConnectionForProfile
    calls getConnection(profile) with a bare name. The main process answers a bare name against the
    primary, so picking researcher on a remote source opened a local backend of that name and
    yanked the gateway home. The pick looked like it never took.

Changes

feat(desktop): add OAuth sign-in to the connections registry editor — render an Authentication
row in the oauth branch that calls the existing oauthLoginConnectionConfig IPC, the same one
first-run-remote-form and the gateway panel already use. The URL is probed (debounced) so the row can
name the provider and use password-specific copy when every advertised provider supports passwords,
matching gateway-settings. No new i18n keys — every string already exists under settings.gateway.
testDesktopConnectionConfig needed no change: it already skips the token for oauth and mints a
ws-ticket from the session.

fix(desktop): keep profile picks on the source being browsed — route selectProfile and
newSessionInProfile through the live source: a non-null activeGatewayConnectionId() means a registry
source owns the current gateway, so activate the (connection, profile) agent. A null id means the
primary is live, which is exactly the legacy path — single-source users are unaffected.

fix(desktop): cancel the registry auth probe on unmount; reset the signed-in pill on mode flips
review follow-ups on the sign-in row: the debounced probe sets a cancelled flag in its effect cleanup,
and oauthConnected resets when the auth mode flips as well as on URL changes.

Scope

This does not make two gateways live at once; $gateway remains a single socket. With #90006 in,
bot-row opens are connection-bound and never move the window — this PR's switcher fix is the same
principle applied to the profile rail: a pick made while browsing a source stays on that source.

Testing

Verified manually against a LAN gateway (kind: remote, gated by username/password, advertising
auth_flows: ['cookie', 'native_pkce']): sign-in completes via native PKCE, and profile picks stay
on that source.

Known gap (follow-up, not in this PR)

Found while dogfooding the same two-machine setup: a registry row with authMode: 'token' pointing
at a gated gateway can never activate — _require_token accepts only cookie/bearer on a gated
bind — so switching to that connection fails with only the generic
Connection "…" did not become active. (in our case it was the primary row for a gated local
daemon, which made "switch back to local" look broken). With this PR the row can at least be flipped
to oauth and signed in from the registry editor; before it there was no path at all. Two candidate
follow-ups: name the auth-mode mismatch in the switch error instead of the generic copy, and default
rows for gateways that advertise auth_required to oauth.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) area/auth Authentication, OAuth, credential pools labels Aug 22, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.
The functional direction is good — canOpenOnOwnSource() fail-closed on unknown kinds (an unknown source could be SSH) and the explicit exclusion of SSH from ACTIVATABLE_SOURCE_KINDS preserve the expensive-hop boundary deliberately. The profile.ts change (route picks through ensureGatewayAgent when a registry source is live) comes with real regression tests for both paths — nice.

Main concern, and it's a repo-policy one: several of the new tests are source-reading tests, which this codebase explicitly bans (they test the shape of the source, not behavior, and break on harmless refactors):

  • open-remote-bot-in-place.test.mjs, the three test('shape: …') cases regex/indexOf into plugin.js text (e.g. counting Stay in this chat and @${handle} occurrences, matching !canOpenOnOwnSource(bot) within 300 chars above a toast, asserting the thin-row menu slice).
  • profile-prewarm.test.mjs similarly slices source ranges to rebuild components.

The first two tests in that file do it right-ish by extracting the real helper via vm — but even better is to move ACTIVATABLE_SOURCE_KINDS / canOpenOnOwnSource into a small pure module (e.g. src/lib/… or a plugin-local bot-source.js) and import it directly in tests; the ordering guarantee that ensureAgent precedes the liveness check belongs in a behavior test against the extracted prepareBotSource, not a regex over its body.

Minor, non-blocking:

  1. connections-registry.tsx — the debounced probe effect guards staleness with probeSeq but not unmount; a late .then after close will call setAuthProbe on an unmounted component. Harmless in React 18, but an cancelled flag in the cleanup is cheap insurance.
  2. oauthConnected resets on URL change — good; consider also resetting it when authMode flips so a saved row edited token→oauth doesn't show a stale "Signed in" pill.

@jonpol01

Copy link
Copy Markdown
Contributor Author

Acted on all of it — thanks, the shape-test point was fair.

Shape tests → behavior tests. The two open paths shared a duplicated toast block; that duplication was the only reason a proximity-regex test existed. Extracted mentionOnlyNotice(bot) (decision + copy in one place, both call sites use it), and the tests now exercise it for both outcomes through the same vm extraction this file already uses. The ordering test is now behavioral too: prepareBotSource runs against a stubbed host where the live connection id only moves when ensureAgent executes — so the test passing is the activation-before-liveness guarantee, and the "didn't become active" and missing-capability paths are exercised as real failures.

On full module extraction: considered and deliberately not done. Runtime-fetched plugins load as a single blob-URL import() and rewriteSpecifiers intentionally skips relative specifiers (contrib/runtime-loader.ts), so a sibling bot-source.js would throw "Failed to resolve module specifier" for every standalone install of this plugin. The single-file form is a contract, not an accident — extraction-at-test-time is the closest equivalent to importing it directly. One structural pin remains (the thin-row context-menu guard, a two-line early return inside JSX); it's annotated with why it stays structural.

Minor notes, both taken: the debounced probe now sets a cancelled flag in its cleanup, and oauthConnected resets when the auth mode flips as well as on URL changes.

For scope: the AGENTS.md rule on source-reading tests is scoped to plugin compatibility tests (§ deprecations), so this wasn't a policy violation as such — but the behavioral versions are better tests regardless, which is why the point was worth acting on.

@jonpol01 jonpol01 changed the title feat(desktop): reach agents on other connections without switching gateways feat(desktop): OAuth sign-in for registry connections; keep profile picks on the browsed source Aug 23, 2026
@jonpol01
jonpol01 force-pushed the feat/desktop-multi-source-agents branch from 4b06984 to e8191cc Compare August 23, 2026 06:53
@jonpol01

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and rescoped: #90006 (connection-bound Bot Mode actions) merged while this sat in queue and solves the open-remote-bots problem connection-bound, without moving the window — strictly better than this PR's activate-the-owner approach, so that commit and its tests are dropped rather than re-fought.

What remains is orthogonal to #90006 and unchanged in substance:

  • feat(desktop): add OAuth sign-in to the connections registry editor
  • fix(desktop): keep profile picks on the source being browsed — same principle feat(desktop): route remote bot actions by connection #90006 establishes for bot rows, applied to the profile rail
  • the review follow-ups on the sign-in row (probe unmount guard, mode-flip reset)

On the rebased branch: plugin suite 406/406 (including #90006's tests), renderer suite 7065 passed / 3 skipped, all three typecheck projects and eslint clean.

@teknium1

Copy link
Copy Markdown
Collaborator

Post-#92731 status (verified against main @ e132e11): partially superseded — re-scope before rebasing.

Superseded: the plugin.js commit (ACTIVATABLE_SOURCE_KINDS / canOpenOnOwnSource / mentionOnlyNotice). Main's BotRow.open() now routes through requestForBot()/botConnectionRoute() on an immutable captured owner — remote bots open their canonical Bot Chat WITHOUT moving the active gateway, which replaces this PR's ensureGatewayAgent-activation approach entirely; the mention-only toast it removes no longer exists.

Still valuable and unduplicated: (1) OAuth sign-in in the connections-registry editor (main's oauth branch is still empty), and (2) the profile.ts fix — selectProfile()/newSessionInProfile() still call bare ensureGatewayProfile(target), so a profile pick while a registry source is active still snaps home. If you drop the plugin.js commit and rebase the other two, this stays a wanted PR.

@jonpol01 jonpol01 closed this Aug 23, 2026
@jonpol01 jonpol01 reopened this Aug 23, 2026
@alt-glitch alt-glitch added P2 Medium — degraded but workaround exists area/profiles Multi-profile isolation, HERMES_HOME scoping and removed P3 Low — cosmetic, nice to have labels Aug 23, 2026
@jonpol01
jonpol01 force-pushed the feat/desktop-multi-source-agents branch from e8191cc to 246fc44 Compare August 23, 2026 07:08
@jonpol01

Copy link
Copy Markdown
Contributor Author

Agreed on all points — and the rescope you describe is exactly what this PR became this morning (comment above): the plugin.js commit and its tests were dropped when #90006 landed, for the same reason you give — the connection-bound owner route replaces gateway activation outright, and the toast it gated no longer exists.

Now also rebased onto current main (post-#92731): the two surviving commits apply clean, and your read matches what I see there — the registry editor's oauth branch is still empty, and selectProfile()/newSessionInProfile() still route through bare ensureGatewayProfile(target), so the snap-home reproduces. On the rebased head: profile-select-source.test.ts 3/3, Bot Mode node suite 406/406, full renderer suite green, all three typecheck projects and eslint clean.

Heads-up on CI: the last few ci.yaml runs here died at workflow startup with zero jobs ("workflow file issue") — same signature hit several unrelated PRs in the same window this morning, so it looks infra-side rather than anything in the diffs. The rebase push should give it a fresh roll; if it sticks again I'd appreciate a maintainer re-run.

@jonpol01
jonpol01 force-pushed the feat/desktop-multi-source-agents branch from 246fc44 to 0613950 Compare August 23, 2026 07:29
A gated remote gateway (OAuth, or username/password) never accepts a
session token — it authenticates with a browser sign-in and the desktop
keeps whatever the flow mints. The registry editor only rendered a token
field for 'token' mode and nothing at all for 'oauth', so a gated
connection could be created but never authenticated: selecting OAuth left
an empty row, and Test failed with no way to fix it.

Render an Authentication row in the oauth branch that calls the existing
oauthLoginConnectionConfig IPC — the same one first-run-remote-form and
the gateway panel already use. The URL is probed (debounced) so the row
can name the provider and use password-specific copy when every
advertised provider supports passwords, matching gateway-settings.

No new i18n keys; all strings already exist under settings.gateway.
Test needed no change: testDesktopConnectionConfig already skips the
token for oauth and mints a ws-ticket from the session.
$profiles is the ACTIVE gateway's list, so a profile picked while a
registry source is live names one of THAT source's profiles. Both
selectProfile and newSessionInProfile sent it through the profile-only
path, which resolves the descriptor with a bare name — and
getConnection(profile) is answered against the primary. Picking
"researcher" while browsing a remote source therefore opened a LOCAL
backend of that name and snapped the gateway home, so the pick looked
like it never took: the user could reach the agent from Bot Mode but
never from the profile switcher.

Route both through the live source instead: a non-null
activeGatewayConnectionId means a registry source owns the current
gateway, so activate the (connection, profile) agent. A null id means
the primary is live, which is exactly the legacy path — single-source
users keep their existing behavior unchanged.
…gned-in pill on mode flips

Review follow-ups on the OAuth sign-in row: the debounced probe sets a
cancelled flag in its effect cleanup (probeSeq covers staleness but not
unmount), and oauthConnected resets when the auth mode flips as well as on
URL changes — a saved row edited token -> oauth no longer reports a stale
'Signed in' from an earlier oauth stint.
@jonpol01
jonpol01 force-pushed the feat/desktop-multi-source-agents branch from 0613950 to efaac4d Compare August 23, 2026 11:10

@OutThisLife OutThisLife left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — the profile-snap fix is the right shape: activateOnCurrentSource routes through ensureGatewayAgent(connectionId, profile) when a registry source is live, and falls back to the legacy path when the primary owns the socket. Bug-class tests cover both paths. The OAuth sign-in row is a clean reuse of the existing oauthLoginConnectionConfig IPC. Thanks for the rescope after #90006/#92731 landed.

@OutThisLife OutThisLife left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — the profile-snap fix is the right shape: activateOnCurrentSource routes through ensureGatewayAgent(connectionId, profile) when a registry source is live, and falls back to the legacy path when the primary owns the socket. Bug-class tests cover both paths. The OAuth sign-in row is a clean reuse of the existing oauthLoginConnectionConfig IPC. Thanks for the rescope after #90006/#92731 landed.

@OutThisLife
OutThisLife merged commit 3dc77ac into NousResearch:main Aug 24, 2026
29 checks passed

@OutThisLife OutThisLife left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Verified against current main: the registry editor's oauth branch is still empty and selectProfile()/newSessionInProfile() still route through bare ensureGatewayProfile(target), so both surviving commits are unduplicated. The self-rescope after #90006 and the rebase past #92731 match what @teknium1 asked for, and the review follow-ups (probe unmount guard, mode-flip reset) are in.

Required checks are green on the current head; the earlier zero-job ci.yaml runs were the infra window that hit several unrelated PRs that morning.

Unrelated note for anyone routed here from the Discord thread about Desktop reverting to a local backend — that one is v1/v2 connection-registry drift, fixed in #93408, not this PR.

Taznc added a commit to Taznc/hermes-agent that referenced this pull request Aug 26, 2026
…+ palette local-agent routing

Three issues surfaced after merging upstream/main (427 commits) into dev:

1. web-bridge-shim.test.ts: profile-remote-override-dialog.tsx (fork feature,
   NousResearch#91349) and connections-registry.tsx's OAuth sign-in row (upstream feature,
   NousResearch#92194) call window.hermesDesktop.applyConnectionConfig /
   oauthLoginConnectionConfig without a method-level guard. Guard both call
   sites so the web build shim's partial-shim trap test passes and the browser
   build doesn't throw "is not a function".

2. profile-select-agent.test.ts / config-settings.test.tsx: stale vi.mock
   factories for @/store/gateway and @/store/settings-scope were missing
   activeGatewayConnectionId and $settingsRequestProfile respectively, both
   added by upstream commits merged in. Updated the mocks to match current
   module shape.

3. src/store/profile.ts: genuine semantic merge conflict (no textual
   conflict). Upstream's NousResearch#92194 changed activateOnCurrentSource so a bare
   selectProfile() call stays on whichever source is currently browsed
   (activeGatewayConnectionId()), instead of always targeting the local pool.
   The fork's selectAgent(null, name) — the command palette / profile rail's
   explicit "switch to this device" action (NousResearch#85731) — delegated to
   selectProfile() assuming the OLD always-local behavior. Added an explicit
   forceLocal option so selectAgent's null-connection case still pins to the
   local pool, while every other selectProfile() caller keeps upstream's new
   stay-on-browsed-source behavior.

Verified: typecheck clean, full vitest suite green (726 files / 7582 tests).
Taznc added a commit to Taznc/hermes-agent that referenced this pull request Aug 26, 2026
… invariants run

The vi.mock('@/store/gateway') factory in profile-select-agent.test.ts
predates NousResearch#92194's activateOnCurrentSource, which now reads
activeGatewayConnectionId() on every null-connection selectProfile
delegation — three (connection, profile) switching-invariant tests died
in the mock loader instead of asserting. Add the registry double
(vi.fn -> null, reset per test, steerable like
profile-select-source.test.ts).

Likewise config-settings.test.tsx's vi.mock('@/store/settings-scope')
lacked $settingsRequestProfile, which config-settings.tsx:61 now reads
via useStore; both settings-retry regression tests failed before
rendering. Mirror the real module: a computed over the override atom
mapping null -> undefined.

No assertion changed; all 8 tests in the two files pass again.
Taznc added a commit to Taznc/hermes-agent that referenced this pull request Aug 28, 2026
… invariants run

The vi.mock('@/store/gateway') factory in profile-select-agent.test.ts
predates NousResearch#92194's activateOnCurrentSource, which now reads
activeGatewayConnectionId() on every null-connection selectProfile
delegation — three (connection, profile) switching-invariant tests died
in the mock loader instead of asserting. Add the registry double
(vi.fn -> null, reset per test, steerable like
profile-select-source.test.ts).

Likewise config-settings.test.tsx's vi.mock('@/store/settings-scope')
lacked $settingsRequestProfile, which config-settings.tsx:61 now reads
via useStore; both settings-retry regression tests failed before
rendering. Mirror the real module: a computed over the override atom
mapping null -> undefined.

No assertion changed; all 8 tests in the two files pass again.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…icks on the browsed source (NousResearch#92194)

* feat(desktop): add OAuth sign-in to the connections registry editor

A gated remote gateway (OAuth, or username/password) never accepts a
session token — it authenticates with a browser sign-in and the desktop
keeps whatever the flow mints. The registry editor only rendered a token
field for 'token' mode and nothing at all for 'oauth', so a gated
connection could be created but never authenticated: selecting OAuth left
an empty row, and Test failed with no way to fix it.

Render an Authentication row in the oauth branch that calls the existing
oauthLoginConnectionConfig IPC — the same one first-run-remote-form and
the gateway panel already use. The URL is probed (debounced) so the row
can name the provider and use password-specific copy when every
advertised provider supports passwords, matching gateway-settings.

No new i18n keys; all strings already exist under settings.gateway.
Test needed no change: testDesktopConnectionConfig already skips the
token for oauth and mints a ws-ticket from the session.

* fix(desktop): keep profile picks on the source being browsed

$profiles is the ACTIVE gateway's list, so a profile picked while a
registry source is live names one of THAT source's profiles. Both
selectProfile and newSessionInProfile sent it through the profile-only
path, which resolves the descriptor with a bare name — and
getConnection(profile) is answered against the primary. Picking
"researcher" while browsing a remote source therefore opened a LOCAL
backend of that name and snapped the gateway home, so the pick looked
like it never took: the user could reach the agent from Bot Mode but
never from the profile switcher.

Route both through the live source instead: a non-null
activeGatewayConnectionId means a registry source owns the current
gateway, so activate the (connection, profile) agent. A null id means
the primary is live, which is exactly the legacy path — single-source
users keep their existing behavior unchanged.

* fix(desktop): cancel the registry auth probe on unmount; reset the signed-in pill on mode flips

Review follow-ups on the OAuth sign-in row: the debounced probe sets a
cancelled flag in its effect cleanup (probeSeq covers staleness but not
unmount), and oauthConnected resets when the auth mode flips as well as on
URL changes — a saved row edited token -> oauth no longer reports a stale
'Signed in' from an earlier oauth stint.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/profiles Multi-profile isolation, HERMES_HOME scoping comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants