src / browser / service.ts
src / browser / service.ts
import { access } from "node:fs/promises";
import { join } from "node:path";
import { AgenticError } from "../core/errors";
import { createId } from "../core/id";
import type { WebResearchService } from "../research/web";
export interface BrowserServiceOptions {
enabled: boolean;
executablePath?: string;
headless: boolean;
allowEvaluate: boolean;
maxSessions: number;
navigationTimeoutMs: number;
maxTextChars: number;
}
export interface BrowserAction {
type:
| "navigate"
| "click"
| "click_text"
| "type"
| "press"
| "wait"
| "scroll"
| "back"
| "reload"
| "evaluate";
selector?: string;
text?: string;
value?: string;
key?: string;
url?: string;
milliseconds?: number;
x?: number;
y?: number;
script?: string;
}
export interface BrowserSnapshot {
sessionId: string;
url: string;
title: string;
text: string;
links: Array<{ text: string; url: string }>;
textTruncated: boolean;
capturedAt: string;
}
interface BrowserSession {
id: string;
page: any;
createdAt: string;
updatedAt: string;
}
async function exists(path: string | undefined): Promise<boolean> {
if (!path) return false;
try {
await access(path);
return true;
} catch {
return false;
}
}
async function detectBrowserExecutable(configured?: string): Promise<string> {
if (configured?.trim()) {
const path = configured.trim();
if (!(await exists(path))) {
throw new AgenticError("NOT_FOUND", `Configured browser executable does not exist: ${path}`);
}
return path;
}
const candidates = process.platform === "win32"
? [
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe"),
process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, "Google", "Chrome", "Application", "chrome.exe"),
process.env["PROGRAMFILES(X86)"] && join(process.env["PROGRAMFILES(X86)"], "Google", "Chrome", "Application", "chrome.exe"),
process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, "Microsoft", "Edge", "Application", "msedge.exe"),
process.env["PROGRAMFILES(X86)"] && join(process.env["PROGRAMFILES(X86)"], "Microsoft", "Edge", "Application", "msedge.exe"),
]
: process.platform === "darwin"
? [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/microsoft-edge",
"/usr/bin/microsoft-edge-stable",
];
for (const candidate of candidates) {
if (await exists(candidate || undefined)) return candidate as string;
}
throw new AgenticError(
"NOT_FOUND",
"No Chrome, Chromium, or Edge executable was found. Set Browser executable path in plugin settings.",
);
}
function validateSessionId(id: string): void {
if (!/^browser_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid browser session id: ${id}`);
}
}
export class BrowserService {
private browser: any;
private readonly sessions = new Map<string, BrowserSession>();
public constructor(
private readonly web: WebResearchService,
private readonly options: BrowserServiceOptions,
) {}
public async open(rawUrl: string): Promise<BrowserSnapshot> {
this.assertEnabled();
if (this.sessions.size >= this.options.maxSessions) {
throw new AgenticError(
"PROCESS_LIMIT",
`Browser session limit reached (${this.options.maxSessions}). Close an existing session first.`,
);
}
const url = await this.web.validateUrl(rawUrl);
const browser = await this.getBrowser();
const page = await browser.newPage();
await this.configurePageNetworkPolicy(page);
page.setDefaultNavigationTimeout(this.options.navigationTimeoutMs);
page.setDefaultTimeout(this.options.navigationTimeoutMs);
const id = createId("browser");
const now = new Date().toISOString();
this.sessions.set(id, { id, page, createdAt: now, updatedAt: now });
try {
await page.goto(url, { waitUntil: "domcontentloaded" });
return await this.snapshot(id);
} catch (error) {
await this.close(id).catch(() => undefined);
throw new AgenticError(
"INTERNAL",
`Browser navigation failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
public async control(id: string, actions: BrowserAction[]): Promise<BrowserSnapshot> {
this.assertEnabled();
if (!Array.isArray(actions) || actions.length === 0) {
throw new AgenticError("INVALID_INPUT", "At least one browser action is required.");
}
if (actions.length > 30) {
throw new AgenticError("INVALID_INPUT", "At most 30 browser actions may run in one call.");
}
const session = this.getSession(id);
for (const action of actions) {
switch (action.type) {
case "navigate":
if (!action.url) throw new AgenticError("INVALID_INPUT", "navigate requires url.");
await session.page.goto(await this.web.validateUrl(action.url), {
waitUntil: "domcontentloaded",
});
break;
case "click":
if (!action.selector) throw new AgenticError("INVALID_INPUT", "click requires selector.");
await session.page.click(action.selector);
break;
case "click_text": {
if (!action.text) throw new AgenticError("INVALID_INPUT", "click_text requires text.");
const clicked = await session.page.evaluate(
(needle: string) => {
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(
'a,button,[role="button"],input[type="submit"]',
),
);
const target = candidates.find((element) => {
const inputValue = element instanceof HTMLInputElement ? element.value : "";
return (element.innerText || inputValue || "").trim().includes(needle);
});
if (!target) return false;
target.click();
return true;
},
action.text,
);
if (!clicked) throw new AgenticError("NOT_FOUND", `No clickable element contained: ${action.text}`);
break;
}
case "type":
if (!action.selector) throw new AgenticError("INVALID_INPUT", "type requires selector.");
await session.page.focus(action.selector);
if (action.value !== undefined) await session.page.keyboard.type(action.value);
break;
case "press":
if (!action.key) throw new AgenticError("INVALID_INPUT", "press requires key.");
await session.page.keyboard.press(action.key);
break;
case "wait":
await new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.min(action.milliseconds ?? 500, 30_000))));
break;
case "scroll":
await session.page.evaluate(
(point: { x: number; y: number }) => window.scrollBy(point.x, point.y),
{ x: action.x ?? 0, y: action.y ?? 700 },
);
break;
case "back":
await session.page.goBack({ waitUntil: "domcontentloaded" });
break;
case "reload":
await session.page.reload({ waitUntil: "domcontentloaded" });
break;
case "evaluate":
if (!this.options.allowEvaluate) {
throw new AgenticError("PROTECTED_PATH", "Browser JavaScript evaluation is disabled.");
}
if (!action.script) throw new AgenticError("INVALID_INPUT", "evaluate requires script.");
if (action.script.length > 20_000) {
throw new AgenticError("INVALID_INPUT", "Browser evaluation scripts are limited to 20,000 characters.");
}
await session.page.evaluate(action.script);
break;
default:
throw new AgenticError("INVALID_INPUT", `Unsupported browser action: ${(action as BrowserAction).type}`);
}
session.updatedAt = new Date().toISOString();
}
return await this.snapshot(id);
}
public async snapshot(id: string): Promise<BrowserSnapshot> {
const session = this.getSession(id);
const data = await session.page.evaluate(() => ({
title: document.title || "",
text: document.body ? document.body.innerText : "",
links: Array.from(document.querySelectorAll<HTMLAnchorElement>("a[href]"))
.slice(0, 300)
.map((anchor) => ({
text: (anchor.innerText || anchor.textContent || "").trim().slice(0, 300),
url: anchor.href,
})),
}));
const rawText = typeof data?.text === "string" ? data.text : "";
const max = this.options.maxTextChars;
const textTruncated = rawText.length > max;
return {
sessionId: id,
url: session.page.url(),
title: typeof data?.title === "string" ? data.title : "",
text: textTruncated ? rawText.slice(0, max) : rawText,
links: Array.isArray(data?.links) ? data.links.slice(0, 200) : [],
textTruncated,
capturedAt: new Date().toISOString(),
};
}
public list(): Array<{ id: string; url: string; createdAt: string; updatedAt: string }> {
return [...this.sessions.values()].map((session) => ({
id: session.id,
url: session.page.url(),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
}));
}
public async close(id: string): Promise<void> {
const session = this.getSession(id);
this.sessions.delete(id);
await session.page.close().catch(() => undefined);
}
public async dispose(): Promise<void> {
const ids = [...this.sessions.keys()];
await Promise.all(ids.map(async (id) => await this.close(id).catch(() => undefined)));
if (this.browser) await this.browser.close().catch(() => undefined);
this.browser = undefined;
}
private async configurePageNetworkPolicy(page: any): Promise<void> {
await page.setRequestInterception(true);
page.on("request", (request: any) => {
void (async () => {
try {
if (request.isInterceptResolutionHandled?.()) return;
const rawUrl = String(request.url?.() ?? "");
let protocol = "";
try {
protocol = new URL(rawUrl).protocol;
} catch {
await request.abort("blockedbyclient").catch(() => undefined);
return;
}
if (protocol === "data:" || protocol === "blob:" || protocol === "about:") {
await request.continue().catch(() => undefined);
return;
}
await this.web.validateUrl(rawUrl);
await request.continue().catch(() => undefined);
} catch {
await request.abort("blockedbyclient").catch(() => undefined);
}
})();
});
}
private getSession(id: string): BrowserSession {
validateSessionId(id);
const session = this.sessions.get(id);
if (!session) throw new AgenticError("NOT_FOUND", `Browser session not found: ${id}`);
return session;
}
private async getBrowser(): Promise<any> {
if (this.browser?.connected) return this.browser;
let module: any;
try {
const moduleName = "puppeteer-core";
module = await import(moduleName);
} catch {
throw new AgenticError(
"NOT_FOUND",
"puppeteer-core is not installed. Run npm install for the plugin before enabling browser sessions.",
);
}
const puppeteer = module.default ?? module;
const executablePath = await detectBrowserExecutable(this.options.executablePath);
this.browser = await puppeteer.launch({
executablePath,
headless: this.options.headless,
args: ["--disable-dev-shm-usage"],
});
return this.browser;
}
private assertEnabled(): void {
if (!this.options.enabled) {
throw new AgenticError("PROCESS_DISABLED", "Browser sessions are disabled in plugin settings.");
}
}
}
import { access } from "node:fs/promises";
import { join } from "node:path";
import { AgenticError } from "../core/errors";
import { createId } from "../core/id";
import type { WebResearchService } from "../research/web";
export interface BrowserServiceOptions {
enabled: boolean;
executablePath?: string;
headless: boolean;
allowEvaluate: boolean;
maxSessions: number;
navigationTimeoutMs: number;
maxTextChars: number;
}
export interface BrowserAction {
type:
| "navigate"
| "click"
| "click_text"
| "type"
| "press"
| "wait"
| "scroll"
| "back"
| "reload"
| "evaluate";
selector?: string;
text?: string;
value?: string;
key?: string;
url?: string;
milliseconds?: number;
x?: number;
y?: number;
script?: string;
}
export interface BrowserSnapshot {
sessionId: string;
url: string;
title: string;
text: string;
links: Array<{ text: string; url: string }>;
textTruncated: boolean;
capturedAt: string;
}
interface BrowserSession {
id: string;
page: any;
createdAt: string;
updatedAt: string;
}
async function exists(path: string | undefined): Promise<boolean> {
if (!path) return false;
try {
await access(path);
return true;
} catch {
return false;
}
}
async function detectBrowserExecutable(configured?: string): Promise<string> {
if (configured?.trim()) {
const path = configured.trim();
if (!(await exists(path))) {
throw new AgenticError("NOT_FOUND", `Configured browser executable does not exist: ${path}`);
}
return path;
}
const candidates = process.platform === "win32"
? [
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe"),
process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, "Google", "Chrome", "Application", "chrome.exe"),
process.env["PROGRAMFILES(X86)"] && join(process.env["PROGRAMFILES(X86)"], "Google", "Chrome", "Application", "chrome.exe"),
process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, "Microsoft", "Edge", "Application", "msedge.exe"),
process.env["PROGRAMFILES(X86)"] && join(process.env["PROGRAMFILES(X86)"], "Microsoft", "Edge", "Application", "msedge.exe"),
]
: process.platform === "darwin"
? [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
: [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/microsoft-edge",
"/usr/bin/microsoft-edge-stable",
];
for (const candidate of candidates) {
if (await exists(candidate || undefined)) return candidate as string;
}
throw new AgenticError(
"NOT_FOUND",
"No Chrome, Chromium, or Edge executable was found. Set Browser executable path in plugin settings.",
);
}
function validateSessionId(id: string): void {
if (!/^browser_[a-z0-9_]+$/i.test(id)) {
throw new AgenticError("INVALID_INPUT", `Invalid browser session id: ${id}`);
}
}
export class BrowserService {
private browser: any;
private readonly sessions = new Map<string, BrowserSession>();
public constructor(
private readonly web: WebResearchService,
private readonly options: BrowserServiceOptions,
) {}
public async open(rawUrl: string): Promise<BrowserSnapshot> {
this.assertEnabled();
if (this.sessions.size >= this.options.maxSessions) {
throw new AgenticError(
"PROCESS_LIMIT",
`Browser session limit reached (${this.options.maxSessions}). Close an existing session first.`,
);
}
const url = await this.web.validateUrl(rawUrl);
const browser = await this.getBrowser();
const page = await browser.newPage();
await this.configurePageNetworkPolicy(page);
page.setDefaultNavigationTimeout(this.options.navigationTimeoutMs);
page.setDefaultTimeout(this.options.navigationTimeoutMs);
const id = createId("browser");
const now = new Date().toISOString();
this.sessions.set(id, { id, page, createdAt: now, updatedAt: now });
try {
await page.goto(url, { waitUntil: "domcontentloaded" });
return await this.snapshot(id);
} catch (error) {
await this.close(id).catch(() => undefined);
throw new AgenticError(
"INTERNAL",
`Browser navigation failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
public async control(id: string, actions: BrowserAction[]): Promise<BrowserSnapshot> {
this.assertEnabled();
if (!Array.isArray(actions) || actions.length === 0) {
throw new AgenticError("INVALID_INPUT", "At least one browser action is required.");
}
if (actions.length > 30) {
throw new AgenticError("INVALID_INPUT", "At most 30 browser actions may run in one call.");
}
const session = this.getSession(id);
for (const action of actions) {
switch (action.type) {
case "navigate":
if (!action.url) throw new AgenticError("INVALID_INPUT", "navigate requires url.");
await session.page.goto(await this.web.validateUrl(action.url), {
waitUntil: "domcontentloaded",
});
break;
case "click":
if (!action.selector) throw new AgenticError("INVALID_INPUT", "click requires selector.");
await session.page.click(action.selector);
break;
case "click_text": {
if (!action.text) throw new AgenticError("INVALID_INPUT", "click_text requires text.");
const clicked = await session.page.evaluate(
(needle: string) => {
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(
'a,button,[role="button"],input[type="submit"]',
),
);
const target = candidates.find((element) => {
const inputValue = element instanceof HTMLInputElement ? element.value : "";
return (element.innerText || inputValue || "").trim().includes(needle);
});
if (!target) return false;
target.click();
return true;
},
action.text,
);
if (!clicked) throw new AgenticError("NOT_FOUND", `No clickable element contained: ${action.text}`);
break;
}
case "type":
if (!action.selector) throw new AgenticError("INVALID_INPUT", "type requires selector.");
await session.page.focus(action.selector);
if (action.value !== undefined) await session.page.keyboard.type(action.value);
break;
case "press":
if (!action.key) throw new AgenticError("INVALID_INPUT", "press requires key.");
await session.page.keyboard.press(action.key);
break;
case "wait":
await new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.min(action.milliseconds ?? 500, 30_000))));
break;
case "scroll":
await session.page.evaluate(
(point: { x: number; y: number }) => window.scrollBy(point.x, point.y),
{ x: action.x ?? 0, y: action.y ?? 700 },
);
break;
case "back":
await session.page.goBack({ waitUntil: "domcontentloaded" });
break;
case "reload":
await session.page.reload({ waitUntil: "domcontentloaded" });
break;
case "evaluate":
if (!this.options.allowEvaluate) {
throw new AgenticError("PROTECTED_PATH", "Browser JavaScript evaluation is disabled.");
}
if (!action.script) throw new AgenticError("INVALID_INPUT", "evaluate requires script.");
if (action.script.length > 20_000) {
throw new AgenticError("INVALID_INPUT", "Browser evaluation scripts are limited to 20,000 characters.");
}
await session.page.evaluate(action.script);
break;
default:
throw new AgenticError("INVALID_INPUT", `Unsupported browser action: ${(action as BrowserAction).type}`);
}
session.updatedAt = new Date().toISOString();
}
return await this.snapshot(id);
}
public async snapshot(id: string): Promise<BrowserSnapshot> {
const session = this.getSession(id);
const data = await session.page.evaluate(() => ({
title: document.title || "",
text: document.body ? document.body.innerText : "",
links: Array.from(document.querySelectorAll<HTMLAnchorElement>("a[href]"))
.slice(0, 300)
.map((anchor) => ({
text: (anchor.innerText || anchor.textContent || "").trim().slice(0, 300),
url: anchor.href,
})),
}));
const rawText = typeof data?.text === "string" ? data.text : "";
const max = this.options.maxTextChars;
const textTruncated = rawText.length > max;
return {
sessionId: id,
url: session.page.url(),
title: typeof data?.title === "string" ? data.title : "",
text: textTruncated ? rawText.slice(0, max) : rawText,
links: Array.isArray(data?.links) ? data.links.slice(0, 200) : [],
textTruncated,
capturedAt: new Date().toISOString(),
};
}
public list(): Array<{ id: string; url: string; createdAt: string; updatedAt: string }> {
return [...this.sessions.values()].map((session) => ({
id: session.id,
url: session.page.url(),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
}));
}
public async close(id: string): Promise<void> {
const session = this.getSession(id);
this.sessions.delete(id);
await session.page.close().catch(() => undefined);
}
public async dispose(): Promise<void> {
const ids = [...this.sessions.keys()];
await Promise.all(ids.map(async (id) => await this.close(id).catch(() => undefined)));
if (this.browser) await this.browser.close().catch(() => undefined);
this.browser = undefined;
}
private async configurePageNetworkPolicy(page: any): Promise<void> {
await page.setRequestInterception(true);
page.on("request", (request: any) => {
void (async () => {
try {
if (request.isInterceptResolutionHandled?.()) return;
const rawUrl = String(request.url?.() ?? "");
let protocol = "";
try {
protocol = new URL(rawUrl).protocol;
} catch {
await request.abort("blockedbyclient").catch(() => undefined);
return;
}
if (protocol === "data:" || protocol === "blob:" || protocol === "about:") {
await request.continue().catch(() => undefined);
return;
}
await this.web.validateUrl(rawUrl);
await request.continue().catch(() => undefined);
} catch {
await request.abort("blockedbyclient").catch(() => undefined);
}
})();
});
}
private getSession(id: string): BrowserSession {
validateSessionId(id);
const session = this.sessions.get(id);
if (!session) throw new AgenticError("NOT_FOUND", `Browser session not found: ${id}`);
return session;
}
private async getBrowser(): Promise<any> {
if (this.browser?.connected) return this.browser;
let module: any;
try {
const moduleName = "puppeteer-core";
module = await import(moduleName);
} catch {
throw new AgenticError(
"NOT_FOUND",
"puppeteer-core is not installed. Run npm install for the plugin before enabling browser sessions.",
);
}
const puppeteer = module.default ?? module;
const executablePath = await detectBrowserExecutable(this.options.executablePath);
this.browser = await puppeteer.launch({
executablePath,
headless: this.options.headless,
args: ["--disable-dev-shm-usage"],
});
return this.browser;
}
private assertEnabled(): void {
if (!this.options.enabled) {
throw new AgenticError("PROCESS_DISABLED", "Browser sessions are disabled in plugin settings.");
}
}
}