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/profile/list.ts | |
| 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/profile/list.ts')
| -rw-r--r-- | src/profile/list.ts | 171 |
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); +}; |
