diff options
| author | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-07-25 00:23:05 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-25 00:23:05 +0530 |
| commit | 6dd79f7c3bafacba54acb2840f8655fcb05318bf (patch) | |
| tree | 1570768785ae836717d3dbb2ff42fe0a13f19585 /src/domains | |
| parent | 0fc1ece6fa4a2f24e87eb4a66eb828a9928e30f7 (diff) | |
feat: pin the agent to a chosen browser profile (#16)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/domains')
| -rw-r--r-- | src/domains/isolated-tab.ts | 27 | ||||
| -rw-r--r-- | src/domains/navigate.ts | 31 | ||||
| -rw-r--r-- | src/domains/setup.ts | 6 |
3 files changed, 29 insertions, 35 deletions
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<Result<IsolatedTab, ToolErr>> => { - const hw = client.ownership().harnessWindow(); - const createParams: Record<string, unknown> = 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<TabResult> => { // 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<Result<ToolOk, ToolErr>> { - const result = await performSetup(client); + async handler(_args, { client, extensionCtx }): Promise<Result<ToolOk, ToolErr>> { + // 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 }); } |
