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