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 | |
| 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>
| -rw-r--r-- | .github/workflows/ci.yml | 60 | ||||
| -rw-r--r-- | CHANGELOG.md | 22 | ||||
| -rw-r--r-- | README.md | 20 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | skills/pi-browser-harness/SKILL.md | 15 | ||||
| -rw-r--r-- | src/cdp/discovery.ts | 96 | ||||
| -rw-r--r-- | src/cdp/session.ts | 19 | ||||
| -rw-r--r-- | src/cdp/target-factory.ts | 223 | ||||
| -rw-r--r-- | src/client.ts | 138 | ||||
| -rw-r--r-- | src/domains/isolated-tab.ts | 27 | ||||
| -rw-r--r-- | src/domains/navigate.ts | 31 | ||||
| -rw-r--r-- | src/domains/setup.ts | 6 | ||||
| -rw-r--r-- | src/index.ts | 16 | ||||
| -rw-r--r-- | src/profile/bind.ts | 138 | ||||
| -rw-r--r-- | src/profile/browser-process.ts | 371 | ||||
| -rw-r--r-- | src/profile/command.ts | 168 | ||||
| -rw-r--r-- | src/profile/launch.ts | 90 | ||||
| -rw-r--r-- | src/profile/list.ts | 171 | ||||
| -rw-r--r-- | src/profile/paths.ts | 166 | ||||
| -rw-r--r-- | src/profile/picker.ts | 73 | ||||
| -rw-r--r-- | src/profile/store.ts | 105 | ||||
| -rw-r--r-- | src/setup.ts | 74 | ||||
| -rw-r--r-- | test/manual/profile-concurrency-test.ts | 192 | ||||
| -rw-r--r-- | test/manual/profile-e2e-test.ts | 295 | ||||
| -rw-r--r-- | test/profile/browser-process-test.ts | 108 | ||||
| -rw-r--r-- | test/profile/list-test.ts | 156 | ||||
| -rw-r--r-- | test/profile/paths-test.ts | 130 | ||||
| -rw-r--r-- | test/profile/store-test.ts | 116 |
28 files changed, 2835 insertions, 193 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d873cf..c93ad8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,63 @@ jobs: - name: Verify npm package run: npm pack --dry-run > /dev/null + + # Profile discovery and pinning are the only parts of the harness whose + # behavior differs per operating system: user-data-dir locations, how the + # running browser is identified, and whether a --profile-directory launch is + # handed to the running browser by Chromium's ProcessSingleton. Chromium's + # source says all three behave the same everywhere; this job is what turns + # that into evidence on real macOS and Windows machines. + profiles: + name: Profiles (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + # Pure path/parsing/storage logic. Each run exercises its own platform's + # branch — the matrix is what gives all three coverage. + - name: Unit tests + run: | + npx tsx test/profile/paths-test.ts + npx tsx test/profile/list-test.ts + npx tsx test/profile/store-test.ts + npx tsx test/profile/browser-process-test.ts + shell: bash + + # The runner images ship Google Chrome on all three platforms. Linux has + # no display server, so the browser runs under Xvfb — the launch handshake + # needs a real (headful) browser, since headless Chrome does not honour + # --profile-directory window opening. + - name: Install Xvfb (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y xvfb + + - name: Profile end-to-end (Linux) + if: runner.os == 'Linux' + run: xvfb-run --auto-servernum npx tsx test/manual/profile-e2e-test.ts + + - name: Profile end-to-end (macOS / Windows) + if: runner.os != 'Linux' + run: npx tsx test/manual/profile-e2e-test.ts + shell: bash + + # Tab-creation concurrency is pure JS coordination — the same on every + # OS — so one platform is enough to guard the invariant. + - name: Tab concurrency (Linux only) + if: runner.os == 'Linux' + run: xvfb-run --auto-servernum npx tsx test/manual/profile-concurrency-test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed726e2..bbd641a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to pi-browser-harness will be documented in this file. +## 0.10.0 — 2026-07-24 + +### Added + +- **`/browser-profile` — choose which browser profile the agent works in.** The command lists every profile in the connected browser, labelled `Name (email)` (or `Name (Profile 3)` when the profile has no signed-in account), marks the current selection, ordered the way the browser orders them. The choice is saved to `~/.pi/agent/browser-harness.json`, so it survives session termination, pi restarts, and applies across projects; a trailing `— Clear selection —` row restores the previous behavior. Picking a profile mid-session takes effect immediately — the harness closes its tabs and reopens its window in the chosen profile. +- **First-run profile prompt.** When no profile has been chosen, `/browser-setup` and the agent-callable `browser_setup` show the same picker before connecting, and report the result as `Browser profile: <label>`. With no interactive UI (print/RPC mode) or when the user cancels, setup continues with a one-line note and the pre-existing behavior, so non-interactive usage is unaffected. +- **`/browser-status` reports the selected profile.** + +### Fixed + +- **The agent no longer lands in an arbitrary browser profile.** Harness tabs were created with a bare `Target.createTarget`, which places them in Chrome's `defaultBrowserContextId` — a value that follows window focus. The 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. With a profile pinned, the harness opens its window inside that profile and keeps every tab there. Chrome offers no direct route for this: `Target.createTarget` rejects another profile's `browserContextId` outright, and `openerId` does not inherit the opener's context. The window is opened through the browser's own command line (`--profile-directory`, which Chromium's ProcessSingleton hands to the already-running browser) and identified by a unique `file://` sentinel page; subsequent tabs come from `window.open` evaluated with `userGesture: true`, which is the only CDP-reachable way to place a tab in a non-default browser context. When the window cannot be opened, the harness reports it and stops rather than silently using another profile. +- **Tab creation is funnelled through one place** (`src/cdp/target-factory.ts`). `browser_open_urls` and the isolated tabs behind `browser_web_search` / `browser_read_page` previously called `Target.createTarget` themselves, so under a pinned profile they would have run with a different profile's cookies than the visible tabs. +- **Snap-installed Chromium is discovered.** Ubuntu's default Chromium keeps its user data in `~/snap/chromium/common/chromium`, which was absent from the discovery list, making it invisible to the harness. +- **Windows profile discovery honours `%LOCALAPPDATA%`** instead of assuming `AppData\Local` under the home directory — the two diverge on roaming and managed accounts. Browsers launched with an explicit `--user-data-dir` (and Linux's `$CHROME_USER_DATA_DIR`) are now found as well. +- **The right browser is identified when several are running.** Browser detection now ranks candidate processes against the user-data-dir the harness is actually connected to, instead of taking the first Chromium-family process it finds; with Chrome and Brave both open, a profile window could otherwise be opened in a browser the harness is not attached to. Detection also reports the executable path and any explicit `--user-data-dir`, and on Windows uses PowerShell CIM plus the App Paths registry key rather than `wmic`, which Microsoft removed by default in Windows 11 24H2. +- **Installed-but-closed browsers are no longer mistaken for running ones** — liveness is derived only from live-process evidence. + +### Tests + +- Fixture-driven unit suites for profile enumeration, pin persistence, per-OS path resolution, and browser-process ranking (`test/profile/`), plus a real-browser end-to-end test (`test/manual/profile-e2e-test.ts`) that creates two profiles in a throwaway user-data-dir and asserts distinct browser contexts, sentinel-based window identification, and that spawned tabs stay in the pinned profile and window. +- CI now runs the profile suites and the end-to-end test on **ubuntu, macOS, and Windows** (Linux under Xvfb). Profile discovery is the one part of the harness whose behavior genuinely differs per OS, and the runner images ship real Chrome, so the cross-platform launch handshake is verified rather than assumed. + ## 0.9.0 — 2026-07-24 ### Added @@ -46,6 +46,21 @@ or relaunch the browser with `--remote-debugging-port=9222`. Finally, run `/browser-setup` in pi to connect. It's a one-time step — the connection survives across pi sessions and reconnects on its own when Chrome restarts. +The first time you connect, you'll be asked which browser profile the agent should work in: + +``` +Select the browser profile the agent should use + vertexcover.io (aman@vertexcover.io) + ✓ Aman (personal@gmail.com) + Harness (harness@gmail.com) + — Clear selection (use whichever window is focused) — +``` + +That choice is saved to `~/.pi/agent/browser-harness.json` and reused by every project until you +change it with `/browser-profile`. It decides which logins, cookies, and extensions the agent works +with, so pinning it keeps runs reproducible. Skip the prompt and the harness behaves as it always +did: tabs open in whichever profile currently has focus. + **Requires:** pi (latest) · Node.js ≥ 22 · Chrome, Chromium, Brave, or Edge --- @@ -293,7 +308,8 @@ re-runnable. | Command | Purpose | |---|---| | `/browser-setup` | Connect pi to your browser. Run once; idempotent. | -| `/browser-status` | Connection state and current page. | +| `/browser-profile` | Pick which browser profile the agent works in. Remembered across sessions. | +| `/browser-status` | Connection state, selected profile, and current page. | | `/deep-research <question>` | Multi-source web research → cited Markdown report. | | `/browser-reload-daemon` | Force a client restart. Rarely needed — the transport reconnects on its own. | @@ -349,6 +365,8 @@ harness process with direct daemon and Node access. | Actions all hang | A JS dialog is blocking. `browser_page_info` reports it; `browser_handle_dialog` clears it. | | No `[eN]` ref for your target | The element wasn't exposed as interactive. Fall back to a CSS `selector`, or `browser_execute_js` with `getBoundingClientRect()`. | | Connected but the wrong browser responds | A stale `DevToolsActivePort` file. Quit every Chromium-family browser, reopen one, then `/browser-setup`. | +| Agent acts as the wrong account | No profile is pinned, so tabs open in whichever profile has focus. Run `/browser-profile` and pick one. | +| `couldn't open a window in "…" automatically` | The profile window couldn't be opened for you. Open that profile from the browser's profile menu, then retry — the harness never falls back to a different profile silently. | --- diff --git a/package.json b/package.json index 780db4f..6e7cd50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-browser-harness", - "version": "0.9.0", + "version": "0.10.0", "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP", "keywords": [ "pi-package", diff --git a/skills/pi-browser-harness/SKILL.md b/skills/pi-browser-harness/SKILL.md index 60e23e0..fa76a2e 100644 --- a/skills/pi-browser-harness/SKILL.md +++ b/skills/pi-browser-harness/SKILL.md @@ -54,6 +54,21 @@ connects to Chrome, and opens a test tab. The user sees a single "Allow Remote Debugging" prompt the first time. After that, all sessions reuse the same connection. `browser_setup` is idempotent — safe to call even when already connected. +## Browser profile + +Every harness tab opens in one chosen browser profile, which determines the logins, +cookies, and extensions you're working with. The user picks it once via +`/browser-profile`; the choice persists across sessions in `~/.pi/agent/`. + +The first `browser_setup` in a fresh install shows that picker, so the tool may pause +briefly on user input — this is expected, and the result appears in the tool output as +`Browser profile: <name> (<email>)`. + +If setup reports `couldn't open a window in "…" automatically`, the harness could not +open the pinned profile's window. Tell the user to open that profile from their browser's +profile menu and retry, or to run `/browser-profile` to choose another. Never work around +it by opening tabs elsewhere — a different profile means different accounts. + ## Connection You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`. diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts index 1b35418..295df20 100644 --- a/src/cdp/discovery.ts +++ b/src/cdp/discovery.ts @@ -1,61 +1,24 @@ import { readFile, stat } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; import { connect as netConnect } from "node:net"; import { request as httpRequest } from "node:http"; import { type Result, err, ok } from "../util/result"; +import { userDataDirCandidates } from "../profile/paths"; import { type CdpError, cdpError } from "./errors"; const PORT_PROBE_DEADLINE_MS = 30_000; const PORT_PROBE_INTERVAL_MS = 1_000; -const profileDirs = (): ReadonlyArray<string> => { - const home = homedir(); - return [ - // macOS — Chrome (all channels), Chromium, Brave, Edge - join(home, "Library/Application Support/Google/Chrome"), - join(home, "Library/Application Support/Google/Chrome Beta"), - join(home, "Library/Application Support/Google/Chrome Dev"), - join(home, "Library/Application Support/Google/Chrome Canary"), - join(home, "Library/Application Support/Chromium"), - join(home, "Library/Application Support/BraveSoftware/Brave-Browser"), - join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Beta"), - join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Nightly"), - join(home, "Library/Application Support/BraveSoftware/Brave-Browser-Dev"), - join(home, "Library/Application Support/Microsoft Edge"), - join(home, "Library/Application Support/Microsoft Edge Beta"), - join(home, "Library/Application Support/Microsoft Edge Dev"), - join(home, "Library/Application Support/Microsoft Edge Canary"), - // Linux — Chrome (all channels), Chromium, Brave, Edge - join(home, ".config/google-chrome"), - join(home, ".config/google-chrome-beta"), - join(home, ".config/google-chrome-unstable"), - join(home, ".config/chromium"), - join(home, ".config/chromium-browser"), - join(home, ".config/BraveSoftware/Brave-Browser"), - join(home, ".config/BraveSoftware/Brave-Browser-Beta"), - join(home, ".config/BraveSoftware/Brave-Browser-Nightly"), - join(home, ".config/microsoft-edge"), - join(home, ".config/microsoft-edge-beta"), - join(home, ".config/microsoft-edge-dev"), - // Linux flatpak - join(home, ".var/app/org.chromium.Chromium/config/chromium"), - join(home, ".var/app/com.google.Chrome/config/google-chrome"), - join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"), - join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"), - // Windows — Chrome, Chromium, Brave, Edge - join(home, "AppData/Local/Google/Chrome/User Data"), - join(home, "AppData/Local/Google/Chrome Beta/User Data"), - join(home, "AppData/Local/Google/Chrome Dev/User Data"), - join(home, "AppData/Local/Google/Chrome SxS/User Data"), - join(home, "AppData/Local/Chromium/User Data"), - join(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data"), - join(home, "AppData/Local/BraveSoftware/Brave-Browser-Beta/User Data"), - join(home, "AppData/Local/Microsoft/Edge/User Data"), - join(home, "AppData/Local/Microsoft/Edge Beta/User Data"), - join(home, "AppData/Local/Microsoft/Edge Dev/User Data"), - join(home, "AppData/Local/Microsoft/Edge SxS/User Data"), - ]; +/** + * A live CDP endpoint plus, when discovery went through a DevToolsActivePort + * file, the user-data-dir that file lives in. That directory is the browser's + * profile registry (`Local State`) — Chromium writes DevToolsActivePort to + * chrome::DIR_USER_DATA — so it is what profile selection enumerates. + * Undefined when the endpoint came from BU_CDP_WS or a bare port probe. + */ +export type CdpEndpoint = { + readonly wsUrl: string; + readonly userDataDir?: string; }; const probePort = (port: number): Promise<Result<void, CdpError>> => @@ -144,10 +107,20 @@ const fallbackPorts = (): ReadonlyArray<number> => { return Array.from(ports); }; -type Candidate = { readonly port: number; readonly path: string; readonly mtimeMs: number }; +type Candidate = { + readonly port: number; + readonly path: string; + readonly mtimeMs: number; + /** The user-data-dir whose DevToolsActivePort produced this candidate. */ + readonly userDataDir: string; +}; -export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { - const dirs = profileDirs(); +/** + * Locate a live CDP endpoint, reporting the user-data-dir it belongs to when + * that is knowable. `discoverWsUrl` is the URL-only view of the same walk. + */ +export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => { + const dirs = userDataDirCandidates(); // Phase 1: collect every readable DevToolsActivePort candidate up front, // rather than committing to the first one found. A single browser writes one @@ -190,7 +163,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { // a Result error. Skip such entries. const portNum = Number(port); if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue; - candidates.push({ port: portNum, path, mtimeMs }); + candidates.push({ port: portNum, path, mtimeMs, userDataDir: base }); } // Phase 2: no readable candidate — probe well-known ports directly. Covers @@ -201,7 +174,9 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { for (const port of fallbackPorts()) { if (!(await isPortLive(port))) continue; const live = await queryLiveWsUrl(port); - if (live) return ok(live); + // No DevToolsActivePort file was involved, so the user-data-dir behind + // this endpoint is unknown — profile selection stays unavailable. + if (live) return ok({ wsUrl: live }); } const permHint = readErrors.length > 0 ? `\n\nDevToolsActivePort reads were denied (${readErrors.join(", ")}); if you're running in a sandbox, grant read access to those files or set BU_CDP_WS / BU_CDP_PORTS to bypass file discovery.` @@ -232,7 +207,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { // /json/version. Some browsers disable that HTTP endpoint when remote // debugging is toggled only via chrome://inspect (rather than a launch flag), // so fall back to the WS path written in DevToolsActivePort. - let lastErr: Result<string, CdpError> | null = null; + let lastErr: Result<CdpEndpoint, CdpError> | null = null; for (const c of ordered) { const ready = await waitForPort(c.port); if (!ready.success) { @@ -240,7 +215,7 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { continue; } const liveUrl = await queryLiveWsUrl(c.port); - return ok(liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`); + return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir }); } // Every discovered candidate was stale/unreachable. Before giving up, try the @@ -250,8 +225,17 @@ export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { for (const port of fallbackPorts()) { if (byPort.has(port) || !(await isPortLive(port))) continue; const liveUrl = await queryLiveWsUrl(port); - if (liveUrl) return ok(liveUrl); + if (liveUrl) return ok({ wsUrl: liveUrl }); } return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates")); }; + +/** + * URL-only view of {@link discoverEndpoint}, kept as the stable entry point for + * callers that don't care which user-data-dir answered. + */ +export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => { + const endpoint = await discoverEndpoint(); + return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint; +}; diff --git a/src/cdp/session.ts b/src/cdp/session.ts index 03410ba..eeb3802 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -45,9 +45,21 @@ export type CdpSession = { drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult; }; +/** + * Supplies the seed tab when attachFirstPage finds no owned tab to attach to. + * Injected by the client so that seeding goes through the target factory — + * which, with a profile pinned, opens the window inside that profile. Without + * it, attachFirstPage's own `Target.createTarget` would land in whichever + * profile currently has focus. + * + * The provider is responsible for its own ownership bookkeeping. + */ +export type SeedTargetProvider = () => Promise<Result<string, CdpError>>; + export const createCdpSession = ( transport: CdpTransport, ownership?: OwnershipRegistry, + createSeedTarget?: SeedTargetProvider, ): CdpSession => { let sessionId: string | null = null; let targetId: string | null = null; @@ -246,6 +258,13 @@ export const createCdpSession = ( const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id)); pickTargetId = ownedLive[0]; } + if (!pickTargetId && createSeedTarget) { + // Delegated seeding (see SeedTargetProvider): the provider creates the + // window in the right profile and records ownership itself. + const seeded = await createSeedTarget(); + if (!seeded.success) return seeded; + pickTargetId = seeded.data; + } if (!pickTargetId) { const createParams: Record<string, unknown> = { url: "about:blank" }; if (ownership) createParams["newWindow"] = true; diff --git a/src/cdp/target-factory.ts b/src/cdp/target-factory.ts new file mode 100644 index 0000000..25c43a6 --- /dev/null +++ b/src/cdp/target-factory.ts @@ -0,0 +1,223 @@ +/** + * The single place harness tabs and windows are created. + * + * Every tab the harness opens must land in the pinned profile, so all creation + * paths (client.newTab, browser_open_urls, isolated tabs for web_search / + * read_page) funnel through here. A missed call site would silently act in + * another profile — with that profile's cookies and logins. + * + * Two mechanisms, chosen by whether a profile is pinned: + * + * unpinned — `Target.createTarget`, exactly as before: `newWindow: true` for + * a fresh dedicated window, `openerId` for siblings inside it. + * + * pinned — the window comes from a command-line launch into the profile + * (see profile/launch.ts), and further tabs come from + * `window.open(..., '_blank')` evaluated in an owned page with + * `userGesture: true`. That is the only CDP-reachable way to place + * a tab in a non-default browser context: createTarget rejects a + * foreign browserContextId outright, and `openerId` does NOT + * inherit the opener's context (verified — it lands in the + * focus-derived default context, in a different window). + * window.open keeps both the context and the window, recursively. + * + * Correlation: window.open returns no target id, so each spawn carries a unique + * `about:blank#pi-<uuid>` token and the new target is found by that token. + * Set-difference polling would be wrong here — isolated tabs run concurrently + * (browser_read_page is registered unserialized). + */ + +import { randomUUID } from "node:crypto"; +import type { BrowserClient } from "../client"; +import { type Result, err, ok } from "../util/result"; +import { type CdpError, cdpError } from "./errors"; + +/** How long to wait for a window.open-spawned target to appear. */ +const SPAWN_DEADLINE_MS = 8_000; +const SPAWN_POLL_MS = 100; + +export type HarnessWindow = { + /** Target id of the window's seed tab. */ + readonly targetId: string; + /** + * True when this call created the window. The seed tab is then a blank tab + * the caller may use directly, instead of opening a second one. + */ + readonly freshlyCreated: boolean; +}; + +type PageTarget = { + readonly targetId: string; + readonly type: string; + readonly url: string; + readonly browserContextId?: string; +}; + +const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => { + const r = await client.session().callBrowser("Target.getTargets"); + if (!r.success) return r; + const data = r.data as { targetInfos: ReadonlyArray<PageTarget> }; + return ok(data.targetInfos.filter((t) => t.type === "page")); +}; + +/** A CDP session id for driving `targetId`, reusing the active one when possible. */ +const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => { + const current = client.current(); + if (current?.targetId === targetId) return ok(current.sessionId); + const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true }); + if (!attached.success) return attached; + return ok((attached.data as { sessionId: string }).sessionId); +}; + +/** + * Spawn a tab from `openerTargetId` via window.open, so it inherits the + * opener's profile and window, and return its target id. + */ +const spawnTabViaOpener = async ( + client: BrowserClient, + openerTargetId: string, +): Promise<Result<string, CdpError>> => { + const session = await sessionForTarget(client, openerTargetId); + if (!session.success) return session; + + const token = `pi-${randomUUID()}`; + // userGesture: true is what gets this past the popup blocker. + const opened = await client.session().callOnTarget( + "Runtime.evaluate", + { + expression: `!!window.open('about:blank#${token}', '_blank')`, + returnByValue: true, + userGesture: true, + }, + session.data, + ); + if (!opened.success) return opened; + const result = opened.data as { result?: { value?: unknown } }; + if (result.result?.value !== true) { + return err(cdpError( + "invalid_response", + "the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed", + "Runtime.evaluate", + )); + } + + const deadline = Date.now() + SPAWN_DEADLINE_MS; + while (Date.now() < deadline) { + const targets = await listPageTargets(client); + if (targets.success) { + const spawned = targets.data.find((t) => t.url.includes(token)); + if (spawned) return ok(spawned.targetId); + } + await new Promise((r) => setTimeout(r, SPAWN_POLL_MS)); + } + return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`)); +}; + +/** + * In-flight window creation, per client. Isolated tabs run concurrently + * (browser_read_page is registered unserialized), so two of them starting at + * once would otherwise each find "no harness window" and create one — two blank + * windows before, and two launched profile windows now. Callers share the first + * call's result instead. + */ +const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>(); + +/** + * The harness window for this session, creating it when absent. + * + * Reuses a live owned tab when one exists — including after the user closed the + * original seed — so reconnects don't scatter windows. + */ +export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => { + const pending = inFlight.get(client); + if (pending) { + const shared = await pending; + // `freshlyCreated` is a claim on the seed tab, and only the caller that + // started the creation may hold it. Everyone waiting on the same window + // must open a tab of their own beside it, or two callers would drive the + // very same tab. + return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared; + } + const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client)); + inFlight.set(client, attempt); + return attempt; +}; + +const ensureHarnessWindowUncoordinated = async ( + client: BrowserClient, +): Promise<Result<HarnessWindow, CdpError>> => { + const targets = await listPageTargets(client); + if (!targets.success) return targets; + const live = new Set(targets.data.map((t) => t.targetId)); + + const ownership = client.ownership(); + const pinnedCtx = client.profileContextId(); + // A tab may only be reused when we can prove which profile it is in. With a + // pin set but no context resolved yet — a fresh connection, or a browser that + // restarted — the recorded tabs cannot be verified, so we seed a new window + // rather than risk adopting one from the wrong profile. (Sessions close their + // owned tabs on shutdown, so this is the normal, no-cost case.) + const canReuse = client.profilePin() === null || pinnedCtx !== undefined; + const inPinnedProfile = (targetId: string): boolean => { + if (!pinnedCtx) return true; + const info = targets.data.find((t) => t.targetId === targetId); + return info?.browserContextId === pinnedCtx; + }; + + if (canReuse) { + const harnessWindow = ownership.harnessWindow(); + if (harnessWindow && live.has(harnessWindow) && inPinnedProfile(harnessWindow)) { + return ok({ targetId: harnessWindow, freshlyCreated: false }); + } + const survivor = ownership.list().find((id) => live.has(id) && inPinnedProfile(id)); + if (survivor) { + ownership.setHarnessWindow(survivor); + return ok({ targetId: survivor, freshlyCreated: false }); + } + } + + const seeded = client.profilePin() + ? await client.seedPinnedProfileWindow() + : await createDedicatedWindow(client); + if (!seeded.success) return seeded; + return ok({ targetId: seeded.data, freshlyCreated: true }); +}; + +/** Unpinned path: a fresh dedicated window, exactly as the harness always did. */ +const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => { + const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true }); + if (!created.success) return created; + const { targetId } = created.data as { targetId: string }; + const ownership = client.ownership(); + ownership.setHarnessWindow(targetId); + ownership.add(targetId); + const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId }); + if (win.success) { + const windowId = (win.data as { windowId?: number }).windowId; + if (typeof windowId === "number") ownership.setHarnessWindowId(windowId); + } + return ok(targetId); +}; + +/** + * Open a harness-owned tab beside `openerTargetId`, in the same window and — + * when a profile is pinned — the same profile. The tab is registered as owned. + */ +export const openHarnessTab = async ( + client: BrowserClient, + openerTargetId: string, +): Promise<Result<string, CdpError>> => { + const spawned = client.profileContextId() + ? await spawnTabViaOpener(client, openerTargetId) + : await (async (): Promise<Result<string, CdpError>> => { + const created = await client.session().callBrowser("Target.createTarget", { + url: "about:blank", + openerId: openerTargetId, + }); + if (!created.success) return created; + return ok((created.data as { targetId: string }).targetId); + })(); + if (!spawned.success) return spawned; + client.ownership().add(spawned.data); + return ok(spawned.data); +}; diff --git a/src/client.ts b/src/client.ts index 9a97369..1049212 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,12 +1,15 @@ import { type Result, err, ok } from "./util/result"; import { safeJs } from "./util/js-template"; import { type Mutex, createMutex } from "./util/mutex"; -import { discoverWsUrl } from "./cdp/discovery"; +import { discoverEndpoint } from "./cdp/discovery"; import { type CdpError, cdpError } from "./cdp/errors"; import type { CdpTransport } from "./cdp/transport"; import { createCdpTransport } from "./cdp/transport"; import { type CdpSession, createCdpSession } from "./cdp/session"; import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership"; +import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory"; +import { seedProfileWindow } from "./profile/bind"; +import type { ProfilePin } from "./profile/store"; import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types"; export type BrowserClientOptions = { @@ -18,6 +21,8 @@ export type BrowserClientOptions = { readonly harnessWindowTargetId?: string; readonly harnessWindowId?: number; }; + /** Profile to pin this session to, loaded from the durable pin store. */ + readonly profilePin?: ProfilePin | null; readonly onOwnershipChange?: (snapshot: { readonly ownedTargetIds: ReadonlyArray<string>; readonly harnessWindowTargetId: string | undefined; @@ -49,6 +54,19 @@ export type BrowserClient = { current(): { readonly sessionId: string; readonly targetId: string } | null; session(): CdpSession; transport(): CdpTransport; + /** User-data-dir of the connected browser, when discovery could determine it. + * Undefined for BU_CDP_WS / bare-port connections, where profile selection + * is unavailable. */ + userDataDir(): string | undefined; + /** The profile this session is pinned to, or null when unpinned. */ + profilePin(): ProfilePin | null; + setProfilePin(pin: ProfilePin | null): void; + /** browserContextId of the pinned profile, once a window has identified it. + * Session-scoped: Chrome re-mints context ids on every browser run. */ + profileContextId(): string | undefined; + setProfileContextId(contextId: string | undefined): void; + /** Open and adopt a window inside the pinned profile. Returns its seed tab. */ + seedPinnedProfileWindow(): Promise<Result<string, CdpError>>; /** Returns the shared async mutex that serialized browser tools must acquire * before performing mutations. Observation tools should not use this. */ mutationMutex(): Mutex; @@ -108,11 +126,33 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = }); }); } - const session = createCdpSession(transport, ownership); + // The seed provider routes attachFirstPage's fallback through the target + // factory, so a pinned profile is honoured on every path that can create the + // harness window. + const session = createCdpSession(transport, ownership, async () => { + const window = await ensureHarnessWindow(api); + return window.success ? ok(window.data.targetId) : window; + }); const mutationMutex = createMutex(); let lastHealth = 0; let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>(); let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null; + let userDataDir: string | undefined; + let profilePin: ProfilePin | null = opts.profilePin ?? null; + let profileContextId: string | undefined; + + /** + * With a profile pinned, the harness window must exist BEFORE anything + * attaches: session.attachFirstPage() would otherwise fall back to a bare + * Target.createTarget, which lands in the focus-derived default profile. + * Seeding first means attachFirstPage always finds an owned tab to attach to. + */ + const seedIfPinned = async (): Promise<Result<void, CdpError>> => { + if (!profilePin) return ok(undefined); + const window = await ensureHarnessWindow(api); + if (!window.success) return window; + return ok(undefined); + }; const start = async (): Promise<Result<void, CdpError>> => { if (transport.state() === "open" && session.current()) return ok(undefined); @@ -123,15 +163,23 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = } else if (envUrl) { wsUrl = envUrl; } else { - const discovered = await discoverWsUrl(); + const discovered = await discoverEndpoint(); if (!discovered.success) return discovered; - wsUrl = discovered.data; + wsUrl = discovered.data.wsUrl; + userDataDir = discovered.data.userDataDir; } const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 }); if (!connected.success) return connected; if (!remote) { remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" }; } + // A fresh browser run invalidates any context id resolved earlier. + profileContextId = undefined; + const seeded = await seedIfPinned(); + if (!seeded.success) { + await transport.close(); + return seeded; + } const attached = await session.attachFirstPage(); if (!attached.success) { await transport.close(); @@ -166,6 +214,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = expression: "1", returnByValue: true, }, { timeoutMs: 2_000 }); if (!jsProbe.success && jsProbe.error.kind === "session_not_found") { + // Re-seed first when pinned: if the user closed every harness tab, + // attachFirstPage would mint a window in the focus-derived profile. + const seeded = await seedIfPinned(); + if (!seeded.success) return seeded; const reattached = await session.attachFirstPage(); if (reattached.success) { pageCaches.clear(); @@ -265,60 +317,34 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = return ok(undefined); }; - // Ask Chrome for the real windowId hosting a target. Undefined on failure — - // callers treat the window binding as best-effort. - const getWindowId = async (targetId: string): Promise<number | undefined> => { - const r = await session.callBrowser("Browser.getWindowForTarget", { targetId }); - if (!r.success) return undefined; - const wid = (r.data as { windowId?: number }).windowId; - return typeof wid === "number" ? wid : undefined; - }; - const newTab = async (url?: string): Promise<Result<string, CdpError>> => { - // Verify the recorded harness window still exists. Querying the live - // target list also gives listTabs's reconciliation a chance to run. + // Reconcile ownership against live targets before deciding where to open. const tabsResult = await listTabs(true); if (!tabsResult.success) return tabsResult; - const live = new Set(tabsResult.data.map((t) => t.targetId)); - const hw = ownership.harnessWindow(); - // Pick an opener that keeps us inside the harness window. Prefer the seed - // tab; if the user closed it but other owned tabs survive, reuse one of - // those instead of spawning a second window (avoids window scatter). - const opener = hw && live.has(hw) - ? hw - : ownership.list().find((id) => live.has(id)); - const params: Record<string, unknown> = { url: "about:blank" }; - if (opener) { - // Open as a child of an owned tab — Chrome places it in the same window, - // giving us visual grouping for free and keeping the session single-window. - params["openerId"] = opener; + // ensureHarnessWindow reuses the seed tab, falls back to any surviving + // owned tab, and only then creates a window — which, when a profile is + // pinned, means launching one inside that profile. + const window = await ensureHarnessWindow(api); + if (!window.success) return window; + // A freshly created window IS the new tab; opening another would leave a + // stray blank tab behind. + let tabId: string; + if (window.data.freshlyCreated) { + tabId = window.data.targetId; } else { - // Fresh session, or the whole harness window was closed by the user. - // Spawn a new dedicated window and rebind to it. - params["newWindow"] = true; + const opened = await openHarnessTab(api, window.data.targetId); + if (!opened.success) return opened; + tabId = opened.data; } - const created = await session.callBrowser("Target.createTarget", params); - if (!created.success) return created; - const c = created.data as { targetId: string }; - if (params["newWindow"]) { - ownership.setHarnessWindow(c.targetId); - const wid = await getWindowId(c.targetId); - if (wid !== undefined) ownership.setHarnessWindowId(wid); - } else if (opener && opener !== hw) { - // Re-seeded onto a surviving owned tab; keep the seed pointer live. - ownership.setHarnessWindow(opener); - } - ownership.add(c.targetId); - - const switched = await switchTab(c.targetId); + const switched = await switchTab(tabId); if (!switched.success) return switched; if (url && url !== "about:blank") { const nav = await session.call("Page.navigate", { url }); if (!nav.success) return nav; } - return ok(c.targetId); + return ok(tabId); }; const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => { @@ -357,7 +383,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null }); }; - return { + const api: BrowserClient = { namespace: opts.namespace, ensureAlive, status, start, stop, detach, closeOwnedTabs, evaluateJs, pageInfo, @@ -368,6 +394,24 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = current: () => session.current(), session: () => session, transport: () => transport, + userDataDir: () => userDataDir, + profilePin: () => profilePin, + setProfilePin: (pin) => { + profilePin = pin; + // The old profile's context no longer describes where tabs should go. + profileContextId = undefined; + }, + profileContextId: () => profileContextId, + setProfileContextId: (contextId) => { + profileContextId = contextId; + }, + seedPinnedProfileWindow: async () => { + if (!profilePin) { + return err(cdpError("discovery_failed", "no browser profile is selected — run /browser-profile")); + } + return seedProfileWindow(api, profilePin); + }, mutationMutex: () => mutationMutex, }; + return api; }; diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts index 73ed158..7969c6b 100644 --- a/src/domains/isolated-tab.ts +++ b/src/domains/isolated-tab.ts @@ -13,7 +13,7 @@ import type { BrowserClient } from "../client"; import { type Result, err, ok } from "../util/result"; import { safeJs } from "../util/js-template"; -import { bindHarnessWindowId } from "../cdp/window-binding"; +import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory"; import type { ToolErr } from "../util/tool"; /** A tab attached by its own sessionId, ready to drive via callOnTarget. */ @@ -32,20 +32,19 @@ const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolEr * sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done. */ export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => { - const hw = client.ownership().harnessWindow(); - const createParams: Record<string, unknown> = hw - ? { url: "about:blank", openerId: hw } - : { url: "about:blank", newWindow: true }; - - const created = await client.session().callBrowser("Target.createTarget", createParams); - if (!created.success) return err(toToolErr(created.error.message)); - const { targetId } = created.data as { targetId: string }; - - if (createParams["newWindow"]) { - client.ownership().setHarnessWindow(targetId); - await bindHarnessWindowId(client, targetId); + // Routed through the target factory so an isolated tab lands in the pinned + // profile like every other harness tab — otherwise a search or page read + // would run with a different profile's cookies than the visible tabs. + const window = await ensureHarnessWindow(client); + if (!window.success) return err(toToolErr(window.error.message)); + let targetId: string; + if (window.data.freshlyCreated) { + targetId = window.data.targetId; + } else { + const opened = await openHarnessTab(client, window.data.targetId); + if (!opened.success) return err(toToolErr(opened.error.message)); + targetId = opened.data; } - client.ownership().add(targetId); const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true }); if (!attached.success) { diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts index e402efc..aa0d671 100644 --- a/src/domains/navigate.ts +++ b/src/domains/navigate.ts @@ -4,7 +4,7 @@ import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; import { safeJs } from "../util/js-template"; -import { bindHarnessWindowId } from "../cdp/window-binding"; +import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory"; const NavigateArgs = Type.Object({ url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }), @@ -89,28 +89,21 @@ export const openUrlsTool = defineBrowserTool({ // (fresh/closed), create the FIRST url as a new dedicated window (binding // its windowId) and open the rest as children of it — a parallel // newWindow:true per url would otherwise scatter tabs across N windows. - let seed = client.ownership().harnessWindow(); - let seedResult: TabResult | undefined; - if (!seed) { - const url0 = args.urls[0]; - if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" }); - const r0 = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true }); - if (!r0.success) return err({ kind: "cdp_error", message: r0.error.message }); - seed = (r0.data as { targetId: string }).targetId; - client.ownership().setHarnessWindow(seed); - await bindHarnessWindowId(client, seed); - client.ownership().add(seed); - seedResult = { url: url0, targetId: seed, ok: true }; - } - const seededWindow = seed; + const url0 = args.urls[0]; + if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" }); + const window = await ensureHarnessWindow(client); + if (!window.success) return err({ kind: "cdp_error", message: window.error.message }); + const seededWindow = window.data.targetId; + // A freshly created window's seed tab serves as the first URL's tab. + const seedResult: TabResult | undefined = window.data.freshlyCreated + ? { url: url0, targetId: seededWindow, ok: true } + : undefined; const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => { // Reuse the seed tab (created above) for the first url instead of duplicating it. if (i === 0 && seedResult) return seedResult; - const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank", openerId: seededWindow }); + const r = await openHarnessTab(client, seededWindow); if (!r.success) return { url, targetId: "", ok: false, error: r.error.message }; - const c = r.data as { targetId: string }; - client.ownership().add(c.targetId); - return { url, targetId: c.targetId, ok: true }; + return { url, targetId: r.data, ok: true }; })); // Phase 2: attach + navigate each created tab in parallel. let completed = 0; diff --git a/src/domains/setup.ts b/src/domains/setup.ts index bbd767f..2ddd739 100644 --- a/src/domains/setup.ts +++ b/src/domains/setup.ts @@ -31,8 +31,10 @@ export const setupTool = defineBrowserTool({ // ensureAlive:false — this tool IS the setup; it must bypass the socket guard ensureAlive: false, renderCall: () => new Text("🔧 Initializing browser...", 0, 0), - async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> { - const result = await performSetup(client); + async handler(_args, { client, extensionCtx }): Promise<Result<ToolOk, ToolErr>> { + // extensionCtx carries the UI, so the agent calling browser_setup gets the + // same first-run profile picker the user gets from /browser-setup. + const result = await performSetup(client, extensionCtx); if (result.success) { return ok({ text: result.data }); } diff --git a/src/index.ts b/src/index.ts index 7109ea0..e421511 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ * * Commands: * /browser-setup — guided setup wizard + * /browser-profile — choose which browser profile the agent uses * /browser-status — show client status and current page * /browser-reload-daemon — restart the browser client * /deep-research <q> — research a question on the web, write a cited report @@ -25,6 +26,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { type BrowserClient, createBrowserClient } from "./client"; import { getBrowserSystemPrompt } from "./prompt"; import { registerSetupCommand } from "./setup"; +import { registerProfileCommand } from "./profile/command"; +import { readPin } from "./profile/store"; import { registerDeepResearchCommand } from "./deep-research"; import { type BrowserState, defaultState, persistState, restoreState } from "./state"; import { registerAllTools } from "./registry"; @@ -57,8 +60,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { return; } const s = client.status(); + const pin = client.profilePin(); const lines = [ `Browser: ${s.alive ? "🟢 Connected" : "🔴 Disconnected"}`, + `Profile: ${pin ? pin.label : "not selected (uses whichever window is focused) — run /browser-profile"}`, `Session: ${s.sessionId ?? "none"}`, ]; if (s.remoteBrowserId) lines.push(`Browser ID: ${s.remoteBrowserId}`); @@ -102,6 +107,11 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { pi.on("session_start", async (_event, ctx) => { state = restoreState(ctx, state.namespace); + // The pinned profile lives on disk, not in the session, so it survives + // session termination. Re-read it every session start: another pi session + // may have changed it since this client was created. + const profilePin = await readPin(); + // Create the client lazily on first session. The daemon transport // is created but NOT connected — that only happens when the user // runs /browser-setup or a browser tool call finds an existing socket. @@ -117,6 +127,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { client = createBrowserClient({ namespace: state.namespace, transport, + profilePin, ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}), onOwnershipChange: (snap) => { state = { @@ -136,6 +147,10 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ } }, }); + } else { + // Client carried over from a previous session — adopt whatever pin is on + // disk now, which another pi session may have changed. + client.setProfilePin(profilePin); } // Do NOT call client.start() here — browser control is on-demand. @@ -147,6 +162,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { toolsRegistered = true; } registerSetupCommand(pi, client); + registerProfileCommand(pi, client); registerDeepResearchCommand(pi); }); diff --git a/src/profile/bind.ts b/src/profile/bind.ts new file mode 100644 index 0000000..aeb59d3 --- /dev/null +++ b/src/profile/bind.ts @@ -0,0 +1,138 @@ +/** + * Binding the harness session to a pinned profile. + * + * Sequence, all of it verified against a live browser: + * 1. resolve the browser binary and any explicit --user-data-dir + * 2. launch a window into the profile, marked with a file:// sentinel + * 3. poll Target.getTargets for the sentinel page — its browserContextId IS + * the profile's context, which is otherwise undiscoverable (CDP exposes no + * profile identity) + * 4. adopt that page as the harness window, blank it, drop the sentinel file + * + * If the sentinel never appears the bind FAILS LOUDLY. Falling through to a + * normal createTarget would put the agent in the focus-derived default profile + * — the exact bug this feature exists to remove — so the caller gets an + * actionable error instead. + * + * The resolved context id is session-scoped state, never persisted: Chrome + * mints new context ids on every browser run. + */ + +import type { BrowserClient } from "../client"; +import { type CdpError, cdpError } from "../cdp/errors"; +import { type Result, err, ok } from "../util/result"; +import { detectRunningBrowser, resolveBrowserExecutable } from "./browser-process"; +import { openProfileWindow } from "./launch"; +import type { ProfilePin } from "./store"; + +/** The launch delegates through ProcessSingleton, so allow for a cold profile. */ +const SEED_DEADLINE_MS = 15_000; +const SEED_POLL_MS = 500; + +type PageTarget = { + readonly targetId: string; + readonly type: string; + readonly url: string; + readonly browserContextId?: string; +}; + +const pageTargets = async (client: BrowserClient): Promise<ReadonlyArray<PageTarget>> => { + const r = await client.session().callBrowser("Target.getTargets"); + if (!r.success) return []; + const data = r.data as { targetInfos: ReadonlyArray<PageTarget> }; + return data.targetInfos.filter((t) => t.type === "page"); +}; + +const manualFallbackHint = (pin: ProfilePin): string => + `open a window for "${pin.label}" yourself (browser profile menu → ${pin.profileDir}), then retry. ` + + `Run /browser-profile to pick a different profile, or clear the selection to use whichever window is focused.`; + +/** + * Open (and adopt) a harness window inside the pinned profile. Returns the + * seed tab's target id. + */ +export const seedProfileWindow = async ( + client: BrowserClient, + pin: ProfilePin, +): Promise<Result<string, CdpError>> => { + // A pin belongs to one user-data-dir. If the harness is now attached to a + // different browser, silently seeding the "same" profile name would be wrong. + const connectedDir = client.userDataDir(); + if (connectedDir && connectedDir !== pin.userDataDir) { + return err(cdpError( + "discovery_failed", + `the selected profile "${pin.label}" belongs to ${pin.userDataDir}, but the harness is connected to ${connectedDir}. ` + + `Run /browser-profile to choose a profile in the browser you're using.`, + )); + } + + // Identify the browser by the dir we're connected to. With several Chromium + // browsers running, launching into the wrong one would open a window the + // harness cannot see — in a browser the user never pointed us at. + const targetUserDataDir = connectedDir ?? pin.userDataDir; + const running = await detectRunningBrowser(targetUserDataDir); + const exePath = await resolveBrowserExecutable(running); + if (!exePath) { + return err(cdpError( + "discovery_failed", + `could not locate the browser executable needed to open "${pin.label}" — ${manualFallbackHint(pin)}`, + )); + } + + const launched = await openProfileWindow({ + exePath, + profileDir: pin.profileDir, + // Forward the user-data-dir ONLY when the running browser advertises one. + // ProcessSingleton keys on this path: passing the value the target process + // itself reports guarantees delegation, whereas supplying a default dir we + // reconstructed could differ by case or normalisation and start a SECOND + // browser instead. + ...(running.explicitUserDataDir ? { explicitUserDataDir: running.explicitUserDataDir } : {}), + }); + if (!launched.success) { + return err(cdpError("discovery_failed", `${launched.error} — ${manualFallbackHint(pin)}`)); + } + + const { sentinelUrl, cleanup } = launched.data; + const deadline = Date.now() + SEED_DEADLINE_MS; + let seed: PageTarget | undefined; + while (Date.now() < deadline && !seed) { + await new Promise((r) => setTimeout(r, SEED_POLL_MS)); + seed = (await pageTargets(client)).find((t) => t.url === sentinelUrl); + } + + if (!seed) { + await cleanup(); + return err(cdpError( + "timeout", + `couldn't open a window in "${pin.label}" automatically — ${manualFallbackHint(pin)}`, + )); + } + + // The sentinel's context is the profile's context — the only way to learn it. + if (seed.browserContextId) client.setProfileContextId(seed.browserContextId); + + const ownership = client.ownership(); + ownership.setHarnessWindow(seed.targetId); + ownership.add(seed.targetId); + const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId: seed.targetId }); + if (win.success) { + const windowId = (win.data as { windowId?: number }).windowId; + if (typeof windowId === "number") ownership.setHarnessWindowId(windowId); + } + + // Blank the handshake page so the seed tab behaves like any other fresh tab, + // then remove the sentinel file. Best-effort: a failure here costs nothing + // beyond the placeholder page staying visible. + const attached = await client.session().callBrowser("Target.attachToTarget", { + targetId: seed.targetId, + flatten: true, + }); + if (attached.success) { + const sessionId = (attached.data as { sessionId: string }).sessionId; + await client.session().callOnTarget("Page.navigate", { url: "about:blank" }, sessionId); + } + await cleanup(); + + return ok(seed.targetId); +}; diff --git a/src/profile/browser-process.ts b/src/profile/browser-process.ts new file mode 100644 index 0000000..c0f6178 --- /dev/null +++ b/src/profile/browser-process.ts @@ -0,0 +1,371 @@ +/** + * Identifying the browser process the harness is attached to. + * + * Two questions, deliberately kept apart: + * + * 1. Is a Chromium-family browser RUNNING? (`detectRunningBrowser().running`) + * Only live-process evidence may answer this — an installed-but-closed + * browser must read as "not running", which is why the registry lookup + * below is never consulted here. + * + * 2. Which binary do we invoke to open a window for a chosen profile? + * (`resolveBrowserExecutable()`) Chromium's ProcessSingleton hands a second + * launch's argv to the already-running browser instead of starting another + * one — Windows via WM_COPYDATA (process_singleton_win.cc), Linux and macOS + * via the socket in the user-data-dir (process_singleton_posix.cc, which is + * compiled on macOS too). Here an installed path is a fine fallback. + * + * Whether the running browser carries an explicit `--user-data-dir` matters: + * ProcessSingleton keys on that directory, so passing a path that differs from + * the running browser's by case or normalisation would start a SECOND browser + * rather than delegating. The flag is forwarded only when the running process + * already has one. + * + * Every probe is best-effort; when nothing can be determined, profile launching + * degrades to a clear message rather than guessing. + * + * Windows note: `wmic` is deliberately unused — Microsoft removed it by default + * in Windows 11 24H2 and entirely in 25H2. PowerShell CIM is the supported + * replacement, with `tasklist` for liveness and the documented App Paths + * registry key for install location. + */ + +import { execFile } from "node:child_process"; +import { access, readFile, readlink } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { browserNameForUserDataDir } from "./paths"; + +// execFile, not exec: arguments are passed as an array, with no shell involved. +const run = promisify(execFile); +const EXEC_TIMEOUT_MS = 5_000; + +export type RunningBrowser = { + /** True when a Chromium-family browser process is live. */ + readonly running: boolean; + /** Absolute path to the running browser's binary, when determinable. */ + readonly exePath?: string; + /** Value of an explicit --user-data-dir on the running process, if any. */ + readonly explicitUserDataDir?: string; +}; + +/** Chromium-family process names, lowercase, as they appear per platform. */ +const LINUX_COMMS: ReadonlyArray<string> = [ + "chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", + "google-chrome", "brave", "brave-browser", +]; +const MAC_BROWSER_NAMES: ReadonlyArray<string> = [ + "google chrome", "chromium", "microsoft edge", "brave browser", +]; +const WIN_IMAGE_NAMES: ReadonlyArray<string> = ["chrome.exe", "msedge.exe", "brave.exe"]; + +/** + * Chromium tags every child process with `--type=<role>`; only the browser + * process omits it or carries `--type=browser`. Matching the flag (rather than + * words like "renderer" anywhere in the string) keeps a user whose install path + * contains such a word from being filtered out. + */ +const hasChildProcessType = (commandLine: string): boolean => { + const lower = commandLine.toLowerCase(); + return lower.includes("--type=") && !lower.includes("--type=browser"); +}; + +/** + * macOS names its child processes in the executable path itself ("Google Chrome + * Helper (Renderer)", crashpad, updaters), so the process NAME must be filtered + * too — the same exclusions the previous setup.ts check applied. + */ +const isMacChildProcessName = (comm: string): boolean => { + const lower = comm.toLowerCase(); + return ( + lower.includes("helper") || + lower.includes("renderer") || + lower.includes("crashpad") || + lower.includes(" gpu") || + lower.includes("updater") + ); +}; + +/** One running browser process, as much as the platform will tell us. */ +export type BrowserCandidate = { + readonly exePath?: string; + readonly explicitUserDataDir?: string; +}; + +/** Path comparison that tolerates separator and case differences per platform. */ +const samePath = (a: string, b: string): boolean => { + const norm = (p: string): string => { + const unified = p.replace(/\\/g, "/").replace(/\/+$/, ""); + return process.platform === "linux" ? unified : unified.toLowerCase(); + }; + return norm(a) === norm(b); +}; + +/** + * Rank a candidate against the user-data-dir the harness is actually connected + * to. Several Chromium browsers can run at once (Chrome plus Brave, or a second + * instance on a custom dir), and launching a profile window into the wrong one + * would open a window the harness cannot see — while disturbing a browser the + * user never pointed us at. + */ +export const rankBrowserCandidate = (candidate: BrowserCandidate, preferUserDataDir?: string): number => { + if (!preferUserDataDir) return 1; + if (candidate.explicitUserDataDir) { + // An explicit dir is decisive in both directions: exact match wins, and a + // different one means this is definitively another browser instance. + return samePath(candidate.explicitUserDataDir, preferUserDataDir) ? 4 : 0; + } + // No flag: the process uses its default dir. Prefer the one whose executable + // belongs to the same browser family as the target directory. + const family = browserNameForUserDataDir(preferUserDataDir).toLowerCase(); + const exe = (candidate.exePath ?? "").toLowerCase(); + if (!exe) return 1; + if (family === "brave" && exe.includes("brave")) return 3; + if (family === "edge" && (exe.includes("edge") || exe.includes("msedge"))) return 3; + if (family === "chromium" && exe.includes("chromium")) return 3; + if (family === "chrome" && exe.includes("chrome") && !exe.includes("chromium")) return 3; + return 1; +}; + +/** Extract `--user-data-dir=<path>` from a command line, quoted or bare. */ +export const parseUserDataDirFlag = (commandLine: string): string | undefined => { + const match = /--user-data-dir=(?:"([^"]*)"|'([^']*)'|(\S+))/.exec(commandLine); + if (!match) return undefined; + const value = match[1] ?? match[2] ?? match[3]; + return value && value.length > 0 ? value : undefined; +}; + +// ── Linux ────────────────────────────────────────────────────────────────── + +const collectLinux = async (): Promise<ReadonlyArray<BrowserCandidate>> => { + let pids: string[]; + try { + const { stdout } = await run("ps", ["-A", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS }); + pids = stdout + .split("\n") + .map((line) => line.trim()) + .flatMap((line) => { + const [pid, comm] = line.split(/\s+/, 2); + if (!pid || !comm) return []; + return LINUX_COMMS.includes(comm.toLowerCase()) ? [pid] : []; + }); + } catch { + return []; + } + + const candidates: BrowserCandidate[] = []; + for (const pid of pids) { + let cmdline: string; + try { + // /proc/<pid>/cmdline is NUL-separated argv. + cmdline = (await readFile(`/proc/${pid}/cmdline`, "utf8")).replace(/\0/g, " ").trim(); + } catch { + continue; + } + if (!cmdline || hasChildProcessType(cmdline)) continue; + let exePath: string | undefined; + try { + exePath = await readlink(`/proc/${pid}/exe`); + } catch { + // /proc/<pid>/exe is unreadable for processes we don't own; argv[0] is + // the next best thing. + exePath = cmdline.split(" ")[0]; + } + const explicit = parseUserDataDirFlag(cmdline); + candidates.push({ + ...(exePath ? { exePath } : {}), + ...(explicit ? { explicitUserDataDir: explicit } : {}), + }); + } + return candidates; +}; + +// ── macOS ────────────────────────────────────────────────────────────────── + +const collectDarwin = async (): Promise<ReadonlyArray<BrowserCandidate>> => { + let lines: string[]; + try { + // BSD ps prints the full executable path for `comm`, unlike Linux. + const { stdout } = await run("ps", ["-A", "-ww", "-o", "pid=,comm="], { timeout: EXEC_TIMEOUT_MS }); + lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean); + } catch { + return []; + } + + const candidates: BrowserCandidate[] = []; + for (const line of lines) { + const spaceAt = line.indexOf(" "); + if (spaceAt < 0) continue; + const pid = line.slice(0, spaceAt); + const comm = line.slice(spaceAt + 1).trim(); + const lower = comm.toLowerCase(); + if (!MAC_BROWSER_NAMES.some((name) => lower.includes(name))) continue; + if (isMacChildProcessName(comm)) continue; + + let args = ""; + try { + const { stdout } = await run("ps", ["-ww", "-o", "args=", "-p", pid], { timeout: EXEC_TIMEOUT_MS }); + args = stdout.trim(); + } catch { + // args are optional — only the --user-data-dir probe needs them + } + if (args && hasChildProcessType(args)) continue; + const explicit = args ? parseUserDataDirFlag(args) : undefined; + candidates.push({ + exePath: comm, + ...(explicit ? { explicitUserDataDir: explicit } : {}), + }); + } + return candidates; +}; + +// ── Windows ──────────────────────────────────────────────────────────────── + +const POWERSHELL_QUERY = [ + "$ErrorActionPreference='SilentlyContinue';", + "Get-CimInstance Win32_Process", + "-Filter \"Name='chrome.exe' or Name='msedge.exe' or Name='brave.exe'\"", + "| Select-Object ExecutablePath,CommandLine", + "| ConvertTo-Json -Compress", +].join(" "); + +type WinProcess = { ExecutablePath?: string | null; CommandLine?: string | null }; + +const collectWin32 = async (): Promise<ReadonlyArray<BrowserCandidate>> => { + try { + const { stdout } = await run( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_QUERY], + { timeout: EXEC_TIMEOUT_MS, windowsHide: true }, + ); + const trimmed = stdout.trim(); + if (trimmed) { + const parsed: unknown = JSON.parse(trimmed); + // ConvertTo-Json emits an object for a single row, an array for many. + const rows: WinProcess[] = Array.isArray(parsed) ? (parsed as WinProcess[]) : [parsed as WinProcess]; + const candidates = rows + .filter((row) => !hasChildProcessType(row.CommandLine ?? "")) + .map((row): BrowserCandidate => { + const explicit = parseUserDataDirFlag(row.CommandLine ?? ""); + return { + ...(row.ExecutablePath ? { exePath: row.ExecutablePath } : {}), + ...(explicit ? { explicitUserDataDir: explicit } : {}), + }; + }); + if (candidates.length > 0) return candidates; + } + } catch { + // PowerShell missing or blocked by policy — fall through to tasklist. + } + + // Liveness-only fallback: tasklist still ships on every supported Windows. + // It reveals no path, so the candidate is a bare "something is running". + try { + const { stdout } = await run("tasklist.exe", [], { timeout: EXEC_TIMEOUT_MS, windowsHide: true }); + const lower = stdout.toLowerCase(); + if (WIN_IMAGE_NAMES.some((n) => lower.includes(n))) return [{}]; + } catch { + // best-effort + } + return []; +}; + +/** Installed-browser locations to try when the live process reveals no path. */ +const winInstallCandidates = (): ReadonlyArray<string> => { + const roots = [ + process.env["PROGRAMFILES"], + process.env["PROGRAMFILES(X86)"], + process.env["LOCALAPPDATA"], + ].filter((r): r is string => typeof r === "string" && r.length > 0); + const suffixes = [ + "Google\\Chrome\\Application\\chrome.exe", + "Google\\Chrome Beta\\Application\\chrome.exe", + "Google\\Chrome SxS\\Application\\chrome.exe", + "Chromium\\Application\\chrome.exe", + "BraveSoftware\\Brave-Browser\\Application\\brave.exe", + "Microsoft\\Edge\\Application\\msedge.exe", + ]; + return roots.flatMap((root) => suffixes.map((suffix) => join(root, suffix))); +}; + +/** Query the Shell's App Paths registration for a browser's install location. */ +const winRegistryExecutable = async (): Promise<string | undefined> => { + for (const image of WIN_IMAGE_NAMES) { + for (const hive of ["HKLM", "HKCU"]) { + try { + const { stdout } = await run( + "reg.exe", + ["query", `${hive}\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${image}`, "/ve"], + { timeout: EXEC_TIMEOUT_MS, windowsHide: true }, + ); + const match = /REG_[A-Z_]+\s+(.+)$/m.exec(stdout.trim()); + const path = match?.[1]?.trim(); + if (path) return path; + } catch { + // key absent for this browser/hive — try the next + } + } + } + return undefined; +}; + +// ── Public API ───────────────────────────────────────────────────────────── + +/** + * Live-process facts about the running Chromium-family browser. `running` is + * derived only from process evidence, never from an install location. + * + * `preferUserDataDir` should be the dir the harness is connected to; with more + * than one browser running it decides which process is described. + */ +export const detectRunningBrowser = async (preferUserDataDir?: string): Promise<RunningBrowser> => { + const candidates = + process.platform === "darwin" + ? await collectDarwin() + : process.platform === "win32" + ? await collectWin32() + : await collectLinux(); + if (candidates.length === 0) return { running: false }; + + let best = candidates[0] as BrowserCandidate; + let bestRank = rankBrowserCandidate(best, preferUserDataDir); + for (const candidate of candidates.slice(1)) { + const rank = rankBrowserCandidate(candidate, preferUserDataDir); + if (rank > bestRank) { + best = candidate; + bestRank = rank; + } + } + + return { + running: true, + ...(best.exePath ? { exePath: best.exePath } : {}), + ...(best.explicitUserDataDir ? { explicitUserDataDir: best.explicitUserDataDir } : {}), + }; +}; + +/** True when a Chromium-family browser process is running. */ +export const isBrowserRunning = async (): Promise<boolean> => (await detectRunningBrowser()).running; + +/** + * The binary to invoke when opening a window for a chosen profile: the running + * browser's own executable when known, otherwise a documented install location. + * Undefined when nothing usable is found. + */ +export const resolveBrowserExecutable = async (running?: RunningBrowser): Promise<string | undefined> => { + const detected = running ?? (await detectRunningBrowser()); + if (detected.exePath) return detected.exePath; + if (process.platform !== "win32") return undefined; + + const fromRegistry = await winRegistryExecutable(); + if (fromRegistry) return fromRegistry; + for (const candidate of winInstallCandidates()) { + try { + await access(candidate); + return candidate; + } catch { + // not installed here + } + } + return undefined; +}; diff --git a/src/profile/command.ts b/src/profile/command.ts new file mode 100644 index 0000000..a49e35d --- /dev/null +++ b/src/profile/command.ts @@ -0,0 +1,168 @@ +/** + * /browser-profile — choose which browser profile the agent works in. + * + * The selection is durable (see store.ts) and applies immediately: the current + * harness tabs are closed and a new window is opened in the chosen profile, so + * the agent's next action happens where the user just asked for it rather than + * one session later. + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { BrowserClient } from "../client"; +import { discoverEndpoint } from "../cdp/discovery"; +import { type Result, err, ok } from "../util/result"; +import { type BrowserProfile, listProfiles } from "./list"; +import { browserNameForUserDataDir, userDataDirCandidates } from "./paths"; +import { pinFor, promptForProfile } from "./picker"; +import { clearPin, readPin, writePin } from "./store"; + +/** + * Which user-data-dir the picker should enumerate: the connected browser's + * when known, otherwise a running browser's, otherwise the only installed one + * that actually has profiles. + */ +export const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string, string>> => { + const connected = client.userDataDir(); + if (connected) return ok(connected); + + const endpoint = await discoverEndpoint(); + if (endpoint.success && endpoint.data.userDataDir) return ok(endpoint.data.userDataDir); + + const withProfiles: string[] = []; + for (const dir of userDataDirCandidates()) { + if ((await listProfiles(dir)).length > 0) withProfiles.push(dir); + } + const only = withProfiles[0]; + if (withProfiles.length === 1 && only !== undefined) return ok(only); + if (withProfiles.length === 0) { + return err("No browser profiles found on this machine. Open Chrome, Brave, or Edge and try again."); + } + return err( + `Found profiles for more than one browser (${withProfiles.map(browserNameForUserDataDir).join(", ")}). ` + + "Open the browser you want the agent to use, then run /browser-profile again.", + ); +}; + +/** Enumerate profiles for the resolved dir, or explain why we cannot. */ +const loadProfiles = async ( + client: BrowserClient, +): Promise<Result<{ userDataDir: string; profiles: ReadonlyArray<BrowserProfile> }, string>> => { + const dir = await resolveUserDataDir(client); + if (!dir.success) return dir; + const profiles = await listProfiles(dir.data); + if (profiles.length === 0) { + return err(`No profiles found in ${dir.data}. If the browser was just installed, open it once and retry.`); + } + return ok({ userDataDir: dir.data, profiles }); +}; + +/** + * Re-seed the live session so the new profile takes effect now. Closing the + * owned tabs and restarting the client makes start() open a window in the + * pinned profile and attach to it. + */ +const applyToLiveSession = async (client: BrowserClient): Promise<Result<void, string>> => { + if (!client.status().alive) return ok(undefined); + await client.closeOwnedTabs(); + await client.stop(); + const started = await client.start(); + if (!started.success) return err(started.error.message); + return ok(undefined); +}; + +export function registerProfileCommand(pi: ExtensionAPI, client: BrowserClient): void { + pi.registerCommand("browser-profile", { + description: "Choose which browser profile the agent uses", + handler: async (_args, ctx) => { + const loaded = await loadProfiles(client); + if (!loaded.success) { + ctx.ui.notify(loaded.error, "error"); + return; + } + if (!ctx.hasUI) { + ctx.ui.notify("Profile selection needs an interactive terminal.", "warning"); + return; + } + + const currentPin = client.profilePin() ?? (await readPin()); + const choice = await promptForProfile(ctx, loaded.data.profiles, currentPin); + + if (choice.kind === "cancelled") { + ctx.ui.notify("Profile unchanged.", "info"); + return; + } + + if (choice.kind === "clear") { + const cleared = await clearPin(); + if (!cleared.success) { + ctx.ui.notify(cleared.error, "error"); + return; + } + client.setProfilePin(null); + ctx.ui.notify( + "Profile selection cleared. New windows will open in whichever browser profile is focused.", + "info", + ); + return; + } + + const pin = pinFor(loaded.data.userDataDir, choice.profile); + const saved = await writePin(pin); + if (!saved.success) { + ctx.ui.notify(saved.error, "error"); + return; + } + client.setProfilePin(pin); + + const applied = await applyToLiveSession(client); + if (!applied.success) { + ctx.ui.notify( + `Saved ${pin.label}, but switching the open session failed: ${applied.error}`, + "warning", + ); + return; + } + ctx.ui.notify(`Browser profile: ${pin.label} ✓\nSaved — this profile is used until you change it.`, "info"); + }, + }); +} + +/** + * First-run prompt used by setup: pick a profile when none is pinned yet. + * Returns a note to append to the setup output; the caller always continues, + * because a cancelled or unavailable picker must leave existing behavior intact. + */ +export const promptForProfileIfUnset = async ( + client: BrowserClient, + ctx: ExtensionContext | undefined, +): Promise<string | undefined> => { + if (client.profilePin()) return undefined; + + const stored = await readPin(); + if (stored) { + client.setProfilePin(stored); + return undefined; + } + if (!ctx?.hasUI) { + return "No browser profile selected — using whichever window is focused. Run /browser-profile to pin one."; + } + + const loaded = await loadProfiles(client); + if (!loaded.success) return loaded.error; + + const choice = await promptForProfile( + ctx, + loaded.data.profiles, + null, + "Select the browser profile the agent should use", + ); + if (choice.kind !== "profile") { + return "No browser profile selected — using whichever window is focused. Run /browser-profile to pin one."; + } + + const pin = pinFor(loaded.data.userDataDir, choice.profile); + const saved = await writePin(pin); + if (!saved.success) return saved.error; + client.setProfilePin(pin); + return `Browser profile: ${pin.label}`; +}; 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(() => {}); + }, + }); +}; diff --git a/src/profile/list.ts b/src/profile/list.ts new file mode 100644 index 0000000..5a83654 --- /dev/null +++ b/src/profile/list.ts @@ -0,0 +1,171 @@ +/** + * Browser profile enumeration. + * + * A Chromium user-data-dir registers its profiles in `Local State` under the + * JSON key `profile.info_cache`, with `profile.profiles_order` giving the + * user's own ordering and `profile.last_used` naming the active one. Those + * keys are declared in Chromium's pref_names.h with no platform BUILDFLAG, so + * the layout is identical on Linux, macOS, and Windows (the C++ constant is + * now `kProfileAttributes`; the on-disk key never changed). + * + * Enumeration is two-tier so a Chromium fork or a future schema change cannot + * blank the list: + * tier 1 — `Local State` → `profile.info_cache` (authoritative: display + * names, accounts, ordering) + * tier 2 — scan subdirectories holding a `Preferences` file and read + * `profile.name` + `account_info[0].email` + * + * Nothing here throws: an unreadable or malformed file yields an empty list so + * callers degrade to the harness's pre-existing behavior. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export type BrowserProfile = { + /** Profile subdirectory name, e.g. "Default" or "Profile 1". */ + readonly dir: string; + /** Display name as shown in the browser's profile menu. */ + readonly name: string; + /** Signed-in account address, when the profile has one. */ + readonly email?: string; + /** Picker label — "Name (email)", or "Name (dir)" with no account. */ + readonly label: string; + /** True for the profile the browser reports as last used. */ + readonly lastUsed: boolean; +}; + +/** Directories inside a user-data-dir that are not user profiles. */ +const NON_PROFILE_DIRS: ReadonlySet<string> = new Set([ + "System Profile", + "Guest Profile", +]); + +/** + * Picker label. The bracketed half is the account address when there is one, + * and the profile directory otherwise, so every row keeps the same shape. + */ +export const formatProfileLabel = (name: string, email: string | undefined, dir: string): string => + `${name} (${email && email.length > 0 ? email : dir})`; + +const readJsonFile = async (path: string): Promise<Record<string, unknown> | null> => { + try { + const raw = await readFile(path, "utf8"); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record<string, unknown>; + } catch { + // Missing file, permission error, or malformed JSON — the caller falls + // back to the next tier, and ultimately to an empty list. + return null; + } +}; + +const asRecord = (v: unknown): Record<string, unknown> | null => + typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null; + +const asString = (v: unknown): string | undefined => + typeof v === "string" && v.length > 0 ? v : undefined; + +/** + * Order profiles the way the browser does: `profiles_order` first (in its own + * sequence), then anything unlisted, alphabetically. + */ +const orderDirs = (dirs: ReadonlyArray<string>, order: ReadonlyArray<string>): ReadonlyArray<string> => { + const known = order.filter((d) => dirs.includes(d)); + const rest = dirs.filter((d) => !known.includes(d)).sort((a, b) => a.localeCompare(b)); + return [...known, ...rest]; +}; + +const buildProfile = ( + dir: string, + name: string | undefined, + email: string | undefined, + lastUsed: boolean, +): BrowserProfile => { + const displayName = name ?? dir; + return { + dir, + name: displayName, + ...(email !== undefined ? { email } : {}), + label: formatProfileLabel(displayName, email, dir), + lastUsed, + }; +}; + +/** Tier 1 — `Local State` → `profile.info_cache`. */ +const listFromLocalState = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => { + const localState = await readJsonFile(join(userDataDir, "Local State")); + if (!localState) return []; + const profileNode = asRecord(localState["profile"]); + if (!profileNode) return []; + const infoCache = asRecord(profileNode["info_cache"]); + if (!infoCache) return []; + + const rawOrder = profileNode["profiles_order"]; + const order = Array.isArray(rawOrder) ? rawOrder.filter((v): v is string => typeof v === "string") : []; + const lastUsed = asString(profileNode["last_used"]); + + const entries: Array<{ dir: string; entry: Record<string, unknown> }> = []; + for (const [dir, value] of Object.entries(infoCache)) { + if (NON_PROFILE_DIRS.has(dir)) continue; + const entry = asRecord(value); + if (!entry) continue; + // Ephemeral (guest-like) profiles vanish when their window closes — never + // offer one as a durable pin. + if (entry["is_ephemeral"] === true) continue; + entries.push({ dir, entry }); + } + + const ordered = orderDirs(entries.map((e) => e.dir), order); + const byDir = new Map(entries.map((e) => [e.dir, e.entry])); + + return ordered.flatMap((dir) => { + const entry = byDir.get(dir); + if (!entry) return []; + // `name` is the user-visible profile name; `gaia_name` is the Google + // account's own display name and is the better fallback when a profile + // still carries an auto-generated name like "Person 1". + const name = asString(entry["name"]) ?? asString(entry["gaia_name"]); + const email = asString(entry["user_name"]); + return [buildProfile(dir, name, email, dir === lastUsed)]; + }); +}; + +/** Tier 2 — directory scan, reading each profile's own `Preferences`. */ +const listFromPreferencesScan = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => { + let dirents; + try { + dirents = await readdir(userDataDir, { withFileTypes: true }); + } catch { + return []; + } + + const candidates = dirents + .filter((d) => d.isDirectory() && !NON_PROFILE_DIRS.has(d.name)) + .map((d) => d.name) + .sort((a, b) => a.localeCompare(b)); + + const profiles: BrowserProfile[] = []; + for (const dir of candidates) { + const prefs = await readJsonFile(join(userDataDir, dir, "Preferences")); + if (!prefs) continue; // not a profile directory + const profileNode = asRecord(prefs["profile"]); + const name = profileNode ? asString(profileNode["name"]) : undefined; + const accounts = prefs["account_info"]; + const firstAccount = Array.isArray(accounts) ? asRecord(accounts[0]) : null; + const email = firstAccount ? asString(firstAccount["email"]) : undefined; + profiles.push(buildProfile(dir, name, email, false)); + } + return profiles; +}; + +/** + * Every selectable profile in a user-data-dir, best-effort. Returns an empty + * array when the directory is unreadable or holds no profiles. + */ +export const listProfiles = async (userDataDir: string): Promise<ReadonlyArray<BrowserProfile>> => { + const fromLocalState = await listFromLocalState(userDataDir); + if (fromLocalState.length > 0) return fromLocalState; + return listFromPreferencesScan(userDataDir); +}; diff --git a/src/profile/paths.ts b/src/profile/paths.ts new file mode 100644 index 0000000..095687e --- /dev/null +++ b/src/profile/paths.ts @@ -0,0 +1,166 @@ +/** + * Filesystem locations for browser profile discovery and pin persistence. + * + * Two independent path families live here: + * + * 1. Chromium user-data-dir candidates, per platform and channel. A + * user-data-dir holds `Local State` (the profile registry) and + * `DevToolsActivePort`; individual profiles are subdirectories of it + * (`Default`, `Profile 1`, …). + * Reference: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md + * + * 2. pi's agent config dir, where the profile pin is stored. + * + * On the agent dir: pi exports `getAgentDir()` from + * `@mariozechner/pi-coding-agent`, but importing it would be a RUNTIME import + * of the host package. Extensions installed via `pi install npm:...` do not + * get pi resolvable from their own node_modules (verified: the installed + * tree's `@mariozechner/` directory is empty), which is why every other import + * in this codebase is `import type` and erased at build time. So the three + * lines of `getAgentDir()` are replicated here instead — env var first, then + * `~/.pi/agent` (pi's dist/config.js: `PI_CODING_AGENT_DIR`, CONFIG_DIR_NAME + * `.pi`). + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; + +// ── pi agent config dir ──────────────────────────────────────────────────── + +/** pi's env override for the agent dir (APP_NAME.toUpperCase() + "_CODING_AGENT_DIR"). */ +const ENV_AGENT_DIR = "PI_CODING_AGENT_DIR"; + +/** + * Expand a leading `~` exactly as pi's expandTildePath does — `~` and `~/…` + * only, never `~\…`, on every platform. This deliberately mirrors pi rather + * than improving on it: the pin has to land in the directory pi itself resolves + * from the same variable, so being more lenient here would put the file + * somewhere pi does not look. + */ +const expandTildePiCompatible = (path: string): string => { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return homedir() + path.slice(1); + return path; +}; + +/** + * Tilde expansion for values this package interprets on its own. Windows users + * write `~\dir` as naturally as `~/dir`, and no compatibility constraint + * applies, so both are accepted. + */ +const expandTildeLenient = (path: string): string => { + if (path === "~") return homedir(); + if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2)); + return path; +}; + +/** pi's agent config directory — `$PI_CODING_AGENT_DIR` or `~/.pi/agent`. */ +export const agentDir = (): string => { + const fromEnv = process.env[ENV_AGENT_DIR]; + if (fromEnv) return expandTildePiCompatible(fromEnv); + return join(homedir(), ".pi", "agent"); +}; + +/** Where the selected-profile pin is persisted. */ +export const pinFilePath = (): string => join(agentDir(), "browser-harness.json"); + +// ── Chromium user-data-dir candidates ────────────────────────────────────── + +/** + * Windows resolves its per-user data root from %LOCALAPPDATA%, which can be + * redirected on roaming / managed accounts. Fall back to the conventional + * layout only when the variable is absent. + */ +const localAppData = (): string => process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local"); + +/** + * Every known default user-data-dir for Chromium-family browsers on the + * current platform. Order is stable but carries no priority — callers probe + * all of them. + * + * Includes paths the harness previously missed: snap Chromium (the default + * Chromium on Ubuntu) and the Linux-only `$CHROME_USER_DATA_DIR` override + * documented in user_data_dir.md. + */ +export const userDataDirCandidates = (): ReadonlyArray<string> => { + const home = homedir(); + const dirs: string[] = []; + + // A user-set override wins a spot at the front regardless of platform. + const envOverride = process.env["CHROME_USER_DATA_DIR"]; + if (envOverride) dirs.push(expandTildeLenient(envOverride)); + + if (process.platform === "darwin") { + const support = join(home, "Library", "Application Support"); + dirs.push( + join(support, "Google/Chrome"), + join(support, "Google/Chrome Beta"), + join(support, "Google/Chrome Dev"), + join(support, "Google/Chrome Canary"), + join(support, "Chromium"), + join(support, "BraveSoftware/Brave-Browser"), + join(support, "BraveSoftware/Brave-Browser-Beta"), + join(support, "BraveSoftware/Brave-Browser-Nightly"), + join(support, "BraveSoftware/Brave-Browser-Dev"), + join(support, "Microsoft Edge"), + join(support, "Microsoft Edge Beta"), + join(support, "Microsoft Edge Dev"), + join(support, "Microsoft Edge Canary"), + ); + } else if (process.platform === "win32") { + const lad = localAppData(); + dirs.push( + join(lad, "Google/Chrome/User Data"), + join(lad, "Google/Chrome Beta/User Data"), + join(lad, "Google/Chrome Dev/User Data"), + join(lad, "Google/Chrome SxS/User Data"), + join(lad, "Chromium/User Data"), + join(lad, "BraveSoftware/Brave-Browser/User Data"), + join(lad, "BraveSoftware/Brave-Browser-Beta/User Data"), + join(lad, "Microsoft/Edge/User Data"), + join(lad, "Microsoft/Edge Beta/User Data"), + join(lad, "Microsoft/Edge Dev/User Data"), + join(lad, "Microsoft/Edge SxS/User Data"), + ); + } else { + dirs.push( + join(home, ".config/google-chrome"), + join(home, ".config/google-chrome-beta"), + join(home, ".config/google-chrome-unstable"), + join(home, ".config/chromium"), + join(home, ".config/chromium-browser"), + join(home, ".config/BraveSoftware/Brave-Browser"), + join(home, ".config/BraveSoftware/Brave-Browser-Beta"), + join(home, ".config/BraveSoftware/Brave-Browser-Nightly"), + join(home, ".config/microsoft-edge"), + join(home, ".config/microsoft-edge-beta"), + join(home, ".config/microsoft-edge-dev"), + // snap (Ubuntu's default Chromium) — confined to ~/snap/<pkg>/common + join(home, "snap/chromium/common/chromium"), + // flatpak + join(home, ".var/app/org.chromium.Chromium/config/chromium"), + join(home, ".var/app/com.google.Chrome/config/google-chrome"), + join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"), + join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"), + ); + } + + // De-duplicate while preserving order — an env override may repeat a default. + return Array.from(new Set(dirs)); +}; + +/** + * Human-readable browser name for a user-data-dir, used to disambiguate the + * picker when more than one browser is installed. Falls back to the directory + * name, which is already descriptive for every path we generate. + */ +export const browserNameForUserDataDir = (userDataDir: string): string => { + const p = userDataDir.replace(/\\/g, "/"); + const has = (needle: string): boolean => p.toLowerCase().includes(needle); + if (has("brave")) return "Brave"; + if (has("edge")) return "Edge"; + if (has("chromium")) return "Chromium"; + if (has("chrome")) return "Chrome"; + const last = p.split("/").filter(Boolean).pop(); + return last ?? userDataDir; +}; diff --git a/src/profile/picker.ts b/src/profile/picker.ts new file mode 100644 index 0000000..b4df059 --- /dev/null +++ b/src/profile/picker.ts @@ -0,0 +1,73 @@ +/** + * The profile picker, shared by /browser-profile and first-run setup so both + * show the user exactly the same list. + * + * Rendering rules: + * - one row per profile, labelled "Name (email)" — or "Name (Profile 3)" when + * the profile has no signed-in account, so every row keeps its shape + * - the pinned profile is marked with a check + * - a final row clears the selection, since a pin must be undoable + * + * `ctx.ui.select` resolves to undefined when the user presses Escape AND in + * non-interactive modes (pi's print/RPC UI context returns undefined for every + * dialog), so callers must treat "no answer" as "change nothing". + */ + +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { BrowserProfile } from "./list"; +import type { ProfilePin } from "./store"; + +export const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —"; + +export type ProfileChoice = + | { readonly kind: "profile"; readonly profile: BrowserProfile } + | { readonly kind: "clear" } + | { readonly kind: "cancelled" }; + +/** + * Two profiles can legitimately share a name and account (the same person + * signed into two profiles). Labels must stay distinct or the selection cannot + * be mapped back, so a repeated label gains its directory. + */ +const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => { + const counts = new Map<string, number>(); + for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1); + return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label)); +}; + +/** + * Show the picker and report what the user chose. Returns "cancelled" when + * there is no UI or the user dismissed the dialog. + */ +export const promptForProfile = async ( + ctx: ExtensionContext, + profiles: ReadonlyArray<BrowserProfile>, + currentPin: ProfilePin | null, + title = "Select browser profile", +): Promise<ProfileChoice> => { + if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" }; + + const labels = disambiguate(profiles); + const rows = labels.map((label, i) => { + const isPinned = currentPin?.profileDir === profiles[i]?.dir; + return isPinned ? `✓ ${label}` : ` ${label}`; + }); + const options = [...rows, CLEAR_SELECTION_LABEL]; + + const answer = await ctx.ui.select(title, options); + if (answer === undefined) return { kind: "cancelled" }; + if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" }; + + const index = options.indexOf(answer); + const profile = index >= 0 ? profiles[index] : undefined; + if (!profile) return { kind: "cancelled" }; + return { kind: "profile", profile }; +}; + +/** Build the durable pin for a chosen profile. */ +export const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({ + userDataDir, + profileDir: profile.dir, + label: profile.label, + savedAt: new Date().toISOString(), +}); diff --git a/src/profile/store.ts b/src/profile/store.ts new file mode 100644 index 0000000..c8b8952 --- /dev/null +++ b/src/profile/store.ts @@ -0,0 +1,105 @@ +/** + * Durable persistence for the selected browser profile. + * + * The pin must outlive session termination, so it cannot live in pi's session + * entries (`pi.appendEntry`, see src/state.ts) — those die with the session. + * It is a small JSON file in pi's agent config dir instead, shared by every + * project on the machine. + * + * The pin deliberately stores only `{ userDataDir, profileDir }`. A + * `browserContextId` is NOT persisted: Chrome mints fresh context ids on every + * browser run, so a stored one would be a stale pointer at best and a pointer + * into the wrong profile at worst. The context is re-resolved on each connect. + * + * Every read failure degrades to "no pin" rather than throwing — a corrupt + * file must never break browser control. + */ + +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname } from "node:path"; +import { type Result, err, ok } from "../util/result"; +import { pinFilePath } from "./paths"; + +export type ProfilePin = { + /** User-data-dir the profile belongs to — guards against browser switches. */ + readonly userDataDir: string; + /** Profile subdirectory, e.g. "Profile 1". */ + readonly profileDir: string; + /** Label captured at selection time, for display without a disk re-read. */ + readonly label: string; + /** ISO timestamp of the selection. */ + readonly savedAt: string; +}; + +type PinFile = { + readonly version: 1; + readonly profile: ProfilePin | null; +}; + +const CURRENT_VERSION = 1; + +const parsePin = (value: unknown): ProfilePin | null => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const o = value as Record<string, unknown>; + const userDataDir = o["userDataDir"]; + const profileDir = o["profileDir"]; + const label = o["label"]; + const savedAt = o["savedAt"]; + if (typeof userDataDir !== "string" || userDataDir.length === 0) return null; + if (typeof profileDir !== "string" || profileDir.length === 0) return null; + return { + userDataDir, + profileDir, + label: typeof label === "string" && label.length > 0 ? label : profileDir, + savedAt: typeof savedAt === "string" ? savedAt : "", + }; +}; + +/** + * The persisted pin, or null when nothing is pinned, the file is missing, or + * it cannot be parsed. A file written by a newer version is treated as "no + * pin" rather than guessed at. + */ +export const readPin = async (): Promise<ProfilePin | null> => { + let raw: string; + try { + raw = await readFile(pinFilePath(), "utf8"); + } catch { + return null; + } + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + const file = parsed as Record<string, unknown>; + if (file["version"] !== CURRENT_VERSION) return null; + return parsePin(file["profile"]); + } catch { + return null; + } +}; + +/** + * Replace the pin atomically: a sibling temp file plus rename, so a crash + * mid-write can never leave a half-written pin behind. + */ +const writePinFile = async (profile: ProfilePin | null): Promise<Result<void, string>> => { + const target = pinFilePath(); + const tmp = `${target}.${randomUUID()}.tmp`; + const payload: PinFile = { version: CURRENT_VERSION, profile }; + try { + await mkdir(dirname(target), { recursive: true }); + await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await rename(tmp, target); + return ok(undefined); + } catch (e) { + await unlink(tmp).catch(() => {}); + return err(`could not save profile selection to ${target}: ${e instanceof Error ? e.message : String(e)}`); + } +}; + +/** Persist the selected profile. */ +export const writePin = (pin: ProfilePin): Promise<Result<void, string>> => writePinFile(pin); + +/** Clear the selection, restoring the harness's unpinned behavior. */ +export const clearPin = (): Promise<Result<void, string>> => writePinFile(null); diff --git a/src/setup.ts b/src/setup.ts index 40f501c..8c04030 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -4,11 +4,12 @@ * users and a tool (browser_setup) for the agent to self-recover. */ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { execSync } from "node:child_process"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { BrowserClient } from "./client"; import { ensureDaemon } from "./daemon/spawn"; import { DAEMON_SOCKET_PATH } from "./daemon/protocol"; +import { isBrowserRunning } from "./profile/browser-process"; +import { promptForProfileIfUnset } from "./profile/command"; // ── Public: register the /browser-setup slash command ────────────────────── @@ -16,7 +17,7 @@ export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): v pi.registerCommand("browser-setup", { description: "Connect pi to your Chrome browser", handler: async (_args, ctx) => { - const result = await performSetup(client); + const result = await performSetup(client, ctx); if (result.success) { ctx.ui.notify(result.data, "info"); } else { @@ -32,9 +33,9 @@ export type SetupResult = { success: true; data: string } | { success: false; er // ── Shared setup logic (used by both the command and the tool) ───────────── -export async function performSetup(client: BrowserClient): Promise<SetupResult> { +export async function performSetup(client: BrowserClient, ctx?: ExtensionContext): Promise<SetupResult> { // Step 1: Check Chrome is running - const chromeRunning = checkChromeRunning(); + const chromeRunning = await isBrowserRunning(); if (!chromeRunning) { return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." }; } @@ -45,7 +46,13 @@ export async function performSetup(client: BrowserClient): Promise<SetupResult> return { success: false, error: `Could not start the browser daemon. Check ${DAEMON_SOCKET_PATH}.` }; } - // Step 3: Connect to Chrome DevTools + // Step 3: Ask which profile to work in, the first time only. Enumeration + // reads DevToolsActivePort + Local State from disk, so it works before the + // connection exists. Declining leaves the pre-profile behavior untouched — + // this must never block setup. + const profileNote = await promptForProfileIfUnset(client, ctx); + + // Step 4: Connect to Chrome DevTools const startResult = await client.start(); if (!startResult.success) { const msg = startResult.error.message; @@ -73,7 +80,7 @@ export async function performSetup(client: BrowserClient): Promise<SetupResult> return { success: false, error: `Connection failed: ${msg}` }; } - // Step 4: Verify with test navigation + // Step 5: Verify with test navigation const tabResult = await client.newTab("https://github.com"); if (!tabResult.success) { return { success: false, error: `Browser connected but test navigation failed: ${tabResult.error.message}` }; @@ -85,52 +92,13 @@ export async function performSetup(client: BrowserClient): Promise<SetupResult> } const pageUrl = info.success && !("dialog" in info.data) ? info.data.url : "github.com"; - return { success: true, data: `Browser connected ✓\nNavigated to: ${pageUrl}` }; + const lines = ["Browser connected ✓", `Navigated to: ${pageUrl}`]; + if (profileNote) lines.push(profileNote); + return { success: true, data: lines.join("\n") }; } // ── Chrome process detection ─────────────────────────────────────────────── - -function checkChromeRunning(): boolean { - try { - if (process.platform === "darwin") { - // ps -o comm= returns the full executable path on modern macOS, e.g. - // "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser". We check - // for a known Chromium-family name anywhere in the path (case-insensitive), - // excluding helper/renderer/gpu/crashpad/updater subprocesses that linger - // after the user quits the browser. - const out = execSync("ps -A -o comm=", { timeout: 5000 }).toString().toLowerCase(); - const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); - const browserNames = ["google chrome", "chromium", "microsoft edge", "brave browser"]; - return lines.some((l) => { - if ( - l.includes("helper") || - l.includes("renderer") || - l.includes("crashpad") || - l.includes(" gpu") || - l.includes("updater") - ) return false; - return browserNames.some((name) => l.includes(name)); - }); - } else if (process.platform === "linux") { - const out = execSync("ps -A -o comm=,args=", { timeout: 5000 }).toString().toLowerCase(); - const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); - const browserComms = [ - "chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", - "google-chrome", "brave", "brave-browser", - ]; - return lines.some((line) => { - const parts = line.split(/\s+/); - const comm = parts[0] ?? ""; - if (!comm || !browserComms.includes(comm)) return false; - const isSubprocess = line.includes("--type=") && !line.includes("--type=browser"); - return !isSubprocess; - }); - } else if (process.platform === "win32") { - const out = execSync("tasklist", { timeout: 5000 }).toString().toLowerCase(); - return ["chrome.exe", "msedge.exe", "brave.exe"].some((n) => out.includes(n)); - } - } catch { - // best-effort - } - return false; -} +// +// Detection moved to src/profile/browser-process.ts, which answers the same +// "is a browser running?" question per platform and additionally reports the +// executable path and any explicit --user-data-dir that profile selection needs. 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); +}); diff --git a/test/profile/browser-process-test.ts b/test/profile/browser-process-test.ts new file mode 100644 index 0000000..29982e5 --- /dev/null +++ b/test/profile/browser-process-test.ts @@ -0,0 +1,108 @@ +/** + * Unit tests for browser-process parsing and candidate ranking — no browser. + * + * Ranking exists because a machine can run several Chromium browsers at once. + * Picking the wrong one means launching a profile window into a browser the + * harness is not connected to: the agent sees nothing, and a browser the user + * never pointed us at gets a new window. (That is exactly what happened during + * development before ranking existed.) + * + * Run: npx tsx test/profile/browser-process-test.ts + */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parseUserDataDirFlag, rankBrowserCandidate } from "../../src/profile/browser-process"; + +let passed = 0; +let failed = 0; +const check = (cond: boolean, label: string): void => { + if (cond) { + passed++; + console.log(` ✓ ${label}`); + } else { + failed++; + console.error(` ✗ ${label}`); + } +}; + +function main(): void { + // B1: --user-data-dir parsing + { + check( + parseUserDataDirFlag("/opt/google/chrome/chrome --user-data-dir=/tmp/x --foo") === "/tmp/x", + "B1: bare value parsed", + ); + check( + parseUserDataDirFlag('chrome.exe --user-data-dir="C:\\Users\\Some Name\\Data" --bar') === + "C:\\Users\\Some Name\\Data", + "B1: double-quoted value with spaces parsed", + ); + check( + parseUserDataDirFlag("chrome --user-data-dir='/home/u/my dir'") === "/home/u/my dir", + "B1: single-quoted value parsed", + ); + check(parseUserDataDirFlag("/opt/google/chrome/chrome") === undefined, "B1: absent flag → undefined"); + check(parseUserDataDirFlag("chrome --user-data-dir=") === undefined, "B1: empty value → undefined"); + } + + // B2: an explicit --user-data-dir is decisive in both directions + { + const target = "/tmp/pi-e2e-abc"; + const match = rankBrowserCandidate({ exePath: "/opt/google/chrome/chrome", explicitUserDataDir: target }, target); + const other = rankBrowserCandidate( + { exePath: "/opt/google/chrome/chrome", explicitUserDataDir: "/tmp/somewhere-else" }, + target, + ); + const noFlag = rankBrowserCandidate({ exePath: "/opt/google/chrome/chrome" }, target); + check(match > noFlag, "B2: exact user-data-dir match outranks a flagless process"); + check(other < noFlag, "B2: a DIFFERENT explicit user-data-dir ranks below a flagless process"); + check(other === 0, "B2: a different explicit dir is disqualified outright"); + } + + // B3: browser family match decides between flagless processes + { + const braveDir = join(homedir(), ".config/BraveSoftware/Brave-Browser"); + const brave = rankBrowserCandidate({ exePath: "/usr/bin/brave-browser" }, braveDir); + const chrome = rankBrowserCandidate({ exePath: "/opt/google/chrome/chrome" }, braveDir); + check(brave > chrome, "B3: Brave wins for a Brave user-data-dir"); + + const chromeDir = join(homedir(), ".config/google-chrome"); + check( + rankBrowserCandidate({ exePath: "/opt/google/chrome/chrome" }, chromeDir) > + rankBrowserCandidate({ exePath: "/usr/bin/brave-browser" }, chromeDir), + "B3: Chrome wins for a Chrome user-data-dir", + ); + + const chromiumDir = join(homedir(), ".config/chromium"); + check( + rankBrowserCandidate({ exePath: "/usr/bin/chromium" }, chromiumDir) > + rankBrowserCandidate({ exePath: "/opt/google/chrome/chrome" }, chromiumDir), + "B3: Chromium is not mistaken for Chrome", + ); + } + + // B4: no preference expressed → every candidate is equally acceptable + { + check( + rankBrowserCandidate({ exePath: "/usr/bin/brave-browser" }) === + rankBrowserCandidate({ exePath: "/opt/google/chrome/chrome" }), + "B4: without a target dir, candidates rank equally", + ); + } + + // B5: Windows path comparison ignores case and separator style + if (process.platform === "win32") { + check( + rankBrowserCandidate( + { exePath: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", explicitUserDataDir: "C:\\Users\\U\\Data" }, + "c:/users/u/data", + ) === 4, + "B5 win: case and separator differences still match", + ); + } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +main(); diff --git a/test/profile/list-test.ts b/test/profile/list-test.ts new file mode 100644 index 0000000..ca39b73 --- /dev/null +++ b/test/profile/list-test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for profile enumeration — no browser required. + * + * Builds throwaway user-data-dirs on disk so both tiers (Local State and the + * Preferences scan) run against real files. + * + * Run: npx tsx test/profile/list-test.ts + */ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { formatProfileLabel, listProfiles } from "../../src/profile/list"; + +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 roots: string[] = []; +const makeUdd = (): string => { + const dir = mkdtempSync(join(tmpdir(), "pi-profile-test-")); + roots.push(dir); + return dir; +}; +const writeLocalState = (udd: string, body: unknown): void => + writeFileSync(join(udd, "Local State"), typeof body === "string" ? body : JSON.stringify(body), "utf8"); +const writeProfileDir = (udd: string, dir: string, prefs?: unknown): void => { + mkdirSync(join(udd, dir), { recursive: true }); + if (prefs !== undefined) writeFileSync(join(udd, dir, "Preferences"), JSON.stringify(prefs), "utf8"); +}; + +async function main(): Promise<void> { + // L1: tier 1 — names, emails, ordering, last-used marker + { + const udd = makeUdd(); + writeLocalState(udd, { + profile: { + last_used: "Profile 1", + profiles_order: ["Default", "Profile 1", "Profile 2"], + info_cache: { + "Profile 2": { name: "Second", user_name: "second@example.com" }, + Default: { name: "vertexcover.io", user_name: "aman@vertexcover.io" }, + "Profile 1": { name: "Aman", user_name: "aman@personal.com" }, + }, + }, + }); + const profiles = await listProfiles(udd); + check(profiles.length === 3, "L1: all three profiles listed"); + check( + profiles.map((p) => p.dir).join(",") === "Default,Profile 1,Profile 2", + "L1: profiles_order drives ordering, not object key order", + ); + check(profiles[1]?.label === "Aman (aman@personal.com)", "L1: label is 'Name (email)'"); + check(profiles[1]?.lastUsed === true, "L1: last_used profile is marked"); + check(profiles[0]?.lastUsed === false, "L1: other profiles are not marked"); + } + + // L2: profile with no signed-in account falls back to the directory + { + const udd = makeUdd(); + writeLocalState(udd, { profile: { info_cache: { "Profile 3": { name: "Work" } } } }); + const profiles = await listProfiles(udd); + check(profiles[0]?.label === "Work (Profile 3)", "L2: no account → 'Name (dir)'"); + check(profiles[0]?.email === undefined, "L2: email stays undefined"); + } + + // L3: gaia_name backfills a missing name; ephemeral profiles are skipped + { + const udd = makeUdd(); + writeLocalState(udd, { + profile: { + info_cache: { + "Profile 4": { gaia_name: "Aman Kumar", user_name: "gaia@example.com" }, + "Profile 5": { name: "Ghost", user_name: "ghost@example.com", is_ephemeral: true }, + "System Profile": { name: "System" }, + }, + }, + }); + const profiles = await listProfiles(udd); + check(profiles.length === 1, "L3: ephemeral and System Profile entries are excluded"); + check(profiles[0]?.label === "Aman Kumar (gaia@example.com)", "L3: gaia_name backfills a missing name"); + } + + // L4: unlisted profiles are appended alphabetically after profiles_order + { + const udd = makeUdd(); + writeLocalState(udd, { + profile: { + profiles_order: ["Profile 9"], + info_cache: { + Default: { name: "D" }, + "Profile 9": { name: "Nine" }, + "Profile 3": { name: "Three" }, + }, + }, + }); + const profiles = await listProfiles(udd); + check( + profiles.map((p) => p.dir).join(",") === "Profile 9,Default,Profile 3", + "L4: ordered entries first, remainder alphabetical", + ); + } + + // L5: corrupt Local State falls through to the Preferences scan + { + const udd = makeUdd(); + writeLocalState(udd, "{ this is not json"); + writeProfileDir(udd, "Default", { + profile: { name: "Fallback Name" }, + account_info: [{ email: "fallback@example.com" }], + }); + writeProfileDir(udd, "Crash Reports"); // no Preferences → not a profile + const profiles = await listProfiles(udd); + check(profiles.length === 1, "L5: only directories holding Preferences count as profiles"); + check(profiles[0]?.label === "Fallback Name (fallback@example.com)", "L5: tier 2 reads name + account email"); + } + + // L6: missing info_cache also falls through to tier 2 + { + const udd = makeUdd(); + writeLocalState(udd, { profile: { last_used: "Default" } }); + writeProfileDir(udd, "Profile 1", { profile: { name: "Tier Two" } }); + const profiles = await listProfiles(udd); + check(profiles[0]?.label === "Tier Two (Profile 1)", "L6: missing info_cache → Preferences scan"); + } + + // L7: nothing readable → empty list, never a throw + { + const profiles = await listProfiles(join(tmpdir(), "pi-profile-does-not-exist-xyz")); + check(profiles.length === 0, "L7: unreadable user-data-dir yields an empty list"); + } + + // L8: label formatting is total + { + check(formatProfileLabel("A", "a@b.c", "Default") === "A (a@b.c)", "L8: email wins the brackets"); + check(formatProfileLabel("A", undefined, "Profile 7") === "A (Profile 7)", "L8: dir fills in without an email"); + check(formatProfileLabel("A", "", "Profile 7") === "A (Profile 7)", "L8: empty email is treated as absent"); + } + + for (const root of roots) rmSync(root, { recursive: true, force: true }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error("profile list test failed:", e); + process.exit(1); +}); diff --git a/test/profile/paths-test.ts b/test/profile/paths-test.ts new file mode 100644 index 0000000..1a2c937 --- /dev/null +++ b/test/profile/paths-test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for path resolution — runs on Linux, macOS, and Windows in CI. + * + * Only the branch for the host platform can be exercised, so each assertion is + * guarded by process.platform; the CI matrix is what gives all three coverage. + * + * Run: npx tsx test/profile/paths-test.ts + */ +import { homedir } from "node:os"; +import { join } from "node:path"; + +let passed = 0; +let failed = 0; +const check = (cond: boolean, label: string): void => { + if (cond) { + passed++; + console.log(` ✓ ${label}`); + } else { + failed++; + console.error(` ✗ ${label}`); + } +}; + +/** Re-import with a fresh module identity so env changes are picked up. */ +const freshImport = async (): Promise<typeof import("../../src/profile/paths")> => + import(`../../src/profile/paths?cache=${Math.random()}`); + +const withEnv = async (key: string, value: string | undefined, fn: () => Promise<void>): Promise<void> => { + const saved = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + try { + await fn(); + } finally { + if (saved === undefined) delete process.env[key]; + else process.env[key] = saved; + } +}; + +async function main(): Promise<void> { + // A1: agent dir default and override + await withEnv("PI_CODING_AGENT_DIR", undefined, async () => { + const { agentDir, pinFilePath } = await freshImport(); + check(agentDir() === join(homedir(), ".pi", "agent"), "A1: default agent dir is ~/.pi/agent"); + check(pinFilePath() === join(homedir(), ".pi", "agent", "browser-harness.json"), "A1: pin file sits in it"); + }); + // pi's own expandTildePath only understands "~/" — never "~\" — so the agent + // dir must resolve identically or the pin lands where pi does not look. + await withEnv("PI_CODING_AGENT_DIR", "~/custom-agent", async () => { + check( + (await freshImport()).agentDir() === `${homedir()}/custom-agent`, + "A1: '~/' in the agent-dir override expands like pi", + ); + }); + if (process.platform === "win32") { + await withEnv("PI_CODING_AGENT_DIR", "~\\custom-agent", async () => { + check( + (await freshImport()).agentDir() === "~\\custom-agent", + "A1 win: '~\\' is left alone, matching pi's own resolution", + ); + }); + } + + // A2: platform candidate list + await withEnv("CHROME_USER_DATA_DIR", undefined, async () => { + const dirs = (await freshImport()).userDataDirCandidates(); + check(dirs.length > 0, "A2: candidates are non-empty on this platform"); + check(new Set(dirs).size === dirs.length, "A2: candidate list is de-duplicated"); + + if (process.platform === "linux") { + check(dirs.includes(join(homedir(), ".config/google-chrome")), "A2 linux: Chrome stable dir present"); + check(dirs.includes(join(homedir(), "snap/chromium/common/chromium")), "A2 linux: snap Chromium dir present"); + check(dirs.includes(join(homedir(), ".config/BraveSoftware/Brave-Browser")), "A2 linux: Brave dir present"); + } else if (process.platform === "darwin") { + const support = join(homedir(), "Library", "Application Support"); + check(dirs.includes(join(support, "Google/Chrome")), "A2 macos: Chrome dir present"); + check(dirs.includes(join(support, "BraveSoftware/Brave-Browser")), "A2 macos: Brave dir present"); + check(dirs.includes(join(support, "Microsoft Edge")), "A2 macos: Edge dir present"); + } else if (process.platform === "win32") { + const lad = process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local"); + check(dirs.includes(join(lad, "Google/Chrome/User Data")), "A2 win: Chrome dir derives from %LOCALAPPDATA%"); + check(dirs.includes(join(lad, "Microsoft/Edge/User Data")), "A2 win: Edge dir present"); + } + }); + + // A3: %LOCALAPPDATA% redirection is honoured (Windows-only behaviour) + if (process.platform === "win32") { + await withEnv("LOCALAPPDATA", "D:\\Redirected\\Local", async () => { + const dirs = (await freshImport()).userDataDirCandidates(); + check( + dirs.some((d) => d.startsWith("D:\\Redirected\\Local") || d.startsWith("D:/Redirected/Local")), + "A3 win: redirected LOCALAPPDATA is used", + ); + }); + } + + // A4: $CHROME_USER_DATA_DIR override leads the list. This value is ours to + // interpret, so both tilde separators are accepted. + await withEnv("CHROME_USER_DATA_DIR", join("~", "my-chrome-data"), async () => { + const dirs = (await freshImport()).userDataDirCandidates(); + check(dirs[0] === join(homedir(), "my-chrome-data"), "A4: env override is first and tilde-expanded"); + }); + await withEnv("CHROME_USER_DATA_DIR", "~/my-chrome-data", async () => { + const dirs = (await freshImport()).userDataDirCandidates(); + check(dirs[0] === join(homedir(), "my-chrome-data"), "A4: forward-slash tilde expands on every platform"); + }); + + // A5: browser naming + { + const { browserNameForUserDataDir } = await freshImport(); + check(browserNameForUserDataDir("/home/u/.config/google-chrome") === "Chrome", "A5: Chrome recognised"); + check( + browserNameForUserDataDir("/home/u/.config/BraveSoftware/Brave-Browser") === "Brave", + "A5: Brave recognised", + ); + check( + browserNameForUserDataDir("C:\\Users\\u\\AppData\\Local\\Microsoft\\Edge\\User Data") === "Edge", + "A5: Edge recognised through Windows separators", + ); + check(browserNameForUserDataDir("/home/u/.config/chromium") === "Chromium", "A5: Chromium beats Chrome"); + } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error("profile paths test failed:", e); + process.exit(1); +}); diff --git a/test/profile/store-test.ts b/test/profile/store-test.ts new file mode 100644 index 0000000..c881372 --- /dev/null +++ b/test/profile/store-test.ts @@ -0,0 +1,116 @@ +/** + * Unit tests for the profile pin store — no browser required. + * + * Redirects pi's agent dir via $PI_CODING_AGENT_DIR so the real + * ~/.pi/agent/browser-harness.json is never touched. + * + * Run: npx tsx test/profile/store-test.ts + */ +import { mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +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 pin = { + userDataDir: "/home/u/.config/google-chrome", + profileDir: "Profile 1", + label: "Aman (aman@example.com)", + savedAt: new Date(0).toISOString(), +}; + +async function main(): Promise<void> { + const sandbox = mkdtempSync(join(tmpdir(), "pi-pin-test-")); + process.env["PI_CODING_AGENT_DIR"] = sandbox; + + // Imported after the env override so paths.ts resolves into the sandbox. + const { pinFilePath } = await import("../../src/profile/paths"); + const { readPin, writePin, clearPin } = await import("../../src/profile/store"); + + // P1: agent dir honours the env override + check(pinFilePath() === join(sandbox, "browser-harness.json"), "P1: pin path follows $PI_CODING_AGENT_DIR"); + + // P2: nothing written yet + check((await readPin()) === null, "P2: absent file reads as no pin"); + + // P3: round trip + { + const written = await writePin(pin); + check(written.success, "P3: write succeeds"); + const read = await readPin(); + check(read?.profileDir === "Profile 1", "P3: profileDir round-trips"); + check(read?.userDataDir === pin.userDataDir, "P3: userDataDir round-trips"); + check(read?.label === pin.label, "P3: label round-trips"); + } + + // P4: no temp files survive an atomic write + check(readdirSync(sandbox).filter((f) => f.endsWith(".tmp")).length === 0, "P4: write leaves no .tmp files"); + + // P5: overwrite replaces rather than appends + { + await writePin({ ...pin, profileDir: "Default", label: "Work (work@example.com)" }); + const read = await readPin(); + check(read?.profileDir === "Default", "P5: second write replaces the first"); + check(readdirSync(sandbox).filter((f) => f === "browser-harness.json").length === 1, "P5: one pin file on disk"); + } + + // P6: clear + { + const cleared = await clearPin(); + check(cleared.success, "P6: clear succeeds"); + check((await readPin()) === null, "P6: cleared pin reads as none"); + } + + // P7: corrupt file degrades to no pin + writeFileSync(pinFilePath(), "{ not json at all", "utf8"); + check((await readPin()) === null, "P7: malformed JSON reads as no pin"); + + // P8: unknown schema version is not guessed at + writeFileSync(pinFilePath(), JSON.stringify({ version: 99, profile: pin }), "utf8"); + check((await readPin()) === null, "P8: future version reads as no pin"); + + // P9: structurally invalid pin is rejected + writeFileSync(pinFilePath(), JSON.stringify({ version: 1, profile: { profileDir: 42 } }), "utf8"); + check((await readPin()) === null, "P9: wrong field types read as no pin"); + + // P10: a missing agent dir is created on write + { + rmSync(sandbox, { recursive: true, force: true }); + const written = await writePin(pin); + check(written.success, "P10: write recreates a missing agent dir"); + check((await readPin())?.profileDir === "Profile 1", "P10: pin readable after dir recreation"); + } + + // P11: an unwritable location surfaces as an error result, not a throw + { + const blocked = join(sandbox, "blocked"); + mkdirSync(blocked, { recursive: true }); + writeFileSync(join(blocked, "browser-harness.json"), "{}", "utf8"); + // Point the agent dir at a FILE — mkdir/write must fail cleanly. + process.env["PI_CODING_AGENT_DIR"] = join(blocked, "browser-harness.json"); + const { writePin: writeAgain } = await import(`../../src/profile/store?nocache=${Date.now()}`); + const written = await writeAgain(pin); + check(!written.success, "P11: write into an invalid dir returns an error result"); + process.env["PI_CODING_AGENT_DIR"] = sandbox; + } + + rmSync(sandbox, { recursive: true, force: true }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error("profile store test failed:", e); + process.exit(1); +}); |
