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/profile/launch.ts | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/profile/launch.ts (limited to 'src/profile/launch.ts') diff --git a/src/profile/launch.ts b/src/profile/launch.ts new file mode 100644 index 0000000..4f8f65a --- /dev/null +++ b/src/profile/launch.ts @@ -0,0 +1,90 @@ +/** + * Opening a browser window for a chosen profile. + * + * CDP cannot do this: `Target.createTarget` rejects another profile's + * browserContextId ("Failed to find browser context with id"), and a bare + * createTarget lands in `defaultBrowserContextId`, which follows window focus. + * The only way in is the browser's own command line — Chromium's + * ProcessSingleton hands a second launch's argv to the running browser, which + * "does what it would have done", i.e. opens a window in `--profile-directory`. + * + * The window is then identified by a unique `file://` sentinel page. That + * matters: a bare `about:blank#token` argument is discarded by Chrome (it opens + * chrome://newtab instead), so the token has to ride on a real URL. Polling for + * the sentinel URL — rather than diffing the target list — also keeps + * identification correct when the user opens tabs at the same moment. + */ + +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { type Result, err, ok } from "../util/result"; + +export type ProfileWindowRequest = { + /** Browser binary to invoke. */ + readonly exePath: string; + /** Profile subdirectory, e.g. "Profile 1". */ + readonly profileDir: string; + /** + * Only set when the RUNNING browser was launched with an explicit + * --user-data-dir. ProcessSingleton keys on this path, so passing one that + * differs by case or normalisation would start a second browser instead of + * delegating to the running one. + */ + readonly explicitUserDataDir?: string; +}; + +export type ProfileWindowHandle = { + /** file:// URL that will appear as the new window's page target. */ + readonly sentinelUrl: string; + /** Remove the sentinel file. Safe to call more than once. */ + readonly cleanup: () => Promise; +}; + +const SENTINEL_HTML = (token: string): string => + `${token}` + + `pi browser harness — opening this profile…`; + +/** + * Ask the running browser to open a window in `profileDir`, marked with a + * unique sentinel page the caller can then find over CDP. + */ +export const openProfileWindow = async ( + req: ProfileWindowRequest, +): Promise> => { + const token = `pi-harness-${randomUUID()}`; + const file = join(tmpdir(), `${token}.html`); + try { + await writeFile(file, SENTINEL_HTML(token), "utf8"); + } catch (e) { + return err(`could not write the profile handshake page: ${e instanceof Error ? e.message : String(e)}`); + } + + const sentinelUrl = pathToFileURL(file).href; + const args: string[] = []; + if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`); + args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl); + + try { + // No shell: arguments are passed as an array, so profile names and paths + // containing spaces survive intact on every platform. + const child = spawn(req.exePath, args, { detached: true, stdio: "ignore" }); + child.unref(); + // The launcher process exits as soon as the running browser takes over the + // command line; an error here means the binary itself could not be started. + child.on("error", () => {}); + } catch (e) { + await rm(file, { force: true }).catch(() => {}); + return err(`could not launch ${req.exePath}: ${e instanceof Error ? e.message : String(e)}`); + } + + return ok({ + sentinelUrl, + cleanup: async () => { + await rm(file, { force: true }).catch(() => {}); + }, + }); +}; -- cgit v1.2.3