Ctrl K
ComunidadGuías…

server/browser-sites.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// agent-browser keeps a session's cookies and storage in the running Chrome, in its kept profile, and in the restore file
// it autosaves (`<restore key>-<session>.json.enc`, cli/src/native/state.rs: AES-256-GCM under SHA-256 of the key text,
// 12-byte nonce first, 16-byte tag last); signing out clears all three. The key stays in this server process; no bot or page ever sees it.
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { browserRestoreKey, browserSessionsDirectory, closeBrowserSession, inRunningBrowser, startBrowserSession } from "./browser-engine.ts";
import { browserRuntimeEnv } from "./browser-runtime.ts";

export interface SignedInSite {
  /** As wide as the browser's own records go: a cookie for `.google.com` makes `google.com` the site of `accounts.google.com`. */
  site: string;
  cookies: number;
  storage: number;
}

export interface SignedInSites {
  running: boolean;
  sites: SignedInSite[];
}

export interface BrowserSitesTarget {
  binaryPath: string;
  session: string;
  env: Record<string, string | undefined>;
  encryptionKey: string;
}

export interface Cdp {
  send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<Record<string, unknown>>;
  close(): void;
}

export interface CdpEvent { method: string; params?: Record<string, unknown>; sessionId?: string }
export interface CdpListeners { event?(message: CdpEvent): void; closed?(): void }

export interface BrowserSitesDeps {
  engine?: (target: BrowserSitesTarget, args: string[]) => Promise<Record<string, unknown> | null>;
  connect?: (url: string) => Promise<Cdp>;
  /** Starts the stopped browser of a session with a kept profile, so a sign-out reaches the profile too. */
  start?: (target: BrowserSitesTarget) => Promise<boolean>;
  stop?: (target: BrowserSitesTarget) => Promise<boolean>;
}

interface Cookie { name: string; domain: string; path: string; partitionKey?: unknown }
interface SavedOrigin { origin: string; localStorage?: unknown; sessionStorage?: unknown }
interface SavedState { cookies: unknown[]; origins: unknown[] }

const EMPTY_STATE: SavedState = { cookies: [], origins: [] };
// Everything an origin keeps but its cookies (deleted one by one) and its
// tabs' sessionStorage (cleared in the open pages).
const ORIGIN_STORAGE = "local_storage,indexeddb,websql,cache_storage,service_workers,file_systems";

function stateKey(key: string): Buffer {
  return createHash("sha256").update(key).digest();
}

export function decryptBrowserState(data: Buffer, key: string): string {
  if (data.length < 29) throw new Error("Saved browser state is too short.");
  const decipher = createDecipheriv("aes-256-gcm", stateKey(key), data.subarray(0, 12));
  decipher.setAuthTag(data.subarray(data.length - 16));
  return Buffer.concat([decipher.update(data.subarray(12, data.length - 16)), decipher.final()]).toString("utf8");
}

export function encryptBrowserState(text: string, key: string): Buffer {
  const nonce = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", stateKey(key), nonce);
  const body = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
  return Buffer.concat([nonce, body, cipher.getAuthTag()]);
}

function isCookie(value: unknown): value is Cookie {
  const cookie = value as Partial<Cookie> | null;
  return typeof cookie?.name === "string" && typeof cookie.domain === "string" && typeof cookie.path === "string";
}

function isOrigin(value: unknown): value is SavedOrigin {
  return typeof (value as Partial<SavedOrigin> | null)?.origin === "string";
}

function hostOfCookie(cookie: Cookie): string {
  return cookie.domain.replace(/^\./u, "").toLowerCase();
}

function hostOfOrigin(origin: string): string | null {
  try { return new URL(origin).hostname.toLowerCase().replace(/^\[|\]$/gu, "") || null; }
  catch { return null; }
}

function hasStorage(origin: SavedOrigin): boolean {
  return [origin.localStorage, origin.sessionStorage].some((entries) => Array.isArray(entries) && entries.length > 0);
}

/** No suffix list is needed: a browser never stores a cookie for `co.uk`. */
function siteOf(host: string, hosts: ReadonlySet<string>): string {
  if (/^[\d.]+$|:/u.test(host)) return host;
  const labels = host.split(".");
  for (let count = 2; count < labels.length; count += 1) {
    const candidate = labels.slice(-count).join(".");
    if (hosts.has(candidate)) return candidate;
  }
  return host;
}

function inSite(host: string, site: string): boolean {
  return host === site || host.endsWith(`.${site}`);
}

export function signedInSites(cookies: readonly unknown[], origins: readonly unknown[]): SignedInSite[] {
  const cookieHosts = cookies.filter(isCookie).map(hostOfCookie);
  const storageHosts = origins.filter(isOrigin).filter(hasStorage).flatMap((origin) => hostOfOrigin(origin.origin) ?? []);
  const hosts = new Set([...cookieHosts, ...storageHosts].filter(Boolean));
  const sites = new Map<string, SignedInSite>();
  const entry = (host: string) => {
    const site = siteOf(host, hosts);
    let found = sites.get(site);
    if (!found) sites.set(site, found = { site, cookies: 0, storage: 0 });
    return found;
  };
  for (const host of cookieHosts) if (host) entry(host).cookies += 1;
  for (const host of storageHosts) entry(host).storage += 1;
  return [...sites.values()].sort((a, b) => a.site.localeCompare(b.site));
}

function withoutSite(state: SavedState, site: string): SavedState {
  return {
    ...state,
    cookies: state.cookies.filter((cookie) => !isCookie(cookie) || !inSite(hostOfCookie(cookie), site)),
    origins: state.origins.filter((origin) => {
      const host = isOrigin(origin) ? hostOfOrigin(origin.origin) : null;
      return !host || !inSite(host, site);
    }),
  };
}

function stateBases(session: string): string[] {
  return [`${browserRestoreKey(session)}-${session}`, `${session}-${session}`];
}

function readState(path: string, key: string): SavedState {
  const raw = readFileSync(path);
  const value: unknown = JSON.parse(path.endsWith(".enc") ? decryptBrowserState(raw, key) : raw.toString("utf8"));
  const state = value as Partial<SavedState> | null;
  if (!state || !Array.isArray(state.cookies) || !Array.isArray(state.origins)) throw new Error("Not saved browser state.");
  return state as SavedState;
}

function modified(path: string): number {
  try { return statSync(path).mtimeMs; } catch { return 0; }
}

/** agent-browser restores the newest file under the session's restore key; an unreadable file restores nothing. */
function savedState(directory: string, session: string, key: string): SavedState {
  for (const base of stateBases(session)) {
    const path = [".json.enc", ".json"].map((suffix) => join(directory, base + suffix)).filter(existsSync)
      .sort((a, b) => modified(b) - modified(a))[0];
    if (!path) continue;
    try { return readState(path, key); }
    catch { return EMPTY_STATE; }
  }
  return EMPTY_STATE;
}

function writeState(path: string, state: SavedState, key: string): void {
  const text = JSON.stringify(state, null, 2);
  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
  writeFileSync(temporary, path.endsWith(".enc") ? encryptBrowserState(text, key) : text, { mode: 0o600 });
  try { renameSync(temporary, path); }
  catch (error) { try { unlinkSync(temporary); } catch {} throw error; }
}

/** Synchronous from read to rename, so no other cleanup interleaves; the `.previous` backups agent-browser leaves mid-save
 * hold the old logins and go too. */
function rewriteSavedState(directory: string, session: string, key: string, site: string | null): void {
  for (const base of stateBases(session)) {
    for (const suffix of [".json.enc", ".json"]) {
      const path = join(directory, base + suffix);
      if (existsSync(path)) {
        let state: SavedState = EMPTY_STATE;
        if (site !== null) {
          try { state = withoutSite(readState(path, key), site); }
          catch { continue; }
        }
        writeState(path, state, key);
      }
      try { unlinkSync(`${path}.previous`); }
      catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
    }
  }
}

export function connectCdp(url: string, timeoutMs = 10_000, listeners: CdpListeners = {}): Promise<Cdp> {
  return new Promise((resolve, reject) => {
    const socket = new WebSocket(url);
    const pending = new Map<number, { done: (value: Record<string, unknown>) => void; fail: (error: Error) => void }>();
    let next = 1;
    const failAll = (error: Error) => { for (const call of pending.values()) call.fail(error); pending.clear(); };
    const opening = setTimeout(() => { socket.close(); reject(new Error("The running browser did not answer.")); }, timeoutMs);
    socket.addEventListener("error", () => {
      clearTimeout(opening);
      reject(new Error("Could not reach the running browser."));
      failAll(new Error("The browser connection failed."));
    });
    socket.addEventListener("close", () => {
      failAll(new Error("The browser connection closed."));
      listeners.closed?.();
    });
    socket.addEventListener("message", (event) => {
      let message: { id?: number; method?: unknown; params?: Record<string, unknown>; sessionId?: string; result?: Record<string, unknown>; error?: { message?: string } };
      try { message = JSON.parse(String(event.data)); } catch { return; }
      if (message.id === undefined && typeof message.method === "string") {
        listeners.event?.({ method: message.method, params: message.params, sessionId: message.sessionId });
        return;
      }
      const call = typeof message.id === "number" ? pending.get(message.id) : undefined;
      if (!call) return;
      pending.delete(message.id!);
      if (message.error) call.fail(new Error(message.error.message || "The browser refused a request."));
      else call.done(message.result ?? {});
    });
    socket.addEventListener("open", () => {
      clearTimeout(opening);
      resolve({
        send: (method, params = {}, sessionId) => new Promise((done, fail) => {
          if (socket.readyState !== WebSocket.OPEN) return fail(new Error("The browser connection closed."));
          const id = next++;
          const timer = setTimeout(() => { pending.delete(id); fail(new Error("The running browser did not answer.")); }, timeoutMs);
          pending.set(id, {
            done: (value) => { clearTimeout(timer); done(value); },
            fail: (error) => { clearTimeout(timer); fail(error); },
          });
          socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
        }),
        close: () => socket.close(),
      });
    });
  });
}

interface PageTarget { targetId: string; url: string }

async function pages(cdp: Cdp): Promise<PageTarget[]> {
  const { targetInfos } = await cdp.send("Target.getTargets") as { targetInfos?: Array<{ type?: string; targetId?: string; url?: string }> };
  return (targetInfos ?? []).flatMap((target) => target.type === "page" && typeof target.targetId === "string"
    ? [{ targetId: target.targetId, url: typeof target.url === "string" ? target.url : "" }] : []);
}

/** Cookies are read and deleted through a page, in the bot's own browser context. */
async function onPage<T>(cdp: Cdp, target: PageTarget, act: (sessionId: string) => Promise<T>): Promise<T> {
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId: target.targetId, flatten: true }) as { sessionId?: string };
  if (typeof sessionId !== "string") throw new Error("Could not open the running browser's page.");
  try { return await act(sessionId); }
  finally { await cdp.send("Target.detachFromTarget", { sessionId }).catch(() => undefined); }
}

function originOf(url: string): string | null {
  try {
    const parsed = new URL(url);
    return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
  } catch { return null; }
}

async function signOutLive(cdp: Cdp, site: string | null, live: SavedState): Promise<void> {
  const open = await pages(cdp);
  const first = open[0];
  if (!first) throw new Error("The running browser has no page open. Close it and try again.");
  const matches = (host: string | null) => host !== null && (site === null || inSite(host, site));
  const origins = new Set<string>();
  await onPage(cdp, first, async (sessionId) => {
    const { cookies } = await cdp.send("Network.getAllCookies", {}, sessionId) as { cookies?: unknown[] };
    const doomed = (cookies ?? []).filter(isCookie).filter((cookie) => matches(hostOfCookie(cookie)));
    for (const cookie of doomed) for (const scheme of ["https", "http"]) origins.add(`${scheme}://${hostOfCookie(cookie)}`);
    if (site === null) await cdp.send("Network.clearBrowserCookies", {}, sessionId);
    else {
      for (const cookie of doomed) {
        await cdp.send("Network.deleteCookies", {
          name: cookie.name, domain: cookie.domain, path: cookie.path,
          ...(cookie.partitionKey ? { partitionKey: cookie.partitionKey } : {}),
        }, sessionId);
      }
    }
    for (const origin of live.origins.filter(isOrigin)) if (matches(hostOfOrigin(origin.origin))) origins.add(origin.origin);
    for (const page of open) {
      const origin = originOf(page.url);
      if (origin && matches(hostOfOrigin(origin))) origins.add(origin);
    }
    for (const origin of origins) await cdp.send("Storage.clearDataForOrigin", { origin, storageTypes: ORIGIN_STORAGE }, sessionId);
  });
  for (const page of open) {
    const origin = originOf(page.url);
    if (!origin || !matches(hostOfOrigin(origin))) continue;
    await onPage(cdp, page, (sessionId) => cdp.send("Runtime.evaluate", { expression: "try { sessionStorage.clear() } catch {}" }, sessionId));
  }
}

const sessionEnv = (target: BrowserSitesTarget) => browserRuntimeEnv({ ...target.env, AGENT_BROWSER_SESSION: target.session });
const defaultEngine = (target: BrowserSitesTarget, args: string[]) => inRunningBrowser(target.binaryPath, sessionEnv(target), args);
const defaultStart = (target: BrowserSitesTarget) => startBrowserSession(target.binaryPath, sessionEnv(target));
const defaultStop = (target: BrowserSitesTarget) => closeBrowserSession(target.binaryPath, sessionEnv(target));

function keptProfile(target: BrowserSitesTarget): boolean {
  const dir = target.env.AGENT_BROWSER_PROFILE;
  return !!dir && existsSync(dir);
}

async function runningState(target: BrowserSitesTarget, deps: BrowserSitesDeps): Promise<SavedState | null> {
  const scratch = mkdtempSync(join(tmpdir(), "omb-browser-state-"));
  try {
    const path = join(scratch, "state.json");
    const answer = await (deps.engine ?? defaultEngine)(target, ["state", "save", path]);
    if (!answer) return null;
    const written = [`${path}.enc`, path].find(existsSync);
    if (!written) throw new Error("The browser did not save its sign-ins.");
    return readState(written, target.encryptionKey);
  } finally { rmSync(scratch, { recursive: true, force: true }); }
}

function directoryOf(target: BrowserSitesTarget): string {
  return browserSessionsDirectory(browserRuntimeEnv({ ...target.env, AGENT_BROWSER_SESSION: target.session }));
}

// One listing or sign-out at a time per session, so two clicks cannot interleave live deletes and file rewrites.
const queues = new Map<string, Promise<unknown>>();
function serially<T>(session: string, work: () => Promise<T>): Promise<T> {
  const previous = queues.get(session) ?? Promise.resolve();
  const run = previous.then(work, work);
  const settled = run.catch(() => undefined);
  queues.set(session, settled);
  void settled.then(() => { if (queues.get(session) === settled) queues.delete(session); });
  return run;
}

export function listSignedInSites(target: BrowserSitesTarget, deps: BrowserSitesDeps = {}): Promise<SignedInSites> {
  return serially(target.session, () => visit(target, deps, null, false));
}

/** The running browser first, then the saved state, so the next launch restores neither. */
export function signOutOfSites(target: BrowserSitesTarget, site: string | null, deps: BrowserSitesDeps = {}): Promise<SignedInSites> {
  return serially(target.session, () => visit(target, deps, site, true));
}

async function visit(target: BrowserSitesTarget, deps: BrowserSitesDeps, site: string | null, signOut: boolean): Promise<SignedInSites> {
  const directory = directoryOf(target);
  const listed = (sites: SignedInSite[]) => site === null || sites.some((entry) => entry.site === site);
  const stopped = (): SignedInSites => {
    const before = savedState(directory, target.session, target.encryptionKey);
    const sites = signedInSites(before.cookies, before.origins);
    if (!signOut || !listed(sites)) return { running: false, sites };
    rewriteSavedState(directory, target.session, target.encryptionKey, site);
    const after = savedState(directory, target.session, target.encryptionKey);
    return { running: false, sites: signedInSites(after.cookies, after.origins) };
  };
  let live = await runningState(target, deps);
  const starting = !live && signOut && keptProfile(target);
  if (starting) {
    if (!await (deps.start ?? defaultStart)(target)) throw new Error("The browser could not start to sign out. Try again.");
    live = await runningState(target, deps);
  }
  try {
    if (!live) return stopped();
    const result = await signOutLiveState(target, deps, site, signOut, live, stopped);
    return starting ? { ...result, running: false } : result;
  } finally {
    if (starting) await (deps.stop ?? defaultStop)(target).catch(() => false);
  }
}

async function signOutLiveState(target: BrowserSitesTarget, deps: BrowserSitesDeps, site: string | null, signOut: boolean, live: SavedState, stopped: () => SignedInSites): Promise<SignedInSites> {
  const directory = directoryOf(target);
  const listed = (sites: SignedInSite[]) => site === null || sites.some((entry) => entry.site === site);
  const sites = signedInSites(live.cookies, live.origins);
  if (!signOut || !listed(sites)) return { running: true, sites };
  const answer = await (deps.engine ?? defaultEngine)(target, ["get", "cdp-url"]);
  if (!answer) return stopped();
  const url = answer.cdpUrl;
  // Only the engine's own Chrome on this machine is ever addressed.
  if (typeof url !== "string" || !/^wss?:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+\//u.test(url)) {
    throw new Error("The running browser did not give its DevTools address. Close it and try again.");
  }
  const cdp = await (deps.connect ?? connectCdp)(url);
  try { await signOutLive(cdp, site, live); }
  finally { cdp.close(); }
  rewriteSavedState(directory, target.session, target.encryptionKey, site);
  const after = await runningState(target, deps) ?? EMPTY_STATE;
  return { running: true, sites: signedInSites(after.cookies, after.origins) };
}