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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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`.
@@ -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
- const profileDirs = (): ReadonlyArray<string> => {
13
- const home = homedir();
14
- return [
15
- // macOS — Chrome (all channels), Chromium, Brave, Edge
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"),
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 = { readonly port: number; readonly path: string; readonly mtimeMs: number };
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
- export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
150
- const dirs = profileDirs();
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
- if (live) return ok(live);
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<string, CdpError> | null = null;
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
+ };
@@ -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 { discoverWsUrl } from "./cdp/discovery";
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
- const session = createCdpSession(transport, ownership);
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 discoverWsUrl();
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
- // Verify the recorded harness window still exists. Querying the live
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
- const params: Record<string, unknown> = { url: "about:blank" };
292
- if (opener) {
293
- // Open as a child of an owned tab — Chrome places it in the same window,
294
- // giving us visual grouping for free and keeping the session single-window.
295
- params["openerId"] = opener;
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
- // Fresh session, or the whole harness window was closed by the user.
298
- // Spawn a new dedicated window and rebind to it.
299
- params["newWindow"] = true;
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
- if (params["newWindow"]) {
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(c.targetId);
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
- return {
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
  };