Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f9a072b
fix(daemon): serialise the first attach so Chrome gets one consent pr…
sanketsudake Jul 27, 2026
75861a4
docs: RFC-0013, surviving Chrome's consent prompt
sanketsudake Jul 27, 2026
983ff42
feat(browser): a three-way probe of the debug endpoint
sanketsudake Jul 27, 2026
b5caff4
feat(connect): wait out Chrome's consent prompt instead of abandoning it
sanketsudake Jul 27, 2026
75180d3
docs: lead with the launch flag, document the consent prompt
sanketsudake Jul 27, 2026
f57dc5c
fix(connect): resolve --port to a ws URL before probing it
sanketsudake Jul 27, 2026
ac8fee6
fix(doctor): verify readiness, and stop echoing every open tab
sanketsudake Jul 27, 2026
46a6fcb
fix(probe): bound the handshake read in size and in time
sanketsudake Jul 27, 2026
c78a393
fix(connect): make consent detectable on the path that prompts
sanketsudake Jul 27, 2026
59f36d5
fix(consent): one meaning for consent_timeout, in every layer
sanketsudake Jul 27, 2026
9d8b578
fix(doctor): diagnose the Chrome --port names
sanketsudake Jul 27, 2026
0603593
fix(daemon): three ways a spawn could fail without saying so
sanketsudake Jul 27, 2026
3c015d3
fix(daemon): stop queued callers re-raising the prompt they are waiti…
sanketsudake Jul 27, 2026
29945ff
refactor(consent): one authored explanation of the prompt
sanketsudake Jul 27, 2026
17a4857
fix(connect): tell the user about the prompt on the --no-daemon path too
sanketsudake Jul 27, 2026
dec4963
fix(doctor): say what the probe's `ready` verdict cost
sanketsudake Jul 27, 2026
c2d9292
refactor(probe): one loop, two named timings, and no discarded answer
sanketsudake Jul 27, 2026
e457f71
refactor: move the endpoint probe next to the connection it feeds
sanketsudake Jul 27, 2026
14bf315
refactor(doctor): one vocabulary for the endpoint's three states
sanketsudake Jul 27, 2026
2639ac5
test: one stall-listener harness instead of three
sanketsudake Jul 27, 2026
98e8fd7
test: remove two real flakiness risks
sanketsudake Jul 27, 2026
aaf15fa
fix: the smaller findings, including two low-severity security ones
sanketsudake Jul 27, 2026
68415a7
docs: the behaviour that changed under review
sanketsudake Jul 27, 2026
9164b40
fix(record): assert --scale against the box asked for, not a second c…
sanketsudake Jul 27, 2026
e85ebe8
fix(record): the screencast cap is an upper bound, so assert it as one
sanketsudake Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .claude/resources/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Data flows outermost → innermost: `cli` parses → resolves a `target` → get
- `result` — the envelope, `Err`, and the exit-code table. No dependencies; the root of the contract.
- `target` — the target grammar (`idprefix | url:<s> | title:<s> | @N`) and `Resolve` against a tab list.
- `config` — layered defaults: built-in < config file (`~/.config/chrome-cdp/config.toml`) < `CHROME_CDP_*` env < flag. `Builtin()`, `Resolve()`, `FromEnv()`.
- `browser` — endpoint discovery: finds Chrome's `DevToolsActivePort` file and computes the per-endpoint key (see connection model below).
- `browser` — endpoint discovery and classification: finds Chrome's `DevToolsActivePort` file, computes the per-endpoint key, and probes the debug endpoint's WebSocket upgrade (`WSState`, `AwaitUpgrade`) — see connection model below.
- `chrome` — the `Browser` interface and its chromedp-backed implementation: snapshot, click/type/fill/select, grid, wait, raw CDP. The real driver logic lives here.
- `chrometest` — `StubBrowser`, a permissive `chrome.Browser` double embedded by the `cli` and `daemon` tests.
- `state` — the sticky current-target store, keyed per endpoint so distinct `--port`s don't share a "current tab".
Expand All @@ -46,6 +46,16 @@ When adding a command that needs a new capability, add the method to the `chrome
- The **daemon** (`chrome-cdp __daemon <socket>`, a hidden mode) holds one CDP connection for ~30 min and serves commands over a Unix socket, so the "Allow debugging?" consent fires once.
`--no-daemon` bypasses it and connects directly (used by tests and one-shot scripts).
- Chrome M136+ dropped the classic `--remote-debugging-port` for the default profile; `browser` reads `DevToolsActivePort` and connects directly, which is why it keeps working where older tools broke.
- The **consent prompt** is a third connection state, not a failure (RFC-0013).
While Chrome holds "Allow remote debugging?" it accepts the TCP connect and then stalls the WebSocket upgrade forever — no error, only silence — so `browser.WSState` is three-way (`WSRefused` / `WSPending` / `WSReady`) and `DecideConnection` maps an open-but-hanging endpoint to its own `ConsentPending` action.
The upgrade itself lives in `chrome/probe.go` (`AwaitUpgrade`, `ProbeWS`, `ResolveWSURL`), next to the connection it feeds; `browser` keeps the vocabulary and the ladder and stays free of I/O against Chrome.
The daemon holds that upgrade open for `consent_timeout` (default 120s, clamped to `[1s, 10m]` by `chrome.ClampConsentTimeout` where flag/env/config resolve) and publishes a `<socket>.pending` marker so `Ensure` extends its own deadline instead of declaring a live daemon dead; a refused endpoint still fails in milliseconds, which is what makes the long wait safe.
`WSState.String()` is the wire value `doctor` reports as `state`, so there is one vocabulary rather than a hand-maintained second list.
Never lead a failure message with the `chrome://inspect` toggle: `browser.EnableAdvice` is the one authored answer, and it recommends `--remote-debugging-port` first because that path never prompts.
`browser.ConsentPromptAdvice` is the matching one for describing the dialog itself — modal to the browser, behind the window, no other input accepted — and every message that mentions the prompt composes it rather than rewriting it.
- **Anything `doctor` reports is a claim it has to have verified**, and anything it echoes ends up in an agent transcript (the Skill runs `doctor --json` first).
A daemon's `running: true` is not evidence — the socket outlives the connection — so `__status` reports `connected` from the `List` round trip it makes, and `doctor` requires that before saying `ready`.
The status payload carries a `target_count`, never the tab list: titles and URLs are not an answer to "can I connect?".

## Human vs. JSON rendering

Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,19 @@ Because it attaches to the browser you're already using, an app you're signed in
Recent Homebrew may print a tap-trust notice for third-party taps on first install.
The install still proceeds; to acknowledge it explicitly, run `brew trust --cask sanketsudake/tap/chrome-cdp` first.

2. **Let Chrome accept a debugger** — open `chrome://inspect/#remote-debugging` and toggle it on (a one-time consent; `chrome-cdp` never suppresses it).
2. **Let Chrome accept a debugger.**
Launch it with the flag — this never prompts:

3. **Check the connection** — `chrome-cdp doctor` confirms it's ready, or prints the exact fix.
```sh
open -a "Google Chrome" --args --remote-debugging-port=9222 # macOS
google-chrome --remote-debugging-port=9222 # Linux
```

Or, to attach to a Chrome that is already running on the default profile, toggle `chrome://inspect/#remote-debugging` on.
That path raises a consent prompt on **every fresh attach**, and the prompt is modal to the whole browser — until it is answered Chrome accepts no other input, and it can sit behind the window, so an unanswered one looks like a crash.
`chrome-cdp` waits for it (see `--consent-timeout`) and never suppresses it.

3. **Check the connection** — `chrome-cdp doctor` actually connects and reports `ready`, `consent_pending`, or `no_endpoint`, with the exact fix.

4. **Drive it:**

Expand Down Expand Up @@ -113,7 +123,7 @@ Shell completion is built in: `chrome-cdp completion bash|zsh|fish|powershell`.

## Security

A live debug endpoint is **full control** of whatever your Chrome is signed into — treat enabling `chrome://inspect` like opening a local root shell into your browser's sessions, and only do it when you intend to automate.
A live debug endpoint is **full control** of whatever your Chrome is signed into — treat enabling remote debugging (by flag or by toggle) like opening a local root shell into your browser's sessions, and only do it when you intend to automate.

- **Loopback only.**
It connects to `127.0.0.1` and never binds the debug port to a non-loopback interface.
Expand Down
35 changes: 35 additions & 0 deletions cmd/chrome-cdp/connect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package main

import (
"fmt"
"io"

"github.com/sanketsudake/chrome-cdp-cli/internal/browser"
"github.com/sanketsudake/chrome-cdp-cli/internal/chrome"
"github.com/sanketsudake/chrome-cdp-cli/internal/cli"
"github.com/sanketsudake/chrome-cdp-cli/internal/config"
)

// directConnectOptions builds the chrome.Options for a --no-daemon connect.
//
// It exists as its own function for the hook at the bottom. Options.
// OnConsentPending was only ever assigned by the daemon, so the --no-daemon
// path sat in complete silence for the whole consent budget — up to two
// minutes during which the user's browser is frozen, the tool has printed
// nothing, and RFC-0013's US-2 ("tell me what is happening") is unmet on the
// one path where nothing else can tell them: there is no daemon to publish a
// .pending marker and no Ensure to read it.
func directConnectOptions(portFile string, o cli.ConnOpts, defs config.Defaults, w io.Writer) chrome.Options {
return chrome.Options{
PortFile: portFile, NoLaunch: o.NoLaunch, ProfileDir: o.ProfileDir, Port: o.Port,
ConsentTimeout: o.ConsentTimeout,
// Fires once, the moment the upgrade is classified as pending — while
// the dialog is still on screen, which is the only time saying so helps.
OnConsentPending: func() {
fmt.Fprintln(w, "chrome-cdp:", browser.ConsentPromptAdvice)
},
ConsoleBuffer: defs.ConsoleBuffer, ConsoleMaxEntry: defs.ConsoleMaxEntry,
NetBuffer: defs.NetBuffer, NetMaxBody: defs.NetMaxBody,
RecordBuffer: defs.RecordBuffer, RecordMaxBytes: defs.RecordMaxBytes,
}
}
30 changes: 30 additions & 0 deletions cmd/chrome-cdp/connect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package main

import (
"bytes"
"strings"
"testing"

"github.com/sanketsudake/chrome-cdp-cli/internal/browser"
"github.com/sanketsudake/chrome-cdp-cli/internal/cli"
"github.com/sanketsudake/chrome-cdp-cli/internal/config"
)

// TestDirectConnectAnnouncesThePendingPrompt is US-2 on the --no-daemon path.
//
// chrome.Options.OnConsentPending was assigned in exactly one place, inside the
// daemon, and the --no-daemon connect passed a ConsentTimeout with no hook. So
// that path waited out the prompt in complete silence — up to two minutes with
// a frozen browser and nothing on stderr — and no test caught it, because the
// tests that cover the hook supply their own.
func TestDirectConnectAnnouncesThePendingPrompt(t *testing.T) {
var buf bytes.Buffer
opts := directConnectOptions("", cli.ConnOpts{}, config.Builtin(), &buf)
if opts.OnConsentPending == nil {
t.Fatal("--no-daemon connects with no pending hook: the user is told nothing for the whole consent budget")
}
opts.OnConsentPending()
if !strings.Contains(buf.String(), browser.ConsentPromptAdvice) {
t.Errorf("the --no-daemon notice does not carry browser.ConsentPromptAdvice:\n%s", buf.String())
}
}
37 changes: 11 additions & 26 deletions cmd/chrome-cdp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package main
import (
"context"
"fmt"
"maps"
"os"
"strconv"
"time"
Expand All @@ -31,6 +30,7 @@ func main() {
ProfileDir: env.ProfileDir,
Port: env.Port,
NoLaunch: env.NoLaunch,
ConsentTimeout: env.ConsentTimeout,
ConsoleBuffer: env.ConsoleBuffer,
ConsoleMaxEntry: env.ConsoleMaxEntry,
NetBuffer: env.NetBuffer,
Expand Down Expand Up @@ -103,6 +103,12 @@ func main() {
if o.NoLaunch {
env = append(env, "CHROME_CDP_NO_LAUNCH=1")
}
// The daemon is the process that actually waits out the consent prompt,
// so --consent-timeout has to reach it; it only ever parses the
// environment. Forwarded unconditionally: o.ConsentTimeout is already
// normalised, and a "> 0" guard here is how the client and the daemon
// it spawned ended up waiting for different lengths of time.
env = append(env, "CHROME_CDP_CONSENT_TIMEOUT="+o.ConsentTimeout.String())
// The daemon parses only the environment, so config-file values for the
// event-capture bounds have to be forwarded explicitly or the buffers it
// holds would silently fall back to the built-in sizes.
Expand All @@ -119,14 +125,9 @@ func main() {

app.WithConnector(func(ctx context.Context, o cli.ConnOpts) (chrome.Browser, error) {
if o.NoDaemon {
return chrome.Connect(ctx, chrome.Options{
PortFile: portFile, NoLaunch: o.NoLaunch, ProfileDir: o.ProfileDir, Port: o.Port,
ConsoleBuffer: defs.ConsoleBuffer, ConsoleMaxEntry: defs.ConsoleMaxEntry,
NetBuffer: defs.NetBuffer, NetMaxBody: defs.NetMaxBody,
RecordBuffer: defs.RecordBuffer, RecordMaxBytes: defs.RecordMaxBytes,
})
return chrome.Connect(ctx, directConnectOptions(portFile, o, defs, os.Stderr))
}
client, err := daemon.Ensure(socketFor(o), exe, daemonEnv(o))
client, err := daemon.Ensure(ctx, socketFor(o), exe, daemonEnv(o), o.ConsentTimeout)
if err != nil {
return nil, err
}
Expand All @@ -136,7 +137,7 @@ func main() {
app.WithDaemonCtl(
func(o cli.ConnOpts) (map[string]any, error) {
sock := socketFor(o)
if _, err := daemon.Ensure(sock, exe, daemonEnv(o)); err != nil {
if _, err := daemon.Ensure(context.Background(), sock, exe, daemonEnv(o), o.ConsentTimeout); err != nil {
return nil, err
}
return map[string]any{"started": true, "socket": sock, "endpoint": browser.EndpointKey(portFile, o.Port)}, nil
Expand All @@ -152,27 +153,11 @@ func main() {
return map[string]any{"stopped": true}, nil
},
func(o cli.ConnOpts) (map[string]any, error) {
return daemonStatus(socketFor(o), browser.EndpointKey(portFile, o.Port))
return daemon.Status(socketFor(o), browser.EndpointKey(portFile, o.Port))
},
)

code := app.Execute(os.Args[1:]...)
app.Close()
os.Exit(code)
}

// daemonStatus reports whether the daemon for this endpoint is running and, when
// it is, what it's attached to (the live tab list, best-effort).
func daemonStatus(sock, endpoint string) (map[string]any, error) {
res := map[string]any{"socket": sock, "endpoint": endpoint}
c := daemon.TryConnect(sock)
if c == nil {
res["running"] = false
return res, nil
}
res["running"] = true
if info, err := c.StatusInfo(); err == nil {
maps.Copy(res, info)
}
return res, nil
}
12 changes: 12 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@
# target = "url:github" # default tab when neither --target nor `use` is set
# json = false # emit the machine-readable JSON envelope by default
# timeout = "30s" # max time to wait per command (Go duration string)
# consent_timeout = "120s" # how long to hold the connection open waiting for Chrome's
# # browser-modal "Allow remote debugging?" prompt to be answered.
# # Only an OPEN port whose upgrade is hanging waits this long; a
# # refused endpoint still fails in milliseconds. Shorten it if you
# # only ever launch Chrome with --remote-debugging-port (which
# # never prompts); lengthen it if the dialog tends to hide behind
# # the window and you want more time to find it.
# # Clamped to 1s-10m. "0s" (or a negative value) means the 120s
# # default, not "do not wait" — a zero wait abandons the prompt
# # the instant it is raised, which is the failure this setting
# # exists to prevent. The ceiling is there because this is also
# # how long a second command can be held up behind the first.
# no_launch = false # never auto-launch a managed fallback Chrome
# no_daemon = false # connect directly instead of via the shared daemon
# no_color = false # plain, symbol-free human output
Expand Down
Loading
Loading