-
-
Notifications
You must be signed in to change notification settings - Fork 272
feat(browser): hosted browser + WebMCP runtime — W2 through W7-prep #4489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
chelojimenez
merged 17 commits into
main
from
claude/hosted-browser-webmcp-audit-hhecd9
Aug 31, 2026
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
94441a5
feat(browserd): durable session ensure — status probe, stream cache
claude c7632f5
fix(browserd): mode-aware sessions, CAS record, credential-path harde…
claude 836e13b
feat(browserd): act verbs, WebMCP bridge, observation budgets (W3a)
claude 1e9f406
feat(browser-tools): six browser_* tools, fail-closed approval, merge…
claude 9c5b056
feat(evals): declared browser toolPolicy for unattended runs (W6.0)
claude d1f7c57
feat(browserd): ephemeral context mode for unattended runs (W6)
claude a49c774
feat(browserd): human-handoff lease with a pre-queue 423 gate
claude 2dc8cd9
feat(computers): Browser Panel — watch, and take control when needed
claude 7601218
feat(webmcp): bridge V1 onto browserd, and say where the browser is
claude 40f80bb
feat(computers): honor the backend's hosted-browser gate, and stop
claude 17eb82b
Merge remote-tracking branch 'origin/main' into claude/hosted-browser…
claude 2f2d6ff
fix(browserd): six review findings — timer leak, aborted lookup, budg…
claude 00c51e6
fix(browserd): make the profile mode impossible to omit
claude 8673503
refactor(panel): attachSession returns nothing, because nothing read it
claude e6bb81a
fix(browserd): a handoff's console must not outlive the handoff
claude c5433dc
fix(browserd): back-to-back handoffs must not drop the earlier purge …
chelojimenez e486da5
fix(browser): the model must see the page, and the coordinate space m…
chelojimenez File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
248 changes: 248 additions & 0 deletions
248
mcpjam-inspector/client/src/components/computer/BrowserPanel.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| /** | ||
| * Browser Panel — watch the browser an agent is driving, and take it when a | ||
| * login or a challenge needs a person. | ||
| * | ||
| * Two states that matter, and the difference between them is the whole | ||
| * feature: | ||
| * | ||
| * WATCHING (default) — the noVNC stream is embedded view-only. Anyone with | ||
| * the panel open can see what the agent is doing. This is deliberately the | ||
| * default (L10): making people take control just to look would push them | ||
| * into the disruptive action every time. | ||
| * | ||
| * HOLDING — the person clicked "Take control". The daemon now refuses every | ||
| * model-driven command AND every observation (a 423 before the queue), so | ||
| * nothing captures the screen while a password is on it. The stream turns | ||
| * interactive. Handing back is explicit, and the agent is told the page may | ||
| * have changed. | ||
| * | ||
| * A lease that stops being heartbeaten PARKS rather than freeing: if this tab | ||
| * is closed mid-login, the agent does not resume underneath the person. That | ||
| * is a deliberate bias toward "stuck" over "surprising"; the panel says so. | ||
| * | ||
| * Nothing here is persisted. The stream is live only. | ||
| */ | ||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||
| import { useMintBrowserToken } from "@/hooks/useProjectComputer"; | ||
|
|
||
| /** Heartbeat cadence while holding the lease (the daemon TTL is 2 minutes). */ | ||
| const LEASE_HEARTBEAT_MS = 30_000; | ||
| /** Keepalive cadence while merely watching. */ | ||
| const KEEPALIVE_MS = 60_000; | ||
|
|
||
| type LeaseState = | ||
| | { state: "free" } | ||
| | { state: "held"; holder: string; expiresAt?: number } | ||
| | { state: "parked"; holder: string } | ||
| | { state: "unknown" }; | ||
|
|
||
| interface SessionInfo { | ||
| bootId: string; | ||
| streamUrl: string; | ||
| streamPassword: string; | ||
| lease: LeaseState; | ||
| } | ||
|
|
||
| export interface BrowserPanelProps { | ||
| projectId: string; | ||
| /** Boot a browser if none is running yet. Off by default: opening a panel | ||
| * should not start a machine's browser behind the user's back. */ | ||
| ensure?: boolean; | ||
| } | ||
|
|
||
| export function BrowserPanel({ projectId, ensure = false }: BrowserPanelProps) { | ||
| const mintBrowserToken = useMintBrowserToken(); | ||
| const [session, setSession] = useState<SessionInfo | null>(null); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [busy, setBusy] = useState(false); | ||
| const [holding, setHolding] = useState(false); | ||
| // A tab that is not visible must not keep a machine awake. | ||
| const visibleRef = useRef(true); | ||
|
|
||
| /** Every call mints its own token: they last ~60s, so caching one across a | ||
| * panel's lifetime would just produce expiry failures. */ | ||
| const authorized = useCallback( | ||
| async (path: string, init: RequestInit = {}): Promise<Response> => { | ||
| const { token } = await mintBrowserToken({ projectId }); | ||
| const headers = new Headers(init.headers); | ||
| headers.set("authorization", `Bearer ${token}`); | ||
| if (init.body) headers.set("content-type", "application/json"); | ||
| return fetch(`/api/web/computers/browser${path}`, { ...init, headers }); | ||
| }, | ||
| [mintBrowserToken, projectId], | ||
| ); | ||
|
|
||
| const refresh = useCallback(async () => { | ||
| try { | ||
| const res = await authorized(`/session${ensure ? "?ensure=1" : ""}`); | ||
| const body = await res.json(); | ||
| if (!res.ok) { | ||
| setSession(null); | ||
| setError( | ||
| body?.error === "no_browser_session" | ||
| ? "No browser is running on this computer yet." | ||
| : (body?.detail ?? body?.error ?? "Could not reach the browser."), | ||
| ); | ||
| return; | ||
| } | ||
| setSession(body as SessionInfo); | ||
| setError(null); | ||
| } catch (cause) { | ||
| setError(cause instanceof Error ? cause.message : String(cause)); | ||
| } | ||
| }, [authorized, ensure]); | ||
|
|
||
| useEffect(() => { | ||
| void refresh(); | ||
| }, [refresh]); | ||
|
|
||
| useEffect(() => { | ||
| const onVisibility = () => { | ||
| visibleRef.current = document.visibilityState === "visible"; | ||
| }; | ||
| document.addEventListener("visibilitychange", onVisibility); | ||
| return () => document.removeEventListener("visibilitychange", onVisibility); | ||
| }, []); | ||
|
|
||
| // Keepalive while watching — only while the tab is actually visible, and the | ||
| // server decides whether an open panel still counts at all. | ||
| useEffect(() => { | ||
| if (!session) return; | ||
| const timer = setInterval(() => { | ||
| if (!visibleRef.current) return; | ||
| void authorized("/keepalive", { method: "POST" }).catch(() => {}); | ||
| }, KEEPALIVE_MS); | ||
| return () => clearInterval(timer); | ||
| }, [authorized, session]); | ||
|
|
||
| // Heartbeat while holding. Stopping (closing the tab, losing the network) | ||
| // parks the lease rather than freeing it, so the agent stays stopped. | ||
| useEffect(() => { | ||
| if (!holding) return; | ||
| const timer = setInterval(() => { | ||
| void authorized("/lease", { | ||
| method: "POST", | ||
| body: JSON.stringify({ action: "heartbeat" }), | ||
| }).catch(() => {}); | ||
| }, LEASE_HEARTBEAT_MS); | ||
| return () => clearInterval(timer); | ||
| }, [authorized, holding]); | ||
|
|
||
| const changeLease = useCallback( | ||
| async (action: "acquire" | "resume") => { | ||
| setBusy(true); | ||
| try { | ||
| const res = await authorized("/lease", { | ||
| method: "POST", | ||
| body: JSON.stringify({ action }), | ||
| }); | ||
| const body = await res.json(); | ||
| if (!res.ok) { | ||
| setError( | ||
| body?.lease?.holder | ||
| ? "Someone else is using this browser right now." | ||
| : "Could not change control of the browser.", | ||
| ); | ||
| return; | ||
| } | ||
| setHolding(action === "acquire"); | ||
| setError(null); | ||
| await refresh(); | ||
| } finally { | ||
| setBusy(false); | ||
| } | ||
| }, | ||
| [authorized, refresh], | ||
| ); | ||
|
|
||
| if (error && !session) { | ||
| return ( | ||
| <div className="p-4 text-sm text-muted-foreground"> | ||
| <p>{error}</p> | ||
| <button | ||
| className="mt-2 underline" | ||
| onClick={() => void refresh()} | ||
| type="button" | ||
| > | ||
| Try again | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (!session) { | ||
| return ( | ||
| <div className="p-4 text-sm text-muted-foreground"> | ||
| Connecting to the browser… | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const heldByOther = | ||
| session.lease.state === "held" && | ||
| !holding && | ||
| session.lease.holder !== undefined; | ||
| const parked = session.lease.state === "parked"; | ||
|
|
||
| return ( | ||
| <div className="flex h-full flex-col"> | ||
| <div className="flex items-center gap-3 border-b px-3 py-2 text-sm"> | ||
| <span className="font-medium"> | ||
| {holding ? "You have control" : "Watching"} | ||
| </span> | ||
| {heldByOther && ( | ||
| <span className="text-muted-foreground"> | ||
| Someone else is using this browser. | ||
| </span> | ||
| )} | ||
| {parked && !holding && ( | ||
| <span className="text-muted-foreground"> | ||
| Paused — a person took control and has not handed it back. | ||
| </span> | ||
| )} | ||
| <div className="ml-auto flex gap-2"> | ||
| {holding ? ( | ||
| <button | ||
| type="button" | ||
| disabled={busy} | ||
| onClick={() => void changeLease("resume")} | ||
| className="rounded border px-2 py-1" | ||
| > | ||
| Hand back to the agent | ||
| </button> | ||
| ) : ( | ||
| <button | ||
| type="button" | ||
| disabled={busy || heldByOther} | ||
| onClick={() => void changeLease("acquire")} | ||
| className="rounded border px-2 py-1" | ||
| > | ||
| Take control | ||
| </button> | ||
| )} | ||
| </div> | ||
| </div> | ||
|
|
||
| {holding && ( | ||
| <p className="border-b px-3 py-2 text-xs text-muted-foreground"> | ||
| While you have control, the agent is stopped and nothing is being | ||
| captured — no screenshots, no page text. Hand control back when you | ||
| are done; the agent will be told the page may have changed. | ||
| </p> | ||
| )} | ||
|
|
||
| <iframe | ||
| // `view_only` is the interaction gate; the daemon-side lease is the | ||
| // real one. Both, deliberately: this stops a stray click, the 423 | ||
| // stops everything else. | ||
| src={`${session.streamUrl}?autoconnect=true&resize=scale&password=${encodeURIComponent( | ||
| session.streamPassword, | ||
| )}${holding ? "" : "&view_only=true"}`} | ||
| title="Computer browser" | ||
| className="min-h-0 flex-1 border-0" | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default BrowserPanel; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: MCPJam/inspector
Length of output: 50373
🏁 Script executed:
Repository: MCPJam/inspector
Length of output: 15042
🏁 Script executed:
Repository: MCPJam/inspector
Length of output: 50372
🏁 Script executed:
Repository: MCPJam/inspector
Length of output: 50372
🏁 Script executed:
Repository: MCPJam/inspector
Length of output: 12410
Authorization Bypass (CWE-862): Missing Authorization
Reachability: External · Exploitability: Moderate
Disable browser interaction when the lease heartbeat fails.
Treat rejected requests and non-OK responses as lease loss. Set
holdingtofalseand refresh the session. The current code keeps the iframe interactive because its URL omitsview_only=true; the daemon gate only blocks model commands. Add aBrowserPanel.test.tsxregression test for both failure cases.🤖 Prompt for AI Agents
Source: Coding guidelines