diff --git a/.env.example b/.env.example index c0ee8587..01715ba6 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,10 @@ SECRET_ENCRYPTION_KEY= # Required for browser execution outside the Vercel one-click flow. # The Kernel Marketplace resource injects this automatically on Vercel. # For manual setup, create a key at https://kernel.sh. +BROWSER_PROVIDER=kernel KERNEL_API_KEY= +# Alternatively set BROWSER_PROVIDER=notte and provide only NOTTE_API_KEY. +NOTTE_API_KEY= # Required for model inference outside Vercel. Vercel deployments use project # OIDC for AI Gateway access instead. AI_GATEWAY_API_KEY= diff --git a/AGENTS.md b/AGENTS.md index da21c8ce..f91b2808 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ Run the validation the task requests. When it does not establish the behavior yo - The workspace manager lives on `/` and the agent chat on `/chat`; browser execution belongs only to the declared browser-agent subagent's flat tool surface under `agent/subagents/browser-agent/tools`. - Keep each worker browser tool's schema and implementation together. Share the Kernel SDK client through `agent/subagents/browser-agent/lib/kernel.ts`; do not add a Kernel extension or root browser connection. - `agent/subagents/browser-agent/lib` is for code genuinely shared by worker tools. Group a shared worker domain in a lower-case folder, such as `trace/domains.ts` or `autofill/provider.ts`; do not use it as a holding area for a tool's one-off logic. -- Validate runtime environment variables through `shared/environment/env.ts`. `KERNEL_API_KEY` is required by the worker browser tools. +- Validate runtime environment variables through `shared/environment/env.ts`. `BROWSER_PROVIDER` defaults to `kernel`; worker browser tools require `KERNEL_API_KEY` for Kernel or `NOTTE_API_KEY` for Notte. - Run `pnpm check` and `pnpm build` before handing off changes. ## Code organization diff --git a/README.md b/README.md index dca0550f..8aadac4d 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,41 @@ Treat the private Blob store as production key material: deleting it loses the automatically generated encryption key, and rotating that key requires re-encrypting existing vault values. +### Notte browser provider + +To use [Notte](https://www.notte.cc/) cloud browsers instead of Kernel, set: + +```dotenv +BROWSER_PROVIDER=notte +NOTTE_API_KEY=your-notte-api-key +``` + +`BROWSER_PROVIDER` defaults to `kernel`. With Notte selected, `KERNEL_API_KEY` +is not required. The one-click Vercel button still provisions Kernel; configure +these variables yourself for an existing deployment or a manual installation. + +Notte sessions support the semantic browser tools (`browser_snapshot`, +`browser_text`, `browser_find`, `browser_act`, `browser_wait_for`), live viewing, +and the existing secure vault autofill over CDP. Each workspace has its own +Notte profile; `save_changes: true` persists login state when the browser is +closed, and only one writer can be active. Read-only sessions can run in parallel. +CDP connection credentials are kept out of tool results. + +Live validation confirmed cookie-backed profile restoration, but a profile +containing only localStorage did not restore that state. Treat storage-only +login persistence as unverified until that issue is resolved. + +Kernel's remote `playwright_execute`, desktop `computer_action`, and +`capture_browser_image` tools are omitted from the Notte tool set. Notte workers +use semantic actions instead and return no image attachments. Set the viewport +at creation; resizing is not supported. Idle timeouts and maximum session lifetimes are 15–30 minutes (default +15), subject to the Notte account’s plan limits. These sessions explicitly use direct +connections without proxies and enable CAPTCHA solving. Trace domains currently +include the starting URL only for Notte. + +Profiles and open sessions stay with their original provider. Finish/delete open +sessions before switching providers; switching does not migrate saved logins. + ### Blob storage The one-click deploy creates and connects a private Blob store automatically. @@ -190,8 +225,8 @@ development is a manual path and requires: - Node.js 24 and pnpm 11.24.0 - Docker Desktop or another running Docker Compose installation -- Kernel credentials from a [Kernel API key](https://kernel.sh) or a linked - Vercel Marketplace resource +- Browser credentials: a [Kernel API key](https://kernel.sh), a linked + Vercel Marketplace resource, or `BROWSER_PROVIDER=notte` with `NOTTE_API_KEY` - AI Gateway access from an API key or a linked Vercel project's OIDC token First clone and install the application: @@ -202,13 +237,14 @@ cd OpenInstinct pnpm install --frozen-lockfile ``` -For fully manual setup, copy the environment template and add your Kernel and AI +For fully manual setup, copy the environment template and add your browser provider and AI Gateway keys: ```bash cp .env.example .env.local # Set KERNEL_API_KEY and AI_GATEWAY_API_KEY in .env.local. +# For Notte, set BROWSER_PROVIDER=notte and NOTTE_API_KEY instead of KERNEL_API_KEY. ``` If you already use a Vercel project, link it to pull AI Gateway access. If that @@ -230,11 +266,11 @@ pnpm dev migrations, and starts the application. Stopping the development process also stops and removes the PostgreSQL container; its data remains in the `postgres-data` volume for the next run. Run `pnpm dev:app` when intentionally -using an externally managed database instead. If `KERNEL_API_KEY` is missing, +using an externally managed database instead. If the selected browser provider's API key is missing, `pnpm dev` stops before starting Docker and points back to the recommended Vercel flow or the manual `.env.local` setup. -Local development otherwise uses the same vault, Kernel browser, and AI Gateway +Local development otherwise uses the same vault, selected browser provider, and AI Gateway path as the Vercel deployment. Better Auth and vault encryption use stable local-only defaults when their variables are unset. Vercel deployments provision them automatically in private Blob; other production hosts require diff --git a/agent/subagents/browser-agent/instructions.md b/agent/subagents/browser-agent/instructions.md index 3abea291..a51843cb 100644 --- a/agent/subagents/browser-agent/instructions.md +++ b/agent/subagents/browser-agent/instructions.md @@ -23,10 +23,11 @@ You are `browser-agent`, the root coordinator's dedicated browser executor. Comp # Execution -- Use `playwright_execute` as the primary browser execution surface. Prefer one bounded program per page state that inspects, performs related safe actions, verifies the meaningful outcome, and returns a compact result. When Playwright is unreliable or semantic interaction is more suitable, inspect with `browser_snapshot`, `browser_text`, or `browser_find`, then use `browser_act` for a short relaxed action plan. `browser_act` dispatches actions and returns the successor state without waiting for model-authored postconditions; do not repeat an action merely because strict causal verification is absent. Use `browser_wait_for` only when the next operation truly depends on a delayed user-visible state. Use current refs only, and snapshot again after navigation, a stale-ref error, or an unavailable successor. -- Use `computer_action` only when the page requires visual reasoning or coordinate input that the semantic browser tools cannot express. Never use fixed multi-second sleeps; use `browser_wait_for` with a specific semantic state, URL, title, value, or element condition. +- When `playwright_execute` is available, use it as the primary browser execution surface. Prefer one bounded program per page state that inspects, performs related safe actions, verifies the meaningful outcome, and returns a compact result. When Playwright is unreliable or semantic interaction is more suitable, inspect with `browser_snapshot`, `browser_text`, or `browser_find`, then use `browser_act` for a short relaxed action plan. `browser_act` dispatches actions and returns the successor state without waiting for model-authored postconditions; do not repeat an action merely because strict causal verification is absent. Use `browser_wait_for` only when the next operation truly depends on a delayed user-visible state. Use current refs only, and snapshot again after navigation, a stale-ref error, or an unavailable successor. +- With Notte, use `browser_snapshot`, `browser_text`, `browser_find`, `browser_act`, and `browser_wait_for` as the primary surface. Remote Playwright programs, desktop actions, and durable image capture are unavailable; return an empty `images` array. Use the live viewer when a task requires human visual interaction. +- Use `computer_action`, when available, only when the page requires visual reasoning or coordinate input that the semantic browser tools cannot express. Never use fixed multi-second sleeps; use `browser_wait_for` with a specific semantic state, URL, title, value, or element condition. - Create one browser and reuse it. Pass a known target as `start_url`. Start read-only; immediately before a saved login is needed, replace it at the same URL with `save_changes: true`, and delete that writer as soon as authentication succeeds so the profile is saved. Only one writable workspace browser may exist. -- Kernel stealth includes managed CAPTCHA solving. Leave a challenge untouched and make one bounded wait of at most 20 seconds. If it remains, preserve the browser and return the takeover blocker and live-view URL. Never bypass authentication, CAPTCHAs, paywalls, or other access controls. +- Browser sessions enable managed CAPTCHA solving. Leave a challenge untouched and make one bounded wait of at most 20 seconds. If it remains, preserve the browser and return the takeover blocker and live-view URL. Never bypass authentication, CAPTCHAs, paywalls, or other access controls. - Keep ordinary `computer_action` screenshots temporary and model-visible only. Use `capture_browser_image` only when the assignment requests an image or visual evidence materially improves the final result. Prefer an `image_resource` for a requested item photo, and return only descriptors actually produced by the capture tool. - Re-read the page after coordinator-approved continuation or human takeover because the browser state may have changed. - Delete the browser when the assignment succeeds or ends without a pending approval or human action. Keep it open only when approval, authentication, CAPTCHA, or takeover is the sole remaining blocker. diff --git a/agent/subagents/browser-agent/lib/autofill/native.ts b/agent/subagents/browser-agent/lib/autofill/native.ts index 2eeab78c..ef016e37 100644 --- a/agent/subagents/browser-agent/lib/autofill/native.ts +++ b/agent/subagents/browser-agent/lib/autofill/native.ts @@ -1,3 +1,4 @@ +import { notteCdpUrl } from "../notte"; import Kernel from "@onkernel/sdk"; import { z } from "zod"; import { env } from "@shared/environment"; @@ -611,10 +612,17 @@ async function withKernelPage( readonly sessionId: readonly string[]; }) => Promise ) { - const browser = await new Kernel({ - apiKey: env.KERNEL_API_KEY, - }).browsers.retrieve(browserSessionId, {}, { signal }); - const connection = await CdpConnection.connect(browser.cdp_ws_url, signal); + const cdpUrl = + env.BROWSER_PROVIDER === "notte" + ? await notteCdpUrl(browserSessionId, signal) + : ( + await new Kernel({ apiKey: env.KERNEL_API_KEY }).browsers.retrieve( + browserSessionId, + {}, + { signal } + ) + ).cdp_ws_url; + const connection = await CdpConnection.connect(cdpUrl, signal); try { const { targetInfos } = targetListSchema.parse( diff --git a/agent/subagents/browser-agent/lib/kernel.ts b/agent/subagents/browser-agent/lib/kernel.ts index 0d683b3a..23f2cebf 100644 --- a/agent/subagents/browser-agent/lib/kernel.ts +++ b/agent/subagents/browser-agent/lib/kernel.ts @@ -1,4 +1,5 @@ import Kernel from "@onkernel/sdk"; import { env } from "@shared/environment"; -export const kernel = new Kernel({ apiKey: env.KERNEL_API_KEY }); +// Notte uses CDP directly and does not require a Kernel credential. +export const kernel = new Kernel({ apiKey: env.KERNEL_API_KEY ?? "" }); diff --git a/agent/subagents/browser-agent/lib/notte.ts b/agent/subagents/browser-agent/lib/notte.ts new file mode 100644 index 00000000..e3f97462 --- /dev/null +++ b/agent/subagents/browser-agent/lib/notte.ts @@ -0,0 +1,168 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { env } from "@shared/environment"; + +const sessionSchema = z.object({ + session_id: z.string().min(1), + created_at: z.string(), + status: z.enum(["active", "closed", "error", "timed_out"]), + cdp_url: z.url().nullish(), + viewer_url: z.url().nullish(), + viewport_width: z.number().nullish(), + viewport_height: z.number().nullish(), +}); +const profileSchema = z.object({ + profile_id: z.string(), + name: z.string().nullable(), +}); + +// Keep provider and write mode in the existing opaque session ID. This also +// prevents a provider switch from sending old sessions to the wrong service. +export function notteSessionId(id: string, writable: boolean) { + return `notte:${writable ? "write" : "read"}:${id}`; +} + +export function isNotteSession(id: string) { + return id.startsWith("notte:"); +} + +function remoteSessionId(id: string) { + const match = /^notte:(?:read|write):(.+)$/u.exec(id); + if (!match?.[1]) throw new Error("Invalid Notte browser session ID."); + return encodeURIComponent(match[1]); +} + +async function request( + path: string, + method: string, + body?: z.infer>, + signal?: AbortSignal +) { + if (!env.NOTTE_API_KEY) + throw new Error("NOTTE_API_KEY is required for Notte browsers."); + const response = await fetch(`https://api.notte.cc${path}`, { + method, + headers: { + Authorization: `Bearer ${env.NOTTE_API_KEY}`, + "Content-Type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) + : AbortSignal.timeout(60_000), + }); + if (!response.ok) { + // Do not expose response bodies, which can include connection credentials. + throw Object.assign( + new Error(`Notte request failed (HTTP ${String(response.status)}).`), + { status: response.status } + ); + } + const value: unknown = await response.json(); + return value; +} + +export async function ensureNotteProfile( + workspaceId: string, + signal?: AbortSignal +) { + const name = `openinstinct-${createHash("sha256").update(workspaceId).digest("hex").slice(0, 40)}`; + const profiles = z + .object({ items: z.array(profileSchema) }) + .parse( + await request( + `/profiles?name=${name}&page_size=100`, + "GET", + undefined, + signal + ) + ); + const existing = profiles.items.find((profile) => profile.name === name); + if (existing) return existing.profile_id; + return profileSchema.parse( + await request("/profiles/create", "POST", { name }, signal) + ).profile_id; +} + +export async function startNotteBrowser( + input: { + profileId: string; + writable: boolean; + timeoutSeconds: number; + viewport?: { width: number; height: number }; + }, + signal?: AbortSignal +) { + if (input.timeoutSeconds > 1800) + throw new Error("Notte idle timeout cannot exceed 1800 seconds."); + const session = sessionSchema.parse( + await request( + "/sessions/start", + "POST", + { + browser_type: "chromium", + proxies: false, + solve_captchas: true, + idle_timeout_minutes: Math.ceil(input.timeoutSeconds / 60), + max_duration_minutes: Math.ceil(input.timeoutSeconds / 60), + profile: { id: input.profileId, persist: input.writable }, + viewport_width: input.viewport?.width ?? null, + viewport_height: input.viewport?.height ?? null, + }, + signal + ) + ); + return descriptor( + session, + notteSessionId(session.session_id, input.writable) + ); +} + +export async function retrieveNotteBrowser(id: string, signal?: AbortSignal) { + const session = sessionSchema.parse( + await request(`/sessions/${remoteSessionId(id)}`, "GET", undefined, signal) + ); + return descriptor(session, id); +} + +export async function stopNotteBrowser(id: string, signal?: AbortSignal) { + await request( + `/sessions/${remoteSessionId(id)}/stop`, + "DELETE", + undefined, + signal + ); +} + +function descriptor(session: z.infer, id: string) { + return { + session_id: id, + created_at: session.created_at, + cdp_ws_url: session.cdp_url ?? undefined, + browser_live_view_url: session.viewer_url ?? undefined, + status: session.status === "active" ? "active" : "deleted", + profile_save_changes: id.startsWith("notte:write:"), + viewport: + session.viewport_width && session.viewport_height + ? { width: session.viewport_width, height: session.viewport_height } + : undefined, + }; +} + +export async function notteCdpUrl(id: string, signal?: AbortSignal) { + const browser = await retrieveNotteBrowser(id, signal); + if (browser.status !== "active") + throw new Error("Notte browser is closed. Create a new session."); + if (browser.cdp_ws_url) return browser.cdp_ws_url; + const debug = z + .object({ ws: z.object({ cdp: z.url() }) }) + .parse( + await request( + `/sessions/${remoteSessionId(id)}/debug`, + "GET", + undefined, + signal + ) + ); + return debug.ws.cdp; +} diff --git a/agent/subagents/browser-agent/lib/semantic-loop.ts b/agent/subagents/browser-agent/lib/semantic-loop.ts index d71b6e62..6a39a98e 100644 --- a/agent/subagents/browser-agent/lib/semantic-loop.ts +++ b/agent/subagents/browser-agent/lib/semantic-loop.ts @@ -1,14 +1,19 @@ import { + BrowserExecutor, + isBrowserAction, LoopExecutionResources, type BrowserRefState, type LoopToolExecutionResult, type LoopToolSpec, } from "@onkernel/browser-loop"; +import { env } from "@shared/environment"; +import { isNotteSession, notteCdpUrl } from "./notte"; import { defineState } from "eve/context"; import { kernel } from "@agent/subagents/browser-agent/lib/kernel"; /* oxlint-disable anti-slop/no-unsafe-dictionary-type -- Browser Loop's materialized vendor tool accepts arbitrary JSON input by contract. */ +const notteExecutors = new Map(); const resourcesBySession = new Map(); const lockTailsBySession = new Map>(); const refStates = defineState>( @@ -23,6 +28,10 @@ export async function executeBrowserLoopTool( signal?: AbortSignal ) { return withBrowserLoopSessionLock(sessionId, async () => { + if (env.BROWSER_PROVIDER === "notte") + return executeNotteTool(sessionId, spec, input, signal); + if (isNotteSession(sessionId)) + throw new Error("This browser belongs to Notte."); const resources = await resourcesFor(sessionId, signal); let output: LoopToolExecutionResult | undefined; @@ -39,6 +48,8 @@ export async function executeBrowserLoopTool( export async function disposeBrowserLoopSession(sessionId: string) { await withBrowserLoopSessionLock(sessionId, async () => { + notteExecutors.get(sessionId)?.close(); + notteExecutors.delete(sessionId); const resources = resourcesBySession.get(sessionId); resourcesBySession.delete(sessionId); refStates.update((current) => { @@ -106,3 +117,47 @@ async function withBrowserLoopSessionLock( function noop() { return undefined; } + +async function executeNotteTool( + sessionId: string, + spec: LoopToolSpec, + input: Record, + signal?: AbortSignal +): Promise { + if (spec.execution.kind !== "actions") + throw new Error( + "Notte supports semantic CDP tools, not Kernel remote Playwright execution." + ); + let executor = notteExecutors.get(sessionId); + if (!executor) { + executor = new BrowserExecutor(await notteCdpUrl(sessionId, signal)); + const state = refStates.get()[sessionId]; + if (state) executor.importRefState(state); + notteExecutors.set(sessionId, executor); + } + const readResults = []; + try { + /* oxlint-disable eslint/no-await-in-loop -- Browser actions depend on the preceding page state. */ + for (const action of spec.execution.toActions(input)) { + if (!isBrowserAction(action)) + throw new Error("This action requires Kernel desktop APIs."); + readResults.push(...(await executor.execute(action, signal))); + } + /* oxlint-enable eslint/no-await-in-loop */ + return { + content: readResults.map((read) => + read.type === "screenshot" + ? { + type: "image" as const, + data: read.data.toString("base64"), + mimeType: read.mimeType, + } + : { type: "text" as const, text: JSON.stringify(read) } + ), + details: { statusText: "Browser action completed.", readResults }, + }; + } finally { + const state = executor.exportRefState(); + refStates.update((current) => ({ ...current, [sessionId]: state })); + } +} diff --git a/agent/subagents/browser-agent/lib/trace/domains.ts b/agent/subagents/browser-agent/lib/trace/domains.ts index 170d0a5c..55113fcb 100644 --- a/agent/subagents/browser-agent/lib/trace/domains.ts +++ b/agent/subagents/browser-agent/lib/trace/domains.ts @@ -1,3 +1,4 @@ +import { isNotteSession } from "../notte"; import { recordBrowserTraceDomains } from "@db/services/browser-traces"; import type { AccessScope } from "@shared/identity/access-scope"; import { kernel } from "@agent/subagents/browser-agent/lib/kernel"; @@ -43,6 +44,9 @@ export async function harvestBrowserTraceDomains( browser: { createdAt: string; sessionId: string }, signal?: AbortSignal ) { + // Notte does not expose Kernel page-navigation telemetry. The create tool + // records the starting domain separately. + if (isNotteSession(browser.sessionId)) return; try { const domains = await collectNavigationDomains(browser, signal); await recordBrowserTraceDomains(scope, traceSessionId, [...domains]); diff --git a/agent/subagents/browser-agent/tools/capture_browser_image.ts b/agent/subagents/browser-agent/tools/capture_browser_image.ts index 345451d6..6ac63612 100644 --- a/agent/subagents/browser-agent/tools/capture_browser_image.ts +++ b/agent/subagents/browser-agent/tools/capture_browser_image.ts @@ -1,3 +1,4 @@ +import { defineDynamic } from "eve/tools"; import { createHash, randomUUID } from "node:crypto"; import { del, put } from "@vercel/blob"; import { defineTool, toolOutput } from "eve/tools"; @@ -53,7 +54,7 @@ const outputSchema = z.object({ image: browserImageArtifactReferenceSchema }); type CaptureInput = z.infer; -export default defineTool({ +const kernelTool = defineTool({ description: "Capture one durable, user-visible image from an owned browser. Use only when the assignment requests an image or one image materially improves the final result; never persist routine debugging screenshots. Supports viewport or region screenshots, full-page screenshots, rendered element screenshots, and original image resources selected from the current page. Original resource capture falls back to the rendered element when needed. Does not expose private Blob URLs or page credentials.", inputSchema, @@ -366,3 +367,10 @@ async function readBoundedResponse(response: Response) { } return bytes; } + +export default defineDynamic({ + events: { + "session.started": () => + env.BROWSER_PROVIDER === "notte" ? null : kernelTool, + }, +}); diff --git a/agent/subagents/browser-agent/tools/computer_action.ts b/agent/subagents/browser-agent/tools/computer_action.ts index 429fc7ec..574c5d71 100644 --- a/agent/subagents/browser-agent/tools/computer_action.ts +++ b/agent/subagents/browser-agent/tools/computer_action.ts @@ -1,3 +1,5 @@ +import { env } from "@shared/environment"; +import { defineDynamic } from "eve/tools"; import { defineTool, toolOutput, toolOutputPart } from "eve/tools"; import type { ComputerBatchParams } from "@onkernel/sdk/resources/browsers/computer"; import { z } from "zod"; @@ -101,7 +103,7 @@ const outputSchema = z.object({ screenshotBase64: z.string().optional(), }); -export default defineTool({ +const kernelTool = defineTool({ description: "Execute a bounded batch of computer actions on one browser session. Prefer one batch over repeated calls, keep sleep actions at or below two seconds, and include a screenshot last only when visual inspection is needed; screenshots are delivered directly to the vision model.", inputSchema, @@ -270,3 +272,10 @@ function toBatchAction( } throw new Error("Unsupported computer action."); } + +export default defineDynamic({ + events: { + "session.started": () => + env.BROWSER_PROVIDER === "notte" ? null : kernelTool, + }, +}); diff --git a/agent/subagents/browser-agent/tools/fill_from_vault.ts b/agent/subagents/browser-agent/tools/fill_from_vault.ts index 5e783ce8..2dccafaf 100644 --- a/agent/subagents/browser-agent/tools/fill_from_vault.ts +++ b/agent/subagents/browser-agent/tools/fill_from_vault.ts @@ -1,3 +1,5 @@ +import { env } from "@shared/environment"; +import { retrieveNotteBrowser } from "../lib/notte"; import { defineTool } from "eve/tools"; import { z } from "zod"; import { requireOwnedBrowserSession } from "@agent/subagents/browser-agent/lib/owned-browser"; @@ -46,11 +48,17 @@ export default defineTool({ ); } if (item.kind === "login") { - const browser = await kernel.browsers.retrieve( - input.browserSessionId, - {}, - { signal: context.abortSignal } - ); + const browser = + env.BROWSER_PROVIDER === "notte" + ? await retrieveNotteBrowser( + input.browserSessionId, + context.abortSignal + ) + : await kernel.browsers.retrieve( + input.browserSessionId, + {}, + { signal: context.abortSignal } + ); if (!browser.profile_save_changes) { throw new Error( "Login autofill requires a browser created with save_changes: true. Delete this browser, create a writable browser at the same URL, then focus and fill again." diff --git a/agent/subagents/browser-agent/tools/manage_browsers.ts b/agent/subagents/browser-agent/tools/manage_browsers.ts index 64b6cbd1..bfb49db1 100644 --- a/agent/subagents/browser-agent/tools/manage_browsers.ts +++ b/agent/subagents/browser-agent/tools/manage_browsers.ts @@ -1,3 +1,13 @@ +import { env } from "@shared/environment"; +import { BrowserExecutor } from "@onkernel/browser-loop"; +import { + ensureNotteProfile, + isNotteSession, + notteCdpUrl, + retrieveNotteBrowser, + startNotteBrowser, + stopNotteBrowser, +} from "../lib/notte"; import { createHash } from "node:crypto"; import { ConflictError, NotFoundError } from "@onkernel/sdk"; import type { @@ -50,6 +60,14 @@ const manageBrowsers = defineTool({ async execute(input, context) { const scope = await requireWorkerScope(context); const signal = context.abortSignal; + if (env.BROWSER_PROVIDER === "notte") { + return manageNotteBrowsers(input, context, scope); + } + if (input.session_id && isNotteSession(input.session_id)) { + throw new Error( + "This session belongs to Notte. Restore BROWSER_PROVIDER=notte to manage it." + ); + } switch (input.action) { case "create": { @@ -114,7 +132,9 @@ const manageBrowsers = defineTool({ : create(); } case "list": { - const records = await listBrowserSessions(scope); + const records = (await listBrowserSessions(scope)).filter( + ({ sessionId }) => !isNotteSession(sessionId) + ); const includeDeleted = input.status !== "active"; const browsers = await Promise.all( records.map(async ({ sessionId }) => { @@ -297,3 +317,162 @@ async function findActiveProfileWriter( } return undefined; } + +async function manageNotteBrowsers( + input: z.infer, + context: Parameters[0] & { + abortSignal?: AbortSignal; + }, + scope: Awaited> +) { + const signal = context.abortSignal; + if (input.action === "create") { + const viewport = browserViewport(input); + // Serialize profile lookup/creation and writer checks across app instances. + return withBrowserProfileWriteLock(scope, async () => { + const records = (await listBrowserSessions(scope)).filter( + ({ sessionId }) => sessionId.startsWith("notte:write:") + ); + if (input.save_changes) { + const writers = await Promise.all( + records.map(async ({ sessionId }) => { + try { + return await retrieveNotteBrowser(sessionId, signal); + } catch (error) { + if (!isNotFoundError(error)) throw error; + return undefined; + } + }) + ); + if (writers.some((browser) => browser?.status === "active")) { + throw new Error( + "Another Notte browser is saving this workspace profile. Delete it before creating a writer." + ); + } + } + const browser = await startNotteBrowser( + { + profileId: await ensureNotteProfile(scope.workspaceId, signal), + writable: input.save_changes ?? false, + timeoutSeconds: input.timeout_seconds ?? browserTimeoutFloorSeconds, + viewport, + }, + signal + ); + try { + if (input.start_url) { + const executor = new BrowserExecutor( + await notteCdpUrl(browser.session_id, signal) + ); + try { + await executor.execute( + { type: "browser_navigate", url: input.start_url }, + signal + ); + } finally { + executor.close(); + } + } + await createBrowserSession(scope, { + createdAt: browser.created_at, + sessionId: browser.session_id, + workerSessionId: context.session.id, + }); + } catch (error) { + // Cleanup must still run when the originating turn has been cancelled. + await stopNotteBrowser(browser.session_id).catch(() => undefined); + throw error; + } + const domain = input.start_url + ? domainFromUrl(input.start_url) + : undefined; + if (domain) + await recordBrowserTraceDomains(scope, context.session.id, [ + domain, + ]).catch(() => undefined); + return { + browser: describeNotteBrowser(browser), + next_actions: [ + "Use browser_snapshot, browser_find, browser_text, browser_act and browser_wait_for to inspect and control this Notte browser over CDP.", + "Use fill_from_vault for secure autofill. Create with save_changes: true before login and delete the writer to persist it.", + "Remote playwright_execute, computer_action and capture_browser_image are unavailable with Notte. Use the live-view URL for human takeover when needed.", + "Delete this browser with manage_browsers when finished.", + ], + }; + }); + } + if (input.action === "list") { + const records = (await listBrowserSessions(scope)).filter(({ sessionId }) => + isNotteSession(sessionId) + ); + const browsers = await Promise.all( + records.map(async ({ sessionId }) => { + try { + return describeNotteBrowser( + await retrieveNotteBrowser(sessionId, signal) + ); + } catch (error) { + if (!isNotFoundError(error)) throw error; + await deleteBrowserSession(scope, sessionId); + return undefined; + } + }) + ); + const filtered = browsers.filter( + (browser) => + browser && + (!input.status || + input.status === "all" || + browser.status === input.status) + ); + const offset = input.offset ?? 0; + const end = offset + (input.limit ?? 100); + return { + items: filtered.slice(offset, end), + has_more: filtered.length > end, + next_offset: filtered.length > end ? end : null, + }; + } + const id = requireSessionId(input.session_id); + await requireOwnedBrowserSession(scope, id); + if (!isNotteSession(id)) + throw new Error( + "This session belongs to Kernel. Restore BROWSER_PROVIDER=kernel to manage it." + ); + if (input.action === "delete") { + await disposeBrowserLoopSession(id); + try { + await stopNotteBrowser(id, signal); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } + await deleteBrowserSession(scope, id); + return "Browser session deleted successfully"; + } + if (input.action === "update" && browserViewport(input)) { + throw new Error( + "Set the Notte viewport when creating the browser; resizing an existing session is not supported." + ); + } + try { + return describeNotteBrowser(await retrieveNotteBrowser(id, signal)); + } catch (error) { + if (!isNotFoundError(error)) throw error; + await disposeBrowserLoopSession(id); + await deleteBrowserSession(scope, id); + throw new Error("Notte browser no longer exists. Create a fresh browser.", { + cause: error, + }); + } +} + +function describeNotteBrowser( + browser: Awaited> +) { + return { + session_id: browser.session_id, + status: browser.status, + browser_live_view_url: browser.browser_live_view_url, + viewport: browser.viewport, + }; +} diff --git a/agent/subagents/browser-agent/tools/semantic_browser.ts b/agent/subagents/browser-agent/tools/semantic_browser.ts index 2c773a50..fbd852f3 100644 --- a/agent/subagents/browser-agent/tools/semantic_browser.ts +++ b/agent/subagents/browser-agent/tools/semantic_browser.ts @@ -1,3 +1,4 @@ +import { env } from "@shared/environment"; import { loop, type BrowserActResult, @@ -24,7 +25,14 @@ const allSpecs = [ loop.tools.browser.act(), loop.tools.playwright(), ]; -const specsByName = new Map(allSpecs.map((spec) => [spec.name, spec])); +const specsByName = new Map( + allSpecs + .filter( + (spec) => + env.BROWSER_PROVIDER !== "notte" || spec.name !== "playwright_execute" + ) + .map((spec) => [spec.name, spec]) +); const relaxedBrowserActTimeoutMs = 8_000; const relaxedBrowserActSnapshotCharacters = 4_000; const relaxedBrowserActOutputCharacters = 6_000; @@ -33,15 +41,21 @@ export default defineDynamic({ events: { "session.started": () => { return Object.fromEntries( - allSpecs.map((spec) => [ - spec.name, - defineTool({ - description: toolDescription(spec), - execute: executeSemanticTool, - inputSchema: withSessionId(spec), - toModelOutput, - }), - ]) + allSpecs + .filter( + (spec) => + env.BROWSER_PROVIDER !== "notte" || + spec.name !== "playwright_execute" + ) + .map((spec) => [ + spec.name, + defineTool({ + description: toolDescription(spec), + execute: executeSemanticTool, + inputSchema: withSessionId(spec), + toModelOutput, + }), + ]) ); }, }, @@ -134,7 +148,7 @@ function withSessionId(spec: LoopToolSpec) { additionalProperties: false, properties: { session_id: { - description: "Owned Kernel browser session ID.", + description: "Owned browser session ID.", minLength: 1, type: "string", }, diff --git a/scripts/dev.ts b/scripts/dev.ts index 0e713fcb..394a59e1 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -154,7 +154,16 @@ function childExitCode(child: ChildProcess) { }); } -function requireKernelApiKey() { +function requireBrowserApiKey() { + const provider = inheritedEnvironment.BROWSER_PROVIDER; + if (provider === "notte") { + if (inheritedEnvironment.NOTTE_API_KEY?.trim()) return; + throw new Error( + "NOTTE_API_KEY is required when BROWSER_PROVIDER=notte. Set it in .env.local and run pnpm dev again." + ); + } + if (provider !== undefined && provider !== "" && provider !== "kernel") + throw new Error("BROWSER_PROVIDER must be kernel or notte."); if (inheritedEnvironment.KERNEL_API_KEY?.trim()) return; throw new Error( @@ -168,7 +177,7 @@ function requireKernelApiKey() { } try { - requireKernelApiKey(); + requireBrowserApiKey(); composeAttempted = true; let shouldContinue = await run( "docker", diff --git a/shared/environment/env.ts b/shared/environment/env.ts index 06f4f3ad..4eafc81f 100644 --- a/shared/environment/env.ts +++ b/shared/environment/env.ts @@ -63,7 +63,15 @@ export const env = createEnv({ server: { // Required DATABASE_URL: databaseUrlSchema, - KERNEL_API_KEY: requiredValue, + BROWSER_PROVIDER: z.enum(["kernel", "notte"]).default("kernel"), + KERNEL_API_KEY: + process.env.BROWSER_PROVIDER === "notte" + ? requiredValue.optional() + : requiredValue, + NOTTE_API_KEY: + process.env.BROWSER_PROVIDER === "notte" + ? requiredValue + : requiredValue.optional(), // Optional overrides with local defaults. Vercel deployments provision // installation secrets in their connected private Blob store. diff --git a/shared/environment/environment.test.ts b/shared/environment/environment.test.ts index 71e8913e..9a673009 100644 --- a/shared/environment/environment.test.ts +++ b/shared/environment/environment.test.ts @@ -16,6 +16,8 @@ describe("environment", () => { for (const [name, value] of Object.entries(requiredEnvironment)) { vi.stubEnv(name, value); } + vi.stubEnv("BROWSER_PROVIDER", ""); + vi.stubEnv("NOTTE_API_KEY", ""); vi.stubEnv("LINQ_CONNECTOR", ""); vi.stubEnv("LINQ_PHONE_NUMBER", ""); }); @@ -31,6 +33,34 @@ describe("environment", () => { expect(env).toMatchObject(requiredEnvironment); }); + it("defaults to Kernel", async () => { + const { env } = await import("@shared/environment"); + expect(env.BROWSER_PROVIDER).toBe("kernel"); + }); + + it("accepts Notte without a Kernel key", async () => { + vi.stubEnv("BROWSER_PROVIDER", "notte"); + vi.stubEnv("NOTTE_API_KEY", "test-notte-key"); + vi.stubEnv("KERNEL_API_KEY", ""); + const { env } = await import("@shared/environment"); + expect(env.BROWSER_PROVIDER).toBe("notte"); + expect(env.KERNEL_API_KEY).toBeUndefined(); + }); + + it("requires the selected provider's key", async () => { + vi.stubEnv("BROWSER_PROVIDER", "notte"); + await expect(import("@shared/environment")).rejects.toThrow( + "Invalid environment variables" + ); + }); + + it("rejects an unknown browser provider", async () => { + vi.stubEnv("BROWSER_PROVIDER", "other"); + await expect(import("@shared/environment")).rejects.toThrow( + "Invalid environment variables" + ); + }); + it("provides the Google connector default without enabling Linq", async () => { vi.stubEnv("GOOGLE_CONNECTOR_UID", ""); diff --git a/tests/agent-tool-boundaries.test.ts b/tests/agent-tool-boundaries.test.ts index d8aef859..b7cf2700 100644 --- a/tests/agent-tool-boundaries.test.ts +++ b/tests/agent-tool-boundaries.test.ts @@ -112,7 +112,9 @@ describe("root and worker capability boundaries", () => { ]) { const source = readFileSync(`${workerTools}/${tool}.ts`, "utf8"); expect(source).toContain("defineTool("); - expect(source).not.toContain("defineDynamic("); + expect(source.includes("defineDynamic(")).toBe( + tool === "computer_action" || tool === "capture_browser_image" + ); expect(source).toContain("requireWorkerScope(context)"); } expect(existsSync(`${workerRoot}/hooks/session-owner.ts`)).toBe(true); @@ -142,7 +144,7 @@ describe("root and worker capability boundaries", () => { ); expect(workerInstructions).toContain("such as Google Flights"); expect(workerInstructions).toContain( - "Use `playwright_execute` as the primary browser execution surface" + "When `playwright_execute` is available, use it as the primary browser execution surface" ); expect(workerInstructions).toContain( "Prefer one bounded program per page state" diff --git a/tests/agent/subagents/browser-agent/tools/browser-provider-tools.test.ts b/tests/agent/subagents/browser-agent/tools/browser-provider-tools.test.ts new file mode 100644 index 00000000..f962115a --- /dev/null +++ b/tests/agent/subagents/browser-agent/tools/browser-provider-tools.test.ts @@ -0,0 +1,62 @@ +import type * as Environment from "@shared/environment"; +import { describe, expect, it, vi } from "vitest"; +import semanticBrowser from "@agent/subagents/browser-agent/tools/semantic_browser"; +import computerAction from "@agent/subagents/browser-agent/tools/computer_action"; +import captureImage from "@agent/subagents/browser-agent/tools/capture_browser_image"; + +const settings = vi.hoisted(() => ({ provider: "notte" })); + +vi.mock("@shared/environment", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + env: { + ...original.env, + get BROWSER_PROVIDER() { + return settings.provider; + }, + }, + }; +}); +const context = { + session: { id: "test", auth: { current: null, initiator: null } }, + channel: {}, + messages: [], +}; + +describe("provider tool selection", () => { + it("exposes semantic CDP tools and omits Kernel-only tools with Notte", async () => { + const tools = await semanticBrowser.events["session.started"]?.( + {}, + context + ); + expect(Object.keys(tools ?? {})).toEqual([ + "browser_snapshot", + "browser_text", + "browser_find", + "browser_wait_for", + "browser_act", + ]); + expect( + await computerAction.events["session.started"]?.({}, context) + ).toBeNull(); + expect( + await captureImage.events["session.started"]?.({}, context) + ).toBeNull(); + }); + + it("keeps the full Kernel tool surface by default", async () => { + settings.provider = "kernel"; + const tools = await semanticBrowser.events["session.started"]?.( + {}, + context + ); + expect(tools).toHaveProperty("playwright_execute"); + expect( + await computerAction.events["session.started"]?.({}, context) + ).not.toBeNull(); + expect( + await captureImage.events["session.started"]?.({}, context) + ).not.toBeNull(); + }); +}); diff --git a/tests/agent/subagents/browser-agent/tools/notte-browser.test.ts b/tests/agent/subagents/browser-agent/tools/notte-browser.test.ts new file mode 100644 index 00000000..a6547a0e --- /dev/null +++ b/tests/agent/subagents/browser-agent/tools/notte-browser.test.ts @@ -0,0 +1,230 @@ +import { z } from "zod"; +import type { AccessScope } from "@shared/identity/access-scope"; +import { harvestBrowserTraceDomains } from "@agent/subagents/browser-agent/lib/trace/domains"; +/* oxlint-disable vitest/require-mock-type-parameters -- Fixtures mock the external service boundaries exercised here. */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toolContextFor } from "@tests/helpers/tool-context"; + +const mocks = vi.hoisted(() => ({ + fetch: vi.fn(), + create: vi.fn(), + remove: vi.fn(), + list: vi.fn(), + owned: vi.fn(), + execute: vi.fn(), + close: vi.fn(), +})); +vi.mock("@shared/environment", () => ({ + env: { BROWSER_PROVIDER: "notte", NOTTE_API_KEY: "test-notte-key" }, +})); +vi.mock("@db/services/browsers", () => ({ + createBrowserSession: mocks.create, + deleteBrowserSession: mocks.remove, + listBrowserSessions: mocks.list, + withBrowserProfileWriteLock: ( + _scope: AccessScope, + operation: () => Promise + ) => operation(), +})); +vi.mock("@db/services/browser-traces", () => ({ + recordBrowserTraceDomains: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@agent/subagents/browser-agent/lib/access", () => ({ + requireWorkerScope: () => ({ workspaceId: "workspace-1", userId: "user-1" }), +})); +vi.mock("@agent/subagents/browser-agent/lib/owned-browser", () => ({ + requireOwnedBrowserSession: mocks.owned, +})); +vi.mock("@agent/subagents/browser-agent/lib/semantic-loop", () => ({ + disposeBrowserLoopSession: vi.fn(), +})); +vi.mock("@onkernel/browser-loop", () => ({ + BrowserExecutor: class { + execute = mocks.execute; + close = mocks.close; + }, +})); +import manageBrowsers from "@agent/subagents/browser-agent/tools/manage_browsers"; +import { notteCdpUrl } from "@agent/subagents/browser-agent/lib/notte"; +import { kernel } from "@agent/subagents/browser-agent/lib/kernel"; + +const session = { + session_id: "remote-id", + created_at: "2026-09-01T00:00:00Z", + status: "active", + cdp_url: "wss://cdp.notte.test/session?token=secret", + viewer_url: "https://viewer.notte.test/session", +}; +function json(value: z.infer>, status = 200) { + return new Response(JSON.stringify(value), { status }); +} +function startResponses() { + mocks.fetch.mockResolvedValueOnce( + json({ items: [{ name: null, profile_id: "unrelated" }] }) + ); + mocks.fetch.mockResolvedValueOnce( + json({ profile_id: "profile-1", name: null }) + ); + mocks.fetch.mockResolvedValueOnce(json(session)); +} +beforeEach(() => { + vi.clearAllMocks(); + mocks.fetch.mockReset(); + vi.stubGlobal("fetch", mocks.fetch); + mocks.list.mockResolvedValue([]); + mocks.create.mockResolvedValue(undefined); + mocks.owned.mockResolvedValue({ sessionId: "notte:read:remote-id" }); +}); + +describe("Notte browser lifecycle", () => { + it("creates a persistent workspace session without calling Kernel or exposing CDP credentials", async () => { + const kernelCreate = vi.spyOn(kernel.browsers, "create"); + startResponses(); + const result = await manageBrowsers.execute( + { action: "create", save_changes: true }, + toolContextFor() + ); + expect(result).toMatchObject({ + browser: { + session_id: "notte:write:remote-id", + browser_live_view_url: session.viewer_url, + }, + }); + expect(JSON.stringify(result)).not.toContain("token=secret"); + const body = z + .json() + .parse( + JSON.parse(z.string().parse(mocks.fetch.mock.calls[2]?.[1]?.body)) + ); + expect(body).toMatchObject({ + profile: { id: "profile-1", persist: true }, + proxies: false, + idle_timeout_minutes: 15, + max_duration_minutes: 15, + }); + expect(mocks.create).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ sessionId: "notte:write:remote-id" }) + ); + expect(kernelCreate).not.toHaveBeenCalled(); + }); + + it("cleans up a created session if database persistence fails, independently of cancellation", async () => { + startResponses(); + mocks.fetch.mockResolvedValueOnce(json(session)); + mocks.create.mockRejectedValueOnce(new Error("database unavailable")); + await expect( + manageBrowsers.execute({ action: "create" }, toolContextFor()) + ).rejects.toThrow("database unavailable"); + expect(mocks.fetch).toHaveBeenLastCalledWith( + "https://api.notte.cc/sessions/remote-id/stop", + expect.objectContaining({ method: "DELETE" }) + ); + }); + + it("navigates through CDP and closes the connection before recording a session", async () => { + startResponses(); + mocks.fetch.mockResolvedValueOnce(json(session)); + await manageBrowsers.execute( + { action: "create", start_url: "https://example.com" }, + toolContextFor() + ); + expect(mocks.execute).toHaveBeenCalledWith( + { type: "browser_navigate", url: "https://example.com" }, + expect.anything() + ); + expect(mocks.close).toHaveBeenCalledOnce(); + }); + + it("blocks a second active profile writer", async () => { + mocks.list.mockResolvedValue([{ sessionId: "notte:write:existing" }]); + mocks.fetch.mockResolvedValueOnce( + json({ ...session, session_id: "existing" }) + ); + await expect( + manageBrowsers.execute( + { action: "create", save_changes: true }, + toolContextFor() + ) + ).rejects.toThrow("Another Notte browser"); + expect(mocks.fetch).toHaveBeenCalledOnce(); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("lists only owned Notte sessions and retains Kernel records", async () => { + mocks.list.mockResolvedValue([ + { sessionId: "kernel-id" }, + { sessionId: "notte:read:remote-id" }, + ]); + mocks.fetch.mockResolvedValueOnce(json(session)); + const result = await manageBrowsers.execute( + { action: "list" }, + toolContextFor() + ); + expect(result).toMatchObject({ + items: [{ session_id: "notte:read:remote-id" }], + }); + expect(mocks.fetch).toHaveBeenCalledOnce(); + expect(mocks.remove).not.toHaveBeenCalled(); + }); + + it("checks ownership before contacting Notte", async () => { + mocks.owned.mockRejectedValueOnce(new Error("not owned")); + await expect( + manageBrowsers.execute( + { action: "get", session_id: "notte:read:remote-id" }, + toolContextFor() + ) + ).rejects.toThrow("not owned"); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("tolerates an already removed remote session during deletion", async () => { + mocks.fetch.mockResolvedValueOnce(json({}, 404)); + await manageBrowsers.execute( + { action: "delete", session_id: "notte:read:remote-id" }, + toolContextFor() + ); + expect(mocks.remove).toHaveBeenCalledWith( + expect.anything(), + "notte:read:remote-id" + ); + }); + + it("does not remove local records on transient provider failure", async () => { + mocks.fetch.mockResolvedValueOnce(json({ detail: "sensitive" }, 503)); + await expect( + manageBrowsers.execute( + { action: "delete", session_id: "notte:read:remote-id" }, + toolContextFor() + ) + ).rejects.toThrow("HTTP 503"); + expect(mocks.remove).not.toHaveBeenCalled(); + }); + + it("does not contact Kernel telemetry for Notte sessions", async () => { + const telemetry = vi.spyOn(kernel.browsers.telemetry, "events"); + await harvestBrowserTraceDomains( + { workspaceId: "workspace-1", userId: "user-1" }, + "trace-1", + { sessionId: "notte:read:remote-id", createdAt: session.created_at } + ); + expect(telemetry).not.toHaveBeenCalled(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("falls back to authenticated debug discovery if status omits CDP", async () => { + mocks.fetch.mockResolvedValueOnce(json({ ...session, cdp_url: null })); + mocks.fetch.mockResolvedValueOnce(json({ ws: { cdp: session.cdp_url } })); + expect(await notteCdpUrl("notte:read:remote-id")).toBe(session.cdp_url); + expect(mocks.fetch).toHaveBeenLastCalledWith( + "https://api.notte.cc/sessions/remote-id/debug", + expect.objectContaining({ + headers: { + Authorization: "Bearer test-notte-key", + "Content-Type": "application/json", + }, + }) + ); + }); +}); diff --git a/tests/agent/subagents/browser-agent/tools/worker-browser-images.test.ts b/tests/agent/subagents/browser-agent/tools/worker-browser-images.test.ts index 6229f4ea..e3bb6a2c 100644 --- a/tests/agent/subagents/browser-agent/tools/worker-browser-images.test.ts +++ b/tests/agent/subagents/browser-agent/tools/worker-browser-images.test.ts @@ -58,7 +58,19 @@ vi.mock("@agent/subagents/browser-agent/lib/kernel", () => ({ }, })); -import captureBrowserImage from "@agent/subagents/browser-agent/tools/capture_browser_image"; +import captureBrowserImageDefinition from "@agent/subagents/browser-agent/tools/capture_browser_image"; + +const captureBrowserImage = await captureBrowserImageDefinition.events[ + "session.started" +]?.( + {}, + { + session: { id: "test", auth: { current: null, initiator: null } }, + channel: {}, + messages: [], + } +); +if (!captureBrowserImage) throw new Error("Kernel tool is unavailable."); const scope = { userId: "user-1", workspaceId: "workspace-1" }; const reservation = { diff --git a/tests/agent/subagents/browser-agent/tools/worker-browser-tools.test.ts b/tests/agent/subagents/browser-agent/tools/worker-browser-tools.test.ts index 174d99f0..13df6d06 100644 --- a/tests/agent/subagents/browser-agent/tools/worker-browser-tools.test.ts +++ b/tests/agent/subagents/browser-agent/tools/worker-browser-tools.test.ts @@ -3,7 +3,19 @@ import * as WorkerAccess from "@agent/subagents/browser-agent/lib/access"; import * as OwnedBrowser from "@agent/subagents/browser-agent/lib/owned-browser"; import { kernel } from "@agent/subagents/browser-agent/lib/kernel"; import { toolContextFor } from "@tests/helpers/tool-context"; -import computerAction from "@agent/subagents/browser-agent/tools/computer_action"; +import computerActionDefinition from "@agent/subagents/browser-agent/tools/computer_action"; + +const computerAction = await computerActionDefinition.events[ + "session.started" +]?.( + {}, + { + session: { id: "test", auth: { current: null, initiator: null } }, + channel: {}, + messages: [], + } +); +if (!computerAction) throw new Error("Kernel tool is unavailable."); const mocks = { batch: vi.spyOn(kernel.browsers.computer, "batch"), diff --git a/tests/local-development.test.ts b/tests/local-development.test.ts index 9517064d..39988053 100644 --- a/tests/local-development.test.ts +++ b/tests/local-development.test.ts @@ -87,6 +87,23 @@ describe("local development", supervisorTestOptions, () => { expect(result.stderr).toContain("create a key at https://kernel.sh"); }); + it("starts with a Notte key and no Kernel key", async () => { + const result = await runSuccessfulSupervisor({ + BROWSER_PROVIDER: "notte", + NOTTE_API_KEY: "test-notte-key", + KERNEL_API_KEY: "", + }); + expect(result.code).toBe(0); + expect(result.commands).toContain("pnpm dev:app"); + }); + + it("rejects a missing Notte key before starting Docker", async () => { + const result = await runWithoutKernelApiKey({ BROWSER_PROVIDER: "notte" }); + expect(result.code).toBe(1); + expect(result.commands).toBe(""); + expect(result.stderr).toContain("NOTTE_API_KEY is required"); + }); + it("does not advance when interrupted startup exits cleanly", async () => { const result = await interruptDuringStartup({ DEV_STARTUP_EXIT: "0" }); @@ -213,7 +230,9 @@ printf 'pnpm %s\\n' "$*" >> "$DEV_SUPERVISOR_LOG" }; } -async function runSuccessfulSupervisor() { +async function runSuccessfulSupervisor( + environment: Record = {} +) { const directory = await mkdtemp(join(tmpdir(), "open-instinct-dev-")); temporaryDirectories.push(directory); const logPath = join(directory, "commands.log"); @@ -247,6 +266,7 @@ printf 'pnpm %s %s\n' "$*" "$DATABASE_URL" >> "$DEV_SUPERVISOR_LOG" KERNEL_API_KEY: "test-kernel-key", NODE_ENV: "test", PATH: directory, + ...environment, }, stdio: "ignore", } @@ -259,7 +279,9 @@ printf 'pnpm %s %s\n' "$*" "$DATABASE_URL" >> "$DEV_SUPERVISOR_LOG" }; } -async function runWithoutKernelApiKey() { +async function runWithoutKernelApiKey( + environment: Record = {} +) { const directory = await mkdtemp(join(tmpdir(), "open-instinct-dev-")); temporaryDirectories.push(directory); const logPath = join(directory, "commands.log"); @@ -280,6 +302,7 @@ printf '%s\n' "$*" >> "$DEV_SUPERVISOR_LOG" DEV_SUPERVISOR_LOG: logPath, NODE_ENV: "test", PATH: directory, + ...environment, }, stdio: ["ignore", "ignore", "pipe"], } diff --git a/tests/turbo-config.test.ts b/tests/turbo-config.test.ts index 4a758395..f4ba8137 100644 --- a/tests/turbo-config.test.ts +++ b/tests/turbo-config.test.ts @@ -8,6 +8,8 @@ const applicationEnvironment = [ "DATABASE_URL", "*_CONNECTOR_UID", "KERNEL_*", + "BROWSER_PROVIDER", + "NOTTE_*", "LINQ_*", "NODE_ENV", "SECRET_ENCRYPTION_KEY", diff --git a/turbo.json b/turbo.json index 129fb85f..013cccf2 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,8 @@ "EVE_NEXT_*", "*_CONNECTOR_UID", "KERNEL_*", + "BROWSER_PROVIDER", + "NOTTE_*", "LINQ_*", "NODE_ENV", "SECRET_ENCRYPTION_KEY", @@ -25,6 +27,8 @@ "DATABASE_URL", "*_CONNECTOR_UID", "KERNEL_*", + "BROWSER_PROVIDER", + "NOTTE_*", "LINQ_*", "NODE_ENV", "SECRET_ENCRYPTION_KEY", @@ -45,6 +49,8 @@ "DATABASE_URL", "*_CONNECTOR_UID", "KERNEL_*", + "BROWSER_PROVIDER", + "NOTTE_*", "LINQ_*", "NODE_ENV", "SECRET_ENCRYPTION_KEY", @@ -72,6 +78,8 @@ "DATABASE_URL", "*_CONNECTOR_UID", "KERNEL_*", + "BROWSER_PROVIDER", + "NOTTE_*", "LINQ_*", "NODE_ENV", "SECRET_ENCRYPTION_KEY",