Repository navigation
feat(remote): live remote-browser web UI at /remote/ - #11
Merged
Merged
Conversation
Extend the single global --allow-domains list into layered policies:
- internal/policy: domain rules (allow/deny, *.sub, *), principals and a
JSON --policy-file defining named tokens with their own rules, an admin
flag and a scripts permission. --deny-domains adds a global denylist.
- Auth attaches the token's principal to the request context; --token is
the admin "default" principal.
- Tab ownership: non-admin tokens only see their own tabs, get their own
per-site default tabs, and cannot adopt existing tabs.
- core.RulesPolicy checks global ∩ requester ∩ owner ∩ session rules,
re-checks the final URL, and guards restricted tabs in the browser via
the DevTools Fetch domain so redirects, clicks and page script obey the
same rules.
- API: session rules on tab create, GET/PUT /tabs/{id}/policy, /whoami.
WebDriver: tether:options.policy, ownership on every command, and
Execute Script gated by the scripts permission.
- Docs: docs/POLICIES.md, SECURITY.md, ARCHITECTURE.md, README, example
policy file. Integration test drives a real Chrome as a restricted token.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfsJJgC4Jp8VqckdcChH4W
Replace the /mcp stub with a real Model Context Protocol server on the shared session core: - Streamable HTTP transport (MCP 2025-06-18, 2025-03-26, 2024-11-05): Mcp-Session-Id sessions, protocol-version checks, batches, 202 for notifications, DELETE to end a session. Inherits token auth, the loopback guard and --allow-domains from the server. - 16 browser tools: tabs/new/select/close, navigate, history, snapshot (interactive elements with stable backend-node refs plus page text), click, type, press_key, select_option, scroll, wait_for, eval, screenshot and in-tab fetch with the user's cookies. Tool annotations, isError results with stable codes, policy checked against the page's current URL, password values and Set-Cookie never returned, tool arguments never logged. - Each MCP session tracks a current tab and the tabs it opened; ending the session (or idling past --idle-timeout) closes only those. - `tether mcp`: stdio bridge for Claude Desktop and other stdio clients, local or across a tailnet, with ordered forwarding. - --mcp flag (default on) to turn the face off. Also fix screenshots of idle background tabs hanging forever (Chrome does not composite hidden tabs): probe briefly, then bring the tab to the front and retry, as ChromeDriver does. Affects WebDriver too. Docs: docs/MCP.md, README, ARCHITECTURE, SECURITY threat model, CHANGELOG. Tests: unit tests with the cdp fakes and a real headless Chrome integration test driving every major tool. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HpbHKDrhpTtF3U8jAhD61N
Replace the /remote stub with a real interactive face, embedded in the binary and built on the shared session core: - Live video of any Chrome tab via CDP Page.startScreencast, with screenshot keyframes for quiet and background tabs (faster for a few seconds after input). One screencast per tab is shared by every viewer; each viewer keeps only the newest frame. - Mouse, wheel, touch, keyboard (IME and mobile keyboards), paste, copy and JavaScript dialog relay over a small documented websocket protocol. Input bypasses the session lock so a person can always intervene. - Address bar (through Core.Navigate and the domain policy), back/forward/reload, bring to front, open/close/adopt tabs, watch-only mode, phone layout, light and dark themes. Plain HTML/CSS/JS, no build. - Token-protected servers get a sign-in page that trades the token for an HttpOnly, SameSite=Strict, path-scoped HMAC cookie accepted only under /remote/. Strict CSP, no framing, Origin-checked websocket. - New cdp.Screen capability (screencast, mouse, insertText, dialogs, bringToFront), cdptest fakes, --no-remote and --remote-quality flags. - Unit tests, a real-Chrome integration test, and docs/REMOTE.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzmmijqffKE848kSpCqq72
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HpbHKDrhpTtF3U8jAhD61N
…rint Close the remaining WebDriver gaps on top of new cdp.Tab primitives: - Switch To Frame / Parent Frame for same- and cross-site (out-of-process) frames at any depth, via cdp.Tab.InFrame and iframe auto-attach. - Get Element Shadow Root and Find From Shadow Root, including closed roots; shadow roots round-trip through scripts. - Alert commands and unhandledPromptBehavior; calls blocked by a dialog fail fast instead of hanging. - Multiple windows: New Window, popups joined automatically, switching, closing; the session ends with its last window. - Window rect, maximise, minimise and fullscreen, applied only to windows that hold nothing but Tether tabs. - File inputs via Element Send Keys plus Selenium's /se/file upload. - Print Page. - Focus emulation on attached tabs so input reaches background tabs with cross-site iframes promptly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhaxD4mv92X8w5zV8j34MD
Apply the same HTTPS detection to the logout cookie as to the login one, and test both. Plain-HTTP loopback/tailnet use keeps non-Secure cookies, which browsers would otherwise refuse to store. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzmmijqffKE848kSpCqq72
POST/DELETE /session/{id}/actions dispatch key, mouse, pen, touch and
wheel input as real CDP events, tick by tick, with element/pointer/viewport
origins, animated moves and scrolls, click counting, US-layout Shift, and
input state that persists until Release Actions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJnokVdV3oFBMpLc45zcJ
…t click Chrome routes input into frames asynchronously, and on some versions a real click into a cross-site frame of a background tab never arrives. Arm a one-shot listener before clicking inside a frame, wait briefly for it, and click from script if the real click did not land. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhaxD4mv92X8w5zV8j34MD
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhaxD4mv92X8w5zV8j34MD
Page.startScreencast fails with "Not attached to an active page" while a navigation commits, which ended the stream for a viewer that connected to a freshly opened tab (seen in CI). Retry the start on every keyframe tick instead of giving up, and re-issue it after each main-frame navigation, which can swap the page's renderer and silently end the stream. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzmmijqffKE848kSpCqq72
Attaching to a tab that is closing can fail after Target.getTargetInfo succeeds (Page.enable: session not found). Map that to ErrNoSuchTab so faces answer 410/no such window instead of 500. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhaxD4mv92X8w5zV8j34MD
Resolves conflicts with #7 (console logs): Tab gets both Guard and Logs, the API keeps the policy routes and adds /tabs/{id}/logs, and docs list both features. Also makes TestConsoleLogs tolerate a late favicon 404 between the two WebDriver log reads (it asserted the second read was empty; it should assert no entry repeats). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
Tab gains Mouse/Touch alongside Guard and Logs; the WebDriver session keeps the log cursor and the Actions input state; docs list everything. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
# Conflicts: # README.md # internal/cdp/cdptest/cdptest.go
Conflict resolution plus one fix the combination needs: popups and new windows now belong to the session that opened them. A popup is attached as the session's own tab (same owner, same session rules, guarded), so a non-admin token can switch to its popups, and a restricted session can no longer leave its allowlist through window.open or New Window. A popup that loaded a forbidden page before Tether attached is closed and the switch fails. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
# Conflicts: # CHANGELOG.md # docs/WEBDRIVER.md # internal/cdp/cdptest/cdptest.go # internal/webdriver/errors.go # internal/webdriver/webdriver.go # test/integration/integration_test.go
Conflict resolution: the fake tab serves MCP's OnCall/Props and the WebDriver wrappers; app wiring keeps per-token credentials and the optional MCP face; docs describe MCP alongside per-token policies. TestNavigateHonoursPolicy now simulates drift with SetURL, since the browser-side guard (from policies) refuses the navigation it used. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
# Conflicts: # README.md # internal/cdp/cdptest/cdptest.go # test/integration/integration_test.go
The UI login cookie is now per token and carries that token's principal, so the remote UI is held to tab ownership and rules like every face. Non-admin tokens list only their own tabs, and viewed tabs get the browser-side navigation guard. Screen drops Mouse and HandleDialog in favour of the Tab methods. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
# Conflicts: # CHANGELOG.md # CONTRIBUTING.md # README.md # SECURITY.md # cmd/tether/main.go # docs/ARCHITECTURE.md # internal/app/app.go # internal/cdp/cdptest/cdptest.go # internal/config/config.go # internal/core/core.go # internal/server/server.go # internal/session/session.go
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Requested by Adeel · project thread
What
Before:
/remoteanswered501 not_implemented.After: opening
http://127.0.0.1:8787/remote/(or just the server address in a browser) shows a web app embedded in the binary. You can pick any Chrome tab, watch it live, and drive it with mouse, wheel, touch, keyboard (including IME and mobile keyboards) and the clipboard. It also handlesalert/confirm/promptdialogs, has an address bar with back/forward/reload, lets you open and close tabs, has a watch-only mode, and works on phones in light and dark themes. You can take over the exact tab an agent or Selenium script is driving.How: frames come from CDP
Page.startScreencastand go out as binary websocket messages. Quiet or background tabs get topped up withPage.captureScreenshotkeyframes, at 4/s for a few seconds after any input. Each tab runs one screencast hub shared by every viewer, and each viewer keeps only the newest frame. Input goes through a new optionalcdp.Screencapability (Input.dispatchMouseEvent,insertText, dialogs,bringToFront) and skips the per-tab lock, so a person can always step in. Address-bar navigation goes throughCore.Navigate, so--allow-domainsapplies. New flags:--no-remoteand--remote-quality. The protocol, the reasoning behind using a websocket rather than WebRTC, and the limitations are indocs/REMOTE.md.Why
Adeel asked for every stubbed face to ship in the first release, not just the stubs.
How tested
make checkequivalent: gofmt,go vet, golangci-lint (0 issues),go test -race ./....internal/remote88% andinternal/server89% coverage.make test-integrationagainst real headless Chromium. The newTestRemoteUIstreams JPEG frames, clicks a form field at its on-screen position, types, pastes, submits with Enter, navigates and goes back, answers aprompt(), and closes the tab.Referrer-Policy: no-referrermade the form's Originnull.make crossand govulncheck pass.docs/REMOTE.md(new), ARCHITECTURE, SECURITY, CONTRIBUTING, CHANGELOG.Security
--token,/remote/logintrades the token for anHttpOnly,SameSite=Strictcookie scoped to/remote. The cookie is an HMAC of the token and is accepted only under/remote/, never by the proxy, API or WebDriver. Login is checked withSec-Fetch-Site/Origin, and failed attempts are slowed down.script-src 'self'andframe-ancestors 'none', plusX-Frame-Options: DENY. The websocket rejects foreign Origins and allows onlyX-Forwarded-Hostfortailscale serve.Does not touch
internal/proxy. It adds small, additive changes tocdp.KeyEvent(Commands), thecdptestfake,server.Options(UIPrefix) and config.🤖 Generated with Claude Code
https://claude.ai/code/session_01UzmmijqffKE848kSpCqq72
Generated by Claude Code