aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-07-25 00:23:05 +0530
committerGitHub <noreply@github.com>2026-07-25 00:23:05 +0530
commit6dd79f7c3bafacba54acb2840f8655fcb05318bf (patch)
tree1570768785ae836717d3dbb2ff42fe0a13f19585
parent0fc1ece6fa4a2f24e87eb4a66eb828a9928e30f7 (diff)
feat: pin the agent to a chosen browser profile (#16)
* feat: pin the agent to a chosen browser profile (#15) Harness tabs were created with a bare Target.createTarget, which places them in Chrome's defaultBrowserContextId — and that value follows window focus. Which profile the agent acted as therefore depended on which browser window the user last clicked, so the same task could run as a work account on one run and a personal account on the next. /browser-profile lists the connected browser's profiles as "Name (email)" and pins one to ~/.pi/agent/browser-harness.json, so the choice outlives the session. The same picker appears on first browser_setup when nothing is pinned; with no interactive UI, or on cancel, setup continues with a note and the previous behavior. Chrome offers no direct route to another profile: Target.createTarget rejects a foreign browserContextId, and openerId does not inherit the opener's context. The window is opened through the browser's own command line (--profile-directory, handed to the running browser by Chromium's ProcessSingleton) and identified by a unique file:// sentinel page, since a bare about:blank#token argument is discarded. Further tabs come from window.open evaluated with userGesture: true — the only CDP-reachable way to place a tab in a non-default browser context — correlated by a unique token so concurrent isolated tabs stay distinct. A failed handshake is reported, never silently redirected to another profile. All four tab-creation sites now funnel through cdp/target-factory, so open_urls and the isolated tabs behind web_search / read_page cannot run with a different profile's cookies than the visible tabs. Also fixed along the way: snap Chromium is discovered, Windows uses %LOCALAPPDATA%, browsers launched with an explicit --user-data-dir are found, the running browser is chosen by ranking candidates against the connected user-data-dir rather than taking the first process found, and an installed-but-closed browser no longer reads as running. CI runs the profile suites and a real-browser end-to-end test on ubuntu, macOS, and Windows — profile discovery is the one part of the harness whose behavior genuinely differs per OS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: single-flight harness window creation Isolated tabs run concurrently (browser_read_page is registered unserialized), so several could each find "no harness window" and create one — two blank windows before this feature, two LAUNCHED profile windows after it. ensureHarnessWindow now shares one in-flight creation per client. Sharing introduces a second hazard: freshlyCreated is a claim on the seed tab, and if every waiter received it, they would all drive the same tab. Only the caller that started the creation keeps the claim; the rest open their own tab beside it. Covered by test/manual/profile-concurrency-test.ts (five concurrent isolated tabs, pinned and unpinned: distinct targets, one window, correct profile), wired into CI on Linux only — the coordination is pure JS and identical across platforms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: tilde expansion differs by purpose on Windows CI on windows-latest caught this: join("~", "x") yields "~\x" there, and expandTilde only handled "~/". The two uses are not the same, so they no longer share a helper. The agent dir stays byte-compatible with pi's own expandTildePath ("~/" only, every platform) — being more lenient would put the pin file somewhere pi never looks. $CHROME_USER_DATA_DIR is ours to interpret, so it accepts "~\" as well, which is what a Windows user would type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
-rw-r--r--.github/workflows/ci.yml60
-rw-r--r--CHANGELOG.md22
-rw-r--r--README.md20
-rw-r--r--package.json2
-rw-r--r--skills/pi-browser-harness/SKILL.md15
-rw-r--r--src/cdp/discovery.ts96
-rw-r--r--src/cdp/session.ts19
-rw-r--r--src/cdp/target-factory.ts223
-rw-r--r--src/client.ts138
-rw-r--r--src/domains/isolated-tab.ts27
-rw-r--r--src/domains/navigate.ts31
-rw-r--r--src/domains/setup.ts6
-rw-r--r--src/index.ts16
-rw-r--r--src/profile/bind.ts138
-rw-r--r--src/profile/browser-process.ts371
-rw-r--r--src/profile/command.ts168
-rw-r--r--src/profile/launch.ts90
-rw-r--r--src/profile/list.ts171
-rw-r--r--src/profile/paths.ts166
-rw-r--r--src/profile/picker.ts73
-rw-r--r--src/profile/store.ts105
-rw-r--r--src/setup.ts74
-rw-r--r--test/manual/profile-concurrency-test.ts192
-rw-r--r--test/manual/profile-e2e-test.ts295
-rw-r--r--test/profile/browser-process-test.ts108
-rw-r--r--test/profile/list-test.ts156
-rw-r--r--test/profile/paths-test.ts130
-rw-r--r--test/profile/store-test.ts116
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
diff --git a/README.md b/README.md
index e0fce44..e6d96ef 100644
--- a/README.md
+++ b/README.md
@@ -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);
+});