Ctrl K
ComunidadGuías…

server/browser-processes.ts

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

Archivo original ↗Preguntar a KiBot
// agent-browser 0.37 starts each daemon in a session of its own (setsid) and each Chrome in its own process group, so neither
// stops with the server, and a killed daemon leaves its Chrome running, re-parented to init. Every process is recorded with
// its start time, so a pid the system has since given to another program is never signalled.
import { execFile } from "node:child_process";
import { readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, unlinkSync } from "node:fs";
import { hostname } from "node:os";
import { basename, isAbsolute, join, sep, win32 } from "node:path";

/** `started` tells a process from a later one that reused its pid; a zombie has exited and counts as gone. */
type ListedProcess = { ppid: number; pgid: number; zombie: boolean; started: string; args?: string };

export interface BrowserProcessTree {
  daemon: number;
  /** pid → start time; start times are empty where the system gives none (Windows). */
  members: Map<number, string>;
  /** Process groups a member leads; a helper Chrome started after the snapshot joins one. */
  groups: number[];
  /** Chrome's temporary profile folders, which the daemon removes only when it closes Chrome itself. */
  profiles?: string[];
  /** Windows gives no start times: the pid counts as the daemon only while it runs this image. */
  image?: string;
}

/** agent-browser's Chrome profile folder; only an absolute path of exactly this name, from Chrome's own arguments, is removed. */
const TEMPORARY_PROFILE = /^agent-browser-chrome-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
function temporaryProfile(args: string | undefined): string | null {
  const dir = args?.split(" ").find((arg) => arg.startsWith("--user-data-dir="))?.slice("--user-data-dir=".length);
  return dir && isAbsolute(dir) && TEMPORARY_PROFILE.test(basename(dir)) ? dir : null;
}

/** macOS keeps ps at /bin/ps; a server started with a bare PATH (a test fixture's, a service's) still finds it there. */
const PS = process.platform === "darwin" ? "/bin/ps" : "ps";

/** The C locale keeps `ps` start times in one fixed five-word form. */
function execText(file: string, args: string[], env: NodeJS.ProcessEnv = {}, timeout = 5_000): Promise<string | null> {
  return new Promise((done) => {
    execFile(file, args, { encoding: "utf8", timeout, windowsHide: true, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, LC_ALL: "C", ...env } }, (error, stdout) => done(error ? null : stdout));
  });
}

export function processAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch (error) {
    return (error as NodeJS.ErrnoException).code === "EPERM";
  }
}

function linuxArgs(pid: number): string {
  try { return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean).join(" "); }
  catch { return ""; }
}

async function processArgs(pid: number): Promise<string | null> {
  if (process.platform === "linux") return linuxArgs(pid) || null;
  return (await execText(PS, ["-ww", "-o", "args=", "-p", String(pid)]))?.trim() || null;
}

/** Linux reads /proc (an image without procps still works), one file at a time so a busy host keeps its descriptors. */
async function processTable(withArgs = false): Promise<Map<number, ListedProcess> | null> {
  if (process.platform === "win32") return null;
  const table = new Map<number, ListedProcess>();
  if (process.platform === "linux") {
    let names: string[];
    try { names = readdirSync("/proc"); } catch { return null; }
    for (const name of names) {
      if (!/^\d+$/.test(name)) continue;
      try {
        const stat = readFileSync(`/proc/${name}/stat`, "utf8");
        // The command name may hold spaces and parentheses; the fields follow its last ')'.
        const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
        table.set(Number(name), { ppid: Number(fields[1]), pgid: Number(fields[2]), zombie: fields[0] === "Z" || fields[0] === "X", started: fields[19] ?? "", ...withArgs ? { args: linuxArgs(Number(name)) } : {} });
      } catch {}
    }
    return table;
  }
  // lstart is always five words (Tue Oct 13 23:48:58 2026); args follow.
  const listing = await execText(PS, ["-A", "-ww", "-o", `pid=,ppid=,pgid=,stat=,lstart=${withArgs ? ",args=" : ""}`]);
  if (listing === null) return null;
  for (const line of listing.split("\n")) {
    const [pid, ppid, pgid, stat, ...rest] = line.trim().split(/\s+/);
    if (!pid || !Number.isInteger(Number(pid)) || !Number.isInteger(Number(ppid)) || !Number.isInteger(Number(pgid))) continue;
    table.set(Number(pid), { ppid: Number(ppid), pgid: Number(pgid), zombie: stat?.startsWith("Z") === true, started: rest.slice(0, 5).join(" "), ...withArgs ? { args: rest.slice(5).join(" ") } : {} });
  }
  return table;
}

async function processEnvironment(pid: number): Promise<Map<string, string> | null> {
  let entries: string[];
  if (process.platform === "linux") {
    try { entries = readFileSync(`/proc/${pid}/environ`, "utf8").split("\0"); } catch { return null; }
  } else {
    // ps -E appends the environment to the arguments, space separated.
    const listing = await execText(PS, ["-E", "-ww", "-o", "command=", "-p", String(pid)]);
    if (listing === null) return null;
    entries = listing.trim().split(/\s+/);
  }
  return new Map(entries.flatMap((entry) => {
    const match = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(entry);
    return match ? [[match[1]!, match[2]!] as const] : [];
  }));
}

async function imageOf(pid: number): Promise<string | null> {
  const row = await execText("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]);
  return row === null ? null : /^"([^"]*)"/.exec(row.trim())?.[1]?.toLowerCase() ?? "";
}

/** agent-browser starts a daemon through the binary's resolved path with AGENT_BROWSER_DAEMON=1 and the session's name, so a
 * pid now running another program, command or session is not it. Read before the close: afterwards Chrome is re-parented. */
export async function browserProcessTree(binaryPath: string, daemon: number, session: string): Promise<BrowserProcessTree | null> {
  if (!Number.isInteger(daemon) || daemon <= 1 || daemon === process.pid || !processAlive(daemon)) return null;
  const paths = new Set([binaryPath]);
  try { paths.add(realpathSync(binaryPath)); } catch {}
  if (process.platform === "win32") {
    // Windows shows no environments, trees or start times: the image tells the daemon from a later program, and taskkill /T walks the tree.
    const image = await imageOf(daemon);
    return image && [...paths].some((path) => basename(path).toLowerCase() === image) ? { daemon, members: new Map([[daemon, ""]]), groups: [], image } : null;
  }
  const command = await processArgs(daemon);
  if (!command || ![...paths].some((path) => command === path || command.startsWith(`${path} `))) return null;
  const env = await processEnvironment(daemon);
  if (env?.get("AGENT_BROWSER_DAEMON") !== "1" || env.get("AGENT_BROWSER_SESSION") !== session) return null;
  const table = await processTable();
  if (!table?.has(daemon)) return null;
  const children = new Map<number, number[]>();
  for (const [pid, entry] of table) children.set(entry.ppid, [...children.get(entry.ppid) ?? [], pid]);
  const members = new Map<number, string>();
  const walk = (pid: number) => {
    members.set(pid, table.get(pid)!.started);
    for (const child of children.get(pid) ?? []) if (!members.has(child)) walk(child);
  };
  walk(daemon);
  const own = table.get(process.pid)?.pgid;
  const groups = [...members.keys()].filter((pid) => table.get(pid)!.pgid === pid && pid !== own);
  const profiles: string[] = [];
  for (const group of groups) {
    const profile = group === daemon ? null : temporaryProfile(await processArgs(group) ?? undefined);
    if (profile) profiles.push(profile);
  }
  return { daemon, members, groups, profiles };
}

/** Whether a Chrome's arguments name a profile folder inside `root`; a path may hold spaces where `ps` joins arguments with them. */
function profileUnder(args: string, root: string): boolean {
  return args.includes(`--user-data-dir=${root.replace(/[\\/]+$/, "")}${sep}`);
}

/** Chromes that outlived their daemon. agent-browser starts Chrome on an `agent-browser-chrome-` profile, or a saved session's
 * own profile under `profiles`, in its own group, and Chrome inherits AGENT_BROWSER_DAEMON=1 and the session's name. Only a
 * temporary profile is ever listed for removal. POSIX only: Windows lists no environments. */
export async function orphanedBrowsers(home: string | undefined, profiles?: string): Promise<Array<{ session: string; tree: BrowserProcessTree }>> {
  const table = await processTable(true);
  if (!table) return [];
  const found: Array<{ session: string; tree: BrowserProcessTree }> = [];
  for (const [pid, entry] of table) {
    const args = entry.args?.split(" ") ?? [];
    const onProfile = args.some((arg) => arg.startsWith("--user-data-dir=") && arg.includes("agent-browser-chrome-")) || (!!profiles && profileUnder(entry.args ?? "", profiles));
    if (entry.zombie || entry.pgid !== pid || !args.includes("--remote-debugging-port=0") || !onProfile) continue;
    // A daemon runs as agent-browser (or its platform build) with no arguments.
    const parent = table.get(entry.ppid);
    if (parent && !parent.zombie && entry.ppid > 1 && (/(?:^|\/)agent-browser[^/\s]*$/.test(parent.args ?? "") || (await processEnvironment(entry.ppid))?.get("AGENT_BROWSER_DAEMON") === "1")) continue;
    const env = await processEnvironment(pid);
    const session = env?.get("AGENT_BROWSER_SESSION");
    if (env?.get("AGENT_BROWSER_DAEMON") !== "1" || !session || !/^[A-Za-z0-9_.-]{1,96}$/.test(session) || (home && env.get("HOME") !== home)) continue;
    const profile = temporaryProfile(entry.args);
    found.push({ session, tree: { daemon: pid, members: new Map([[pid, entry.started]]), groups: [pid], profiles: profile ? [profile] : [] } });
  }
  return found;
}

/** The lock a daemon's own Chrome held when last checked, and that daemon: while both live, a call lists no processes. */
const heldProfiles = new Map<string, { target: string; daemon: number }>();

function namesProfile(args: string, dir: string): boolean {
  const flag = `--user-data-dir=${dir}`;
  for (let at = args.indexOf(flag); at >= 0; at = args.indexOf(flag, at + 1)) {
    if (at + flag.length === args.length || args[at + flag.length] === " ") return true;
  }
  return false;
}

async function daemonOf(pid: number, table: Map<number, ListedProcess>): Promise<number | null> {
  for (let parent = table.get(pid)?.ppid ?? 0, depth = 0; parent > 1 && depth < 3; parent = table.get(parent)?.ppid ?? 0, depth++) {
    if (table.get(parent)?.zombie) return null;
    if ((await processEnvironment(parent))?.get("AGENT_BROWSER_DAEMON") === "1") return parent;
  }
  return null;
}

/** POSIX Chrome locks its profile with a SingletonLock symlink to `<host>-<pid>`. A later launch on the profile hands itself
 * to the Chrome the lock names and exits, and refuses a lock that names another host (a renamed Mac, a moved Cloud volume).
 * A Chrome whose daemon was killed keeps running, re-parented, and would take every later launch, so it is stopped; then the
 * lock goes. A daemon's own Chrome keeps it. The profile's contents are never touched. */
export async function releaseBrowserProfile(dir: string | undefined, binaryPath?: string, chromePath?: string): Promise<void> {
  if (!dir) return;
  if (process.platform === "win32") return releaseWindowsProfile(dir, binaryPath, chromePath);
  let target: string;
  try { target = readlinkSync(join(dir, "SingletonLock")); }
  catch { heldProfiles.delete(dir); return; }
  const match = /^(.*)-(\d+)$/.exec(target);
  const held = heldProfiles.get(dir);
  if (held?.target === target && match?.[1] === hostname() && processAlive(Number(match[2])) && processAlive(held.daemon)) return;
  heldProfiles.delete(dir);
  const table = await processTable(true);
  if (!table) return;
  const browsers = [...table].filter(([, entry]) => !entry.zombie && namesProfile(entry.args ?? "", dir) && !/(?:^|\s)--type=/.test(entry.args ?? ""));
  for (const [pid, entry] of browsers) {
    const daemon = await daemonOf(pid, table);
    if (daemon !== null) { heldProfiles.set(dir, { target, daemon }); return; }
    if (!await stopBrowserProcesses({ daemon: pid, members: new Map([[pid, entry.started]]), groups: entry.pgid === pid ? [pid] : [] })) return;
  }
  for (const name of ["SingletonLock", "SingletonSocket", "SingletonCookie"]) { try { unlinkSync(join(dir, name)); } catch {} }
}

/** Windows has no tree to re-parent to: the Chromes naming the profile, each with its image, start time and its parent's and
 * grandparent's (a pid the system gave on to a newer process is not the parent). Start times are UTC strings, which sort as they compare and
 * keep their precision. The folder goes in through the environment, so no path is ever quoted into the script. */
const WINDOWS_PROFILE_BROWSERS = "$d = $env:OMB_BROWSER_PROFILE; $all = @(Get-CimInstance Win32_Process); $by = @{}; foreach ($p in $all) { $by[$p.ProcessId] = $p }"
  + " ; ConvertTo-Json -Compress -InputObject @($all | Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($d, [StringComparison]::OrdinalIgnoreCase) -ge 0 }"
  + " | ForEach-Object { $q = $by[$_.ParentProcessId]; $g = $(if ($q) { $by[$q.ParentProcessId] }); [pscustomobject]@{ ProcessId = $_.ProcessId; ParentProcessId = $_.ParentProcessId; CommandLine = $_.CommandLine;"
  + " Name = $_.Name; Started = $_.CreationDate.ToUniversalTime().ToString('o'); ParentName = $(if ($q) { $q.Name });"
  + " ParentStarted = $(if ($q) { $q.CreationDate.ToUniversalTime().ToString('o') }); GrandparentId = $(if ($q) { $q.ParentProcessId });"
  + " GrandparentName = $(if ($g) { $g.Name }); GrandparentStarted = $(if ($g) { $g.CreationDate.ToUniversalTime().ToString('o') }) } })";

/** One process's start time, read again right before it is stopped. */
const WINDOWS_PROCESS_STARTED = "(Get-CimInstance Win32_Process -Filter ('ProcessId=' + [uint32]$env:OMB_PROCESS_ID)).CreationDate.ToUniversalTime().ToString('o')";

/** The images Chrome-family browsers run as, besides the configured Chrome's own: no other program on the list is ever stopped. */
const CHROMIUM_IMAGE = /^(?:chrome|chromium|msedge|brave|chrome-headless-shell)\.exe$/i;

/** A Windows command line's arguments: spaces split them except inside double quotes, which may sit anywhere in one. */
function windowsArgs(line: string): string[] {
  const args: string[] = [];
  let arg = "";
  let quoted = false;
  let started = false;
  for (const char of line) {
    if (char === "\"") { quoted = !quoted; started = true; }
    else if (!quoted && /\s/.test(char)) { if (started) args.push(arg); arg = ""; started = false; }
    else { arg += char; started = true; }
  }
  if (started) args.push(arg);
  return args;
}

function lockReleased(lock: string): boolean {
  try { unlinkSync(lock); return true; }
  catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
}

const heldWindowsProfiles = new Map<string, { browser: number; daemon: number; image: string; shell?: number }>();

/** Windows Chrome keeps `lockfile` in its profile open and undeletable while it runs. One whose daemon was killed keeps running
 * and makes every later launch on the profile exit with code 21 (profile in use), even after the app restarts, so it is
 * stopped, once it is checked to be the same Chrome; a daemon's own Chrome (its parent, or the cmd.exe its launcher runs
 * in, runs `binaryPath` or another agent-browser) is kept. The profile's contents are never touched. */
async function releaseWindowsProfile(dir: string, binaryPath?: string, chromePath?: string): Promise<void> {
  const lock = join(dir, "lockfile");
  if (lockReleased(lock)) { heldWindowsProfiles.delete(dir); return; }
  // The Chrome kept last time still runs under the same daemon: every process is not listed again on each turn.
  const held = heldWindowsProfiles.get(dir);
  if (held && processAlive(held.browser) && await imageOf(held.daemon) === held.image && (held.shell === undefined || await imageOf(held.shell) === "cmd.exe")) return;
  heldWindowsProfiles.delete(dir);
  const listing = await execText("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PROFILE_BROWSERS], { OMB_BROWSER_PROFILE: dir }, 15_000);
  let rows: unknown;
  try { rows = JSON.parse(listing ?? ""); } catch { return; }
  const flag = `--user-data-dir=${dir}`.toLowerCase();
  const text = (value: unknown) => typeof value === "string" ? value : "";
  const chromeImage = chromePath ? win32.basename(chromePath).toLowerCase() : undefined;
  const isChrome = (name: string) => CHROMIUM_IMAGE.test(name) || name.toLowerCase() === chromeImage;
  // A parent started after its child holds a reused pid: the child's own parent is gone.
  const parentOf = (name: unknown, parentStarted: unknown, started: unknown) =>
    text(name) && text(parentStarted) && text(parentStarted) <= text(started) ? text(name).toLowerCase() : "";
  const browsers = (Array.isArray(rows) ? rows : [rows]).flatMap((row): { pid: number; started: string; daemonPid: number; shell?: number; daemon: string }[] => {
    const {
      ProcessId: pid, ParentProcessId: ppid, CommandLine: line, Name: name, Started: started, ParentName: parent, ParentStarted: parentStarted,
      GrandparentId: gppid, GrandparentName: grandparent, GrandparentStarted: grandparentStarted,
    } = (row ?? {}) as Record<string, unknown>;
    if (!Number.isInteger(pid) || !Number.isInteger(ppid) || typeof line !== "string" || !isChrome(text(name)) || !text(started)) return [];
    // A path's case does not matter on Windows.
    const args = windowsArgs(line).map((arg) => arg.toLowerCase());
    if (!args.includes(flag) || args.some((arg) => arg.startsWith("--type="))) return [];
    const daemon = parentOf(parent, parentStarted, started);
    // Rust's Command runs a .cmd launcher through cmd.exe, so that Chrome's daemon is the shell's parent. agent-browser 0.36
    // stops only its child on Windows: a Chrome whose shell it stopped is orphaned under a daemon that still runs.
    if (daemon === "cmd.exe") {
      return [{ pid: pid as number, started: started as string, daemonPid: gppid as number, shell: ppid as number, daemon: Number.isInteger(gppid) ? parentOf(grandparent, grandparentStarted, parentStarted) : "" }];
    }
    return [{ pid: pid as number, started: started as string, daemonPid: ppid as number, daemon }];
  });
  for (const { pid, started, daemonPid, shell, daemon } of browsers) {
    if (daemon.startsWith("agent-browser") || (binaryPath && daemon === win32.basename(binaryPath).toLowerCase())) {
      heldWindowsProfiles.set(dir, { browser: pid, daemon: daemonPid, image: daemon, shell });
      return;
    }
    // Between the listing and now the pid may have gone to another program: only the same Chrome is stopped.
    const now = await execText("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PROCESS_STARTED], { OMB_PROCESS_ID: String(pid) }, 15_000);
    if (now?.trim() === started) await execText("taskkill", ["/PID", String(pid), "/T", "/F"]);
  }
  if (browsers.length) await within(2_000, async () => lockReleased(lock));
}

function stillRuns(tree: BrowserProcessTree, table: Map<number, ListedProcess>, pid: number): boolean {
  const entry = table.get(pid);
  return !!entry && !entry.zombie && entry.started === tree.members.get(pid);
}

/** Linux and macOS never give a new process the number of a group that still has a process, so a group stays the tree's own
 * unless its leader's number now runs another process. */
function survivors(tree: BrowserProcessTree, table: Map<number, ListedProcess>): { pids: number[]; groups: number[] } {
  const same = (pid: number) => table.get(pid)?.started === tree.members.get(pid);
  const groups = tree.groups.filter((group) => !table.has(group) || same(group));
  const pids = [...table].filter(([pid, entry]) => !entry.zombie && ((tree.members.has(pid) && same(pid)) || groups.includes(entry.pgid))).map(([pid]) => pid);
  return { pids, groups: groups.filter((group) => pids.some((pid) => table.get(pid)!.pgid === group)) };
}

/** Windows soon gives a freed pid to a new process, which is never counted or killed as the daemon. */
async function windowsDaemonRuns(tree: BrowserProcessTree): Promise<boolean | null> {
  const image = await imageOf(tree.daemon);
  return image === null ? null : !!image && image === tree.image;
}

export async function browserProcessesRunning(tree: BrowserProcessTree): Promise<boolean> {
  if (process.platform === "win32") return await windowsDaemonRuns(tree) ?? processAlive(tree.daemon);
  const table = await processTable();
  if (!table) return [...tree.members.keys()].some(processAlive);
  return survivors(tree, table).pids.length > 0;
}

async function within(ms: number, done: () => Promise<boolean>): Promise<boolean> {
  for (const until = Date.now() + ms; ; await new Promise((resolve) => setTimeout(resolve, 100))) {
    if (await done()) return true;
    if (Date.now() >= until) return false;
  }
}

/** The root gets TERM first, alone: the daemon saves sign-ins over its Chrome's DevTools connection, so Chrome must still run
 * (CONT wakes a stopped daemon). Past `graceMs` the tree is killed by process group. */
export async function stopBrowserProcesses(tree: BrowserProcessTree, graceMs = 1_500): Promise<boolean> {
  if (process.platform === "win32") {
    // /T takes the daemon's Chrome too; only a pid still running the daemon's image is named.
    if (await windowsDaemonRuns(tree)) await execText("taskkill", ["/PID", String(tree.daemon), "/T", "/F"]);
    return within(2_000, async () => await windowsDaemonRuns(tree) === false);
  }
  const send = (target: number, signal: NodeJS.Signals) => { try { process.kill(target, signal); } catch {} };
  const rootRuns = async () => {
    const table = await processTable();
    return table ? stillRuns(tree, table, tree.daemon) : processAlive(tree.daemon);
  };
  if (await rootRuns()) {
    send(tree.daemon, "SIGTERM");
    send(tree.daemon, "SIGCONT");
    await within(graceMs, async () => !await rootRuns());
  }
  let gone = false;
  for (let pass = 0; pass < 2 && !gone; pass++) {
    const table = await processTable();
    if (!table) {
      for (const pid of tree.members.keys()) send(pid, "SIGKILL");
    } else {
      const left = survivors(tree, table);
      if (!left.pids.length) { gone = true; break; }
      for (const group of left.groups) send(-group, "SIGKILL");
      for (const pid of left.pids) if (!left.groups.includes(table.get(pid)!.pgid)) send(pid, "SIGKILL");
    }
    gone = await within(1_000, async () => !await browserProcessesRunning(tree));
  }
  if (gone) for (const dir of tree.profiles ?? []) { try { rmSync(dir, { recursive: true, force: true }); } catch {} }
  return gone;
}