Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk-typescript/packages/adk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"node": ">=18.0.0"
},
"dependencies": {
"@wave-av/kernel": "workspace:*",
"zod": "^3.23.0"
},
"devDependencies": {
Expand Down
116 changes: 116 additions & 0 deletions sdk-typescript/packages/adk/src/__tests__/kernel-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock the shared Kernel client so the adapter's lifecycle (create → playwright
// execute → deleteByID) is observable without a live Kernel API key.
const create = vi.fn();
const execute = vi.fn();
const deleteByID = vi.fn().mockResolvedValue(undefined);

vi.mock('@wave-av/kernel', async () => {
const actual = await vi.importActual<typeof import('@wave-av/kernel')>('@wave-av/kernel');
return {
...actual,
WaveKernel: class {
browsers = {
create,
deleteByID,
playwright: { execute },
};
},
};
});

async function tools() {
const { createKernelTools } = await import('../adapters/kernel');
return createKernelTools({ apiKey: 'test-key' });
}

function tool(name: string) {
return tools().then((ts) => ts.find((t) => t.name === name)!);
}

/** The `code` string of the nth (0-indexed) `playwright.execute` call. */
function executedCode(nth: number): string {
return (execute.mock.calls[nth][1] as { code: string }).code;
}

beforeEach(() => {
create.mockReset().mockResolvedValue({ session_id: 'br_123', cdp_ws_url: 'ws://x' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:
Insecure WebSocket Detected. WebSocket Secure (wss) should be used for all WebSocket connections.

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by detect-insecure-websocket.

You can view more details about this finding in the Semgrep AppSec Platform.

execute.mockReset();
deleteByID.mockReset().mockResolvedValue(undefined);
});

describe('Kernel adapter — lifecycle', () => {
it('browse_url creates a session, runs goto/title/content code, then tears it down', async () => {
execute.mockResolvedValue({ success: true, result: { title: 'T', content: '<html>' } });
const browse = await tool('browse_url');

const out = await browse.handler({ url: 'https://example.com' });

expect(create).toHaveBeenCalledOnce();
expect(execute).toHaveBeenCalledWith('br_123', { code: expect.stringContaining('page.goto') });
expect(executedCode(0)).toContain('page.title');
expect(deleteByID).toHaveBeenCalledWith('br_123');
expect(out).toEqual({ title: 'T', content: '<html>' });
});

it('waitForSelector is injected into the executed code when provided', async () => {
execute.mockResolvedValue({ success: true, result: {} });
const browse = await tool('browse_url');

await browse.handler({ url: 'https://example.com', waitForSelector: '#player' });

expect(executedCode(0)).toContain('waitForSelector');
expect(executedCode(0)).toContain('#player');
});

it('take_screenshot returns base64 and uses fullPage when no selector', async () => {
execute.mockResolvedValue({ success: true, result: { screenshotBase64: 'AAAA', width: 800, height: 600 } });
const shot = await tool('take_screenshot');

const out = await shot.handler({ url: 'https://example.com', width: 800, height: 600 });

expect(executedCode(0)).toContain('fullPage: true');
expect(out).toEqual({ screenshotBase64: 'AAAA', width: 800, height: 600 });
});

it('take_screenshot scopes to a locator (no fullPage) when a selector is given', async () => {
execute.mockResolvedValue({ success: true, result: {} });
const shot = await tool('take_screenshot');

await shot.handler({ url: 'https://example.com', selector: '.video' });

const code = executedCode(0);
expect(code).toContain('page.locator');
expect(code).not.toContain('fullPage');
});

it('run_playwright navigates first when a url is given, then runs the user code', async () => {
execute.mockResolvedValue({ success: true, result: 'done' });
const run = await tool('run_playwright');

const out = await run.handler({ code: 'return await page.title();', url: 'https://example.com' });

expect(executedCode(0)).toContain('page.goto');
expect(execute).toHaveBeenNthCalledWith(2, 'br_123', { code: 'return await page.title();' });
expect(out).toEqual({ output: 'done', logs: [] });
});

it('run_playwright surfaces stdout/stderr as logs', async () => {
execute.mockResolvedValue({ success: true, result: 'ok', stdout: 'hello', stderr: 'warn' });
const run = await tool('run_playwright');

const out = await run.handler({ code: 'return 1;' });

expect(out).toEqual({ output: 'ok', logs: ['hello', 'warn'] });
});

it('a non-success execution surfaces a KernelApiError AND still tears the session down', async () => {
const { KernelApiError } = await import('@wave-av/kernel');
execute.mockResolvedValue({ success: false, error: 'boom' });
const browse = await tool('browse_url');

await expect(browse.handler({ url: 'https://example.com' })).rejects.toBeInstanceOf(KernelApiError);
expect(deleteByID).toHaveBeenCalledWith('br_123');
});
});
170 changes: 126 additions & 44 deletions sdk-typescript/packages/adk/src/adapters/kernel.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,42 @@
/**
* Kernel.sh Adapter for WAVE ADK
* Kernel Adapter for WAVE ADK
*
* Provides cloud browser capabilities to WAVE agents via Kernel.sh:
* Provides cloud browser capabilities to WAVE agents via Kernel:
* - Screenshot live stream players for visual QA
* - Interact with third-party dashboards (Mux, Cloudflare)
* - Run visual regression on embedded players
* - Scrape competitor streaming pages for intelligence
*
* Kernel provides managed cloud browsers (unikernel-based)
* with Playwright API, session persistence, and file I/O.
* Kernel provides managed cloud browsers (unikernel-based) with a Playwright
* execution surface, session persistence, and file I/O.
*
* All Kernel access goes through the shared {@link WaveKernel} client
* (`@wave-av/kernel`), never a hand-rolled fetch — one place for telemetry,
* resilience, auth, and the SDK's real resource surface
* (law: kernel-substrate-governed).
*
* @see https://docs.onkernel.com
*/

import { WaveKernel, KernelApiError } from '@wave-av/kernel';
import { z } from 'zod';
import type { AgentTool } from '../tools/AgentToolkit.js';

export interface KernelConfig {
readonly apiKey: string;
/** Override the Kernel API base URL. Defaults to the SDK default (`https://api.onkernel.com/`). */
readonly baseUrl?: string;
}

const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_WIDTH = 1920;
const DEFAULT_HEIGHT = 1080;

/**
* Create WAVE ADK tools backed by Kernel.sh cloud browsers.
* Create WAVE ADK tools backed by Kernel cloud browsers.
*
* Each tool spins up a fresh Kernel browser session, drives it through the
* SDK's Playwright execution surface, and always tears the session down.
*
* Usage:
* ```typescript
Expand All @@ -34,84 +48,152 @@ export interface KernelConfig {
* ```
*/
export function createKernelTools(config: KernelConfig): AgentTool[] {
const baseUrl = config.baseUrl ?? 'https://api.onkernel.com';
const kernel = new WaveKernel({
apiKey: config.apiKey,
...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}),
});

async function kernelFetch<T>(path: string, body: Record<string, unknown>): Promise<T> {
const response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});

if (!response.ok) {
throw new Error(`Kernel API error: ${response.status} ${response.statusText}`);
/**
* Create a fresh cloud browser, run `fn` against its session id, and always
* terminate the session afterwards (even if `fn` throws). Teardown failures
* are swallowed so they never mask the real result/error.
*/
async function withBrowser<T>(fn: (browserId: string) => Promise<T>): Promise<T> {
const browser = await kernel.browsers.create({});
try {
return await fn(browser.session_id);
} finally {
await kernel.browsers.deleteByID(browser.session_id).catch(() => {});
}
}
Comment on lines +61 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Teardown failures are swallowed with no observability.

kernel.browsers.deleteByID(...).catch(() => {}) silently discards any teardown failure. If deletes fail intermittently, cloud browser sessions leak (and likely accrue cost) with no signal to detect it. Given telemetry integration is explicitly deferred to Phase C, at minimum log the swallowed error (e.g., console.error) as a stopgap until the full withTelemetry wrapper lands.

♻️ Suggested stopgap logging
     } finally {
-      await kernel.browsers.deleteByID(browser.session_id).catch(() => {});
+      await kernel.browsers.deleteByID(browser.session_id).catch((err) => {
+        console.error(`Failed to tear down Kernel browser ${browser.session_id}:`, err);
+      });
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function withBrowser<T>(fn: (browserId: string) => Promise<T>): Promise<T> {
const browser = await kernel.browsers.create({});
try {
return await fn(browser.session_id);
} finally {
await kernel.browsers.deleteByID(browser.session_id).catch(() => {});
}
}
async function withBrowser<T>(fn: (browserId: string) => Promise<T>): Promise<T> {
const browser = await kernel.browsers.create({});
try {
return await fn(browser.session_id);
} finally {
await kernel.browsers.deleteByID(browser.session_id).catch((err) => {
console.error(`Failed to tear down Kernel browser ${browser.session_id}:`, err);
});
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk-typescript/packages/adk/src/adapters/kernel.ts` around lines 61 - 68,
Update withBrowser’s finally cleanup to log any error from
kernel.browsers.deleteByID instead of silently swallowing it; retain the cleanup
attempt and allow the original callback result or error to propagate unchanged.


return response.json() as Promise<T>;
/**
* Execute Playwright code in the browser VM. The code has access to `page`,
* `context`, and `browser`, and returns via `return`. A non-success result is
* surfaced through the shared Kernel error taxonomy. Returns the full
* execution response so callers can read `result`, `stdout`, and `stderr`.
*/
async function execute(browserId: string, code: string) {
const res = await kernel.browsers.playwright.execute(browserId, { code });
if (!res.success) {
throw new KernelApiError(res.error ?? 'Playwright execution failed', undefined, {
browserId,
});
}
return res;
}

const browseSchema = z.object({ url: z.string().url(), waitForSelector: z.string().optional(), timeoutMs: z.number().optional() });
const screenshotSchema = z.object({ url: z.string().url(), selector: z.string().optional(), width: z.number().optional(), height: z.number().optional() });
const browseSchema = z.object({
url: z.string().url(),
waitForSelector: z.string().optional(),
timeoutMs: z.number().optional(),
});
const screenshotSchema = z.object({
url: z.string().url(),
selector: z.string().optional(),
width: z.number().optional(),
height: z.number().optional(),
});
const playwrightSchema = z.object({ code: z.string().min(1), url: z.string().url().optional() });

return [
{
name: 'browse_url',
description: 'Navigate a cloud browser to a URL and return the page content/snapshot. Uses Kernel.sh managed browsers — no local browser needed.',
description:
'Navigate a cloud browser to a URL and return the page title + HTML content. Uses Kernel managed browsers — no local browser needed.',
parameters: {
url: { type: 'string', description: 'URL to navigate to', required: true },
waitForSelector: { type: 'string', description: 'CSS selector to wait for before capturing', required: false },
timeoutMs: { type: 'number', description: 'Navigation timeout in milliseconds', required: false },
waitForSelector: {
type: 'string',
description: 'CSS selector to wait for before capturing',
required: false,
},
timeoutMs: {
type: 'number',
description: 'Navigation timeout in milliseconds',
required: false,
},
},
schema: browseSchema,
handler: async (params: Record<string, unknown>) => {
const result = await kernelFetch<{ browserId: string; content: string; title: string }>('/v1/browsers/navigate', {
url: params.url,
waitForSelector: params.waitForSelector,
timeout: params.timeoutMs ?? 30_000,
const p = browseSchema.parse(params);
const timeout = p.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return withBrowser(async (id) => {
const waitForSelector = p.waitForSelector
? `await page.waitForSelector(${JSON.stringify(p.waitForSelector)}, { timeout: ${timeout} });`
: '';
const code = [
`await page.goto(${JSON.stringify(p.url)}, { waitUntil: 'load', timeout: ${timeout} });`,
waitForSelector,
`return { title: await page.title(), content: await page.content() };`,
]
.filter(Boolean)
.join('\n');
return (await execute(id, code)).result as { title: string; content: string };
});
return result;
},
},
{
name: 'take_screenshot',
description: 'Take a screenshot of a URL using a cloud browser. Returns base64-encoded PNG. Useful for visual QA of live stream players.',
description:
'Take a screenshot of a URL using a cloud browser. Returns a base64-encoded PNG. Useful for visual QA of live stream players.',
parameters: {
url: { type: 'string', description: 'URL to screenshot', required: true },
selector: { type: 'string', description: 'CSS selector to screenshot (optional, defaults to full page)', required: false },
selector: {
type: 'string',
description: 'CSS selector to screenshot (optional, defaults to full page)',
required: false,
},
width: { type: 'number', description: 'Viewport width in pixels', required: false },
height: { type: 'number', description: 'Viewport height in pixels', required: false },
},
schema: screenshotSchema,
handler: async (params: Record<string, unknown>) => {
const result = await kernelFetch<{ screenshotBase64: string; width: number; height: number }>('/v1/browsers/screenshot', {
url: params.url,
selector: params.selector,
viewport: {
width: params.width ?? 1920,
height: params.height ?? 1080,
},
const p = screenshotSchema.parse(params);
const width = p.width ?? DEFAULT_WIDTH;
const height = p.height ?? DEFAULT_HEIGHT;
return withBrowser(async (id) => {
// Element screenshots can't use fullPage; full-page shots can.
const target = p.selector ? `page.locator(${JSON.stringify(p.selector)})` : 'page';
const shotOpts = p.selector ? `{ type: 'png' }` : `{ type: 'png', fullPage: true }`;
const code = [
`await page.setViewportSize({ width: ${width}, height: ${height} });`,
`await page.goto(${JSON.stringify(p.url)}, { waitUntil: 'load', timeout: ${DEFAULT_TIMEOUT_MS} });`,
`const buf = await ${target}.screenshot(${shotOpts});`,
`return { screenshotBase64: buf.toString('base64'), width: ${width}, height: ${height} };`,
].join('\n');
return (await execute(id, code)).result as {
screenshotBase64: string;
width: number;
height: number;
};
});
return result;
},
},
{
name: 'run_playwright',
description: 'Execute Playwright code in a Kernel.sh cloud browser. For complex browser automation like testing embed players or monitoring dashboards.',
description:
'Execute Playwright code in a Kernel cloud browser. For complex browser automation like testing embed players or monitoring dashboards.',
parameters: {
code: { type: 'string', description: 'Playwright JavaScript code to execute', required: true },
url: { type: 'string', description: 'Starting URL (optional)', required: false },
},
schema: playwrightSchema,
handler: async (params: Record<string, unknown>) => {
const result = await kernelFetch<{ output: string; logs: string[] }>('/v1/browsers/execute', {
code: params.code,
url: params.url,
const p = playwrightSchema.parse(params);
return withBrowser(async (id) => {
if (p.url) {
await execute(
id,
`await page.goto(${JSON.stringify(p.url)}, { waitUntil: 'load', timeout: ${DEFAULT_TIMEOUT_MS} });`,
);
}
const res = await execute(id, p.code);
const logs = [res.stdout, res.stderr].filter(
(line): line is string => typeof line === 'string' && line.length > 0,
);
return { output: res.result ?? '', logs };
});
return result;
},
},
];
Expand Down
3 changes: 3 additions & 0 deletions sdk-typescript/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading