pi-browser-harness 0.8.3 → 0.9.0
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/CHANGELOG.md +28 -0
- package/README.md +28 -7
- package/package.json +1 -1
- package/skills/deep-research/SKILL.md +66 -0
- package/skills/pi-browser-harness/SKILL.md +8 -0
- package/src/cdp/discovery.ts +171 -9
- package/src/cdp/ownership.ts +19 -1
- package/src/cdp/session.ts +55 -0
- package/src/cdp/window-binding.ts +15 -0
- package/src/client.ts +52 -9
- package/src/daemon/protocol.ts +12 -2
- package/src/daemon/spawn.ts +46 -13
- package/src/deep-research.ts +41 -0
- package/src/domains/forms.ts +355 -0
- package/src/domains/isolated-tab.ts +121 -0
- package/src/domains/navigate.ts +25 -10
- package/src/domains/readpage/capture.ts +46 -0
- package/src/domains/readpage/read-page.ts +119 -0
- package/src/domains/readpage/readability.ts +89 -0
- package/src/domains/render.ts +64 -0
- package/src/domains/search/extract.ts +44 -0
- package/src/domains/search/google-serp.ts +114 -0
- package/src/domains/search/web-search.ts +116 -0
- package/src/index.ts +17 -7
- package/src/prompt.ts +92 -116
- package/src/registry.ts +6 -0
- package/src/setup.ts +26 -10
- package/src/state.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-browser-harness will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.9.0 — 2026-07-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`browser_web_search`** — new tool. Query → ranked links by scraping a real Google SERP in the user's own Chrome, so no API key or search subscription is involved. Runs in an isolated tab whose lifecycle mirrors `browser_open_urls` (never touches the user's current tab). The SERP parser is pure and fixture-tested (9 scenarios), extracts results semantically (`a h3`) and unwraps redirect URLs. CAPTCHA walls and empty result sets surface as `invalid_state` with `details.reason` rather than an empty list.
|
|
10
|
+
- **`browser_read_page`** — new tool. URL (or an owned `targetId`) → clean main-article text with nav/ad/footer boilerplate stripped, via a dependency-free readability heuristic. The DOM walk runs as an in-page capture expression; scoring and selection are a pure, fixture-tested function (link-density plus boilerplate-ancestor filtering, with a body-text fallback for pages that have no article structure). Registered unserialized, so concurrent reads are safe.
|
|
11
|
+
- **`deep-research` skill and `/deep-research` command.** Fans out to isolated `web-search-researcher` subagents, runs a coverage-driven loop with a hard iteration ceiling, and synthesizes a cited Markdown report. The researcher agent now uses `browser_web_search` + `browser_read_page`; it previously referenced `web_search`/`web_fetch` tools that do not exist in this harness.
|
|
12
|
+
- **Forms domain with a universal field setter.** `browser_fill`, `browser_fill_form`, `browser_select_option`, and `browser_set_checked` now live in `src/domains/forms.ts` behind one setter that auto-detects the element type and fires framework-compatible input events (the React native-setter trick), so controlled inputs register the change instead of silently reverting.
|
|
13
|
+
- **Durable window binding.** Every new-window code path now captures the real Chrome `windowId` and binds it as the session's window identity, so per-session tab ownership survives navigation and tab churn instead of being inferred fresh each time.
|
|
14
|
+
- **Brave support and additional Chrome channels.** `checkChromeRunning()` recognizes Brave (macOS `brave browser`, Linux `brave`/`brave-browser`, Windows `brave.exe`) alongside Chrome/Chromium/Edge, and now excludes `gpu`/`updater` sub-processes that linger after a browser quits. Profile discovery covers Brave Stable/Beta/Nightly/Dev and Chrome Beta/Dev/Canary/Chromium across macOS, Linux, and Windows.
|
|
15
|
+
- **Expand/collapse (Ctrl+O) rendering for `browser_web_search` and `browser_read_page`,** via a shared `renderExpandableText` helper (`src/domains/render.ts`) mirroring `browser_execute_js`. Compact preview by default, full body on expand; the complete text still reaches the model regardless of render state. The `web_search` summary line surfaces engine and result count.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Daemon setup now works on Windows.** The Unix-socket daemon introduced in 0.6.0 carried several POSIX-only assumptions that broke setup entirely on Windows: `DAEMON_SOCKET_PATH` is now a named pipe (`\\.\pipe\pi-browser-daemon`) on win32, since a `/tmp` path is not a valid `net` listen/connect target there; `spawnDaemon()` resolves `tsx.cmd`/`npx.cmd` and runs them through a shell with per-token quoting (paths with spaces) and `windowsVerbatimArguments`, because npm's `.cmd` shims fail with EINVAL/ENOENT when spawned directly; `isDaemonRunning()` skips the `fs.access` pre-check and probes the pipe directly, as named pipes are not filesystem entries and `access()` always fails on them; and stale-socket `unlink()` is a no-op on Windows, where pipes self-clean. (#7)
|
|
20
|
+
- **Stale `DevToolsActivePort` files no longer break CDP discovery.** Discovery previously trusted the first readable port file — a browser that has quit leaves its file behind, so when another browser later bound the same port (e.g. 9222), discovery returned a WS URL carrying the dead browser's UUID against the live browser's server and the connection failed. `discoverWsUrl()` now collects all readable candidates, keeps the most recently written file when candidates share a port, skips ports that aren't live via a fast single-shot probe (no 30s `waitForPort` block on stale files), and asks each live browser for its canonical `webSocketDebuggerUrl` via `/json/version`, falling back to the file's WS path when that endpoint is disabled. Well-known ports (9222, plus `BU_CDP_PORTS`) are probed both when no profile file is readable — covering sandboxed harnesses hitting EPERM/EACCES and non-default install locations — and after all discovered candidates prove stale. Ports parsed from a truncated or corrupt file are validated before use, since `net.connect` throws `ERR_SOCKET_BAD_PORT` synchronously for `NaN`/out-of-range values, which previously escaped as an unhandled rejection. (#4)
|
|
21
|
+
- **Footer browser status indicator removed.** The chip set during `session_start` went stale: a successful `/browser-setup` only called `ctx.ui.notify()` and never updated `ctx.ui.setStatus("browser", …)`, so the red "Browser — run /browser-setup" nudge persisted while the browser was in fact connected. Browser control is on-demand and setup already reports its own outcome, so the persistent chip was removed rather than resynced. (#10)
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Setup guidance** now mentions `brave://inspect` / `edge://inspect` and the `--remote-debugging-port` launch flag.
|
|
26
|
+
|
|
27
|
+
## 0.8.3 — 2026-07-08
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **Stale socket detection no longer blocks daemon spawn after a crash.** `isDaemonRunning()` only checked that the Unix socket file existed; a dead daemon leaves a stale socket behind, so `ensureDaemon()` skipped spawning and `client.start()` failed with "Chrome not connected". It now runs a liveness probe — connect, register, verify the ack, disconnect — and cleans up the stale socket on failure. The bridge's `handleRequest` is also async, polling for a Chrome connection for up to 15s before rejecting.
|
|
32
|
+
|
|
5
33
|
## 0.8.1 — 2026-07-05
|
|
6
34
|
|
|
7
35
|
### Fixed
|
package/README.md
CHANGED
|
@@ -85,6 +85,13 @@ Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. **No screensh
|
|
|
85
85
|
| `browser_page_info` | URL, title, viewport, scroll position, or pending dialog. |
|
|
86
86
|
| `browser_http_get` | Direct HTTP GET outside the browser — 10-50× faster for APIs. |
|
|
87
87
|
|
|
88
|
+
### Research & reading
|
|
89
|
+
|
|
90
|
+
| Tool | Purpose |
|
|
91
|
+
|------|---------|
|
|
92
|
+
| `browser_web_search` | Search the web — scrapes a Google results page in the real Chrome (no API key), returns ranked `{title, url, snippet, rank}`. **Links only** — follow up with `browser_read_page`. Runs in its own isolated tab. |
|
|
93
|
+
| `browser_read_page` | Reader mode — a `url` (opened + closed in an isolated tab) or an owned `targetId` → clean main-article text with nav/ads/boilerplate stripped. Use over snapshot/execute_js when you want an article's content. |
|
|
94
|
+
|
|
88
95
|
### Visual (last resort)
|
|
89
96
|
|
|
90
97
|
| Tool | Purpose |
|
|
@@ -173,16 +180,29 @@ browser_network_requests({
|
|
|
173
180
|
|
|
174
181
|
### Research workflow
|
|
175
182
|
|
|
183
|
+
Search → read, with each tool running in its own isolated tab (your current tab is never disturbed):
|
|
184
|
+
|
|
176
185
|
```
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
browser_wait_for_load()
|
|
182
|
-
browser_snapshot()
|
|
183
|
-
browser_execute_js({ expression: "document.querySelector('.content').innerText" })
|
|
186
|
+
browser_web_search({ query: "chrome devtools protocol overview", limit: 5 })
|
|
187
|
+
# → ranked {title, url, snippet, rank} — links only
|
|
188
|
+
browser_read_page({ url: "<the best result's url>" })
|
|
189
|
+
# → clean main-article text, boilerplate stripped
|
|
184
190
|
```
|
|
185
191
|
|
|
192
|
+
### Deep research
|
|
193
|
+
|
|
194
|
+
For a question that needs multiple sources synthesized into a cited report, run:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
/deep-research <your question>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
This triggers the **deep-research skill**: it decomposes the question into sub-questions, fans out
|
|
201
|
+
one isolated `web-search-researcher` subagent per sub-question (each using `browser_web_search` +
|
|
202
|
+
`browser_read_page`), loops for coverage under a hard ceiling, and writes a **cited Markdown report**
|
|
203
|
+
file where every claim carries a source link. The skill also auto-triggers on research-shaped
|
|
204
|
+
requests ("research X and cite sources", "compare X and Y with sources") without the command.
|
|
205
|
+
|
|
186
206
|
### Visual verification (only when pixels matter)
|
|
187
207
|
|
|
188
208
|
```
|
|
@@ -271,6 +291,7 @@ Scripts are written to disk — auditable and re-runnable.
|
|
|
271
291
|
| `/browser-setup` | Connect pi to Chrome (run once) |
|
|
272
292
|
| `/browser-status` | Show daemon health and current page |
|
|
273
293
|
| `/browser-reload-daemon` | Restart the connection |
|
|
294
|
+
| `/deep-research <q>` | Research a question on the web and write a cited Markdown report |
|
|
274
295
|
|
|
275
296
|
---
|
|
276
297
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: deep-research
|
|
3
|
+
description: Use when the user asks you to research a topic on the web, do deep research, investigate something across multiple sources, or produce a cited report / literature review from the live web. Decomposes the question, fans out isolated web-search-researcher subagents (one per sub-question) that scrape the real browser, then synthesizes a source-cited Markdown report. Trigger phrases include "research", "deep research", "look this up and cite sources", "investigate", "compare X and Y with sources".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Deep Research
|
|
7
|
+
|
|
8
|
+
Turn a research question into a **cited Markdown report** by fanning out to isolated `web-search-researcher` subagents that use the real browser, then synthesizing their distilled findings. Every claim in the final report carries a source link.
|
|
9
|
+
|
|
10
|
+
This runs on two browser tools — `browser_web_search` (ranked SERP, links only) and `browser_read_page` (URL → clean article text) — used *inside the subagents*, not by you directly. Both tools open their own isolated tabs, so **the fan-out never disturbs the user's tabs or current page.** You do not need `/browser-setup` to have been run by you; if the browser isn't connected the subagents' first tool call will report `not_connected` — relay that and ask the user to run `/browser-setup`.
|
|
11
|
+
|
|
12
|
+
## Workflow
|
|
13
|
+
|
|
14
|
+
### 1. Decompose
|
|
15
|
+
|
|
16
|
+
Break the question into **3–6 focused sub-questions**, each independently answerable from the web. Cover the distinct facets — definitions, current state, competing options, trade-offs, recent changes — not slight rewordings of the same thing. Aim for coverage: if you synthesized perfect answers to all sub-questions, the original question should be fully answered.
|
|
17
|
+
|
|
18
|
+
State the sub-questions to the user before dispatching (a short numbered list) so the plan is visible.
|
|
19
|
+
|
|
20
|
+
### 2. Fan out
|
|
21
|
+
|
|
22
|
+
Dispatch **one `web-search-researcher` subagent per sub-question**, in parallel where the runtime allows. Each subagent runs in isolated context and returns a distilled, source-linked finding (claims + urls) — not raw pages. Give each subagent exactly one sub-question plus any constraints (recency, specific ecosystem, authoritative sources to prefer).
|
|
23
|
+
|
|
24
|
+
Do not do the searching yourself — that is the subagents' job, and their isolation is what keeps your context clean enough to synthesize well.
|
|
25
|
+
|
|
26
|
+
### 3. Assess coverage — loop with a hard ceiling
|
|
27
|
+
|
|
28
|
+
After each round of findings, self-assess: **is every sub-question answered, or did findings surface new gaps worth a follow-up search?**
|
|
29
|
+
|
|
30
|
+
- If gaps remain and you are under the ceiling, dispatch a second round of researchers targeting only the gaps (rephrased or newly discovered sub-questions).
|
|
31
|
+
- **Hard ceiling:** at most **2 rounds** and **8 researcher subagents total**. When you hit either limit, stop searching, synthesize with what you have, and list the unresolved gaps explicitly in the report. Never loop indefinitely chasing completeness.
|
|
32
|
+
|
|
33
|
+
Handle subagent-reported tool failures:
|
|
34
|
+
- A subagent reporting a `browser_web_search` `invalid_state` with `reason: "captcha"` means a bot wall. **Surface this to the user immediately** and do not spawn a tight retry loop — ask whether to continue, wait, or narrow scope.
|
|
35
|
+
- `reason: "no_results"` is already handled inside the subagent (one rephrase); if it still comes back empty, treat that sub-question as a gap.
|
|
36
|
+
|
|
37
|
+
### 4. Synthesize a cited report
|
|
38
|
+
|
|
39
|
+
Merge the distilled findings into a **Markdown report written to a file** (e.g. `research-<topic-slug>.md` in the working directory — tell the user the path). Structure:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
# {The research question}
|
|
43
|
+
|
|
44
|
+
## Summary
|
|
45
|
+
{A few paragraphs answering the question, synthesized across sources.}
|
|
46
|
+
|
|
47
|
+
## {Sub-topic / sub-question 1}
|
|
48
|
+
{Findings, in prose. Every claim carries a source link inline: … as shown in [Title](url).}
|
|
49
|
+
|
|
50
|
+
## {Sub-topic 2}
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
## Open questions / gaps
|
|
54
|
+
{Anything the ceiling left unresolved, or where sources conflicted.}
|
|
55
|
+
|
|
56
|
+
## Sources
|
|
57
|
+
{Deduplicated list of every URL cited, with titles.}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Rules for the report:
|
|
61
|
+
- **Every factual claim carries a source link** — inline `[title](url)`. No unsourced assertions.
|
|
62
|
+
- **Attribute conflicts.** Where sources disagree, present both and cite each.
|
|
63
|
+
- **Distinguish synthesis from quotation.** Don't fabricate precision the sources didn't provide.
|
|
64
|
+
- **Be honest about gaps.** The gaps section is mandatory when the ceiling cut off coverage — an incomplete-but-honest report beats a complete-looking-but-padded one.
|
|
65
|
+
|
|
66
|
+
After writing the file, give the user a short chat summary and the file path — don't paste the whole report into the conversation.
|
|
@@ -21,6 +21,12 @@ What do you need to know?
|
|
|
21
21
|
├─ Network behavior on the current page?
|
|
22
22
|
│ → browser_network_requests
|
|
23
23
|
│
|
|
24
|
+
├─ Find pages on the web about a topic?
|
|
25
|
+
│ → browser_web_search (ranked SERP — links only; follow up with browser_read_page)
|
|
26
|
+
│
|
|
27
|
+
├─ An article's main content as clean text?
|
|
28
|
+
│ → browser_read_page (reader mode — a url or an owned targetId → boilerplate stripped)
|
|
29
|
+
│
|
|
24
30
|
├─ JS errors / why did nothing happen after an action?
|
|
25
31
|
│ → browser_console (DIAGNOSTIC — only when something looks broken)
|
|
26
32
|
│
|
|
@@ -30,6 +36,8 @@ What do you need to know?
|
|
|
30
36
|
|
|
31
37
|
Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip.
|
|
32
38
|
|
|
39
|
+
`browser_web_search` and `browser_read_page` each run in their own isolated tab and never touch the user's current tab. For a multi-source question that needs a synthesized, cited report, use the **deep-research** skill (or `/deep-research <question>`): it fans out isolated `web-search-researcher` subagents over both tools and writes a source-cited Markdown report.
|
|
40
|
+
|
|
33
41
|
## Connection Setup
|
|
34
42
|
|
|
35
43
|
Browser control is **on-demand** — the daemon does NOT start automatically.
|
package/src/cdp/discovery.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { connect as netConnect } from "node:net";
|
|
5
|
+
import { request as httpRequest } from "node:http";
|
|
5
6
|
import { type Result, err, ok } from "../util/result";
|
|
6
7
|
import { type CdpError, cdpError } from "./errors";
|
|
7
8
|
|
|
@@ -11,23 +12,45 @@ const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
|
11
12
|
const profileDirs = (): ReadonlyArray<string> => {
|
|
12
13
|
const home = homedir();
|
|
13
14
|
return [
|
|
15
|
+
// macOS — Chrome (all channels), Chromium, Brave, Edge
|
|
14
16
|
join(home, "Library/Application Support/Google/Chrome"),
|
|
17
|
+
join(home, "Library/Application Support/Google/Chrome Beta"),
|
|
18
|
+
join(home, "Library/Application Support/Google/Chrome Dev"),
|
|
19
|
+
join(home, "Library/Application Support/Google/Chrome Canary"),
|
|
20
|
+
join(home, "Library/Application Support/Chromium"),
|
|
21
|
+
join(home, "Library/Application Support/BraveSoftware/Brave-Browser"),
|
|
22
|
+
join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Beta"),
|
|
23
|
+
join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Nightly"),
|
|
24
|
+
join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Dev"),
|
|
15
25
|
join(home, "Library/Application Support/Microsoft Edge"),
|
|
16
26
|
join(home, "Library/Application Support/Microsoft Edge Beta"),
|
|
17
27
|
join(home, "Library/Application Support/Microsoft Edge Dev"),
|
|
18
28
|
join(home, "Library/Application Support/Microsoft Edge Canary"),
|
|
29
|
+
// Linux — Chrome (all channels), Chromium, Brave, Edge
|
|
19
30
|
join(home, ".config/google-chrome"),
|
|
31
|
+
join(home, ".config/google-chrome-beta"),
|
|
32
|
+
join(home, ".config/google-chrome-unstable"),
|
|
20
33
|
join(home, ".config/chromium"),
|
|
21
34
|
join(home, ".config/chromium-browser"),
|
|
35
|
+
join(home, ".config/BraveSoftware/Brave-Browser"),
|
|
36
|
+
join(home, ".config/BraveSoftware/Brave-Browser-Beta"),
|
|
37
|
+
join(home, ".config/BraveSoftware/Brave-Browser-Nightly"),
|
|
22
38
|
join(home, ".config/microsoft-edge"),
|
|
23
39
|
join(home, ".config/microsoft-edge-beta"),
|
|
24
40
|
join(home, ".config/microsoft-edge-dev"),
|
|
41
|
+
// Linux flatpak
|
|
25
42
|
join(home, ".var/app/org.chromium.Chromium/config/chromium"),
|
|
26
43
|
join(home, ".var/app/com.google.Chrome/config/google-chrome"),
|
|
27
44
|
join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
|
|
28
45
|
join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
|
|
46
|
+
// Windows — Chrome, Chromium, Brave, Edge
|
|
29
47
|
join(home, "AppData/Local/Google/Chrome/User Data"),
|
|
48
|
+
join(home, "AppData/Local/Google/Chrome Beta/User Data"),
|
|
49
|
+
join(home, "AppData/Local/Google/Chrome Dev/User Data"),
|
|
50
|
+
join(home, "AppData/Local/Google/Chrome SxS/User Data"),
|
|
30
51
|
join(home, "AppData/Local/Chromium/User Data"),
|
|
52
|
+
join(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data"),
|
|
53
|
+
join(home, "AppData/Local/BraveSoftware/Brave-Browser-Beta/User Data"),
|
|
31
54
|
join(home, "AppData/Local/Microsoft/Edge/User Data"),
|
|
32
55
|
join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
|
|
33
56
|
join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
|
|
@@ -66,17 +89,94 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
|
|
|
66
89
|
));
|
|
67
90
|
};
|
|
68
91
|
|
|
92
|
+
// Fast, single-shot TCP check (no retry loop). Used to skip stale
|
|
93
|
+
// DevToolsActivePort files left behind by a browser that has since quit —
|
|
94
|
+
// unlike waitForPort, this returns immediately instead of blocking ~30s.
|
|
95
|
+
const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
|
|
96
|
+
|
|
97
|
+
// Ask the live browser for its canonical webSocketDebuggerUrl via
|
|
98
|
+
// http://127.0.0.1:<port>/json/version. This is authoritative: it avoids
|
|
99
|
+
// trusting a path from a stale DevToolsActivePort file that may belong to a
|
|
100
|
+
// different browser which has since exited (e.g. Chrome's stale file pointing
|
|
101
|
+
// at 9222 while Brave actually owns the port now). Returns null if the endpoint
|
|
102
|
+
// is unreachable, non-200, or doesn't expose a WS URL.
|
|
103
|
+
const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> =>
|
|
104
|
+
new Promise<string | null>((resolve) => {
|
|
105
|
+
const req = httpRequest(
|
|
106
|
+
{ host: "127.0.0.1", port, path: "/json/version", method: "GET", timeout: timeoutMs },
|
|
107
|
+
(res) => {
|
|
108
|
+
if (res.statusCode !== 200) {
|
|
109
|
+
res.resume();
|
|
110
|
+
resolve(null);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const chunks: Buffer[] = [];
|
|
114
|
+
res.on("data", (c: Buffer) => chunks.push(c));
|
|
115
|
+
res.on("end", () => {
|
|
116
|
+
try {
|
|
117
|
+
const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
|
|
118
|
+
const ws = json["webSocketDebuggerUrl"];
|
|
119
|
+
resolve(typeof ws === "string" ? ws : null);
|
|
120
|
+
} catch {
|
|
121
|
+
resolve(null);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
127
|
+
req.on("error", () => resolve(null));
|
|
128
|
+
req.end();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Well-known CDP ports to probe when no DevToolsActivePort file is readable
|
|
132
|
+
// (sandboxed harness, restricted filesystem permissions, or an unusual profile
|
|
133
|
+
// location not on the profileDirs list). The default Chromium remote-debugging
|
|
134
|
+
// port is 9222; users can extend via BU_CDP_PORTS="9222,9333".
|
|
135
|
+
const fallbackPorts = (): ReadonlyArray<number> => {
|
|
136
|
+
const ports = new Set<number>([9222]);
|
|
137
|
+
const fromEnv = process.env["BU_CDP_PORTS"];
|
|
138
|
+
if (fromEnv) {
|
|
139
|
+
for (const tok of fromEnv.split(",")) {
|
|
140
|
+
const n = Number(tok.trim());
|
|
141
|
+
if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return Array.from(ports);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type Candidate = { readonly port: number; readonly path: string; readonly mtimeMs: number };
|
|
148
|
+
|
|
69
149
|
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
70
150
|
const dirs = profileDirs();
|
|
151
|
+
|
|
152
|
+
// Phase 1: collect every readable DevToolsActivePort candidate up front,
|
|
153
|
+
// rather than committing to the first one found. A single browser writes one
|
|
154
|
+
// such file, but stale files from quit browsers linger, so we must
|
|
155
|
+
// disambiguate below instead of trusting discovery order.
|
|
156
|
+
const candidates: Candidate[] = [];
|
|
157
|
+
const readErrors: string[] = [];
|
|
71
158
|
for (const base of dirs) {
|
|
72
159
|
const portFile = join(base, "DevToolsActivePort");
|
|
73
160
|
let raw: string;
|
|
161
|
+
let mtimeMs = 0;
|
|
74
162
|
try {
|
|
75
163
|
raw = await readFile(portFile, "utf8");
|
|
164
|
+
try {
|
|
165
|
+
mtimeMs = (await stat(portFile)).mtimeMs;
|
|
166
|
+
} catch {
|
|
167
|
+
// mtime is only a tiebreaker; a stat failure just leaves it at 0.
|
|
168
|
+
}
|
|
76
169
|
} catch (e) {
|
|
77
|
-
// Node fs errors carry .code; see ErrnoException
|
|
170
|
+
// Node fs errors carry .code; see ErrnoException. ENOENT is normal — the
|
|
171
|
+
// dir is on our list but the user hasn't installed that browser. EPERM/
|
|
172
|
+
// EACCES is common under sandboxes; remember it and fall back to network
|
|
173
|
+
// probing rather than failing the whole discovery.
|
|
78
174
|
const code = (e as NodeJS.ErrnoException).code;
|
|
79
175
|
if (code === "ENOENT" || code === undefined) continue;
|
|
176
|
+
if (code === "EPERM" || code === "EACCES") {
|
|
177
|
+
readErrors.push(`${portFile}: ${code}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
80
180
|
return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`));
|
|
81
181
|
}
|
|
82
182
|
const lines = raw.trim().split("\n");
|
|
@@ -84,12 +184,74 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
|
84
184
|
const port = lines[0]?.trim();
|
|
85
185
|
const path = lines[1]?.trim();
|
|
86
186
|
if (!port || !path) continue;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
187
|
+
// Guard against a truncated/corrupt port file (e.g. a non-numeric first
|
|
188
|
+
// line): an out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT
|
|
189
|
+
// synchronously, which would escape discoverWsUrl instead of surfacing as
|
|
190
|
+
// a Result error. Skip such entries.
|
|
191
|
+
const portNum = Number(port);
|
|
192
|
+
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
|
|
193
|
+
candidates.push({ port: portNum, path, mtimeMs });
|
|
90
194
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
195
|
+
|
|
196
|
+
// Phase 2: no readable candidate — probe well-known ports directly. Covers
|
|
197
|
+
// sandboxed harnesses that can't read profile dirs and non-default install
|
|
198
|
+
// locations. Requires the browser to have been launched with
|
|
199
|
+
// --remote-debugging-port so /json/version is served.
|
|
200
|
+
if (candidates.length === 0) {
|
|
201
|
+
for (const port of fallbackPorts()) {
|
|
202
|
+
if (!(await isPortLive(port))) continue;
|
|
203
|
+
const live = await queryLiveWsUrl(port);
|
|
204
|
+
if (live) return ok(live);
|
|
205
|
+
}
|
|
206
|
+
const permHint = readErrors.length > 0
|
|
207
|
+
? `\n\nDevToolsActivePort reads were denied (${readErrors.join(", ")}); if you're running in a sandbox, grant read access to those files or set BU_CDP_WS / BU_CDP_PORTS to bypass file discovery.`
|
|
208
|
+
: "";
|
|
209
|
+
return err(cdpError(
|
|
210
|
+
"discovery_failed",
|
|
211
|
+
`DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging (or brave://inspect, edge://inspect) in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.${permHint}`,
|
|
212
|
+
));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Phase 3: disambiguate candidates sharing a port (one browser running, and
|
|
216
|
+
// another's file left behind on the default 9222): keep the most-recently-
|
|
217
|
+
// written file — that's the currently-running browser.
|
|
218
|
+
const byPort = new Map<number, Candidate>();
|
|
219
|
+
for (const c of candidates) {
|
|
220
|
+
const prev = byPort.get(c.port);
|
|
221
|
+
if (!prev || c.mtimeMs > prev.mtimeMs) byPort.set(c.port, c);
|
|
222
|
+
}
|
|
223
|
+
const unique = Array.from(byPort.values());
|
|
224
|
+
|
|
225
|
+
// Prefer candidates whose port is actually accepting connections; this skips
|
|
226
|
+
// stale files whose browser has quit (avoiding a ~30s waitForPort block).
|
|
227
|
+
const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
|
|
228
|
+
const liveCandidates = unique.filter((_, i) => liveChecks[i]);
|
|
229
|
+
const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
|
|
230
|
+
|
|
231
|
+
// Phase 4: for each candidate, prefer the browser's own canonical WS URL from
|
|
232
|
+
// /json/version. Some browsers disable that HTTP endpoint when remote
|
|
233
|
+
// debugging is toggled only via chrome://inspect (rather than a launch flag),
|
|
234
|
+
// so fall back to the WS path written in DevToolsActivePort.
|
|
235
|
+
let lastErr: Result<string, CdpError> | null = null;
|
|
236
|
+
for (const c of ordered) {
|
|
237
|
+
const ready = await waitForPort(c.port);
|
|
238
|
+
if (!ready.success) {
|
|
239
|
+
lastErr = err(ready.error);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const liveUrl = await queryLiveWsUrl(c.port);
|
|
243
|
+
return ok(liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Every discovered candidate was stale/unreachable. Before giving up, try the
|
|
247
|
+
// well-known fallback ports — a user with a leftover DevToolsActivePort file
|
|
248
|
+
// may still have a live browser reachable via 9222 / BU_CDP_PORTS. (Skip ports
|
|
249
|
+
// already covered by a candidate above so we don't re-probe them.)
|
|
250
|
+
for (const port of fallbackPorts()) {
|
|
251
|
+
if (byPort.has(port) || !(await isPortLive(port))) continue;
|
|
252
|
+
const liveUrl = await queryLiveWsUrl(port);
|
|
253
|
+
if (liveUrl) return ok(liveUrl);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
|
|
95
257
|
};
|
package/src/cdp/ownership.ts
CHANGED
|
@@ -20,14 +20,24 @@ export type OwnershipRegistry = {
|
|
|
20
20
|
replaceAll(ids: ReadonlyArray<string>): void;
|
|
21
21
|
setHarnessWindow(targetId: string | undefined): void;
|
|
22
22
|
harnessWindow(): string | undefined;
|
|
23
|
+
/** The real Chrome windowId of the dedicated harness window — the durable
|
|
24
|
+
* identity of "the window this session initialized", surviving seed-tab
|
|
25
|
+
* closure. Undefined until the window is created (or after teardown). */
|
|
26
|
+
setHarnessWindowId(windowId: number | undefined): void;
|
|
27
|
+
harnessWindowId(): number | undefined;
|
|
23
28
|
onChange(cb: () => void): void;
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
export const createOwnershipRegistry = (
|
|
27
|
-
initial?: {
|
|
32
|
+
initial?: {
|
|
33
|
+
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
34
|
+
readonly harnessWindowTargetId?: string;
|
|
35
|
+
readonly harnessWindowId?: number;
|
|
36
|
+
},
|
|
28
37
|
): OwnershipRegistry => {
|
|
29
38
|
const owned = new Set<string>(initial?.ownedTargetIds ?? []);
|
|
30
39
|
let harnessWindow: string | undefined = initial?.harnessWindowTargetId;
|
|
40
|
+
let harnessWindowId: number | undefined = initial?.harnessWindowId;
|
|
31
41
|
let listener: (() => void) | null = null;
|
|
32
42
|
|
|
33
43
|
const notify = (): void => {
|
|
@@ -65,6 +75,14 @@ export const createOwnershipRegistry = (
|
|
|
65
75
|
harnessWindow() {
|
|
66
76
|
return harnessWindow;
|
|
67
77
|
},
|
|
78
|
+
setHarnessWindowId(windowId) {
|
|
79
|
+
if (harnessWindowId === windowId) return;
|
|
80
|
+
harnessWindowId = windowId;
|
|
81
|
+
notify();
|
|
82
|
+
},
|
|
83
|
+
harnessWindowId() {
|
|
84
|
+
return harnessWindowId;
|
|
85
|
+
},
|
|
68
86
|
onChange(cb) {
|
|
69
87
|
listener = cb;
|
|
70
88
|
},
|
package/src/cdp/session.ts
CHANGED
|
@@ -92,6 +92,17 @@ export const createCdpSession = (
|
|
|
92
92
|
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
93
93
|
if (tab) tab.pageInfoDirty = true;
|
|
94
94
|
}
|
|
95
|
+
if (ev.method === "Target.targetCreated" && ownership) {
|
|
96
|
+
// Adopt tabs opened BY a tab we already own (window.open, target=_blank,
|
|
97
|
+
// popups). Chrome sets openerId to the opener target; if that opener is
|
|
98
|
+
// owned, the child belongs to this session's window and must be
|
|
99
|
+
// controllable + cleaned up. Targets the user opens themselves have no
|
|
100
|
+
// owned opener, so they are never adopted.
|
|
101
|
+
const info = (ev.params as { targetInfo?: { targetId?: string; type?: string; openerId?: string } } | undefined)?.targetInfo;
|
|
102
|
+
if (info?.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
|
|
103
|
+
ownership.add(info.targetId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
95
106
|
if (ev.method === "Target.targetDestroyed" && ownership) {
|
|
96
107
|
const params = ev.params as { targetId?: string } | undefined;
|
|
97
108
|
if (params?.targetId) {
|
|
@@ -179,6 +190,43 @@ export const createCdpSession = (
|
|
|
179
190
|
if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
|
|
180
191
|
const hw = ownership.harnessWindow();
|
|
181
192
|
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
193
|
+
|
|
194
|
+
if (survivors.length === 0) {
|
|
195
|
+
// No owned tab survived — the harness window is gone (user closed it,
|
|
196
|
+
// or Chrome restarted and reassigned all ids). The persisted windowId
|
|
197
|
+
// is now meaningless and could even collide with one of the user's
|
|
198
|
+
// windows, so drop it. attachFirstPage will mint a fresh window below.
|
|
199
|
+
ownership.setHarnessWindowId(undefined);
|
|
200
|
+
} else {
|
|
201
|
+
// Self-heal ownership from the window itself. We anchor on a tab we
|
|
202
|
+
// KNOW we own (a survivor) and re-adopt only its live window-mates —
|
|
203
|
+
// popups/children from last session, tabs reopened in our window.
|
|
204
|
+
// Ownership is thus DERIVED from the real window, never from the bare
|
|
205
|
+
// persisted integer (which Chrome may reassign to a user window on
|
|
206
|
+
// restart — anchoring on a survivor makes that misfire impossible).
|
|
207
|
+
const anchorId = survivors[0]!;
|
|
208
|
+
const anchorWin = await transport.request("Browser.getWindowForTarget", { targetId: anchorId }, { sessionId: null });
|
|
209
|
+
if (anchorWin.success) {
|
|
210
|
+
const anchorWindowId = (anchorWin.data as { windowId?: number }).windowId;
|
|
211
|
+
if (typeof anchorWindowId === "number") {
|
|
212
|
+
ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id
|
|
213
|
+
const windows = await Promise.all(
|
|
214
|
+
allPages.map((p) =>
|
|
215
|
+
p.targetId === anchorId
|
|
216
|
+
? Promise.resolve(anchorWin)
|
|
217
|
+
: transport.request("Browser.getWindowForTarget", { targetId: p.targetId }, { sessionId: null }),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
allPages.forEach((p, i) => {
|
|
221
|
+
const r = windows[i];
|
|
222
|
+
if (r?.success && (r.data as { windowId?: number }).windowId === anchorWindowId) {
|
|
223
|
+
ownership.add(p.targetId);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId);
|
|
229
|
+
}
|
|
182
230
|
}
|
|
183
231
|
// Prune per-tab state for pages that no longer exist
|
|
184
232
|
const liveTargetIds = new Set(allPages.map((p) => p.targetId));
|
|
@@ -208,6 +256,13 @@ export const createCdpSession = (
|
|
|
208
256
|
if (ownership) {
|
|
209
257
|
ownership.setHarnessWindow(c.targetId);
|
|
210
258
|
ownership.add(c.targetId);
|
|
259
|
+
// Capture the real Chrome windowId — the durable identity of the
|
|
260
|
+
// window this session initialized (survives seed-tab closure).
|
|
261
|
+
const win = await transport.request("Browser.getWindowForTarget", { targetId: c.targetId }, { sessionId: null });
|
|
262
|
+
if (win.success) {
|
|
263
|
+
const wid = (win.data as { windowId?: number }).windowId;
|
|
264
|
+
if (typeof wid === "number") ownership.setHarnessWindowId(wid);
|
|
265
|
+
}
|
|
211
266
|
}
|
|
212
267
|
}
|
|
213
268
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BrowserClient } from "../client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capture the real Chrome windowId of a freshly-created harness window and bind
|
|
5
|
+
* it as the session's durable window identity. Best-effort: a failed CDP call
|
|
6
|
+
* leaves the previous binding untouched. Call this on every code path that
|
|
7
|
+
* spawns a NEW dedicated window (newWindow:true), so the windowId never points
|
|
8
|
+
* at a window that has since been replaced.
|
|
9
|
+
*/
|
|
10
|
+
export const bindHarnessWindowId = async (client: BrowserClient, targetId: string): Promise<void> => {
|
|
11
|
+
const r = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
|
|
12
|
+
if (!r.success) return;
|
|
13
|
+
const wid = (r.data as { windowId?: number }).windowId;
|
|
14
|
+
if (typeof wid === "number") client.ownership().setHarnessWindowId(wid);
|
|
15
|
+
};
|