tuna-agent 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/agents/claude-code-adapter.d.ts +3 -1
  2. package/dist/agents/claude-code-adapter.js +28 -4
  3. package/dist/agents/factory.d.ts +2 -1
  4. package/dist/agents/factory.js +2 -2
  5. package/dist/browser/actions/download.d.ts +16 -0
  6. package/dist/browser/actions/download.js +39 -0
  7. package/dist/browser/actions/emulation.d.ts +53 -0
  8. package/dist/browser/actions/emulation.js +103 -0
  9. package/dist/browser/actions/evaluate.d.ts +29 -0
  10. package/dist/browser/actions/evaluate.js +92 -0
  11. package/dist/browser/actions/interaction.d.ts +79 -0
  12. package/dist/browser/actions/interaction.js +210 -0
  13. package/dist/browser/actions/keyboard.d.ts +6 -0
  14. package/dist/browser/actions/keyboard.js +9 -0
  15. package/dist/browser/actions/navigation.d.ts +40 -0
  16. package/dist/browser/actions/navigation.js +92 -0
  17. package/dist/browser/actions/wait.d.ts +12 -0
  18. package/dist/browser/actions/wait.js +33 -0
  19. package/dist/browser/browser.d.ts +722 -0
  20. package/dist/browser/browser.js +1066 -0
  21. package/dist/browser/capture/activity.d.ts +22 -0
  22. package/dist/browser/capture/activity.js +39 -0
  23. package/dist/browser/capture/pdf.d.ts +6 -0
  24. package/dist/browser/capture/pdf.js +6 -0
  25. package/dist/browser/capture/response.d.ts +8 -0
  26. package/dist/browser/capture/response.js +28 -0
  27. package/dist/browser/capture/screenshot.d.ts +30 -0
  28. package/dist/browser/capture/screenshot.js +72 -0
  29. package/dist/browser/capture/trace.d.ts +13 -0
  30. package/dist/browser/capture/trace.js +19 -0
  31. package/dist/browser/chrome-launcher.d.ts +8 -0
  32. package/dist/browser/chrome-launcher.js +543 -0
  33. package/dist/browser/connection.d.ts +42 -0
  34. package/dist/browser/connection.js +359 -0
  35. package/dist/browser/index.d.ts +6 -0
  36. package/dist/browser/index.js +3 -0
  37. package/dist/browser/security.d.ts +51 -0
  38. package/dist/browser/security.js +357 -0
  39. package/dist/browser/snapshot/ai-snapshot.d.ts +12 -0
  40. package/dist/browser/snapshot/ai-snapshot.js +47 -0
  41. package/dist/browser/snapshot/aria-snapshot.d.ts +26 -0
  42. package/dist/browser/snapshot/aria-snapshot.js +121 -0
  43. package/dist/browser/snapshot/ref-map.d.ts +31 -0
  44. package/dist/browser/snapshot/ref-map.js +250 -0
  45. package/dist/browser/storage/index.d.ts +36 -0
  46. package/dist/browser/storage/index.js +65 -0
  47. package/dist/browser/types.d.ts +429 -0
  48. package/dist/browser/types.js +2 -0
  49. package/dist/cli/commands/extension.d.ts +10 -0
  50. package/dist/cli/commands/extension.js +86 -0
  51. package/dist/cli/index.js +12 -0
  52. package/dist/daemon/extension-handlers.d.ts +63 -0
  53. package/dist/daemon/extension-handlers.js +630 -0
  54. package/dist/daemon/index.js +173 -44
  55. package/dist/daemon/ws-client.d.ts +28 -8
  56. package/dist/daemon/ws-client.js +68 -62
  57. package/dist/mcp/browser-server.d.ts +11 -0
  58. package/dist/mcp/browser-server.js +467 -0
  59. package/dist/mcp/knowledge-server.d.ts +11 -0
  60. package/dist/mcp/knowledge-server.js +263 -0
  61. package/dist/mcp/setup.d.ts +20 -0
  62. package/dist/mcp/setup.js +94 -0
  63. package/dist/types/index.d.ts +2 -0
  64. package/dist/utils/claude-cli.d.ts +2 -0
  65. package/dist/utils/claude-cli.js +29 -9
  66. package/dist/utils/message-schemas.d.ts +4 -1
  67. package/dist/utils/message-schemas.js +6 -1
  68. package/package.json +2 -1
@@ -0,0 +1,359 @@
1
+ import { chromium } from 'playwright-core';
2
+ import { getChromeWebSocketUrl } from './chrome-launcher.js';
3
+ // ── Persistent Connection Cache ──
4
+ let cached = null;
5
+ const connectingByUrl = new Map();
6
+ const pageStates = new WeakMap();
7
+ const observedContexts = new WeakSet();
8
+ const observedPages = new WeakSet();
9
+ // Ref cache: keyed by "cdpUrl::targetId"
10
+ const roleRefsByTarget = new Map();
11
+ const MAX_ROLE_REFS_CACHE = 50;
12
+ const MAX_CONSOLE_MESSAGES = 500;
13
+ const MAX_PAGE_ERRORS = 200;
14
+ const MAX_NETWORK_REQUESTS = 500;
15
+ function normalizeCdpUrl(raw) {
16
+ return raw.replace(/\/$/, '');
17
+ }
18
+ function roleRefsKey(cdpUrl, targetId) {
19
+ return `${normalizeCdpUrl(cdpUrl)}::${targetId}`;
20
+ }
21
+ // ── Page State Management ──
22
+ export function ensurePageState(page) {
23
+ const existing = pageStates.get(page);
24
+ if (existing)
25
+ return existing;
26
+ const state = {
27
+ console: [],
28
+ errors: [],
29
+ requests: [],
30
+ requestIds: new WeakMap(),
31
+ nextRequestId: 0,
32
+ };
33
+ pageStates.set(page, state);
34
+ if (!observedPages.has(page)) {
35
+ observedPages.add(page);
36
+ page.on('console', (msg) => {
37
+ state.console.push({
38
+ type: msg.type(),
39
+ text: msg.text(),
40
+ timestamp: new Date().toISOString(),
41
+ location: msg.location(),
42
+ });
43
+ if (state.console.length > MAX_CONSOLE_MESSAGES)
44
+ state.console.shift();
45
+ });
46
+ page.on('pageerror', (err) => {
47
+ state.errors.push({
48
+ message: err?.message ? String(err.message) : String(err),
49
+ name: err?.name ? String(err.name) : undefined,
50
+ stack: err?.stack ? String(err.stack) : undefined,
51
+ timestamp: new Date().toISOString(),
52
+ });
53
+ if (state.errors.length > MAX_PAGE_ERRORS)
54
+ state.errors.shift();
55
+ });
56
+ page.on('request', (req) => {
57
+ state.nextRequestId += 1;
58
+ const id = `r${state.nextRequestId}`;
59
+ state.requestIds.set(req, id);
60
+ state.requests.push({
61
+ id,
62
+ timestamp: new Date().toISOString(),
63
+ method: req.method(),
64
+ url: req.url(),
65
+ resourceType: req.resourceType(),
66
+ });
67
+ if (state.requests.length > MAX_NETWORK_REQUESTS)
68
+ state.requests.shift();
69
+ });
70
+ page.on('response', (resp) => {
71
+ const req = resp.request();
72
+ const id = state.requestIds.get(req);
73
+ if (!id)
74
+ return;
75
+ for (let i = state.requests.length - 1; i >= 0; i--) {
76
+ const rec = state.requests[i];
77
+ if (rec && rec.id === id) {
78
+ rec.status = resp.status();
79
+ rec.ok = resp.ok();
80
+ break;
81
+ }
82
+ }
83
+ });
84
+ page.on('requestfailed', (req) => {
85
+ const id = state.requestIds.get(req);
86
+ if (!id)
87
+ return;
88
+ for (let i = state.requests.length - 1; i >= 0; i--) {
89
+ const rec = state.requests[i];
90
+ if (rec && rec.id === id) {
91
+ rec.failureText = req.failure()?.errorText;
92
+ rec.ok = false;
93
+ break;
94
+ }
95
+ }
96
+ });
97
+ page.on('close', () => {
98
+ pageStates.delete(page);
99
+ observedPages.delete(page);
100
+ });
101
+ }
102
+ return state;
103
+ }
104
+ // ── Stealth: hide navigator.webdriver ──
105
+ const STEALTH_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => undefined })`;
106
+ /** Skip stealth injection (sites like X.com detect it and force logout) */
107
+ let stealthEnabled = true;
108
+ export function setStealthEnabled(enabled) {
109
+ stealthEnabled = enabled;
110
+ }
111
+ function applyStealthToPage(page) {
112
+ if (!stealthEnabled)
113
+ return;
114
+ page.evaluate(STEALTH_SCRIPT).catch((e) => {
115
+ if (process.env.DEBUG)
116
+ console.warn('[browserclaw] stealth evaluate failed:', e.message);
117
+ });
118
+ }
119
+ function observeContext(context) {
120
+ if (observedContexts.has(context))
121
+ return;
122
+ observedContexts.add(context);
123
+ if (stealthEnabled) {
124
+ context.addInitScript(STEALTH_SCRIPT).catch((e) => {
125
+ if (process.env.DEBUG)
126
+ console.warn('[browserclaw] stealth initScript failed:', e.message);
127
+ });
128
+ }
129
+ for (const page of context.pages()) {
130
+ ensurePageState(page);
131
+ applyStealthToPage(page);
132
+ }
133
+ context.on('page', (page) => {
134
+ ensurePageState(page);
135
+ applyStealthToPage(page);
136
+ });
137
+ }
138
+ function observeBrowser(browser) {
139
+ for (const context of browser.contexts())
140
+ observeContext(context);
141
+ }
142
+ // ── Role Refs Storage ──
143
+ export function storeRoleRefsForTarget(opts) {
144
+ const state = ensurePageState(opts.page);
145
+ state.roleRefs = opts.refs;
146
+ state.roleRefsFrameSelector = opts.frameSelector;
147
+ state.roleRefsMode = opts.mode;
148
+ const targetId = opts.targetId?.trim();
149
+ if (!targetId)
150
+ return;
151
+ roleRefsByTarget.set(roleRefsKey(opts.cdpUrl, targetId), {
152
+ refs: opts.refs,
153
+ ...(opts.frameSelector ? { frameSelector: opts.frameSelector } : {}),
154
+ ...(opts.mode ? { mode: opts.mode } : {}),
155
+ });
156
+ while (roleRefsByTarget.size > MAX_ROLE_REFS_CACHE) {
157
+ const first = roleRefsByTarget.keys().next();
158
+ if (first.done)
159
+ break;
160
+ roleRefsByTarget.delete(first.value);
161
+ }
162
+ }
163
+ export function restoreRoleRefsForTarget(opts) {
164
+ const targetId = opts.targetId?.trim() || '';
165
+ if (!targetId)
166
+ return;
167
+ const entry = roleRefsByTarget.get(roleRefsKey(opts.cdpUrl, targetId));
168
+ if (!entry)
169
+ return;
170
+ const state = ensurePageState(opts.page);
171
+ if (state.roleRefs)
172
+ return;
173
+ state.roleRefs = entry.refs;
174
+ state.roleRefsFrameSelector = entry.frameSelector;
175
+ state.roleRefsMode = entry.mode;
176
+ }
177
+ // ── Connect to Browser ──
178
+ export async function connectBrowser(cdpUrl, authToken) {
179
+ const normalized = normalizeCdpUrl(cdpUrl);
180
+ if (cached?.cdpUrl === normalized)
181
+ return cached;
182
+ const existing = connectingByUrl.get(normalized);
183
+ if (existing)
184
+ return await existing;
185
+ const connectWithRetry = async () => {
186
+ let lastErr;
187
+ for (let attempt = 0; attempt < 3; attempt++) {
188
+ try {
189
+ const timeout = 5000 + attempt * 2000;
190
+ const endpoint = await getChromeWebSocketUrl(normalized, timeout, authToken).catch(() => null) ?? normalized;
191
+ const headers = {};
192
+ if (authToken)
193
+ headers['Authorization'] = `Bearer ${authToken}`;
194
+ const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
195
+ const connected = { browser, cdpUrl: normalized, authToken };
196
+ cached = connected;
197
+ observeBrowser(browser);
198
+ browser.on('disconnected', () => {
199
+ if (cached?.browser === browser)
200
+ cached = null;
201
+ for (const key of roleRefsByTarget.keys()) {
202
+ if (key.startsWith(normalized + '::'))
203
+ roleRefsByTarget.delete(key);
204
+ }
205
+ });
206
+ return connected;
207
+ }
208
+ catch (err) {
209
+ lastErr = err;
210
+ await new Promise(r => setTimeout(r, 250 + attempt * 250));
211
+ }
212
+ }
213
+ throw lastErr instanceof Error ? lastErr : new Error('CDP connect failed');
214
+ };
215
+ const promise = connectWithRetry().finally(() => { connectingByUrl.delete(normalized); });
216
+ connectingByUrl.set(normalized, promise);
217
+ return await promise;
218
+ }
219
+ export async function disconnectBrowser() {
220
+ if (connectingByUrl.size) {
221
+ for (const p of connectingByUrl.values()) {
222
+ try {
223
+ await p;
224
+ }
225
+ catch { }
226
+ }
227
+ }
228
+ const cur = cached;
229
+ cached = null;
230
+ if (cur)
231
+ await cur.browser.close().catch(() => { });
232
+ }
233
+ export async function getAllPages(browser) {
234
+ return browser.contexts().flatMap(c => c.pages());
235
+ }
236
+ export async function pageTargetId(page) {
237
+ const session = await page.context().newCDPSession(page);
238
+ try {
239
+ const info = await session.send('Target.getTargetInfo');
240
+ const targetInfo = info.targetInfo;
241
+ return String(targetInfo?.targetId ?? '').trim() || null;
242
+ }
243
+ finally {
244
+ await session.detach().catch(() => { });
245
+ }
246
+ }
247
+ export async function findPageByTargetId(browser, targetId, cdpUrl) {
248
+ const pages = await getAllPages(browser);
249
+ let resolvedViaCdp = false;
250
+ for (const page of pages) {
251
+ let tid = null;
252
+ try {
253
+ tid = await pageTargetId(page);
254
+ resolvedViaCdp = true;
255
+ }
256
+ catch {
257
+ tid = null;
258
+ }
259
+ if (tid && tid === targetId)
260
+ return page;
261
+ }
262
+ // Extension relays can block CDP attachment APIs entirely. If that happens and
263
+ // Playwright only exposes one page, return it as the best available mapping.
264
+ if (!resolvedViaCdp && pages.length === 1) {
265
+ return pages[0];
266
+ }
267
+ // Fallback: match by URL from /json/list
268
+ if (cdpUrl) {
269
+ try {
270
+ const listUrl = `${cdpUrl.replace(/\/+$/, '').replace(/^ws:/, 'http:').replace(/\/cdp$/, '')}/json/list`;
271
+ const headers = {};
272
+ if (cached?.authToken)
273
+ headers['Authorization'] = `Bearer ${cached.authToken}`;
274
+ const response = await fetch(listUrl, { headers });
275
+ if (response.ok) {
276
+ const targets = await response.json();
277
+ const target = targets.find(t => t.id === targetId);
278
+ if (target) {
279
+ const urlMatch = pages.filter(p => p.url() === target.url);
280
+ if (urlMatch.length === 1)
281
+ return urlMatch[0];
282
+ if (urlMatch.length > 1) {
283
+ const sameUrlTargets = targets.filter(t => t.url === target.url);
284
+ if (sameUrlTargets.length === urlMatch.length) {
285
+ const idx = sameUrlTargets.findIndex(t => t.id === targetId);
286
+ if (idx >= 0 && idx < urlMatch.length)
287
+ return urlMatch[idx];
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
293
+ catch { }
294
+ }
295
+ return null;
296
+ }
297
+ export async function getPageForTargetId(opts) {
298
+ const { browser } = await connectBrowser(opts.cdpUrl);
299
+ const pages = await getAllPages(browser);
300
+ if (!pages.length)
301
+ throw new Error('No pages available in the connected browser.');
302
+ const first = pages[0];
303
+ if (!opts.targetId)
304
+ return first;
305
+ const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
306
+ if (!found) {
307
+ if (pages.length === 1)
308
+ return first;
309
+ throw new Error(`Tab not found (targetId: ${opts.targetId}). Call browser.tabs() to list open tabs.`);
310
+ }
311
+ return found;
312
+ }
313
+ // ── Ref Locator ──
314
+ export function refLocator(page, ref) {
315
+ const normalized = ref.startsWith('@') ? ref.slice(1) : ref.startsWith('ref=') ? ref.slice(4) : ref;
316
+ if (!normalized.trim())
317
+ throw new Error('ref is required');
318
+ if (/^e\d+$/.test(normalized)) {
319
+ const state = pageStates.get(page);
320
+ // Aria mode: use aria-ref locator
321
+ if (state?.roleRefsMode === 'aria') {
322
+ return (state.roleRefsFrameSelector ? page.frameLocator(state.roleRefsFrameSelector) : page)
323
+ .locator(`aria-ref=${normalized}`);
324
+ }
325
+ // Role mode: use getByRole
326
+ const info = state?.roleRefs?.[normalized];
327
+ if (!info)
328
+ throw new Error(`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`);
329
+ const locAny = state?.roleRefsFrameSelector
330
+ ? page.frameLocator(state.roleRefsFrameSelector)
331
+ : page;
332
+ const role = info.role;
333
+ const locator = info.name
334
+ ? locAny.getByRole(role, { name: info.name, exact: true })
335
+ : locAny.getByRole(role);
336
+ return info.nth !== undefined ? locator.nth(info.nth) : locator;
337
+ }
338
+ return page.locator(`aria-ref=${normalized}`);
339
+ }
340
+ // ── Error Helpers ──
341
+ export function toAIFriendlyError(error, selector) {
342
+ const message = error instanceof Error ? error.message : String(error);
343
+ if (message.includes('strict mode violation')) {
344
+ const countMatch = message.match(/resolved to (\d+) elements/);
345
+ const count = countMatch ? countMatch[1] : 'multiple';
346
+ return new Error(`Selector "${selector}" matched ${count} elements. Run a new snapshot to get updated refs, or use a different ref.`);
347
+ }
348
+ if ((message.includes('Timeout') || message.includes('waiting for')) &&
349
+ (message.includes('to be visible') || message.includes('not visible'))) {
350
+ return new Error(`Element "${selector}" not found or not visible. Run a new snapshot to see current page elements.`);
351
+ }
352
+ if (message.includes('intercepts pointer events') || message.includes('not visible') || message.includes('not receive pointer events')) {
353
+ return new Error(`Element "${selector}" is not interactable (hidden or covered). Try scrolling it into view, closing overlays, or re-snapshotting.`);
354
+ }
355
+ return error instanceof Error ? error : new Error(message);
356
+ }
357
+ export function normalizeTimeoutMs(timeoutMs, fallback, maxMs = 120000) {
358
+ return Math.max(500, Math.min(maxMs, timeoutMs ?? fallback));
359
+ }
@@ -0,0 +1,6 @@
1
+ export { BrowserClaw, CrawlPage } from './browser.js';
2
+ export { setStealthEnabled } from './connection.js';
3
+ export { InvalidBrowserNavigationUrlError, withBrowserNavigationPolicy, assertBrowserNavigationAllowed } from './security.js';
4
+ export type { BrowserNavigationPolicyOptions, LookupFn } from './security.js';
5
+ export type { FrameEvalResult } from './actions/evaluate.js';
6
+ export type { SsrfPolicy, LaunchOptions, ConnectOptions, SnapshotResult, SnapshotOptions, SnapshotStats, UntrustedContentMeta, AriaSnapshotResult, AriaNode, RoleRefInfo, RoleRefs, BrowserTab, FormField, ClickOptions, TypeOptions, WaitOptions, ScreenshotOptions, ConsoleMessage, PageError, NetworkRequest, CookieData, StorageKind, ChromeKind, ChromeExecutable, DownloadResult, DialogOptions, ResponseBodyResult, TraceStartOptions, ColorScheme, GeolocationOptions, HttpCredentials, } from './types.js';
@@ -0,0 +1,3 @@
1
+ export { BrowserClaw, CrawlPage } from './browser.js';
2
+ export { setStealthEnabled } from './connection.js';
3
+ export { InvalidBrowserNavigationUrlError, withBrowserNavigationPolicy, assertBrowserNavigationAllowed } from './security.js';
@@ -0,0 +1,51 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import type { SsrfPolicy } from './types.js';
3
+ export type LookupFn = typeof lookup;
4
+ /**
5
+ * Thrown when a navigation URL is blocked by SSRF policy.
6
+ * Callers can catch this specifically to distinguish navigation blocks
7
+ * from other errors.
8
+ */
9
+ export declare class InvalidBrowserNavigationUrlError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ /** Options for browser navigation SSRF policy. */
13
+ export type BrowserNavigationPolicyOptions = {
14
+ ssrfPolicy?: SsrfPolicy;
15
+ };
16
+ /** Build a BrowserNavigationPolicyOptions from an SsrfPolicy. */
17
+ export declare function withBrowserNavigationPolicy(ssrfPolicy?: SsrfPolicy): BrowserNavigationPolicyOptions;
18
+ /**
19
+ * Assert that a URL is allowed for browser navigation under the given SSRF policy.
20
+ * Throws `InvalidBrowserNavigationUrlError` if the URL is blocked.
21
+ */
22
+ export declare function assertBrowserNavigationAllowed(opts: {
23
+ url: string;
24
+ lookupFn?: LookupFn;
25
+ } & BrowserNavigationPolicyOptions): Promise<void>;
26
+ /**
27
+ * Validate that an output file path is safe — no directory traversal or escape.
28
+ * Rejects paths containing `..` segments or relative paths that could escape
29
+ * the intended output directory.
30
+ *
31
+ * @param path - The output path to validate
32
+ * @param allowedRoots - Optional list of allowed root directories. If provided,
33
+ * the resolved path must be within one of these roots.
34
+ * @throws If the path is unsafe
35
+ */
36
+ export declare function assertSafeOutputPath(path: string, allowedRoots?: string[]): Promise<void>;
37
+ /**
38
+ * Check whether a URL targets a loopback or private/internal network address.
39
+ * Synchronous hostname-based check. Used to prevent SSRF attacks.
40
+ */
41
+ export declare function isInternalUrl(url: string): boolean;
42
+ /**
43
+ * Validate upload file paths immediately before use — rejects symlinks,
44
+ * missing files, and non-regular files to prevent TOCTOU path-swap attacks.
45
+ */
46
+ export declare function assertSafeUploadPaths(paths: string[]): Promise<void>;
47
+ /**
48
+ * Async version that also resolves DNS to catch rebinding attacks
49
+ * where a public hostname resolves to an internal IP.
50
+ */
51
+ export declare function isInternalUrlResolved(url: string, lookupFn?: LookupFn): Promise<boolean>;