import { isIP } from "node:net"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { CookieImportError, parseCookieNames } from "./chromium-import.ts"; import { MAX_COOKIE_ORIGINS, MAX_URL_CHARS } from "./constants.ts"; import type { confirmCookieAccessWithUI } from "./cookie-consent.ts"; export type CookieConfirmation = (title: string, message: string, signal?: AbortSignal) => Promise; import { normalizeHostname, validateBrowserUrl } from "./gate.ts"; import type { BrowserSession, SessionResult } from "./session.ts"; type CookieAccessSession = Pick; export type CookieAccessParams = { origins?: string[]; cookieNames?: string[] }; export function exactOrigins(input: unknown): string[] { if (!Array.isArray(input) || input.length === 0 || input.length > MAX_COOKIE_ORIGINS) { throw new Error(`Cookie requests require 1..${MAX_COOKIE_ORIGINS} exact HTTP(S) origins`); } return [...new Set(input.map((origin) => { try { if (typeof origin !== "string" || origin.length > MAX_URL_CHARS || /[\s\p{C}]/u.test(origin) || !/^https?:\/\/[^/?#\\]+\/?$/i.test(origin)) throw new Error(); const url = new URL(origin); if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error(); // URL accepts some non-DNS host characters, including decoded commas. Reject // them before displaying a supposedly exact destination in the consent dialog. const host = normalizeHostname(url.hostname); if (!isIP(host) && (host.length > 253 || !host.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))) throw new Error(); return url.origin; } catch { throw new Error("Cookie access requires exact HTTP(S) origins, not paths, credentials or wildcards"); } }))]; } function notGranted(status: "denied" | "cancelled"): SessionResult { return { content: `Cookie access ${status}. No new access granted. Do not retry without the user's direction.`, details: { status } }; } /** Caller holds the session lock. No cookie inventory/source reads occur before approval. */ export async function requestCookieAccessWithUI( session: CookieAccessSession, ctx: ExtensionContext, params: CookieAccessParams, signal?: AbortSignal, options: { confirm?: typeof confirmCookieAccessWithUI } = {}, ): Promise { if (!ctx?.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) { throw new Error("Cookie access requires an interactive approval dialog (TUI or RPC). No access granted."); } return requestCookieAccess(session, params, async (title, message, signal) => { const confirm = options.confirm ?? (await import("./cookie-consent.ts")).confirmCookieAccessWithUI; return confirm(ctx, title, message, signal); }, signal); } /** Transport-independent consent flow. Caller holds the session lock. */ export async function requestCookieAccess( session: CookieAccessSession, params: CookieAccessParams, confirm: CookieConfirmation, signal?: AbortSignal, options: { refresh?: boolean } = {}, ): Promise { if (signal?.aborted) return notGranted("cancelled"); // Copy and validate inputs before awaiting the dialog; later argument mutations cannot widen approval. const requestedOrigins = exactOrigins(params.origins); const cookieNames = parseCookieNames(params.cookieNames); if (!options.refresh && session.hasCookieAccess(requestedOrigins, cookieNames)) { return { content: `Cookie access already approved for ${requestedOrigins.join(", ")} this session. Reusing the existing isolated profile; no cookies re-imported. Tabs and current grants unchanged. Ready for browser navigation.`, details: { status: "granted", origins: requestedOrigins, cookieNames, reused: true }, }; } const approved = await confirm("Allow browser cookie access for this session?", `Source: Chromium's Linux Default profile (read-only snapshot).\n` + `Destinations (exact origins):\n${requestedOrigins.map((origin) => ` ${origin}`).join("\n")}\n` + `Cookie names: ${cookieNames ? cookieNames.join(", ") : "ALL cookies matching these destinations"}.\n\n` + "Only cookies whose own domain matches a destination are imported; cookies are never reassigned to another domain. " + "Name filters limit the imported snapshot, not cookies the site creates later.\n\n" + "The agent can act as you on these origins for this session. Requests within this scope reuse approval without another prompt or import. " + "New origins or broader cookie-name access need approval. This replaces the current browser profile/grants and closes its tabs. " + "Cookie values are not shown to the model. The temporary copy is deleted on /browser logout (or Revoke access in the MCP app), /reload or shutdown.", signal); if (signal?.aborted) return notGranted("cancelled"); if (approved !== true) return notGranted("denied"); // Validate each approved origin directly; never reparse an array as comma-separated // command input, which could turn one displayed destination into multiple grants. for (const origin of requestedOrigins) { if (signal?.aborted) return notGranted("cancelled"); try { const validated = await validateBrowserUrl(origin, { signal }); if (validated.origin !== origin) throw new Error("Cookie access origin changed during validation"); } catch (error) { if (signal?.aborted) return notGranted("cancelled"); throw error; } } const origins = requestedOrigins; if (signal?.aborted) return notGranted("cancelled"); try { await session.closeBrowser(); await session.clearGrants(); if (signal?.aborted) throw new Error("cancelled"); const count = await session.importChromiumCookies(origins, { cookieNames, signal }); if (signal?.aborted) throw new Error("cancelled"); return { content: `Cookie access granted for ${origins.join(", ")}. Loaded ${count} cookies into an isolated profile. Previous grants replaced. Ready for browser navigation.`, details: { status: "granted", origins, cookieNames, cookieCount: count }, }; } catch (error) { try { try { await session.closeBrowser(); } finally { await session.clearGrants(); } } catch { throw new Error("Cookie access cleanup failed. Retry /browser logout before requesting access again."); } if (signal?.aborted) return notGranted("cancelled"); if (error instanceof CookieImportError) throw new CookieImportError(error.code); // Never relay browser/SQLite/keyring errors or source details to the model. throw new Error("Chromium cookie access failed. Check Node >=22.19.0, matching Default-profile cookies and the unlocked desktop keyring, or use /browser login."); } }