Skip to content

feat: add secure browser human handoff - #92350

Open
Reksely wants to merge 4 commits into
NousResearch:mainfrom
Reksely:feature/browser-human-handoff
Open

feat: add secure browser human handoff#92350
Reksely wants to merge 4 commits into
NousResearch:mainfrom
Reksely:feature/browser-human-handoff

Conversation

@Reksely

@Reksely Reksely commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

What changed

  • retain Browser Use live-control URLs in Hermes-owned cloud sessions and reuse those sessions from browser_exec
  • give every bot/task a separate background harness daemon and separate managed cloud Chrome instance, including unnamed sessions and identical friendly session names
  • add a 30-minute, no-login bearer handoff page with embedded remote control, fallback link, and Done action
  • DM the configured Discord owner with precise instructions and the handoff URL
  • hash handoff tokens at rest, expire/revoke them, redact them from access logs, and cancel them on browser cleanup
  • preserve the exact cloud browser while the human controls it and throughout the resumed agent turn, then close every browser owned by the bot during normal task cleanup
  • resume only the exact pinned Hermes session after Done or expiry, including raw API sessions and multiplex profiles
  • hard-bound public-route rate-limit state and add profile-aware retry sweeping

Configuration

browser:
  handoff:
    enabled: true
    public_base_url: "https://hermes.example.com"
    ttl_minutes: 30
    discord_user_id: "1063878950851448853"

The HTTPS reverse proxy must route /browser-handoff/ and multiplex /p/<profile>/browser-handoff/ paths to the gateway API server. Browser handoff is available only for Browser Use cloud sessions that provide liveUrl; cloud mode is what provides full per-bot Chrome-process isolation and background concurrency.

Risk / blast radius

This adds two intentionally API-key-free, bearer-token-authenticated routes. Tokens have 256 bits of randomness, are stored only by SHA-256 digest, expire in at most 30 minutes, are one-shot, profile-scoped, hard-rate-limited, no-store/no-referrer, redacted from access logs, and pinned to one browser and one Hermes session. All other API server routes retain their existing authentication.

Validation

  • 186 focused tests passed across browser isolation/session expiry, handoff state/security, gateway routing/browser-control surfaces, multiplex routing, wake delivery, and turn finalization
  • Python compile checks passed for all touched production modules
  • git diff --check passed
  • independent gpt-5.6-sol high-reasoning review completed; all seven substantive findings were fixed and regression-tested

Merge

Owner approval to merge was given in the implementation thread.

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins tool/browser Browser automation (CDP, Playwright) P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 22, 2026

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

Blocking review (GitHub will not permit this identity to set REQUEST_CHANGES without explicit repository review access).

The core handoff machinery is thoughtfully built: the bearer is 256-bit and digest-only at rest, terminal states stop rendering the live URL, browser retention is tied to the durable handoff/wake state, claim_wake() is an atomic single-delivery boundary, and the turn loop now has an explicit wait boundary rather than asking the model to keep acting while the human owns the browser. I also verified this exact head against current main (999703fd43ab6d75c4a5c7bc8b610dd73ecece76): CI 32584694775, Docker 32584694468, and Nix 32584694449 are all green.

There is one blocking routing defect in the claimed raw-API + multiplex path.

Blocker — a /p/<profile> API-origin handoff loses the selected profile before the URL and wake identities are persisted.

The production chain on this head is:

  1. APIServerAdapter's profile-prefix middleware correctly resolves /p/worker/..., sets _api_request_profile, and enters _profile_scope("worker"). That makes get_hermes_home() / secrets / profile-local state point at the worker profile for the request.
  2. _bind_api_server_session() is documented as the single structural chokepoint for every API-server agent entry, but its set_session_vars(...) call binds platform="api_server", session/chat identity, browser-control identity, and async_delivery=False without binding profile.
  3. gateway/browser_handoff.py::_source_from_context() derives durable handoff routing identity only from the HERMES_SESSION_* context and adds source["profile"] only when HERMES_SESSION_PROFILE is non-empty. For the API path above it therefore persists an API source with no profile.
  4. create_browser_handoff() is still executing inside the worker profile's runtime scope, so BrowserHandoffStore() writes the token row to the worker profile's state/browser-handoffs.db. But the public link is constructed from source.get("profile"); because that field is absent, the DM contains /browser-handoff/<token> instead of /p/worker/browser-handoff/<token>. The recipient lands on the default-profile route/database, which cannot find the worker-profile token.
  5. Even if the operator manually repairs the URL, the row itself still has no profile. _deliver_browser_handoff_wake() later calls deliver_wake(..., profile=str(record.source.get("profile") or "")), so the raw API resume path is also told to target the default profile rather than the profile that owns the paused session.

That breaks two explicit claims of this PR at the same boundary: that handoff tokens are profile-scoped, and that Done/expiry resumes the exact pinned raw API session under multiplexing.

The current tests miss this because they manufacture the identity that production fails to bind: test_secondary_profile_handoff_link_uses_profile_prefix explicitly sets HERMES_SESSION_PROFILE=worker, and test_api_origin_wake_uses_raw_session_without_gateway_lookup constructs a source dict containing "profile": "worker". Neither test traverses the real /p/worker middleware → _bind_api_server_session()browser_exec(action="handoff") path.

Required repair: make the URL-selected profile a server-owned part of API session context at the admission chokepoint (or carry an equivalent explicit profile capability through the handoff source), rather than relying on ambient runtime-home scoping while leaving HERMES_SESSION_PROFILE blank. Then add a production-path multiplex regression proving all four identities agree: a /p/worker raw API turn creates the row only in worker state, emits a /p/worker/browser-handoff/... URL, Done/expiry resumes under worker, and the default profile cannot consume or wake that capability. Keep the existing single/default-profile behavior unchanged.

Architecture/provenance: #90435 by @ctaylor86 is complementary Desktop Viewer / human-agent ownership work, not a duplicate of this Browser Use cloud lane. #84000 by @SolshineCode is likewise the complementary visible-Chrome human-assist lane. The already-merged extension-controller authority is #91535 by @kshitijk4poor, which explicitly preserves @abundantbeing's #85351 implementation provenance; this PR should remain a distinct cloud/Discord handoff mechanism rather than being treated as superseding that lineage.

Once the profile identity is bound at API-session admission and the integrated multiplex regression closes this path, the rest of the handoff mechanism I checked looks coherent.

@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

Reviewed the 16-file diff (+1951/-113) with focus on gateway/browser_handoff.py, the API-server routes, wake delivery, and the test files.

What's good

  • The credential model is right for a human-facing link: 32-byte secrets.token_hex bearer, only its SHA-256 digest stored, released live-URL gated on pending+unexpired, TTL hard-clamped to ≤30 minutes, one-shot completion with an idempotent two-state wake machine so double-clicks can't spawn duplicate turns.
  • public_base_url validation refuses non-HTTPS, userinfo, query, and fragment — that kills an entire class of "handoff link points somewhere else" misconfigurations, and the handoff page renders through html.escape with no-store/no-referrer headers.
  • Session pinning discipline is correct throughout: the wake resumes only the exact pinned Hermes session (raw API sessions and multiplex profiles included), cancel_for_browser revokes pending handoffs during browser cleanup, and the wake message tells the agent to verify the login rather than assume success — that instruction matters more than it looks.
  • Test coverage spans handoff state/security, gateway routing, multiplex routing, wake delivery, and session expiry — the surfaces where regressions would be exploitable.

Suggestions

  1. Token-in-URL residuals: with no-referrer headers and log redaction the main leaks are covered, but the link still lands in Discord message history and the human's browser history. Since the mitigation story is otherwise so tight, consider documenting "treat the link as a 30-minute credential" next to the config sample, and whether a #fragment variant is feasible long-term (fragments never reach server logs).
  2. Silent clamp on ttl_minutes: values above 30 are quietly reduced rather than rejected. Given this is a security bound, failing setup with a clear message (or logging the clamp once at startup) would beat surprising an operator who asked for 120 minutes.
  3. send_discord_handoff_dm failure path: if the bot token is absent or Discord errors, does the flow still surface the fallback link somewhere the user will see? If it only logs, consider echoing the handoff URL in the agent's own reply text as last resort.
  4. Nit: the design doc under docs/superpowers/specs/ ships in-tree — confirm that directory is intended for durable specs rather than a working-notes location, since it becomes public documentation of the security model.

Impressively thorough security posture for a feature whose whole job is handing credentials to a browser.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages 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 tool/browser Browser automation (CDP, Playwright) type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants