aboutsummaryrefslogtreecommitdiff
path: root/src/profile/launch.ts
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/profile/launch.ts
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/profile/launch.ts')
-rw-r--r--src/profile/launch.ts90
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(() => {});
+ },
+ });
+};