Menu
popagent
publicLatest 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);