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