cdpilot 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ two-axis policy — extension workload × platform stability:
44
44
 
45
45
  | Your workload | Auto-pick order |
46
46
  |---|---|
47
- | Has extensions registered (`ext-install`) | vivaldi → brave → edge → chromium → chrome |
47
+ | Has extensions registered (`ext-install`) | vivaldi → brave → edge → chromium → chrome (→ Chrome for Testing, if installed) |
48
48
  | No extensions (pure automation) | chrome → vivaldi → edge → chromium → brave |
49
49
 
50
50
  Override anytime:
@@ -52,13 +52,16 @@ Override anytime:
52
52
  ```bash
53
53
  cdpilot browser # show current pick + reason
54
54
  cdpilot browser vivaldi # pin to Vivaldi
55
+ cdpilot browser chrome-for-testing # pin to Chrome for Testing (after installing it, below)
55
56
  cdpilot browser auto # restore smart default
56
57
  ```
57
58
 
58
59
  **Why the split?**
59
- - **Chrome 147+ silently drops `--load-extension`** for unpacked extensions
60
- (no error, no warning). Verified — `chrome://extensions` shows 0 items.
61
- - **Vivaldi, Brave, Edge, Chromium** honor `--load-extension` (tested).
60
+ - **Chrome 137+ silently drops `--load-extension`** for unpacked extensions
61
+ (no error, no warning) — Google removed it from branded Chrome builds
62
+ ([PSA](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ)).
63
+ Verified — `chrome://extensions` shows 0 items.
64
+ - **Vivaldi, Brave, Edge, Chromium and Chrome for Testing** honor `--load-extension` (tested).
62
65
  - On **macOS 26 (Tahoe)** Brave 1.89 crashes deterministically at ~7min
63
66
  uptime (SIGTRAP in ThreadPoolForegroundWorker). cdpilot detects the OS
64
67
  and demotes Brave automatically until a fixed Brave release ships.
@@ -66,6 +69,43 @@ cdpilot browser auto # restore smart default
66
69
  Each browser gets its own isolated profile (`~/.cdpilot/.../profile-vivaldi`
67
70
  etc.) so switching never causes prefs corruption.
68
71
 
72
+ ### Extension development: Chrome for Testing
73
+
74
+ If you develop an extension against Chrome itself, install
75
+ [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) (CfT) —
76
+ the Chrome team's versioned, non-auto-updating Chrome build for automation, which
77
+ still loads unpacked extensions:
78
+
79
+ ```bash
80
+ cdpilot browser install chrome-for-testing # latest Stable
81
+ cdpilot browser install chrome-for-testing --channel beta # stable | beta | dev | canary
82
+ cdpilot browser install chrome-for-testing --version 154 # a milestone, or a full version (154.0.8037.57)
83
+ cdpilot ext-install ./my-extension/ # register your unpacked extension
84
+ cdpilot browser status # shows the CfT version and path
85
+ ```
86
+
87
+ - **Download only on that command.** It is a one-time ~150-190 MB zip (mac-arm64 at
88
+ 154.0.8037.57: 191,429,663 bytes); cdpilot never fetches it by itself. The build comes
89
+ from the official [CfT JSON endpoints](https://googlechromelabs.github.io/chrome-for-testing/)
90
+ (`last-known-good-versions-with-downloads.json`, per-version and per-milestone files).
91
+ Those list URLs only, no hash, so the download is checked against the server's
92
+ `Content-Length` and its `x-goog-hash` md5; the zip's SHA-256 is recorded.
93
+ - It is extracted to `$CDPILOT_HOME/browsers/chrome-for-testing/<version>/` (on macOS the
94
+ quarantine attribute is cleared on that directory only) and recorded in `installed.json`.
95
+ - **Used automatically for extension work:** while dev extensions are registered, a
96
+ branded Chrome pick (`cdpilot browser chrome`, or `auto` with Chrome the only
97
+ extension-capable choice) becomes Chrome for Testing, with one stderr line saying so.
98
+ Without CfT installed, `ext-install` on Chrome prints the install command and keeps
99
+ Vivaldi/Brave as the alternative. `cdpilot browser chrome-for-testing` selects it
100
+ always. An explicit `CHROME_BIN` is never replaced.
101
+ - It runs with cdpilot's usual per-project isolated profile (`profile-cft`).
102
+ - **It is for extension development and testing — not a stealth browser.** cdpilot
103
+ makes no anti-bot claims for it; use your regular browser setup for that.
104
+ - **Linux (Ubuntu 23.10+):** AppArmor blocks unprivileged user namespaces, and CfT
105
+ (unlike the distro's Chrome package) has no profile allowing them, so it exits at
106
+ start. cdpilot prints this hint; allow them with
107
+ `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`.
108
+
69
109
  ## Installation
70
110
 
71
111
  ```bash
@@ -86,6 +126,37 @@ npx cdpilot launch # Start browser with CDP enabled
86
126
  npx cdpilot status # Check connection
87
127
  ```
88
128
 
129
+ ### Claude Code plugin marketplace
130
+
131
+ Install cdpilot's MCP server and its skill straight into Claude Code, no
132
+ `.mcp.json` editing required:
133
+
134
+ ```bash
135
+ /plugin marketplace add mehmetnadir/cdpilot
136
+ /plugin install cdpilot@cdpilot
137
+ ```
138
+
139
+ This registers the `cdpilot` MCP server (`npx cdpilot mcp`) and a short skill
140
+ that teaches Claude Code when and how to reach for cdpilot's commands. See
141
+ [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json) and
142
+ [`plugins/cdpilot/`](plugins/cdpilot/).
143
+
144
+ ### MCP Bundle (.mcpb)
145
+
146
+ For MCP clients that support one-click local server installation (Claude
147
+ Desktop and others), download the `.mcpb` file attached to the
148
+ [latest release](https://github.com/mehmetnadir/cdpilot/releases/latest) and
149
+ open it, or build it yourself:
150
+
151
+ ```bash
152
+ npm run build:mcpb # writes cdpilot.mcpb at the repo root
153
+ ```
154
+
155
+ The bundle still needs the same runtime on the machine that runs it: Node.js
156
+ 18+, Python 3.10+, and Brave/Chrome/Chromium installed. See
157
+ [`manifest.json`](manifest.json) for the bundle's MCP Bundle manifest
158
+ ([spec](https://github.com/modelcontextprotocol/mcpb)).
159
+
89
160
  ### Upgrading from 0.4.x → 0.5.0 — read this first
90
161
 
91
162
  **One breaking change**, the rest is additive.
@@ -144,6 +215,86 @@ cdpilot scroll-to <selector> # Scroll element into view
144
215
  cdpilot drag <from> <to> # Drag and drop
145
216
  ```
146
217
 
218
+ A real mouse click (inside a frame, or `--entropy=on`) whose target moves or gets covered while the button is held is not clicked again by script: `click` prints `Pressed (released elsewhere, not clicked): …` and ends with **exit 3** (pressed, release missed the target, not clicked; `1` stays "error"). A target the page replaced or navigated away from on mousedown prints `Pressed (the page replaced or left it, not clicked): …` and exits 0. MCP (`browser_click`, `browser_smart_click`) returns `isError: false` with a first line `{"clicked": false, "reason": "moved"|"gone"|"unknown"}`. `batch` and `run` finish every step, then exit 1 if a step failed, else 3 if a click was not clicked, else 0.
219
+
220
+ ### Element targeting inside iframes
221
+
222
+ Card forms (Stripe/iyzico-style), embedded login widgets and the reCAPTCHA
223
+ checkbox live inside `<iframe>`s. Name the frame chain before the element
224
+ selector with `>>>`, or pass `--frame`:
225
+
226
+ ```bash
227
+ # Payment-style page: the card fields are inside <iframe id="card" title="Secure card payment">
228
+ cdpilot fill "iframe#card >>> input[name=cardnumber]" "4242424242424242"
229
+ cdpilot type --frame "#card" "input[name=cvc]" "123"
230
+ cdpilot click "iframe[title='Secure card payment'] >>> button#pay-btn"
231
+
232
+ # Nested frames: outermost first
233
+ cdpilot click "iframe.checkout >>> iframe.card >>> button"
234
+
235
+ # --frame <selector|index|url-substring>, nestable with >>>
236
+ cdpilot click --frame 0 "button.submit" # first iframe (same order as `frame list`)
237
+ cdpilot fill --frame "url=js.stripe.com" "input[name=cardnumber]" "4242424242424242"
238
+ cdpilot frame list --frame "#card" # iframes inside that frame
239
+ cdpilot frame eval --frame "#card" "document.title" # runs in the frame's own page context
240
+ ```
241
+
242
+ - Works for `click`, `fill`, `type`, `submit`, `hover`, `dblclick`, `rightclick`,
243
+ `smart-click`, `smart-fill`, `smart-select` and `frame list|eval|shadow`.
244
+ - A frame hop is a CSS selector, a 0-based index, a `name`/`id`, or
245
+ `url=<src substring>`. `--frame` also takes a bare `src` substring.
246
+ `>>>` inside quotes or `[...]` is literal. A selector that matches an
247
+ element *wrapping* an iframe (Stripe's `#card-element`) enters the iframe
248
+ inside it and says so on stderr:
249
+ `note: '#card-element' is not an iframe; using the iframe inside it`.
250
+ - Selectors and texts that already contain `>>>` keep working: when the part
251
+ before the first `>>>` finds no iframe in the page, or a segment is empty
252
+ (`click "Next >>>"`), the whole string is used as written, exactly as
253
+ before. If that fails too, one stderr line explains:
254
+ `note: 'Home' matched no iframe; used the selector as written`.
255
+ `--frame` never falls back: a frame it cannot find is an error (exit 1).
256
+ - For `smart-click` / `smart-fill` / `smart-select` the part before the first
257
+ `>>>` is a frame only if it looks like a selector (it has `#`, `.` or `[`,
258
+ or starts with an `iframe`/`frame` tag) or is `url=…`; words stay text.
259
+ `smart-click "Main >>> Settings"` clicks the page's "Main >>> Settings"
260
+ link even when a `<main>` element holds an iframe. For a frame index or a
261
+ bare name use `--frame`.
262
+ - Same-origin **and cross-origin** frames work, including out-of-process
263
+ iframes (site isolation): cdpilot resolves each hop over CDP
264
+ (`DOM.describeNode` → frame id → the frame's execution context, or
265
+ `Target.attachToTarget` with a flat session for an out-of-process frame),
266
+ never through `contentDocument`, which the browser blocks cross-origin.
267
+ - Inside a frame, `click` and `smart-click` are real mouse input
268
+ (`isTrusted: true`), like `hover`, `dblclick`, `rightclick` and
269
+ `--entropy=on` clicks; an element with no box falls back to a script click.
270
+ Mouse input is dispatched at page coordinates: each frame's offset and scale
271
+ (`transform: scale()`, `zoom`) in the page is applied automatically, so the
272
+ browser hit-tests into the right frame. Before pressing, cdpilot checks the
273
+ point (`elementFromPoint` in each document on the way); if something covers
274
+ the target, such as a cookie banner, it clicks the target by script instead
275
+ and says so on one stderr line (`note: … is covered by div#cookie at the
276
+ click point; used a script click`). cdpilot's own input blocker (`show on`,
277
+ MCP sessions) lets only cdpilot's click through, for the click itself.
278
+ - `smart-click` / `smart-fill` / `smart-select` look in the page first and
279
+ compare texts with whitespace collapsed (`&nbsp;` and line breaks count as
280
+ one space). An enabled page element whose text or label contains the whole
281
+ query is used. If the page's only such elements are disabled, the command
282
+ fails as before (`no enabled element matches …`) without looking in frames.
283
+ Otherwise the visible frames are searched breadth-first for a whole-query
284
+ match (at most 20 frames, and 2 s for all of the search's CDP calls) and the
285
+ match is reported on stderr:
286
+ `smart-click: matched inside frame iframe#card (https://…)`. The search only
287
+ looks (no click, no typing); the command then acts once, in the chosen
288
+ frame. With no frame match, the page's partial (word) match is used as
289
+ before. When the 2 s run out:
290
+ `smart-click: frame search stopped after 2s (3 of 7 frames)`; a frame that
291
+ had not answered by then has not been touched.
292
+ - `smart-fill` and `smart-select` search automatically only frames of the
293
+ page's own origin, so a typed value never lands in a third-party frame (an
294
+ ad, chat or payment widget) by accident; reach a cross-origin frame with
295
+ `>>>` or `--frame`. `smart-click` searches all visible frames.
296
+ - `frame list` without `--frame` prints the same list as before.
297
+
147
298
  ### Debugging
148
299
 
149
300
  ```bash
@@ -308,7 +459,8 @@ cdpilot storage # localStorage contents
308
459
  cdpilot upload <sel> <file> # Upload file to input
309
460
  cdpilot multi-eval <js> # Execute JS in all tabs
310
461
  cdpilot headless [on|off] # Toggle headless mode
311
- cdpilot frame list # List iframes
462
+ cdpilot frame list # List iframes (index = --frame <index>)
463
+ cdpilot frame eval --frame <f> <js> # Run JS inside an iframe (cross-origin too)
312
464
  cdpilot dialog auto-accept # Auto-accept dialogs
313
465
  cdpilot permission grant geo # Grant geolocation
314
466
  ```
@@ -391,6 +543,8 @@ At launch, cdpilot also passes `--disable-blink-features=AutomationControlled`,
391
543
  which closes the Blink runtime flag that Cloudflare and DataDome probe to detect
392
544
  an automated browser.
393
545
 
546
+ Real mouse clicks (frames, `--entropy=on`, `click @ref`, `dblclick`, `rightclick`) hold the button 40-120 ms like a person's press; `CDPILOT_PRESS_MS=min-max` changes the range (`0-0` = instant).
547
+
394
548
  #### Three-tier stealth mode
395
549
 
396
550
  `cdpilot mode` is the recommended entry point — one switch that sets how much
@@ -434,6 +588,145 @@ cdpilot adaptive clear # Drop the stealth host memory entirely
434
588
  > when it sees one — adds the host to a persistent list, retries once with
435
589
  > stealth on. Never auto-demotes. Conservative by design.
436
590
 
591
+ ### Web Bot Auth (signed agent)
592
+
593
+ The opposite of stealth. Stealth tries to make the browser look like a person;
594
+ Web Bot Auth says "this is an automated agent, and here is proof of which one".
595
+ Every request the browser makes carries an Ed25519
596
+ [HTTP Message Signature (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421)
597
+ that a site, or Cloudflare in front of it, checks against a key directory you
598
+ publish on your own domain. The format follows
599
+ [draft-meunier-web-bot-auth-architecture](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/)
600
+ and [draft-meunier-http-message-signatures-directory](https://datatracker.ietf.org/doc/draft-meunier-http-message-signatures-directory/),
601
+ and reproduces the Ed25519 test vectors of Cloudflare's reference
602
+ implementation ([cloudflare/web-bot-auth](https://github.com/cloudflare/web-bot-auth)).
603
+
604
+ **Optional dependency.** Signing needs the `cryptography` package, for the Python cdpilot runs
605
+ on (`CDPILOT_PYTHON` if you set it):
606
+
607
+ ```bash
608
+ pip install cryptography
609
+ ```
610
+
611
+ Without it, the `bot-auth` commands and `launch --bot-auth` print that hint and exit 2. Every
612
+ other command works as before.
613
+
614
+ **1. Generate the key** (once):
615
+
616
+ ```bash
617
+ cdpilot bot-auth init --agent-url https://your-domain.com
618
+ ```
619
+
620
+ This writes an Ed25519 private key to `CDPILOT_HOME/bot-auth/ed25519.key` (default
621
+ `~/.cdpilot`), created with mode `0600`. cdpilot never prints or logs the key, and it warns
622
+ when the file is readable by others. The agent URL must be an `https://` origin (no path). A
623
+ second `init` refuses to replace the key unless you pass `--force`, because the new key would no
624
+ longer match the directory you published.
625
+
626
+ **2. Host the directory on your own domain, signed.** `cdpilot bot-auth directory` prints the
627
+ public key as a JWKS, where `kid` is the key's RFC 7638 JWK thumbprint. Cloudflare also requires
628
+ the directory *response* to be signed, so `--headers` prints the headers to serve with it:
629
+
630
+ ```bash
631
+ cdpilot bot-auth directory --headers # headers, a blank line, then the JSON body
632
+ cdpilot bot-auth directory --headers --json # the same as {"authority", "headers", "body"}
633
+ ```
634
+
635
+ ```
636
+ Content-Type: application/http-message-signatures-directory+json
637
+ Signature-Input: sig1=("@authority";req);created=…;keyid="<thumbprint>";alg="ed25519";expires=…;nonce="…";tag="http-message-signatures-directory"
638
+ Signature: sig1=:<base64 Ed25519 signature>:
639
+
640
+ { "keys": [ … ] }
641
+ ```
642
+
643
+ Serve that body at `https://your-domain.com/.well-known/http-message-signatures-directory` with
644
+ those three headers. The signature covers `("@authority";req)`, the host the directory is fetched
645
+ from: by default the agent URL's host, or `--authority <host>`. There is one signature per key in
646
+ the directory. It is valid for 24 hours (`--ttl <seconds>`), so a static host needs it refreshed
647
+ daily, from cron or an edge function that runs the same signing. A cron example that rewrites the
648
+ headers file and the body your web server serves:
649
+
650
+ ```bash
651
+ # m h dom mon dow — every day at 03:17, 48 h validity so one missed run does not break it
652
+ 17 3 * * * cdpilot bot-auth directory --headers --ttl 172800 --json > /var/www/wba/directory.json.tmp \
653
+ && mv /var/www/wba/directory.json.tmp /var/www/wba/directory.json
654
+ ```
655
+
656
+ Your server (or an edge function) reads `headers` and `body` from that JSON and serves them at
657
+ the well-known path. `--content-digest` also covers the body (`Content-Digest`, RFC 9530), as
658
+ in Cloudflare's reference vector; then the body must be served byte for byte as printed.
659
+
660
+ **3. Launch a signing browser:**
661
+
662
+ ```bash
663
+ cdpilot launch --bot-auth # or: CDPILOT_BOT_AUTH=1 cdpilot launch
664
+ cdpilot status # ... bot-auth: on (keyid <thumbprint>)
665
+ cdpilot go https://example.com # this request is signed, and so is everything after it
666
+ cdpilot stop # stops the browser and the signer
667
+ ```
668
+
669
+ `launch --bot-auth` starts a small detached signer next to the browser. It uses the same
670
+ self-fork as the idle-close watcher and lives exactly as long as the browser. It holds one CDP
671
+ connection and auto-attaches to every page, popup, new tab, out-of-process iframe and worker. It
672
+ signs each request at the Fetch "Request" stage, including every redirect hop. So requests the page makes on its own are
673
+ signed as well: timers, `fetch()` after your cdpilot command has returned, a tab the page opens.
674
+ `data:`, `blob:` and `chrome-extension:` requests never leave the browser, so they pass through
675
+ unchanged. If signing a request fails, that request goes out unsigned, one line is written to
676
+ `CDPILOT_HOME/bot-auth/signers/<port>.log` (host only), and the page is never held up. Running
677
+ `launch --bot-auth` against a browser that is already up adds the signer to it.
678
+
679
+ Each request gets three headers:
680
+
681
+ | Header | Value |
682
+ |--------|-------|
683
+ | `Signature-Agent` | `"https://your-domain.com"` |
684
+ | `Signature-Input` | `sig1=("@authority" "signature-agent");created=…;keyid="<thumbprint>";alg="ed25519";expires=…;nonce="…";tag="web-bot-auth"` |
685
+ | `Signature` | `sig1=:<base64 Ed25519 signature>:` |
686
+
687
+ `expires` is `created` + 5 minutes. The `nonce` is 64 random bytes, base64-encoded, and new for
688
+ every request.
689
+
690
+ This is the **legacy** `Signature-Agent` form (draft-05 A.2.3), and it is the default because it
691
+ is the form Cloudflare's verifier accepts: Cloudflare rejects the dictionary form of later drafts.
692
+ To send the dictionary form instead (`Signature-Agent: sig1="https://your-domain.com"`, covered as
693
+ `"signature-agent";key="sig1"`), run `cdpilot bot-auth format dict` (`bot-auth format legacy`
694
+ switches back, `bot-auth format` shows it, `bot-auth init --agent-format dict` sets it at
695
+ creation). It applies from the next `launch --bot-auth`.
696
+
697
+ **What is not signed.** WebSocket handshakes are not signed: the browser does not pass them
698
+ through CDP's `Fetch` domain, so a `wss://` connection the page opens goes out without the
699
+ headers. Service-worker-internal cache hits and `data:`/`blob:` URLs never reach a server.
700
+
701
+ **If the signer dies or hangs.** While it runs, the signer holds every request of the browser at
702
+ the Fetch stage until it has added the headers. If it is killed, requests go out unsigned; `cdpilot
703
+ status` and `cdpilot go` then print one stderr line (signer not running, requests go out unsigned,
704
+ run `cdpilot launch --bot-auth` again) until you do, or `stop`. If it is frozen (a
705
+ stopped process, a debugger), requests hang: `cdpilot stop` and `launch --bot-auth` again.
706
+ cdpilot trusts its signer state only for the process whose command line is that port's signer and
707
+ only for the browser it attached to, so a stale state file whose pid was reused by another program
708
+ is dropped, never signalled.
709
+
710
+ **Idle close** still works while the signer runs: the signer is attached to every page, so instead
711
+ of "a page is attached" cdpilot counts the CDP clients other than the signer (by socket owner:
712
+ `netstat` on macOS and Windows, `ss` on Linux), and a `watch` daemon or a Playwright session keeps
713
+ the browser open. On Linux, `ss -tnp` shows only your own processes' sockets, so a client running
714
+ as another user is not counted and idle close may close the browser under it.
715
+
716
+ **Stealth conflict.** Signing says "I am an agent", and stealth says "I am not". If you pass
717
+ `--bot-auth` together with `--stealth`/`--undetected`, or while `cdpilot mode` is `stealth` or
718
+ `undetected`, cdpilot prints one warning line and applies bot-auth. While the signer runs, no
719
+ stealth script or user-agent override is injected, and adaptive escalation does not retry at a
720
+ stealth tier.
721
+
722
+ **Register with Cloudflare.** Once the directory is live, submit it through the Bot Submission
723
+ Form in the Cloudflare dashboard as a *signed agent* (an agent acting for its users) or a
724
+ *verified bot*; see Cloudflare's
725
+ [signed agents](https://blog.cloudflare.com/signed-agents/) and
726
+ [verified bots with cryptography](https://blog.cloudflare.com/verified-bots-with-cryptography/)
727
+ posts. Sites behind Cloudflare then see your requests as coming from your agent. Sites that
728
+ don't check Web Bot Auth ignore the headers.
729
+
437
730
  ### Friction Ladder (progressive anti-bot detection)
438
731
 
439
732
  Real sites don't just throw a CAPTCHA — they stack defenses incrementally.
@@ -562,7 +855,8 @@ Earlier figures on this page (sannysoft 24/24, intoli 6/6) were measured on v0.4
562
855
 
563
856
  ```bash
564
857
  cdpilot browser [name|auto] # workload-aware browser selection
565
- cdpilot health # JSON: alive, port, tabs, browser, today's crashes
858
+ cdpilot browser install chrome-for-testing # for extension development (see above)
859
+ cdpilot health # JSON: alive, port, tabs, browser, today's crashes, idle close
566
860
  ```
567
861
 
568
862
  `cdpilot health` is designed for shell watchdogs:
@@ -584,6 +878,33 @@ Lifecycle, status and configuration commands (`launch`, `stop`, `close`,
584
878
  `mcp`, `serve`, …) never launch. `CDPILOT_NO_AUTOLAUNCH=1` restores the old
585
879
  "CDP connection error. Is the browser running?" error and exit code 1.
586
880
 
881
+ **Idle auto-close.** A browser that a page command auto-launched, or that the
882
+ MCP server launched (`browser_launch` included), closes itself after
883
+ **15 minutes** without a cdpilot command or a visible page change, so a
884
+ finished agent task does not leave it holding memory. An explicit CLI
885
+ `cdpilot launch` stays open (you may be browsing in it by hand) unless you ask:
886
+ `cdpilot launch --idle-close 30` or `CDPILOT_IDLE_CLOSE=30`. The env var also
887
+ sets the auto-launch delay (read when the browser starts; fractions allowed);
888
+ `0` turns idle close off. A browser you started yourself, or one cdpilot merely
889
+ attached to, is never closed.
890
+
891
+ What counts as use: any cdpilot command except the read-only checks (`status`,
892
+ `health`, `projects`, `version` — a `cdpilot health` watchdog loop does not keep
893
+ the browser alive), MCP tool calls, `serve` requests, a CDP client still
894
+ attached to a page (a long-running command, `watch`, Playwright via
895
+ `connectOverCDP`), and any change in the open pages' URLs or in the set of
896
+ tabs (someone navigating, a tab opened or closed). Title changes do not count,
897
+ so a page that rewrites its own title (a clock, an unread counter) cannot keep
898
+ the browser alive. `cdpilot status` and
899
+ `cdpilot health` show `idle close in 12m` or `idle close off`.
900
+
901
+ How: every command stamps `~/.cdpilot/projects/<id>/last-activity`; the launch
902
+ starts a small detached watcher (one per port, no console window on Windows)
903
+ that checks every ≤30 s, stops the browser like `cdpilot stop`, marks it
904
+ stopped in the registry and exits — it also exits as soon as the browser is
905
+ gone for any other reason. Browsers started by `serve --api` are managed by the
906
+ server and have no idle close.
907
+
587
908
  **Timeouts.** Any command takes `--timeout <seconds>`, before or after the
588
909
  command name, or a default from `CDPILOT_TIMEOUT` (the flag wins; `0`
589
910
  disables). It bounds the whole command's wall-clock: on expiry cdpilot prints
@@ -600,6 +921,198 @@ CDPILOT_TIMEOUT=60 cdpilot run flow.cdp # default for every command (and each
600
921
  For `mcp` and `serve` the value is not applied to the long-running server
601
922
  itself; it is passed on to every tool call / request it runs.
602
923
 
924
+ ### Session log
925
+
926
+ Every command (and every MCP tool call) appends one JSON line to a local,
927
+ per-project log, so when a browser task is done there is a record of what was
928
+ done and found. Nothing leaves the machine.
929
+
930
+ ```bash
931
+ cdpilot log # today's commands: time, exit, command, url, result
932
+ cdpilot log --md # Markdown report: pages visited, actions, errors, files produced
933
+ cdpilot log --json # raw lines (one JSON object per command)
934
+ cdpilot log --days 3 # include the last 3 days
935
+ cdpilot log --path # where the files are
936
+ ```
937
+
938
+ Each line has `ts`, `cmd`, `args`, `exit`, `duration_ms`, the page `url` and
939
+ `title` after the command (when the command already had them), a ~200-char
940
+ `summary` of its output, the `error` line, and `files` it wrote (screenshots,
941
+ PDFs). Files live in `~/.cdpilot/projects/<project-id>/log/<YYYY-MM-DD>.jsonl`.
942
+
943
+ Redaction happens before anything is written: values given to `fill`, `type`,
944
+ `smart-fill`, `smart-select`, `assert-value` and `dialog prompt` become
945
+ `«redacted:N chars»`; so do values of password/token/key/secret/cookie/auth
946
+ flags and headers, token-shaped arguments, and URL query/fragment values whose
947
+ names contain token, key, secret, password, auth, code or session. `cookies`
948
+ and `storage` output is never logged; `eval` source is, with string literals
949
+ over 40 chars cut and secret-looking ones replaced. Logging is best effort: it
950
+ never changes a command's output or exit code, and a failed write costs one
951
+ stderr line. `CDPILOT_LOG=0` turns it off; `CDPILOT_LOG_DAYS` (default 14)
952
+ sets how many days are kept. The MCP server exposes it as `browser_log`.
953
+
954
+ ### Use your own browser (human-in-the-loop)
955
+
956
+ An agent hits a CAPTCHA or a login wall it cannot get past. A person solves it,
957
+ and the agent carries on in the same browser.
958
+
959
+ **The simplest way needs no `connect`.** cdpilot's own browser is a normal,
960
+ visible window unless you launch it headless (`cdpilot headless on` /
961
+ `CHROME_HEADLESS=1`). The person solves the CAPTCHA or logs in right there, and
962
+ the agent continues:
963
+
964
+ ```bash
965
+ cdpilot go https://example.com/dashboard # agent: "captcha detected" / login wall
966
+ cdpilot captcha-wait 300 # agent waits while the person solves it
967
+ cdpilot go https://example.com/dashboard # agent continues in the same window
968
+ ```
969
+
970
+ That window uses cdpilot's own profile, so the login stays for later runs.
971
+
972
+ **`connect` is for a browser you start yourself**, with a debugging port and
973
+ your own profile directory (log into it once; it keeps its cookies):
974
+
975
+ ```bash
976
+ # macOS
977
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
978
+ --remote-debugging-port=9450 --user-data-dir="$HOME/cdpilot-chrome"
979
+ # Linux
980
+ google-chrome --remote-debugging-port=9450 --user-data-dir="$HOME/cdpilot-chrome"
981
+ # Windows
982
+ "%ProgramFiles%\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9450 --user-data-dir="%USERPROFILE%\cdpilot-chrome"
983
+
984
+ cdpilot connect 9450 # or: cdpilot connect ws://127.0.0.1:9450/devtools/browser/<id>
985
+ cdpilot connect --auto # or: find it via a DevToolsActivePort file
986
+ cdpilot go https://example.com/dashboard
987
+ cdpilot disconnect # forget it; the browser keeps running
988
+ ```
989
+
990
+ Since Chrome 136, `--remote-debugging-port` is ignored on the default profile,
991
+ so the separate `--user-data-dir` is required. `connect --auto` reads the
992
+ DevToolsActivePort file of each known profile directory (`~/cdpilot-chrome`
993
+ from the command above, Chrome stable/beta/dev/canary, Chromium, Brave,
994
+ Vivaldi, Edge), skips files left over from a browser that is gone or was
995
+ replaced on that port (the browser id in the file must match), and connects to
996
+ the first live one. It asks over HTTP only; a WebSocket probe (which Chrome's
997
+ chrome://inspect mode may answer with a permission prompt) is sent only when no
998
+ live browser was found.
999
+
1000
+ **Not supported yet: Chrome's `chrome://inspect/#remote-debugging` toggle**
1001
+ (Chrome 144+, your everyday logged-in profile). In that mode Chrome answers
1002
+ only on its WebSocket (`/json/version` and `/json` return 404) and may ask you
1003
+ to allow every new debugging connection, while cdpilot lists tabs over `/json`
1004
+ and opens a new connection per command. `connect --auto` recognises the mode
1005
+ and exits with code 2 and the start command above instead of half-working.
1006
+
1007
+ **What cdpilot does with a connected browser:**
1008
+
1009
+ - Page commands (`go`, `click`, `fill`, `shot`, …) run in a tab cdpilot opens
1010
+ for itself in a new window that does not take the focus
1011
+ (`Target.createTarget` with `newWindow` + `background`, so your window and
1012
+ tab keep it) on the first page command, and keep using that tab. `close`,
1013
+ `session-close` and the idle session cleanup close only such tabs, the ones
1014
+ cdpilot opened in this browser run, never the last one. Your own tabs are
1015
+ never navigated or typed into; only a command that names a tab touches it
1016
+ (`switch-tab`, `close-tab <index|id>`, `CDPILOT_TARGET`, and `multi-eval`,
1017
+ which runs its script in every open tab).
1018
+ - It never closes or kills the browser. `close`, `close --force`, `stop`,
1019
+ `stop --smart [--force]`, `project-stop`, `stop-all`, `session-close` and MCP
1020
+ `browser_close` close at most cdpilot's own tabs (above) and print
1021
+ "connected browser left running; run `cdpilot disconnect` to forget it".
1022
+ The idle auto-close never touches it,
1023
+ `tabs --reap` closes nothing, `wipe`, `permission`, `cookies load` and `cf-replay` are refused (add `--allow-external` to load/replay cookies anyway),
1024
+ `context close` only destroys a context `context create` made, and `launch` /
1025
+ MCP `browser_launch` start nothing.
1026
+ - `close-tab` is an explicit command and **can close your tabs**: the active
1027
+ (cdpilot) tab with no argument, or any tab you name by index or id. It never
1028
+ closes the last tab, which would quit the browser on Windows and Linux.
1029
+ - It injects nothing into it: no stealth patch or user-agent override (whatever
1030
+ `cdpilot mode` / `stealth` says), no glow, no input blocker, no dev-extension
1031
+ scripts, no auto-cookie restore.
1032
+ - The registration stays until you run `cdpilot disconnect`. If the browser is
1033
+ closed, page commands exit 1 with "your connected browser is gone; run
1034
+ `cdpilot connect` again or `cdpilot disconnect`", and cdpilot never launches a
1035
+ browser in its place.
1036
+ - `connect` refuses when this project already has a browser cdpilot launched
1037
+ ("stop it first (`cdpilot stop`) or use another project"), and only
1038
+ `127.0.0.1` / `localhost` is accepted (anything else exits 2). `disconnect`
1039
+ also stops this project's `watch` daemon.
1040
+ - cdpilot treats a browser as its own only with proof: the browser id it
1041
+ recorded at `launch` (or, for older entries, a live recorded pid that holds
1042
+ the debug port). A busy port is not proof, so a leftover registry entry never
1043
+ makes `stop` / `close` touch your own Chrome on port 9222.
1044
+
1045
+ **Risks:**
1046
+
1047
+ - Every cdpilot command, and any agent that can run cdpilot, can read and act
1048
+ with the cookies and sessions of that profile (mail, banking, admin panels).
1049
+ Use a profile that is logged into only what the task needs.
1050
+ - The debugging port is open to every local program for as long as that browser
1051
+ runs, not just to cdpilot. Close the browser when you are done.
1052
+ - Sites can tell automation more easily: cdpilot applies no stealth to a
1053
+ connected browser.
1054
+ - cdpilot's tab sits next to yours in the same window, and an agent may
1055
+ still act in one of your tabs when it names it (`switch-tab`,
1056
+ `close-tab <n>`, `multi-eval`).
1057
+
1058
+ ### WebMCP tools (opt-in)
1059
+
1060
+ [WebMCP](https://webmachinelearning.github.io/webmcp/) lets a page register
1061
+ "tools" on `document.modelContext`: imperatively with `registerTool()`, or
1062
+ declaratively with `<form toolname tooldescription>` (inputs described by
1063
+ `toolparamdescription`). cdpilot lists and calls them through the browser's own
1064
+ API, `getTools()` and `executeTool()`. In Chrome the API is behind
1065
+ `chrome://flags/#enable-webmcp-testing` (Chromium 146+), so start the browser
1066
+ with it:
1067
+
1068
+ ```bash
1069
+ cdpilot launch --webmcp # saved for this project: later launches (auto-launch too) keep it
1070
+ cdpilot go https://example.com/shop
1071
+ cdpilot tools list # name, title, description, annotations, input schema
1072
+ cdpilot tools list --json
1073
+ cdpilot tools call add_to_cart '{"sku":"A1","qty":2}'
1074
+ cdpilot tools call add_to_cart --arg sku=A1 --arg qty=2
1075
+ cdpilot launch --no-webmcp # turn it off again (applies at the next start)
1076
+ ```
1077
+
1078
+ - `launch --webmcp` adds `--enable-features=WebMCP` to the browser's command
1079
+ line and saves the mode next to the stealth mode (`webmcp.json` in the
1080
+ project profile); `CDPILOT_WEBMCP=1|0` overrides it. `cdpilot status` shows a
1081
+ WebMCP line while the mode is on.
1082
+ Chrome reads feature flags only at startup: a browser that is already running
1083
+ keeps its flags until `cdpilot stop`.
1084
+ - `tools list` shows the tools of the page and of its same-origin iframes
1085
+ (with the frame URL), marks form tools and whether the form submits itself
1086
+ (`toolautosubmit`), and shows `title` and the `readOnlyHint` /
1087
+ `consequentialHint` / `untrustedContentHint` annotations. Nothing is injected
1088
+ into the page: the list is the browser's registry at that moment, so it
1089
+ follows reloads and navigations. Both commands run in cdpilot's own isolated
1090
+ world (no `Runtime.enable`), so a page script that wraps `getTools()` /
1091
+ `executeTool()` cannot add tools, change results or see the calls; only if
1092
+ that world cannot see `document.modelContext` does cdpilot use the page's
1093
+ main world, and it says so on stderr. With no tools it says why (WebMCP not
1094
+ enabled in the running browser, not a secure context, browser too old,
1095
+ document not origin-keyed, or the page registered none) and exits 0.
1096
+ - `tools call` checks the arguments against the tool's `inputSchema` first
1097
+ (`required`, `type` — `true` is not an integer —, `enum`, `const`, nested
1098
+ `properties` and `items`; other keywords are left to the page) and exits 1
1099
+ on a mismatch, an unknown tool or a tool error. It passes an `AbortSignal`
1100
+ and aborts it when `--timeout` (default 20 s) runs out, then exits 124
1101
+ with `timed out after <N>s (--timeout); its execution was aborted`. The
1102
+ signal fires at the `--timeout` deadline; the watchdog waits 3 s more so
1103
+ that report is printed.
1104
+ A form tool without `toolautosubmit` waits for a person to submit the form.
1105
+ When several frames register the same name, `--frame <url-part>` picks one
1106
+ (an exact frame URL wins); without it the top document's tool is used, and
1107
+ if there is none the call stops and lists the frames.
1108
+ - MCP: `browser_site_tools` and `browser_site_tool_call` are listed only while
1109
+ the project's WebMCP mode is on (or `CDPILOT_WEBMCP=1` for the server).
1110
+
1111
+ > **Security**: `tools call` runs the page's own code, and tool names,
1112
+ > descriptions and results are page content: treat them as untrusted.
1113
+ > Arguments and results are written to the session log with secret-named
1114
+ > fields (password, token, key, secret, auth, ...) redacted at any depth.
1115
+
603
1116
  ### Scaling & Workstation Use
604
1117
 
605
1118
  ```bash
@@ -663,6 +1176,9 @@ print(result.stdout)
663
1176
  | `CDPILOT_OFFSCREEN` | `0` | Headed but render off-screen — no window steals focus |
664
1177
  | `CDPILOT_TIMEOUT` | unset | Default `--timeout` in seconds for every command (flag wins, `0` disables); expiry exits 124 |
665
1178
  | `CDPILOT_NO_AUTOLAUNCH` | `0` | `1` = page commands fail with the old "Is the browser running?" error instead of launching the browser |
1179
+ | `CDPILOT_IDLE_CLOSE` | `15` | Minutes without a cdpilot command or page change before a browser cdpilot launched closes itself (fractions allowed, `0` = never; read at launch). Default applies to auto-launch and MCP launches; an explicit CLI `launch` is off unless this is set or `--idle-close <min>` is given |
1180
+ | `CDPILOT_LOG` | `1` | `0` = do not write the session log (`cdpilot log`) |
1181
+ | `CDPILOT_LOG_DAYS` | `14` | Days of session log to keep; older day files are deleted on the first write of a day (`0` keeps all) |
666
1182
 
667
1183
  ## How It Works
668
1184
 
@@ -823,10 +1339,10 @@ The only browser MCP with built-in test assertions. Here's what we've shipped an
823
1339
  - [x] **Data extraction** (`extract`) — structured DOM data in text, JSON, or list format
824
1340
  - [x] **Page observation** (`observe`) — list all interactive elements with available actions
825
1341
  - [x] **Script runner** (`run`) — execute `.cdp` script files with pass/fail reporting
1342
+ - [x] **iframe targeting** — `"iframe#card >>> input"` / `--frame` for element commands, same-origin and cross-origin (out-of-process) frames; smart commands search frames automatically
826
1343
 
827
1344
  ### Coming Soon
828
1345
 
829
- - [ ] **iframe** support — interact with elements inside iframes (Shadow DOM traversal already shipped in smart commands)
830
1346
  - [ ] **Multi-instance pool** (`CDPILOT_POOL_SIZE`) — N independent browser processes with least-loaded dispatch
831
1347
  - [ ] **Session recording & replay** — record browser sessions and replay them deterministically
832
1348
  - [ ] **Stealth mode** *(Pro)* — human-like mouse/typing, anti-fingerprint, CAPTCHA solving