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
19.6 KiB569 linesraw
1import type { Environment } from "@berry/api";
2import { observe, resolveElement } from "./page.ts";
3import { launch, type Session } from "./transport.ts";
4import type { Action, Crop, Image, Snapshot } from "./types.ts";
5
6type Tab = {
7  context: string;
8  key: string;
9  prefix: string;
10  document?: string;
11  snapshot: Snapshot;
12  image?: Image;
13};
14type Evaluation =
15  | { type: "success"; result: { type: string; value?: string; sharedId?: string } }
16  | { type: "exception"; exceptionDetails: { text: string } };
17
18const KEYS: Record<string, string> = {
19  Enter: "\uE007",
20  Tab: "\uE004",
21  Escape: "\uE00C",
22  Backspace: "\uE003",
23  Delete: "\uE017",
24  ArrowLeft: "\uE012",
25  ArrowUp: "\uE013",
26  ArrowRight: "\uE014",
27  ArrowDown: "\uE015",
28  Home: "\uE011",
29  End: "\uE010",
30  PageUp: "\uE00E",
31  PageDown: "\uE00F",
32  Space: " ",
33  Control: "\uE009",
34  Shift: "\uE008",
35  Alt: "\uE00A",
36  Meta: "\uE03D",
37};
38
39/** How long a session may go without a call before it closes. */
40export const IDLE_MS = 15 * 60_000;
41
42/** One conversation's temporary session; operations are serialized to keep references meaningful. */
43export class Browser {
44  #session?: Promise<Session>;
45  #tabs = new Map<string, Tab>();
46  #bootstrap = new Set<string>();
47  #diagnostics = new Map<string, string[]>();
48  #navigation = new Map<string, { id: unknown; url: string; document?: string }>();
49  #nextTab = 0;
50  #queue: Promise<unknown> = Promise.resolve();
51  #signal?: AbortSignal;
52  #disposed = false;
53  #lifetime = new AbortController();
54  #stopped?: AbortController;
55  #idleMs: number;
56  #idle?: ReturnType<typeof setTimeout>;
57  #calls = 0;
58
59  constructor(readonly env: Environment, { idleMs = IDLE_MS } = {}) {
60    this.#idleMs = idleMs;
61    env.lifetime?.addEventListener("abort", this.#disconnected, { once: true });
62  }
63
64  #disconnected = () => void this.dispose();
65
66  get active(): boolean {
67    return this.#session !== undefined;
68  }
69  get snapshots(): Snapshot[] {
70    return [...this.#tabs.values()].map((tab) => tab.snapshot);
71  }
72  get screenshots(): Record<string, Image> {
73    return Object.fromEntries(
74      [...this.#tabs].flatMap(([id, tab]) => tab.image ? [[id, tab.image]] : []),
75    );
76  }
77
78  async exclusive<T>(work: () => Promise<T>, signal: AbortSignal): Promise<T> {
79    this.#calls++;
80    clearTimeout(this.#idle);
81    const result = this.#queue.catch(() => {}).then(async () => {
82      const stopped = this.#stopped = new AbortController();
83      signal = AbortSignal.any([
84        signal,
85        this.#lifetime.signal,
86        stopped.signal,
87        ...(this.env.lifetime ? [this.env.lifetime] : []),
88      ]);
89      signal.throwIfAborted();
90      if (this.#disposed) throw new Error("Browser extension has been unloaded.");
91      this.#signal = signal;
92      let closing: Promise<void> | undefined;
93      let abort!: () => void;
94      const cancelled = new Promise<never>((_, reject) => {
95        abort = () => {
96          // stop abandoned input/navigation before another call uses the session
97          closing = this.close();
98          reject(signal.reason);
99        };
100        signal.addEventListener("abort", abort, { once: true });
101      });
102      const running = work();
103      try {
104        return await Promise.race([running, cancelled]);
105      } finally {
106        signal.removeEventListener("abort", abort);
107        if (signal.aborted) {
108          await running.catch(() => {});
109          await closing;
110        }
111        this.#signal = undefined;
112        this.#stopped = undefined;
113      }
114    });
115    this.#queue = result;
116    try {
117      return await result;
118    } finally {
119      if (--this.#calls === 0 && this.#session) {
120        this.#idle = setTimeout(() => void this.close(), this.#idleMs);
121        Deno.unrefTimer(this.#idle);
122      }
123    }
124  }
125
126  /** End the session now, interrupting the call using it. */
127  async stop(): Promise<void> {
128    this.#stopped?.abort(new Error("The user closed the browser session."));
129    await this.close();
130  }
131
132  async dispose(): Promise<void> {
133    this.env.lifetime?.removeEventListener("abort", this.#disconnected);
134    this.#disposed = true;
135    this.#lifetime.abort(new Error("Browser extension has been unloaded."));
136    await this.close();
137    await this.#queue.catch(() => {});
138  }
139
140  async close(): Promise<void> {
141    clearTimeout(this.#idle);
142    const session = this.#session;
143    this.#session = undefined;
144    this.#tabs.clear();
145    this.#bootstrap.clear();
146    this.#diagnostics.clear();
147    this.#navigation.clear();
148    await (await session?.catch(() => undefined))?.close();
149  }
150
151  #event = (method: string, params: Record<string, unknown>) => {
152    const source = params.source as { context?: string } | undefined;
153    const context = String(params.context ?? source?.context ?? "");
154    if (method === "browsingContext.navigationStarted") {
155      const tab = [...this.#tabs.values()].find((tab) => tab.context === context);
156      this.#navigation.set(context, {
157        id: params.navigation,
158        url: String(params.url),
159        document: tab?.document,
160      });
161    } else if (
162      method.startsWith("browsingContext.") &&
163      this.#navigation.get(context)?.id === params.navigation
164    ) {
165      this.#navigation.delete(context);
166    }
167    let message = "";
168    if (method === "log.entryAdded" && (params.level === "error" || params.level === "warn")) {
169      message = `${params.level}: ${params.text ?? "Console error"}`;
170    } else if (method === "network.fetchError") {
171      const request = params.request as { url?: string } | undefined;
172      message = `Network: ${params.errorText} ${request?.url ?? ""}`;
173    } else if (method === "network.responseCompleted") {
174      const response = params.response as { status?: number; url?: string } | undefined;
175      if ((response?.status ?? 0) >= 400) message = `HTTP ${response!.status}: ${response!.url}`;
176    }
177    if (message && context) {
178      this.#diagnostics.set(
179        context,
180        [...(this.#diagnostics.get(context) ?? []), message.slice(0, 1_000)].slice(-20),
181      );
182    }
183  };
184
185  async #getSession(): Promise<Session> {
186    this.#signal?.throwIfAborted();
187    if (this.#disposed) throw new Error("Browser extension has been unloaded.");
188    if (!this.#session) {
189      const started = launch(this.env, this.#event, this.#signal).then(async (session) => {
190        try {
191          const { contexts } = await session.request<{ contexts: { context: string }[] }>(
192            "browsingContext.getTree",
193            {},
194          );
195          this.#bootstrap = new Set(contexts.map((context) => context.context));
196          session.closed.then(() => {
197            if (this.#session === started) {
198              this.#session = undefined;
199              this.#tabs.clear();
200              this.#diagnostics.clear();
201            }
202          });
203          return session;
204        } catch (error) {
205          await session.close();
206          throw error;
207        }
208      });
209      this.#session = started;
210      started.catch(() => {
211        if (this.#session === started) this.#session = undefined;
212      });
213    }
214    const session = await this.#session;
215    this.#signal?.throwIfAborted();
216    if (this.#disposed) throw new Error("Browser extension has been unloaded.");
217    return session;
218  }
219
220  #adopt(context: string, url = "about:blank"): string {
221    const id = `t${++this.#nextTab}`;
222    this.#tabs.set(id, {
223      context,
224      key: `berry_${crypto.randomUUID().replaceAll("-", "")}`,
225      prefix: "",
226      snapshot: {
227        tab: id,
228        url,
229        title: "",
230        text: "",
231        elements: [],
232        truncated: false,
233        frames: [],
234        diagnostics: [],
235      },
236    });
237    return id;
238  }
239
240  async tabs(): Promise<{ tab: string; url: string; title: string }[]> {
241    if (!this.#session) return [];
242    const session = await this.#getSession();
243    const { contexts } = await session.request<{ contexts: { context: string; url: string }[] }>(
244      "browsingContext.getTree",
245      {},
246    );
247    for (const [id, tab] of this.#tabs) {
248      if (!contexts.some((context) => context.context === tab.context)) this.#tabs.delete(id);
249    }
250    for (const context of contexts) {
251      if (this.#bootstrap.has(context.context)) continue;
252      const tab = [...this.#tabs.values()].find((tab) => tab.context === context.context);
253      if (tab) tab.snapshot = { ...tab.snapshot, url: context.url };
254      else this.#adopt(context.context, context.url);
255    }
256    return this.snapshots.map(({ tab, url, title }) => ({ tab, url, title }));
257  }
258
259  #tab(id: string): Tab {
260    const tab = this.#tabs.get(id);
261    if (!tab) {
262      throw new Error(
263        `No live tab ${id}. Use browser with action "tabs"; sessions end when idle, closed, reloaded, or reset.`,
264      );
265    }
266    return tab;
267  }
268
269  async #evaluate(tab: Tab, expression: string): Promise<Evaluation & { type: "success" }> {
270    const session = await this.#getSession();
271    const result = await session.request<Evaluation>("script.evaluate", {
272      expression,
273      target: { context: tab.context, sandbox: "berry" },
274      awaitPromise: true,
275      resultOwnership: "none",
276    });
277    if (result.type === "exception") throw new Error(result.exceptionDetails.text);
278    return result;
279  }
280
281  #resolve(tab: Tab, ref: string): string {
282    if (!tab.prefix || !tab.snapshot.elements.some((element) => element.ref === ref)) {
283      throw new Error("Stale or unknown element reference; take a new snapshot.");
284    }
285    return `(${resolveElement.toString()})(${JSON.stringify(tab.key)}, ${JSON.stringify(ref)})`;
286  }
287
288  async #keys(tab: Tab, actions: { type: string; value: string }[]): Promise<void> {
289    const session = await this.#getSession();
290    try {
291      await session.request("input.performActions", {
292        context: tab.context,
293        actions: [{ type: "key", id: "keyboard", actions }],
294      });
295    } finally {
296      await session.request("input.releaseActions", { context: tab.context }).catch(() => {});
297    }
298  }
299
300  async snapshot(id: string): Promise<Snapshot> {
301    const tab = this.#tab(id);
302    const prefix = `${crypto.randomUUID().slice(0, 8)}-`;
303    const expression = `(${observe.toString()})(${JSON.stringify(tab.key)}, ${
304      JSON.stringify(prefix)
305    }).then(JSON.stringify)`;
306    const deadline = Date.now() + 3_000;
307    while (true) {
308      await this.#waitForNavigation(tab.context);
309      try {
310        const { result } = await this.#evaluate(tab, expression);
311        if (result.type !== "string") throw new Error("Browser observation was not text.");
312        const { documentId, ...observation } = JSON.parse(result.value!) as
313          & Omit<Snapshot, "tab" | "diagnostics">
314          & { documentId: string };
315        tab.document = documentId;
316        tab.snapshot = {
317          ...observation,
318          tab: id,
319          diagnostics: this.#diagnostics.get(tab.context) ?? [],
320        };
321        tab.prefix = prefix;
322        tab.image = undefined;
323        return tab.snapshot;
324      } catch (error) {
325        if (
326          Date.now() >= deadline || !/realm|document.*unload|execution context/i.test(String(error))
327        ) throw error;
328        await this.#pause(100);
329      }
330    }
331  }
332
333  async screenshot(id: string, crop?: Crop): Promise<Image> {
334    const tab = this.#tab(id);
335    const session = await this.#getSession();
336    const { data } = await session.request<{ data: string }>("browsingContext.captureScreenshot", {
337      context: tab.context,
338      origin: "viewport",
339      ...(crop ? { clip: { type: "box", ...crop } } : {}),
340    });
341    return tab.image = { type: "image", mimeType: "image/png", data };
342  }
343
344  async act(action: Action): Promise<Snapshot | null> {
345    if (action.action === "reset") {
346      await this.close();
347      return null;
348    }
349    if (action.action === "tabs") {
350      await this.tabs();
351      return null;
352    }
353    if (action.action === "open") {
354      this.#url(action.url);
355      if (this.#tabs.size >= 12) throw new Error("Close a tab before opening more (limit: 12).");
356      const session = await this.#getSession();
357      const { context } = await session.request<{ context: string }>("browsingContext.create", {
358        type: "tab",
359      });
360      const id = this.#adopt(context);
361      await session.request("browsingContext.setViewport", {
362        context,
363        viewport: { width: 1280, height: 900 },
364        devicePixelRatio: 1,
365      });
366      await session.request("browsingContext.navigate", {
367        context,
368        url: action.url,
369        wait: "interactive",
370      });
371      return await this.snapshot(id);
372    }
373    const tab = this.#tab(action.tab);
374    const session = await this.#getSession();
375    const context = tab.context;
376    switch (action.action) {
377      case "snapshot":
378        break;
379      case "close":
380        await session.request("browsingContext.close", { context });
381        this.#tabs.delete(action.tab);
382        this.#diagnostics.delete(context);
383        return null;
384      case "navigate":
385        this.#url(action.url);
386        await session.request("browsingContext.navigate", {
387          context,
388          url: action.url,
389          wait: "interactive",
390        });
391        break;
392      case "reload":
393        await session.request("browsingContext.reload", { context, wait: "interactive" });
394        break;
395      case "back":
396      case "forward":
397        await session.request("browsingContext.traverseHistory", {
398          context,
399          delta: action.action === "back" ? -1 : 1,
400        });
401        break;
402      case "click": {
403        const { result } = await this.#evaluate(
404          tab,
405          `(() => {
406          const element = ${this.#resolve(tab, action.ref)};
407          const rect = element.getBoundingClientRect();
408          const x = (Math.max(0, rect.left) + Math.min(innerWidth, rect.right)) / 2;
409          const y = (Math.max(0, rect.top) + Math.min(innerHeight, rect.bottom)) / 2;
410          let hit = document.elementFromPoint(x, y);
411          while (hit?.shadowRoot?.elementFromPoint(x, y)) {
412            const next = hit.shadowRoot.elementFromPoint(x, y);
413            if (next === hit) break;
414            hit = next;
415          }
416          for (let current = hit; current; current = current.parentElement || current.getRootNode().host) {
417            if (current === element) return element;
418          }
419          throw new Error("Element is obscured; take a new snapshot or screenshot.");
420        })()`,
421        );
422        if (!result.sharedId) throw new Error("Could not resolve the element.");
423        try {
424          await session.request("input.performActions", {
425            context,
426            actions: [{
427              type: "pointer",
428              id: "mouse",
429              parameters: { pointerType: "mouse" },
430              actions: [
431                {
432                  type: "pointerMove",
433                  x: 0,
434                  y: 0,
435                  origin: { type: "element", element: { sharedId: result.sharedId } },
436                },
437                { type: "pointerDown", button: 0 },
438                { type: "pointerUp", button: 0 },
439              ],
440            }],
441          });
442        } finally {
443          await session.request("input.releaseActions", { context }).catch(() => {});
444        }
445        break;
446      }
447      case "fill": {
448        await this.#evaluate(
449          tab,
450          `(() => {
451          const element = ${this.#resolve(tab, action.ref)};
452          if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable) || element.readOnly) throw new Error("Element is not an editable text field.");
453          element.focus();
454          if (document.activeElement !== element && element.getRootNode().activeElement !== element) throw new Error("Could not focus the field.");
455        })()`,
456        );
457        const control = KEYS.Control;
458        await this.#keys(tab, [
459          { type: "keyDown", value: control },
460          { type: "keyDown", value: "a" },
461          { type: "keyUp", value: "a" },
462          { type: "keyUp", value: control },
463          { type: "keyDown", value: KEYS.Backspace },
464          { type: "keyUp", value: KEYS.Backspace },
465          ...[...action.text].flatMap((
466            value,
467          ) => [{ type: "keyDown", value }, { type: "keyUp", value }]),
468        ]);
469        break;
470      }
471      case "select":
472        await this.#evaluate(
473          tab,
474          `(() => {
475          const element = ${this.#resolve(tab, action.ref)};
476          if (!(element instanceof HTMLSelectElement)) throw new Error("Element is not a select.");
477          const value = ${JSON.stringify(action.value)};
478          const option = [...element.options].find(option => option.value === value);
479          if (!option || option.disabled) throw new Error("No enabled option with that value.");
480          element.value = value;
481          element.dispatchEvent(new Event("input", { bubbles: true }));
482          element.dispatchEvent(new Event("change", { bubbles: true }));
483        })()`,
484        );
485        break;
486      case "press": {
487        if (action.ref) await this.#evaluate(tab, `(${this.#resolve(tab, action.ref)}).focus()`);
488        const parts = action.key.split("+");
489        const key = parts.pop()!;
490        if (
491          parts.some((part) => !["Control", "Shift", "Alt", "Meta"].includes(part)) ||
492          (!KEYS[key] && [...key].length !== 1)
493        ) {
494          throw new Error(
495            "Use a named key or character, optionally prefixed with Control+, Shift+, Alt+, or Meta+.",
496          );
497        }
498        const values = [...parts.map((part) => KEYS[part]), KEYS[key] ?? key];
499        await this.#keys(tab, [
500          ...values.map((value) => ({ type: "keyDown", value })),
501          ...values.toReversed().map((value) => ({ type: "keyUp", value })),
502        ]);
503        break;
504      }
505      case "scroll":
506        await this.#evaluate(tab, `window.scrollBy(${action.x}, ${action.y})`);
507        break;
508      case "wait":
509        await this.#pause(action.ms);
510        break;
511    }
512    return await this.snapshot(action.tab);
513  }
514
515  async #waitForNavigation(context: string): Promise<void> {
516    const tab = [...this.#tabs.values()].find((tab) => tab.context === context)!;
517    const deadline = Date.now() + 25_000;
518    while (this.#navigation.has(context)) {
519      const navigation = this.#navigation.get(context)!;
520      try {
521        // firefox does not emit domContentLoaded when restoring from the back/forward cache
522        const { result } = await this.#evaluate(
523          tab,
524          `JSON.stringify(document.readyState !== "loading" && location.href === ${
525            JSON.stringify(navigation.url)
526          } && (${JSON.stringify(navigation.document ?? null)} === null || globalThis[${
527            JSON.stringify(tab.key + "_document")
528          }] !== ${JSON.stringify(navigation.document)} || location.href !== ${
529            JSON.stringify(tab.snapshot.url)
530          }))`,
531        );
532        if (result.value === "true") {
533          if (this.#navigation.get(context) === navigation) this.#navigation.delete(context);
534          continue;
535        }
536      } catch (error) {
537        if (!/realm|document.*unload|execution context/i.test(String(error))) throw error;
538      }
539      if (Date.now() >= deadline) {
540        throw new Error("Navigation did not finish; inspect the tab again.");
541      }
542      await this.#pause(50);
543    }
544  }
545
546  #pause(ms: number): Promise<void> {
547    const signal = this.#signal;
548    signal?.throwIfAborted();
549    return new Promise((resolve, reject) => {
550      const finish = () => {
551        clearTimeout(timer);
552        signal?.removeEventListener("abort", abort);
553        resolve();
554      };
555      const abort = () => {
556        clearTimeout(timer);
557        reject(signal!.reason);
558      };
559      const timer = setTimeout(finish, ms);
560      signal?.addEventListener("abort", abort, { once: true });
561    });
562  }
563
564  #url(url: string) {
565    if (url !== "about:blank" && !["http:", "https:"].includes(new URL(url).protocol)) {
566      throw new Error("Only HTTP/HTTPS URLs and about:blank are supported.");
567    }
568  }
569}