From 6dd79f7c3bafacba54acb2840f8655fcb05318bf Mon Sep 17 00:00:00 2001 From: Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:23:05 +0530 Subject: feat: pin the agent to a chosen browser profile (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: pin the agent to a chosen browser profile (#15) Harness tabs were created with a bare Target.createTarget, which places them in Chrome's defaultBrowserContextId — and that value follows window focus. Which profile the agent acted as therefore depended on which browser window the user last clicked, so the same task could run as a work account on one run and a personal account on the next. /browser-profile lists the connected browser's profiles as "Name (email)" and pins one to ~/.pi/agent/browser-harness.json, so the choice outlives the session. The same picker appears on first browser_setup when nothing is pinned; with no interactive UI, or on cancel, setup continues with a note and the previous behavior. Chrome offers no direct route to another profile: Target.createTarget rejects a foreign browserContextId, and openerId does not inherit the opener's context. The window is opened through the browser's own command line (--profile-directory, handed to the running browser by Chromium's ProcessSingleton) and identified by a unique file:// sentinel page, since a bare about:blank#token argument is discarded. Further tabs come from window.open evaluated with userGesture: true — the only CDP-reachable way to place a tab in a non-default browser context — correlated by a unique token so concurrent isolated tabs stay distinct. A failed handshake is reported, never silently redirected to another profile. All four tab-creation sites now funnel through cdp/target-factory, so open_urls and the isolated tabs behind web_search / read_page cannot run with a different profile's cookies than the visible tabs. Also fixed along the way: snap Chromium is discovered, Windows uses %LOCALAPPDATA%, browsers launched with an explicit --user-data-dir are found, the running browser is chosen by ranking candidates against the connected user-data-dir rather than taking the first process found, and an installed-but-closed browser no longer reads as running. CI runs the profile suites and a real-browser end-to-end test on ubuntu, macOS, and Windows — profile discovery is the one part of the harness whose behavior genuinely differs per OS. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: single-flight harness window creation Isolated tabs run concurrently (browser_read_page is registered unserialized), so several could each find "no harness window" and create one — two blank windows before this feature, two LAUNCHED profile windows after it. ensureHarnessWindow now shares one in-flight creation per client. Sharing introduces a second hazard: freshlyCreated is a claim on the seed tab, and if every waiter received it, they would all drive the same tab. Only the caller that started the creation keeps the claim; the rest open their own tab beside it. Covered by test/manual/profile-concurrency-test.ts (five concurrent isolated tabs, pinned and unpinned: distinct targets, one window, correct profile), wired into CI on Linux only — the coordination is pure JS and identical across platforms. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: tilde expansion differs by purpose on Windows CI on windows-latest caught this: join("~", "x") yields "~\x" there, and expandTilde only handled "~/". The two uses are not the same, so they no longer share a helper. The agent dir stays byte-compatible with pi's own expandTildePath ("~/" only, every platform) — being more lenient would put the pin file somewhere pi never looks. $CHROME_USER_DATA_DIR is ours to interpret, so it accepts "~\" as well, which is what a Windows user would type. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/domains/isolated-tab.ts | 27 +++++++++++++-------------- src/domains/navigate.ts | 31 ++++++++++++------------------- src/domains/setup.ts | 6 ++++-- 3 files changed, 29 insertions(+), 35 deletions(-) (limited to 'src/domains') diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts index 73ed158..7969c6b 100644 --- a/src/domains/isolated-tab.ts +++ b/src/domains/isolated-tab.ts @@ -13,7 +13,7 @@ import type { BrowserClient } from "../client"; import { type Result, err, ok } from "../util/result"; import { safeJs } from "../util/js-template"; -import { bindHarnessWindowId } from "../cdp/window-binding"; +import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory"; import type { ToolErr } from "../util/tool"; /** A tab attached by its own sessionId, ready to drive via callOnTarget. */ @@ -32,20 +32,19 @@ const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolEr * sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done. */ export const openIsolatedTab = async (client: BrowserClient): Promise> => { - const hw = client.ownership().harnessWindow(); - const createParams: Record = hw - ? { url: "about:blank", openerId: hw } - : { url: "about:blank", newWindow: true }; - - const created = await client.session().callBrowser("Target.createTarget", createParams); - if (!created.success) return err(toToolErr(created.error.message)); - const { targetId } = created.data as { targetId: string }; - - if (createParams["newWindow"]) { - client.ownership().setHarnessWindow(targetId); - await bindHarnessWindowId(client, targetId); + // Routed through the target factory so an isolated tab lands in the pinned + // profile like every other harness tab — otherwise a search or page read + // would run with a different profile's cookies than the visible tabs. + const window = await ensureHarnessWindow(client); + if (!window.success) return err(toToolErr(window.error.message)); + let targetId: string; + if (window.data.freshlyCreated) { + targetId = window.data.targetId; + } else { + const opened = await openHarnessTab(client, window.data.targetId); + if (!opened.success) return err(toToolErr(opened.error.message)); + targetId = opened.data; } - client.ownership().add(targetId); const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true }); if (!attached.success) { diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts index e402efc..aa0d671 100644 --- a/src/domains/navigate.ts +++ b/src/domains/navigate.ts @@ -4,7 +4,7 @@ import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; import { safeJs } from "../util/js-template"; -import { bindHarnessWindowId } from "../cdp/window-binding"; +import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory"; const NavigateArgs = Type.Object({ url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }), @@ -89,28 +89,21 @@ export const openUrlsTool = defineBrowserTool({ // (fresh/closed), create the FIRST url as a new dedicated window (binding // its windowId) and open the rest as children of it — a parallel // newWindow:true per url would otherwise scatter tabs across N windows. - let seed = client.ownership().harnessWindow(); - let seedResult: TabResult | undefined; - if (!seed) { - const url0 = args.urls[0]; - if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" }); - const r0 = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true }); - if (!r0.success) return err({ kind: "cdp_error", message: r0.error.message }); - seed = (r0.data as { targetId: string }).targetId; - client.ownership().setHarnessWindow(seed); - await bindHarnessWindowId(client, seed); - client.ownership().add(seed); - seedResult = { url: url0, targetId: seed, ok: true }; - } - const seededWindow = seed; + const url0 = args.urls[0]; + if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" }); + const window = await ensureHarnessWindow(client); + if (!window.success) return err({ kind: "cdp_error", message: window.error.message }); + const seededWindow = window.data.targetId; + // A freshly created window's seed tab serves as the first URL's tab. + const seedResult: TabResult | undefined = window.data.freshlyCreated + ? { url: url0, targetId: seededWindow, ok: true } + : undefined; const created = await Promise.all(args.urls.map(async (url, i): Promise => { // Reuse the seed tab (created above) for the first url instead of duplicating it. if (i === 0 && seedResult) return seedResult; - const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank", openerId: seededWindow }); + const r = await openHarnessTab(client, seededWindow); if (!r.success) return { url, targetId: "", ok: false, error: r.error.message }; - const c = r.data as { targetId: string }; - client.ownership().add(c.targetId); - return { url, targetId: c.targetId, ok: true }; + return { url, targetId: r.data, ok: true }; })); // Phase 2: attach + navigate each created tab in parallel. let completed = 0; diff --git a/src/domains/setup.ts b/src/domains/setup.ts index bbd767f..2ddd739 100644 --- a/src/domains/setup.ts +++ b/src/domains/setup.ts @@ -31,8 +31,10 @@ export const setupTool = defineBrowserTool({ // ensureAlive:false — this tool IS the setup; it must bypass the socket guard ensureAlive: false, renderCall: () => new Text("🔧 Initializing browser...", 0, 0), - async handler(_args, { client }): Promise> { - const result = await performSetup(client); + async handler(_args, { client, extensionCtx }): Promise> { + // extensionCtx carries the UI, so the agent calling browser_setup gets the + // same first-run profile picker the user gets from /browser-setup. + const result = await performSetup(client, extensionCtx); if (result.success) { return ok({ text: result.data }); } -- cgit v1.2.3