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/profile/launch.ts | |
| 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/profile/launch.ts')
| -rw-r--r-- | src/profile/launch.ts | 90 |
1 files changed, 90 insertions, 0 deletions
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<void>; +}; + +const SENTINEL_HTML = (token: string): string => + `<!doctype html><meta charset="utf-8"><title>${token}</title>` + + `<body style="font:14px system-ui;padding:2rem;color:#555">pi browser harness — opening this profile…</body>`; + +/** + * 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<Result<ProfileWindowHandle, string>> => { + 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(() => {}); + }, + }); +}; |
