From 6dd79f7c3bafacba54acb2840f8655fcb05318bf Mon Sep 17 00:00:00 2001 From: Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:23:05 +0530 Subject: feat: pin the agent to a chosen browser profile (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/profile/command.ts | 168 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/profile/command.ts (limited to 'src/profile/command.ts') 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> => { + 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 }, 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> => { + 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 => { + 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}`; +}; -- cgit v1.2.3