iframer-toolkit 0.10.5 → 0.10.6

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
@@ -1,23 +1,62 @@
1
- # iframer-toolkit
1
+ <div align="center">
2
2
 
3
- Browser access for AI agents when normal fetching fails. Give Claude, Codex, or any MCP-compatible agent a real browser: with session persistence, stealth fingerprinting, encrypted credential storage, and automatic captcha solving, and most of all, API Reverse Engineering.
3
+ <img src="docs/logo.svg" width="104" alt="iframer">
4
4
 
5
- ### Is this AI slop?
6
- No. This is a tool I use almost EVERY SINGLE DAY and I have put a lot of thought and real engineering to actually make this a tool I am proud of.
7
- Why did I develop this? Because I felt like the other agentic browsers didn't give me enough power and felt very expensive token-wise.
5
+ # iframer
8
6
 
9
- ## Example:
10
- <img width="2962" height="1116" alt="image" src="https://github.com/user-attachments/assets/c937b39c-5dba-4d6a-892d-71243ecf8146" />
11
- Resulting rev API example:
12
- <img width="2141" height="1173" alt="image" src="https://github.com/user-attachments/assets/19a27904-ebe0-4e65-8a44-a8cfb3756ed8" />
7
+ **Give an AI agent a real browser, get an API back.**<br>
8
+ Drive a site once and iframer hands the agent the actual request behind it, no devtools, no HAR file.
13
9
 
14
- Ships as:
15
- - **CLI** (`iframer-toolkit` / `iframer`) — browse, screenshot, credentials, sessions, reverse-engineer APIs
16
- - **MCP server** — plugs directly into Claude Code or Codex so agents can drive the browser themselves
17
- - **Browser extension** (optional) — lets the agent drive tabs in **your real Chrome**, on your real logged-in session
18
- - **Self-hosted Docker server** (optional) — adds live headful browsing over noVNC for remote/multi-user setups
10
+ ![npm](https://img.shields.io/npm/v/iframer-toolkit?color=6D5EF0&label=npm)
11
+ ![license](https://img.shields.io/badge/license-MIT-6D5EF0)
12
+ ![node](https://img.shields.io/badge/node-%E2%89%A518-6D5EF0)
13
+ ![CLI](https://img.shields.io/badge/-CLI-6D5EF0)
14
+ ![MCP](https://img.shields.io/badge/-MCP-6D5EF0)
15
+ ![extension](https://img.shields.io/badge/-extension-6D5EF0)
16
+
17
+ <img src="docs/example/demo.gif" width="760" alt="left: a demo page being clicked. right: the terminal, running iframer reverse-engineer, showing the captured endpoint">
18
+
19
+ </div>
20
+
21
+ Most agent-driven browsers only replay clicks. Every visit to the same page means rendering the whole thing again, screenshot after screenshot, just to read one field off it.
22
+
23
+ iframer drives a real, stealth-patched Chromium, or your own logged-in Chrome through the extension, and while it does:
24
+
25
+ - Records the fetch and XHR calls the page makes while it drives it (not static assets), no manual devtools work
26
+ - Hands back the real request behind a login form or a button click: method, headers, auth, a working curl
27
+ - Generates typed functions ready to `import`, when an agent is doing the driving
28
+ - Caches the domain after the first run, so the next visit skips the browser and hits the endpoint directly, seconds down to milliseconds
29
+
30
+ ## Example
31
+
32
+ The left side of the GIF above is [`docs/example/site/index.html`](docs/example/site/index.html): one button, one hidden API call. The right side is a real terminal, only the typing pace was staged for the recording; the command and its output are exactly what running it produces. Capturing it for real:
33
+
34
+ ```sh
35
+ cd docs/example/site && python3 -m http.server 8934 &
36
+ iframer-toolkit reverse-engineer '[
37
+ {"type":"navigate","url":"http://localhost:8934/index.html"},
38
+ {"type":"click","selector":"#load"},
39
+ {"type":"wait","ms":1000}
40
+ ]' --output docs/example/api
41
+ ```
42
+
43
+ That produced [`docs/example/api/captured-api.json`](docs/example/api/captured-api.json): the exact request, headers, response body, and a curl command, all automatic, no agent required.
44
+
45
+ The rest of `docs/example/api/` (`getTodos.ts`, `transport/rest.ts`, `types.ts`, `README.md`) is what an agent writes from that capture: iframer tells it the endpoint's protocol, verb, and function name, and the agent turns that into a typed function. That's what happened here, driven by Claude through the MCP `reverse-engineer` tool.
46
+
47
+ The whole round trip, one click to one typed endpoint, cost about 590 tokens back to the agent. This is the whole thing you get: no HAR file, no manual devtools work, a function you can call.
48
+
49
+ ## Token cost
19
50
 
20
- The rest of this description is AI generated, bad for humans, good for LLMs:
51
+ Most of iframer's savings come from the snapshot it hands back: capped and filtered, instead of the full accessibility tree other tools dump into context. Measured with the same tokenizer, same steps, on the same three tasks:
52
+
53
+ | Task | iframer (MCP) | iframer (CLI) | agent-browser | Playwright MCP |
54
+ |------|---------------|----------------|----------------|-----------------|
55
+ | Wiki link chase (6 hops, Everest → Gojo) | 517 | 721 | 913 | 1,405 |
56
+ | Store purchase (login → sort → cart → checkout) | 552 | 697 | 851 | 2,739 |
57
+ | Heavy-page recon (GitHub: look + pull 4 facts) | 986 | 1,058 | 8,433 | 19,906 |
58
+
59
+ The gap grows with how much DOM the page has. On the GitHub page, iframer spent about 986 tokens where Playwright MCP spent about 19,900, roughly 20x. That's per-task cost. It doesn't include the roughly 5.1k tokens of MCP tool-definition overhead loaded once per session, not per task.
21
60
 
22
61
  ## Install
23
62
 
@@ -47,11 +86,17 @@ iframer-toolkit update # update via npm (also refreshes the extensio
47
86
  iframer-toolkit update --check # just report whether a newer version exists
48
87
  ```
49
88
 
50
- > **Note:** If you prefer, your agent can run `iframer-toolkit install` for you — it'll figure the rest out.
89
+ Your agent can also just run `iframer-toolkit install` for you; it figures out the rest.
90
+
91
+ Ships as:
92
+ - **CLI** (`iframer-toolkit` / `iframer`): browse, screenshot, credentials, sessions, reverse-engineer APIs
93
+ - **MCP server**: plugs into Claude Code or Codex so agents drive the browser themselves
94
+ - **Browser extension** (optional): lets the agent drive tabs in your real Chrome, on your real logged-in session
95
+ - **Self-hosted Docker server** (optional): adds live headful browsing over noVNC for remote or multi-user setups
51
96
 
52
97
  ## Quick start
53
98
 
54
- Once installed, you can either drive the browser via the CLI directly, or ask your agent (Claude Code / Codex) to do it for you via the MCP.
99
+ Once installed, drive the browser via the CLI directly, or ask your agent (Claude Code / Codex) to do it through the MCP.
55
100
 
56
101
  **CLI:**
57
102
 
@@ -60,7 +105,7 @@ iframer-toolkit status # system + browser m
60
105
  iframer-toolkit browse https://example.com --extract 'document.title'
61
106
  iframer-toolkit screenshot https://news.ycombinator.com -o /tmp/hn.png
62
107
  iframer-toolkit credentials add github.com # interactive masked prompt
63
- iframer-toolkit reverse-engineer https://some-spa.com # capture the APIs it calls
108
+ iframer-toolkit reverse-engineer https://some-spa.com # capture the APIs it calls
64
109
  iframer-toolkit --cache # list cached domains
65
110
  ```
66
111
 
@@ -68,44 +113,80 @@ iframer-toolkit --cache # list cached domain
68
113
 
69
114
  > "Log into my account on example.com and extract the latest invoice."
70
115
 
71
- The agent will call `knowledge` first (to see if a direct-API path is cached), fall back to `credentials` + `execute` if not, auto-escalate browser modes if a site blocks headless, and return the result. No copying cookies, no proxies, no manual login.
116
+ The agent calls `knowledge` first to check for a cached direct-API path, falls back to `credentials` + `execute` if there isn't one, auto-escalates browser modes if a site blocks headless, and returns the result. No copying cookies, no proxies, no manual login.
72
117
 
73
118
  ## How it works
74
119
 
75
120
  ```
76
- Claude / Codex (MCP) ──→ iframer MCP server ──→ shared local server (127.0.0.1)
77
- ├─ patchright (stealth Chromium)
78
- ├─ Chrome for Testing
79
- ├─ your real Chrome (optional, via extension)
80
- └─ SQLite at ~/.iframer — one file for
81
- encrypted credentials, session state,
82
- and per-domain knowledge cache
121
+ Claude / Codex (MCP) ──▶ iframer MCP server ──▶ shared local server (127.0.0.1)
122
+ ├─ patchright (stealth Chromium)
123
+ ├─ Chrome for Testing
124
+ ├─ your real Chrome (optional, via extension)
125
+ └─ SQLite at ~/.iframer: one file for
126
+ encrypted credentials, session state,
127
+ and per-domain knowledge cache
83
128
  ```
84
129
 
85
- By default, `install mcp` runs in **local mode**: no Docker needed. Both the MCP clients and the `iframer execute` CLI talk to **one shared local server** (discovered via `~/.iframer/server.json`, loopback-only) that keeps a stealth-patched Chromium (via [patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright)) warm between calls, tracks every browser it spawns in an on-disk PID registry so nothing leaks, and idle-exits when no one needs it. `install mcp` registers the server in both Claude Code (`~/.claude.json`) and Codex (`~/.codex/config.toml`).
130
+ By default, `install mcp` runs in **local mode**, no Docker needed. Both the MCP clients and the `iframer execute` CLI talk to one shared local server (discovered via `~/.iframer/server.json`, loopback only) that keeps a stealth-patched Chromium warm between calls, tracks every browser it spawns in an on-disk PID registry so nothing leaks, and idle-exits when no one needs it. `install mcp` registers the server in both Claude Code (`~/.claude.json`) and Codex (`~/.codex/config.toml`).
86
131
 
87
- **Warm CLI.** `iframer execute` routes to that shared daemon instead of launching a cold browser per invocation, so after the first call it's as fast as the MCP (pass `--in-process` to force a private throwaway browser). Each agent gets its own browser instance (keyed off `CLAUDE_CODE_SESSION_ID` / `TERM_SESSION_ID`, or set `IFRAMER_INSTANCE`), so concurrent agents never share a window, while all CLI calls share one login session.
132
+ **Warm CLI.** `iframer execute` routes to that shared daemon instead of launching a cold browser per invocation, so after the first call it's as fast as the MCP (pass `--in-process` to force a private throwaway browser). Each agent gets its own browser instance, keyed off `CLAUDE_CODE_SESSION_ID` / `TERM_SESSION_ID` or `IFRAMER_INSTANCE`, so concurrent agents never share a window, while all CLI calls share one login session.
88
133
 
89
- **Resuming a task.** The browser and its page persist in the daemon between calls and survive an interrupt (Ctrl-C). Name a multi-step or human-in-the-loop task's browser with a stable `instanceId`, and to resume just run `execute` again with the same `instanceId` and act on the current page (`snapshot`/`read`/`find`) — don't navigate again, which would reload and lose the state (e.g. an OTP screen). `iframer instances` lists the live windows and which page each is on.
134
+ **Resuming a task.** The browser and its page persist in the daemon between calls and survive an interrupt (Ctrl-C). Name a multi-step or human-in-the-loop task's browser with a stable `instanceId`, and to resume, run `execute` again with the same `instanceId` and act on the current page (`snapshot` / `read` / `find`). Don't navigate again; that reloads the page and loses state like an OTP screen. `iframer instances` lists the live windows and which page each is on.
90
135
 
91
- **One credential store for every browser mode.** Stored credentials, session cookies/localStorage, and the knowledge cache all live in a single SQLite file at `~/.iframer/iframer.db`. Store a password once (via CLI or MCP) and every mode — `headless`, `binary-headful`, `docker-headful` — uses the same row. No split-brain between modes.
136
+ **One credential store for every browser mode.** Stored credentials, session cookies/localStorage, and the knowledge cache all live in one SQLite file at `~/.iframer/iframer.db`. Store a password once, from the CLI or the MCP, and every mode (`headless`, `binary-headful`, `docker-headful`) uses the same row.
92
137
 
93
- **Auto-escalation.** When a pipeline is blocked in `headless` (bot detection, captcha), iframer transparently retries in `binary-headful` and then `docker-headful` without a round-trip to the agent. The mode that worked is recorded in `~/.iframer/domain-modes.json` so the next run on that domain starts at the right mode.
138
+ **Auto-escalation.** When a pipeline is blocked in `headless` (bot detection, captcha), iframer retries in `binary-headful` and then `docker-headful` without a round trip to the agent. The mode that worked is recorded in `~/.iframer/domain-modes.json`, so the next run on that domain starts at the right mode.
94
139
 
95
- **Knowledge cache.** After every successful run, iframer writes a per-domain markdown file at `~/.iframer/knowledge/<domain>.md` recording which cookies / localStorage keys / headers the site uses for auth, plus any API endpoints observed along the way. Next time the agent needs data from that domain, it reads the cache first — if there's a direct-API path, it skips the browser entirely. See [Knowledge cache](#knowledge-cache) below.
140
+ **Knowledge cache.** After every successful run, iframer writes a per-domain markdown file at `~/.iframer/knowledge/<domain>.md` recording which cookies, localStorage keys, and headers the site uses for auth, plus any API endpoints seen along the way. The next time an agent needs data from that domain, it reads the cache first; if there's a direct-API path, it skips the browser entirely. See [Knowledge cache](#knowledge-cache) below.
96
141
 
97
142
  For live remote viewing, multi-user, or Linux server deployments, see [Self-hosting with Docker](#self-hosting-with-docker) below.
98
143
 
99
- ## Extension mode — drive your real Chrome
144
+ ## Extension mode: drive your real Chrome
145
+
146
+ The optional browser extension lets the agent drive a tab you already have open in your real Chrome. No relaunch, no remote-debugging port, your real logged-in session. The extension dials out to iframer's local server and relays the CDP protocol via `chrome.debugger`, so your live tab is driven by the exact same pipeline engine (find, click, snapshot, obstacle handling, API capture) as every other mode, with real trusted input. While a run is active, Chrome shows its yellow "started debugging" bar.
100
147
 
101
- The optional browser extension lets the agent drive **a tab you already have open in your real Chrome** — no relaunch, no remote-debugging port, on your real logged-in session. The extension dials out to iframer's local server and relays the CDP protocol via `chrome.debugger`, so your live tab is driven by the exact same pipeline engine (find / click / snapshot / obstacle handling / API capture) as every other mode, with real trusted input. While a run is active, Chrome shows its yellow "started debugging" bar.
148
+ There's no Chrome Web Store listing. You load it as an unpacked, developer-mode extension, straight from the files npm just installed on your machine.
102
149
 
150
+ ### Installing it
151
+
152
+ 1. Run:
153
+ ```sh
154
+ iframer install extension chrome
155
+ ```
156
+ This installs a small pairing host and prints a folder path. That folder is the extension itself.
157
+
158
+ 2. Open `chrome://extensions` in Chrome.
159
+ 3. Turn on **Developer mode**, the toggle is top right.
160
+ 4. Click **Load unpacked**.
161
+ 5. In the file picker, paste the folder path from step 1 and select it.
162
+
163
+ Chrome loads the extension and pins its icon to the toolbar. That's the whole install, one time only.
164
+
165
+ ### Finding the path again
166
+
167
+ Lost the path, or setting up a second machine? Run:
103
168
  ```sh
104
- iframer install extension chrome # installs the pairing host, prints the folder to load
105
- iframer extension path # prints it again
169
+ iframer extension path
106
170
  ```
171
+ It prints the same folder every time.
107
172
 
108
- Then load it once: `chrome://extensions` → Developer mode → **Load unpacked** → the printed folder. Once paired, the agent lists your open tabs with the `tabs` tool and drives the one you mean. Multiple Chrome profiles/browsers can be paired at once — each identifies itself with a profile name, and iframer routes work to the profile that owns the target tab. See [`extension/README.md`](extension/README.md) for details.
173
+ That folder lives inside wherever npm put `iframer-toolkit` globally, not inside a git checkout of this repo. On most machines that's something like `/usr/local/lib/node_modules/iframer-toolkit/extension` or, with nvm, a path under `~/.nvm/versions/node/<version>/lib/node_modules/iframer-toolkit/extension`. Run the command above instead of guessing, the exact path depends on your Node setup.
174
+
175
+ Loading it from that exact path matters: it's what lets `iframer update` refresh the extension files in place, then tell Chrome to reload it, no repeating the install steps after every update.
176
+
177
+ ### Pairing (automatic)
178
+
179
+ Once loaded, the extension pairs itself. The pairing host installed in step 1 hands it the same local token the CLI and MCP already use, straight from `~/.iframer/secret`. Nothing to copy, nothing to type.
180
+
181
+ If that ever fails (host not installed, or you're on a platform it doesn't support), click the iframer icon in the toolbar and paste the token yourself:
182
+ ```sh
183
+ cat ~/.iframer/secret
184
+ ```
185
+ then **Save & connect**.
186
+
187
+ Either way, check the popup: the dot next to "iframer" turns green once it finds the running local server.
188
+
189
+ You can repeat this whole install on more than one Chrome profile, or in Brave, Edge, or another Chromium-based browser, at the same time. Each one pairs under its own profile name, and iframer routes each task to whichever profile actually has the tab. Once paired, the agent lists your open tabs with the `tabs` tool and drives the one you mean. See [`extension/README.md`](extension/README.md) for how the pipeline and multi-profile routing work under the hood.
109
190
 
110
191
  ## CLI reference
111
192
 
@@ -175,7 +256,7 @@ Setup:
175
256
 
176
257
  Browser:
177
258
  modes Show available browser modes
178
- status Show system status
259
+ status Show system status
179
260
  ```
180
261
 
181
262
  The binary is available as either `iframer-toolkit` (full name) or `iframer` (short alias). `npx iframer-toolkit ...` also works without a global install.
@@ -184,33 +265,33 @@ The binary is available as either `iframer-toolkit` (full name) or `iframer` (sh
184
265
 
185
266
  Once the MCP is registered, the agent has access to:
186
267
 
187
- - **`status`** — system health, session state, stored credentials, available browser modes
188
- - **`knowledge`** — read / list / clear the per-domain knowledge cache. Agents are told to check this **before** every `execute` or `browse` — if the cache has a direct-API path for the data, the browser doesn't launch at all.
189
- - **`execute`** — run a pipeline of browser steps (navigate, click, right-click, fill, select, human-click, human-type, type-code, read, evaluate, extract, wait, wait-for, scroll, keyboard, upload, paste, download, login, solve-captcha, recaptcha, screenshot, snapshot, find). Each step has a 20s stale-state timeout. On failure, returns the exact step, error type, and a screenshot of the page at the point of failure. Auto-escalates browser modes transparently on bot-block.
190
- - **`browse`** — fast headless fetch with session persistence for pages that don't need a full pipeline
191
- - **`reverse-engineer`** — capture the APIs a site calls (feeds into the knowledge cache so future runs can skip the browser)
192
- - **`remember`** — per-domain **element anchors**: save a selector once under a name, then target it as `@a:<name>` in any later pipeline — no re-finding the element on every visit. Anchors self-heal (a failed anchor triggers re-discovery, not blind retries) and the tool also records per-site quirks.
193
- - **`tabs`** — list and target tabs in your real Chrome (requires the [extension](#extension-mode--drive-your-real-chrome))
194
- - **`clipboard`** — read/write the machine's clipboard (the same one your Chrome uses): read a code a site just copied, or stage text to paste into a field via the `paste` step
195
- - **`session`** — `stop` (save state) or `clear` (wipe)
196
- - **`credentials`** — `store` (secure form via MCP elicitation), `list`. The `store` action will **refuse to overwrite** existing credentials unless explicitly told to (`force: true`) — this blocks the common "login failed, re-ask for password" anti-pattern when the real problem is browser mode / bot detection.
268
+ - **`status`**: system health, session state, stored credentials, available browser modes
269
+ - **`knowledge`**: read/list/clear the per-domain knowledge cache. Agents are told to check this before every `execute` or `browse`; if the cache already has a direct-API path, the browser never launches.
270
+ - **`execute`**: run a pipeline of browser steps (navigate, click, right-click, fill, select, human-click, human-type, type-code, read, evaluate, extract, wait, wait-for, scroll, keyboard, upload, paste, download, login, solve-captcha, recaptcha, screenshot, snapshot, find). Each step has a 20s stale-state timeout. On failure, it returns the exact step, error type, and a screenshot of the page at the point of failure. Auto-escalates browser modes on bot-block.
271
+ - **`browse`**: fast headless fetch with session persistence for pages that don't need a full pipeline
272
+ - **`reverse-engineer`**: capture the APIs a site calls (feeds the knowledge cache so future runs can skip the browser)
273
+ - **`remember`**: per-domain element anchors. Save a selector once under a name, then target it as `@a:<name>` in any later pipeline, no re-finding the element on every visit. Anchors self-heal (a failed anchor triggers re-discovery, not blind retries) and the tool also records per-site quirks.
274
+ - **`tabs`**: list and target tabs in your real Chrome (requires the [extension](#extension-mode-drive-your-real-chrome))
275
+ - **`clipboard`**: read/write the machine's clipboard, the same one your Chrome uses: read a code a site just copied, or stage text to paste into a field via the `paste` step
276
+ - **`session`**: `stop` (save state) or `clear` (wipe)
277
+ - **`credentials`**: `store` (secure form via MCP elicitation), `list`. `store` refuses to overwrite existing credentials unless told to (`force: true`), which blocks the common "login failed, re-ask for password" mistake when the real problem is browser mode or bot detection.
197
278
 
198
279
  ## Session persistence
199
280
 
200
- Session data (cookies + localStorage) and credentials are stored in SQLite at `~/.iframer/iframer.db` and encrypted with AES-256-GCM. Data is automatically re-injected on the next `execute` or `browse` so agents stay logged in across restarts — and across browser modes: a session captured in `binary-headful` will transparently load into `headless` on the next run.
281
+ Session data (cookies + localStorage) and credentials are stored in SQLite at `~/.iframer/iframer.db` and encrypted with AES-256-GCM. Data is automatically re-injected on the next `execute` or `browse` so agents stay logged in across restarts, and across browser modes: a session captured in `binary-headful` will load into `headless` on the next run.
201
282
 
202
- The encryption key lives at `~/.iframer/secret` (0600 permissions), generated on first `install mcp`. Set `IFRAMER_SECRET` in your environment to override it — useful if you want to synchronize the key across multiple machines.
283
+ The encryption key lives at `~/.iframer/secret` (0600 permissions), generated on first `install mcp`. Set `IFRAMER_SECRET` in your environment to override it, useful if you want to sync the key across machines.
203
284
 
204
285
  ## Knowledge cache
205
286
 
206
- Every successful `execute` run updates a plain markdown file at `~/.iframer/knowledge/<domain>.md` — human-readable, grep-able, and editable. Each file captures:
287
+ Every successful `execute` run updates a plain markdown file at `~/.iframer/knowledge/<domain>.md`, human-readable, grep-able, editable. Each file captures:
207
288
 
208
- - Which cookies, localStorage keys, and headers are load-bearing for authentication (names only — values stay encrypted in the session store)
209
- - Which API endpoints the site called during the run (with method, path, example curl, and status code)
289
+ - Which cookies, localStorage keys, and headers are load-bearing for authentication (names only; values stay encrypted in the session store)
290
+ - Which API endpoints the site called during the run, with method, path, example curl, and status code
210
291
  - Which browser mode last worked for the domain
211
- - Notes about captcha/bot-detection behavior
292
+ - Notes about captcha or bot-detection behavior
212
293
 
213
- The MCP `knowledge` tool exposes this to the agent with `get`, `list`, and `clear` actions. Agents are instructed to call it **before** every browser-touching tool — if the cache already knows a direct-API path for the data the user is asking about, the agent hits the API directly and never launches a browser. First request takes seconds; repeat requests on the same domain take milliseconds.
294
+ The MCP `knowledge` tool exposes this with `get`, `list`, and `clear` actions. Agents are instructed to call it before every browser-touching tool; if the cache already has a direct-API path for the data, the agent hits the API directly and never launches a browser. First request takes seconds, repeat requests on the same domain take milliseconds.
214
295
 
215
296
  Inspect the cache yourself:
216
297
 
@@ -223,7 +304,7 @@ iframer-toolkit --clear-cache figma.com # wipe one domain
223
304
 
224
305
  ## Captcha solving
225
306
 
226
- iframer auto-detects and solves reCAPTCHA and hCaptcha using Claude's vision API. Use the `solve-captcha` step in a pipeline:
307
+ iframer detects and solves reCAPTCHA and hCaptcha using Claude's vision API. Use the `solve-captcha` step in a pipeline:
227
308
 
228
309
  ```json
229
310
  { "type": "solve-captcha" }
@@ -245,9 +326,9 @@ Requires `ANTHROPIC_API_KEY` in your environment.
245
326
 
246
327
  ## Self-hosting with Docker
247
328
 
248
- The Docker server adds a live headful browsing mode over noVNC (watch the agent drive the browser in real time) and lets multiple clients share one browser pool. The Docker path is only used when the pipeline explicitly requests `mode: "docker-headful"` — `headless` and `binary-headful` always run directly on the host even when Docker is up, so host-stored credentials are always visible.
329
+ The Docker server adds a live headful browsing mode over noVNC (watch the agent drive the browser in real time) and lets multiple clients share one browser pool. The Docker path only runs when a pipeline explicitly requests `mode: "docker-headful"`; `headless` and `binary-headful` always run directly on the host even when Docker is up, so host-stored credentials stay visible.
249
330
 
250
- The `docker-compose.yml` bind-mounts `~/.iframer` from the host into the container as `/iframer-data` and sets `IFRAMER_DATA_DIR=/iframer-data`, so **container and host share the same SQLite file**. Credentials stored via the CLI or MCP on the host are immediately visible to `docker-headful` without any copy step.
331
+ `docker-compose.yml` bind-mounts `~/.iframer` from the host into the container as `/iframer-data` and sets `IFRAMER_DATA_DIR=/iframer-data`, so container and host share the same SQLite file. Credentials stored via the CLI or MCP on the host are immediately visible to `docker-headful`, no copy step.
251
332
 
252
333
  **1. Clone and configure**
253
334
 
@@ -255,7 +336,7 @@ The `docker-compose.yml` bind-mounts `~/.iframer` from the host into the contain
255
336
  git clone https://github.com/EduardoFazolo/iframer-toolkit.git
256
337
  cd iframer-toolkit
257
338
  cp .env.example .env
258
- # Edit .env — set ANTHROPIC_API_KEY (for captcha) and IFRAMER_SECRET (for auth)
339
+ # Edit .env: set ANTHROPIC_API_KEY (for captcha) and IFRAMER_SECRET (for auth)
259
340
  ```
260
341
 
261
342
  **2. Start**
@@ -268,13 +349,13 @@ bun run stop:docker # stop containers
268
349
 
269
350
  **3. Point the MCP at it (remote host only)**
270
351
 
271
- If you're running Docker on a remote machine and the MCP on a different machine, install the MCP with the remote URL:
352
+ If Docker runs on a remote machine and the MCP on a different one, install the MCP with the remote URL:
272
353
 
273
354
  ```sh
274
355
  IFRAMER_URL=https://your-host:3021 iframer-toolkit install mcp --dev
275
356
  ```
276
357
 
277
- When Docker is on the same host as the MCP, no extra setup is needed — the local MCP server will auto-detect the Docker API on `localhost:3021` and route only `docker-headful` requests through it.
358
+ When Docker is on the same host as the MCP, no extra setup is needed; the local MCP server auto-detects the Docker API on `localhost:3021` and routes only `docker-headful` requests through it.
278
359
 
279
360
  **4. Watch the browser live**
280
361
 
@@ -292,17 +373,36 @@ Or run `iframer-toolkit watch` to auto-open it.
292
373
  |--------------------|------------|
293
374
  | Browser engine | [patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) (stealth-patched Playwright fork) |
294
375
  | Browser binary | Chrome for Testing (downloaded to `~/.iframer/chrome/`) |
295
- | Local server | One shared warm server per machine (loopback-only, discovered via `~/.iframer/server.json`), on-disk browser PID registry, idle auto-exit |
296
- | Extension mode | Chrome extension relaying CDP over WebSocket (`chrome.debugger` → `connectOverCDP`) — drives tabs in your real Chrome |
376
+ | Local server | One shared warm server per machine (loopback only, discovered via `~/.iframer/server.json`), on-disk browser PID registry, idle auto-exit |
377
+ | Extension mode | Chrome extension relaying CDP over WebSocket (`chrome.debugger` → `connectOverCDP`), drives tabs in your real Chrome |
297
378
  | Stealth | Fingerprint injection, WebRTC leak prevention, worker patching |
298
379
  | Credential store | SQLite at `~/.iframer/iframer.db`, AES-256-GCM encrypted, shared by every browser mode |
299
- | Session persistence| Same SQLite file — cookies + localStorage re-injected across runs and modes |
380
+ | Session persistence| Same SQLite file, cookies + localStorage re-injected across runs and modes |
300
381
  | Knowledge cache | Plain markdown at `~/.iframer/knowledge/<domain>.md` |
301
382
  | Captcha solving | Anthropic vision API (`@anthropic-ai/sdk`) |
302
383
  | Live viewing | Xvfb + x11vnc + noVNC + websockify (Docker mode only) |
303
384
  | MCP server | `@modelcontextprotocol/sdk` |
304
385
  | Runtime | Node.js ≥18 (Bun for development) |
305
386
 
387
+ ## Repo layout
388
+
389
+ ```
390
+ src/lib/ core: pipeline executor, actions, auth, screenshot, knowledge cache
391
+ src/mcp/ MCP server + tool definitions
392
+ bin/cli.js CLI entry point
393
+ extension/ Chrome extension (manifest v3, CDP relay)
394
+ docs/example/ the demo in this README: site/ + captured api/
395
+ benchmark/ token-cost benchmark harness vs agent-browser and Playwright MCP (gitignored, local only)
396
+ ```
397
+
398
+ ## What's not done yet
399
+
400
+ - Stealth fingerprinting is injected right after the page loads, not at browser-context creation, so a site checking for it in the first tick of navigation can still catch it. This is the main remaining cause of false bot-blocks.
401
+ - The knowledge cache's markdown merge can drop an endpoint's description or example fields when the same domain is captured a second time.
402
+ - Consent banners rendered inside a cross-origin iframe (common with some CMPs) aren't recognized as an obstacle yet.
403
+ - Element anchors (`remember`) still have to be saved by hand after a successful run; auto-saving the selectors that worked isn't wired up.
404
+ - A few internal modules (the SQLite adapter, the stealth/humanize helpers) still carry loose `any` types.
405
+
306
406
  ## Development
307
407
 
308
408
  ```sh
@@ -310,7 +410,7 @@ git clone https://github.com/EduardoFazolo/iframer-toolkit.git
310
410
  cd iframer-toolkit
311
411
  bun install
312
412
 
313
- # Run the CLI from source (no build needed — bun runs .ts directly)
413
+ # Run the CLI from source (no build needed, bun runs .ts directly)
314
414
  bun run bin/cli.js status
315
415
 
316
416
  # Run the MCP server from source
package/dist/cli.js CHANGED
@@ -902,6 +902,10 @@ function findChromeExecutable() {
902
902
  if (import_fs3.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
903
903
  return void 0;
904
904
  }
905
+ async function getHeadedBrowser() {
906
+ if (!headedBrowser?.isConnected()) headedBrowser = await import_patchright.chromium.launch({ headless: false, args: STEALTH_ARGS });
907
+ return headedBrowser;
908
+ }
905
909
  async function getBrowser(_name = "chromium") {
906
910
  if (cachedBrowser) {
907
911
  if (cachedBrowser.isConnected()) return cachedBrowser;
@@ -919,6 +923,11 @@ async function getBrowser(_name = "chromium") {
919
923
  return cachedBrowser;
920
924
  }
921
925
  async function closeBrowser() {
926
+ if (headedBrowser) {
927
+ await headedBrowser.close().catch(() => {
928
+ });
929
+ headedBrowser = null;
930
+ }
922
931
  if (!cachedBrowser) return;
923
932
  try {
924
933
  await cachedBrowser.close();
@@ -955,7 +964,7 @@ async function launchHeadful(displayNum) {
955
964
  log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
956
965
  return import_patchright.chromium.launch(launchOpts);
957
966
  }
958
- var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser;
967
+ var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser, headedBrowser;
959
968
  var init_launcher = __esm({
960
969
  "src/lib/browser/launcher.ts"() {
961
970
  "use strict";
@@ -966,6 +975,7 @@ var init_launcher = __esm({
966
975
  log2 = createLogger("launcher");
967
976
  UBLOCK_PATH = "/extensions/uBlock0.chromium";
968
977
  cachedBrowser = null;
978
+ headedBrowser = null;
969
979
  }
970
980
  });
971
981
 
@@ -2273,6 +2283,59 @@ var init_ref_store = __esm({
2273
2283
  }
2274
2284
  });
2275
2285
 
2286
+ // src/lib/site-protection.ts
2287
+ async function paceProtectedDomain(domain) {
2288
+ const now = Date.now();
2289
+ const slot = Math.max(now, nextRequest.get(domain) || 0);
2290
+ nextRequest.set(domain, slot + PROTECTED_INTERVAL_MS);
2291
+ if (slot > now) await new Promise((resolve) => setTimeout(resolve, slot - now));
2292
+ }
2293
+ async function detectSiteProtection(page) {
2294
+ const cookies = await page.context().cookies(page.url()).catch(() => []);
2295
+ if (cookies.some((c) => /^(incap_ses_|visid_incap_|nlbi_)/i.test(c.name))) return "imperva";
2296
+ if (cookies.some((c) => /^(?:_abck|bm_sz|ak_bmsc|bm_sv)$/.test(c.name))) return "akamai";
2297
+ const html = await page.content().catch(() => "");
2298
+ if (/\/[_]Incapsula_Resource|incapsula incident id|powered by imperva/i.test(html)) return "imperva";
2299
+ if (/errors\.edgesuite\.net|akamai bot manager/i.test(html)) return "akamai";
2300
+ return null;
2301
+ }
2302
+ function protectionHooks(headless, respectful = false) {
2303
+ return {
2304
+ before: async (page, step) => {
2305
+ const protection = await detectSiteProtection(page);
2306
+ if (protection) {
2307
+ respectful = true;
2308
+ if (headless) throw new HeadedRequiredError(page.url());
2309
+ }
2310
+ if (respectful) await paceProtectedDomain(new URL(step.url || page.url()).hostname);
2311
+ },
2312
+ after: async (page) => {
2313
+ const protection = await detectSiteProtection(page);
2314
+ if (!protection) return;
2315
+ respectful = true;
2316
+ if (headless) throw new HeadedRequiredError(page.url());
2317
+ }
2318
+ };
2319
+ }
2320
+ var PROTECTED_INTERVAL_MS, nextRequest, HeadedRequiredError;
2321
+ var init_site_protection = __esm({
2322
+ "src/lib/site-protection.ts"() {
2323
+ "use strict";
2324
+ PROTECTED_INTERVAL_MS = 3e3;
2325
+ nextRequest = /* @__PURE__ */ new Map();
2326
+ HeadedRequiredError = class extends Error {
2327
+ constructor(url) {
2328
+ super("This site requires a headed browser.");
2329
+ this.url = url;
2330
+ }
2331
+ url;
2332
+ stepIndex = 0;
2333
+ results = [];
2334
+ obstacles = [];
2335
+ };
2336
+ }
2337
+ });
2338
+
2276
2339
  // src/lib/actions/types.ts
2277
2340
  function failedStepResult(step, error, durationMs, stepIndex = -1) {
2278
2341
  return { stepIndex, step, ok: false, error, durationMs };
@@ -5561,6 +5624,7 @@ var DEFAULT_STALE_TIMEOUT_MS2, PipelineRunner;
5561
5624
  var init_pipeline = __esm({
5562
5625
  "src/lib/pipeline.ts"() {
5563
5626
  "use strict";
5627
+ init_site_protection();
5564
5628
  init_registry2();
5565
5629
  init_types();
5566
5630
  init_stale_monitor();
@@ -5574,10 +5638,12 @@ var init_pipeline = __esm({
5574
5638
  init_resolve_selector();
5575
5639
  DEFAULT_STALE_TIMEOUT_MS2 = 2e4;
5576
5640
  PipelineRunner = class {
5577
- constructor(ctx) {
5641
+ constructor(ctx, hooks) {
5578
5642
  this.ctx = ctx;
5643
+ this.hooks = hooks;
5579
5644
  }
5580
5645
  ctx;
5646
+ hooks;
5581
5647
  async run(initialPage, pipeline) {
5582
5648
  const tracker = new TabTracker(initialPage.context(), initialPage);
5583
5649
  try {
@@ -5623,12 +5689,19 @@ var init_pipeline = __esm({
5623
5689
  const monitor = new StaleStateMonitor(page, staleTimeoutMs);
5624
5690
  let stepResult;
5625
5691
  try {
5692
+ await this.hooks?.before(page, step);
5626
5693
  stepResult = await monitor.withMonitoring(async () => {
5627
5694
  const r = await executeAction(page, step, this.ctx, monitor);
5695
+ if (step.type === "navigate") await this.hooks?.after(page, step.url);
5628
5696
  r.stepIndex = i;
5629
5697
  return r;
5630
5698
  });
5631
5699
  } catch (err) {
5700
+ if (err instanceof HeadedRequiredError) {
5701
+ Object.assign(err, { stepIndex: i, results, obstacles });
5702
+ await finishCapture();
5703
+ throw err;
5704
+ }
5632
5705
  const asError = err instanceof Error ? err : new Error(String(err));
5633
5706
  const errorType = classifyError(asError, step);
5634
5707
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
@@ -10119,7 +10192,7 @@ var init_cdp_relay = __esm({
10119
10192
  return {};
10120
10193
  case "Browser.close":
10121
10194
  return {};
10122
- // patchright disconnect — don't kill the user's Chrome
10195
+ // Disconnect without closing the user's Chrome.
10123
10196
  case "Target.setDiscoverTargets":
10124
10197
  return {};
10125
10198
  case "Target.getTargets":
@@ -10226,6 +10299,7 @@ var import_playwright_core, log18, PipelineExecutor;
10226
10299
  var init_pipeline_executor = __esm({
10227
10300
  "src/lib/execution/pipeline-executor.ts"() {
10228
10301
  "use strict";
10302
+ init_site_protection();
10229
10303
  import_playwright_core = require("playwright-core");
10230
10304
  init_daemon();
10231
10305
  init_pipeline();
@@ -10245,8 +10319,8 @@ var init_pipeline_executor = __esm({
10245
10319
  this.deps = deps;
10246
10320
  }
10247
10321
  deps;
10248
- /** Runtime elicitation hook, set per-call via execute(). Consumed once by
10249
- * executeLocal and immediately cleared. */
10322
+ /** Shared runtime hook read by local and extension execution;
10323
+ * execute() clears it when the call finishes. */
10250
10324
  pendingElicitOtp;
10251
10325
  /** One pipeline per real tab at a time: chrome.debugger and the CDP relay
10252
10326
  * can't share a tab, so concurrent executes on the same tab queue up here. */
@@ -10292,6 +10366,7 @@ var init_pipeline_executor = __esm({
10292
10366
  mode = availableModes[0] || "headless";
10293
10367
  }
10294
10368
  let result = await this.executeWithMode(userId, token, pipeline, mode, instanceId);
10369
+ mode = result.modeUsed || mode;
10295
10370
  if (!result.ok && autoEscalate && domain && result.error?.errorType === "bot-blocked") {
10296
10371
  const failedMode = mode;
10297
10372
  if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
@@ -10328,11 +10403,11 @@ var init_pipeline_executor = __esm({
10328
10403
  }
10329
10404
  return result;
10330
10405
  }
10331
- async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
10406
+ async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
10332
10407
  if (mode === "docker-headful") {
10333
- return this.executeDocker(userId, token, pipeline);
10408
+ return this.executeDocker(userId, token, pipeline, respectful);
10334
10409
  }
10335
- return this.executeLocal(userId, token, pipeline, mode, instanceId);
10410
+ return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
10336
10411
  }
10337
10412
  /** Execute against the user's real Chrome tab via the extension CDP relay,
10338
10413
  * using the SAME PipelineRunner as every other mode. No session inject/extract
@@ -10425,7 +10500,7 @@ var init_pipeline_executor = __esm({
10425
10500
  }
10426
10501
  }
10427
10502
  /** Execute via Docker session-manager. */
10428
- async executeDocker(userId, token, pipeline) {
10503
+ async executeDocker(userId, token, pipeline, respectful = false) {
10429
10504
  let session = getSession(userId);
10430
10505
  if (!session) {
10431
10506
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
@@ -10434,7 +10509,7 @@ var init_pipeline_executor = __esm({
10434
10509
  }
10435
10510
  resetTimeout(userId);
10436
10511
  const ctx = this.deps.refStore.makeContext(userId, token);
10437
- const runner = new PipelineRunner(ctx);
10512
+ const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
10438
10513
  const result = await runner.run(session.page, pipeline);
10439
10514
  if (result.ok) {
10440
10515
  const blockResult = await detectBlock(session.page);
@@ -10461,7 +10536,7 @@ var init_pipeline_executor = __esm({
10461
10536
  return result;
10462
10537
  }
10463
10538
  /** Execute via local Chrome daemon (headless + binary-headful). */
10464
- async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
10539
+ async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
10465
10540
  const startTime = Date.now();
10466
10541
  let acquired = false;
10467
10542
  try {
@@ -10483,8 +10558,38 @@ var init_pipeline_executor = __esm({
10483
10558
  const ctx = this.deps.refStore.makeContext(userId, token);
10484
10559
  if (sessionData) ctx.sessionData = sessionData;
10485
10560
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
10486
- const runner = new PipelineRunner(ctx);
10487
- const result = await runner.run(page, pipeline);
10561
+ const runner = new PipelineRunner(ctx, protectionHooks(mode === "headless", respectful));
10562
+ let result;
10563
+ try {
10564
+ result = await runner.run(page, pipeline);
10565
+ } catch (err) {
10566
+ if (!(err instanceof HeadedRequiredError)) throw err;
10567
+ this.deps.daemon.release(mode, instanceId);
10568
+ acquired = false;
10569
+ await this.deps.daemon.stopMode(mode, instanceId);
10570
+ const available = this.deps.availableModes();
10571
+ const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
10572
+ if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
10573
+ await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
10574
+ const index = err.stepIndex;
10575
+ const remaining = pipeline.steps.slice(index);
10576
+ const insertedNavigation = remaining[0]?.type !== "navigate";
10577
+ if (insertedNavigation) remaining.unshift({ type: "navigate", url: err.url });
10578
+ const resumed = await this.executeWithMode(userId, token, { ...pipeline, steps: remaining }, headed, instanceId, true);
10579
+ const offset = index - Number(insertedNavigation);
10580
+ resumed.results = [...err.results, ...resumed.results.filter((r) => !insertedNavigation || r.stepIndex > 0).map((r) => ({ ...r, stepIndex: r.stepIndex + offset }))];
10581
+ resumed.completedSteps = index + Math.max(0, resumed.completedSteps - Number(insertedNavigation));
10582
+ resumed.totalSteps = pipeline.steps.length;
10583
+ resumed.obstacles = [...err.obstacles, ...resumed.obstacles];
10584
+ if (resumed.error) {
10585
+ resumed.error.failedAtStep = Math.max(index, resumed.error.failedAtStep + offset);
10586
+ resumed.error.failedStep = pipeline.steps[resumed.error.failedAtStep];
10587
+ }
10588
+ resumed.durationMs = Date.now() - startTime;
10589
+ resumed.modeEscalated = true;
10590
+ resumed.modeUsed = headed;
10591
+ return resumed;
10592
+ }
10488
10593
  if (result.ok) {
10489
10594
  const blockResult = await detectBlock(page);
10490
10595
  if (blockResult.blocked) {
@@ -10557,6 +10662,8 @@ var FetchService;
10557
10662
  var init_fetch_service = __esm({
10558
10663
  "src/lib/execution/fetch-service.ts"() {
10559
10664
  "use strict";
10665
+ init_site_protection();
10666
+ init_launcher();
10560
10667
  init_launcher();
10561
10668
  init_stealth();
10562
10669
  init_humanize();
@@ -10587,12 +10694,30 @@ var init_fetch_service = __esm({
10587
10694
  const { browser, name: browserName } = await getBrowserWithFallback(preferredBrowser);
10588
10695
  context = await browser.newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
10589
10696
  if (sessionData) await injectCookies(context, sessionData);
10590
- const page = await context.newPage();
10697
+ let page = await context.newPage();
10591
10698
  await applyStealthToPage(page);
10592
10699
  await page.goto(url, { waitUntil: waitUntil || "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
10700
+ let protection = false;
10701
+ const pivotIfDetected = async () => {
10702
+ if (protection || !await detectSiteProtection(page)) return;
10703
+ protection = true;
10704
+ const targetUrl = page.url();
10705
+ const cookies = await context.cookies();
10706
+ await context.close();
10707
+ context = await (await getHeadedBrowser()).newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
10708
+ await context.addCookies(cookies);
10709
+ page = await context.newPage();
10710
+ await applyStealthToPage(page);
10711
+ await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
10712
+ await paceProtectedDomain(new URL(targetUrl).hostname);
10713
+ await page.goto(targetUrl, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
10714
+ };
10715
+ await pivotIfDetected();
10593
10716
  if (sessionData) await injectStorage(page, sessionData);
10594
10717
  if (waitForSelector) await page.waitForSelector(waitForSelector, { timeout: TIMEOUTS.SELECTOR_WAIT });
10595
10718
  for (const action of actions) {
10719
+ await pivotIfDetected();
10720
+ if (protection) await paceProtectedDomain(new URL(page.url()).hostname);
10596
10721
  switch (action.type) {
10597
10722
  case "click":
10598
10723
  await page.click(action.selector);
@@ -10623,6 +10748,7 @@ var init_fetch_service = __esm({
10623
10748
  break;
10624
10749
  }
10625
10750
  }
10751
+ await pivotIfDetected();
10626
10752
  const finalUrl = page.url();
10627
10753
  const html = returnHtml ? await page.content() : void 0;
10628
10754
  const result = extract2 ? await page.evaluate(extract2) : void 0;
@@ -551,6 +551,11 @@ function findChromeExecutable() {
551
551
  return void 0;
552
552
  }
553
553
  var cachedBrowser = null;
554
+ var headedBrowser = null;
555
+ async function getHeadedBrowser() {
556
+ if (!headedBrowser?.isConnected()) headedBrowser = await import_patchright.chromium.launch({ headless: false, args: STEALTH_ARGS });
557
+ return headedBrowser;
558
+ }
554
559
  async function getBrowser(_name = "chromium") {
555
560
  if (cachedBrowser) {
556
561
  if (cachedBrowser.isConnected()) return cachedBrowser;
@@ -568,6 +573,11 @@ async function getBrowser(_name = "chromium") {
568
573
  return cachedBrowser;
569
574
  }
570
575
  async function closeBrowser() {
576
+ if (headedBrowser) {
577
+ await headedBrowser.close().catch(() => {
578
+ });
579
+ headedBrowser = null;
580
+ }
571
581
  if (!cachedBrowser) return;
572
582
  try {
573
583
  await cachedBrowser.close();
@@ -1880,6 +1890,53 @@ var RefStore = class {
1880
1890
  }
1881
1891
  };
1882
1892
 
1893
+ // src/lib/site-protection.ts
1894
+ var PROTECTED_INTERVAL_MS = 3e3;
1895
+ var nextRequest = /* @__PURE__ */ new Map();
1896
+ async function paceProtectedDomain(domain) {
1897
+ const now = Date.now();
1898
+ const slot = Math.max(now, nextRequest.get(domain) || 0);
1899
+ nextRequest.set(domain, slot + PROTECTED_INTERVAL_MS);
1900
+ if (slot > now) await new Promise((resolve) => setTimeout(resolve, slot - now));
1901
+ }
1902
+ async function detectSiteProtection(page) {
1903
+ const cookies = await page.context().cookies(page.url()).catch(() => []);
1904
+ if (cookies.some((c) => /^(incap_ses_|visid_incap_|nlbi_)/i.test(c.name))) return "imperva";
1905
+ if (cookies.some((c) => /^(?:_abck|bm_sz|ak_bmsc|bm_sv)$/.test(c.name))) return "akamai";
1906
+ const html = await page.content().catch(() => "");
1907
+ if (/\/[_]Incapsula_Resource|incapsula incident id|powered by imperva/i.test(html)) return "imperva";
1908
+ if (/errors\.edgesuite\.net|akamai bot manager/i.test(html)) return "akamai";
1909
+ return null;
1910
+ }
1911
+ var HeadedRequiredError = class extends Error {
1912
+ constructor(url) {
1913
+ super("This site requires a headed browser.");
1914
+ this.url = url;
1915
+ }
1916
+ url;
1917
+ stepIndex = 0;
1918
+ results = [];
1919
+ obstacles = [];
1920
+ };
1921
+ function protectionHooks(headless, respectful = false) {
1922
+ return {
1923
+ before: async (page, step) => {
1924
+ const protection = await detectSiteProtection(page);
1925
+ if (protection) {
1926
+ respectful = true;
1927
+ if (headless) throw new HeadedRequiredError(page.url());
1928
+ }
1929
+ if (respectful) await paceProtectedDomain(new URL(step.url || page.url()).hostname);
1930
+ },
1931
+ after: async (page) => {
1932
+ const protection = await detectSiteProtection(page);
1933
+ if (!protection) return;
1934
+ respectful = true;
1935
+ if (headless) throw new HeadedRequiredError(page.url());
1936
+ }
1937
+ };
1938
+ }
1939
+
1883
1940
  // src/lib/execution/pipeline-executor.ts
1884
1941
  var import_playwright_core = require("playwright-core");
1885
1942
 
@@ -4912,10 +4969,12 @@ function getSuggestion(errorType, step) {
4912
4969
  }
4913
4970
  }
4914
4971
  var PipelineRunner = class {
4915
- constructor(ctx) {
4972
+ constructor(ctx, hooks) {
4916
4973
  this.ctx = ctx;
4974
+ this.hooks = hooks;
4917
4975
  }
4918
4976
  ctx;
4977
+ hooks;
4919
4978
  async run(initialPage, pipeline) {
4920
4979
  const tracker = new TabTracker(initialPage.context(), initialPage);
4921
4980
  try {
@@ -4961,12 +5020,19 @@ var PipelineRunner = class {
4961
5020
  const monitor = new StaleStateMonitor(page, staleTimeoutMs);
4962
5021
  let stepResult;
4963
5022
  try {
5023
+ await this.hooks?.before(page, step);
4964
5024
  stepResult = await monitor.withMonitoring(async () => {
4965
5025
  const r = await executeAction(page, step, this.ctx, monitor);
5026
+ if (step.type === "navigate") await this.hooks?.after(page, step.url);
4966
5027
  r.stepIndex = i;
4967
5028
  return r;
4968
5029
  });
4969
5030
  } catch (err) {
5031
+ if (err instanceof HeadedRequiredError) {
5032
+ Object.assign(err, { stepIndex: i, results, obstacles });
5033
+ await finishCapture();
5034
+ throw err;
5035
+ }
4970
5036
  const asError = err instanceof Error ? err : new Error(String(err));
4971
5037
  const errorType = classifyError(asError, step);
4972
5038
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
@@ -5743,7 +5809,7 @@ var CdpRelay = class {
5743
5809
  return {};
5744
5810
  case "Browser.close":
5745
5811
  return {};
5746
- // patchright disconnect — don't kill the user's Chrome
5812
+ // Disconnect without closing the user's Chrome.
5747
5813
  case "Target.setDiscoverTargets":
5748
5814
  return {};
5749
5815
  case "Target.getTargets":
@@ -5850,8 +5916,8 @@ var PipelineExecutor = class {
5850
5916
  this.deps = deps;
5851
5917
  }
5852
5918
  deps;
5853
- /** Runtime elicitation hook, set per-call via execute(). Consumed once by
5854
- * executeLocal and immediately cleared. */
5919
+ /** Shared runtime hook read by local and extension execution;
5920
+ * execute() clears it when the call finishes. */
5855
5921
  pendingElicitOtp;
5856
5922
  /** One pipeline per real tab at a time: chrome.debugger and the CDP relay
5857
5923
  * can't share a tab, so concurrent executes on the same tab queue up here. */
@@ -5897,6 +5963,7 @@ var PipelineExecutor = class {
5897
5963
  mode = availableModes[0] || "headless";
5898
5964
  }
5899
5965
  let result = await this.executeWithMode(userId, token, pipeline, mode, instanceId);
5966
+ mode = result.modeUsed || mode;
5900
5967
  if (!result.ok && autoEscalate && domain && result.error?.errorType === "bot-blocked") {
5901
5968
  const failedMode = mode;
5902
5969
  if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
@@ -5933,11 +6000,11 @@ var PipelineExecutor = class {
5933
6000
  }
5934
6001
  return result;
5935
6002
  }
5936
- async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
6003
+ async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
5937
6004
  if (mode === "docker-headful") {
5938
- return this.executeDocker(userId, token, pipeline);
6005
+ return this.executeDocker(userId, token, pipeline, respectful);
5939
6006
  }
5940
- return this.executeLocal(userId, token, pipeline, mode, instanceId);
6007
+ return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
5941
6008
  }
5942
6009
  /** Execute against the user's real Chrome tab via the extension CDP relay,
5943
6010
  * using the SAME PipelineRunner as every other mode. No session inject/extract
@@ -6030,7 +6097,7 @@ var PipelineExecutor = class {
6030
6097
  }
6031
6098
  }
6032
6099
  /** Execute via Docker session-manager. */
6033
- async executeDocker(userId, token, pipeline) {
6100
+ async executeDocker(userId, token, pipeline, respectful = false) {
6034
6101
  let session = getSession(userId);
6035
6102
  if (!session) {
6036
6103
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
@@ -6039,7 +6106,7 @@ var PipelineExecutor = class {
6039
6106
  }
6040
6107
  resetTimeout(userId);
6041
6108
  const ctx = this.deps.refStore.makeContext(userId, token);
6042
- const runner = new PipelineRunner(ctx);
6109
+ const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
6043
6110
  const result = await runner.run(session.page, pipeline);
6044
6111
  if (result.ok) {
6045
6112
  const blockResult = await detectBlock(session.page);
@@ -6066,7 +6133,7 @@ var PipelineExecutor = class {
6066
6133
  return result;
6067
6134
  }
6068
6135
  /** Execute via local Chrome daemon (headless + binary-headful). */
6069
- async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
6136
+ async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
6070
6137
  const startTime = Date.now();
6071
6138
  let acquired = false;
6072
6139
  try {
@@ -6088,8 +6155,38 @@ var PipelineExecutor = class {
6088
6155
  const ctx = this.deps.refStore.makeContext(userId, token);
6089
6156
  if (sessionData) ctx.sessionData = sessionData;
6090
6157
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
6091
- const runner = new PipelineRunner(ctx);
6092
- const result = await runner.run(page, pipeline);
6158
+ const runner = new PipelineRunner(ctx, protectionHooks(mode === "headless", respectful));
6159
+ let result;
6160
+ try {
6161
+ result = await runner.run(page, pipeline);
6162
+ } catch (err) {
6163
+ if (!(err instanceof HeadedRequiredError)) throw err;
6164
+ this.deps.daemon.release(mode, instanceId);
6165
+ acquired = false;
6166
+ await this.deps.daemon.stopMode(mode, instanceId);
6167
+ const available = this.deps.availableModes();
6168
+ const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
6169
+ if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
6170
+ await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
6171
+ const index = err.stepIndex;
6172
+ const remaining = pipeline.steps.slice(index);
6173
+ const insertedNavigation = remaining[0]?.type !== "navigate";
6174
+ if (insertedNavigation) remaining.unshift({ type: "navigate", url: err.url });
6175
+ const resumed = await this.executeWithMode(userId, token, { ...pipeline, steps: remaining }, headed, instanceId, true);
6176
+ const offset = index - Number(insertedNavigation);
6177
+ resumed.results = [...err.results, ...resumed.results.filter((r) => !insertedNavigation || r.stepIndex > 0).map((r) => ({ ...r, stepIndex: r.stepIndex + offset }))];
6178
+ resumed.completedSteps = index + Math.max(0, resumed.completedSteps - Number(insertedNavigation));
6179
+ resumed.totalSteps = pipeline.steps.length;
6180
+ resumed.obstacles = [...err.obstacles, ...resumed.obstacles];
6181
+ if (resumed.error) {
6182
+ resumed.error.failedAtStep = Math.max(index, resumed.error.failedAtStep + offset);
6183
+ resumed.error.failedStep = pipeline.steps[resumed.error.failedAtStep];
6184
+ }
6185
+ resumed.durationMs = Date.now() - startTime;
6186
+ resumed.modeEscalated = true;
6187
+ resumed.modeUsed = headed;
6188
+ return resumed;
6189
+ }
6093
6190
  if (result.ok) {
6094
6191
  const blockResult = await detectBlock(page);
6095
6192
  if (blockResult.blocked) {
@@ -6180,12 +6277,30 @@ var FetchService = class {
6180
6277
  const { browser, name: browserName } = await getBrowserWithFallback(preferredBrowser);
6181
6278
  context = await browser.newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
6182
6279
  if (sessionData) await injectCookies(context, sessionData);
6183
- const page = await context.newPage();
6280
+ let page = await context.newPage();
6184
6281
  await applyStealthToPage(page);
6185
6282
  await page.goto(url, { waitUntil: waitUntil || "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
6283
+ let protection = false;
6284
+ const pivotIfDetected = async () => {
6285
+ if (protection || !await detectSiteProtection(page)) return;
6286
+ protection = true;
6287
+ const targetUrl = page.url();
6288
+ const cookies = await context.cookies();
6289
+ await context.close();
6290
+ context = await (await getHeadedBrowser()).newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
6291
+ await context.addCookies(cookies);
6292
+ page = await context.newPage();
6293
+ await applyStealthToPage(page);
6294
+ await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
6295
+ await paceProtectedDomain(new URL(targetUrl).hostname);
6296
+ await page.goto(targetUrl, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
6297
+ };
6298
+ await pivotIfDetected();
6186
6299
  if (sessionData) await injectStorage(page, sessionData);
6187
6300
  if (waitForSelector) await page.waitForSelector(waitForSelector, { timeout: TIMEOUTS.SELECTOR_WAIT });
6188
6301
  for (const action of actions) {
6302
+ await pivotIfDetected();
6303
+ if (protection) await paceProtectedDomain(new URL(page.url()).hostname);
6189
6304
  switch (action.type) {
6190
6305
  case "click":
6191
6306
  await page.click(action.selector);
@@ -6216,6 +6331,7 @@ var FetchService = class {
6216
6331
  break;
6217
6332
  }
6218
6333
  }
6334
+ await pivotIfDetected();
6219
6335
  const finalUrl = page.url();
6220
6336
  const html = returnHtml ? await page.content() : void 0;
6221
6337
  const result = extract2 ? await page.evaluate(extract2) : void 0;
@@ -2087,6 +2087,8 @@ CRITICAL RULES:
2087
2087
  7. If the browser crashes, call "session restart" and retry. Don't panic.
2088
2088
  8. ALWAYS call "session" action=stop when you are done with browser work. It saves session state and frees the browser. Idle browsers are auto-reclaimed, but don't rely on that.
2089
2089
 
2090
+ If headless detects Imperva or Akamai, immediately stop headless and pivot to headed in the same run; do not wait for a block, retry headless, or defer the switch to a future request. Use headed from the outset when the provider is already known in the current task. On detection, switch to headed and use respectful pacing (at least 3 seconds between top-level actions; no parallel bursts or repeated blocked retries). Do not announce the provider or explain the switch unless the user asks why. Only when asked, explain that the site is covered by Imperva or Akamai and iframer adjusts its browser mode and pacing to respect it.
2091
+
2090
2092
  BROWSER MODES: Don't specify options.mode \u2014 iframer auto-selects and auto-escalates (headless \u2192 binary-headful). Only set a mode if the user explicitly asks.
2091
2093
 
2092
2094
  CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. In docker-headful mode, use the "solve-captcha" step.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "iframer",
4
- "version": "0.10.5",
4
+ "version": "0.10.6",
5
5
  "description": "Let iframer drive a Chrome tab you choose, on your real logged-in session.",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp2wbce98k+xDnEgtuzLyNXYBLEDY0nqChuvolb94B3VV1HsHFUulUyNe92eN6MjQuadDohQ2j5mj7zWs4yQoixgTQPe7Gm/+GHWdgxwM2s9t5opnLHwF90LPiJm6q1NGWLM4aK6JT+2I6FnUGmj2bzB4WIlf55C3aMEBoXTqIzG2U8s2cxkXGebWWqbPlMK/Phsmsvo4POWnidbGHPhBOq0iyPTu7RdSDlNdJdJ+dtdNXkMmltKUS881MQI9T6GcCCKhIKcZrJGGnk0xphlWyl+ublTAO0tbQSVB/K7D3JmCxMv690TPtYUPPhGi+C6VCv3IeutLmNo60mvux3wbGQIDAQAB",
7
7
  "permissions": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iframer-toolkit",
3
- "version": "0.10.5",
3
+ "version": "0.10.6",
4
4
  "description": "Browser access toolkit for AI agents — CLI and MCP server for headless/headful browsing, login flows, and pipeline execution.",
5
5
  "keywords": [
6
6
  "iframer",
package/skills/iframer.md CHANGED
@@ -20,7 +20,8 @@ You are an expert at using the iframer browser automation toolkit. The user want
20
20
  2. **NEVER guess CSS selectors.** Use `snapshot` to see what's on the page, then use `@e` refs from the snapshot for click/fill/etc.
21
21
  3. **NEVER re-store credentials when login fails.** If credentials exist in `credentials list`, they are valid. Login failures are browser-mode or bot-detection problems, not credential problems. Retry with a stronger mode.
22
22
  4. **NEVER pass explicit selectors to the login step** unless the auto-detect failed AND you took a snapshot to find the right ones. The login step auto-detects email, password, submit, and OTP fields.
23
- 5. **Let iframer handle mode escalation.** Don't hardcode modes. If you must pick one, prefer no mode (auto-select) or `binary-headful` for sites known to block headless.
23
+ 5. If headless detects Imperva or Akamai, immediately stop headless and pivot to headed in the same run; do not wait for a block, retry headless, or defer the switch to a future request. Use headed from the outset when the provider is already known in the current task. On detection, switch to headed and use respectful pacing (at least 3 seconds between top-level actions; no parallel bursts or repeated blocked retries). Do not announce the provider or explain the switch unless the user asks why. Only when asked, explain that the site is covered by Imperva or Akamai and iframer adjusts its browser mode and pacing to respect it.
24
+ 6. **Let iframer handle mode escalation.** Don't hardcode modes. If you must pick one, prefer no mode (auto-select) or `binary-headful` for sites known to block headless.
24
25
 
25
26
  ## How to build pipelines
26
27
 
@@ -199,7 +200,7 @@ session: { action: "restart" }
199
200
  ```
200
201
  This kills ALL running browser instances (local + Docker) and resets state. Credentials and knowledge cache are NOT affected. The next `execute` call launches a fresh browser automatically.
201
202
 
202
- **The execute tool also auto-recovers:** if it detects a crash (connection closed, timeout, ECONNREFUSED), it automatically restarts the browser and retries once before reporting failure. If auto-recovery also fails, the agent should call `session restart` manually and try again.
203
+ **The execute tool also auto-recovers:** if it detects a *connection-level* crash (connection closed, ECONNREFUSED/ECONNRESET/EPIPE, socket hang up), it restarts the browser server and retries once before reporting failure. Timeouts are deliberately NOT auto-retried — a slow-but-healthy pipeline that hit the client timeout must not be re-run, or non-idempotent steps (form submits, purchases) would double-execute. If auto-recovery fails, or a step timed out, call `session restart` manually and continue from where you left off (don't blindly re-run the whole pipeline).
203
204
 
204
205
  ### Manage credentials
205
206
 
@@ -274,7 +275,7 @@ Many modern sites don't have email+password on one page. The login step handles
274
275
 
275
276
  ## Recovering from MCP disconnect
276
277
 
277
- The iframer MCP server runs Chrome in-process. If Chrome crashes hard enough (native segfault, out of memory), the entire MCP server process dies and Claude Code shows "MCP disconnected."
278
+ The iframer MCP server does NOT run Chrome — Chrome lives in a separate, shared local background server (the daemon on 127.0.0.1). The MCP process is just a thin HTTP/stdio client. So a browser crash can't take the MCP down with it: if Chrome dies, the daemon's own recovery handles it and the MCP keeps running. A real "MCP disconnected" in Claude Code means the MCP client process itself dropped (session killed, pipe closed), not a browser crash — the daemon and its warm browsers survive that too.
278
279
 
279
280
  **Recovery is simple — Claude Code auto-reconnects MCP servers.** Just wait 2-3 seconds and retry the tool call. Don't:
280
281
  - Try to run `claude mcp` commands