Added anchorbrowser remote browser support
This commit is contained in:
parent
9daa846457
commit
a2f0229640
@ -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,
|
||||
|
||||
238
src/agents/sandbox/anchorbrowser-session.ts
Normal file
238
src/agents/sandbox/anchorbrowser-session.ts
Normal file
@ -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<string, AnchorSessionEntry>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<SandboxBrowserContext | null> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
264
src/agents/sandbox/anchorbrowser.test.ts
Normal file
264
src/agents/sandbox/anchorbrowser.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
234
src/agents/sandbox/anchorbrowser.ts
Normal file
234
src/agents/sandbox/anchorbrowser.ts
Normal file
@ -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<string, string> {
|
||||
return {
|
||||
"anchor-api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request body for creating an Anchorbrowser session.
|
||||
*/
|
||||
function buildCreateSessionBody(params: AnchorBrowserCreateParams): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
// Session config
|
||||
const session: Record<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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<AnchorBrowserSession> {
|
||||
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<void> {
|
||||
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<AnchorBrowserSession | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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<void> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user