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.
- package/dist/agents/claude-code-adapter.d.ts +3 -1
- package/dist/agents/claude-code-adapter.js +28 -4
- package/dist/agents/factory.d.ts +2 -1
- package/dist/agents/factory.js +2 -2
- package/dist/browser/actions/download.d.ts +16 -0
- package/dist/browser/actions/download.js +39 -0
- package/dist/browser/actions/emulation.d.ts +53 -0
- package/dist/browser/actions/emulation.js +103 -0
- package/dist/browser/actions/evaluate.d.ts +29 -0
- package/dist/browser/actions/evaluate.js +92 -0
- package/dist/browser/actions/interaction.d.ts +79 -0
- package/dist/browser/actions/interaction.js +210 -0
- package/dist/browser/actions/keyboard.d.ts +6 -0
- package/dist/browser/actions/keyboard.js +9 -0
- package/dist/browser/actions/navigation.d.ts +40 -0
- package/dist/browser/actions/navigation.js +92 -0
- package/dist/browser/actions/wait.d.ts +12 -0
- package/dist/browser/actions/wait.js +33 -0
- package/dist/browser/browser.d.ts +722 -0
- package/dist/browser/browser.js +1066 -0
- package/dist/browser/capture/activity.d.ts +22 -0
- package/dist/browser/capture/activity.js +39 -0
- package/dist/browser/capture/pdf.d.ts +6 -0
- package/dist/browser/capture/pdf.js +6 -0
- package/dist/browser/capture/response.d.ts +8 -0
- package/dist/browser/capture/response.js +28 -0
- package/dist/browser/capture/screenshot.d.ts +30 -0
- package/dist/browser/capture/screenshot.js +72 -0
- package/dist/browser/capture/trace.d.ts +13 -0
- package/dist/browser/capture/trace.js +19 -0
- package/dist/browser/chrome-launcher.d.ts +8 -0
- package/dist/browser/chrome-launcher.js +543 -0
- package/dist/browser/connection.d.ts +42 -0
- package/dist/browser/connection.js +359 -0
- package/dist/browser/index.d.ts +6 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/security.d.ts +51 -0
- package/dist/browser/security.js +357 -0
- package/dist/browser/snapshot/ai-snapshot.d.ts +12 -0
- package/dist/browser/snapshot/ai-snapshot.js +47 -0
- package/dist/browser/snapshot/aria-snapshot.d.ts +26 -0
- package/dist/browser/snapshot/aria-snapshot.js +121 -0
- package/dist/browser/snapshot/ref-map.d.ts +31 -0
- package/dist/browser/snapshot/ref-map.js +250 -0
- package/dist/browser/storage/index.d.ts +36 -0
- package/dist/browser/storage/index.js +65 -0
- package/dist/browser/types.d.ts +429 -0
- package/dist/browser/types.js +2 -0
- package/dist/cli/commands/extension.d.ts +10 -0
- package/dist/cli/commands/extension.js +86 -0
- package/dist/cli/index.js +12 -0
- package/dist/daemon/extension-handlers.d.ts +63 -0
- package/dist/daemon/extension-handlers.js +630 -0
- package/dist/daemon/index.js +173 -44
- package/dist/daemon/ws-client.d.ts +28 -8
- package/dist/daemon/ws-client.js +68 -62
- package/dist/mcp/browser-server.d.ts +11 -0
- package/dist/mcp/browser-server.js +467 -0
- package/dist/mcp/knowledge-server.d.ts +11 -0
- package/dist/mcp/knowledge-server.js +263 -0
- package/dist/mcp/setup.d.ts +20 -0
- package/dist/mcp/setup.js +94 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/utils/claude-cli.d.ts +2 -0
- package/dist/utils/claude-cli.js +29 -9
- package/dist/utils/message-schemas.d.ts +4 -1
- package/dist/utils/message-schemas.js +6 -1
- 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
|
+
};
|