Skip to content
This repository was archived by the owner on Sep 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion browser_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@
from __future__ import annotations

import asyncio
import hashlib
import hmac
import json
import logging
import secrets
import subprocess
import time

# Module-scope fastapi imports (the host always provides fastapi; __init__.py catches
# ImportError so the tools still serve if the panel can't import).
from fastapi import APIRouter, Body, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Body, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse

from . import browser_stream
Expand All @@ -30,6 +34,62 @@
log = logging.getLogger("protoagent.plugins.agent_browser")


# ── /panel/dash auth gate: a short-lived, HMAC-signed session cookie ──────────────
# The full-mode dashboard proxy (``/panel/dash`` + its CDP screencast WS) loads in an
# iframe, which cannot carry an ``Authorization: Bearer`` header — so on a token-gated
# deployment it would be reachable *unauthenticated*. Gate it in two steps: the panel
# mints a signed token via the bearer-gated ``POST /dash-session``, then presents it
# once as ``?dash=`` on the proxy URL, whose response sets the ``ab_session`` cookie.
# The cookie MUST be set from the proxy's own URL — RFC 6265 path-matching means a
# cookie Path-scoped to the page surface set from a ``/api/...`` response is dropped
# by browsers. The signing key is per-boot + in-memory ONLY: a cookie can never
# outlive a restart, and there is no key at rest to steal. Non-token-gated
# deployments leave the gate open (backward compatible — nothing has to mint a cookie).
_DASH_COOKIE = "ab_session"
_DASH_COOKIE_PATH = "/plugins/agent_browser/" # cookie scope: the plugin's page surface only
_DASH_TTL = 300 # ~5 min — long enough to load, short enough to leak-proof
_DASH_KEY = secrets.token_bytes(32) # per-boot random HMAC key; in-memory, never persisted


def _dash_cookie_path(request_path: str) -> str:
"""Cookie Path for ``ab_session``, derived from the URL that sets it so it always
path-matches (RFC 6265) — ``/plugins/agent_browser/`` on the host,
``/agents/<slug>/plugins/agent_browser/`` through the fleet proxy."""
i = request_path.find(_DASH_COOKIE_PATH)
return request_path[: i + len(_DASH_COOKIE_PATH)] if i >= 0 else _DASH_COOKIE_PATH


def _dash_sig(expiry: str) -> str:
return hmac.new(_DASH_KEY, expiry.encode(), hashlib.sha256).hexdigest()


def mint_dash_token(now: float | None = None, ttl: int = _DASH_TTL) -> str:
"""Mint an HMAC-signed ``<expiry>.<sig>`` token good for ~5 min. Called only from the
bearer-gated ``POST /dash-session``, so possession proves the caller cleared the gate."""
exp = int((time.time() if now is None else now) + ttl)
return f"{exp}.{_dash_sig(str(exp))}"


def verify_dash_token(token: str, now: float | None = None) -> bool:
"""True iff ``token`` carries a valid signature (constant-time compare) and hasn't
expired. A missing/tampered signature or a re-dated expiry can't forge the HMAC."""
exp_s, _, sig = (token or "").partition(".")
if not sig or not hmac.compare_digest(sig, _dash_sig(exp_s)):
return False
try:
exp = int(exp_s)
except ValueError:
return False
return exp >= int(time.time() if now is None else now)


def _dash_auth_required(cfg: dict | None) -> bool:
"""The gate bites only on a token-gated deployment — the host signals that by setting
``require_auth`` truthy in the plugin config (the same host that applies the operator
bearer gate). Absent/false ⇒ the proxy stays open (backward compatible)."""
return bool((cfg or {}).get("require_auth"))


def build_panel_router(cfg: dict | None):
cfg = cfg or {}
home = str(cfg.get("home_url") or "").strip()
Expand All @@ -44,6 +104,33 @@ def build_panel_router(cfg: dict | None):
async def _panel():
return HTMLResponse(_INTERACTIVE_PAGE.replace("__HOME_URL__", home_literal))

@router.get("/panel/dash")
async def _panel_dash(request: Request):
"""Full-mode dashboard proxy entry — loaded in an iframe, so it can't carry a
bearer. On a token-gated deployment it admits the signed ``ab_session`` cookie
or a fresh ``?dash=<token>`` minted by the bearer-gated ``POST /dash-session``
(401 on a missing / tampered / expired one); a ``?dash=`` entry answers with the
``Set-Cookie`` — it must happen HERE, not on the ``/api`` POST, because browsers
drop a cookie whose Path doesn't path-match the URL that set it — so reloads and
the plugin's sub-requests ride the cookie without re-minting. A non-gated
deployment serves openly (backward compatible)."""
page = _INTERACTIVE_PAGE.replace("__HOME_URL__", home_literal)
if not _dash_auth_required(cfg) or verify_dash_token(request.cookies.get(_DASH_COOKIE, "")):
return HTMLResponse(page)
token = request.query_params.get("dash", "")
if not verify_dash_token(token):
return JSONResponse({"error": "unauthorized"}, status_code=401)
# First entry via the minted token: exchange it for the path-scoped session
# cookie. Secure when the origin — or the TLS-terminating proxy's
# ``X-Forwarded-Proto`` — is HTTPS.
secure = (request.url.scheme == "https"
or request.headers.get("x-forwarded-proto", "").lower() == "https")
resp = HTMLResponse(page)
resp.set_cookie(_DASH_COOKIE, token, max_age=_DASH_TTL,
path=_dash_cookie_path(request.url.path),
httponly=True, samesite="strict", secure=secure)
return resp

return router


Expand Down Expand Up @@ -72,6 +159,18 @@ def _run(*args: str) -> tuple[int, str]:
except subprocess.TimeoutExpired:
return 124, "timed out"

# ── /panel/dash gate: mint the signed session token (bearer-gated route) ─────────
@router.post("/dash-session")
async def _dash_session():
"""Mint the short-lived signed token that unlocks the ``/panel/dash`` proxy.
HTTP + under ``/api`` ⇒ it rides the host operator-bearer gate, so only an
authenticated console can mint one. The token rides the body, NOT a Set-Cookie:
a cookie Path-scoped to the page surface would not path-match this ``/api/...``
URL, and browsers drop such a cookie (RFC 6265). The panel presents it once as
``?dash=`` on the proxy URL, whose response sets the real path-matched
``ab_session`` cookie (HttpOnly + SameSite=Strict + Secure-on-HTTPS)."""
return JSONResponse({"ok": True, "dash": mint_dash_token()})

# ── interactive stream: a single-use ticket (gated) + the WS bridge (self-gated) ──
@router.post("/stream-ticket")
async def _stream_ticket():
Expand Down Expand Up @@ -260,8 +359,24 @@ async def _nav(body: dict = Body(...)):
u.searchParams.set("ticket", ticket);
return u.toString();
}
// ── /panel/dash auth: set the short-lived ab_session cookie BEFORE pointing the
// iframe/WS at the full-mode proxy. On a token-gated deployment the proxy 401s a
// cookieless request; apiFetch (operator bearer) mints a signed token, then ONE
// same-origin GET presents it as ?dash= — the proxy answers THAT request with the
// path-scoped cookie (a Set-Cookie must path-match its own URL or browsers drop it,
// so the /api POST can't set it). A non-gated deployment doesn't need it, and a
// failure must not block the stream — best-effort, awaited only to order it before
// the connect. ──
async function ensureDashSession(){
try{
const r=await kit.apiFetch("/api/plugins/agent_browser/dash-session",{method:"POST"});
const t=(await r.json()).dash;
if(t) await fetch(BASE+"/plugins/agent_browser/panel/dash?dash="+encodeURIComponent(t));
}catch(_){}
}
async function connect(){
clearTimeout(retry);
await ensureDashSession(); // set the ab_session cookie before opening the (proxied) stream
try{
const r=await kit.apiFetch("/api/plugins/agent_browser/stream-ticket",{method:"POST"});
const ticket=(await r.json()).ticket;
Expand Down
138 changes: 138 additions & 0 deletions tests/test_agent_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,141 @@ def test_nav_open_applies_launch_flags(monkeypatch):
assert rec[-1] == ["agent-browser", "reload"]


# ── /panel/dash signed-cookie auth gate (mint token + proxy gate) ─────────────────


def test_dash_token_roundtrips_and_expires():
# a fresh token verifies through its ~5-min window; one minted in the past is expired;
# a flipped signature byte or a re-dated expiry can never forge the HMAC.
tok = bp.mint_dash_token(now=1000, ttl=300)
assert bp.verify_dash_token(tok, now=1000) is True
assert bp.verify_dash_token(tok, now=1299) is True
assert bp.verify_dash_token(tok, now=1301) is False # past expiry → rejected
exp, _, sig = tok.partition(".")
bad_sig = sig[:-1] + ("1" if sig.endswith("0") else "0")
assert bp.verify_dash_token(f"{exp}.{bad_sig}", now=1000) is False # tampered signature
assert bp.verify_dash_token(f"99999999999.{sig}", now=1000) is False # re-dated expiry
assert bp.verify_dash_token("", now=1000) is False
assert bp.verify_dash_token("garbage-no-dot", now=1000) is False


def test_dash_session_mints_signed_token_without_api_cookie():
from fastapi.testclient import TestClient

r = TestClient(_app({"require_auth": True})).post("/api/plugins/agent_browser/dash-session")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert bp.verify_dash_token(body["dash"]) is True # a real, verifiable signed token
# NO Set-Cookie on the /api response: a Path=/plugins/agent_browser/ cookie would
# not path-match this /api/... URL and browsers drop it (RFC 6265) — the ?dash=
# exchange on the proxy itself sets the cookie instead.
assert "set-cookie" not in r.headers


def test_dash_query_token_exchanges_for_path_matched_cookie():
from fastapi.testclient import TestClient

c = TestClient(_app({"require_auth": True}))
r = c.get(f"/plugins/agent_browser/panel/dash?dash={bp.mint_dash_token()}")
assert r.status_code == 200 and 'id="cv"' in r.text # a valid minted token admits entry
sc = r.headers["set-cookie"]
low = sc.lower()
assert sc.startswith("ab_session=")
assert "httponly" in low # not readable from JS
assert "samesite=strict" in low # no cross-site send
assert "path=/plugins/agent_browser/" in low # scoped to the plugin's page surface
assert "max-age=300" in low # ~5-min TTL
# the Path path-matches the URL that set it (RFC 6265 — else browsers drop the cookie)
cookie_path = next(p.split("=", 1)[1] for p in sc.split(";")
if p.strip().lower().startswith("path="))
assert "/plugins/agent_browser/panel/dash".startswith(cookie_path)
# the cookie value is a real, verifiable signed token…
token = sc.split("ab_session=", 1)[1].split(";", 1)[0]
assert bp.verify_dash_token(token) is True
# …and the retained cookie admits a plain reload with no query token
assert c.get("/plugins/agent_browser/panel/dash").status_code == 200


def test_dash_cookie_path_follows_proxy_prefix():
from fastapi import FastAPI
from fastapi.testclient import TestClient

# through the fleet proxy the page surface lives under /agents/<slug>/… — the cookie
# Path must follow the actual request URL or the browser would never send it back.
app = FastAPI()
app.include_router(bp.build_panel_router({"require_auth": True}),
prefix="/agents/slug/plugins/agent_browser")
r = TestClient(app).get(
f"/agents/slug/plugins/agent_browser/panel/dash?dash={bp.mint_dash_token()}")
assert "path=/agents/slug/plugins/agent_browser/" in r.headers["set-cookie"].lower()


def test_dash_cookie_is_secure_only_on_https():
from fastapi.testclient import TestClient

app = _app({"require_auth": True})
url = "/plugins/agent_browser/panel/dash"
http = TestClient(app).get(f"{url}?dash={bp.mint_dash_token()}")
assert "secure" not in http.headers["set-cookie"].lower() # plain HTTP → no Secure flag
https = TestClient(app, base_url="https://testserver").get(f"{url}?dash={bp.mint_dash_token()}")
assert "secure" in https.headers["set-cookie"].lower() # HTTPS origin → Secure
xfp = TestClient(app).get(f"{url}?dash={bp.mint_dash_token()}",
headers={"X-Forwarded-Proto": "https"})
assert "secure" in xfp.headers["set-cookie"].lower() # TLS-terminating proxy → Secure


def test_dash_proxy_rejects_without_cookie_when_gated():
from fastapi.testclient import TestClient

# token-gated deployment: the iframe proxy is unreachable without a valid cookie
# or a valid minted ?dash= token.
c = TestClient(_app({"require_auth": True}))
assert c.get("/plugins/agent_browser/panel/dash").status_code == 401
assert c.get("/plugins/agent_browser/panel/dash?dash=9999999999.forged").status_code == 401
expired = bp.mint_dash_token(now=0)
assert c.get(f"/plugins/agent_browser/panel/dash?dash={expired}").status_code == 401


def test_dash_proxy_serves_with_valid_cookie_when_gated():
from fastapi.testclient import TestClient

c = TestClient(_app({"require_auth": True}))
token = bp.mint_dash_token()
r = c.get("/plugins/agent_browser/panel/dash", headers={"Cookie": f"ab_session={token}"})
assert r.status_code == 200 and 'id="cv"' in r.text # the drivable dashboard served


def test_dash_proxy_rejects_expired_or_tampered_cookie_when_gated():
from fastapi.testclient import TestClient

c = TestClient(_app({"require_auth": True}))
expired = bp.mint_dash_token(now=0) # exp far in the past
assert c.get("/plugins/agent_browser/panel/dash",
headers={"Cookie": f"ab_session={expired}"}).status_code == 401
assert c.get("/plugins/agent_browser/panel/dash",
headers={"Cookie": "ab_session=9999999999.deadbeef"}).status_code == 401 # bad signature


def test_dash_proxy_open_when_not_gated():
from fastapi.testclient import TestClient

# backward compatible: a deployment that doesn't require bearer auth serves the proxy
# even with no cookie at all.
r = TestClient(_app({})).get("/plugins/agent_browser/panel/dash")
assert r.status_code == 200 and 'id="cv"' in r.text


def test_panel_page_mints_dash_session_before_stream():
from fastapi.testclient import TestClient

html = TestClient(_app({})).get("/plugins/agent_browser/panel").text
# the panel mints a token via the (bearer-gated) dash-session route, then presents
# it as ?dash= to the proxy — whose response sets the path-matched ab_session cookie…
assert "/api/plugins/agent_browser/dash-session" in html and "ensureDashSession" in html
assert "/plugins/agent_browser/panel/dash?dash=" in html
# …and it does so from connect(), before the stream WS handshake.
assert "await ensureDashSession();" in html
assert html.index("await ensureDashSession();") < html.index("new WebSocket(")


Loading