Skip to content

feat(desktop): connect to OAuth-gated remote gateways#37888

Merged
teknium1 merged 3 commits into
mainfrom
desktop-remote-oauth-v2
Jun 4, 2026
Merged

feat(desktop): connect to OAuth-gated remote gateways#37888
teknium1 merged 3 commits into
mainfrom
desktop-remote-oauth-v2

Conversation

@benbarclay

Copy link
Copy Markdown
Collaborator

Summary

Adds the desktop-client half of remote OAuth-gated gateway support: Hermes Desktop can now connect to a hosted/remote Hermes gateway that authenticates with OAuth (cookie + single-use WS ticket) instead of a static session token. The gateway/server half — the WS Origin guard fix that lets the packaged file:// renderer through — already landed in #37870.

Background

A remote Hermes gateway can authenticate two ways:

  • token — legacy static dashboard session token (X-Hermes-Session-Token header for REST, ?token= for WS).
  • oauth — hosted gateways gate behind an OAuth provider. REST is authed by an HttpOnly session cookie; WS upgrades require a single-use ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket. The gateway advertises this via the public /api/status field auth_required: true.

Previously the desktop's remote-gateway settings only supported a static session token, so it could not drive an OAuth-gated hosted gateway at all.

What this does

Auto-detection. As the user types a remote URL, the settings page debounce-probes GET {base}/api/status (auth_required → oauth vs token) and GET {base}/api/auth/providers (provider label). It then renders either a "Sign in with <provider>" button or the existing session-token box.

OAuth connection mechanism (main process):

  • REST is authed by the HttpOnly session cookie held in a persistent Electron session partition (persist:hermes-remote-oauth); main-process REST routes through Electron net bound to that partition so the cookie attaches automatically.
  • Sign-in opens a BrowserWindow on {base}/login in that partition and resolves once the hermes_session_at cookie lands.
  • WebSocket upgrades use a single-use ?ticket= minted at POST /api/auth/ws-ticket. getGatewayWsUrl() re-mints before every (re)connect, since tickets are single-use and short-lived (the ticket baked into the cached conn.wsUrl is already stale on the second connect).
  • A missing cookie / 401 surfaces needsOauthLogin to prompt re-sign-in (Portal contract v1 issues no refresh token).

UI flicker fix. The session-token box previously rendered for every gateway during the idle/probing window (because authMode defaults to 'token'). Both the OAuth button and the token box are now gated behind an authResolved flag, so neither renders until the probe resolves the scheme (or a previously-saved remote config is being re-shown, so re-opening settings doesn't flicker).

Local and token modes are unchanged.

Changes

  • apps/desktop/electron/main.cjs — OAuth session partition, cookie-authed REST via Electron net, login BrowserWindow, ws-ticket minting, freshGatewayWsUrl() re-mint-before-connect, needsOauthLogin surfacing.
  • apps/desktop/electron/connection-config.cjs (+ .test.cjs) — pure, electron-free helpers (URL normalize, token/ticket WS-URL builders, auth-mode classify/resolve, cookie detector), unit-tested with node --test (26 tests), matching the backend-probes.cjs pattern.
  • apps/desktop/electron/preload.cjsgetGatewayWsUrl / probeConnectionConfig / oauthLogin* / oauthLogout* IPC bridges.
  • apps/desktop/src/app/settings/gateway-settings.tsx — probe-driven OAuth/token UI + authResolved flicker fix.
  • apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts / use-gateway-request.ts, src/global.d.ts, src/lib/icons.ts, package.json — supporting plumbing/types.

Validation

  • Desktop tsc -b passes.
  • node --test electron/connection-config.test.cjs26/26.
  • End-to-end against the live staging gateway: with a real OAuth session cookie, POST /api/auth/ws-ticket → 200 + ticket, and wss://…/api/ws?ticket=<real> (no cookie, exactly the renderer's situation) → connected + received gateway.ready. After fix(dashboard): trust non-web WS origins on OAuth-gated binds after ticket auth #37870 the file://-origin upgrade is accepted server-side.

Dependency

Requires #37870 (merged) on the gateway. The hosted gateway image must be rebuilt/redeployed with #37870 before the desktop can complete the WS upgrade to an OAuth-gated gateway.

Touches the gateway/connection layer (main.cjs, gateway hooks), so flagging for @teknium1 review per the desktop/gateway lane.

@benbarclay benbarclay requested a review from a team June 3, 2026 04:47
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: desktop-remote-oauth-v2 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9781 on HEAD, 9781 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5074 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/feature New feature or request area/auth Authentication, OAuth, credential pools P3 Low — cosmetic, nice to have labels Jun 3, 2026
@Kenmege

Kenmege commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Field verification + CI triage from the desktop/cloud integration path:

With #37870 landed, this PR looks like the desktop-side half needed for OAuth-gated remote Hermes gateways.

I validated this flow from a desktop-to-remote-gateway setup, with private endpoint/token details intentionally omitted. The critical path I would keep as the merge/release gate is:

  • desktop discovers remote gateway auth state through status/provider metadata
  • OAuth login runs in a persistent Electron session
  • desktop mints a websocket ticket before connect/reconnect
  • /api/ws?ticket=... reaches gateway.ready
  • existing local gateway/token modes keep working

The current red checks look unrelated to the files in this PR. The PR patch is limited to desktop connection/auth/settings files, while the failing jobs I see are:

  • test (2): tests/run_agent/test_strict_api_validation.py::TestStrictApiValidation::test_sanitize_method_with_fireworks_provider times out connecting to api.fireworks.ai during model metadata lookup.
  • test (6): tests/tools/test_local_interrupt_cleanup.py::test_wait_for_process_kills_subprocess_on_keyboardinterrupt times out waiting for process-group cleanup.

So I do not think either red check is evidence against the desktop OAuth/websocket-ticket change. I can also contribute a follow-up release/runbook doc PR that captures the rollout checklist once this lands.

@Kenmege Kenmege 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.

Non-blocking implementation question from the desktop OAuth path review:

Both reconnect call sites now do this shape:

const wsUrl = (await desktop.getGatewayWsUrl?.().catch(() => null)) || conn.wsUrl
await gateway.connect(wsUrl)

The fallback is fine for local/token mode, and I can see why optional chaining helps older preload/runtime shapes. For OAuth mode, though, a failed getGatewayWsUrl() means the app could fall back to conn.wsUrl, which may contain a single-use or expired ticket. That can mask the better failure path from mintGatewayWsTicket() / needsOauthLogin and turn it into a generic websocket failure.

Would it be safer to let fresh-mint failures propagate when conn.authMode === "oauth", and only fall back to conn.wsUrl for token/local mode or when the preload method is genuinely absent? That keeps the reauth prompt path intact while preserving compatibility for non-OAuth connections.

@benbarclay benbarclay force-pushed the desktop-remote-oauth-v2 branch from 58ede30 to aaae6a9 Compare June 4, 2026 02:44
benbarclay and others added 3 commits June 4, 2026 16:49
The desktop remote-gateway settings now auto-detect whether a gateway
authenticates with OAuth or a static session token and present the
matching UI + connection mechanism.

Detection: an unauthenticated GET {base}/api/status reads auth_required
(true => OAuth, false => session token); /api/auth/providers supplies the
provider label. The settings UI debounce-probes the entered URL and shows
either a 'Sign in with <provider>' button or the session-token box.

OAuth connection mechanism:
- REST is authed by the HttpOnly session cookie held in a persistent
  Electron session partition (persist:hermes-remote-oauth); main-process
  REST routes through electron net bound to that partition so the cookie
  attaches automatically.
- Login opens a BrowserWindow on {base}/login in that partition and
  resolves once the hermes_session_at cookie lands.
- WebSocket upgrades use a single-use ?ticket= minted at
  POST /api/auth/ws-ticket (the gateway rejects ?token= in gated mode);
  getGatewayWsUrl() re-mints before every (re)connect since tickets are
  single-use and short-lived.
- Missing cookie / 401 surfaces needsOauthLogin to prompt re-sign-in
  (Nous Portal contract v1 issues no refresh token).

Local and token modes are unchanged.

Pure helpers (URL normalize, ws-url token/ticket builders, auth-mode
classify/resolve, cookie detector) are extracted to a standalone
connection-config.cjs (no electron import) and unit-tested with
node --test (26 tests), matching the backend-probes.cjs pattern.
The remote-gateway settings rendered the session-token box for every gateway
during the idle/probing window before the first /api/status probe lands,
because authMode defaults to 'token'. Gate both the OAuth sign-in button and
the token box behind an authResolved flag so neither renders until the probe
resolves the scheme (or a previously-saved remote config is being re-shown,
so re-opening settings doesn't flicker).

The gateway-side WS Origin fix that lets the packaged desktop (file:// origin)
connect to an OAuth-gated remote gateway landed separately in #37870; this
branch is now purely the desktop client + this UI fix.
…ilure

The reconnect and boot paths resolved the WS URL with
`(await getGatewayWsUrl().catch(() => null)) || conn.wsUrl`. For OAuth
gateways the cached conn.wsUrl carries a single-use, ~30s-TTL ticket; the
desktop connection is memoized for the process lifetime, so on reconnect
that ticket is both expired and already consumed. A failed fresh mint
therefore fell back to a guaranteed-dead ticket and surfaced as an opaque
"connection closed", masking the gateway's actionable "session expired,
sign in again" message.

Extract resolveGatewayWsUrl() (with unit tests): in OAuth mode a mint
failure throws a tagged GatewayReauthRequiredError instead of falling back;
token/local modes keep the long-lived-token fallback. Thread that error
through the reconnect path so requestGateway surfaces the reauth message
rather than the generic transport error that triggered the retry.

Co-authored-by: Kenmege <205099287+Kenmege@users.noreply.github.com>
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 P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants