diff --git a/README.md b/README.md index 87b66195..4e4b8bed 100644 --- a/README.md +++ b/README.md @@ -739,6 +739,40 @@ When enabled, agent-browser connects to a Browser Use cloud session instead of l Get your API key from the [Browser Use Cloud Dashboard](https://cloud.browser-use.com/settings?tab=api-keys). Free credits are available to get started, with pay-as-you-go pricing after. +### Kernel + +[Kernel](https://www.kernel.sh) provides cloud browser infrastructure for AI agents with features like stealth mode and persistent profiles. + +To enable Kernel, use the `-p` flag: + +```bash +export KERNEL_API_KEY="your-api-key" +agent-browser -p kernel open https://example.com +``` + +Or use environment variables for CI/scripts: + +```bash +export AGENT_BROWSER_PROVIDER=kernel +export KERNEL_API_KEY="your-api-key" +agent-browser open https://example.com +``` + +Optional configuration via environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `KERNEL_HEADLESS` | Run browser in headless mode (`true`/`false`) | `false` | +| `KERNEL_STEALTH` | Enable stealth mode to avoid bot detection (`true`/`false`) | `true` | +| `KERNEL_TIMEOUT_SECONDS` | Session timeout in seconds | `300` | +| `KERNEL_PROFILE_NAME` | Browser profile name for persistent cookies/logins (created if it doesn't exist) | (none) | + +When enabled, agent-browser connects to a Kernel cloud session instead of launching a local browser. All commands work identically. + +**Profile Persistence:** When `KERNEL_PROFILE_NAME` is set, the profile will be created if it doesn't already exist. Cookies, logins, and session data are automatically saved back to the profile when the browser session ends, making them available for future sessions. + +Get your API key from the [Kernel Dashboard](https://dashboard.onkernel.com). + ## License Apache-2.0 diff --git a/src/browser.ts b/src/browser.ts index 415da783..d5697d1a 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -74,6 +74,8 @@ export class BrowserManager { private browserbaseApiKey: string | null = null; private browserUseSessionId: string | null = null; private browserUseApiKey: string | null = null; + private kernelSessionId: string | null = null; + private kernelApiKey: string | null = null; private contexts: BrowserContext[] = []; private pages: Page[] = []; private activePageIndex: number = 0; @@ -676,6 +678,22 @@ export class BrowserManager { } } + /** + * Close a Kernel session via API + */ + private async closeKernelSession(sessionId: string, apiKey: string): Promise { + const response = await fetch(`https://api.onkernel.com/browsers/${sessionId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to close Kernel session: ${response.statusText}`); + } + } + /** * Connect to Browserbase remote browser via CDP. * Requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables. @@ -737,6 +755,147 @@ export class BrowserManager { } } + /** + * Find or create a Kernel profile by name. + * Returns the profile object if successful. + */ + private async findOrCreateKernelProfile( + profileName: string, + apiKey: string + ): Promise<{ name: string }> { + // First, try to get the existing profile + const getResponse = await fetch( + `https://api.onkernel.com/profiles/${encodeURIComponent(profileName)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + } + ); + + if (getResponse.ok) { + // Profile exists, return it + return { name: profileName }; + } + + if (getResponse.status !== 404) { + throw new Error(`Failed to check Kernel profile: ${getResponse.statusText}`); + } + + // Profile doesn't exist, create it + const createResponse = await fetch('https://api.onkernel.com/profiles', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ name: profileName }), + }); + + if (!createResponse.ok) { + throw new Error(`Failed to create Kernel profile: ${createResponse.statusText}`); + } + + return { name: profileName }; + } + + /** + * Connect to Kernel remote browser via CDP. + * Requires KERNEL_API_KEY environment variable. + */ + private async connectToKernel(): Promise { + const kernelApiKey = process.env.KERNEL_API_KEY; + if (!kernelApiKey) { + throw new Error('KERNEL_API_KEY is required when using kernel as a provider'); + } + + // Find or create profile if KERNEL_PROFILE_NAME is set + const profileName = process.env.KERNEL_PROFILE_NAME; + let profileConfig: { profile: { name: string; save_changes: boolean } } | undefined; + + if (profileName) { + await this.findOrCreateKernelProfile(profileName, kernelApiKey); + profileConfig = { + profile: { + name: profileName, + save_changes: true, // Save cookies/state back to the profile when session ends + }, + }; + } + + const response = await fetch('https://api.onkernel.com/browsers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${kernelApiKey}`, + }, + body: JSON.stringify({ + // Kernel browsers are headful by default with stealth mode available + // The user can configure these via environment variables if needed + headless: process.env.KERNEL_HEADLESS?.toLowerCase() === 'true', + stealth: process.env.KERNEL_STEALTH?.toLowerCase() !== 'false', // Default to stealth mode + timeout_seconds: parseInt(process.env.KERNEL_TIMEOUT_SECONDS || '300', 10), + // Load and save to a profile if specified + ...profileConfig, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to create Kernel session: ${response.statusText}`); + } + + let session: { session_id: string; cdp_ws_url: string }; + try { + session = (await response.json()) as { session_id: string; cdp_ws_url: string }; + } catch (error) { + throw new Error( + `Failed to parse Kernel session response: ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (!session.session_id || !session.cdp_ws_url) { + throw new Error( + `Invalid Kernel session response: missing ${!session.session_id ? 'session_id' : 'cdp_ws_url'}` + ); + } + + const browser = await chromium.connectOverCDP(session.cdp_ws_url).catch(() => { + throw new Error('Failed to connect to Kernel session via CDP'); + }); + + try { + const contexts = browser.contexts(); + let context: BrowserContext; + let page: Page; + + // Kernel browsers launch with a default context and page + if (contexts.length === 0) { + context = await browser.newContext(); + page = await context.newPage(); + } else { + context = contexts[0]; + const pages = context.pages(); + page = pages[0] ?? (await context.newPage()); + } + + this.kernelSessionId = session.session_id; + this.kernelApiKey = kernelApiKey; + this.browser = browser; + context.setDefaultTimeout(60000); + this.contexts.push(context); + this.pages.push(page); + this.activePageIndex = 0; + this.setupPageTracking(page); + this.setupContextTracking(context); + } catch (error) { + await this.closeKernelSession(session.session_id, kernelApiKey).catch((sessionError) => { + console.error('Failed to close Kernel session during cleanup:', sessionError); + }); + throw error; + } + } + /** * Connect to Browser Use remote browser via CDP. * Requires BROWSER_USE_API_KEY environment variable. @@ -856,6 +1015,12 @@ export class BrowserManager { return; } + // Kernel: requires explicit opt-in via -p kernel flag or AGENT_BROWSER_PROVIDER=kernel + if (provider === 'kernel') { + await this.connectToKernel(); + return; + } + const browserType = options.browser ?? 'chromium'; if (hasExtensions && browserType !== 'chromium') { throw new Error('Extensions are only supported in Chromium'); @@ -1604,6 +1769,11 @@ export class BrowserManager { } ); this.browser = null; + } else if (this.kernelSessionId && this.kernelApiKey) { + await this.closeKernelSession(this.kernelSessionId, this.kernelApiKey).catch((error) => { + console.error('Failed to close Kernel session:', error); + }); + this.browser = null; } else if (this.cdpEndpoint !== null) { // CDP: only disconnect, don't close external app's pages if (this.browser) { @@ -1631,6 +1801,8 @@ export class BrowserManager { this.browserbaseApiKey = null; this.browserUseSessionId = null; this.browserUseApiKey = null; + this.kernelSessionId = null; + this.kernelApiKey = null; this.isPersistentContext = false; this.activePageIndex = 0; this.refMap = {};