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,357 @@
1
+ import { resolve, normalize, dirname, sep } from 'node:path';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { lstat, realpath } from 'node:fs/promises';
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 class InvalidBrowserNavigationUrlError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'InvalidBrowserNavigationUrlError';
13
+ }
14
+ }
15
+ /** Build a BrowserNavigationPolicyOptions from an SsrfPolicy. */
16
+ export function withBrowserNavigationPolicy(ssrfPolicy) {
17
+ return { ssrfPolicy };
18
+ }
19
+ // Only http: and https: are permitted for navigation; about:blank is the sole non-network exception.
20
+ const NETWORK_NAVIGATION_PROTOCOLS = new Set(['http:', 'https:']);
21
+ const SAFE_NON_NETWORK_URLS = new Set(['about:blank']);
22
+ /**
23
+ * Assert that a URL is allowed for browser navigation under the given SSRF policy.
24
+ * Throws `InvalidBrowserNavigationUrlError` if the URL is blocked.
25
+ */
26
+ export async function assertBrowserNavigationAllowed(opts) {
27
+ const rawUrl = String(opts.url ?? '').trim();
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(rawUrl);
31
+ }
32
+ catch {
33
+ throw new InvalidBrowserNavigationUrlError(`Invalid URL: "${rawUrl}"`);
34
+ }
35
+ // Block non-network protocols (file:, data:, javascript:, etc.) — only http/https allowed.
36
+ if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
37
+ if (SAFE_NON_NETWORK_URLS.has(parsed.href))
38
+ return;
39
+ throw new InvalidBrowserNavigationUrlError(`Navigation blocked: unsupported protocol "${parsed.protocol}"`);
40
+ }
41
+ const policy = opts.ssrfPolicy;
42
+ if (policy?.dangerouslyAllowPrivateNetwork ?? policy?.allowPrivateNetwork ?? true)
43
+ return;
44
+ const allowedHostnames = [
45
+ ...(policy?.allowedHostnames ?? []),
46
+ ...(policy?.hostnameAllowlist ?? []),
47
+ ];
48
+ if (allowedHostnames.length) {
49
+ const hostname = parsed.hostname.toLowerCase();
50
+ if (allowedHostnames.some(h => h.toLowerCase() === hostname))
51
+ return;
52
+ }
53
+ if (await isInternalUrlResolved(rawUrl, opts.lookupFn)) {
54
+ throw new InvalidBrowserNavigationUrlError(`Navigation to internal/loopback address blocked: "${rawUrl}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`);
55
+ }
56
+ }
57
+ /**
58
+ * Validate that an output file path is safe — no directory traversal or escape.
59
+ * Rejects paths containing `..` segments or relative paths that could escape
60
+ * the intended output directory.
61
+ *
62
+ * @param path - The output path to validate
63
+ * @param allowedRoots - Optional list of allowed root directories. If provided,
64
+ * the resolved path must be within one of these roots.
65
+ * @throws If the path is unsafe
66
+ */
67
+ export async function assertSafeOutputPath(path, allowedRoots) {
68
+ if (!path || typeof path !== 'string') {
69
+ throw new Error('Output path is required.');
70
+ }
71
+ const normalized = normalize(path);
72
+ // Reject paths with traversal segments
73
+ if (normalized.includes('..')) {
74
+ throw new Error(`Unsafe output path: directory traversal detected in "${path}".`);
75
+ }
76
+ if (allowedRoots?.length) {
77
+ const resolved = resolve(normalized);
78
+ // Resolve the parent directory via realpath to detect symlink-parent escapes
79
+ let parentReal;
80
+ try {
81
+ parentReal = await realpath(dirname(resolved));
82
+ }
83
+ catch {
84
+ throw new Error(`Unsafe output path: parent directory is inaccessible for "${path}".`);
85
+ }
86
+ // If the target already exists, ensure it is not a symlink
87
+ try {
88
+ const targetStat = await lstat(resolved);
89
+ if (targetStat.isSymbolicLink()) {
90
+ throw new Error(`Unsafe output path: "${path}" is a symbolic link.`);
91
+ }
92
+ }
93
+ catch (e) {
94
+ if (e.code !== 'ENOENT')
95
+ throw e;
96
+ }
97
+ // Verify the resolved parent is within one of the allowed roots
98
+ const results = await Promise.all(allowedRoots.map(async (root) => {
99
+ try {
100
+ const rootStat = await lstat(resolve(root));
101
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
102
+ return false;
103
+ const rootReal = await realpath(resolve(root));
104
+ return parentReal === rootReal || parentReal.startsWith(rootReal + sep);
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }));
110
+ if (!results.some(Boolean)) {
111
+ throw new Error(`Unsafe output path: "${path}" is outside allowed directories.`);
112
+ }
113
+ }
114
+ }
115
+ /**
116
+ * Expand an IPv6 address (with optional :: abbreviation) to full 8-group
117
+ * colon-separated hex form. Returns null for invalid input.
118
+ */
119
+ function expandIPv6(ip) {
120
+ let normalized = ip;
121
+ // Handle embedded IPv4 literal at end (e.g., 64:ff9b::192.168.1.1)
122
+ const v4Match = normalized.match(/^(.+:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
123
+ if (v4Match) {
124
+ const octets = v4Match[2].split('.').map(Number);
125
+ if (octets.some(o => o > 255))
126
+ return null;
127
+ const hexHi = ((octets[0] << 8) | octets[1]).toString(16).padStart(4, '0');
128
+ const hexLo = ((octets[2] << 8) | octets[3]).toString(16).padStart(4, '0');
129
+ normalized = v4Match[1] + hexHi + ':' + hexLo;
130
+ }
131
+ const halves = normalized.split('::');
132
+ if (halves.length > 2)
133
+ return null; // multiple :: is invalid
134
+ if (halves.length === 2) {
135
+ const left = halves[0] !== '' ? halves[0].split(':') : [];
136
+ const right = halves[1] !== '' ? halves[1].split(':') : [];
137
+ const needed = 8 - left.length - right.length;
138
+ if (needed < 0)
139
+ return null;
140
+ const groups = [...left, ...Array(needed).fill('0'), ...right];
141
+ if (groups.length !== 8)
142
+ return null;
143
+ return groups.map(g => g.padStart(4, '0')).join(':');
144
+ }
145
+ const groups = normalized.split(':');
146
+ if (groups.length !== 8)
147
+ return null;
148
+ return groups.map(g => g.padStart(4, '0')).join(':');
149
+ }
150
+ /**
151
+ * Convert two 16-bit hex group strings to a dotted-decimal IPv4 string.
152
+ */
153
+ function hexToIPv4(hiHex, loHex) {
154
+ const hi = parseInt(hiHex, 16);
155
+ const lo = parseInt(loHex, 16);
156
+ return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
157
+ }
158
+ /**
159
+ * Attempt to extract an IPv4 address embedded in an IPv6 transition address.
160
+ * Handles: IPv4-mapped (::ffff:), NAT64 (64:ff9b::/96, 64:ff9b:1::/48),
161
+ * 6to4 (2002::/16), and Teredo (2001:0000::/32).
162
+ *
163
+ * Returns:
164
+ * - The embedded IPv4 string if found
165
+ * - null if the address is not a known transition format
166
+ * - '' (empty string) on parse error — callers MUST treat this as internal (fail closed)
167
+ */
168
+ function extractEmbeddedIPv4(lower) {
169
+ // IPv4-mapped: ::ffff:a.b.c.d (most common transition form)
170
+ if (lower.startsWith('::ffff:')) {
171
+ return lower.slice(7);
172
+ }
173
+ // For NAT64, 6to4, and Teredo we need to fully expand the address
174
+ const expanded = expandIPv6(lower);
175
+ if (expanded === null)
176
+ return ''; // fail closed on invalid IPv6
177
+ const groups = expanded.split(':');
178
+ if (groups.length !== 8)
179
+ return ''; // fail closed
180
+ // NAT64 well-known prefix: 64:ff9b::/96
181
+ // Expanded form: 0064:ff9b:0000:0000:0000:0000:wwxx:yyzz → IPv4 = ww.xx.yy.zz
182
+ if (groups[0] === '0064' && groups[1] === 'ff9b' &&
183
+ groups[2] === '0000' && groups[3] === '0000' &&
184
+ groups[4] === '0000' && groups[5] === '0000') {
185
+ return hexToIPv4(groups[6], groups[7]);
186
+ }
187
+ // NAT64 local-use prefix: 64:ff9b:1::/48
188
+ // Expanded form: 0064:ff9b:0001:xxxx:xxxx:xxxx:wwxx:yyzz → IPv4 = ww.xx.yy.zz
189
+ if (groups[0] === '0064' && groups[1] === 'ff9b' && groups[2] === '0001') {
190
+ return hexToIPv4(groups[6], groups[7]);
191
+ }
192
+ // 6to4 prefix: 2002::/16
193
+ // Expanded form: 2002:aabb:ccdd:xxxx:xxxx:xxxx:xxxx:xxxx → IPv4 = aa.bb.cc.dd
194
+ if (groups[0] === '2002') {
195
+ return hexToIPv4(groups[1], groups[2]);
196
+ }
197
+ // Teredo prefix: 2001:0000::/32
198
+ // Expanded form: 2001:0000:...:...:...:...:~ww~xx:~yy~zz
199
+ // Client IPv4 is in the last 32 bits XOR'd with 0xFFFFFFFF
200
+ if (groups[0] === '2001' && groups[1] === '0000') {
201
+ const hiXored = (parseInt(groups[6], 16) ^ 0xffff).toString(16).padStart(4, '0');
202
+ const loXored = (parseInt(groups[7], 16) ^ 0xffff).toString(16).padStart(4, '0');
203
+ return hexToIPv4(hiXored, loXored);
204
+ }
205
+ return null; // not a known transition format
206
+ }
207
+ /**
208
+ * Validate a single IPv4 octet string: must be a plain decimal integer 0-255
209
+ * with no leading zeros, hex prefixes, or other non-standard forms.
210
+ * Returns false for any octet that doesn't round-trip cleanly.
211
+ */
212
+ function isStrictDecimalOctet(part) {
213
+ if (!/^[0-9]+$/.test(part))
214
+ return false;
215
+ const n = parseInt(part, 10);
216
+ if (n < 0 || n > 255)
217
+ return false;
218
+ // Reject leading zeros (e.g. "0177" would be octal in some parsers)
219
+ if (String(n) !== part)
220
+ return false;
221
+ return true;
222
+ }
223
+ /**
224
+ * Returns true if the string looks like a legacy/non-standard IPv4 literal
225
+ * that should be treated as internal and blocked (fail closed).
226
+ * Catches octal (0177.0.0.1), hex (0xff.0.0.1), short (127.1),
227
+ * and packed decimal (2130706433) forms.
228
+ */
229
+ function isUnsupportedIPv4Literal(ip) {
230
+ // Packed decimal: single integer with no dots
231
+ if (/^[0-9]+$/.test(ip))
232
+ return true;
233
+ const parts = ip.split('.');
234
+ // Must be exactly 4 dotted parts
235
+ if (parts.length !== 4)
236
+ return true;
237
+ // Each part must be a strict decimal octet
238
+ if (!parts.every(isStrictDecimalOctet))
239
+ return true;
240
+ return false;
241
+ }
242
+ /**
243
+ * Check whether an IP address string is internal/private/loopback.
244
+ */
245
+ function isInternalIP(ip) {
246
+ // Reject non-standard IPv4 literals before any range checks (fail closed)
247
+ if (!ip.includes(':') && isUnsupportedIPv4Literal(ip))
248
+ return true;
249
+ // IPv4
250
+ if (/^127\./.test(ip))
251
+ return true;
252
+ if (/^10\./.test(ip))
253
+ return true;
254
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip))
255
+ return true;
256
+ if (/^192\.168\./.test(ip))
257
+ return true;
258
+ if (/^169\.254\./.test(ip))
259
+ return true;
260
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip))
261
+ return true;
262
+ if (ip === '0.0.0.0')
263
+ return true;
264
+ // IPv6
265
+ const lower = ip.toLowerCase();
266
+ if (lower === '::1')
267
+ return true;
268
+ if (lower.startsWith('fe80:'))
269
+ return true; // link-local
270
+ if (lower.startsWith('fc') || lower.startsWith('fd'))
271
+ return true; // ULA
272
+ if (lower.startsWith('ff'))
273
+ return true; // multicast (ff00::/8)
274
+ // IPv6 transition addresses: NAT64, 6to4, Teredo, IPv4-mapped
275
+ const embedded = extractEmbeddedIPv4(lower);
276
+ if (embedded !== null) {
277
+ if (embedded === '')
278
+ return true; // parse error — fail closed
279
+ return isInternalIP(embedded);
280
+ }
281
+ return false;
282
+ }
283
+ /**
284
+ * Check whether a URL targets a loopback or private/internal network address.
285
+ * Synchronous hostname-based check. Used to prevent SSRF attacks.
286
+ */
287
+ export function isInternalUrl(url) {
288
+ let parsed;
289
+ try {
290
+ parsed = new URL(url);
291
+ }
292
+ catch {
293
+ // Fail closed: treat unparseable URLs as internal/blocked
294
+ return true;
295
+ }
296
+ const hostname = parsed.hostname.toLowerCase();
297
+ // Direct hostname checks
298
+ // Note: URL.hostname strips IPv6 brackets, so [::1] becomes ::1
299
+ if (hostname === 'localhost')
300
+ return true;
301
+ // Check if hostname is an IP literal
302
+ if (isInternalIP(hostname))
303
+ return true;
304
+ // .local, .internal, .localhost TLDs
305
+ if (hostname.endsWith('.local') || hostname.endsWith('.internal') || hostname.endsWith('.localhost')) {
306
+ return true;
307
+ }
308
+ return false;
309
+ }
310
+ /**
311
+ * Validate upload file paths immediately before use — rejects symlinks,
312
+ * missing files, and non-regular files to prevent TOCTOU path-swap attacks.
313
+ */
314
+ export async function assertSafeUploadPaths(paths) {
315
+ for (const filePath of paths) {
316
+ let stat;
317
+ try {
318
+ stat = await lstat(filePath);
319
+ }
320
+ catch {
321
+ throw new Error(`Upload path does not exist or is inaccessible: "${filePath}".`);
322
+ }
323
+ if (stat.isSymbolicLink()) {
324
+ throw new Error(`Upload path is a symbolic link: "${filePath}".`);
325
+ }
326
+ if (!stat.isFile()) {
327
+ throw new Error(`Upload path is not a regular file: "${filePath}".`);
328
+ }
329
+ }
330
+ }
331
+ /**
332
+ * Async version that also resolves DNS to catch rebinding attacks
333
+ * where a public hostname resolves to an internal IP.
334
+ */
335
+ export async function isInternalUrlResolved(url, lookupFn = lookup) {
336
+ // First do the fast synchronous check
337
+ if (isInternalUrl(url))
338
+ return true;
339
+ // Then resolve DNS to catch rebinding
340
+ let parsed;
341
+ try {
342
+ parsed = new URL(url);
343
+ }
344
+ catch {
345
+ return true;
346
+ }
347
+ try {
348
+ const { address } = await lookupFn(parsed.hostname);
349
+ if (isInternalIP(address))
350
+ return true;
351
+ }
352
+ catch {
353
+ // DNS resolution failed — fail closed
354
+ return true;
355
+ }
356
+ return false;
357
+ }
@@ -0,0 +1,12 @@
1
+ import type { SnapshotResult, SnapshotOptions } from '../types.js';
2
+ /**
3
+ * Take an AI-readable snapshot using Playwright's _snapshotForAI.
4
+ * This is the primary snapshot method — uses Playwright's built-in AI mode.
5
+ */
6
+ export declare function snapshotAi(opts: {
7
+ cdpUrl: string;
8
+ targetId?: string;
9
+ maxChars?: number;
10
+ timeoutMs?: number;
11
+ options?: SnapshotOptions;
12
+ }): Promise<SnapshotResult>;
@@ -0,0 +1,47 @@
1
+ import { getPageForTargetId, ensurePageState, storeRoleRefsForTarget, normalizeTimeoutMs } from '../connection.js';
2
+ import { buildRoleSnapshotFromAiSnapshot, getRoleSnapshotStats } from './ref-map.js';
3
+ /**
4
+ * Take an AI-readable snapshot using Playwright's _snapshotForAI.
5
+ * This is the primary snapshot method — uses Playwright's built-in AI mode.
6
+ */
7
+ export async function snapshotAi(opts) {
8
+ const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
9
+ ensurePageState(page);
10
+ const maybe = page;
11
+ if (!maybe._snapshotForAI) {
12
+ throw new Error('Playwright _snapshotForAI is not available. Upgrade playwright-core to >= 1.50.');
13
+ }
14
+ const sourceUrl = page.url();
15
+ const result = await maybe._snapshotForAI({
16
+ timeout: normalizeTimeoutMs(opts.timeoutMs, 5000, 60000),
17
+ track: 'response',
18
+ });
19
+ let snapshot = String(result?.full ?? '');
20
+ const maxChars = opts.maxChars;
21
+ const limit = typeof maxChars === 'number' && Number.isFinite(maxChars) && maxChars > 0
22
+ ? Math.floor(maxChars) : undefined;
23
+ if (limit && snapshot.length > limit) {
24
+ const lastNewline = snapshot.lastIndexOf('\n', limit);
25
+ const cutoff = lastNewline > 0 ? lastNewline : limit;
26
+ snapshot = `${snapshot.slice(0, cutoff)}\n\n[...TRUNCATED - page too large]`;
27
+ }
28
+ const built = buildRoleSnapshotFromAiSnapshot(snapshot, opts.options);
29
+ storeRoleRefsForTarget({
30
+ page,
31
+ cdpUrl: opts.cdpUrl,
32
+ targetId: opts.targetId,
33
+ refs: built.refs,
34
+ mode: 'aria',
35
+ });
36
+ return {
37
+ snapshot: built.snapshot,
38
+ refs: built.refs,
39
+ stats: getRoleSnapshotStats(built.snapshot, built.refs),
40
+ untrusted: true,
41
+ contentMeta: {
42
+ sourceUrl,
43
+ contentType: 'browser-snapshot',
44
+ capturedAt: new Date().toISOString(),
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,26 @@
1
+ import type { SnapshotResult, AriaSnapshotResult } from '../types.js';
2
+ /**
3
+ * Take a role-based snapshot using Playwright's ariaSnapshot().
4
+ * This produces a tree with ref IDs that can be targeted by actions.
5
+ */
6
+ export declare function snapshotRole(opts: {
7
+ cdpUrl: string;
8
+ targetId?: string;
9
+ selector?: string;
10
+ frameSelector?: string;
11
+ refsMode?: 'role' | 'aria';
12
+ timeoutMs?: number;
13
+ options?: {
14
+ interactive?: boolean;
15
+ compact?: boolean;
16
+ maxDepth?: number;
17
+ };
18
+ }): Promise<SnapshotResult>;
19
+ /**
20
+ * Take a raw ARIA accessibility tree snapshot via CDP.
21
+ */
22
+ export declare function snapshotAria(opts: {
23
+ cdpUrl: string;
24
+ targetId?: string;
25
+ limit?: number;
26
+ }): Promise<AriaSnapshotResult>;
@@ -0,0 +1,121 @@
1
+ import { getPageForTargetId, ensurePageState, storeRoleRefsForTarget, normalizeTimeoutMs } from '../connection.js';
2
+ import { buildRoleSnapshotFromAriaSnapshot, getRoleSnapshotStats, } from './ref-map.js';
3
+ /**
4
+ * Take a role-based snapshot using Playwright's ariaSnapshot().
5
+ * This produces a tree with ref IDs that can be targeted by actions.
6
+ */
7
+ export async function snapshotRole(opts) {
8
+ const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
9
+ ensurePageState(page);
10
+ const sourceUrl = page.url();
11
+ const frameSelector = opts.frameSelector?.trim() || '';
12
+ const selector = opts.selector?.trim() || '';
13
+ const locator = frameSelector
14
+ ? (selector
15
+ ? page.frameLocator(frameSelector).locator(selector)
16
+ : page.frameLocator(frameSelector).locator(':root'))
17
+ : (selector
18
+ ? page.locator(selector)
19
+ : page.locator(':root'));
20
+ const ariaSnapshot = await locator.ariaSnapshot({ timeout: normalizeTimeoutMs(opts.timeoutMs, 5000) });
21
+ const built = buildRoleSnapshotFromAriaSnapshot(String(ariaSnapshot ?? ''), opts.options);
22
+ storeRoleRefsForTarget({
23
+ page,
24
+ cdpUrl: opts.cdpUrl,
25
+ targetId: opts.targetId,
26
+ refs: built.refs,
27
+ frameSelector: frameSelector || undefined,
28
+ mode: opts.refsMode ?? 'role',
29
+ });
30
+ return {
31
+ snapshot: built.snapshot,
32
+ refs: built.refs,
33
+ stats: getRoleSnapshotStats(built.snapshot, built.refs),
34
+ untrusted: true,
35
+ contentMeta: {
36
+ sourceUrl,
37
+ contentType: 'browser-snapshot',
38
+ capturedAt: new Date().toISOString(),
39
+ },
40
+ };
41
+ }
42
+ /**
43
+ * Take a raw ARIA accessibility tree snapshot via CDP.
44
+ */
45
+ export async function snapshotAria(opts) {
46
+ const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500)));
47
+ const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
48
+ ensurePageState(page);
49
+ const sourceUrl = page.url();
50
+ const session = await page.context().newCDPSession(page);
51
+ try {
52
+ await session.send('Accessibility.enable').catch(() => { });
53
+ const res = await session.send('Accessibility.getFullAXTree');
54
+ return {
55
+ nodes: formatAriaNodes(Array.isArray(res?.nodes) ? res.nodes : [], limit),
56
+ untrusted: true,
57
+ contentMeta: {
58
+ sourceUrl,
59
+ contentType: 'browser-aria-tree',
60
+ capturedAt: new Date().toISOString(),
61
+ },
62
+ };
63
+ }
64
+ finally {
65
+ await session.detach().catch(() => { });
66
+ }
67
+ }
68
+ function axValue(v) {
69
+ if (!v || typeof v !== 'object')
70
+ return '';
71
+ const value = v.value;
72
+ if (typeof value === 'string')
73
+ return value;
74
+ if (typeof value === 'number' || typeof value === 'boolean')
75
+ return String(value);
76
+ return '';
77
+ }
78
+ function formatAriaNodes(nodes, limit) {
79
+ const byId = new Map();
80
+ for (const n of nodes)
81
+ if (n.nodeId)
82
+ byId.set(n.nodeId, n);
83
+ const referenced = new Set();
84
+ for (const n of nodes)
85
+ for (const c of n.childIds ?? [])
86
+ referenced.add(c);
87
+ const root = nodes.find(n => n.nodeId && !referenced.has(n.nodeId)) ?? nodes[0];
88
+ if (!root?.nodeId)
89
+ return [];
90
+ const out = [];
91
+ const stack = [{ id: root.nodeId, depth: 0 }];
92
+ while (stack.length && out.length < limit) {
93
+ const popped = stack.pop();
94
+ if (!popped)
95
+ break;
96
+ const { id, depth } = popped;
97
+ const n = byId.get(id);
98
+ if (!n)
99
+ continue;
100
+ const role = axValue(n.role);
101
+ const name = axValue(n.name);
102
+ const value = axValue(n.value);
103
+ const description = axValue(n.description);
104
+ const ref = `ax${out.length + 1}`;
105
+ out.push({
106
+ ref,
107
+ role: role || 'unknown',
108
+ name: name || '',
109
+ ...(value ? { value } : {}),
110
+ ...(description ? { description } : {}),
111
+ ...(typeof n.backendDOMNodeId === 'number' ? { backendDOMNodeId: n.backendDOMNodeId } : {}),
112
+ depth,
113
+ });
114
+ const children = (n.childIds ?? []).filter((c) => byId.has(c));
115
+ for (let i = children.length - 1; i >= 0; i--) {
116
+ if (children[i])
117
+ stack.push({ id: children[i], depth: depth + 1 });
118
+ }
119
+ }
120
+ return out;
121
+ }
@@ -0,0 +1,31 @@
1
+ import type { RoleRefs } from '../types.js';
2
+ export declare const INTERACTIVE_ROLES: Set<string>;
3
+ export declare const CONTENT_ROLES: Set<string>;
4
+ export declare const STRUCTURAL_ROLES: Set<string>;
5
+ export interface SnapshotBuildOptions {
6
+ interactive?: boolean;
7
+ compact?: boolean;
8
+ maxDepth?: number;
9
+ }
10
+ /**
11
+ * Build a role snapshot from Playwright's ariaSnapshot() output.
12
+ * Assigns ref IDs (e1, e2, ...) to interactive/content elements.
13
+ */
14
+ export declare function buildRoleSnapshotFromAriaSnapshot(ariaSnapshot: string, options?: SnapshotBuildOptions): {
15
+ snapshot: string;
16
+ refs: RoleRefs;
17
+ };
18
+ /**
19
+ * Build a role snapshot from Playwright's AI snapshot output.
20
+ * Preserves Playwright's own aria-ref ids (e.g. ref=e13).
21
+ */
22
+ export declare function buildRoleSnapshotFromAiSnapshot(aiSnapshot: string, options?: SnapshotBuildOptions): {
23
+ snapshot: string;
24
+ refs: RoleRefs;
25
+ };
26
+ export declare function getRoleSnapshotStats(snapshot: string, refs: RoleRefs): {
27
+ lines: number;
28
+ chars: number;
29
+ refs: number;
30
+ interactive: number;
31
+ };