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 /test/manual | |
| 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 'test/manual')
| -rw-r--r-- | test/manual/profile-concurrency-test.ts | 192 | ||||
| -rw-r--r-- | test/manual/profile-e2e-test.ts | 295 |
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); +}); |
