Skip to content

feat(desktop): add secure Continue on phone - #71579

Open
joelbrilliant wants to merge 30 commits into
NousResearch:mainfrom
joelbrilliant:feat/dashboard-qr-handoff
Open

joelbrilliant wants to merge 30 commits into
NousResearch:mainfrom
joelbrilliant:feat/dashboard-qr-handoff

Conversation

@joelbrilliant

@joelbrilliant joelbrilliant commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a secure, persistent Continue on phone flow to Hermes Desktop.

Users can open it from:

  • the phone button beside the active chat composer
  • the session actions menu
  • /remote-control or /rc

Desktop displays a one-time QR code valid for 120 seconds. The phone exchanges it for a persistent, revocable linked-device credential and opens the exact selected chat. Future visits reconnect silently until the device is revoked, browser data is cleared, or the 90-day inactivity window expires.

Linked devices appear in Desktop settings with their device name, linked date, last-used date and a Revoke action.

Phone permission boundary

A linked phone can:

  • view and continue the linked chat
  • reconnect to that chat without signing in again

It cannot access:

  • plugins or plugin navigation
  • credentials or environment configuration
  • administrative settings
  • session browsing or unrelated sessions
  • standalone terminal, model or tool configuration controls

An ended session asks the user to link it again rather than creating a new session from the phone.

Security boundary

  • The bootstrap ticket is single-use and expires after 120 seconds.
  • The ticket remains in the URL fragment and is not sent in the HTTP request line or Referer.
  • Cross-process tickets are stored using their SHA-256 hash and consumed with an atomic delete.
  • The persistent device secret is stored only in an HttpOnly, Secure, SameSite=Lax browser cookie.
  • Hermes stores only the credential hash on the Mac.
  • Linked credentials receive exact resume-only scope bound to one session and profile.
  • Revocation invalidates subsequent HTTP and WebSocket access.
  • The Hermes Desktop backend remains loopback-only and does not accept the configured public Host header.
  • Resume-only clients receive the chat interface without dashboard navigation, plugins or administrative surfaces.

Scope

This PR does not add:

It requires an operator-configured HTTPS dashboard public URL with the existing browser authentication gate enabled.

This supersedes the earlier Desktop-only QR implementation in #67668.

Verification

  • GitHub required-check gate passes at afaa7e67d
  • Full Desktop UI suite: 3,325 tests passed
  • Focused dashboard authentication and handoff suite: 112 tests passed
  • Desktop typecheck, lint and production build passed
  • Web tests, typecheck, lint and production build passed
  • Physical iPhone Safari testing confirmed QR pairing, chat continuation, message entry and persistent reconnection
  • Replay, expiry, hostile path, foreign session, scope tampering and revocation cases fail closed

@joelbrilliant
joelbrilliant requested a review from a team July 25, 2026 20:57
@alt-glitch alt-glitch added type/feature New feature or request comp/dashboard Web dashboard / control panel UI (dashboard/, landing) comp/desktop Electron desktop app (apps/desktop/*) area/auth Authentication, OAuth, credential pools area/sessions Session lifecycle, resume, persistence, history P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 25, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the thorough ticket, scope, and WS binding work. Two issues need resolution before this can safely support the documented deployment path.

Problems

  • The new loopback tunnel/reverse-proxy instructions (website/docs/user-guide/features/web-dashboard.md:921-945) cannot engage the gate: current should_require_auth() returns False for loopback binds (hermes_cli/web_server.py:442-461), and start_server() assigns that result to app.state.auth_required (hermes_cli/web_server.py:17064). Thus the documented public origin remains token-mode, contrary to the feature's gated-SPA requirement.
  • isPhoneHandoffAuthMode() accepts "handoff" (apps/desktop/src/lib/continue-on-phone.ts:31-35), but the actual probe type only allows oauth | token | unknown (apps/desktop/src/global.d.ts:588-595) and probeRemoteAuthMode() only returns oauth or token (apps/desktop/electron/main.ts:7612-7645). The new test covers a value production cannot produce.

Suggested changes

  • Implement and integration-test a real public-proxy gate activation path, or remove/reject the loopback-tunnel deployment guidance.
  • Align the handoff-mode predicate with the real probe contract, or extend that contract end-to-end.

Automated hermes-sweeper review.

Comment thread website/docs/user-guide/features/web-dashboard.md Outdated
Comment thread apps/desktop/src/lib/continue-on-phone.ts Outdated
@joelbrilliant
joelbrilliant force-pushed the feat/dashboard-qr-handoff branch from b8a4e65 to 70cb702 Compare July 30, 2026 11:31
@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 30, 2026
@joelbrilliant
joelbrilliant force-pushed the feat/dashboard-qr-handoff branch from 70cb702 to df132dc Compare July 31, 2026 18:37
@joelbrilliant

joelbrilliant commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased this onto current main (6ecd335) and refreshed the branch at df132dccf.\n\nCurrent proof:\n\n- 199 focused backend auth, handoff, runtime and browser tests\n- 19 focused Desktop handoff tests\n- full Desktop gate: 3,179 UI tests, 899 platform tests, typecheck, lint, app build and macOS DMG build\n- full web gate: 144 tests, typecheck, lint and production build\n- phone viewport check at 393 x 852 with no horizontal overflow\n\nThe security model is unchanged: a single-use 120-second handoff ticket, a 45-minute resume-scoped cookie, the ticket stripped on consume, session/profile/channel-bound REST and WebSocket access, and default-deny everywhere else.\n\nHosted CI is now fully green: 40 successful checks, 0 failing and 0 pending. The PR is mergeable and ready for maintainer review.

joelbrilliant and others added 14 commits August 3, 2026 05:15
… phone handoff

Add a separate handoff ticket kind (hnd_ prefix, TTL 120s, single-use) that
authenticated desk sessions can mint via POST /api/auth/handoff-ticket. Gated
middleware consumes ?handoff= on cookie-less requests, sets a resume-scoped
session cookie (never superuser/API_SERVER_KEY/*), and 302s with the param
stripped. Cross-use with WS tickets is rejected on both paths.
F-01: non-empty Session.scopes is restricted; middleware + _require_token
default-deny except a narrow resume allowlist (auth/me, ws-ticket scoped,
bound session GET, SPA shell). Resume WS tickets carry allowed_endpoints.

F-02: mint validates session_id/profile exist; consume redirect uses only
ticket-bound resume/profile (ticket wins over client query).

F-03: ?handoff= consume only on GET .../chat; /api and POST do not mint.

F-04: HANDOFF_SESSION_TTL_SECONDS shortened to 45m.

Oscar probe cases extended in test_dashboard_auth_handoff.py.
…ume scopes

Oscar 1.1 HOLD M1-M4: empty resume WS allowlist until destination bind;
exact GET /chat consume only; single exact_handoff_scopes_or_none validator;
real env sink routes + hostile path/WS/scope tests.
Oscar F-01: only exact ASGI GET path /chat and raw b"/chat" may consume.
Prefix still applies after consume for redirect Location and cookie Path.
Add live middleware cases for /hermes proxy, nested, and api-shaped attacks.
Fail closed when raw_path is missing, None, non-bytes, or non-canonical.
Closes Oscar F-02 (slice 1.4): decoded path lookalikes without wire bytes
must not mint cookies or burn the ticket.
- Stamp the active gateway profile on sessions resolved by id in
  resolveStoredSession (matching upsertOptimisticSession), so the
  continue-on-phone URL resumes against the owning profile instead of
  whichever profile the gateway is on later.
- Reword the continue-on-phone unavailable description (en/ja/zh/zh-hant)
  to require OAuth browser sign-in and state that token-authenticated
  dashboards cannot be opened from a phone browser; update the dialog
  test to match.
- Clarify the session-actions-menu OPEN comment: continue on phone is
  orthogonal to tab-vs-window, so every surface always offers it.
- test(web-server): cover /api/dashboard/remote-access through real
  config loading by writing dashboard.public_url into the isolated
  HERMES_HOME config.yaml instead of monkeypatching resolve_public_url.
- docs(web-dashboard): document GET /api/dashboard/remote-access and add
  a "Continue a session on your phone" section covering prerequisites
  (public HTTPS URL, OAuth browser sign-in, no token-proxy topologies)
  and that the QR code encodes no credentials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ccess)

Remote and continue-on-phone access are only safe because gated mode serves
the SPA WITHOUT the long-lived _SESSION_TOKEN in the HTML - the browser
authenticates with a cookie session instead. The docs for exposing the
dashboard behind a reverse proxy rest entirely on that property, but nothing
asserted it: the existing _serve_index tests only cover theme bootstrap.

If it regressed, every operator following the documented proxy setup would
ship a long-lived dashboard credential to the public internet in plain HTML,
and a phone-handoff QR would become a full-privilege token leak.

Pins both directions so a future change cannot silently flip which mode gets
the credential:
- gated (auth_required) -> token absent, __HERMES_AUTH_REQUIRED__=true
- loopback              -> token present, __HERMES_AUTH_REQUIRED__=false

Note for reviewers: mount_spa(application) takes an app argument but
_serve_index reads gating from the module-level web_server.app. Same object
in production; the test helper documents and uses that real seam rather than
the mounted app, which would silently read False.

497 passed in tests/hermes_cli/test_web_server.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test set auth_required on the module-level app because that is what
_serve_index currently reads. A separate fix (fix/mount-spa-app-scope)
corrects it to read the app it was mounted on, which would break this test
depending on merge order. Set both seams so the invariant holds either way.
Spin a real uvicorn on 127.0.0.1 and prove desk mint → phone consume →
resume land, plus env/config/foreign-session and unscoped WS denials
against the running server (not TestClient-only).
…sUrl

Document handoff lifetime/revocation and tunnel access-log capture of
?handoff= before the app strips it. Add localhost runtime proofs for
120s ticket expiry, 45m resume TTL contract, logout revoke, and a real
Chromium path that consumes handoff then exercises the gated WS ticket
+ event_channel overwrite used by buildWsUrl.
`web_dist` is a gitignored build artifact and CI's Python test slices never
run `npm run build`, so the fixture's hard assert on `web_dist/index.html`
failed slice 8/8 on every run.

It also took down `test_s5_ttl_and_revocation_contract`, which does not need
the SPA at all: it exercises the handoff TTL and logout revocation contract
over HTTP, and `web_server` already guards its own SPA mount with
`not WEB_DIST.exists()`, so the runtime server boots fine without it. That
lost real security coverage in CI for an artifact the test never touched.

Move the requirement to the one test that genuinely needs a built shell
(`test_s5_browser_build_ws_url_after_handoff` asserts on the gated SPA's
injected globals) and make it a skip, matching the existing agent-browser
skip beside it.

Verified by hiding `hermes_cli/web_dist` to reproduce CI: the TTL contract
test passes and the browser test skips with a clear reason. With the SPA
present, all 620 focused tests still pass.
@joelbrilliant
joelbrilliant force-pushed the feat/dashboard-qr-handoff branch from df132dc to e33becb Compare August 2, 2026 19:27
@joelbrilliant

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and refreshed the branch at e33becb25.

The key hardening in this update is that the one-time handoff ticket no longer appears in the HTTP request URL. Desktop now emits /handoff#ticket=.... A nonce-protected same-origin bootstrap removes the fragment from browser history, exchanges it in a JSON POST body, sets the existing resume-scoped cookie, and opens the server-bound session.

Current proof:

  • 223 focused backend auth, handoff, runtime, and browser tests
  • 14 focused Desktop Continue-on-phone UI tests
  • Desktop typecheck, lint, and production build
  • Web typecheck, 144 tests, lint, and production build
  • Real Chromium at an emulated iPhone 12 viewport, including exact-session landing and scoped WebSocket continuation

The PR body now records the overlap and remaining boundary. A physical iOS Safari and Android Chrome scan through a real HTTPS route is still the final device check.

@joelbrilliant joelbrilliant changed the title feat(dashboard): secure phone QR handoff (resume-scoped, gated SPA) feat(desktop): add secure Continue on phone Aug 3, 2026
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/sessions Session lifecycle, resume, persistence, history comp/dashboard Web dashboard / control panel UI (dashboard/, landing) comp/desktop Electron desktop app (apps/desktop/*) needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants