browsing-as-you
Run ONE long-lived, logged-in Chrome with remote debugging on; every agent and
tool attaches to it. No per-task browser launches, no re-login, no macOS "Allow"
prompts. Tabs and the window open in the background (open -g), never stealing
focus.
Golden rule: never launch your own Chrome — attach to the shared instance at
http://127.0.0.1:9222. Deep detail lives in reference/integration.md
(setup, concurrency, triage) and reference/launchd.md (autostart).
1. Start (idempotent, concurrent-safe)
scripts/chrome-agent.sh start # prints http://127.0.0.1:9222 when live
Triage: health → ok|down|wedged (exit 0|3|4) · doctor for diagnostics ·
recover a wedged one · stop/kill touch only the bridge profile (your real
Chrome is safe). Full table + runbook: reference/integration.md.
Agent: bring it up yourself — don't bail, don't ask. If you land mid-task and
healthisdown/wedged, just runstart(idempotent, concurrent-safe) and, for a cookie site,seed(prompt-free — see §2); then carry on and actually drive the browser. Reporting "the bridge was down" as a blocker, or asking the user to start it, defeats the whole point of the skill. The user should not be pinged for perms. The only human step islogin(typing a password), and only for the localStorage/SSO sites flagged in §2 — never for plain cookie sites like GitHub.
2. Authenticate (the bridge starts logged out)
It uses a dedicated profile — it must, since Chrome 136 ignores the debug port on your default profile. Give it your sessions one of two ways:
scripts/cdp.py seed # import logins from your Default Chrome
scripts/cdp.py seed --filter github.com # (cookies only; also --profile/--dry-run)
scripts/chrome-agent.sh login # OR sign in once in the bridge (stop first)
| Site type | via seed |
durable answer |
|---|---|---|
| Normal cookie sites (GitHub, most SaaS, X) | ✅ survives restart | seed once |
| DBSC (Google, YouTube) | live only — drops on restart | login once |
| localStorage / SSO SPAs (Cloudflare, Devin) | ❌ not covered — looks authed, renders sign-in | login once |
localStorage/SSO dashboards: prefer
login, notseed. Cloudflare and similar SPAs keep part of their auth in localStorage or a device-bound SSO key that cookie-onlyseedcan't carry. The catch:seedreports success, cookies are injected, andlocation.hrefstill reads as authed — but the SPA quietly renders its sign-in screen (no redirect, so a URL check is fooled). Don't trust a URL heuristic; confirm with a DOM probe:cdp.py probe https://dash.cloudflare.com --front→login-wall | likely-authed. (--frontmatters: CF's dash is visibility-gated and renders blank in a hidden tab, which reads asunknown.) Alogin-wallafterseedmeans: runchrome-agent.sh loginfor that site.
seed is cookies-only and reads your real profile locally (nothing leaves the
machine, nothing is written back). login creates the device key inside the
bridge profile, so Gmail, Docs/Drive, X, and Cloudflare stay authed across
restarts and reboots — verified. Adding a new site later: seed --filter <host> if you're already signed in there, else one login. At most one login
per site, then it persists; an agent can't type your password — that's the only
human step.
3. Drive it
Preferred: chrome-devtools-mcp pointed at the browser (--browser-url http://127.0.0.1:9222; setup in reference/integration.md). Or scripts/cdp.py:
| Need | Command |
|---|---|
| open background tab, wait for load | cdp.py open <url> -w → prints targetId |
| open + foreground a visibility-gated SPA | cdp.py open <url> --front -w |
| eval JS · screenshot · navigate | cdp.py eval '<js>' -t <id> · shot out.png -t <id> · nav <url> -t <id> |
| trusted click (coords or selector) | cdp.py click '#submit' -t <id> · click 240,180 -t <id> |
| trusted type (into focused / a selector) | cdp.py type 'hello' -t <id> · type 'us-east' --into '[role=combobox]' --enter -t <id> |
| trusted key (Enter/Tab/Arrow…) | cdp.py key ArrowDown -t <id> · key Enter -t <id> |
| auth probe (is it really logged in?) | cdp.py probe <url> --front → login-wall | likely-authed | unknown |
| upload files to a GitHub issue/PR (no submit click) | cdp.py --json gh-attach --repo o/r --pr 19 -f a.png -f b.log → asset URLs |
| foreground · close · sweep blanks | cdp.py front -t <id> · close <id> · gc |
| isolated context | cdp.py ctx new → open <url> --context <id> → ctx close <id> |
Trusted verbs (click/type/key) use the CDP Input domain — real
isTrusted events — so they drive react-select dropdowns, role=combobox
widgets, and native checkboxes that ignore synthetic eval-dispatched events.
click/type --into take a CSS selector (scrolled into view) or x,y coords.
--json (before the subcommand) for machine-readable output. Concurrency:
each agent owns its targetId and passes -t; set CHROME_AGENT_STRICT=1 to
make eval/nav refuse an implicit tab. Full rules: reference/integration.md.
GitHub attachments — safe two-phase upload
To attach local files (screenshots, logs, any file type) to a GitHub issue or PR without committing binaries to the repo, never drive the page's submit button: GitHub puts Close / Merge / Delete / Reopen / Submit-review controls right next to the comment box, and a broad "first enabled submit" selector can click a destructive one after the upload. Split it in two:
Phase 1 — upload only (browser), prints the user-attachments URLs:
cdp.py --json gh-attach --repo OWNER/REPO --pr 19 -f ./shot-1.png -f ./trace.log
# --issue N for issues · --url <full link> · -t <tab already on the page>
gh-attach sets the files on GitHub's hidden file input over CDP and reads the
URL(s) back from the composer — it issues zero clicks, so destructive
controls are unreachable by construction. It fails closed: if the comment
box isn't found (logged out / no permission) or a URL never appears, it restores
the composer, leaves the page untouched, and exits non-zero. It uploads any file
type (assets/<uuid> for images, files/<n>/<name> otherwise); it never posts.
Phase 2 — post the comment yourself via the API (no page button). gh-attach
prints the ready line; it uses the issues endpoint (PR comments included):
gh api repos/OWNER/REPO/issues/19/comments -f body="$(...uploaded markdown...)"
Posting through the browser is intentionally unsupported —
--allow-submitis refused. Treat PR Close/Merge/Reopen/Delete as out of scope unless the user explicitly asks. Self-test the helper logic withmake test-browsing.
Guardrails
- Be a good citizen. Human pace, not machine-gun. A CAPTCHA / "unusual
traffic" / interstitial is a STOP sign, not a retry trigger — back off and tell
the user. Prefer official APIs for bulk work. A blank/stuck SPA is usually just
a hidden background tab →
--front, not bot detection. - Trusted input is not for CAPTCHA.
click/type/keyare for app forms, not human-verification.clickrefuses any target inside a reCAPTCHA / hCaptcha / Cloudflare Turnstile widget (exit 2) — solving a challenge stays a human step. Don't try to coordinate-click around the guard. - Dedicated profile, loopback only.
seedreads your real profile but never writes to it; the debug port binds to127.0.0.1— never expose it.stop/kill/recoveronly ever touch the bridge profile. - Real keychain by default so
loginis durable (one "Always Allow" at most).CHROME_AGENT_MOCK_KEYCHAIN=1gives a prompt-free, seed-only browser. - Clean up tabs you open (
gc/close).
Setup & autostart (human, once)
seed or login to authenticate, wire up chrome-devtools-mcp
(reference/integration.md), and for hands-free use
scripts/chrome-agent.sh autostart on — a self-pathing login LaunchAgent
(Linux: systemd, see reference/launchd.md). Needs uv; chrome-devtools-mcp
needs Node 22+; seed is macOS-only.