aboutsummaryrefslogtreecommitdiff
path: root/src/domains
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-07-25 00:23:05 +0530
committerGitHub <noreply@github.com>2026-07-25 00:23:05 +0530
commit6dd79f7c3bafacba54acb2840f8655fcb05318bf (patch)
tree1570768785ae836717d3dbb2ff42fe0a13f19585 /src/domains
parent0fc1ece6fa4a2f24e87eb4a66eb828a9928e30f7 (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.ts27
-rw-r--r--src/domains/navigate.ts31
-rw-r--r--src/domains/setup.ts6
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 });
}