diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 8ac65795d..0769ea938 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -1,3 +1,7 @@ +export { + cleanupAnchorBrowserSession, + cleanupAllAnchorBrowserSessions, +} from "./sandbox/anchorbrowser-session.js"; export { resolveSandboxBrowserConfig, resolveSandboxConfigForAgent, @@ -21,6 +25,7 @@ export { type SandboxBrowserInfo, type SandboxContainerInfo, } from "./sandbox/manage.js"; +export { cleanupSandboxBrowserForScope } from "./sandbox/prune.js"; export { formatSandboxToolPolicyBlockedMessage, resolveSandboxRuntimeStatus, @@ -29,8 +34,10 @@ export { export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; export type { + AnchorBrowserSettings, SandboxBrowserConfig, SandboxBrowserContext, + SandboxBrowserProvider, SandboxConfig, SandboxContext, SandboxDockerConfig, diff --git a/src/agents/sandbox/anchorbrowser-session.ts b/src/agents/sandbox/anchorbrowser-session.ts new file mode 100644 index 000000000..fe59eb6ee --- /dev/null +++ b/src/agents/sandbox/anchorbrowser-session.ts @@ -0,0 +1,238 @@ +/** + * Anchorbrowser session management for sandbox browser contexts. + * + * Manages the lifecycle of Anchorbrowser sessions: + * - Creates sessions on-demand when the browser tool is first used + * - Tracks active sessions for reuse within the same scope + * - Cleans up sessions when the agent session ends + */ + +import type { BrowserBridge } from "../../browser/bridge-server.js"; +import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; +import type { ResolvedBrowserConfig } from "../../browser/config.js"; +import { + DEFAULT_BROWSER_EVALUATE_ENABLED, + DEFAULT_CLAWD_BROWSER_COLOR, +} from "../../browser/constants.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + createAnchorBrowserSession, + endAnchorBrowserSession, + type AnchorBrowserSession, +} from "./anchorbrowser.js"; +import type { SandboxBrowserConfig, SandboxBrowserContext } from "./types.js"; + +const log = createSubsystemLogger("sandbox").child("anchorbrowser"); + +// --------------------------------------------------------------------------- +// Session tracking +// --------------------------------------------------------------------------- + +type AnchorSessionEntry = { + session: AnchorBrowserSession; + bridge: BrowserBridge; + apiKey: string; + apiUrl?: string; +}; + +/** Map of scopeKey -> active Anchorbrowser session entry. */ +const ANCHOR_SESSIONS = new Map(); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Resolve the Anchorbrowser API key from config or environment. + */ +function resolveAnchorApiKey(browserCfg: SandboxBrowserConfig): string { + const apiKey = + browserCfg.anchorbrowser?.apiKey?.trim() || process.env.ANCHORBROWSER_API_KEY?.trim(); + + if (!apiKey) { + throw new Error( + "Anchorbrowser API key not configured. " + + "Set agents.defaults.sandbox.browser.anchorbrowser.apiKey in config " + + "or ANCHORBROWSER_API_KEY environment variable.", + ); + } + + return apiKey; +} + +/** + * Build a ResolvedBrowserConfig for a remote CDP endpoint. + */ +function buildRemoteBrowserConfig(params: { + cdpUrl: string; + evaluateEnabled: boolean; +}): ResolvedBrowserConfig { + const parsed = new URL(params.cdpUrl); + const cdpHost = parsed.hostname; + const cdpProtocol = parsed.protocol === "https:" ? "https" : "http"; + const isLoopback = + cdpHost === "localhost" || + cdpHost === "127.0.0.1" || + cdpHost === "0.0.0.0" || + cdpHost === "::1"; + + return { + enabled: true, + evaluateEnabled: params.evaluateEnabled, + controlPort: 0, // Will be assigned by the bridge server + cdpProtocol, + cdpHost, + cdpIsLoopback: isLoopback, + remoteCdpTimeoutMs: 5000, // Longer timeout for remote + remoteCdpHandshakeTimeoutMs: 10000, + color: DEFAULT_CLAWD_BROWSER_COLOR, + executablePath: undefined, + headless: false, // Anchorbrowser manages this + noSandbox: false, + attachOnly: true, // Never launch locally + defaultProfile: "remote", + profiles: { + remote: { + cdpUrl: params.cdpUrl, + color: DEFAULT_CLAWD_BROWSER_COLOR, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Ensure an Anchorbrowser session exists for the given scope. + * + * If a session already exists for this scope, reuses it. + * Otherwise, creates a new session via the Anchorbrowser API. + */ +export async function ensureAnchorBrowser(params: { + scopeKey: string; + cfg: SandboxBrowserConfig; + evaluateEnabled?: boolean; +}): Promise { + // Check for existing session + const existing = ANCHOR_SESSIONS.get(params.scopeKey); + if (existing) { + log.debug(`Reusing existing Anchorbrowser session for scope ${params.scopeKey}`); + return { + bridgeUrl: existing.bridge.baseUrl, + liveViewUrl: existing.session.liveViewUrl, + sessionId: existing.session.id, + }; + } + + // Resolve API key + const apiKey = resolveAnchorApiKey(params.cfg); + const apiUrl = params.cfg.anchorbrowser?.apiUrl; + + log.info(`Creating new Anchorbrowser session for scope ${params.scopeKey}`); + + // Create new session via API + const session = await createAnchorBrowserSession({ + apiKey, + apiUrl, + headless: params.cfg.anchorbrowser?.headless ?? params.cfg.headless, + viewport: params.cfg.anchorbrowser?.viewport, + proxy: params.cfg.anchorbrowser?.proxy, + captchaSolver: params.cfg.anchorbrowser?.captchaSolver, + adblock: params.cfg.anchorbrowser?.adblock, + popupBlocker: params.cfg.anchorbrowser?.popupBlocker, + timeout: params.cfg.anchorbrowser?.timeout, + recording: params.cfg.anchorbrowser?.recording, + extraStealth: params.cfg.anchorbrowser?.extraStealth, + }); + + log.info(`Anchorbrowser session created: ${session.id}`); + if (session.liveViewUrl) { + log.info(`Live view URL: ${session.liveViewUrl}`); + } + + // Start local bridge server pointing at the remote CDP + const evaluateEnabled = params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; + const bridge = await startBrowserBridgeServer({ + resolved: buildRemoteBrowserConfig({ + cdpUrl: session.cdpUrl, + evaluateEnabled, + }), + }); + + // Track the session + ANCHOR_SESSIONS.set(params.scopeKey, { + session, + bridge, + apiKey, + apiUrl, + }); + + return { + bridgeUrl: bridge.baseUrl, + liveViewUrl: session.liveViewUrl, + sessionId: session.id, + }; +} + +/** + * Clean up an Anchorbrowser session for the given scope. + * + * Stops the local bridge server and ends the remote session via API. + */ +export async function cleanupAnchorBrowserSession(scopeKey: string): Promise { + const entry = ANCHOR_SESSIONS.get(scopeKey); + if (!entry) return; + + log.info(`Cleaning up Anchorbrowser session for scope ${scopeKey}`); + + // Stop the local bridge server + try { + await stopBrowserBridgeServer(entry.bridge.server); + } catch (err) { + log.warn(`Failed to stop bridge server: ${err instanceof Error ? err.message : String(err)}`); + } + + // End the remote session + try { + await endAnchorBrowserSession({ + apiKey: entry.apiKey, + apiUrl: entry.apiUrl, + sessionId: entry.session.id, + }); + log.info(`Anchorbrowser session ${entry.session.id} ended`); + } catch (err) { + log.warn( + `Failed to end Anchorbrowser session: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + ANCHOR_SESSIONS.delete(scopeKey); +} + +/** + * Clean up all active Anchorbrowser sessions. + * + * Called during gateway shutdown. + */ +export async function cleanupAllAnchorBrowserSessions(): Promise { + const scopeKeys = Array.from(ANCHOR_SESSIONS.keys()); + for (const scopeKey of scopeKeys) { + await cleanupAnchorBrowserSession(scopeKey); + } +} + +/** + * Check if an Anchorbrowser session exists for the given scope. + */ +export function hasAnchorBrowserSession(scopeKey: string): boolean { + return ANCHOR_SESSIONS.has(scopeKey); +} + +/** + * Get the Anchorbrowser session entry for a scope (if any). + */ +export function getAnchorBrowserSessionEntry(scopeKey: string): AnchorSessionEntry | undefined { + return ANCHOR_SESSIONS.get(scopeKey); +} diff --git a/src/agents/sandbox/anchorbrowser.test.ts b/src/agents/sandbox/anchorbrowser.test.ts new file mode 100644 index 000000000..f3083b39b --- /dev/null +++ b/src/agents/sandbox/anchorbrowser.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createAnchorBrowserSession, + endAnchorBrowserSession, + getAnchorBrowserSession, +} from "./anchorbrowser.js"; + +describe("anchorbrowser client", () => { + const mockFetch = vi.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe("createAnchorBrowserSession", () => { + it("creates a session with minimal params", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + id: "session-123", + cdp_url: "wss://browser.anchorbrowser.io/session-123", + live_view_url: "https://app.anchorbrowser.io/live/session-123", + }, + }), + }); + + const session = await createAnchorBrowserSession({ + apiKey: "test-api-key", + }); + + expect(session).toEqual({ + id: "session-123", + cdpUrl: "wss://browser.anchorbrowser.io/session-123", + liveViewUrl: "https://app.anchorbrowser.io/live/session-123", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.anchorbrowser.io/v1/sessions", + expect.objectContaining({ + method: "POST", + headers: { + "anchor-api-key": "test-api-key", + "Content-Type": "application/json", + }, + }), + ); + }); + + it("creates a session with all params", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + id: "session-456", + cdp_url: "wss://browser.anchorbrowser.io/session-456", + }, + }), + }); + + const session = await createAnchorBrowserSession({ + apiKey: "test-api-key", + apiUrl: "https://custom.api.io/v1", + headless: true, + viewport: { width: 1920, height: 1080 }, + proxy: { active: true, type: "anchor_residential", countryCode: "de" }, + captchaSolver: true, + adblock: false, + timeout: { maxDuration: 30, idleTimeout: 10 }, + }); + + expect(session.id).toBe("session-456"); + expect(mockFetch).toHaveBeenCalledWith( + "https://custom.api.io/v1/sessions", + expect.objectContaining({ + method: "POST", + body: expect.any(String), + }), + ); + + // Parse the body to verify structure + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(call[1].body as string); + expect(body.browser.headless).toEqual({ active: true }); + expect(body.browser.viewport).toEqual({ width: 1920, height: 1080 }); + expect(body.browser.captcha_solver).toEqual({ active: true }); + expect(body.browser.adblock).toEqual({ active: false }); + expect(body.session.proxy).toEqual({ + active: true, + type: "anchor_residential", + country_code: "de", + }); + expect(body.session.timeout).toEqual({ + max_duration: 30, + idle_timeout: 10, + }); + }); + + it("throws on API error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: async () => ({ + error: "Invalid API key", + }), + }); + + await expect(createAnchorBrowserSession({ apiKey: "invalid-key" })).rejects.toThrow( + "Anchorbrowser API error: Invalid API key", + ); + }); + + it("throws on non-JSON error response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => { + throw new Error("Not JSON"); + }, + }); + + await expect(createAnchorBrowserSession({ apiKey: "test-key" })).rejects.toThrow( + "Anchorbrowser API error: 500 Internal Server Error", + ); + }); + }); + + describe("endAnchorBrowserSession", () => { + it("ends a session successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await endAnchorBrowserSession({ + apiKey: "test-api-key", + sessionId: "session-123", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.anchorbrowser.io/v1/sessions/session-123", + expect.objectContaining({ + method: "DELETE", + headers: { + "anchor-api-key": "test-api-key", + "Content-Type": "application/json", + }, + }), + ); + }); + + it("accepts 404 (session already ended)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Should not throw + await endAnchorBrowserSession({ + apiKey: "test-api-key", + sessionId: "session-123", + }); + }); + + it("throws on other errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => ({ message: "Server error" }), + }); + + await expect( + endAnchorBrowserSession({ + apiKey: "test-api-key", + sessionId: "session-123", + }), + ).rejects.toThrow("Anchorbrowser API error: Server error"); + }); + + it("uses custom API URL", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await endAnchorBrowserSession({ + apiKey: "test-api-key", + apiUrl: "https://custom.api.io/v1", + sessionId: "session-123", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://custom.api.io/v1/sessions/session-123", + expect.anything(), + ); + }); + }); + + describe("getAnchorBrowserSession", () => { + it("returns session info", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + id: "session-123", + cdp_url: "wss://browser.anchorbrowser.io/session-123", + live_view_url: "https://app.anchorbrowser.io/live/session-123", + }, + }), + }); + + const session = await getAnchorBrowserSession({ + apiKey: "test-api-key", + sessionId: "session-123", + }); + + expect(session).toEqual({ + id: "session-123", + cdpUrl: "wss://browser.anchorbrowser.io/session-123", + liveViewUrl: "https://app.anchorbrowser.io/live/session-123", + }); + }); + + it("returns null for 404", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const session = await getAnchorBrowserSession({ + apiKey: "test-api-key", + sessionId: "nonexistent", + }); + + expect(session).toBeNull(); + }); + + it("throws on other errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => ({}), + }); + + await expect( + getAnchorBrowserSession({ + apiKey: "test-api-key", + sessionId: "session-123", + }), + ).rejects.toThrow("Anchorbrowser API error: 500 Internal Server Error"); + }); + }); +}); diff --git a/src/agents/sandbox/anchorbrowser.ts b/src/agents/sandbox/anchorbrowser.ts new file mode 100644 index 000000000..d492015c4 --- /dev/null +++ b/src/agents/sandbox/anchorbrowser.ts @@ -0,0 +1,234 @@ +/** + * Anchorbrowser API client for creating and managing remote browser sessions. + * + * @see https://docs.anchorbrowser.io/api-reference/browser-sessions/start-browser-session + */ + +import type { AnchorBrowserSettings } from "./types.js"; + +const DEFAULT_API_URL = "https://api.anchorbrowser.io/v1"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AnchorBrowserSession = { + id: string; + cdpUrl: string; + liveViewUrl?: string; +}; + +export type AnchorBrowserCreateParams = AnchorBrowserSettings & { + /** API key (required). */ + apiKey: string; +}; + +type AnchorApiSessionResponse = { + data: { + id: string; + cdp_url: string; + live_view_url?: string; + }; +}; + +type AnchorApiErrorResponse = { + error?: string; + message?: string; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resolveApiUrl(settings?: AnchorBrowserSettings): string { + return settings?.apiUrl?.trim() || DEFAULT_API_URL; +} + +function buildHeaders(apiKey: string): Record { + return { + "anchor-api-key": apiKey, + "Content-Type": "application/json", + }; +} + +/** + * Build the request body for creating an Anchorbrowser session. + */ +function buildCreateSessionBody(params: AnchorBrowserCreateParams): Record { + const body: Record = {}; + + // Session config + const session: Record = {}; + if (params.recording !== undefined) { + session.recording = { active: params.recording }; + } + if (params.proxy) { + session.proxy = { + active: params.proxy.active ?? false, + ...(params.proxy.type && { type: params.proxy.type }), + ...(params.proxy.countryCode && { country_code: params.proxy.countryCode }), + ...(params.proxy.region && { region: params.proxy.region }), + ...(params.proxy.city && { city: params.proxy.city }), + }; + } + if (params.timeout) { + session.timeout = { + ...(params.timeout.maxDuration !== undefined && { max_duration: params.timeout.maxDuration }), + ...(params.timeout.idleTimeout !== undefined && { idle_timeout: params.timeout.idleTimeout }), + }; + } + if (Object.keys(session).length > 0) { + body.session = session; + } + + // Browser config + const browser: Record = {}; + if (params.adblock !== undefined) { + browser.adblock = { active: params.adblock }; + } + if (params.popupBlocker !== undefined) { + browser.popup_blocker = { active: params.popupBlocker }; + } + if (params.captchaSolver !== undefined) { + browser.captcha_solver = { active: params.captchaSolver }; + } + if (params.headless !== undefined) { + browser.headless = { active: params.headless }; + } + if (params.viewport) { + browser.viewport = { + width: params.viewport.width, + height: params.viewport.height, + }; + } + if (params.extraStealth !== undefined) { + browser.extra_stealth = { active: params.extraStealth }; + } + if (Object.keys(browser).length > 0) { + body.browser = browser; + } + + return body; +} + +// --------------------------------------------------------------------------- +// API Functions +// --------------------------------------------------------------------------- + +/** + * Create a new Anchorbrowser session. + * + * @throws Error if the API call fails or returns an error. + */ +export async function createAnchorBrowserSession( + params: AnchorBrowserCreateParams, +): Promise { + const apiUrl = resolveApiUrl(params); + const url = `${apiUrl}/sessions`; + + const body = buildCreateSessionBody(params); + + const response = await fetch(url, { + method: "POST", + headers: buildHeaders(params.apiKey), + body: JSON.stringify(body), + }); + + if (!response.ok) { + let errorMessage = `Anchorbrowser API error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as AnchorApiErrorResponse; + if (errorData.error || errorData.message) { + errorMessage = `Anchorbrowser API error: ${errorData.error || errorData.message}`; + } + } catch { + // Ignore JSON parse errors + } + throw new Error(errorMessage); + } + + const data = (await response.json()) as AnchorApiSessionResponse; + + return { + id: data.data.id, + cdpUrl: data.data.cdp_url, + liveViewUrl: data.data.live_view_url, + }; +} + +/** + * End an Anchorbrowser session. + * + * @throws Error if the API call fails. + */ +export async function endAnchorBrowserSession(params: { + apiKey: string; + apiUrl?: string; + sessionId: string; +}): Promise { + const apiUrl = params.apiUrl?.trim() || DEFAULT_API_URL; + const url = `${apiUrl}/sessions/${encodeURIComponent(params.sessionId)}`; + + const response = await fetch(url, { + method: "DELETE", + headers: buildHeaders(params.apiKey), + }); + + // 404 is acceptable - session may have already ended + if (!response.ok && response.status !== 404) { + let errorMessage = `Anchorbrowser API error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as AnchorApiErrorResponse; + if (errorData.error || errorData.message) { + errorMessage = `Anchorbrowser API error: ${errorData.error || errorData.message}`; + } + } catch { + // Ignore JSON parse errors + } + throw new Error(errorMessage); + } +} + +/** + * Get the status of an Anchorbrowser session. + * + * @returns The session info, or null if the session doesn't exist. + */ +export async function getAnchorBrowserSession(params: { + apiKey: string; + apiUrl?: string; + sessionId: string; +}): Promise { + const apiUrl = params.apiUrl?.trim() || DEFAULT_API_URL; + const url = `${apiUrl}/sessions/${encodeURIComponent(params.sessionId)}`; + + const response = await fetch(url, { + method: "GET", + headers: buildHeaders(params.apiKey), + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + let errorMessage = `Anchorbrowser API error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as AnchorApiErrorResponse; + if (errorData.error || errorData.message) { + errorMessage = `Anchorbrowser API error: ${errorData.error || errorData.message}`; + } + } catch { + // Ignore JSON parse errors + } + throw new Error(errorMessage); + } + + const data = (await response.json()) as AnchorApiSessionResponse; + + return { + id: data.data.id, + cdpUrl: data.data.cdp_url, + liveViewUrl: data.data.live_view_url, + }; +} diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 4951d9ce5..53f5e1102 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -4,6 +4,7 @@ import { DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_CLAWD_BROWSER_COLOR, } from "../../browser/constants.js"; +import { ensureAnchorBrowser } from "./anchorbrowser-session.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { @@ -86,6 +87,17 @@ export async function ensureSandboxBrowser(params: { if (!params.cfg.browser.enabled) return null; if (!isToolAllowed(params.cfg.tools, "browser")) return null; + // Branch based on provider + const provider = params.cfg.browser.provider ?? "docker"; + if (provider === "anchorbrowser") { + return ensureAnchorBrowser({ + scopeKey: params.scopeKey, + cfg: params.cfg.browser, + evaluateEnabled: params.evaluateEnabled, + }); + } + + // Docker provider (default) const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); const name = `${params.cfg.browser.containerPrefix}${slug}`; const containerName = name.slice(0, 63); diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index cabf907bc..09d6da649 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -86,8 +86,32 @@ export function resolveSandboxBrowserConfig(params: { }): SandboxBrowserConfig { const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser; const globalBrowser = params.globalBrowser; + + // Merge Anchorbrowser settings + const globalAnchor = globalBrowser?.anchorbrowser; + const agentAnchor = agentBrowser?.anchorbrowser; + const anchorbrowser = + globalAnchor || agentAnchor + ? { + apiKey: agentAnchor?.apiKey ?? globalAnchor?.apiKey ?? process.env.ANCHORBROWSER_API_KEY, + apiUrl: agentAnchor?.apiUrl ?? globalAnchor?.apiUrl, + proxy: agentAnchor?.proxy ?? globalAnchor?.proxy, + timeout: agentAnchor?.timeout ?? globalAnchor?.timeout, + captchaSolver: agentAnchor?.captchaSolver ?? globalAnchor?.captchaSolver, + adblock: agentAnchor?.adblock ?? globalAnchor?.adblock, + popupBlocker: agentAnchor?.popupBlocker ?? globalAnchor?.popupBlocker, + headless: agentAnchor?.headless ?? globalAnchor?.headless, + viewport: agentAnchor?.viewport ?? globalAnchor?.viewport, + recording: agentAnchor?.recording ?? globalAnchor?.recording, + extraStealth: agentAnchor?.extraStealth ?? globalAnchor?.extraStealth, + } + : undefined; + return { enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false, + provider: agentBrowser?.provider ?? globalBrowser?.provider ?? "docker", + + // Docker-specific settings image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE, containerPrefix: agentBrowser?.containerPrefix ?? @@ -97,8 +121,13 @@ export function resolveSandboxBrowserConfig(params: { vncPort: agentBrowser?.vncPort ?? globalBrowser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT, noVncPort: agentBrowser?.noVncPort ?? globalBrowser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, - headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false, enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true, + + // Anchorbrowser-specific settings + anchorbrowser, + + // Common settings + headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false, allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false, autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true, autoStartTimeoutMs: diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 5c23e58e6..b292d64ac 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,5 +1,6 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { defaultRuntime } from "../../runtime.js"; +import { cleanupAnchorBrowserSession } from "./anchorbrowser-session.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { dockerContainerState, execDocker } from "./docker.js"; import { @@ -93,3 +94,21 @@ export async function ensureDockerContainerIsRunning(containerName: string) { await execDocker(["start", containerName]); } } + +/** + * Clean up sandbox browser resources for a specific session scope. + * + * For Docker-based browsers: stops the bridge server. + * For Anchorbrowser sessions: ends the remote session via API. + */ +export async function cleanupSandboxBrowserForScope(scopeKey: string): Promise { + // Clean up Docker-based browser bridge + const bridge = BROWSER_BRIDGES.get(scopeKey); + if (bridge) { + await stopBrowserBridgeServer(bridge.bridge.server).catch(() => undefined); + BROWSER_BRIDGES.delete(scopeKey); + } + + // Clean up Anchorbrowser session + await cleanupAnchorBrowserSession(scopeKey); +} diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index f27dfd715..08f47cdd7 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -1,6 +1,8 @@ import type { SandboxDockerConfig } from "./types.docker.js"; +import type { AnchorBrowserSettings, SandboxBrowserProvider } from "../../config/types.sandbox.js"; export type { SandboxDockerConfig } from "./types.docker.js"; +export type { AnchorBrowserSettings, SandboxBrowserProvider } from "../../config/types.sandbox.js"; export type SandboxToolPolicy = { allow?: string[]; @@ -29,13 +31,22 @@ export type SandboxWorkspaceAccess = "none" | "ro" | "rw"; export type SandboxBrowserConfig = { enabled: boolean; + /** Browser provider: "docker" (default) or "anchorbrowser". */ + provider: SandboxBrowserProvider; + + // Docker-specific settings image: string; containerPrefix: string; cdpPort: number; vncPort: number; noVncPort: number; - headless: boolean; enableNoVnc: boolean; + + // Anchorbrowser-specific settings + anchorbrowser?: AnchorBrowserSettings; + + // Common settings + headless: boolean; allowHostControl: boolean; autoStart: boolean; autoStartTimeoutMs: number; @@ -61,8 +72,14 @@ export type SandboxConfig = { export type SandboxBrowserContext = { bridgeUrl: string; + /** NoVNC URL for Docker-based browsers. */ noVncUrl?: string; - containerName: string; + /** Live view URL for Anchorbrowser sessions. */ + liveViewUrl?: string; + /** Docker container name (Docker provider only). */ + containerName?: string; + /** Anchorbrowser session ID (Anchorbrowser provider only). */ + sessionId?: string; }; export type SandboxContext = { diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 4f5f83810..11ed044b1 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -44,15 +44,75 @@ export type SandboxDockerSettings = { binds?: string[]; }; +/** Browser provider for sandbox sessions. */ +export type SandboxBrowserProvider = "docker" | "anchorbrowser"; + +/** Anchorbrowser proxy configuration. */ +export type AnchorBrowserProxySettings = { + /** Enable proxy. */ + active?: boolean; + /** Proxy type (anchor_proxy, anchor_residential, anchor_mobile, anchor_gov). */ + type?: "anchor_proxy" | "anchor_residential" | "anchor_mobile" | "anchor_gov"; + /** Country code (ISO 2 lowercase, e.g. "us"). */ + countryCode?: string; + /** Region code for geographic targeting. */ + region?: string; + /** City name for precise targeting (requires region). */ + city?: string; +}; + +/** Anchorbrowser timeout configuration. */ +export type AnchorBrowserTimeoutSettings = { + /** Max session duration in minutes (default: 20). */ + maxDuration?: number; + /** Idle timeout in minutes before session stops (default: 5). */ + idleTimeout?: number; +}; + +/** Anchorbrowser-specific settings. */ +export type AnchorBrowserSettings = { + /** API key (prefer env var ANCHORBROWSER_API_KEY). */ + apiKey?: string; + /** Override API URL (default: https://api.anchorbrowser.io/v1). */ + apiUrl?: string; + /** Proxy configuration. */ + proxy?: AnchorBrowserProxySettings; + /** Timeout configuration. */ + timeout?: AnchorBrowserTimeoutSettings; + /** Enable captcha solving (requires proxy). */ + captchaSolver?: boolean; + /** Enable ad blocking (default: true). */ + adblock?: boolean; + /** Enable popup blocking (default: true). */ + popupBlocker?: boolean; + /** Run browser headless (default: false). */ + headless?: boolean; + /** Viewport dimensions. */ + viewport?: { width: number; height: number }; + /** Enable session recording (default: true). */ + recording?: boolean; + /** Enable extra stealth mode (requires proxy). */ + extraStealth?: boolean; +}; + export type SandboxBrowserSettings = { enabled?: boolean; + /** Browser provider: "docker" (default) or "anchorbrowser". */ + provider?: SandboxBrowserProvider; + + // Docker-specific settings image?: string; containerPrefix?: string; cdpPort?: number; vncPort?: number; noVncPort?: number; - headless?: boolean; enableNoVnc?: boolean; + + // Anchorbrowser-specific settings + anchorbrowser?: AnchorBrowserSettings; + + // Common settings + headless?: boolean; /** * Allow sandboxed sessions to target the host browser control server. * Default: false.