diff options
Diffstat (limited to 'src/profile')
| -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 |
8 files changed, 1282 insertions, 0 deletions
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); |
