Findings
Browser session_id review: three tools missed the key, none checked it
Date: 2026-09-16
Area: packages/server/src/plugins/internal/browser.ts, packages/portal/src/components/CdpScreencast.tsx
What was reviewed#
The whole browser feature after the 2026-09-15 change that moved the routing
key into tool arguments (browser_start mints session_id, /mcp lifts it
into X-Browser-Session and proxies to INTERNAL_MCP_URL): agent path over
MCP, human path over the portal's live view, and how the two stay on one
Chromium.
What holds up#
- The portal already carries the routing header everywhere.
attachmints it, andevents,commands,detach, the connect-link redeem, and the portal-initiated live URL all sendX-Browser-Session. Nothing to add on the React side. - Human and agent share the same tab. The bridge's
ensureUpstreamdialssession.cdpPageWsUrlof the sameWarmSessionthe tools use, so the live view is the agent's page, not a sibling target. Closing the tab detaches the channel and leaves Chromium running; the agent's next call continues there. - The link is a claim, not a capability. Redeem checks the portal session matches the user in the token before warming anything (2026-09-02 finding).
What did not#
- Three tools had no
session_id:browser_expect_download,browser_await_download,browser_upload_file. UnderINTERNAL_MCP_URLthese were never proxied, so they ran on a random replica:ensureSessionthere spawns a second Chromium on the shared profile and fights over theSingletonLock;awaitDownloadlooks up a handle in aMapthat lives in another process and reports it unknown. Single-replica deployments never see it, which is why it survived. - No handler verified the key it was given. It was required by the
schema and then ignored — routing only. Behind a load balancer a wrong key
has already been hashed to the wrong replica when it arrives, and running
the tool there is exactly the double-spawn above. The bridge's
keyed()guard already refuses a bad key withBAD_SESSION_KEY; the tools now do the same, beforeensureSession.
What session_id is#
HMAC(SESSION_SECRET, userId). Same value every call, per user, forever
(until the secret rotates). It is a routing key so an L7 proxy can hash on it,
and a cheap "you meant this user" check. It is not a session handle: one
user has one Chromium, one profile lock, one page. Calling browser_start
twice gives the same key; two agents on one user share a tab. Multiple
concurrent sessions per user would need per-session CDP targets (tabs) inside
that one Chromium, with the routing key still per user — a real feature, not
a tweak, and out of scope here. The docs now say so instead of implying
otherwise.
Also added#
browser_evaluate — Runtime.evaluate with returnByValue and
awaitPromise, the page.evaluate shape. Selector-driven clicks and DOM
scraping were impossible with coordinates alone. A thrown exception comes
back as EVALUATION_FAILED, a result over 100k characters as
RESULT_TOO_LARGE rather than truncated (a sliced JSON string is not JSON).
files_read and files_write descriptions now name files_presign as the
path for anything large. The old text said "use the REST endpoint", which is
not something the model can call.