feat(desktop): add secure Continue on phone - #71579
joelbrilliant wants to merge 30 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
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: currentshould_require_auth()returnsFalsefor loopback binds (hermes_cli/web_server.py:442-461), andstart_server()assigns that result toapp.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 allowsoauth | token | unknown(apps/desktop/src/global.d.ts:588-595) andprobeRemoteAuthMode()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.
b8a4e65 to
70cb702
Compare
70cb702 to
df132dc
Compare
|
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. |
… 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.
df132dc to
e33becb
Compare
|
Rebased onto current main and refreshed the branch at The key hardening in this update is that the one-time handoff ticket no longer appears in the HTTP request URL. Desktop now emits Current proof:
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. |
Summary
Adds a secure, persistent Continue on phone flow to Hermes Desktop.
Users can open it from:
/remote-controlor/rcDesktop 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:
It cannot access:
An ended session asks the user to link it again rather than creating a new session from the phone.
Security boundary
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
afaa7e67d