pi-browser-harness 0.9.0 → 0.10.1

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.
@@ -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,14 +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
- if (!remote) {
133
- remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" };
173
+
174
+ const newBrowserId: string = wsUrl.split("/").pop() ?? "unknown";
175
+ if (!remote || remote.browserId !== newBrowserId) {
176
+ profileContextId = undefined;
177
+ }
178
+ remote = { cdpUrl: wsUrl, browserId: newBrowserId };
179
+ const seeded = await seedIfPinned();
180
+ if (!seeded.success) {
181
+ await transport.close();
182
+ return seeded;
134
183
  }
135
184
  const attached = await session.attachFirstPage();
136
185
  if (!attached.success) {
@@ -166,6 +215,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
166
215
  expression: "1", returnByValue: true,
167
216
  }, { timeoutMs: 2_000 });
168
217
  if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
218
+ // Re-seed first when pinned: if the user closed every harness tab,
219
+ // attachFirstPage would mint a window in the focus-derived profile.
220
+ const seeded = await seedIfPinned();
221
+ if (!seeded.success) return seeded;
169
222
  const reattached = await session.attachFirstPage();
170
223
  if (reattached.success) {
171
224
  pageCaches.clear();
@@ -265,60 +318,34 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
265
318
  return ok(undefined);
266
319
  };
267
320
 
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
321
  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.
322
+ // Reconcile ownership against live targets before deciding where to open.
280
323
  const tabsResult = await listTabs(true);
281
324
  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
325
 
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;
326
+ // ensureHarnessWindow reuses the seed tab, falls back to any surviving
327
+ // owned tab, and only then creates a window — which, when a profile is
328
+ // pinned, means launching one inside that profile.
329
+ const window = await ensureHarnessWindow(api);
330
+ if (!window.success) return window;
331
+ // A freshly created window IS the new tab; opening another would leave a
332
+ // stray blank tab behind.
333
+ let tabId: string;
334
+ if (window.data.freshlyCreated) {
335
+ tabId = window.data.targetId;
296
336
  } 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;
337
+ const opened = await openHarnessTab(api, window.data.targetId);
338
+ if (!opened.success) return opened;
339
+ tabId = opened.data;
300
340
  }
301
- const created = await session.callBrowser("Target.createTarget", params);
302
- if (!created.success) return created;
303
- const c = created.data as { targetId: string };
304
341
 
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);
342
+ const switched = await switchTab(tabId);
316
343
  if (!switched.success) return switched;
317
344
  if (url && url !== "about:blank") {
318
345
  const nav = await session.call("Page.navigate", { url });
319
346
  if (!nav.success) return nav;
320
347
  }
321
- return ok(c.targetId);
348
+ return ok(tabId);
322
349
  };
323
350
 
324
351
  const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
@@ -357,7 +384,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
357
384
  await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
358
385
  };
359
386
 
360
- return {
387
+ const api: BrowserClient = {
361
388
  namespace: opts.namespace,
362
389
  ensureAlive, status, start, stop, detach, closeOwnedTabs,
363
390
  evaluateJs, pageInfo,
@@ -368,6 +395,28 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
368
395
  current: () => session.current(),
369
396
  session: () => session,
370
397
  transport: () => transport,
398
+ userDataDir: () => userDataDir,
399
+ profilePin: () => profilePin,
400
+ setProfilePin: (pin) => {
401
+ const changed = pin === null
402
+ ? profilePin !== null
403
+ : profilePin === null || profilePin.userDataDir !== pin.userDataDir || profilePin.profileDir !== pin.profileDir;
404
+ profilePin = pin;
405
+ if (changed) {
406
+ profileContextId = undefined;
407
+ }
408
+ },
409
+ profileContextId: () => profileContextId,
410
+ setProfileContextId: (contextId) => {
411
+ profileContextId = contextId;
412
+ },
413
+ seedPinnedProfileWindow: async () => {
414
+ if (!profilePin) {
415
+ return err(cdpError("discovery_failed", "no browser profile is selected — run /browser-profile"));
416
+ }
417
+ return seedProfileWindow(api, profilePin);
418
+ },
371
419
  mutationMutex: () => mutationMutex,
372
420
  };
421
+ return api;
373
422
  };