AkurAI Build
Menu

popagent

public

Latest change 248b7c0673ea9d544f9e4738ee4d3e8ec2925924 - Add native BifrOSt Navigator browser provider with per-browser status LEDs by AkurAI Build

import { AgentBrowser, BROWSER_TOOLS, type BrowserToolName } from "@mastra/agent-browser";
import type { Agent } from "@mastra/core/agent";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { chromium } from "playwright-core";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { agentSettings, type AgentSettingsStore } from "./agent-settings";
import { storage } from "./storage";
import type { BrowserHealth, BrowserProviderHealth, BrowserSettings, BrowserSettingsInput } from "./api-types";
import { retryableInit } from "./retryable-init";
import { validateBrowserDestination } from "./browser-policy";
import { BrowserSessionRegistry, type ManagedBrowser } from "./browser-sessions";
import { browserProfiles, type BrowserProfileStore } from "./browser-profiles";
import { callBifrOSt, listBifrOStTools } from "./tools/bifrost";

const BROWSER_COLUMNS = `enabled, provider, 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[];


type BoundSecretFill = {
  threadId: string;
  ref: string;
  name: string;
  value: string;
  allowedOrigin: string;
};

type PolicyBrowser = AgentBrowser & {
  fillBoundSecret(input: BoundSecretFill): Promise<{ used: true; name: string; origin: string }>;
};

export function assertBoundSecretOrigin(
  pageUrl: string,
  elementOrigin: string,
  allowedOrigin: string,
): string {
  let expected: URL;
  let page: URL;
  try {
    expected = new URL(allowedOrigin);
    page = new URL(pageUrl);
  } catch {
    throw new Error("Secret origin validation failed");
  }
  if (
    expected.protocol !== "https:"
    || expected.origin !== allowedOrigin
    || expected.pathname !== "/"
    || expected.search
    || expected.hash
  ) {
    throw new Error("Secret binding is not one exact HTTPS origin");
  }
  if (page.origin !== allowedOrigin) {
    throw new Error(`Secret is not authorized for the current page origin ${page.origin}`);
  }
  if (elementOrigin !== allowedOrigin) {
    throw new Error(`Secret is not authorized for the target element origin ${elementOrigin}`);
  }
  return allowedOrigin;
}

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,
  storageState?: string,
): Promise<PolicyBrowser> {
  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),
    ...(storageState ? { storageState } : {}),
    ...(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 protectedContexts = new WeakSet<object>();
  const protectThread = async (threadId: string) => {
    const manager = await browser.getManagerForThread(threadId);
    const context = manager.getContext();
    if (!context || protectedContexts.has(context)) return;
    protectedContexts.add(context);
    await context.route("**/*", async (route) => {
      const url = route.request().url();
      if (!url.startsWith("http://") && !url.startsWith("https://")) return route.continue();
      const result = await validateBrowserDestination(url, rules);
      if (result.allowed) await route.continue();
      else await route.abort("blockedbyclient");
    });
  };

  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) => {
      const state = await browser.getBrowserState(threadId);
      if (!state) return null;
      return {
        currentUrl: state.tabs[state.activeTabIndex]?.url ?? null,
        tabs: state.tabs.map((tab, index) => ({ id: String(index), url: tab.url, title: tab.title ?? tab.url })),
        activeTabIndex: state.activeTabIndex,
      };
    },
    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 last = args.at(-1);
      const threadId = typeof last === "string" ? last : browser.getCurrentThread();
      await protectThread(threadId);
      const result = await original(...args);
      await sessions.touch(threadId, readOnly ? "read-only" : "interactive", managed);
      return result;
    };
  }
  const policyBrowser = browser as PolicyBrowser;
  policyBrowser.fillBoundSecret = async ({ threadId, ref, name, value, allowedOrigin }) => {
    if (readOnly || !settings.formsEnabled) {
      throw new Error("Secret use requires interactive browser form access");
    }
    await protectThread(threadId);
    const manager = await browser.getManagerForThread(threadId);
    const page = manager.getPage();
    const locator = manager.getLocatorFromRef(ref);
    if (!locator) throw new Error(`Browser element ${ref} is stale or unavailable`);
    const elementOrigin = await locator.evaluate(
      (element) => element.ownerDocument.defaultView?.location.origin ?? "null",
    );
    const origin = assertBoundSecretOrigin(page.url(), elementOrigin, allowedOrigin);
    await locator.fill("", { timeout: settings.timeoutMs });
    await locator.fill(value, { timeout: settings.timeoutMs });
    await sessions.touch(threadId, "interactive", managed);
    return { used: true, name, origin };
  };
  return policyBrowser;
}

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,provider=$20,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, input.provider,
    ]);
  }
}

export class BrowserRuntime {
  private browsers: AgentBrowser[] = [];
  private interactiveBrowser?: PolicyBrowser;
  private activeSettings?: BrowserSettings;
  private applied?: string;
  private profileStatePath?: string;
  private profileStateDir?: string;
  readonly sessions = new BrowserSessionRegistry();

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

  async useBoundSecret(input: {
    agentId: string;
    threadId: string;
    ref: string;
    name: string;
    value: string;
    allowedOrigin: string;
    signal?: AbortSignal;
  }): Promise<{ used: true; name: string; origin: string }> {
    input.signal?.throwIfAborted();
    const settings = this.activeSettings;
    const role = (await this.roles.list()).find(({ id }) => id === input.agentId);
    if (!role || role.browserAccess !== "interactive") {
      throw new Error("Agent role is not authorized for interactive secret use");
    }
    if (!settings?.enabled || !settings.formsEnabled || !this.interactiveBrowser) {
      throw new Error("Interactive browser form access is not enabled");
    }
    const result = await this.interactiveBrowser.fillBoundSecret(input);
    input.signal?.throwIfAborted();
    return result;
  }

  async apply(agent: Agent, settings?: BrowserSettings): Promise<void> {

    settings ??= await this.settings.get();
    this.activeSettings = settings;
    const profiles = await this.roles.list();
    const activeProfile = await this.profiles.active();
    const fingerprint = JSON.stringify([
      settings,
      activeProfile?.profile,
      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 this.sessions.closeAll();
    await Promise.all(this.browsers.map((browser) => browser.close()));
    this.browsers = [];
    if (this.profileStateDir) await rm(this.profileStateDir, { recursive: true, force: true });
    this.profileStatePath = undefined;
    this.profileStateDir = undefined;
    if (activeProfile) {
      this.profileStateDir = await mkdtemp(join(tmpdir(), "popagent-browser-profile-"));
      this.profileStatePath = join(this.profileStateDir, "storage-state.json");
      await writeFile(this.profileStatePath, JSON.stringify(activeProfile.state), { mode: 0o600 });
    }

    const headlessActive = settings.enabled && settings.provider === "agent-browser";
    const accesses = headlessActive ? profiles.map((profile) => profile.browserAccess) : [];
    const interactiveBrowser = accesses.includes("interactive")
      ? await createPolicyBrowser(settings, false, this.sessions, this.profileStatePath)
      : undefined;
    const readOnlyBrowser = accesses.includes("read-only")
      ? await createPolicyBrowser(settings, true, this.sessions, this.profileStatePath)
      : undefined;
    const browserFor = (id: string) => {
      if (!headlessActive) 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 PolicyBrowser => browser !== undefined);
    this.interactiveBrowser = interactiveBrowser;
    this.applied = fingerprint;
  }

  /**
   * Per-provider readiness for the settings page LEDs; never launches a
   * browser. The headless provider is ready when its Chromium build exists;
   * BifrOSt is ready when its MCP socket answers `ping`.
   */
  async health(settings?: BrowserSettings): Promise<Pick<BrowserHealth, "provider" | "healthy" | "headless" | "selected" | "providers">> {
    settings ??= await this.settings.get();
    const [chromiumReady, bifrostTools] = await Promise.all([
      Promise.resolve().then(() => existsSync(chromium.executablePath())).catch(() => false),
      listBifrOStTools(AbortSignal.timeout(3_000)).catch(() => undefined),
    ]);
    const bifrostReady = bifrostTools !== undefined && bifrostTools.length > 0;
    const bifrostDetail = bifrostTools === undefined
      ? "MCP socket unreachable"
      : bifrostTools.length === 0
        ? "Socket answers, but no MCP capabilities are granted (start BifrOSt with --allow-mcp-capability …)"
        : `MCP socket answering · ${bifrostTools.length} tools exposed`;
    const providers: BrowserProviderHealth[] = [
      {
        id: "agent-browser",
        label: "Headless AgentBrowser",
        healthy: chromiumReady,
        headless: true,
        detail: chromiumReady ? "Chromium installed" : "Chromium build missing",
      },
      {
        id: "bifrost-navigator",
        label: "BifrOSt Navigator",
        healthy: bifrostReady,
        headless: false,
        detail: bifrostDetail,
      },
    ];
    const selected = providers.find((provider) => provider.id === settings!.provider) ?? providers[0]!;
    return { provider: selected.label, healthy: selected.healthy, headless: selected.headless, selected: selected.id, providers };
  }

  async test(): Promise<{ url: string; title: string; elementCount: number }> {
    if (this.activeSettings?.provider === "bifrost-navigator") {
      const result = await callBifrOSt("browser_current_page", {}, AbortSignal.timeout(15_000));
      const page = result.structuredContent ?? {};
      return {
        url: String(page.url ?? ""),
        title: String(page.title ?? ""),
        elementCount: 0,
      };
    }
    const browser = this.interactiveBrowser;
    if (!browser) throw new Error("Interactive browser is not enabled");
    const threadId = `browser-test:${crypto.randomUUID()}`;
    try {
      const navigation = await browser.goto({ url: "https://example.com", waitUntil: "domcontentloaded" }, threadId);
      if (!("success" in navigation) || !navigation.success) throw new Error("Browser navigation test failed");
      const snapshot = await browser.snapshot({ interactiveOnly: false, maxDepth: 4 }, threadId);
      if (!("success" in snapshot) || !snapshot.success) throw new Error("Browser snapshot test failed");
      return { url: snapshot.url, title: snapshot.title, elementCount: snapshot.elementCount };
    } finally {
      await this.sessions.closeThread(threadId);
    }
  }

  invalidate() {
    this.applied = undefined;
    this.activeSettings = undefined;
  }

  /** Close every open browser and its sessions during process shutdown. */
  async shutdown(): Promise<void> {
    await this.sessions.closeAll();
    await Promise.all(this.browsers.map((browser) => browser.close()));
    this.browsers = [];
    this.interactiveBrowser = undefined;
    if (this.profileStateDir) await rm(this.profileStateDir, { recursive: true, force: true });
    this.profileStatePath = undefined;
    this.profileStateDir = undefined;
    this.applied = undefined;
  }
}

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