AkurAI Build
Menu

popagent

public

Latest change 6edc7e928c57dab23c0973ed3f7ee70b3ebb72d5 - Initial popagent baseline by AkurAI Build

import { AgentBrowser, BROWSER_TOOLS, type BrowserToolName } from "@mastra/agent-browser";
import type { Agent } from "@mastra/core/agent";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { agentSettings, type AgentSettingsStore } from "./agent-settings";
import { storage } from "./storage";
import type { BrowserSettings, BrowserSettingsInput } from "./api-types";
import { retryableInit } from "./retryable-init";
import { validateBrowserDestination } from "./browser-policy";
import { BrowserSessionRegistry, type ManagedBrowser } from "./browser-sessions";

const BROWSER_COLUMNS = `enabled, scope, viewport_width AS "viewportWidth",
  viewport_height AS "viewportHeight", timeout_ms AS "timeoutMs",
  max_sessions AS "maxSessions", idle_timeout_ms AS "idleTimeoutMs",
  screencast_enabled AS "screencastEnabled", screenshots_enabled AS "screenshotsEnabled",
  multi_tab_enabled AS "multiTabEnabled", forms_enabled AS "formsEnabled",
  dialogs_enabled AS "dialogsEnabled", drag_enabled AS "dragEnabled",
  evaluate_enabled AS "evaluateEnabled", recording_enabled AS "recordingEnabled",
  recording_retention_days AS "recordingRetentionDays",
  recording_max_files AS "recordingMaxFiles", allow_hosts AS "allowHosts",
  deny_hosts AS "denyHosts", updated_at::text AS "updatedAt"`;

const READ_ONLY_BROWSER_EXCLUSIONS = [
  BROWSER_TOOLS.CLICK,
  BROWSER_TOOLS.TYPE,
  BROWSER_TOOLS.PRESS,
  BROWSER_TOOLS.SELECT,
  BROWSER_TOOLS.CLOSE,
  BROWSER_TOOLS.TABS,
  BROWSER_TOOLS.DIALOG,
  BROWSER_TOOLS.DRAG,
  BROWSER_TOOLS.EVALUATE,
] satisfies BrowserToolName[];

function capabilityExclusions(settings: BrowserSettings, readOnly: boolean): BrowserToolName[] {
  const excluded = new Set<BrowserToolName>(readOnly ? READ_ONLY_BROWSER_EXCLUSIONS : []);
  if (!settings.screenshotsEnabled) excluded.add(BROWSER_TOOLS.SCREENSHOT);
  if (!settings.multiTabEnabled) excluded.add(BROWSER_TOOLS.TABS);
  if (!settings.formsEnabled) {
    excluded.add(BROWSER_TOOLS.TYPE);
    excluded.add(BROWSER_TOOLS.PRESS);
    excluded.add(BROWSER_TOOLS.SELECT);
  }
  if (!settings.dialogsEnabled) excluded.add(BROWSER_TOOLS.DIALOG);
  if (!settings.dragEnabled) excluded.add(BROWSER_TOOLS.DRAG);
  if (!settings.evaluateEnabled) excluded.add(BROWSER_TOOLS.EVALUATE);
  return [...excluded];
}

async function createPolicyBrowser(
  settings: BrowserSettings,
  readOnly: boolean,
  sessions: BrowserSessionRegistry,
) {
  const recordingDir = join(process.env.POPAGENT_DATA_DIR ?? join(process.cwd(), "data"), "browser-recordings");
  if (settings.recordingEnabled && !readOnly) await mkdir(recordingDir, { recursive: true, mode: 0o700 });
  const browser = new AgentBrowser({
    headless: true,
    scope: settings.scope,
    viewport: { width: settings.viewportWidth, height: settings.viewportHeight },
    timeout: settings.timeoutMs,
    excludeTools: capabilityExclusions(settings, readOnly),
    ...(settings.screencastEnabled ? { screencast: {
      format: "jpeg" as const,
      quality: 75,
      maxWidth: 1280,
      maxHeight: 720,
      everyNthFrame: 2,
    } } : {}),
    ...(settings.recordingEnabled && !readOnly ? { recording: { outputDir: recordingDir } } : {}),
  });
  const rules = { allowHosts: settings.allowHosts, denyHosts: settings.denyHosts };
  const goto = browser.goto.bind(browser);
  browser.goto = async (input, threadId) => {
    const result = await validateBrowserDestination(input.url, rules);
    if (!result.allowed) return `destination blocked by browser policy: ${result.reason ?? "destination denied"}` as never;
    const response = await goto(input, threadId);
    if ("url" in response) {
      const final = await validateBrowserDestination(response.url, rules);
      if (!final.allowed) {
        await browser.closeThreadSession(threadId ?? browser.getCurrentThread());
        return `redirect blocked by browser policy: ${final.reason ?? "destination denied"}` as never;
      }
    }
    return response;
  };
  const tabs = browser.tabs.bind(browser);
  browser.tabs = async (input, threadId) => {
    if (input.action === "new" && input.url) {
      const result = await validateBrowserDestination(input.url, rules);
      if (!result.allowed) return `destination blocked by browser policy: ${result.reason ?? "destination denied"}` as never;
    }
    return tabs(input, threadId);
  };

  const trackedMethods = [
    "goto", "snapshot", "screenshot", "click", "type", "press", "select", "scroll",
    "hover", "back", "dialog", "wait", "tabs", "drag", "evaluate",
  ] as const;
  const target = browser as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>;
  const managed: ManagedBrowser = {
    getState: async (threadId) => await browser.getBrowserState(threadId),
    closeThreadSession: async (threadId) => browser.closeThreadSession(threadId),
    startScreencast: async (options) => browser.startScreencast(options),
    injectMouseEvent: async (event, threadId) => browser.injectMouseEvent(event, threadId),
    injectKeyboardEvent: async (event, threadId) => browser.injectKeyboardEvent(event, threadId),
  };
  for (const method of trackedMethods) {
    const original = target[method]?.bind(browser);
    if (!original) continue;
    target[method] = async (...args: unknown[]) => {
      const result = await original(...args);
      const last = args.at(-1);
      const threadId = typeof last === "string" ? last : browser.getCurrentThread();
      await sessions.touch(threadId, readOnly ? "read-only" : "interactive", managed);
      return result;
    };
  }
  return browser;
}

export class BrowserSettingsStore {
  readonly storage = storage;
  private readonly initializeOnce = retryableInit(() => this.initialize());

  init(): Promise<void> {
    return this.initializeOnce();
  }

  private async initialize() {
    await this.storage.init();
    const schema = await Bun.file(new URL("./browser-settings.sql", import.meta.url)).text();
    await this.storage.db.none(schema);
  }

  async get(): Promise<BrowserSettings> {
    await this.init();
    return this.storage.db.one<BrowserSettings>(`
      SELECT ${BROWSER_COLUMNS} FROM popagent_browser_settings WHERE singleton = TRUE
    `);
  }

  async update(input: BrowserSettingsInput): Promise<BrowserSettings> {
    await this.init();
    return this.storage.db.one<BrowserSettings>(`
      UPDATE popagent_browser_settings SET
        enabled=$1,scope=$2,viewport_width=$3,viewport_height=$4,timeout_ms=$5,
        max_sessions=$6,idle_timeout_ms=$7,screencast_enabled=$8,screenshots_enabled=$9,
        multi_tab_enabled=$10,forms_enabled=$11,dialogs_enabled=$12,drag_enabled=$13,
        evaluate_enabled=$14,recording_enabled=$15,recording_retention_days=$16,
        recording_max_files=$17,allow_hosts=$18,deny_hosts=$19,updated_at=NOW()
      WHERE singleton=TRUE RETURNING ${BROWSER_COLUMNS}
    `, [
      input.enabled, input.scope, input.viewportWidth, input.viewportHeight, input.timeoutMs,
      input.maxSessions, input.idleTimeoutMs, input.screencastEnabled, input.screenshotsEnabled,
      input.multiTabEnabled, input.formsEnabled, input.dialogsEnabled, input.dragEnabled,
      input.evaluateEnabled, input.recordingEnabled, input.recordingRetentionDays,
      input.recordingMaxFiles, input.allowHosts, input.denyHosts,
    ]);
  }
}

export class BrowserRuntime {
  private browsers: AgentBrowser[] = [];
  private applied?: string;
  readonly sessions = new BrowserSessionRegistry();

  constructor(
    readonly settings: BrowserSettingsStore,
    private readonly roles: Pick<AgentSettingsStore, "list"> = agentSettings,
  ) {}

  async apply(agent: Agent, settings?: BrowserSettings): Promise<void> {
    settings ??= await this.settings.get();
    const profiles = await this.roles.list();
    const fingerprint = JSON.stringify([
      settings,
      profiles.map(({ id, browserAccess }) => ({ id, browserAccess })).sort((a, b) => a.id.localeCompare(b.id)),
    ]);
    this.sessions.configure(settings);
    await this.sessions.cleanupIdle();
    if (this.applied === fingerprint) return;
    await Promise.all(this.browsers.map((browser) => browser.close()));
    this.browsers = [];

    const accesses = settings.enabled ? profiles.map((profile) => profile.browserAccess) : [];
    const interactiveBrowser = accesses.includes("interactive")
      ? await createPolicyBrowser(settings, false, this.sessions)
      : undefined;
    const readOnlyBrowser = accesses.includes("read-only")
      ? await createPolicyBrowser(settings, true, this.sessions)
      : undefined;
    const browserFor = (id: string) => {
      if (!settings.enabled) return undefined;
      const access = profiles.find((profile) => profile.id === id)?.browserAccess ?? "none";
      return access === "interactive" ? interactiveBrowser : access === "read-only" ? readOnlyBrowser : undefined;
    };

    agent.setBrowser(browserFor(agent.id));
    const specialists = Object.entries(await agent.listAgents()) as unknown as [string, Agent][];
    for (const [id, specialist] of specialists) specialist.setBrowser(browserFor(id));
    this.browsers = [interactiveBrowser, readOnlyBrowser].filter((browser): browser is AgentBrowser => browser !== undefined);
    this.applied = fingerprint;
  }
}

export const browserSettings = new BrowserSettingsStore();
export const browserRuntime = new BrowserRuntime(browserSettings);