char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Sombrowser: run Firefox in the target environment6183bc3

main
5.6 KiB167 linesraw
1import type { Environment } from "@berry/api";
2import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
3import { run } from "./runner.ts";
4
5type Reply = {
6  id?: number;
7  type?: string;
8  result?: unknown;
9  message?: string;
10  error?: string;
11  method?: string;
12  params?: Record<string, unknown>;
13};
14
15export type Session = {
16  request<T>(method: string, params: object): Promise<T>;
17  close(): Promise<void>;
18  readonly closed: Promise<void>;
19};
20
21/** A target-side Firefox session, spoken to through its runner's stdin and stdout. */
22export async function launch(
23  env: Environment,
24  onEvent: (method: string, params: Record<string, unknown>) => void,
25  signal?: AbortSignal,
26): Promise<Session> {
27  signal?.throwIfAborted();
28  env.lifetime?.throwIfAborted();
29  if (env.platform === "win32") {
30    throw new Error("The browser requires Firefox and a POSIX shell on the target machine.");
31  }
32  const child = await env.spawn(
33    ["deno", "eval", "--quiet", "--no-config", "--no-lock", `await (${run.toString()})();`],
34    {},
35    BACKGROUND_CONTEXT,
36  );
37  const input = child.stdin.getWriter();
38  const encoder = new TextEncoder();
39  const ready = Promise.withResolvers<void>();
40  const closed = Promise.withResolvers<void>();
41  const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
42  let nextId = 0;
43  let failure: Error | undefined;
44  let stderr = "";
45  let closing: Promise<void> | undefined;
46  const fail = (error: unknown) => {
47    if (failure) return;
48    failure = error instanceof Error ? error : new Error(String(error));
49    ready.reject(failure);
50    for (const request of pending.values()) request.reject(failure);
51    pending.clear();
52    closed.resolve();
53    void close();
54  };
55  const close = () =>
56    closing ??= (async () => {
57      env.lifetime?.removeEventListener("abort", disconnected);
58      const timeout = setTimeout(() => child.kill("SIGKILL"), 3_000);
59      try {
60        await input.close().catch(() => {});
61        await child.exited;
62        await Promise.allSettled([reading, errors]);
63      } finally {
64        clearTimeout(timeout);
65      }
66    })();
67  const disconnected = () => fail(env.lifetime?.reason ?? new Error("Target connection closed."));
68  const aborted = () => fail(signal!.reason);
69  const errors = (async () => {
70    const decoder = new TextDecoder();
71    for await (const chunk of child.stderr) {
72      stderr = (stderr + decoder.decode(chunk, { stream: true })).slice(-16_384);
73    }
74  })();
75  void errors.catch(fail);
76  const reading = (async () => {
77    const decoder = new TextDecoder();
78    let buffered = "";
79    for await (const chunk of child.stdout) {
80      buffered += decoder.decode(chunk, { stream: true });
81      let newline: number;
82      while ((newline = buffered.indexOf("\n")) >= 0) {
83        const message = JSON.parse(buffered.slice(0, newline)) as Reply;
84        buffered = buffered.slice(newline + 1);
85        if (message.type === "ready") ready.resolve();
86        else if (message.id === undefined) {
87          if (message.type === "error") throw new Error(message.message);
88          if (message.method && message.params) onEvent(message.method, message.params);
89        } else {
90          const request = pending.get(message.id);
91          if (!request) continue;
92          pending.delete(message.id);
93          if (message.type === "success") request.resolve(message.result);
94          else {
95            request.reject(
96              new Error(
97                `${message.error ?? "Firefox error"}: ${message.message ?? "Command failed"}`,
98              ),
99            );
100          }
101        }
102      }
103    }
104    await errors.catch(() => {});
105    throw new Error(stderr.trim() || "Firefox connection closed.");
106  })();
107  void reading.catch(fail);
108  env.lifetime?.addEventListener("abort", disconnected, { once: true });
109  signal?.addEventListener("abort", aborted, { once: true });
110  const timeout = setTimeout(
111    () => fail(new Error("Browser runner did not start within 25 seconds.")),
112    25_000,
113  );
114  try {
115    if (signal?.aborted) aborted();
116    if (env.lifetime?.aborted) disconnected();
117    await ready.promise;
118    const session: Session = {
119      closed: closed.promise,
120      close,
121      request<T>(method: string, params: object): Promise<T> {
122        if (failure || closing) {
123          return Promise.reject(failure ?? new Error("Firefox connection closed."));
124        }
125        const id = ++nextId;
126        return new Promise<T>((resolve, reject) => {
127          const timeout = setTimeout(() => {
128            pending.delete(id);
129            reject(new Error(`Firefox command ${method} timed out.`));
130          }, 30_000);
131          pending.set(id, {
132            resolve: (value) => {
133              clearTimeout(timeout);
134              resolve(value as T);
135            },
136            reject: (error) => {
137              clearTimeout(timeout);
138              reject(error);
139            },
140          });
141          void input.write(encoder.encode(JSON.stringify({ id, method, params }) + "\n")).catch(
142            fail,
143          );
144        });
145      },
146    };
147    await session.request("session.new", { capabilities: { alwaysMatch: {} } });
148    await session.request("session.subscribe", {
149      events: [
150        "log.entryAdded",
151        "network.fetchError",
152        "network.responseCompleted",
153        "browsingContext.navigationStarted",
154        "browsingContext.domContentLoaded",
155        "browsingContext.navigationFailed",
156        "browsingContext.fragmentNavigated",
157      ],
158    });
159    return session;
160  } catch (error) {
161    await close();
162    throw error;
163  } finally {
164    clearTimeout(timeout);
165    signal?.removeEventListener("abort", aborted);
166  }
167}