Ctrl K
ComunidadGuías…

server/browser-relay.ts

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

Archivo original ↗Preguntar a KiBot
// agent-browser's daemon (0.36/0.37, stream_http.rs) serves HTTP on 127.0.0.1 and relays `POST /api/command` over its
// session socket. It runs one command per session at a time, so a read waits behind the bot's own command and a timed-out
// read stays queued after the HTTP request is gone. A command outside its launch-free list relaunches a dead Chrome, so
// every pass starts with `stream_status` and reads only when Chrome is connected.
import { randomUUID } from "node:crypto";
import { closeSync, openSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { browserDaemonFile, inRunningBrowser } from "./browser-engine.ts";
import { processAlive } from "./browser-processes.ts";
import { browserRuntimeEnv, type BrowserSpawnSpec } from "./browser-runtime.ts";

export interface RelayTarget { session: string; binaryPath: string; env: NodeJS.ProcessEnv }

export interface RelayAnswer {
  success: boolean;
  data: Record<string, unknown> | null;
  error?: string;
  launched: boolean;
}

/** The relay did not answer in time; the command may still be queued in the daemon. */
export class RelayTimeout extends Error {}

const MAX_ANSWER_BYTES = 4 * 1024 * 1024;
export const PROBE_MS = 250;

function record(value: unknown): Record<string, unknown> | null {
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}

/** Resolves with the daemon's answer, success or not; rejects on a timeout, a closed port or an oversized answer. */
export async function relay(port: number, command: Record<string, unknown>, options: { timeoutMs: number; maxBytes?: number }): Promise<RelayAnswer> {
  const origin = `http://127.0.0.1:${port}`;
  const maxBytes = options.maxBytes ?? MAX_ANSWER_BYTES;
  let response: Response;
  const signal = AbortSignal.timeout(Math.max(1, options.timeoutMs));
  try {
    response = await fetch(`${origin}/api/command`, {
      method: "POST", redirect: "error", signal,
      // The relay accepts only a same-origin request (stream_http.rs).
      headers: { "Content-Type": "application/json", Origin: origin },
      body: JSON.stringify(command),
    });
  } catch (error) {
    if (signal.aborted) throw new RelayTimeout("The browser did not answer in time.");
    throw error;
  }
  const chunks: Buffer[] = [];
  let size = 0;
  try {
    if (response.body) {
      for await (const chunk of response.body) {
        size += chunk.byteLength;
        if (size > maxBytes) throw new Error("The browser's answer was too large.");
        chunks.push(Buffer.from(chunk));
      }
    }
  } catch (error) {
    await response.body?.cancel().catch(() => {});
    if (signal.aborted) throw new RelayTimeout("The browser did not answer in time.");
    throw error;
  }
  let parsed: Record<string, unknown> | null = null;
  try { parsed = record(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch {}
  if (!parsed) return { success: false, data: null, error: `The browser relay answered HTTP ${response.status}.`, launched: false };
  const data = record(parsed.data);
  const lifecycle = record(data?.lifecycle);
  return {
    success: parsed.success === true && response.ok,
    data,
    ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
    launched: lifecycle?.launched === true,
  };
}

type Known = { port: number; daemonPid: number | null };

export class RelayPorts {
  private known = new Map<string, Known>();
  private tripped = new Set<string>();
  private started = new Set<string>();
  private readFile: (path: string) => string | null;
  private alive: (pid: number) => boolean;

  constructor(options: { readFile?: (path: string) => string | null; alive?: (pid: number) => boolean } = {}) {
    this.readFile = options.readFile ?? ((path) => { try { return readFileSync(path, "utf8"); } catch { return null; } });
    this.alive = options.alive ?? ((pid) => processAlive(pid));
  }

  /** From the `.stream` file the daemon writes and deletes on exit, confirmed by `session_info` once per daemon: a port a
   * dead daemon left behind may already belong to another bot's browser. A daemon too busy to confirm trips the session. */
  async port(target: RelayTarget): Promise<number | null> {
    const text = this.readFile(browserDaemonFile(target.env, ".stream"))?.trim() ?? "";
    const port = /^\d{1,5}$/.test(text) ? Number(text) : 0;
    if (port < 1 || port > 65_535) { this.known.delete(target.session); return null; }
    const known = this.known.get(target.session);
    if (known?.port === port && (known.daemonPid === null || this.alive(known.daemonPid))) return port;
    this.known.delete(target.session);
    try {
      const info = await relay(port, { action: "session_info" }, { timeoutMs: PROBE_MS * 4, maxBytes: 65_536 });
      if (!info.success || info.data?.session !== target.session) return null;
      const daemonPid = typeof info.data.backgroundPid === "number" ? info.data.backgroundPid : null;
      this.known.set(target.session, { port, daemonPid });
      return port;
    } catch (error) {
      if (error instanceof RelayTimeout) this.trip(target.session);
      return null;
    }
  }

  daemonPid(session: string): number | null { return this.known.get(session)?.daemonPid ?? null; }

  /** After a relay timeout: no more relay work for this session until a probe answers. */
  trip(session: string): void { this.tripped.add(session); }

  /** Null when there is nothing to read: no daemon, no answer in the probe budget, or a tripped session whose probe still fails. */
  async probe(target: RelayTarget): Promise<{ port: number; connected: boolean } | null> {
    const port = await this.port(target);
    if (port === null) return null;
    try {
      const status = await relay(port, { action: "stream_status" }, { timeoutMs: PROBE_MS, maxBytes: 65_536 });
      this.tripped.delete(target.session);
      return { port, connected: status.success && status.data?.connected === true };
    } catch (error) {
      if (error instanceof RelayTimeout) this.trip(target.session);
      else this.known.delete(target.session);
      return null;
    }
  }

  isTripped(session: string): boolean { return this.tripped.has(session); }

  /** The engine restarted Chrome: its page is blank and unsized until the bot's next call sizes it. */
  noteStarted(session: string): void { this.started.add(session); }
  takeStarted(session: string): boolean { return this.started.delete(session); }

  forget(session: string): void {
    this.known.delete(session);
    this.tripped.delete(session);
    this.started.delete(session);
  }
}

/** Real input into the focused field; the text travels only in this request's body, never on a command line, and only into a running browser. */
async function openPort(ports: RelayPorts, target: RelayTarget): Promise<number> {
  const port = await ports.port(target);
  if (port !== null) return port;
  let status = await inRunningBrowser(target.binaryPath, target.env, ["stream", "status"]);
  if (status && status.enabled !== true) status = await inRunningBrowser(target.binaryPath, target.env, ["stream", "enable"]);
  const value = status?.port;
  if (status?.enabled !== true || typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65_535) {
    throw new Error("The browser did not open its local input channel. Restart the browser and try again.");
  }
  return value;
}

export async function typeText(ports: RelayPorts, target: RelayTarget, text: string): Promise<void> {
  const port = await openPort(ports, target);
  let answer: RelayAnswer | null = null;
  try { answer = await relay(port, { action: "keyboard", subaction: "insertText", text }, { timeoutMs: 30_000, maxBytes: 65_536 }); }
  catch {}
  if (answer?.success !== true) throw new Error("The browser did not take the typing.");
}

export async function runCommand(ports: RelayPorts, target: RelayTarget, command: Record<string, unknown>, timeoutMs: number): Promise<RelayAnswer> {
  const port = await openPort(ports, target);
  try { return await relay(port, command, { timeoutMs, maxBytes: 65_536 }); }
  catch (error) {
    if (error instanceof RelayTimeout) ports.trip(target.session);
    throw error;
  }
}

/** A photo-heavy page was ~400 KB as PNG and ~110 KB as JPEG at quality 80, in 30-70 ms, with text still legible. */
const FRAME_JPEG_QUALITY = 80;
/** A missing picture beats a stuck fold; headless Chrome on macOS can stall a fresh browser's first capture 10-20 s. */
const FRAME_TIMEOUT_MS = 12_000;

/** Never starts a browser: with no daemon, or Chrome not connected, there is simply no frame. */
export async function captureFrame(ports: RelayPorts, target: RelayTarget): Promise<{ png: string; format: "jpeg" }> {
  const probe = await ports.probe(target);
  if (!probe?.connected) throw new Error("the browser has no open page to picture");
  const file = join(tmpdir(), `openmausbot-browser-${randomUUID()}.jpg`);
  try {
    closeSync(openSync(file, "wx", 0o600));
    let answer: RelayAnswer;
    try { answer = await relay(probe.port, { action: "screenshot", path: file, format: "jpeg", quality: FRAME_JPEG_QUALITY }, { timeoutMs: FRAME_TIMEOUT_MS, maxBytes: 65_536 }); }
    catch (error) {
      if (error instanceof RelayTimeout) ports.trip(target.session);
      throw new Error("the browser did not return a picture in time");
    }
    if (answer.launched) ports.noteStarted(target.session);
    if (!answer.success) throw new Error("the browser could not be pictured");
    const png = readFileSync(file).toString("base64");
    if (!png) throw new Error("the browser reported a picture it did not write");
    return { png, format: "jpeg" };
  } finally {
    rmSync(file, { force: true });
  }
}

export const relayPorts = new RelayPorts();

export function relayTarget(spec: Pick<BrowserSpawnSpec, "command" | "env">, session: string): RelayTarget {
  return { session, binaryPath: spec.command, env: browserRuntimeEnv({ ...spec.env, AGENT_BROWSER_SESSION: session }) };
}

export const relayFrame = (spec: Pick<BrowserSpawnSpec, "command" | "env">, session: string) => captureFrame(relayPorts, relayTarget(spec, session));
/** Typing, a key and a click as real input, for filling a login; each throws when the browser did not take it. */
export function relayInput(spec: Pick<BrowserSpawnSpec, "command" | "env">, session: string) {
  const target = relayTarget(spec, session);
  const act = async (command: Record<string, unknown>, refused: string) => {
    let answer: RelayAnswer | null = null;
    try { answer = await runCommand(relayPorts, target, command, 15_000); } catch {}
    if (answer?.success !== true) throw new Error(refused);
  };
  return {
    insertText: (text: string) => typeText(relayPorts, target, text),
    press: (key: string) => act({ action: "press", key }, "The browser did not take the key."),
    click: (selector: string) => act({ action: "click", selector }, "The browser did not take the click."),
  };
}
export const relayCommand = (spec: Pick<BrowserSpawnSpec, "command" | "env">, session: string, command: Record<string, unknown>, timeoutMs: number) =>
  runCommand(relayPorts, relayTarget(spec, session), command, timeoutMs);