pi-browser-harness 0.9.0 → 0.10.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 +22 -0
- package/README.md +237 -197
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +15 -0
- package/src/cdp/discovery.ts +40 -56
- package/src/cdp/session.ts +19 -0
- package/src/cdp/target-factory.ts +223 -0
- package/src/client.ts +91 -47
- package/src/domains/isolated-tab.ts +13 -14
- package/src/domains/navigate.ts +12 -19
- package/src/domains/setup.ts +4 -2
- package/src/index.ts +16 -0
- package/src/profile/bind.ts +138 -0
- package/src/profile/browser-process.ts +371 -0
- package/src/profile/command.ts +168 -0
- package/src/profile/launch.ts +90 -0
- package/src/profile/list.ts +171 -0
- package/src/profile/paths.ts +166 -0
- package/src/profile/picker.ts +73 -0
- package/src/profile/store.ts +105 -0
- package/src/setup.ts +21 -53
package/package.json
CHANGED
|
@@ -54,6 +54,21 @@ connects to Chrome, and opens a test tab. The user sees a single "Allow Remote
|
|
|
54
54
|
Debugging" prompt the first time. After that, all sessions reuse the same connection.
|
|
55
55
|
`browser_setup` is idempotent — safe to call even when already connected.
|
|
56
56
|
|
|
57
|
+
## Browser profile
|
|
58
|
+
|
|
59
|
+
Every harness tab opens in one chosen browser profile, which determines the logins,
|
|
60
|
+
cookies, and extensions you're working with. The user picks it once via
|
|
61
|
+
`/browser-profile`; the choice persists across sessions in `~/.pi/agent/`.
|
|
62
|
+
|
|
63
|
+
The first `browser_setup` in a fresh install shows that picker, so the tool may pause
|
|
64
|
+
briefly on user input — this is expected, and the result appears in the tool output as
|
|
65
|
+
`Browser profile: <name> (<email>)`.
|
|
66
|
+
|
|
67
|
+
If setup reports `couldn't open a window in "…" automatically`, the harness could not
|
|
68
|
+
open the pinned profile's window. Tell the user to open that profile from their browser's
|
|
69
|
+
profile menu and retry, or to run `/browser-profile` to choose another. Never work around
|
|
70
|
+
it by opening tabs elsewhere — a different profile means different accounts.
|
|
71
|
+
|
|
57
72
|
## Connection
|
|
58
73
|
|
|
59
74
|
You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`.
|
package/src/cdp/discovery.ts
CHANGED
|
@@ -1,61 +1,24 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { connect as netConnect } from "node:net";
|
|
5
4
|
import { request as httpRequest } from "node:http";
|
|
6
5
|
import { type Result, err, ok } from "../util/result";
|
|
6
|
+
import { userDataDirCandidates } from "../profile/paths";
|
|
7
7
|
import { type CdpError, cdpError } from "./errors";
|
|
8
8
|
|
|
9
9
|
const PORT_PROBE_DEADLINE_MS = 30_000;
|
|
10
10
|
const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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"),
|
|
25
|
-
join(home, "Library/Application Support/Microsoft Edge"),
|
|
26
|
-
join(home, "Library/Application Support/Microsoft Edge Beta"),
|
|
27
|
-
join(home, "Library/Application Support/Microsoft Edge Dev"),
|
|
28
|
-
join(home, "Library/Application Support/Microsoft Edge Canary"),
|
|
29
|
-
// Linux — Chrome (all channels), Chromium, Brave, Edge
|
|
30
|
-
join(home, ".config/google-chrome"),
|
|
31
|
-
join(home, ".config/google-chrome-beta"),
|
|
32
|
-
join(home, ".config/google-chrome-unstable"),
|
|
33
|
-
join(home, ".config/chromium"),
|
|
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"),
|
|
38
|
-
join(home, ".config/microsoft-edge"),
|
|
39
|
-
join(home, ".config/microsoft-edge-beta"),
|
|
40
|
-
join(home, ".config/microsoft-edge-dev"),
|
|
41
|
-
// Linux flatpak
|
|
42
|
-
join(home, ".var/app/org.chromium.Chromium/config/chromium"),
|
|
43
|
-
join(home, ".var/app/com.google.Chrome/config/google-chrome"),
|
|
44
|
-
join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
|
|
45
|
-
join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
|
|
46
|
-
// Windows — Chrome, Chromium, Brave, Edge
|
|
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"),
|
|
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"),
|
|
54
|
-
join(home, "AppData/Local/Microsoft/Edge/User Data"),
|
|
55
|
-
join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
|
|
56
|
-
join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
|
|
57
|
-
join(home, "AppData/Local/Microsoft/Edge SxS/User Data"),
|
|
58
|
-
];
|
|
12
|
+
/**
|
|
13
|
+
* A live CDP endpoint plus, when discovery went through a DevToolsActivePort
|
|
14
|
+
* file, the user-data-dir that file lives in. That directory is the browser's
|
|
15
|
+
* profile registry (`Local State`) — Chromium writes DevToolsActivePort to
|
|
16
|
+
* chrome::DIR_USER_DATA — so it is what profile selection enumerates.
|
|
17
|
+
* Undefined when the endpoint came from BU_CDP_WS or a bare port probe.
|
|
18
|
+
*/
|
|
19
|
+
export type CdpEndpoint = {
|
|
20
|
+
readonly wsUrl: string;
|
|
21
|
+
readonly userDataDir?: string;
|
|
59
22
|
};
|
|
60
23
|
|
|
61
24
|
const probePort = (port: number): Promise<Result<void, CdpError>> =>
|
|
@@ -144,10 +107,20 @@ const fallbackPorts = (): ReadonlyArray<number> => {
|
|
|
144
107
|
return Array.from(ports);
|
|
145
108
|
};
|
|
146
109
|
|
|
147
|
-
type Candidate = {
|
|
110
|
+
type Candidate = {
|
|
111
|
+
readonly port: number;
|
|
112
|
+
readonly path: string;
|
|
113
|
+
readonly mtimeMs: number;
|
|
114
|
+
/** The user-data-dir whose DevToolsActivePort produced this candidate. */
|
|
115
|
+
readonly userDataDir: string;
|
|
116
|
+
};
|
|
148
117
|
|
|
149
|
-
|
|
150
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Locate a live CDP endpoint, reporting the user-data-dir it belongs to when
|
|
120
|
+
* that is knowable. `discoverWsUrl` is the URL-only view of the same walk.
|
|
121
|
+
*/
|
|
122
|
+
export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
|
|
123
|
+
const dirs = userDataDirCandidates();
|
|
151
124
|
|
|
152
125
|
// Phase 1: collect every readable DevToolsActivePort candidate up front,
|
|
153
126
|
// rather than committing to the first one found. A single browser writes one
|
|
@@ -190,7 +163,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
|
190
163
|
// a Result error. Skip such entries.
|
|
191
164
|
const portNum = Number(port);
|
|
192
165
|
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
|
|
193
|
-
candidates.push({ port: portNum, path, mtimeMs });
|
|
166
|
+
candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
|
|
194
167
|
}
|
|
195
168
|
|
|
196
169
|
// Phase 2: no readable candidate — probe well-known ports directly. Covers
|
|
@@ -201,7 +174,9 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
|
201
174
|
for (const port of fallbackPorts()) {
|
|
202
175
|
if (!(await isPortLive(port))) continue;
|
|
203
176
|
const live = await queryLiveWsUrl(port);
|
|
204
|
-
|
|
177
|
+
// No DevToolsActivePort file was involved, so the user-data-dir behind
|
|
178
|
+
// this endpoint is unknown — profile selection stays unavailable.
|
|
179
|
+
if (live) return ok({ wsUrl: live });
|
|
205
180
|
}
|
|
206
181
|
const permHint = readErrors.length > 0
|
|
207
182
|
? `\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.`
|
|
@@ -232,7 +207,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
|
232
207
|
// /json/version. Some browsers disable that HTTP endpoint when remote
|
|
233
208
|
// debugging is toggled only via chrome://inspect (rather than a launch flag),
|
|
234
209
|
// so fall back to the WS path written in DevToolsActivePort.
|
|
235
|
-
let lastErr: Result<
|
|
210
|
+
let lastErr: Result<CdpEndpoint, CdpError> | null = null;
|
|
236
211
|
for (const c of ordered) {
|
|
237
212
|
const ready = await waitForPort(c.port);
|
|
238
213
|
if (!ready.success) {
|
|
@@ -240,7 +215,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
|
240
215
|
continue;
|
|
241
216
|
}
|
|
242
217
|
const liveUrl = await queryLiveWsUrl(c.port);
|
|
243
|
-
return ok(liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}
|
|
218
|
+
return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
|
|
244
219
|
}
|
|
245
220
|
|
|
246
221
|
// Every discovered candidate was stale/unreachable. Before giving up, try the
|
|
@@ -250,8 +225,17 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
|
250
225
|
for (const port of fallbackPorts()) {
|
|
251
226
|
if (byPort.has(port) || !(await isPortLive(port))) continue;
|
|
252
227
|
const liveUrl = await queryLiveWsUrl(port);
|
|
253
|
-
if (liveUrl) return ok(liveUrl);
|
|
228
|
+
if (liveUrl) return ok({ wsUrl: liveUrl });
|
|
254
229
|
}
|
|
255
230
|
|
|
256
231
|
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
|
|
257
232
|
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* URL-only view of {@link discoverEndpoint}, kept as the stable entry point for
|
|
236
|
+
* callers that don't care which user-data-dir answered.
|
|
237
|
+
*/
|
|
238
|
+
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
239
|
+
const endpoint = await discoverEndpoint();
|
|
240
|
+
return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint;
|
|
241
|
+
};
|
package/src/cdp/session.ts
CHANGED
|
@@ -45,9 +45,21 @@ export type CdpSession = {
|
|
|
45
45
|
drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Supplies the seed tab when attachFirstPage finds no owned tab to attach to.
|
|
50
|
+
* Injected by the client so that seeding goes through the target factory —
|
|
51
|
+
* which, with a profile pinned, opens the window inside that profile. Without
|
|
52
|
+
* it, attachFirstPage's own `Target.createTarget` would land in whichever
|
|
53
|
+
* profile currently has focus.
|
|
54
|
+
*
|
|
55
|
+
* The provider is responsible for its own ownership bookkeeping.
|
|
56
|
+
*/
|
|
57
|
+
export type SeedTargetProvider = () => Promise<Result<string, CdpError>>;
|
|
58
|
+
|
|
48
59
|
export const createCdpSession = (
|
|
49
60
|
transport: CdpTransport,
|
|
50
61
|
ownership?: OwnershipRegistry,
|
|
62
|
+
createSeedTarget?: SeedTargetProvider,
|
|
51
63
|
): CdpSession => {
|
|
52
64
|
let sessionId: string | null = null;
|
|
53
65
|
let targetId: string | null = null;
|
|
@@ -246,6 +258,13 @@ export const createCdpSession = (
|
|
|
246
258
|
const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
|
|
247
259
|
pickTargetId = ownedLive[0];
|
|
248
260
|
}
|
|
261
|
+
if (!pickTargetId && createSeedTarget) {
|
|
262
|
+
// Delegated seeding (see SeedTargetProvider): the provider creates the
|
|
263
|
+
// window in the right profile and records ownership itself.
|
|
264
|
+
const seeded = await createSeedTarget();
|
|
265
|
+
if (!seeded.success) return seeded;
|
|
266
|
+
pickTargetId = seeded.data;
|
|
267
|
+
}
|
|
249
268
|
if (!pickTargetId) {
|
|
250
269
|
const createParams: Record<string, unknown> = { url: "about:blank" };
|
|
251
270
|
if (ownership) createParams["newWindow"] = true;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single place harness tabs and windows are created.
|
|
3
|
+
*
|
|
4
|
+
* Every tab the harness opens must land in the pinned profile, so all creation
|
|
5
|
+
* paths (client.newTab, browser_open_urls, isolated tabs for web_search /
|
|
6
|
+
* read_page) funnel through here. A missed call site would silently act in
|
|
7
|
+
* another profile — with that profile's cookies and logins.
|
|
8
|
+
*
|
|
9
|
+
* Two mechanisms, chosen by whether a profile is pinned:
|
|
10
|
+
*
|
|
11
|
+
* unpinned — `Target.createTarget`, exactly as before: `newWindow: true` for
|
|
12
|
+
* a fresh dedicated window, `openerId` for siblings inside it.
|
|
13
|
+
*
|
|
14
|
+
* pinned — the window comes from a command-line launch into the profile
|
|
15
|
+
* (see profile/launch.ts), and further tabs come from
|
|
16
|
+
* `window.open(..., '_blank')` evaluated in an owned page with
|
|
17
|
+
* `userGesture: true`. That is the only CDP-reachable way to place
|
|
18
|
+
* a tab in a non-default browser context: createTarget rejects a
|
|
19
|
+
* foreign browserContextId outright, and `openerId` does NOT
|
|
20
|
+
* inherit the opener's context (verified — it lands in the
|
|
21
|
+
* focus-derived default context, in a different window).
|
|
22
|
+
* window.open keeps both the context and the window, recursively.
|
|
23
|
+
*
|
|
24
|
+
* Correlation: window.open returns no target id, so each spawn carries a unique
|
|
25
|
+
* `about:blank#pi-<uuid>` token and the new target is found by that token.
|
|
26
|
+
* Set-difference polling would be wrong here — isolated tabs run concurrently
|
|
27
|
+
* (browser_read_page is registered unserialized).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { randomUUID } from "node:crypto";
|
|
31
|
+
import type { BrowserClient } from "../client";
|
|
32
|
+
import { type Result, err, ok } from "../util/result";
|
|
33
|
+
import { type CdpError, cdpError } from "./errors";
|
|
34
|
+
|
|
35
|
+
/** How long to wait for a window.open-spawned target to appear. */
|
|
36
|
+
const SPAWN_DEADLINE_MS = 8_000;
|
|
37
|
+
const SPAWN_POLL_MS = 100;
|
|
38
|
+
|
|
39
|
+
export type HarnessWindow = {
|
|
40
|
+
/** Target id of the window's seed tab. */
|
|
41
|
+
readonly targetId: string;
|
|
42
|
+
/**
|
|
43
|
+
* True when this call created the window. The seed tab is then a blank tab
|
|
44
|
+
* the caller may use directly, instead of opening a second one.
|
|
45
|
+
*/
|
|
46
|
+
readonly freshlyCreated: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type PageTarget = {
|
|
50
|
+
readonly targetId: string;
|
|
51
|
+
readonly type: string;
|
|
52
|
+
readonly url: string;
|
|
53
|
+
readonly browserContextId?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
|
|
57
|
+
const r = await client.session().callBrowser("Target.getTargets");
|
|
58
|
+
if (!r.success) return r;
|
|
59
|
+
const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
|
|
60
|
+
return ok(data.targetInfos.filter((t) => t.type === "page"));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A CDP session id for driving `targetId`, reusing the active one when possible. */
|
|
64
|
+
const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
|
|
65
|
+
const current = client.current();
|
|
66
|
+
if (current?.targetId === targetId) return ok(current.sessionId);
|
|
67
|
+
const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
|
|
68
|
+
if (!attached.success) return attached;
|
|
69
|
+
return ok((attached.data as { sessionId: string }).sessionId);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Spawn a tab from `openerTargetId` via window.open, so it inherits the
|
|
74
|
+
* opener's profile and window, and return its target id.
|
|
75
|
+
*/
|
|
76
|
+
const spawnTabViaOpener = async (
|
|
77
|
+
client: BrowserClient,
|
|
78
|
+
openerTargetId: string,
|
|
79
|
+
): Promise<Result<string, CdpError>> => {
|
|
80
|
+
const session = await sessionForTarget(client, openerTargetId);
|
|
81
|
+
if (!session.success) return session;
|
|
82
|
+
|
|
83
|
+
const token = `pi-${randomUUID()}`;
|
|
84
|
+
// userGesture: true is what gets this past the popup blocker.
|
|
85
|
+
const opened = await client.session().callOnTarget(
|
|
86
|
+
"Runtime.evaluate",
|
|
87
|
+
{
|
|
88
|
+
expression: `!!window.open('about:blank#${token}', '_blank')`,
|
|
89
|
+
returnByValue: true,
|
|
90
|
+
userGesture: true,
|
|
91
|
+
},
|
|
92
|
+
session.data,
|
|
93
|
+
);
|
|
94
|
+
if (!opened.success) return opened;
|
|
95
|
+
const result = opened.data as { result?: { value?: unknown } };
|
|
96
|
+
if (result.result?.value !== true) {
|
|
97
|
+
return err(cdpError(
|
|
98
|
+
"invalid_response",
|
|
99
|
+
"the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
|
|
100
|
+
"Runtime.evaluate",
|
|
101
|
+
));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const deadline = Date.now() + SPAWN_DEADLINE_MS;
|
|
105
|
+
while (Date.now() < deadline) {
|
|
106
|
+
const targets = await listPageTargets(client);
|
|
107
|
+
if (targets.success) {
|
|
108
|
+
const spawned = targets.data.find((t) => t.url.includes(token));
|
|
109
|
+
if (spawned) return ok(spawned.targetId);
|
|
110
|
+
}
|
|
111
|
+
await new Promise((r) => setTimeout(r, SPAWN_POLL_MS));
|
|
112
|
+
}
|
|
113
|
+
return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* In-flight window creation, per client. Isolated tabs run concurrently
|
|
118
|
+
* (browser_read_page is registered unserialized), so two of them starting at
|
|
119
|
+
* once would otherwise each find "no harness window" and create one — two blank
|
|
120
|
+
* windows before, and two launched profile windows now. Callers share the first
|
|
121
|
+
* call's result instead.
|
|
122
|
+
*/
|
|
123
|
+
const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>();
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The harness window for this session, creating it when absent.
|
|
127
|
+
*
|
|
128
|
+
* Reuses a live owned tab when one exists — including after the user closed the
|
|
129
|
+
* original seed — so reconnects don't scatter windows.
|
|
130
|
+
*/
|
|
131
|
+
export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => {
|
|
132
|
+
const pending = inFlight.get(client);
|
|
133
|
+
if (pending) {
|
|
134
|
+
const shared = await pending;
|
|
135
|
+
// `freshlyCreated` is a claim on the seed tab, and only the caller that
|
|
136
|
+
// started the creation may hold it. Everyone waiting on the same window
|
|
137
|
+
// must open a tab of their own beside it, or two callers would drive the
|
|
138
|
+
// very same tab.
|
|
139
|
+
return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared;
|
|
140
|
+
}
|
|
141
|
+
const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client));
|
|
142
|
+
inFlight.set(client, attempt);
|
|
143
|
+
return attempt;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const ensureHarnessWindowUncoordinated = async (
|
|
147
|
+
client: BrowserClient,
|
|
148
|
+
): Promise<Result<HarnessWindow, CdpError>> => {
|
|
149
|
+
const targets = await listPageTargets(client);
|
|
150
|
+
if (!targets.success) return targets;
|
|
151
|
+
const live = new Set(targets.data.map((t) => t.targetId));
|
|
152
|
+
|
|
153
|
+
const ownership = client.ownership();
|
|
154
|
+
const pinnedCtx = client.profileContextId();
|
|
155
|
+
// A tab may only be reused when we can prove which profile it is in. With a
|
|
156
|
+
// pin set but no context resolved yet — a fresh connection, or a browser that
|
|
157
|
+
// restarted — the recorded tabs cannot be verified, so we seed a new window
|
|
158
|
+
// rather than risk adopting one from the wrong profile. (Sessions close their
|
|
159
|
+
// owned tabs on shutdown, so this is the normal, no-cost case.)
|
|
160
|
+
const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
|
|
161
|
+
const inPinnedProfile = (targetId: string): boolean => {
|
|
162
|
+
if (!pinnedCtx) return true;
|
|
163
|
+
const info = targets.data.find((t) => t.targetId === targetId);
|
|
164
|
+
return info?.browserContextId === pinnedCtx;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (canReuse) {
|
|
168
|
+
const harnessWindow = ownership.harnessWindow();
|
|
169
|
+
if (harnessWindow && live.has(harnessWindow) && inPinnedProfile(harnessWindow)) {
|
|
170
|
+
return ok({ targetId: harnessWindow, freshlyCreated: false });
|
|
171
|
+
}
|
|
172
|
+
const survivor = ownership.list().find((id) => live.has(id) && inPinnedProfile(id));
|
|
173
|
+
if (survivor) {
|
|
174
|
+
ownership.setHarnessWindow(survivor);
|
|
175
|
+
return ok({ targetId: survivor, freshlyCreated: false });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const seeded = client.profilePin()
|
|
180
|
+
? await client.seedPinnedProfileWindow()
|
|
181
|
+
: await createDedicatedWindow(client);
|
|
182
|
+
if (!seeded.success) return seeded;
|
|
183
|
+
return ok({ targetId: seeded.data, freshlyCreated: true });
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Unpinned path: a fresh dedicated window, exactly as the harness always did. */
|
|
187
|
+
const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
|
|
188
|
+
const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
|
|
189
|
+
if (!created.success) return created;
|
|
190
|
+
const { targetId } = created.data as { targetId: string };
|
|
191
|
+
const ownership = client.ownership();
|
|
192
|
+
ownership.setHarnessWindow(targetId);
|
|
193
|
+
ownership.add(targetId);
|
|
194
|
+
const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
|
|
195
|
+
if (win.success) {
|
|
196
|
+
const windowId = (win.data as { windowId?: number }).windowId;
|
|
197
|
+
if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
|
|
198
|
+
}
|
|
199
|
+
return ok(targetId);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Open a harness-owned tab beside `openerTargetId`, in the same window and —
|
|
204
|
+
* when a profile is pinned — the same profile. The tab is registered as owned.
|
|
205
|
+
*/
|
|
206
|
+
export const openHarnessTab = async (
|
|
207
|
+
client: BrowserClient,
|
|
208
|
+
openerTargetId: string,
|
|
209
|
+
): Promise<Result<string, CdpError>> => {
|
|
210
|
+
const spawned = client.profileContextId()
|
|
211
|
+
? await spawnTabViaOpener(client, openerTargetId)
|
|
212
|
+
: await (async (): Promise<Result<string, CdpError>> => {
|
|
213
|
+
const created = await client.session().callBrowser("Target.createTarget", {
|
|
214
|
+
url: "about:blank",
|
|
215
|
+
openerId: openerTargetId,
|
|
216
|
+
});
|
|
217
|
+
if (!created.success) return created;
|
|
218
|
+
return ok((created.data as { targetId: string }).targetId);
|
|
219
|
+
})();
|
|
220
|
+
if (!spawned.success) return spawned;
|
|
221
|
+
client.ownership().add(spawned.data);
|
|
222
|
+
return ok(spawned.data);
|
|
223
|
+
};
|
package/src/client.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { type Result, err, ok } from "./util/result";
|
|
2
2
|
import { safeJs } from "./util/js-template";
|
|
3
3
|
import { type Mutex, createMutex } from "./util/mutex";
|
|
4
|
-
import {
|
|
4
|
+
import { discoverEndpoint } from "./cdp/discovery";
|
|
5
5
|
import { type CdpError, cdpError } from "./cdp/errors";
|
|
6
6
|
import type { CdpTransport } from "./cdp/transport";
|
|
7
7
|
import { createCdpTransport } from "./cdp/transport";
|
|
8
8
|
import { type CdpSession, createCdpSession } from "./cdp/session";
|
|
9
9
|
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
|
|
10
|
+
import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
|
|
11
|
+
import { seedProfileWindow } from "./profile/bind";
|
|
12
|
+
import type { ProfilePin } from "./profile/store";
|
|
10
13
|
import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
|
|
11
14
|
|
|
12
15
|
export type BrowserClientOptions = {
|
|
@@ -18,6 +21,8 @@ export type BrowserClientOptions = {
|
|
|
18
21
|
readonly harnessWindowTargetId?: string;
|
|
19
22
|
readonly harnessWindowId?: number;
|
|
20
23
|
};
|
|
24
|
+
/** Profile to pin this session to, loaded from the durable pin store. */
|
|
25
|
+
readonly profilePin?: ProfilePin | null;
|
|
21
26
|
readonly onOwnershipChange?: (snapshot: {
|
|
22
27
|
readonly ownedTargetIds: ReadonlyArray<string>;
|
|
23
28
|
readonly harnessWindowTargetId: string | undefined;
|
|
@@ -49,6 +54,19 @@ export type BrowserClient = {
|
|
|
49
54
|
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
50
55
|
session(): CdpSession;
|
|
51
56
|
transport(): CdpTransport;
|
|
57
|
+
/** User-data-dir of the connected browser, when discovery could determine it.
|
|
58
|
+
* Undefined for BU_CDP_WS / bare-port connections, where profile selection
|
|
59
|
+
* is unavailable. */
|
|
60
|
+
userDataDir(): string | undefined;
|
|
61
|
+
/** The profile this session is pinned to, or null when unpinned. */
|
|
62
|
+
profilePin(): ProfilePin | null;
|
|
63
|
+
setProfilePin(pin: ProfilePin | null): void;
|
|
64
|
+
/** browserContextId of the pinned profile, once a window has identified it.
|
|
65
|
+
* Session-scoped: Chrome re-mints context ids on every browser run. */
|
|
66
|
+
profileContextId(): string | undefined;
|
|
67
|
+
setProfileContextId(contextId: string | undefined): void;
|
|
68
|
+
/** Open and adopt a window inside the pinned profile. Returns its seed tab. */
|
|
69
|
+
seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
|
|
52
70
|
/** Returns the shared async mutex that serialized browser tools must acquire
|
|
53
71
|
* before performing mutations. Observation tools should not use this. */
|
|
54
72
|
mutationMutex(): Mutex;
|
|
@@ -108,11 +126,33 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
108
126
|
});
|
|
109
127
|
});
|
|
110
128
|
}
|
|
111
|
-
|
|
129
|
+
// The seed provider routes attachFirstPage's fallback through the target
|
|
130
|
+
// factory, so a pinned profile is honoured on every path that can create the
|
|
131
|
+
// harness window.
|
|
132
|
+
const session = createCdpSession(transport, ownership, async () => {
|
|
133
|
+
const window = await ensureHarnessWindow(api);
|
|
134
|
+
return window.success ? ok(window.data.targetId) : window;
|
|
135
|
+
});
|
|
112
136
|
const mutationMutex = createMutex();
|
|
113
137
|
let lastHealth = 0;
|
|
114
138
|
let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
|
|
115
139
|
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
|
|
140
|
+
let userDataDir: string | undefined;
|
|
141
|
+
let profilePin: ProfilePin | null = opts.profilePin ?? null;
|
|
142
|
+
let profileContextId: string | undefined;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* With a profile pinned, the harness window must exist BEFORE anything
|
|
146
|
+
* attaches: session.attachFirstPage() would otherwise fall back to a bare
|
|
147
|
+
* Target.createTarget, which lands in the focus-derived default profile.
|
|
148
|
+
* Seeding first means attachFirstPage always finds an owned tab to attach to.
|
|
149
|
+
*/
|
|
150
|
+
const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
|
|
151
|
+
if (!profilePin) return ok(undefined);
|
|
152
|
+
const window = await ensureHarnessWindow(api);
|
|
153
|
+
if (!window.success) return window;
|
|
154
|
+
return ok(undefined);
|
|
155
|
+
};
|
|
116
156
|
|
|
117
157
|
const start = async (): Promise<Result<void, CdpError>> => {
|
|
118
158
|
if (transport.state() === "open" && session.current()) return ok(undefined);
|
|
@@ -123,15 +163,23 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
123
163
|
} else if (envUrl) {
|
|
124
164
|
wsUrl = envUrl;
|
|
125
165
|
} else {
|
|
126
|
-
const discovered = await
|
|
166
|
+
const discovered = await discoverEndpoint();
|
|
127
167
|
if (!discovered.success) return discovered;
|
|
128
|
-
wsUrl = discovered.data;
|
|
168
|
+
wsUrl = discovered.data.wsUrl;
|
|
169
|
+
userDataDir = discovered.data.userDataDir;
|
|
129
170
|
}
|
|
130
171
|
const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
|
|
131
172
|
if (!connected.success) return connected;
|
|
132
173
|
if (!remote) {
|
|
133
174
|
remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" };
|
|
134
175
|
}
|
|
176
|
+
// A fresh browser run invalidates any context id resolved earlier.
|
|
177
|
+
profileContextId = undefined;
|
|
178
|
+
const seeded = await seedIfPinned();
|
|
179
|
+
if (!seeded.success) {
|
|
180
|
+
await transport.close();
|
|
181
|
+
return seeded;
|
|
182
|
+
}
|
|
135
183
|
const attached = await session.attachFirstPage();
|
|
136
184
|
if (!attached.success) {
|
|
137
185
|
await transport.close();
|
|
@@ -166,6 +214,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
166
214
|
expression: "1", returnByValue: true,
|
|
167
215
|
}, { timeoutMs: 2_000 });
|
|
168
216
|
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
|
|
217
|
+
// Re-seed first when pinned: if the user closed every harness tab,
|
|
218
|
+
// attachFirstPage would mint a window in the focus-derived profile.
|
|
219
|
+
const seeded = await seedIfPinned();
|
|
220
|
+
if (!seeded.success) return seeded;
|
|
169
221
|
const reattached = await session.attachFirstPage();
|
|
170
222
|
if (reattached.success) {
|
|
171
223
|
pageCaches.clear();
|
|
@@ -265,60 +317,34 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
265
317
|
return ok(undefined);
|
|
266
318
|
};
|
|
267
319
|
|
|
268
|
-
// Ask Chrome for the real windowId hosting a target. Undefined on failure —
|
|
269
|
-
// callers treat the window binding as best-effort.
|
|
270
|
-
const getWindowId = async (targetId: string): Promise<number | undefined> => {
|
|
271
|
-
const r = await session.callBrowser("Browser.getWindowForTarget", { targetId });
|
|
272
|
-
if (!r.success) return undefined;
|
|
273
|
-
const wid = (r.data as { windowId?: number }).windowId;
|
|
274
|
-
return typeof wid === "number" ? wid : undefined;
|
|
275
|
-
};
|
|
276
|
-
|
|
277
320
|
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
|
|
278
|
-
//
|
|
279
|
-
// target list also gives listTabs's reconciliation a chance to run.
|
|
321
|
+
// Reconcile ownership against live targets before deciding where to open.
|
|
280
322
|
const tabsResult = await listTabs(true);
|
|
281
323
|
if (!tabsResult.success) return tabsResult;
|
|
282
|
-
const live = new Set(tabsResult.data.map((t) => t.targetId));
|
|
283
|
-
const hw = ownership.harnessWindow();
|
|
284
|
-
// Pick an opener that keeps us inside the harness window. Prefer the seed
|
|
285
|
-
// tab; if the user closed it but other owned tabs survive, reuse one of
|
|
286
|
-
// those instead of spawning a second window (avoids window scatter).
|
|
287
|
-
const opener = hw && live.has(hw)
|
|
288
|
-
? hw
|
|
289
|
-
: ownership.list().find((id) => live.has(id));
|
|
290
324
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
325
|
+
// ensureHarnessWindow reuses the seed tab, falls back to any surviving
|
|
326
|
+
// owned tab, and only then creates a window — which, when a profile is
|
|
327
|
+
// pinned, means launching one inside that profile.
|
|
328
|
+
const window = await ensureHarnessWindow(api);
|
|
329
|
+
if (!window.success) return window;
|
|
330
|
+
// A freshly created window IS the new tab; opening another would leave a
|
|
331
|
+
// stray blank tab behind.
|
|
332
|
+
let tabId: string;
|
|
333
|
+
if (window.data.freshlyCreated) {
|
|
334
|
+
tabId = window.data.targetId;
|
|
296
335
|
} else {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
336
|
+
const opened = await openHarnessTab(api, window.data.targetId);
|
|
337
|
+
if (!opened.success) return opened;
|
|
338
|
+
tabId = opened.data;
|
|
300
339
|
}
|
|
301
|
-
const created = await session.callBrowser("Target.createTarget", params);
|
|
302
|
-
if (!created.success) return created;
|
|
303
|
-
const c = created.data as { targetId: string };
|
|
304
340
|
|
|
305
|
-
|
|
306
|
-
ownership.setHarnessWindow(c.targetId);
|
|
307
|
-
const wid = await getWindowId(c.targetId);
|
|
308
|
-
if (wid !== undefined) ownership.setHarnessWindowId(wid);
|
|
309
|
-
} else if (opener && opener !== hw) {
|
|
310
|
-
// Re-seeded onto a surviving owned tab; keep the seed pointer live.
|
|
311
|
-
ownership.setHarnessWindow(opener);
|
|
312
|
-
}
|
|
313
|
-
ownership.add(c.targetId);
|
|
314
|
-
|
|
315
|
-
const switched = await switchTab(c.targetId);
|
|
341
|
+
const switched = await switchTab(tabId);
|
|
316
342
|
if (!switched.success) return switched;
|
|
317
343
|
if (url && url !== "about:blank") {
|
|
318
344
|
const nav = await session.call("Page.navigate", { url });
|
|
319
345
|
if (!nav.success) return nav;
|
|
320
346
|
}
|
|
321
|
-
return ok(
|
|
347
|
+
return ok(tabId);
|
|
322
348
|
};
|
|
323
349
|
|
|
324
350
|
const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
@@ -357,7 +383,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
357
383
|
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
|
|
358
384
|
};
|
|
359
385
|
|
|
360
|
-
|
|
386
|
+
const api: BrowserClient = {
|
|
361
387
|
namespace: opts.namespace,
|
|
362
388
|
ensureAlive, status, start, stop, detach, closeOwnedTabs,
|
|
363
389
|
evaluateJs, pageInfo,
|
|
@@ -368,6 +394,24 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
368
394
|
current: () => session.current(),
|
|
369
395
|
session: () => session,
|
|
370
396
|
transport: () => transport,
|
|
397
|
+
userDataDir: () => userDataDir,
|
|
398
|
+
profilePin: () => profilePin,
|
|
399
|
+
setProfilePin: (pin) => {
|
|
400
|
+
profilePin = pin;
|
|
401
|
+
// The old profile's context no longer describes where tabs should go.
|
|
402
|
+
profileContextId = undefined;
|
|
403
|
+
},
|
|
404
|
+
profileContextId: () => profileContextId,
|
|
405
|
+
setProfileContextId: (contextId) => {
|
|
406
|
+
profileContextId = contextId;
|
|
407
|
+
},
|
|
408
|
+
seedPinnedProfileWindow: async () => {
|
|
409
|
+
if (!profilePin) {
|
|
410
|
+
return err(cdpError("discovery_failed", "no browser profile is selected — run /browser-profile"));
|
|
411
|
+
}
|
|
412
|
+
return seedProfileWindow(api, profilePin);
|
|
413
|
+
},
|
|
371
414
|
mutationMutex: () => mutationMutex,
|
|
372
415
|
};
|
|
416
|
+
return api;
|
|
373
417
|
};
|