aboutsummaryrefslogtreecommitdiff
path: root/test/manual
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 /test/manual
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 'test/manual')
-rw-r--r--test/manual/profile-concurrency-test.ts192
-rw-r--r--test/manual/profile-e2e-test.ts295
2 files changed, 487 insertions, 0 deletions
diff --git a/test/manual/profile-concurrency-test.ts b/test/manual/profile-concurrency-test.ts
new file mode 100644
index 0000000..ab792c7
--- /dev/null
+++ b/test/manual/profile-concurrency-test.ts
@@ -0,0 +1,192 @@
+/**
+ * Concurrency regression for harness tab creation, against a real browser.
+ *
+ * browser_read_page is registered unserialized, so several isolated tabs can be
+ * opened at the same moment. Two risks live here:
+ *
+ * 1. Each concurrent caller finding "no harness window" and creating its own
+ * — two blank windows before profiles existed, two LAUNCHED profile windows
+ * after. ensureHarnessWindow single-flights per client to prevent it.
+ * 2. Callers sharing that single creation and all claiming the same seed tab.
+ * Only the caller that started the creation may claim it; the rest must
+ * open their own tab beside it.
+ *
+ * Runs pinned and unpinned inside a throwaway --user-data-dir.
+ *
+ * Run: npx tsx test/manual/profile-concurrency-test.ts
+ */
+import { spawn, execFileSync } from "node:child_process";
+import { existsSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createBrowserClient } from "../../src/client";
+import { closeIsolatedTab, openIsolatedTab } from "../../src/domains/isolated-tab";
+
+const PORT = Number(process.env["PROFILE_CONC_PORT"] ?? 9488);
+const CONCURRENT_TABS = 5;
+
+let passed = 0;
+let failed = 0;
+const check = (cond: boolean, label: string): void => {
+ if (cond) {
+ passed++;
+ console.log(` ✓ ${label}`);
+ } else {
+ failed++;
+ console.error(` ✗ ${label}`);
+ }
+};
+const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
+
+function findBrowser(): string | undefined {
+ const fromEnv = process.env["CHROME_BIN"] ?? process.env["CHROME_PATH"];
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
+ const candidates: string[] =
+ process.platform === "darwin"
+ ? [
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ ]
+ : process.platform === "win32"
+ ? [
+ join(process.env["PROGRAMFILES"] ?? "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"),
+ join(process.env["LOCALAPPDATA"] ?? "", "Google\\Chrome\\Application\\chrome.exe"),
+ ]
+ : ["/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
+ return candidates.find((c) => c && existsSync(c));
+}
+
+const killTree = (pid: number | undefined): void => {
+ if (pid === undefined) return;
+ if (process.platform === "win32") {
+ try {
+ execFileSync("taskkill.exe", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
+ } catch {
+ // already gone
+ }
+ return;
+ }
+ try {
+ process.kill(-pid, "SIGKILL");
+ } catch {
+ try {
+ process.kill(pid, "SIGKILL");
+ } catch {
+ // already gone
+ }
+ }
+};
+
+async function scenario(exePath: string, label: string, pinned: boolean): Promise<void> {
+ const udd = mkdtempSync(join(tmpdir(), "pi-profile-conc-"));
+ const base = [
+ `--user-data-dir=${udd}`,
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-sync",
+ "--disable-background-networking",
+ "--window-size=600,450",
+ ];
+ const browser = spawn(exePath, [...base, `--remote-debugging-port=${PORT}`, "about:blank"], {
+ detached: true,
+ stdio: "ignore",
+ });
+ browser.unref();
+
+ try {
+ let endpoint: string | undefined;
+ for (let i = 0; i < 60 && !endpoint; i++) {
+ await sleep(500);
+ try {
+ const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
+ if (res.ok) endpoint = ((await res.json()) as { webSocketDebuggerUrl: string }).webSocketDebuggerUrl;
+ } catch {
+ // not up yet
+ }
+ }
+ if (!endpoint) {
+ check(false, `${label}: browser exposed its DevTools endpoint`);
+ return;
+ }
+
+ if (pinned) {
+ // Materialise the profile the run pins to.
+ spawn(exePath, [...base, "--profile-directory=Profile 2", "about:blank"], {
+ detached: true,
+ stdio: "ignore",
+ }).unref();
+ await sleep(6_000);
+ }
+
+ process.env["BU_CDP_WS"] = endpoint;
+ const client = createBrowserClient({
+ namespace: `conc-${pinned ? "pinned" : "plain"}`,
+ ...(pinned
+ ? { profilePin: { userDataDir: udd, profileDir: "Profile 2", label: "pinned", savedAt: "" } }
+ : {}),
+ });
+
+ const started = await client.start();
+ check(started.success, `${label}: client starts`);
+ if (!started.success) return;
+
+ const results = await Promise.all(Array.from({ length: CONCURRENT_TABS }, () => openIsolatedTab(client)));
+ const tabs = results.flatMap((r) => (r.success ? [r.data] : []));
+ check(tabs.length === CONCURRENT_TABS, `${label}: all ${CONCURRENT_TABS} concurrent tabs opened (${tabs.length})`);
+ check(new Set(tabs.map((t) => t.targetId)).size === tabs.length, `${label}: every tab is a distinct target`);
+
+ const windows = await Promise.all(
+ tabs.map(async (t) => {
+ const r = await client.session().callBrowser("Browser.getWindowForTarget", { targetId: t.targetId });
+ return r.success ? (r.data as { windowId?: number }).windowId : undefined;
+ }),
+ );
+ check(new Set(windows).size === 1, `${label}: all tabs share one window (${new Set(windows).size})`);
+
+ if (pinned) {
+ const contexts = await Promise.all(
+ tabs.map(async (t) => {
+ const r = await client.session().callBrowser("Target.getTargetInfo", { targetId: t.targetId });
+ return r.success
+ ? (r.data as { targetInfo?: { browserContextId?: string } }).targetInfo?.browserContextId
+ : undefined;
+ }),
+ );
+ check(
+ new Set(contexts).size === 1 && contexts[0] === client.profileContextId(),
+ `${label}: every tab is in the pinned profile`,
+ );
+ }
+
+ for (const tab of tabs) await closeIsolatedTab(client, tab);
+ await client.closeOwnedTabs();
+ await client.stop();
+ } finally {
+ killTree(browser.pid);
+ try {
+ rmSync(udd, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+ } catch {
+ // a locked temp dir is not worth failing over
+ }
+ }
+}
+
+async function main(): Promise<void> {
+ const exePath = findBrowser();
+ if (!exePath) {
+ console.error("No Chrome/Chromium found — set CHROME_BIN. Skipping.");
+ process.exit(process.env["CI"] ? 1 : 0);
+ }
+ console.log(`browser: ${exePath}\n`);
+
+ await scenario(exePath, "UNPINNED", false);
+ await scenario(exePath, "PINNED", true);
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error("profile concurrency test failed:", e);
+ process.exit(1);
+});
diff --git a/test/manual/profile-e2e-test.ts b/test/manual/profile-e2e-test.ts
new file mode 100644
index 0000000..3ac3ac2
--- /dev/null
+++ b/test/manual/profile-e2e-test.ts
@@ -0,0 +1,295 @@
+/**
+ * End-to-end proof of profile pinning, against a REAL browser.
+ *
+ * Runs entirely inside a throwaway --user-data-dir, so the user's own browser
+ * and profiles are never touched. Designed to run on Linux (under xvfb-run),
+ * macOS, and Windows — the three OSes where the launch handshake relies on
+ * Chromium's ProcessSingleton handing our argv to the running browser.
+ *
+ * What it asserts:
+ * 1. two profiles in one browser enumerate from Local State
+ * 2. they surface as DISTINCT browserContextIds over one CDP endpoint
+ * 3. a sentinel launch opens a window in the requested profile and is
+ * identifiable
+ * 4. tabs spawned afterwards stay in that profile AND that window
+ * 5. a spawned tab is attachable, navigable, and closable
+ *
+ * Run: npx tsx test/manual/profile-e2e-test.ts
+ * CHROME_BIN=/path/to/chrome npx tsx test/manual/profile-e2e-test.ts
+ */
+import { spawn, execFileSync } from "node:child_process";
+import { mkdtempSync, rmSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import WebSocket from "ws";
+import { listProfiles } from "../../src/profile/list";
+import { openProfileWindow } from "../../src/profile/launch";
+
+const PORT = Number(process.env["PROFILE_E2E_PORT"] ?? 9444);
+
+let passed = 0;
+let failed = 0;
+const check = (cond: boolean, label: string): void => {
+ if (cond) {
+ passed++;
+ console.log(` ✓ ${label}`);
+ } else {
+ failed++;
+ console.error(` ✗ ${label}`);
+ }
+};
+
+/** Locate a Chrome/Chromium binary for the host OS. */
+function findBrowser(): string | undefined {
+ const fromEnv = process.env["CHROME_BIN"] ?? process.env["CHROME_PATH"];
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
+
+ const candidates: string[] =
+ process.platform === "darwin"
+ ? [
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ ]
+ : process.platform === "win32"
+ ? [
+ join(process.env["PROGRAMFILES"] ?? "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"),
+ join(process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe"),
+ join(process.env["LOCALAPPDATA"] ?? "", "Google\\Chrome\\Application\\chrome.exe"),
+ ]
+ : ["/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
+
+ for (const candidate of candidates) {
+ if (candidate && existsSync(candidate)) return candidate;
+ }
+ if (process.platform === "linux") {
+ for (const name of ["google-chrome", "chromium", "chromium-browser"]) {
+ try {
+ const resolved = execFileSync("which", [name], { encoding: "utf8" }).trim();
+ if (resolved) return resolved;
+ } catch {
+ // not on PATH
+ }
+ }
+ }
+ return undefined;
+}
+
+type Cdp = {
+ call: (method: string, params?: Record<string, unknown>, sessionId?: string) => Promise<any>;
+ close: () => void;
+};
+
+async function connectCdp(): Promise<Cdp> {
+ const version = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json();
+ const ws = new WebSocket((version as { webSocketDebuggerUrl: string }).webSocketDebuggerUrl, {
+ maxPayload: 64 * 1024 * 1024,
+ });
+ let id = 0;
+ const pending = new Map<number, (v: any) => void>();
+ ws.on("message", (d) => {
+ const m = JSON.parse(d.toString());
+ if (m.id && pending.has(m.id)) {
+ pending.get(m.id)!(m);
+ pending.delete(m.id);
+ }
+ });
+ await new Promise<void>((resolve, reject) => {
+ ws.once("open", () => resolve());
+ ws.once("error", reject);
+ });
+ return {
+ call: (method, params = {}, sessionId) =>
+ new Promise((resolve) => {
+ const myId = ++id;
+ pending.set(myId, resolve);
+ ws.send(JSON.stringify({ id: myId, method, params, ...(sessionId ? { sessionId } : {}) }));
+ }),
+ close: () => ws.close(),
+ };
+}
+
+const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
+
+async function waitForEndpoint(deadlineMs = 30_000): Promise<boolean> {
+ const end = Date.now() + deadlineMs;
+ while (Date.now() < end) {
+ try {
+ const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
+ if (res.ok) return true;
+ } catch {
+ // not up yet
+ }
+ await sleep(500);
+ }
+ return false;
+}
+
+async function main(): Promise<void> {
+ const exePath = findBrowser();
+ if (!exePath) {
+ console.error("No Chrome/Chromium found — set CHROME_BIN. Skipping.");
+ process.exit(process.env["CI"] ? 1 : 0);
+ }
+ console.log(`browser: ${exePath}\n`);
+
+ const udd = mkdtempSync(join(tmpdir(), "pi-profile-e2e-"));
+ const baseArgs = [
+ `--user-data-dir=${udd}`,
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-sync",
+ "--disable-background-networking",
+ "--window-size=600,450",
+ ];
+
+ const browser = spawn(exePath, [...baseArgs, `--remote-debugging-port=${PORT}`, "about:blank"], {
+ detached: true,
+ stdio: "ignore",
+ });
+ browser.unref();
+
+ const cleanup = (): void => {
+ // Chrome spawns a tree of child processes. Leaving any alive keeps the
+ // user-data-dir locked, which on Windows also blocks the directory removal.
+ if (process.platform === "win32") {
+ try {
+ execFileSync("taskkill.exe", ["/pid", String(browser.pid), "/T", "/F"], { stdio: "ignore" });
+ } catch {
+ // already gone
+ }
+ } else {
+ try {
+ // detached:true put the browser in its own process group; the negative
+ // pid signals the whole group.
+ process.kill(-browser.pid!, "SIGKILL");
+ } catch {
+ try {
+ browser.kill("SIGKILL");
+ } catch {
+ // already gone
+ }
+ }
+ }
+ try {
+ rmSync(udd, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+ } catch {
+ // A locked file in a temp dir is not worth failing the run over.
+ }
+ };
+
+ try {
+ if (!(await waitForEndpoint())) {
+ console.error("browser never exposed its DevTools endpoint");
+ cleanup();
+ process.exit(1);
+ }
+
+ // Create a SECOND profile in the same browser by launching into it. The
+ // running instance handles this via ProcessSingleton — the very behaviour
+ // profile pinning depends on.
+ const second = spawn(exePath, [...baseArgs, "--profile-directory=Profile 2", "about:blank"], {
+ detached: true,
+ stdio: "ignore",
+ });
+ second.unref();
+ await sleep(6_000);
+
+ const cdp = await connectCdp();
+ const pageTargets = async (): Promise<any[]> =>
+ ((await cdp.call("Target.getTargets")).result?.targetInfos ?? []).filter((t: any) => t.type === "page");
+ const windowOf = async (targetId: string): Promise<number | undefined> =>
+ (await cdp.call("Browser.getWindowForTarget", { targetId })).result?.windowId;
+
+ // 1. enumeration. Chrome flushes Local State asynchronously — a profile
+ // created seconds ago is not registered yet — so poll rather than read once.
+ let profiles = await listProfiles(udd);
+ const enumDeadline = Date.now() + 30_000;
+ while (Date.now() < enumDeadline && profiles.length < 2) {
+ await sleep(1_000);
+ profiles = await listProfiles(udd);
+ }
+ const dirs = profiles.map((p) => p.dir).sort();
+ check(dirs.includes("Default"), "E1: Default profile enumerated from Local State");
+ check(dirs.includes("Profile 2"), "E1: second profile enumerated from Local State");
+ check(
+ profiles.length >= 2 && profiles.every((p) => /^.+ \(.+\)$/.test(p.label)),
+ "E1: every profile has a 'Name (…)' label",
+ );
+
+ // 2. distinct browser contexts
+ const contexts = new Set((await pageTargets()).map((t) => t.browserContextId));
+ check(contexts.size >= 2, `E2: profiles surface as distinct browserContextIds (${contexts.size} seen)`);
+
+ // 3. sentinel launch into a chosen profile
+ const launched = await openProfileWindow({ exePath, profileDir: "Profile 2", explicitUserDataDir: udd });
+ check(launched.success, "E3: profile window launch dispatched");
+ if (!launched.success) throw new Error(launched.error);
+
+ const deadline = Date.now() + 20_000;
+ let seed: any;
+ while (Date.now() < deadline && !seed) {
+ await sleep(500);
+ seed = (await pageTargets()).find((t) => t.url === launched.data.sentinelUrl);
+ }
+ check(!!seed, "E3: sentinel page identifies the new profile window");
+ if (!seed) throw new Error("sentinel target never appeared");
+ await launched.data.cleanup();
+
+ const seedWindow = await windowOf(seed.targetId);
+ const seedContext = seed.browserContextId;
+ check(typeof seedContext === "string" && seedContext.length > 0, "E3: seed carries the profile's browserContextId");
+
+ // 4. + 5. spawn a tab the way the target factory does
+ const attached = await cdp.call("Target.attachToTarget", { targetId: seed.targetId, flatten: true });
+ const sessionId = attached.result?.sessionId;
+ check(!!sessionId, "E4: seed page in a non-default profile is attachable");
+
+ const token = `pi-e2e-${Date.now()}`;
+ const opened = await cdp.call(
+ "Runtime.evaluate",
+ {
+ expression: `!!window.open('about:blank#${token}', '_blank')`,
+ returnByValue: true,
+ userGesture: true,
+ },
+ sessionId,
+ );
+ check(opened.result?.result?.value === true, "E4: window.open with userGesture is not blocked");
+
+ let spawned: any;
+ const spawnDeadline = Date.now() + 10_000;
+ while (Date.now() < spawnDeadline && !spawned) {
+ await sleep(200);
+ spawned = (await pageTargets()).find((t) => t.url.includes(token));
+ }
+ check(!!spawned, "E4: spawned tab is found by its unique token");
+ if (!spawned) throw new Error("spawned tab never appeared");
+
+ check(spawned.browserContextId === seedContext, "E4: spawned tab stays in the pinned profile");
+ check((await windowOf(spawned.targetId)) === seedWindow, "E4: spawned tab stays in the harness window");
+
+ const attachSpawned = await cdp.call("Target.attachToTarget", { targetId: spawned.targetId, flatten: true });
+ check(!!attachSpawned.result?.sessionId, "E5: spawned tab is attachable");
+ const navigated = await cdp.call(
+ "Page.navigate",
+ { url: "about:blank#navigated" },
+ attachSpawned.result.sessionId,
+ );
+ check(!navigated.error, "E5: spawned tab is navigable");
+ const closed = await cdp.call("Target.closeTarget", { targetId: spawned.targetId });
+ check(closed.result?.success === true, "E5: spawned tab is closable");
+
+ cdp.close();
+ } finally {
+ cleanup();
+ }
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ process.exit(failed === 0 ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error("profile e2e test failed:", e);
+ process.exit(1);
+});