{"id":"361dee98-92c5-4eb3-ba87-dd01404bacec","slug":"sallyport","kind":"mcp","name":"sallyport","summary":"A security-first bridge between Claude Code (MCP) and Chrome — HMAC pairing, domain allowlist, per-domain evaluate opt-in.","owner":"ginkida","repoUrl":"https://github.com/ginkida/sallyport","license":"MIT","licenseTier":"permissive","trust":"community","stars":11,"match":null,"fewStars":false,"format":"mcp","createdAt":null,"addedAt":"2026-10-14T05:24:07.946Z","logoUrl":"https://avatars.githubusercontent.com/u/3363127?u=7308bcad6fd9e91bc0ba2e4386dd207a45ec574e&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["browser-web","developer-tools","cloud-infra"],"tags":["browser-automation","chrome-extension","claude","hmac","security","ai-agents","anthropic","chrome-devtools-protocol"],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-14T07:28:11.576Z","sample":false,"url":"https://conduits.dev/tools/sallyport","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# Sallyport\n\n[![CI](https://github.com/ginkida/sallyport/actions/workflows/ci.yml/badge.svg)](https://github.com/ginkida/sallyport/actions/workflows/ci.yml)\n[![CodeQL](https://github.com/ginkida/sallyport/actions/workflows/codeql.yml/badge.svg)](https://github.com/ginkida/sallyport/actions/workflows/codeql.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n[![Release](https://img.shields.io/github/v/release/ginkida/sallyport)](https://github.com/ginkida/sallyport/releases/latest)\n\nA secure browser-automation bridge between Claude Code (or any MCP client) and\nyour Chrome. An alternative to Kimi WebBridge with explicit security\nboundaries instead of implicit ones.\n\n```\nClaude Code ── MCP/stdio ──▶ daemon ── WS+HMAC ──▶ extension ── CDP ──▶ Chrome\n```\n\n| Status | Number |\n|---|---|\n| Daemon tests (pytest) | 539 |\n| Extension tests (vitest) | 1206 |\n| Lint / typecheck (ruff, mypy, eslint, prettier, tsc) | all green |\n\n## What's in the box\n\n| Path | What it is |\n|---|---|\n| `extension/` | MV3 Chrome extension (TypeScript, esbuild, vitest). Loads as an unpacked extension. |\n| `daemon/` | Python MCP server. Speaks MCP on stdio to Claude Code, hosts a WS server on `127.0.0.1:10086` for the extension. |\n| `fixtures/` | Cross-language canonical-JSON / HMAC vectors shared by both test suites. |\n| `.pre-commit-config.yaml` | Fast lint/format checks before commit. |\n| `.github/workflows/ci.yml` | Same checks plus full tests on push/PR. |\n\n## Security model\n\nA deeper threat model + known limitations lives in [`SECURITY.md`](SECURITY.md).\nThe short version: the original Kimi extension trusts any process that can\nreach `127.0.0.1:10086`, which on a shared/compromised machine means\neverything. Sallyport changes the default in five places:\n\n1. **HMAC-SHA256 on every frame.** A 32-byte random secret lives in\n   `~/.config/sallyport/secret` (chmod 600) and is generated on first run. Both\n   sides sign every WS frame and verify timestamp drift (≤ 30 s) and nonce\n   freshness (rolling cache of 4096 nonces — replay-protected and persisted\n   across extension service-worker eviction). A\n   cross-language test pin in pytest + vitest guarantees the canonical-JSON\n   and MAC bytes stay byte-for-byte compatible.\n2. **Domain allowlist enforced in the extension.** Tools refuse to run on any\n   URL whose host isn't in `chrome.storage.local.sallyport_allowlist`. Patterns\n   are `example.com`, `*.example.com`, or `https://x.com/path/*`. Bare `*` is\n   rejected by the validator.\n3. **`evaluate` is opt-in per domain.** Even on an allow-listed domain,\n   arbitrary JS is refused unless that entry has `allowEvaluate: true`. Other\n   tools (`click`, `fill`, `read_text`, …) use structured CDP calls only.\n4. **Defense-in-depth on inputs.** `fill` refuses `<input type=password>`\n   unless `allowPassword=true`. The daemon refuses to bind to anything that\n   isn't a loopback address. WS frames over 16 MiB are dropped (1009).\n5. **Operational visibility.** Every tool call (and its outcome — `ok` or\n   `error`) is appended to `chrome.storage.local.sallyport_audit` (last 500\n   entries), browsable and JSON-exportable from the popup. One-click **Pause**\n   in the popup stops the WS connection and rejects all tool calls.\n\nOther deliberate choices:\n\n- No content-script injection, no `<all_urls>` content scripts. Permissions\n  are only what the debugger API (and a popup-pin context-menu entry) need\n  (`tabs`, `debugger`, `storage`, `alarms`, `contextMenus`).\n- Per-tab accessibility refs (`@e1`, `@e2`). Snapshotting tab A cannot\n  invalidate refs for tab B, and a ref scoped to A cannot resolve to a node\n  in B. A ref is also bound to the document it came from: after any\n  cross-document navigation, including one the page starts itself (a link,\n  a form submit, a redirect), it fails as `bad_ref` instead of resolving to\n  an element of the new page, and ids are never reissued on a live tab.\n- MCP-side tool calls are serialised per client, with up to 8 clients running\n  concurrently. The extension also serialises calls per tab to protect shared\n  browser state.\n- The daemon shuts down cleanly on stdin EOF (Claude Code closing) or\n  SIGINT/SIGTERM: pending calls fail with `ExtensionNotConnected`, the\n  client gets a 1001 close, no orphan tasks.\n\nWhat the extension still trusts: anyone with read access to\n`~/.config/sallyport/secret`. The browser debugger is, ultimately, the browser\ndebugger — this bridge limits *which* domains it operates on and *who* can\ndrive it.\n\n## Setup\n\n### 1. Build the extension\n\nThe extension is **not** on PyPI — `pip install sallyport` (step 2) gives you\nonly the daemon. The extension lives in this repo's `extension/` directory, so\nyou need a checkout to build it:\n\n```sh\ngit clone https://github.com/ginkida/sallyport\ncd sallyport/extension\nnpm install\nnpm run build\n```\n\nThe output lands in `extension/dist/`. Load it as an unpacked extension:\n\n1. `chrome://extensions`\n2. Enable **Developer mode**\n3. **Load unpacked** → pick `extension/dist`\n\nPin the toolbar icon.\n\n### 2. Install the daemon\n\nSallyport needs **Python ≥ 3.10** (it uses match statements and `X | Y` type\nsyntax). Check with `python --version` first.\n\n```sh\npip install --user sallyport\n```\n\nOr from source (for development): `cd daemon && pip install --user -e .`\n\nThis installs the `sallyport-daemon` command on your `PATH`. Verify it landed there:\n\n```sh\nwhich sallyport-daemon   # should print a path; if not, add your Python\n                      # user-scripts dir (e.g. ~/.local/bin) to PATH\n```\n\nThe first time something runs it, the daemon will:\n\n- Generate a 32-byte secret in `~/.config/sallyport/secret` (chmod 600).\n- Print the base64 secret to stderr — paste it into the extension popup.\n- Start listening on `127.0.0.1:10086` and speak MCP on stdio.\n\nThen run the built-in setup check, which validates the install and prints the\nexact block to paste into the popup:\n\n```sh\nsallyport-daemon doctor\n```\n\nIt checks your Python version, the secret file and its permissions, and that\nthe port is free — then prints the pairing secret and the remaining steps.\nRun it any time a connection won't come up. To just re-print the secret:\n\n```sh\nsallyport-daemon --show-secret\n```\n\n### 3. Register with Claude Code\n\nAdd an MCP server entry — either edit `~/.claude/mcp.json` directly, or:\n\n```sh\n# Use the ABSOLUTE path. A GUI-launched Claude Code often doesn't inherit your\n# shell PATH, so a bare \"sallyport-daemon\" command silently fails to spawn.\nclaude mcp add sallyport \"$(which sallyport-daemon)\"\n```\n\n`sallyport-daemon doctor` prints this exact line with the path already resolved\n(and falls back to `python -m sallyport_daemon` if the console script isn't on\nPATH) — copy it from there if you're unsure. The result:\n\n```json\n{\n  \"mcpServers\": {\n    \"sallyport\": {\n      \"command\": \"/Users/you/.local/bin/sallyport-daemon\",\n      \"args\": []\n    }\n  }\n}\n```\n\nRestart Claude Code. The tools appear as `mcp__sallyport__navigate`,\n`mcp__sallyport__click`, etc.\n\n### 4. Pair the extension\n\n1. Open the popup. It will show a one-card **Pair** onboarding view.\n2. Get the secret. Run `sallyport-daemon doctor` (or `--show-secret`) in a\n   terminal and copy the printed block. **Note:** when Claude Code spawns the\n   daemon for you (step 3), the first-run banner goes to the daemon's stderr,\n   which Claude Code does not display — so `doctor`/`--show-secret` is the\n   reliable way to see it.\n3. Paste into the popup textarea — you can paste the whole banner; the\n   popup auto-extracts the base64 secret and shows\n   \"✓ secret detected (32 bytes)\".\n4. Click **Pair**. Status flips to **connected** with a pulsing green\n   indicator and an at-a-glance summary of allowed sites / recent calls.\n\n### 5. Add the first domain to the allowlist\n\nThe allowlist starts empty — every tool rejects every URL by default. In the\npopup's **Allowlist** tab, add an entry like `example.com` or\n`*.github.com`. Tick **allow evaluate()** only if you actually need\narbitrary JS on that host.\n\n## Several sessions, one browser (automatic)\n\nSallyport is built for the case where *you* are using Chrome while one or more\nClaude Code sessions drive tabs in it. That works out of the box: the first\n`sallyport-daemon` starts a small **broker** in the background and attaches to\nit, and every session after that attaches to the same one. Register Sallyport as\nin step 3 from **as many project folders as you like** — nothing else to set up.\n\n```sh\n# Nothing. Just start a second Claude Code session.\n```\n\nUnder the hood the broker owns the extension connection and the port; each\nsession relays its MCP over a `0600` Unix-domain socket next to the secret\n(`broker-10086.sock`). Before this, the second session simply failed to start —\nthe first one held the port — and had no browser tools at all.\n\nWhat you get:\n\n- **Sessions don't block each other.** Calls from different sessions run\n  concurrently (a session's own calls stay in order, and a tab is only ever\n  touched by one call at a time). One agent's 30-second wait no longer freezes\n  everyone else's next click.\n- **Each agent stays in its own tabs.** An agent can only see and act on tabs it\n  created (`navigate` with no tab opens a fresh one); it cannot touch — or even\n  list — your tabs or another agent's. `list_tabs` shows a session only its own.\n- **It won't steal your focus.** Each session's tabs live in its own un-focused,\n  muted window, and the window you were using is re-focused if Chrome tries to\n  raise the new one (only if the new window really took focus — it never pulls\n  a backgrounded Chrome to the front). Screenshots work there too, without foregrounding\n  anything. An agent closing or navigating away from its own tab never raises a\n  \"Leave site?\" / \"Close site?\" prompt — Chrome would bring the agent window to\n  the front to show it and wait for your click — while your own tabs, and an\n  agent tab you used or have in front of you, keep it.\n- **You can see who did what.** The popup's Audit tab tags every row with the\n  session (its folder name by default), and its **Agent tabs** section lists what\n  each session left open, with a one-click sweep.\n- **You're logged in.** These are ordinary tabs in your real Chrome profile, so\n  agents operate on the sites you're already signed into — scoped, as always, to\n  the allowlist. There is no incognito or separate profile involved: the\n  separation is about *who may drive which tab*, not about identity.\n\nUseful knobs:\n\n```sh\nsallyport-daemon --no-broker            # single session, own the port directly\n                                        # (or SALLYPORT_NO_BROKER=1)\nsallyport-daemon --session-label review # name this session in the audit log\nsallyport-daemon broker --idle-exit 3600  # start one explicitly, exit when idle\nsallyport-daemon doctor --stop-broker   # stop it (needed after an upgrade:\n                                        # a broker outlives `pip install -U`\n                                        # and serves its build to everyone)\n```\n\nThe broker is long-lived by design, so `doctor` treats it as the intended setup\nrather than a stale daemon: the port check reports **OK**, and\n`doctor --kill-stale` leaves it running.\n\nThis is a *software partition* of one shared profile, not an OS-level sandbox:\nthe security floor is still \"any process running as you\" (it can read the secret\nand drive the browser within the allowlist). See [`SECURITY.md`](SECURITY.md) for\nthe full model, including the tab-ownership and MCP-client-auth invariants.\n\n## Tools\n\n| Name | Notes |\n|---|---|\n| `list_tabs` | No allowlist check — listing is free. |\n| `navigate` | Checks the *destination* URL against allowlist. `waitFor={selector?,text?,absent?,timeoutMs?}` polls after the load until the page is actually usable (SPAs render long after \"loaded\"). |\n| `reload` | Hard reload via `bypassCache=true`. Allowlist-gated; refs invalidate. |\n| `history_go` | Back/forward through the tab's session history — return to the previous page without knowing its URL. `direction='back'\\|'forward'`, `steps` hops several entries in one jump (intermediate pages never load). The **landing** entry is allowlist-checked *before* anything moves (`Page.getNavigationHistory` → `Page.navigateToHistoryEntry`, no `evaluate`); too far → `no_history` with how far it does reach — neither error names the blocked page's host, to avoid an oracle over non-allowlisted browsing history. On a `timeout`, don't assume it failed: the hop can land before the load watchdog fires, so check with `read_text`/`snapshot` rather than blindly retrying. Conversely, if a `beforeunload` prompt cancels the hop (dismissed by default), that's caught too — `navigation_cancelled` instead of silently reporting success on a page the tab never actually reached (a hop not served from the back/forward cache is a live navigation, so the check tolerates a legitimate mid-hop redirect and reports wherever the tab actually landed, not just the requested entry's URL). Refs invalidate; optional `waitFor`. |\n| `close_tab` | `tabId` required — no implicit fallback (closing the wrong tab loses work). |\n| `snapshot` | Accessibility tree with stable `@eN` refs (per-tab), pruned of layout noise. Cross-checks against a DOM walk (same refs) when the a11y tree looks suspiciously sparse — Telegram Web K and similar SPAs. `mode=auto\\|a11y\\|dom`; `compact=true` → flat list of actionable elements only; `selector` scopes to one subtree. |\n| _(embedded)_ `observe` | Optional on every tool that takes `waitFor`. Returns the page as it is once the action settled — `observe:{snapshot:'compact'}` or `{text:true}` → `observed:{source, elements\\|tree, text?}` — so act→look is one call instead of two. Matters most after `navigate`/`reload`/`history_go`, which invalidate every `@eN` and thus made a follow-up snapshot unavoidable. Re-checks the allowlist against the page it is about to read, so a redirect off the allowlist comes back `skipped` with nothing read; a failure inside it never fails the action. |\n| `read_text` | Whole-page or by ref. No raw JS. Capped at 20 000 chars by default (`maxChars` overrides, ceiling 200 000; cut results carry `truncated`/`totalChars`/`nextOffset`). Pass `offset=<nextOffset>` to continue past a cut instead of re-reading from zero — and slices never split a surrogate pair, so an emoji on the boundary can't make the result unsignable. |\n| `get_state` | Cheap one-element probe (CSS or `@eN`) — `{exists, visible, tag, text, box?, inViewport?}` without a full snapshot. Verify an action's effect or re-check a ref in one round-trip. Never errors on a missing node: returns `{exists:false, reason}` (`not_found`/`unknown_ref`/`detached`), so it is safe to poll; `{exists:null, reason:'unknown'}` means an `@eN` could not be placed in the current page — not gone. Does **not** read input `.value` (no password readback). Structured CDP only. |\n| `console_tail` | Recent page console errors/warnings + uncaught exceptions for a tab (`{enabled, entries:[{ts,level,text,origin}]}`) — tell \"the handler threw and the page is wedged\" from \"merely slow\". **Opt-in** (popup setting, off by default; returns `{enabled:false}` when off). Capture starts at first attach (no replay); entries are **origin-filtered to the allowlist**. Pure CDP event capture, no `evaluate`. |\n| `network_tail` | Recent XHR/fetch responses for a tab (`{enabled, entries:[{ts,method,url,status,type,contentType,size,body?}]}`) — **the data behind canvas dashboards** (Metrika, Chart.js, WebGL) that have no readable DOM. Pull exact JSON instead of screenshot + vision. **Opt-in** (popup \"capture API responses\", off by default). Bodies only for textual content-types, capped; no auth headers captured; entries **origin-filtered to the allowlist**; `filter` narrows by URL substring. Pure CDP event capture, no `evaluate`. |\n| `handle_dialog` | See and steer native JS dialogs (`alert`/`confirm`/`prompt`/`beforeunload`) — an open dialog **freezes the page's JS** and is browser UI no other tool can click. With handling on, every dialog is answered the moment it opens (alert → OK, everything else → cancel) and recorded (`{enabled, armed, recent:[{ts,type,message,origin,response,armed}], truncated?}`); `action='accept'\\|'dismiss'` (+`promptText`) arms a **one-shot** answer for the tab's *next* dialog — arm, then click the button that opens `confirm()`. The arm is **origin-bound** to the page it was armed on (a cross-origin iframe can't hijack it) and is dropped the instant the tab's top-level frame navigates anywhere — link clicks, redirects, `navigate`/`reload`/`history_go` — so it can never fire on an unrelated later dialog. **Opt-in** (popup \"auto-handle JS dialogs\", off by default; returns `{enabled:false}` when off — and unchecking it mid-session actually stops auto-answering, not just future recording). Entries **origin-filtered to the allowlist**. Structured CDP only, no `evaluate`. |\n| `click` | DOM `.click()`. CSS selector or `@eN` ref. Optional `waitFor` polls for the click's effect in the same call. |\n| `mouse_click` | Real `Input.dispatchMouseEvent` as a full hover→press→release sequence. Auto-aims around partial overlays; a fully covered target reports `covered`/`hitTarget`/`hitTargetRef`. Explicit `x`/`y` (viewport CSS px) as manual aim. `button` left/middle/right, `clickCount` 1–3, optional `waitFor`. |\n| `hover` | Hover the pointer over an element/point without clicking (the `mouseMoved` preamble only). For CSS `:hover`-only menus, tooltips, row-action UIs. `selector`/`@eN` (auto-aimed, reports `covered`/`hitTargetRef`) or viewport `x`/`y`; optional `waitFor` to hover→wait-for-menu. Strictly weaker than `mouse_click`; the `:hover` state is transient. |\n| `fill` | Blocks password fields without `allowPassword=true` — the gate checks the resolved node **and** the node the keystrokes actually reach, following focus through open shadow roots and same-origin frames. `method=insertText` clears the field and types via CDP with real input events (for SPA editors that ignore programmatic values), then reads back and reports `applied` (`yes`/`no`/`unclear`) plus `len` — the field's length after the write, never its contents. Optional `waitFor`. |\n| `select_option` | Choose an option in a native `<select>` (the OS popup can't be driven via CDP). Sets the value in the DOM and fires `input`/`change` instead of opening the menu. One of `value`/`label`/`index`; array for `<select multiple>`. `wrong_element` for non-`<select>` targets — custom JS comboboxes (react-select, MUI) stay on `click`/`find`/`reveal`. Optional `waitFor`. |\n| `key_type` | Raw text input via CDP. Blocks when focus is on a password field without `allowPassword=true`. |\n| `send_keys` | `Mod+A`, `Shift+Tab`, etc. `Mod` = `Cmd` on macOS, `Ctrl` elsewhere. Same password-field gate as `key_type`. |\n| `screenshot` | PNG/JPEG as a native MCP image block. `maxWidth` downscales so the returned **image** is at most that many pixels wide (it accounts for the display's device pixel ratio and for any `set_viewport` emulation), `region={x,y,width,height}` crops (viewport-relative CSS px). Hidden tabs fail fast with `tab_not_visible`; `bringToFront=true` activates the tab first (steals focus). A capture too big for the wire fails with `screenshot_too_large` rather than dropping the connection — shrink it with `maxWidth`/`region`/`format=jpeg` (or a lower `deviceScaleFactor`). |\n| `set_viewport` | Emulate a device viewport — the way to test responsive/mobile layouts. Per **tab**, not a window resize: it disturbs nothing the human is looking at, and gives you a device pixel ratio and mobile `<meta name=viewport>` handling that no window size can. `preset` (`mobile-small` 375×667, `mobile` 393×852, `mobile-large` 412×915, `tablet` 820×1180, `desktop` 1280×800, `desktop-wide` 1920×1080) or explicit `width`+`height`, with `deviceScaleFactor` (≤ 3), `mobile`, `touch`, `orientation`. The mobile presets also present a mobile Chrome UA + matching UA client hints (`mobileUserAgent=false` opts out), so a UA-sniffing server sends its mobile bundle — set the viewport **before** navigating. `reset=true` restores the real viewport; no arguments at all reads the current one. Reports what the page actually sees, not what you asked for. Refs invalidate (a breakpoint change remounts DOM). Structured CDP only, no `evaluate`. |\n| `print_to_pdf` | Render the page to a PDF in the download sandbox — returns `{path, size, filename}`, so the bytes never enter the model context. Unlike `screenshot` it **needs no visible tab**: a background agent tab prints fine, which makes it the fallback when a capture fails with `tab_not_visible`. `landscape`, `printBackground` (default true), `scale` (0.1–2). `filename` is a single name with no path separators (default `print-<UTC timestamp>.pdf`) written under `~/Downloads/sallyport/` — same sandbox as `save_to_file`. Fails with `pdf_too_large` past the bridge frame cap (~9 MiB). Structured CDP only, no `evaluate`. |\n| `wait_for` | Poll (250 ms) until a selector/`@eN` ref is visible (for CSS: any match — not necessarily the first, which `click`/`fill` act on; with `absent=true`, gone means no match is visible) and/or page text contains a substring; `absent=true` waits until it is GONE. `timeoutMs` ≤ 30 s; timeout returns `{found:false}`, not an error. Replaces blind sleeps. Prefer the embedded `waitFor` on the preceding action when there is one. |\n| `settle` | Wait for the DOM to stop changing (no child, text or attribute mutations in the top-level document for `stableMs`, default 500 ms) — for \"the page just did *something*\" moments with no single element to `wait_for`. Catches equal-length edits and changes between polls. Excludes iframe/shadow-root contents, CSS animations and network activity. A navigation mid-wait continues on the new page. Poll (250 ms), ≤ 30 s; a never-settling page returns `{settled:false}`, not an error. |\n| `find` | Semantic element locator — match by `role`/`name`/`nameExact`/`value` over the accessibility tree instead of a CSS selector, ranked exact-match-first (`limit`, ≤ 50). No `evaluate`, no probe. |\n| `reveal` | Scroll a virtualized list/container and re-`snapshot` until an element matching `find`'s predicate appears — for infinite-scroll feeds and lazy-rendered tables. `container` is the element that scrolls; between steps reveal waits for its subtree to go quiet (≈ 0.5–1.5 s), so a clock or ticker elsewhere on the page doesn't slow it, and a step that reaches the end waits on the whole page for the next batch to load. Stops on found/stall/`maxSteps` (≤ 40)/timeout. |\n| `scroll` | Deterministic scrolling — the predicate-less companion to `reveal`. `selector` → `scrollIntoView`; or scroll the page (or a `selector` container) by `dx`/`dy` (negatives = up/left) or `to='top'\\|'bottom'`. Returns `{x, y, scrollHeight, atBottom}` so a lazy-load loop knows when to stop. Fixed scroll probe, no `evaluate`. |\n| `evaluate` | Per-domain opt-in. Returns `{type, value}`. |\n| `fetch_in_page` | `fetch()` with page cookies/auth. Returns `{status, contentType, headers, mode, data}`. `saveAs=<filename>` writes the body straight into the download sandbox instead — result becomes `{status, contentType, mode, path, size, filename}`, so a multi-MB asset never enters the agent's context, and it replaces the old `fetch_in_page` → `save_to_file` pair with one call (it also defaults `returnAs` to `base64`, since the text path would UTF-8-decode binary lossily). Bodies whose serialised payload exceeds 12 MiB fail with `fetch_too_large` (measured extension-side, on the escaped form — a control character costs six bytes in JSON); `saveAs` does **not** raise that ceiling, because the body still crosses the bridge before the daemon writes it. Allowlist-gated. |\n| `upload` | Attach local files to `<input type=file>` via `DOM.setFileInputFiles`. Paths must be absolute, `..`-free, **and resolve under `~/Downloads/sallyport/`** (override via `SALLYPORT_DOWNLOAD_DIR`) — same sandbox as `save_to_file`, with symlink escapes blocked by `Path.resolve()`. Target must really be a file input. Allowlist-gated. |\n| `save_to_file` | **Daemon-local** — writes base64 to `~/Downloads/sallyport/<filename>` (override via `SALLYPORT_DOWNLOAD_DIR`). Sandboxed: no path separators or `..`. |\n| `status` | **Daemon-answered** health check: `{connected, mode, version, port, pendingCalls, uptimeS, lastCalls, lastError, lastHandshakeError}`. `mode` is `broker` (explicit owned `tabId` required per call) or `standalone` (active-tab fallback). `lastCalls` is a ring of recent tool **outcomes** (`{tool, ok, ms, code?}` — never the args) and `lastError` the latest failure, so a loop can attribute a stall to a specific tool/code; when `connected` is false, `lastHandshakeError` says why the extension leg failed to attach (wrong secret, clock skew, no hello). No browser round-trip and never queues behind a running call — use it as preflight before browser work. |\n\nIn broker mode, target an owned tab with an explicit `tabId`; `navigate`\nwithout one creates an agent tab. Standalone mode supports an active-tab\nfallback for browser tools, except `close_tab`, which always requires `tabId`.\nThere is no implicit \"last touched tab\" memo.\n\nConsole/network capture starts on the next tool call after enabling it.\nSwitching either setting off immediately clears that capture's buffers on\nall tabs and switches the capture off in the browser as well. For\n`network_tail`, `bodyPending: true` means a body read is queued\nor still running; read again shortly. At most 4 body reads per tab and 16\nglobally are in flight at once; the rest wait in a per-tab queue bounded by\nthe 100-entry ring, so a burst of simultaneous responses still yields every\nbody. Only when that queue overflows does a response keep its metadata but\ncarry `bodyOmitted: true` and `bodyOmissionReason: \"capture_busy\"`, with the\nresult marked `truncated`; filtering cannot recover those bodies. Retained\nbody payloads are bounded across tabs — 10 MiB per tab and 40 MiB total,\nmeasured in the same wire bytes as the per-result budget — so older bodies\nwithin the same tab may be discarded; their metadata remains with\n`bodyOmissionReason: \"cache_limit\"`, and a tab cannot evict another tab's\nbodies. Chrome's own capture buffer is capped at 48 MB per tab and 10 MB per\nresponse, counted decoded — a body with any non-Latin-1 character is stored as\nUTF-16, so for it the per-response cap is ~5 MB raw (Chrome's defaults are\n200 MB / 20 MB). A body Chrome does not hold — over the per-response cap,\npushed out by newer traffic, or never stored at all (typically an empty\nresponse) — keeps its metadata with `bodyOmissionReason: \"unavailable\"`;\nre-fetch it with `fetch_in_page` only when the request is safe to repeat (an\nidempotent GET), never a POST or a signed one-shot call. These limits cover body\npayloads, not transient decoding, metadata or total process memory.\n\nFor agents running on a schedule, the cheap iteration shape is: `status`\n(skip everything if the extension is detached) → scoped reads\n(`snapshot selector=… compact=true`, `read_text ref=…`) → actions with\nembedded `waitFor` instead of separate `wait_for` calls → verify with\n`get_state ref=…` (one element) instead of re-snapshotting the whole page.\nDriven tabs are\nkept awake automatically, so the loop keeps working while the browser\nwindow sits in the background (see Troubleshooting for the trade-offs).\n\n## Compared to Kimi WebBridge\n\nSallyport implements the everyday Kimi tools (`navigate`, `click`, `fill`,\n`snapshot`, `screenshot`, `evaluate`, `mouse_click`, `upload`, …) and adds\na few of its own (`fetch_in_page`, `save_to_file`, `reload`). Three Kimi\nfeatures are deliberately *not* here:\n\n| Kimi feature | Why Sallyport omits it | If you need the behaviour |\n|---|---|---|\n| `network` (start/stop/list/detail HTTP capture via `Network.enable`) | Kimi's version captures auth headers, cookies, and every request body with no per-domain gate — that ungated shape is what Sallyport omits. | Use `network_tail`: a gated subset — opt-in per popup, **response bodies only** (no headers/cookies), origin-filtered to the allowlist. Or `fetch_in_page` against a specific URL. |\n| `_session` (per-agent Chrome tab groups, coloured) | Cosmetic flair that complicates tab handling without solving a real problem at current scale. | Use `list_tabs` to find what you opened. |\n\n`find_tab` is also intentionally absent: Sallyport's `list_tabs` returns the\nfull set and the agent filters client-side — one round trip instead of two.\n\n## Testing it locally\n\nThree layers, from fastest to most realistic:\n\n### A. Wire only — no MCP, no Claude Code\n\n> A running broker owns the port, so stop it first (`sallyport-daemon doctor\n> --stop-broker`) or pass a different `--port` and update the popup's daemon URL.\n\nConfirms HMAC pairing, allowlist, audit log without any tools firing.\n\n```sh\n# terminal: run the daemon in WS-only mode. Stays up until Ctrl-C —\n# no stdin / no MCP client needed.\nsallyport-daemon serve\n```\n\nDefault `sallyport-daemon` (no subcommand) expects an MCP client on stdin and\nexits immediately on EOF — fine for Claude Code, awkward for local testing.\nUse `serve` whenever you want a stable long-running daemon.\n\nOpen the popup → **Pair** → paste secret → status flips to **connected**.\nThat alone proves: WS reach, HMAC handshake, perms.\n\n### B. Fire individual tools from the shell — no Claude Code\n\n`sallyport-daemon exec <tool> key=value...` calls one tool and exits, printing\nthe result. Values are JSON when parseable, otherwise strings.\n\n```sh\n# Catalogue of tools (works offline, no extension needed):\nsallyport-daemon list-tools\n\n# Open a tab (the host must already be in the popup's allowlist):\nsallyport-daemon exec navigate url=https://example.com newTab=true\n\n# Read the page:\nsallyport-daemon exec read_text\n\n# Get the accessibility tree + refs:\nsallyport-daemon exec snapshot\n\n# Click something from snapshot (use a ref):\nsallyport-daemon exec click selector=@e3\n\n# Targeted screenshot:\nsallyport-daemon exec screenshot format=jpeg quality=70\n```\n\nNotes:\n- `exec` works **alongside** running Claude Code sessions: when a broker is up\n  (the normal case) it goes through it as a one-shot client rather than\n  fighting for the port. With no broker it owns the port itself, and is then\n  mutually exclusive with a running session — stop it, or pass a different\n  `--port` here and update the popup's daemon URL.\n- Without a broker, the first `exec` waits up to 10s (`--wait 30` to bump it)\n  for the popup to connect. Once paired, the extension reconnects on its own.\n- Screenshot blobs are truncated in the printout — they're still passed\n  in full to a real MCP client.\n\n### C. End-to-end with Claude Code\n\nOnce A and B work, register the MCP server (see Setup → step 3), restart\nClaude Code, and ask it to do anything web-shaped. Watch the popup's\n**Audit** tab — every call lands there with `ok`/`error` and target URL.\n\n## Troubleshooting\n\n| Symptom | Fix |\n|---|---|\n| Anything in setup feels off | Run `sallyport-daemon doctor` — it checks Python version, secret + perms, and port, and reprints the pairing block. |\n| Popup stuck on **\"Connecting to daemon…\"** | The daemon isn't reachable at the popup's URL. Confirm it's running (`sallyport-daemon serve` in another shell), that the **Advanced → daemon URL** matches (default `ws://127.0.0.1:10086/ws`), and that the port is free (`sallyport-daemon doctor`). The single-client rule means a live Claude Code session already holds the port — that's expected; the extension connects to *it*. |\n| Popup says **\"extension is not connected\"** | Daemon isn't running, or the popup says **paused** — open and hit **Resume**/**Reconnect**. |\n| `domain_not_allowed` | Add the host to the **Allowlist** tab. |\n| `evaluate_not_allowed` | Edit the allowlist entry and re-add with **allow evaluate()** checked. |\n| `password_field` (from `fill`) | `fill` refuses `<input type=password>` by default. Pass `allowPassword=true` if you really mean it. |\n| `wrong_element` (from `upload`) | Selector resolved to something other than `<input type=file>`. Re-`snapshot` and pick a real file-input ref. |\n| `wrong_element` (from `select_option`) | Target isn't a native `<select>` — it's a custom JS combobox (react-select, MUI, Radix). Those live in the DOM: `click`/`mouse_click` to open, then `click` the option (use `find`/`reveal` to locate it). |\n| `unsafe_path` (from `upload`) | Path contains `..`, isn't absolute, or resolves outside the sandbox (default `~/Downloads/sallyport/`). Stage the file via `save_to_file` first (writes to the sandbox), then upload. Widen the sandbox via `SALLYPORT_DOWNLOAD_DIR` if you really need to upload from elsewhere. |\n| `not_visible` (from `mouse_click`) | Element has zero size — likely `display:none` or detached. Snapshot again; if it's hidden by design, drive the toggle that reveals it. |\n| `mouse_click` reports `covered: true` | Another node sits on top of the target at every probe point. The result includes `hitTarget` (what ate the click) and `hitTargetRef` — an `@eN` for that node; click it directly, or aim manually with `mouse_click x= y=`. |\n| Automation stalls when the browser window is in the background | Chrome freezes background tabs and fully-occluded windows. The bridge keeps driven tabs awake automatically (popup → **Advanced → keep automated tabs awake**, default on; note the page then believes it is focused and visible — e.g. Telegram sends read receipts — and keeps rendering while attached, which costs CPU on animated pages). If a page must stay alive *before* the bridge attaches, add its site under `chrome://settings/performance` → \"Always keep these sites active\", or run a dedicated automation profile with `--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-background-timer-throttling`. |\n| `tab_not_visible` (from `screenshot`) | The tab produced no frame within 8s. `screenshot` already activates an agent tab inside its own un-focused window, so this means the window is **fully** occluded or minimised, or the display is asleep (macOS reports every window occluded once displays sleep). Keep a sliver of the agent window visible, or use `snapshot`/`read_text` (no frame needed) or `print_to_pdf` (renders a hidden tab). Check **keep automated tabs awake** is on — turning it off also stops the tab painting. `bringToFront=true` works in standalone and steals focus; it's refused in broker mode. |\n| Several sessions feel slower than one | They share one browser: up to 8 calls run at once, then calls queue. A call that waits out the queue fails `busy` (safe to retry — it was never sent). Back off exponentially: `maxConcurrentCalls` is a constant, and there is deliberately no per-caller contention signal (it would be a live read on how busy the other sessions are). |\n| A session lost its browser tools mid-run | Its broker went away. The session says so and exits non-zero; start it again (a new one starts a fresh broker). `sallyport-daemon doctor` shows the current state. |\n| Agents left tabs everywhere | There is a ceiling: **Max agent tabs at once** (popup → Settings, default 20). Past it, opening a new agent tab closes the least valuable ones first — tabs left by sessions that have already finished, then the creating session's own least-recently-used — never one you have looked at, and never another running session's. Set it to 0 to turn that off. Popup → **Agent tabs** lists what each session opened (marking the ones whose session has ended), with **Close all agent tabs**. Tabs otherwise survive the session that made them by default — closing an agent's half-finished work is the loss `close_tab`'s gate exists to prevent. For dispatched/one-shot agents, tick **close a session's tabs when it disconnects** in the popup; note it applies to EVERY session, including your own interactive one. |\n| `bad_ref` | An `@eN` ref is stale (snapshot expired) or addressed at the wrong tab. Re-`snapshot` the right tab. Refs are per-tab and per-snapshot. |\n| `mac mismatch` (in popup) | Secret in `~/.config/sallyport/secret` no longer matches the one paired in the popup. Run `sallyport-daemon --show-secret`, copy, **Unpair** → paste → **Pair**. |\n| `timestamp skew` | Clocks are >30 s apart. Check NTP. |\n| Daemon warns about **loose permissions** on startup | `chmod 600 ~/.config/sallyport/secret`. |\n| WS frame >16 MiB | The extension is silently dropped with a 1009 close. Lower screenshot quality or take a region screenshot. |\n\n### Rotating the secret\n\n1. `rm ~/.config/sallyport/secret`\n2. Restart whatever runs `sallyport-daemon` (Claude Code will respawn it on next call). A new secret prints to stderr.\n3. In the popup: **Unpair** → paste new secret → **Pair**.\n\n## Development\n\n```sh\n# Extension\ncd extension\nnpm install\nnpm run watch         # esbuild rebuild on save — reload extension in chrome://extensions to pick up\nnpm run typecheck     # tsc --noEmit\nnpm test              # vitest run\nnpm run lint          # eslint\nnpm run format        # prettier --write\nnpm run format:check  # prettier --check (CI gate)\n\n# Daemon\ncd daemon\npip install -e '.[dev]'\nruff check .\nmypy\npytest -q\nsallyport-daemon --verbose < <(sleep 99999)   # smoke-test a long-running daemon\n```\n\n### Real-browser capture smoke test\n\nWith Node 22+ and a Chromium / Chrome for Testing binary that supports unpacked\nextensions, run from `extension/`:\n\n```sh\nCHROME_BIN='/absolute/path/to/chromium' npm run test:browser\n```\n\nThe test creates an isolated temporary profile, loads the extension, and uses\na localhost fixture to exercise real console/network capture, immediate\nopt-out, re-enabling, debugger detach/re-attach, and popup cleanup of finished\nsessions while preserving active, human-viewed and non-agent tabs. It also\nverifies keyboard navigation, accessible tab state and long session names.\nIt removes its profile and stops its browser and fixture server when finished.\nIt does not use your\nnormal browser profile or pairing secret. This is an additional integration\ncheck; the deterministic unit tests still cover body-read races and overload.\n\nFor the complete MCP → daemon → extension → Chrome route, install the daemon\ndependencies in `daemon/.venv` and run:\n\n```sh\nCHROME_BIN='/absolute/path/to/chromium' npm run test:e2e\n```\n\n`SALLYPORT_TEST_PYTHON` can select a different Python interpreter with the daemon\ndependencies installed. This mode starts a standalone daemon on a temporary\nlocal port with a new test secret, then verifies MCP initialization and tool\ndiscovery, authenticated pairing, allowlist refusal, form filling and clicking,\nreading the result, password refusal, audit redaction, and waiting for a page\nwhose text and attributes keep changing without changing length, including\nDOM changes reverted between polls and a navigation during the wait. Both daemon and\nbrowser are stopped after the test. It does not connect to your usual daemon.\n\n### Pre-commit\n\n```sh\npip install --user pre-commit\npre-commit install\n```\n\nThis runs trailing-whitespace/json/yaml/merge-conflict checks plus ruff, mypy,\nprettier, eslint, and tsc on each commit. Heavy test suites stay in CI\nbecause they're too slow for a commit gate.\n\n### CI\n\n`.github/workflows/ci.yml` matrices over Python 3.10/3.11/3.12 (daemon) and\nNode 20/22 (extension), runs lint + typecheck + tests + build for both, and\nuploads `extension/dist` as an artefact on `main`.\n\n### Adding a new tool\n\nFive files, same order both for an extension-side tool and a daemon-local\none — keep tool names byte-identical on both sides since routing is a dict\nlookup.\n\n1. **Implement.** Extension-side: a new `extension/src/tools/<name>.ts`\n   exporting a `Tool`. Pure validators (path / arg shape) belong in a\n   chrome-free sibling module like `upload-path.ts` so vitest can cover\n   them without a chrome harness. Daemon-only: a coroutine in\n   `daemon/src/sallyport_daemon/local_tools.py` registered in `LOCAL_TOOLS`.\n2. **Register on the extension side.** Add an import + entry to the `tools`\n   map in `extension/src/tools.ts`.\n3. **Declare the MCP schema.** Append a `Tool(name=..., description=...,\n   inputSchema=...)` to `TOOLS` in `daemon/src/sallyport_daemon/mcp_server.py`.\n   Mark fields `required` whenever the implementation throws without them —\n   schema and behaviour should agree (see the `close_tab` regression\n   captured by `test_close_tab_requires_tab_id`).\n4. **Pin the catalogue.** Add the new name to the expected set in\n   `daemon/tests/test_mcp_server.py::test_tools_catalogue_covers_extension`.\n   Mismatches between the daemon and extension registries fail the build\n   immediately rather than at the wire.\n5. **Document.** A row in the Tools table in `README.md`, and an entry in\n   `CHANGELOG.md` under `[Unreleased] / Added` describing the user-visible\n   shape (args, gates, error codes worth knowing).\n\nThen `cd extension && npm test && npm run lint && npm run typecheck`\nand `cd daemon && ruff check . && mypy && pytest -q` — all green before\ncalling it done.\n\n## Versioning & releases\n\n`CHANGELOG.md` tracks every notable change; the project uses\n[Semantic Versioning](https://semver.org/). The wire protocol version is\n**1** — bump only on incompatible changes and update the cross-language\nvector tests in the same commit.\n\n## License\n\nMIT — see `LICENSE`.\n\n## Wire protocol\n\nDocumented in `extension/src/protocol.ts` and\n`daemon/src/sallyport_daemon/protocol.py`. Envelope shape:\n\n```\n{ v, ts, nonce, type, id?, body, mac }\n```\n\n`mac = HMAC-SHA256(secret, canonical_json({v, ts, nonce, type, id?, body}))`,\nbase64. Canonical JSON: keys sorted by Unicode code point, no whitespace,\nunicode passthrough, cross-language number formatting.\n\nThe exact bytes are pinned to **49 cross-language vectors** in\n`fixtures/canonical-vectors.json` — both `daemon/tests/test_vectors.py`\nand `extension/test/vectors.test.ts` read that file and assert byte-by-byte\nagreement. If you change the canonicalisation rules, regenerate via\n`python3 fixtures/generate.py` and update both implementations in the\nsame commit.\n\nThere is no version negotiation — both sides expect `v: 1`. Bump it on\nbreaking changes and update both vector tests in the same commit.\n"}