stuntdouble 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +57 -2
- package/dist/chunk-JAB46QV7.js +506 -0
- package/dist/chunk-JAB46QV7.js.map +1 -0
- package/dist/chunk-VWTYG7G4.js +238 -0
- package/dist/chunk-VWTYG7G4.js.map +1 -0
- package/dist/cli.js +97 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp-HXJQN2C7.js +2391 -0
- package/dist/mcp-HXJQN2C7.js.map +1 -0
- package/dist/ui-server-AYIR7DTN.js +10 -0
- package/dist/ui-server-AYIR7DTN.js.map +1 -0
- package/dist/ui.html +698 -0
- package/package.json +54 -4
|
@@ -0,0 +1,2391 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startUi
|
|
3
|
+
} from "./chunk-VWTYG7G4.js";
|
|
4
|
+
import {
|
|
5
|
+
ActorSchema,
|
|
6
|
+
ChecklistSchema,
|
|
7
|
+
ConfigSchema,
|
|
8
|
+
appendJsonl,
|
|
9
|
+
describeRunScore,
|
|
10
|
+
isAlive,
|
|
11
|
+
newChecklistScoring,
|
|
12
|
+
projectDir,
|
|
13
|
+
readJson,
|
|
14
|
+
readRuns,
|
|
15
|
+
resolveHaltedChecks,
|
|
16
|
+
resolveSkippedChecks,
|
|
17
|
+
scoreChecklistRun,
|
|
18
|
+
sdDir,
|
|
19
|
+
writeFileAtomic,
|
|
20
|
+
writeJsonAtomic
|
|
21
|
+
} from "./chunk-JAB46QV7.js";
|
|
22
|
+
|
|
23
|
+
// src/mcp.ts
|
|
24
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
25
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
26
|
+
|
|
27
|
+
// ../browser-toolset/src/keys.ts
|
|
28
|
+
var ALIASES = {
|
|
29
|
+
ctrl: "Control",
|
|
30
|
+
control: "Control",
|
|
31
|
+
cmd: "Meta",
|
|
32
|
+
command: "Meta",
|
|
33
|
+
meta: "Meta",
|
|
34
|
+
super: "Meta",
|
|
35
|
+
win: "Meta",
|
|
36
|
+
alt: "Alt",
|
|
37
|
+
option: "Alt",
|
|
38
|
+
shift: "Shift",
|
|
39
|
+
enter: "Enter",
|
|
40
|
+
return: "Enter",
|
|
41
|
+
kp_enter: "Enter",
|
|
42
|
+
esc: "Escape",
|
|
43
|
+
escape: "Escape",
|
|
44
|
+
tab: "Tab",
|
|
45
|
+
space: " ",
|
|
46
|
+
backspace: "Backspace",
|
|
47
|
+
delete: "Delete",
|
|
48
|
+
del: "Delete",
|
|
49
|
+
insert: "Insert",
|
|
50
|
+
home: "Home",
|
|
51
|
+
end: "End",
|
|
52
|
+
page_up: "PageUp",
|
|
53
|
+
pageup: "PageUp",
|
|
54
|
+
prior: "PageUp",
|
|
55
|
+
page_down: "PageDown",
|
|
56
|
+
pagedown: "PageDown",
|
|
57
|
+
next: "PageDown",
|
|
58
|
+
up: "ArrowUp",
|
|
59
|
+
down: "ArrowDown",
|
|
60
|
+
left: "ArrowLeft",
|
|
61
|
+
right: "ArrowRight",
|
|
62
|
+
arrowup: "ArrowUp",
|
|
63
|
+
arrowdown: "ArrowDown",
|
|
64
|
+
arrowleft: "ArrowLeft",
|
|
65
|
+
arrowright: "ArrowRight",
|
|
66
|
+
capslock: "CapsLock",
|
|
67
|
+
caps_lock: "CapsLock"
|
|
68
|
+
};
|
|
69
|
+
function normaliseKey(raw) {
|
|
70
|
+
const key = raw.trim();
|
|
71
|
+
const alias = ALIASES[key.toLowerCase()];
|
|
72
|
+
if (alias) return alias;
|
|
73
|
+
if (/^f([1-9]|1[0-2])$/i.test(key)) return key.toUpperCase();
|
|
74
|
+
return key;
|
|
75
|
+
}
|
|
76
|
+
function toPlaywrightChord(chord) {
|
|
77
|
+
const parts = chord.split("+").filter((p) => p.length > 0);
|
|
78
|
+
if (chord.endsWith("+") && chord.length > 1 && chord[chord.length - 2] === "+") parts.push("+");
|
|
79
|
+
if (parts.length === 0) return chord === "+" ? "+" : chord;
|
|
80
|
+
const keys = parts.map(normaliseKey);
|
|
81
|
+
if (keys.length > 1) {
|
|
82
|
+
const last = keys[keys.length - 1];
|
|
83
|
+
if (last.length === 1) keys[keys.length - 1] = last.toUpperCase();
|
|
84
|
+
}
|
|
85
|
+
return keys.join("+");
|
|
86
|
+
}
|
|
87
|
+
function toPlaywrightKeySequence(text2) {
|
|
88
|
+
if (text2 === " ") return [" "];
|
|
89
|
+
return text2.split(/\s+/).filter((k) => k.length > 0).map(toPlaywrightChord);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ../browser-toolset/src/safety.ts
|
|
93
|
+
var INDEX_SESSION_SAFETY = {
|
|
94
|
+
refuseSensitiveInput: true,
|
|
95
|
+
blockSubmit: true
|
|
96
|
+
};
|
|
97
|
+
var SAFETY_REFUSAL_SUFFIX = "This session is not allowed to pay, subscribe, create an account or enter passwords or card details. Do not try another way round: report what you observed and what the next step would be, then carry on or call done.";
|
|
98
|
+
var PAYMENT_PROVIDER_DOMAINS = [
|
|
99
|
+
"js.stripe.com",
|
|
100
|
+
"api.stripe.com",
|
|
101
|
+
"checkout.stripe.com",
|
|
102
|
+
"m.stripe.network",
|
|
103
|
+
"hooks.stripe.com",
|
|
104
|
+
"paypal.com",
|
|
105
|
+
"paypalobjects.com",
|
|
106
|
+
"adyen.com",
|
|
107
|
+
"adyenpayments.com",
|
|
108
|
+
"braintreegateway.com",
|
|
109
|
+
"braintree-api.com",
|
|
110
|
+
"pay.google.com",
|
|
111
|
+
"payments.google.com",
|
|
112
|
+
"applepay.cdn-apple.com",
|
|
113
|
+
"klarna.com",
|
|
114
|
+
"klarnaservices.com",
|
|
115
|
+
"afterpay.com",
|
|
116
|
+
"checkout.com",
|
|
117
|
+
"squareup.com",
|
|
118
|
+
"squarecdn.com",
|
|
119
|
+
"paddle.com",
|
|
120
|
+
"lemonsqueezy.com"
|
|
121
|
+
];
|
|
122
|
+
function hostMatchesDomain(host, domain) {
|
|
123
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
124
|
+
const d = domain.toLowerCase().replace(/^\*\./, "");
|
|
125
|
+
return h === d || h.endsWith(`.${d}`);
|
|
126
|
+
}
|
|
127
|
+
function isPaymentProviderHost(host, domains = PAYMENT_PROVIDER_DOMAINS) {
|
|
128
|
+
return domains.some((d) => hostMatchesDomain(host, d));
|
|
129
|
+
}
|
|
130
|
+
function luhnValid(digits) {
|
|
131
|
+
let sum = 0;
|
|
132
|
+
let double = false;
|
|
133
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
134
|
+
let n = digits.charCodeAt(i) - 48;
|
|
135
|
+
if (n < 0 || n > 9) return false;
|
|
136
|
+
if (double) {
|
|
137
|
+
n *= 2;
|
|
138
|
+
if (n > 9) n -= 9;
|
|
139
|
+
}
|
|
140
|
+
sum += n;
|
|
141
|
+
double = !double;
|
|
142
|
+
}
|
|
143
|
+
return sum > 0 && sum % 10 === 0;
|
|
144
|
+
}
|
|
145
|
+
function containsCardNumber(value) {
|
|
146
|
+
const runs = value.match(/\d(?:[ -]?\d){12,18}/g) ?? [];
|
|
147
|
+
return runs.some((run) => {
|
|
148
|
+
const digits = run.replace(/[ -]/g, "");
|
|
149
|
+
return digits.length >= 13 && digits.length <= 19 && luhnValid(digits);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
var CARD_FIELD_PATTERN = /card.?(?:number|num|no\b)|cc.?(?:num|number|no\b|exp|csc|cvv|cvc)|credit.?card|debit.?card|\bcvc\b|\bcvv2?\b|\bcsc\b|security.?code|card.?(?:verification|code)|expir(?:y|ation)|exp.?(?:date|month|year)|\bmm.?\/?.?yy\b|card.?holder|name.?on.?card|\bpan\b/i;
|
|
153
|
+
var PAYMENT_ACTION_PATTERN = /^(?:pay(?:\s+now)?\b(?!\s+(?:monthly|annually|yearly|per|by|with|later|in|over|as))|purchase\b|complete (?:my |your |the )?(?:purchase|order|payment|booking)\b|place (?:my |your |the )?order\b|confirm (?:and|&) pay\b|confirm (?:order|purchase|payment|booking)\b|submit (?:order|payment)\b|book and pay\b)/i;
|
|
154
|
+
var COMMIT_ACTION_PATTERN = /\b(?:pay|purchase|place (?:my |your |the )?order|complete (?:my |your |the )?(?:order|purchase|booking)|subscribe|buy now|sign ?up|create (?:an |my |your )?account|register|join now|start (?:my |your )?(?:free )?trial|confirm (?:order|booking)|submit order)\b/i;
|
|
155
|
+
var FORMLESS_COMMIT_PATTERN = /^(?:create (?:an |my |your )?account|complete (?:my |your )?(?:registration|sign ?up)|start (?:my |your )?(?:free )?trial)\b/i;
|
|
156
|
+
function hostOf(url) {
|
|
157
|
+
try {
|
|
158
|
+
return new URL(url).hostname;
|
|
159
|
+
} catch {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function sensitiveFieldReason(f) {
|
|
164
|
+
if (f.type === "password" || /(?:current|new)-password/.test(f.autocomplete)) {
|
|
165
|
+
return "That is a password field.";
|
|
166
|
+
}
|
|
167
|
+
if (/\bcc-/.test(f.autocomplete)) return "That is a payment card field.";
|
|
168
|
+
if (f.tag === "iframe") {
|
|
169
|
+
if (isPaymentProviderHost(hostOf(f.iframeSrc)))
|
|
170
|
+
return "That is a payment provider\u2019s card field.";
|
|
171
|
+
if (CARD_FIELD_PATTERN.test(`${f.iframeTitle} ${f.name}`))
|
|
172
|
+
return "That is a payment card field.";
|
|
173
|
+
}
|
|
174
|
+
const text2 = [f.name, f.id, f.ariaLabel, f.placeholder, f.label].join(" ");
|
|
175
|
+
if (f.tag !== "a" && f.tag !== "button" && CARD_FIELD_PATTERN.test(text2)) {
|
|
176
|
+
return "That is a payment card field.";
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
function submitReason(f) {
|
|
181
|
+
if (f.isNavigatingLink) return null;
|
|
182
|
+
if (f.isSubmit && f.formSensitive) {
|
|
183
|
+
return "That would submit a form holding a password or payment card field.";
|
|
184
|
+
}
|
|
185
|
+
if (f.isSubmit && COMMIT_ACTION_PATTERN.test(f.label)) {
|
|
186
|
+
return `That would submit "${f.label.slice(0, 60)}".`;
|
|
187
|
+
}
|
|
188
|
+
if (f.isButton && !f.inForm && f.formSensitive && FORMLESS_COMMIT_PATTERN.test(f.label.trim())) {
|
|
189
|
+
return `That would press "${f.label.slice(0, 60)}".`;
|
|
190
|
+
}
|
|
191
|
+
if (f.isButton && PAYMENT_ACTION_PATTERN.test(f.label.trim())) {
|
|
192
|
+
return `That would press "${f.label.slice(0, 60)}".`;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
function enterSubmitReason(f) {
|
|
197
|
+
if (!f.inForm || f.tag === "textarea") return null;
|
|
198
|
+
if (f.formSensitive) return "Enter would submit a form holding a password or payment card field.";
|
|
199
|
+
if (COMMIT_ACTION_PATTERN.test(f.formSubmitLabel)) {
|
|
200
|
+
return `Enter would submit "${f.formSubmitLabel.slice(0, 60)}".`;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
function refusalText(reason) {
|
|
205
|
+
return `Refused: ${reason} ${SAFETY_REFUSAL_SUFFIX}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ../browser-toolset/src/page-script.ts
|
|
209
|
+
var MAX_PAGE_TEXT_CHARS = 5e4;
|
|
210
|
+
var CARD_PATTERN_SOURCE = JSON.stringify(CARD_FIELD_PATTERN.source);
|
|
211
|
+
var PAYMENT_DOMAINS_JSON = JSON.stringify(PAYMENT_PROVIDER_DOMAINS);
|
|
212
|
+
var SCRIPT = String.raw`
|
|
213
|
+
(function (args) {
|
|
214
|
+
var REF_ATTR = 'data-sd-ref';
|
|
215
|
+
var MAX = ${MAX_PAGE_TEXT_CHARS};
|
|
216
|
+
var w = window;
|
|
217
|
+
if (typeof w.__sdRefSeq !== 'number') w.__sdRefSeq = 0;
|
|
218
|
+
|
|
219
|
+
function refOf(el) {
|
|
220
|
+
var existing = el.getAttribute(REF_ATTR);
|
|
221
|
+
if (existing) return existing;
|
|
222
|
+
w.__sdRefSeq += 1;
|
|
223
|
+
var ref = 'ref_' + w.__sdRefSeq;
|
|
224
|
+
el.setAttribute(REF_ATTR, ref);
|
|
225
|
+
return ref;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findRef(ref, root) {
|
|
229
|
+
root = root || document;
|
|
230
|
+
var hit = root.querySelector('[' + REF_ATTR + '="' + ref + '"]');
|
|
231
|
+
if (hit) return hit;
|
|
232
|
+
var all = root.querySelectorAll('*');
|
|
233
|
+
for (var i = 0; i < all.length; i++) {
|
|
234
|
+
if (all[i].shadowRoot) {
|
|
235
|
+
var inner = findRef(ref, all[i].shadowRoot);
|
|
236
|
+
if (inner) return inner;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isHidden(el) {
|
|
243
|
+
if (el.getAttribute('aria-hidden') === 'true') return true;
|
|
244
|
+
var style = w.getComputedStyle(el);
|
|
245
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return true;
|
|
246
|
+
if (parseFloat(style.opacity) === 0) return true;
|
|
247
|
+
var rect = el.getBoundingClientRect();
|
|
248
|
+
return rect.width === 0 && rect.height === 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function inViewport(el) {
|
|
252
|
+
var r = el.getBoundingClientRect();
|
|
253
|
+
return r.bottom > 0 && r.right > 0 && r.top < w.innerHeight && r.left < w.innerWidth;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
var INPUT_ROLES = {
|
|
257
|
+
button: 'button', submit: 'button', reset: 'button', image: 'button',
|
|
258
|
+
checkbox: 'checkbox', radio: 'radio', range: 'slider', search: 'searchbox',
|
|
259
|
+
email: 'textbox', tel: 'textbox', url: 'textbox', text: 'textbox',
|
|
260
|
+
password: 'textbox', number: 'spinbutton', date: 'textbox', file: 'button'
|
|
261
|
+
};
|
|
262
|
+
var TAG_ROLES = {
|
|
263
|
+
A: 'link', BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox',
|
|
264
|
+
H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading', H6: 'heading',
|
|
265
|
+
IMG: 'img', NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo',
|
|
266
|
+
FORM: 'form', DIALOG: 'dialog', TABLE: 'table', UL: 'list', OL: 'list', LI: 'listitem',
|
|
267
|
+
SUMMARY: 'button', LABEL: 'label', OPTION: 'option', IFRAME: 'iframe', VIDEO: 'video'
|
|
268
|
+
};
|
|
269
|
+
var INTERACTIVE_ROLES = {
|
|
270
|
+
button: 1, link: 1, checkbox: 1, radio: 1, textbox: 1, searchbox: 1, combobox: 1,
|
|
271
|
+
slider: 1, spinbutton: 1, switch: 1, tab: 1, menuitem: 1, menuitemcheckbox: 1,
|
|
272
|
+
menuitemradio: 1, option: 1, treeitem: 1
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
function roleOf(el) {
|
|
276
|
+
var explicit = el.getAttribute('role');
|
|
277
|
+
if (explicit) return explicit.split(' ')[0];
|
|
278
|
+
if (el.tagName === 'INPUT') {
|
|
279
|
+
var t = (el.getAttribute('type') || 'text').toLowerCase();
|
|
280
|
+
if (t === 'hidden') return null;
|
|
281
|
+
return INPUT_ROLES[t] || 'textbox';
|
|
282
|
+
}
|
|
283
|
+
if (el.tagName === 'A' && !el.hasAttribute('href')) return null;
|
|
284
|
+
if (el.tagName === 'IMG' && !el.getAttribute('alt')) return null;
|
|
285
|
+
if (el.isContentEditable && el.parentElement && !el.parentElement.isContentEditable) return 'textbox';
|
|
286
|
+
return TAG_ROLES[el.tagName] || null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isInteractive(el, role) {
|
|
290
|
+
if (role && INTERACTIVE_ROLES[role]) return true;
|
|
291
|
+
if (el.hasAttribute('onclick')) return true;
|
|
292
|
+
var tab = el.getAttribute('tabindex');
|
|
293
|
+
return tab !== null && Number(tab) >= 0 && !!role;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function clean(s) {
|
|
297
|
+
return (s || '').replace(/\s+/g, ' ').trim();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Roles whose own text is their name. Containers (banner, list, table, form)
|
|
301
|
+
// take a name only from an explicit label: their text is their children's,
|
|
302
|
+
// which the tree already lists, and repeating it drowns \`find\`.
|
|
303
|
+
var TEXT_NAMED = { heading: 1, option: 1, label: 1, text: 1, generic: 1, listitem: 0 };
|
|
304
|
+
|
|
305
|
+
function nameOf(el, role) {
|
|
306
|
+
var label = el.getAttribute('aria-label');
|
|
307
|
+
if (label) return clean(label);
|
|
308
|
+
var by = el.getAttribute('aria-labelledby');
|
|
309
|
+
if (by) {
|
|
310
|
+
var parts = by.split(/\s+/).map(function (id) {
|
|
311
|
+
var n = document.getElementById(id);
|
|
312
|
+
return n ? n.textContent : '';
|
|
313
|
+
});
|
|
314
|
+
var joined = clean(parts.join(' '));
|
|
315
|
+
if (joined) return joined;
|
|
316
|
+
}
|
|
317
|
+
if (el.id) {
|
|
318
|
+
var forLabel = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
|
|
319
|
+
if (forLabel) return clean(forLabel.textContent);
|
|
320
|
+
}
|
|
321
|
+
var alt = el.getAttribute('alt') || el.getAttribute('title');
|
|
322
|
+
if (alt) return clean(alt);
|
|
323
|
+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
324
|
+
return clean(el.getAttribute('placeholder') || '');
|
|
325
|
+
}
|
|
326
|
+
if (role && !INTERACTIVE_ROLES[role] && !TEXT_NAMED[role]) return '';
|
|
327
|
+
return clean(el.innerText || el.textContent).slice(0, 100);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function valueOf(el) {
|
|
331
|
+
if (el.tagName === 'INPUT') {
|
|
332
|
+
var t = (el.getAttribute('type') || 'text').toLowerCase();
|
|
333
|
+
if (t === 'checkbox' || t === 'radio') return el.checked ? 'checked' : 'unchecked';
|
|
334
|
+
if (t === 'password') return el.value ? '••••' : '';
|
|
335
|
+
return el.value ? clean(el.value).slice(0, 80) : '';
|
|
336
|
+
}
|
|
337
|
+
if (el.tagName === 'TEXTAREA') return el.value ? clean(el.value).slice(0, 80) : '';
|
|
338
|
+
if (el.tagName === 'SELECT') {
|
|
339
|
+
var opt = el.options[el.selectedIndex];
|
|
340
|
+
return opt ? clean(opt.text) : '';
|
|
341
|
+
}
|
|
342
|
+
return '';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function hasOwnText(el) {
|
|
346
|
+
for (var c = el.firstChild; c; c = c.nextSibling) {
|
|
347
|
+
if (c.nodeType === 3 && c.nodeValue && c.nodeValue.trim()) return true;
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function children(el) {
|
|
353
|
+
var out = Array.prototype.slice.call(el.children);
|
|
354
|
+
if (el.shadowRoot) out = out.concat(Array.prototype.slice.call(el.shadowRoot.children));
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function describe(el, role) {
|
|
359
|
+
var name = nameOf(el, role);
|
|
360
|
+
var line = role + (name ? ' "' + name.replace(/"/g, "'") + '"' : '');
|
|
361
|
+
var value = valueOf(el);
|
|
362
|
+
if (value) line += ' value="' + value.replace(/"/g, "'") + '"';
|
|
363
|
+
if (el.tagName === 'A' && el.getAttribute('href')) {
|
|
364
|
+
var href = el.getAttribute('href');
|
|
365
|
+
if (href.indexOf('javascript:') !== 0) line += ' href="' + href.slice(0, 200) + '"';
|
|
366
|
+
}
|
|
367
|
+
var description = clean(el.getAttribute('aria-description'));
|
|
368
|
+
if (description) line += ' description="' + description.slice(0, 200).replace(/"/g, "'") + '"';
|
|
369
|
+
if (el.disabled) line += ' disabled';
|
|
370
|
+
// Semantics WAO (@stunt-double/wao) inferred rather than the page's authors
|
|
371
|
+
// wrote: \`data-wao="role name"\` lists what it set.
|
|
372
|
+
var inferred = clean(el.getAttribute('data-wao'));
|
|
373
|
+
if (inferred) line += ' inferred="' + inferred.replace(/"/g, "'") + '"';
|
|
374
|
+
return line + ' [' + refOf(el) + ']';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function read(filter, depth, rootRef) {
|
|
378
|
+
var root = document.body;
|
|
379
|
+
if (rootRef) {
|
|
380
|
+
root = findRef(rootRef);
|
|
381
|
+
if (!root) return { ok: false, error: rootRef + ' is stale or not found on the current page. Re-read the page to get fresh references.' };
|
|
382
|
+
}
|
|
383
|
+
var lines = [];
|
|
384
|
+
var size = 0;
|
|
385
|
+
var truncated = false;
|
|
386
|
+
function walk(el, level, reported) {
|
|
387
|
+
if (truncated || !el || el.nodeType !== 1) return;
|
|
388
|
+
if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE' || el.tagName === 'NOSCRIPT' || el.tagName === 'TEMPLATE') return;
|
|
389
|
+
if (isHidden(el)) return;
|
|
390
|
+
var role = roleOf(el);
|
|
391
|
+
var interactive = isInteractive(el, role);
|
|
392
|
+
var include;
|
|
393
|
+
if (filter === 'interactive') include = interactive;
|
|
394
|
+
else include = !!role || interactive || (hasOwnText(el) && clean(el.innerText).length > 0);
|
|
395
|
+
if (include && filter !== 'all' && !inViewport(el)) include = false;
|
|
396
|
+
var next = reported;
|
|
397
|
+
if (include) {
|
|
398
|
+
var line = ' '.repeat(Math.min(reported, 20)) + describe(el, role || (interactive ? 'generic' : 'text'));
|
|
399
|
+
if (size + line.length + 1 > ${MAX_PAGE_TEXT_CHARS}) { truncated = true; return; }
|
|
400
|
+
lines.push(line);
|
|
401
|
+
size += line.length + 1;
|
|
402
|
+
next = reported + 1;
|
|
403
|
+
if (!interactive && !role) return;
|
|
404
|
+
}
|
|
405
|
+
// Depth counts levels of the reported tree, not DOM nesting: a real
|
|
406
|
+
// page nests its buttons far deeper than 15 elements.
|
|
407
|
+
if (next >= depth) return;
|
|
408
|
+
var kids = children(el);
|
|
409
|
+
for (var i = 0; i < kids.length; i++) walk(kids[i], level + 1, next);
|
|
410
|
+
}
|
|
411
|
+
walk(root, 0, 0);
|
|
412
|
+
var text = lines.join('\n');
|
|
413
|
+
if (!text) text = filter === 'all' ? 'The page has no readable elements.' : 'No elements in the viewport match. Scroll, or read with filter "all".';
|
|
414
|
+
if (truncated) text += '\n[truncated: the page tree exceeded ' + MAX + ' characters; pass a ref or a smaller depth]';
|
|
415
|
+
return { ok: true, text: text, truncated: truncated };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function find(query) {
|
|
419
|
+
var terms = clean(query).toLowerCase().split(' ').filter(function (t) { return t.length > 1; });
|
|
420
|
+
if (!terms.length) return { ok: false, error: 'find needs a query.' };
|
|
421
|
+
var scored = [];
|
|
422
|
+
var all = document.body.querySelectorAll('*');
|
|
423
|
+
for (var i = 0; i < all.length; i++) {
|
|
424
|
+
var el = all[i];
|
|
425
|
+
if (isHidden(el)) continue;
|
|
426
|
+
var role = roleOf(el);
|
|
427
|
+
var interactive = isInteractive(el, role);
|
|
428
|
+
if (!role && !interactive) continue;
|
|
429
|
+
var hay = (role + ' ' + nameOf(el, role || 'generic') + ' ' + (el.getAttribute('aria-description') || '') + ' ' + (el.getAttribute('placeholder') || '') + ' ' + (el.getAttribute('name') || '')).toLowerCase();
|
|
430
|
+
var score = 0;
|
|
431
|
+
for (var j = 0; j < terms.length; j++) if (hay.indexOf(terms[j]) !== -1) score += 1;
|
|
432
|
+
if (score > 0) scored.push({ el: el, role: role || 'generic', score: score + (interactive ? 0.5 : 0) + (inViewport(el) ? 0.25 : 0) });
|
|
433
|
+
}
|
|
434
|
+
scored.sort(function (a, b) { return b.score - a.score; });
|
|
435
|
+
var top = scored.slice(0, 20);
|
|
436
|
+
if (!top.length) return { ok: true, text: 'No elements match "' + clean(query) + '".', truncated: false };
|
|
437
|
+
return { ok: true, text: top.map(function (s) { return describe(s.el, s.role); }).join('\n'), truncated: false };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function pageText() {
|
|
441
|
+
var main = document.querySelector('main, [role="main"], article');
|
|
442
|
+
var text = clean((main && main.innerText.length > 200 ? main.innerText : document.body.innerText) || '');
|
|
443
|
+
var truncated = text.length > MAX;
|
|
444
|
+
return { ok: true, text: truncated ? text.slice(0, MAX) + '\n[truncated]' : text || 'The page has no text.', truncated: truncated };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function resolve(ref, scroll) {
|
|
448
|
+
var el = findRef(ref);
|
|
449
|
+
if (!el || !el.isConnected) return { ok: false, error: ref + ' is stale or not found on the current page. Re-read the page to get fresh references.' };
|
|
450
|
+
// Instant, not the page's own scroll-behavior: smooth, which is still
|
|
451
|
+
// moving when the box is measured, so the click would land short.
|
|
452
|
+
if (scroll && !inViewport(el)) el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
453
|
+
var r = el.getBoundingClientRect();
|
|
454
|
+
return { ok: true, x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), inViewport: inViewport(el) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function form(ref, value) {
|
|
458
|
+
var el = findRef(ref);
|
|
459
|
+
if (!el) return { ok: false, error: ref + ' is stale or not found on the current page. Re-read the page to get fresh references.' };
|
|
460
|
+
var fire = function (type) { el.dispatchEvent(new Event(type, { bubbles: true })); };
|
|
461
|
+
if (el.tagName === 'SELECT') {
|
|
462
|
+
var want = String(value).toLowerCase();
|
|
463
|
+
for (var i = 0; i < el.options.length; i++) {
|
|
464
|
+
var o = el.options[i];
|
|
465
|
+
if (o.value.toLowerCase() === want || clean(o.text).toLowerCase() === want) {
|
|
466
|
+
el.selectedIndex = i; fire('input'); fire('change');
|
|
467
|
+
return { ok: true, description: 'Selected "' + clean(o.text) + '"' };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { ok: false, error: 'No option in ' + ref + ' matches that value. Read the page to see its options.' };
|
|
471
|
+
}
|
|
472
|
+
if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
|
|
473
|
+
var on = value === true || String(value).toLowerCase() === 'true' || String(value).toLowerCase() === 'checked';
|
|
474
|
+
if (el.checked !== on) el.click();
|
|
475
|
+
return { ok: true, description: (el.checked ? 'Checked ' : 'Unchecked ') + ref };
|
|
476
|
+
}
|
|
477
|
+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
478
|
+
var proto = el.tagName === 'INPUT' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
|
|
479
|
+
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
|
480
|
+
el.focus();
|
|
481
|
+
setter.call(el, String(value));
|
|
482
|
+
fire('input'); fire('change');
|
|
483
|
+
// The value can be a password or a code, and results are recorded, so only its length.
|
|
484
|
+
return { ok: true, description: 'Set ' + ref + ' (' + String(value).length + ' characters)' };
|
|
485
|
+
}
|
|
486
|
+
if (el.isContentEditable) {
|
|
487
|
+
el.focus(); el.textContent = String(value); fire('input');
|
|
488
|
+
return { ok: true, description: 'Set ' + ref + ' text' };
|
|
489
|
+
}
|
|
490
|
+
return { ok: false, error: ref + ' is not a form field.' };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --- inspect: the facts the safety guards decide on ----------------------
|
|
494
|
+
|
|
495
|
+
var CARD_RE = new RegExp(${CARD_PATTERN_SOURCE}, 'i');
|
|
496
|
+
var PAYMENT_DOMAINS = ${PAYMENT_DOMAINS_JSON};
|
|
497
|
+
var ACTIONABLE = 'a[href],button,input,select,textarea,summary,iframe,[role="button"],[role="link"],[role="checkbox"],[role="textbox"],[contenteditable="true"]';
|
|
498
|
+
|
|
499
|
+
function isPaymentFrame(src) {
|
|
500
|
+
var host = '';
|
|
501
|
+
try { host = new URL(src, location.href).hostname.toLowerCase(); } catch (e) { return false; }
|
|
502
|
+
for (var i = 0; i < PAYMENT_DOMAINS.length; i++) {
|
|
503
|
+
var d = PAYMENT_DOMAINS[i];
|
|
504
|
+
if (host === d || host.slice(-(d.length + 1)) === '.' + d) return true;
|
|
505
|
+
}
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function isCardField(el) {
|
|
510
|
+
if (el.tagName === 'IFRAME') {
|
|
511
|
+
return isPaymentFrame(el.getAttribute('src') || '') || CARD_RE.test((el.getAttribute('title') || '') + ' ' + (el.getAttribute('name') || ''));
|
|
512
|
+
}
|
|
513
|
+
if (/(^|\s)cc-/.test((el.getAttribute('autocomplete') || '').toLowerCase())) return true;
|
|
514
|
+
var text = [el.getAttribute('name'), el.id, el.getAttribute('aria-label'), el.getAttribute('placeholder')].join(' ');
|
|
515
|
+
return CARD_RE.test(text);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Across the whole page only visible fields count: a login modal parked
|
|
519
|
+
// in the DOM must not make every button on the page look like a submit.
|
|
520
|
+
function holdsSensitive(root, visibleOnly) {
|
|
521
|
+
if (!root) return false;
|
|
522
|
+
var fields = root.querySelectorAll('input, iframe');
|
|
523
|
+
for (var i = 0; i < fields.length; i++) {
|
|
524
|
+
var f = fields[i];
|
|
525
|
+
var t = (f.getAttribute('type') || '').toLowerCase();
|
|
526
|
+
if (t === 'hidden' || t === 'submit' || t === 'button') continue;
|
|
527
|
+
if (t !== 'password' && !isCardField(f)) continue;
|
|
528
|
+
if (visibleOnly && isHidden(f)) continue;
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function deepActive() {
|
|
535
|
+
var el = document.activeElement;
|
|
536
|
+
for (var i = 0; el && i < 10; i++) {
|
|
537
|
+
if (el.shadowRoot && el.shadowRoot.activeElement) { el = el.shadowRoot.activeElement; continue; }
|
|
538
|
+
if (el.tagName === 'IFRAME') {
|
|
539
|
+
var inner = null;
|
|
540
|
+
try { inner = el.contentDocument && el.contentDocument.activeElement; } catch (e) { inner = null; }
|
|
541
|
+
if (inner && inner !== el.contentDocument.body) { el = inner; continue; }
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
return el && el !== document.body && el !== document.documentElement ? el : null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function atPoint(x, y) {
|
|
549
|
+
var el = document.elementFromPoint(x, y);
|
|
550
|
+
for (var i = 0; el && el.shadowRoot && i < 10; i++) {
|
|
551
|
+
var inner = el.shadowRoot.elementFromPoint(x, y);
|
|
552
|
+
if (!inner || inner === el) break;
|
|
553
|
+
el = inner;
|
|
554
|
+
}
|
|
555
|
+
return el;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function submitLabel(form) {
|
|
559
|
+
var controls = form.querySelectorAll('button, input[type="submit" i], input[type="image" i]');
|
|
560
|
+
for (var i = 0; i < controls.length; i++) {
|
|
561
|
+
var c = controls[i];
|
|
562
|
+
var t = (c.getAttribute('type') || 'submit').toLowerCase();
|
|
563
|
+
if (t === 'submit' || t === 'image') return labelOf(c);
|
|
564
|
+
}
|
|
565
|
+
return '';
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function labelOf(el) {
|
|
569
|
+
var name = nameOf(el, roleOf(el) || 'generic');
|
|
570
|
+
if (!name && el.tagName === 'INPUT') name = clean(el.value || '');
|
|
571
|
+
return name;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function factsOf(raw) {
|
|
575
|
+
if (!raw || raw.nodeType !== 1) return null;
|
|
576
|
+
var el = raw.closest ? raw.closest(ACTIONABLE) || raw : raw;
|
|
577
|
+
var tag = el.tagName.toLowerCase();
|
|
578
|
+
var type = (el.getAttribute('type') || '').toLowerCase();
|
|
579
|
+
if (tag === 'button' && !type) type = 'submit';
|
|
580
|
+
var role = roleOf(el) || '';
|
|
581
|
+
var form = el.form || (el.closest ? el.closest('form') : null);
|
|
582
|
+
var href = tag === 'a' ? el.getAttribute('href') || '' : '';
|
|
583
|
+
var navigating = tag === 'a' && !!href && href.charAt(0) !== '#' && href.toLowerCase().indexOf('javascript:') !== 0 && el.getAttribute('role') !== 'button';
|
|
584
|
+
var isSubmit = !!form && ((tag === 'button' && type === 'submit') || (tag === 'input' && (type === 'submit' || type === 'image')));
|
|
585
|
+
var isButton = tag === 'button' || role === 'button' || (tag === 'input' && (type === 'submit' || type === 'button' || type === 'image'));
|
|
586
|
+
return {
|
|
587
|
+
tag: tag,
|
|
588
|
+
type: type,
|
|
589
|
+
autocomplete: (el.getAttribute('autocomplete') || '').toLowerCase(),
|
|
590
|
+
name: el.getAttribute('name') || '',
|
|
591
|
+
id: el.id || '',
|
|
592
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
593
|
+
placeholder: el.getAttribute('placeholder') || '',
|
|
594
|
+
label: labelOf(el).slice(0, 200),
|
|
595
|
+
role: role,
|
|
596
|
+
isNavigatingLink: navigating,
|
|
597
|
+
isSubmit: isSubmit,
|
|
598
|
+
isButton: isButton,
|
|
599
|
+
iframeSrc: tag === 'iframe' ? (el.getAttribute('src') || '').slice(0, 500) : '',
|
|
600
|
+
iframeTitle: tag === 'iframe' ? el.getAttribute('title') || '' : '',
|
|
601
|
+
inForm: !!form,
|
|
602
|
+
// Without a form, the page stands in for one: a single-page sign-up
|
|
603
|
+
// with a password box and a "Create account" button is still a sign-up.
|
|
604
|
+
formSensitive: form ? holdsSensitive(form, false) : holdsSensitive(document, true),
|
|
605
|
+
formSubmitLabel: form ? submitLabel(form).slice(0, 200) : ''
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function inspect(target) {
|
|
610
|
+
if (target.type === 'focused') return { ok: true, facts: factsOf(deepActive()) };
|
|
611
|
+
if (target.type === 'coordinate') return { ok: true, facts: factsOf(atPoint(target.x, target.y)) };
|
|
612
|
+
var el = findRef(target.ref);
|
|
613
|
+
if (!el) return { ok: false, error: target.ref + ' is stale or not found on the current page. Re-read the page to get fresh references.' };
|
|
614
|
+
return { ok: true, facts: factsOf(el) };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
switch (args.op) {
|
|
618
|
+
case 'read': return read(args.filter, args.depth, args.ref);
|
|
619
|
+
case 'find': return find(args.query);
|
|
620
|
+
case 'text': return pageText();
|
|
621
|
+
case 'resolve': return resolve(args.ref, args.scroll);
|
|
622
|
+
case 'form': return form(args.ref, args.value);
|
|
623
|
+
case 'viewport': return { ok: true, width: w.innerWidth, height: w.innerHeight };
|
|
624
|
+
case 'inspect': return inspect(args.target);
|
|
625
|
+
}
|
|
626
|
+
return { ok: false, error: 'unknown op' };
|
|
627
|
+
})`;
|
|
628
|
+
function buildPageCall(op) {
|
|
629
|
+
return `${SCRIPT}(${JSON.stringify(op)})`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ../browser-toolset/src/executor.ts
|
|
633
|
+
var BROWSER_TOOLSET_NAME = "browser";
|
|
634
|
+
var HALT_TEXT = "Not executed: an earlier action in this turn failed.";
|
|
635
|
+
var DISABLED_MEMBERS = /* @__PURE__ */ new Set([
|
|
636
|
+
"javascript_exec",
|
|
637
|
+
"file_upload",
|
|
638
|
+
"read_console",
|
|
639
|
+
"read_network"
|
|
640
|
+
]);
|
|
641
|
+
var TAB_MEMBERS = /* @__PURE__ */ new Set(["new_tab", "list_tabs", "switch_tab", "close_tab"]);
|
|
642
|
+
var MAX_WAIT_SECONDS = 30;
|
|
643
|
+
var SCROLL_NOTCH_PX = 100;
|
|
644
|
+
var FIELD_LIMIT = 4096;
|
|
645
|
+
var ToolsetError = class extends Error {
|
|
646
|
+
};
|
|
647
|
+
function isPrivateHost(hostname) {
|
|
648
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
649
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal"))
|
|
650
|
+
return true;
|
|
651
|
+
if (host === "metadata.google.internal") return true;
|
|
652
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
653
|
+
if (v4) {
|
|
654
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
655
|
+
return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
|
|
656
|
+
}
|
|
657
|
+
if (host.includes(":")) {
|
|
658
|
+
return host === "::1" || host === "::" || /^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith("::ffff:");
|
|
659
|
+
}
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
function redactInput(name, input) {
|
|
663
|
+
if (name === "type" && typeof input.text === "string") {
|
|
664
|
+
return { ...input, text: `[${input.text.length} characters]` };
|
|
665
|
+
}
|
|
666
|
+
if (name === "form_input" && "value" in input) {
|
|
667
|
+
const v = input.value;
|
|
668
|
+
return { ...input, value: typeof v === "boolean" ? v : `[${String(v).length} characters]` };
|
|
669
|
+
}
|
|
670
|
+
return input;
|
|
671
|
+
}
|
|
672
|
+
function sanitiseStateField(value) {
|
|
673
|
+
return value.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").slice(0, FIELD_LIMIT);
|
|
674
|
+
}
|
|
675
|
+
function parseTarget(raw, allow) {
|
|
676
|
+
const t = raw ?? {};
|
|
677
|
+
if (t.type === "ref" && typeof t.ref === "string" && allow !== "coordinate") {
|
|
678
|
+
return { type: "ref", ref: t.ref };
|
|
679
|
+
}
|
|
680
|
+
if (t.type === "coordinate" && typeof t.x === "number" && typeof t.y === "number" && allow !== "ref") {
|
|
681
|
+
return { type: "coordinate", x: Math.round(t.x), y: Math.round(t.y) };
|
|
682
|
+
}
|
|
683
|
+
const want = allow === "ref" ? "a ref target" : allow === "coordinate" ? "a coordinate target" : "a ref or coordinate target";
|
|
684
|
+
throw new ToolsetError(`Error: expected ${want}.`);
|
|
685
|
+
}
|
|
686
|
+
function clampNumber(value, min, max, fallback) {
|
|
687
|
+
const n = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
688
|
+
return Math.min(max, Math.max(min, n));
|
|
689
|
+
}
|
|
690
|
+
var BrowserToolsetExecutor = class {
|
|
691
|
+
/** Tab ids by the driver's stable page id (page objects are not stable). */
|
|
692
|
+
tabIds = /* @__PURE__ */ new Map();
|
|
693
|
+
tabSeq = 0;
|
|
694
|
+
// Plain fields rather than parameter properties: `node --test` runs this
|
|
695
|
+
// file with type stripping only, which rejects parameter properties.
|
|
696
|
+
driver;
|
|
697
|
+
options;
|
|
698
|
+
constructor(driver, options = {}) {
|
|
699
|
+
this.driver = driver;
|
|
700
|
+
this.options = options;
|
|
701
|
+
}
|
|
702
|
+
/** Turns run one at a time: two batches interleaving on one page would be nonsense. */
|
|
703
|
+
queue = Promise.resolve();
|
|
704
|
+
/**
|
|
705
|
+
* Run every browser call in a turn, in order, halting after the first
|
|
706
|
+
* failure. An aborted `signal` halts the rest the same way, so a caller that
|
|
707
|
+
* gave up on a stuck turn does not have its remaining calls land on the
|
|
708
|
+
* connection it rebuilt.
|
|
709
|
+
*/
|
|
710
|
+
runTurn(calls, opts = {}) {
|
|
711
|
+
const run = this.queue.then(() => this.runTurnNow(calls, opts.signal));
|
|
712
|
+
this.queue = run.catch(() => {
|
|
713
|
+
});
|
|
714
|
+
return run;
|
|
715
|
+
}
|
|
716
|
+
async runTurnNow(calls, signal) {
|
|
717
|
+
const results = [];
|
|
718
|
+
const records = [];
|
|
719
|
+
let failed = false;
|
|
720
|
+
for (const call of calls) {
|
|
721
|
+
const input = redactInput(call.name, call.input ?? {});
|
|
722
|
+
if (failed || signal?.aborted) {
|
|
723
|
+
results.push(this.errorResult(call.id, HALT_TEXT));
|
|
724
|
+
records.push({
|
|
725
|
+
toolUseId: call.id,
|
|
726
|
+
name: call.name,
|
|
727
|
+
input,
|
|
728
|
+
text: HALT_TEXT,
|
|
729
|
+
isError: true
|
|
730
|
+
});
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const content = await this.execute(
|
|
735
|
+
call.name,
|
|
736
|
+
call.input ?? {}
|
|
737
|
+
);
|
|
738
|
+
results.push({
|
|
739
|
+
type: "tool_result",
|
|
740
|
+
tool_use_id: call.id,
|
|
741
|
+
toolset_name: BROWSER_TOOLSET_NAME,
|
|
742
|
+
content
|
|
743
|
+
});
|
|
744
|
+
const text2 = content.map(
|
|
745
|
+
(b) => b.type === "text" ? b.text : b.type === "browser_state" ? describeState(b) : ""
|
|
746
|
+
).filter(Boolean).join("\n");
|
|
747
|
+
const image2 = content.find((b) => b.type === "image");
|
|
748
|
+
records.push({
|
|
749
|
+
toolUseId: call.id,
|
|
750
|
+
name: call.name,
|
|
751
|
+
input,
|
|
752
|
+
text: text2,
|
|
753
|
+
screenshotBase64: image2 && image2.type === "image" && image2.source.type === "base64" ? image2.source.data : void 0,
|
|
754
|
+
isError: false
|
|
755
|
+
});
|
|
756
|
+
} catch (err) {
|
|
757
|
+
failed = true;
|
|
758
|
+
const message = err instanceof ToolsetError ? err.message : `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
759
|
+
results.push(this.errorResult(call.id, message));
|
|
760
|
+
records.push({ toolUseId: call.id, name: call.name, input, text: message, isError: true });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return { results, records };
|
|
764
|
+
}
|
|
765
|
+
errorResult(toolUseId, text2) {
|
|
766
|
+
return {
|
|
767
|
+
type: "tool_result",
|
|
768
|
+
tool_use_id: toolUseId,
|
|
769
|
+
toolset_name: BROWSER_TOOLSET_NAME,
|
|
770
|
+
is_error: true,
|
|
771
|
+
content: text2
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
/** Execute one member. Throws `ToolsetError` (or anything) to fail the call. */
|
|
775
|
+
async execute(name, input) {
|
|
776
|
+
if (DISABLED_MEMBERS.has(name)) {
|
|
777
|
+
throw new ToolsetError(`Error: ${name} is not enabled in this environment.`);
|
|
778
|
+
}
|
|
779
|
+
if (TAB_MEMBERS.has(name)) return [await this.tabMember(name, input)];
|
|
780
|
+
const page = await this.pageFor(input.tab_id);
|
|
781
|
+
switch (name) {
|
|
782
|
+
case "navigate":
|
|
783
|
+
return this.navigate(page, input);
|
|
784
|
+
case "screenshot":
|
|
785
|
+
return [await this.screenshot(page)];
|
|
786
|
+
case "zoom":
|
|
787
|
+
return [await this.zoom(page, input)];
|
|
788
|
+
case "left_click":
|
|
789
|
+
case "right_click":
|
|
790
|
+
case "middle_click":
|
|
791
|
+
case "double_click":
|
|
792
|
+
case "triple_click": {
|
|
793
|
+
if (this.options.safety?.blockSubmit && name !== "right_click") {
|
|
794
|
+
const target = parseTarget(input.target, "any");
|
|
795
|
+
const facts = await this.inspect(page, target);
|
|
796
|
+
const reason = facts && submitReason(facts);
|
|
797
|
+
if (reason) return [text(refusalText(reason))];
|
|
798
|
+
}
|
|
799
|
+
return [text(await this.click(page, name, input))];
|
|
800
|
+
}
|
|
801
|
+
case "hover": {
|
|
802
|
+
const { x, y } = await this.point(page, parseTarget(input.target, "any"));
|
|
803
|
+
await page.hover(x, y);
|
|
804
|
+
return [text(`Hovered at (${x}, ${y})`)];
|
|
805
|
+
}
|
|
806
|
+
case "mouse_move": {
|
|
807
|
+
const { x, y } = await this.point(page, parseTarget(input.target, "coordinate"));
|
|
808
|
+
await page.hover(x, y);
|
|
809
|
+
return [text(`Moved the mouse to (${x}, ${y})`)];
|
|
810
|
+
}
|
|
811
|
+
case "left_click_drag": {
|
|
812
|
+
const from = await this.point(page, parseTarget(input.from, "coordinate"));
|
|
813
|
+
const to = await this.point(page, parseTarget(input.target, "coordinate"));
|
|
814
|
+
await page.dragAndDrop(from.x, from.y, to.x, to.y);
|
|
815
|
+
return [text(`Dragged from (${from.x}, ${from.y}) to (${to.x}, ${to.y})`)];
|
|
816
|
+
}
|
|
817
|
+
case "left_mouse_down":
|
|
818
|
+
case "left_mouse_up":
|
|
819
|
+
case "hold_key":
|
|
820
|
+
throw new ToolsetError(
|
|
821
|
+
`Error: ${name} is not supported by this browser. Use left_click, left_click_drag or key instead.`
|
|
822
|
+
);
|
|
823
|
+
case "scroll":
|
|
824
|
+
return [text(await this.scroll(page, input))];
|
|
825
|
+
case "scroll_to": {
|
|
826
|
+
const target = parseTarget(input.target, "ref");
|
|
827
|
+
const r = await this.pageCall(page, {
|
|
828
|
+
op: "resolve",
|
|
829
|
+
ref: target.ref,
|
|
830
|
+
scroll: true
|
|
831
|
+
});
|
|
832
|
+
if (!r.ok) throw new ToolsetError(`Error: ${r.error}`);
|
|
833
|
+
return [text(`Scrolled ${target.ref} into view`)];
|
|
834
|
+
}
|
|
835
|
+
case "type": {
|
|
836
|
+
const value = String(input.text ?? "");
|
|
837
|
+
const refusal = await this.typeRefusal(page, value);
|
|
838
|
+
if (refusal) return [text(refusal)];
|
|
839
|
+
await page.type(value);
|
|
840
|
+
return [text(`Typed ${value.length} character${value.length === 1 ? "" : "s"}`)];
|
|
841
|
+
}
|
|
842
|
+
case "key": {
|
|
843
|
+
const keys = toPlaywrightKeySequence(String(input.text ?? ""));
|
|
844
|
+
if (keys.length === 0) throw new ToolsetError("Error: key needs text.");
|
|
845
|
+
const repeat = Math.round(clampNumber(input.repeat, 1, 100, 1));
|
|
846
|
+
const refusal = await this.keyRefusal(page, keys);
|
|
847
|
+
if (refusal) return [text(refusal)];
|
|
848
|
+
for (let i = 0; i < repeat; i++) for (const k of keys) await page.keyPress(k);
|
|
849
|
+
return [text(`Pressed ${keys.join(" ")}${repeat > 1 ? ` x${repeat}` : ""}`)];
|
|
850
|
+
}
|
|
851
|
+
case "wait": {
|
|
852
|
+
const seconds = clampNumber(input.duration, 0, MAX_WAIT_SECONDS, 1);
|
|
853
|
+
await page.waitForTimeout(seconds * 1e3);
|
|
854
|
+
return [text(`Waited ${seconds}s`)];
|
|
855
|
+
}
|
|
856
|
+
case "read_page": {
|
|
857
|
+
const filter = input.filter === "interactive" || input.filter === "all" ? input.filter : "visible";
|
|
858
|
+
const depth = Math.round(clampNumber(input.depth, 1, 100, 15));
|
|
859
|
+
const ref = typeof input.ref === "string" ? input.ref : void 0;
|
|
860
|
+
const r = await this.pageCall(page, { op: "read", filter, depth, ref });
|
|
861
|
+
if (!r.ok) throw new ToolsetError(`Error: ${r.error}`);
|
|
862
|
+
return [text(r.text)];
|
|
863
|
+
}
|
|
864
|
+
case "find": {
|
|
865
|
+
const r = await this.pageCall(page, {
|
|
866
|
+
op: "find",
|
|
867
|
+
query: String(input.query ?? "")
|
|
868
|
+
});
|
|
869
|
+
if (!r.ok) throw new ToolsetError(`Error: ${r.error}`);
|
|
870
|
+
return [text(r.text)];
|
|
871
|
+
}
|
|
872
|
+
case "get_page_text": {
|
|
873
|
+
const r = await this.pageCall(page, { op: "text" });
|
|
874
|
+
if (!r.ok) throw new ToolsetError(`Error: ${r.error}`);
|
|
875
|
+
return [text(r.text)];
|
|
876
|
+
}
|
|
877
|
+
case "form_input": {
|
|
878
|
+
const target = parseTarget(input.target, "ref");
|
|
879
|
+
const value = input.value;
|
|
880
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
881
|
+
throw new ToolsetError("Error: form_input needs a string, number or boolean value.");
|
|
882
|
+
}
|
|
883
|
+
if (this.options.safety?.refuseSensitiveInput && typeof value !== "boolean") {
|
|
884
|
+
const refusal = containsCardNumber(String(value)) && refusalText("That value is a card number.") || await this.fieldRefusal(page, target);
|
|
885
|
+
if (refusal) return [text(refusal)];
|
|
886
|
+
}
|
|
887
|
+
const r = await this.pageCall(page, { op: "form", ref: target.ref, value });
|
|
888
|
+
if (!r.ok) throw new ToolsetError(`Error: ${r.error}`);
|
|
889
|
+
return [text(r.description)];
|
|
890
|
+
}
|
|
891
|
+
default:
|
|
892
|
+
throw new ToolsetError(
|
|
893
|
+
`Error: ${name} is not a browser toolset member this executor knows.`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// --- members -------------------------------------------------------------
|
|
898
|
+
async navigate(page, input) {
|
|
899
|
+
const raw = String(input.url ?? "").trim();
|
|
900
|
+
if (raw === "back" || raw === "forward") {
|
|
901
|
+
await (raw === "back" ? page.goBack() : page.goForward());
|
|
902
|
+
return [text(`Went ${raw} to ${await page.url()}`), await this.browserState()];
|
|
903
|
+
}
|
|
904
|
+
let url;
|
|
905
|
+
try {
|
|
906
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || /^(javascript|data|file|about|blob|mailto|vbscript|chrome|view-source):/i.test(raw);
|
|
907
|
+
url = new URL(hasScheme ? raw : `https://${raw}`);
|
|
908
|
+
} catch {
|
|
909
|
+
throw new ToolsetError(`Error: "${raw.slice(0, 200)}" is not a valid URL.`);
|
|
910
|
+
}
|
|
911
|
+
if (/^https?:$/.test(url.protocol) && !url.hostname.includes(".") && url.hostname !== "localhost") {
|
|
912
|
+
throw new ToolsetError(
|
|
913
|
+
`Error: "${raw.slice(0, 200)}" is not a URL. Pass a full address, or click the link with left_click.`
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
917
|
+
throw new ToolsetError("Error: Navigation refused. Only http and https URLs are allowed.");
|
|
918
|
+
}
|
|
919
|
+
if (!this.options.allowPrivateHosts && isPrivateHost(url.hostname)) {
|
|
920
|
+
throw new ToolsetError(
|
|
921
|
+
"Error: Navigation refused. Local and private network addresses are not reachable from this browser."
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
const refusal = this.options.checkNavigation?.(url.href);
|
|
925
|
+
if (refusal) throw new ToolsetError(`Error: Navigation refused. ${refusal}`);
|
|
926
|
+
await page.goto(url.href, {
|
|
927
|
+
waitUntil: "domcontentloaded",
|
|
928
|
+
...this.options.navigationTimeoutMs ? { timeoutMs: this.options.navigationTimeoutMs } : {}
|
|
929
|
+
});
|
|
930
|
+
return [text(`Navigated to ${await page.url()}`), await this.browserState()];
|
|
931
|
+
}
|
|
932
|
+
async screenshot(page) {
|
|
933
|
+
const bytes = await page.screenshot({
|
|
934
|
+
type: "png",
|
|
935
|
+
...this.options.screenshotTimeoutMs ? { timeout: this.options.screenshotTimeoutMs } : {}
|
|
936
|
+
});
|
|
937
|
+
return image(bytes.toString("base64"));
|
|
938
|
+
}
|
|
939
|
+
async zoom(page, input) {
|
|
940
|
+
const region = input.region;
|
|
941
|
+
if (!Array.isArray(region) || region.length !== 4 || !region.every((n) => typeof n === "number")) {
|
|
942
|
+
throw new ToolsetError("Error: zoom needs region [x0, y0, x1, y1].");
|
|
943
|
+
}
|
|
944
|
+
const [x0, y0, x1, y1] = region;
|
|
945
|
+
const width = Math.abs(x1 - x0);
|
|
946
|
+
const height = Math.abs(y1 - y0);
|
|
947
|
+
if (width < 1 || height < 1) throw new ToolsetError("Error: zoom region is empty.");
|
|
948
|
+
const bytes = await page.screenshot({
|
|
949
|
+
type: "png",
|
|
950
|
+
clip: { x: Math.min(x0, x1), y: Math.min(y0, y1), width, height },
|
|
951
|
+
...this.options.screenshotTimeoutMs ? { timeout: this.options.screenshotTimeoutMs } : {}
|
|
952
|
+
});
|
|
953
|
+
return image(bytes.toString("base64"));
|
|
954
|
+
}
|
|
955
|
+
async click(page, name, input) {
|
|
956
|
+
if (Array.isArray(input.modifiers) ? input.modifiers.length > 0 : input.modifiers) {
|
|
957
|
+
throw new ToolsetError(
|
|
958
|
+
"Error: modifier clicks are not supported by this browser. Click without modifiers, or use key."
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
const target = parseTarget(input.target, "any");
|
|
962
|
+
const { x, y } = await this.point(page, target);
|
|
963
|
+
const button = name === "right_click" ? "right" : name === "middle_click" ? "middle" : "left";
|
|
964
|
+
const clickCount = name === "double_click" ? 2 : name === "triple_click" ? 3 : 1;
|
|
965
|
+
await page.click(x, y, { button, clickCount });
|
|
966
|
+
const where = target.type === "ref" ? `${target.ref} at (${x}, ${y})` : `(${x}, ${y})`;
|
|
967
|
+
return `${name.replace("_", " ")} on ${where}`;
|
|
968
|
+
}
|
|
969
|
+
async scroll(page, input) {
|
|
970
|
+
const direction = input.scroll_direction;
|
|
971
|
+
if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
|
|
972
|
+
throw new ToolsetError("Error: scroll_direction must be up, down, left or right.");
|
|
973
|
+
}
|
|
974
|
+
const amount = Math.round(clampNumber(input.scroll_amount, 1, 10, 3));
|
|
975
|
+
const { x, y } = await this.point(page, parseTarget(input.target, "any"));
|
|
976
|
+
const delta = amount * SCROLL_NOTCH_PX;
|
|
977
|
+
const dx = direction === "left" ? -delta : direction === "right" ? delta : 0;
|
|
978
|
+
const dy = direction === "up" ? -delta : direction === "down" ? delta : 0;
|
|
979
|
+
await page.scroll(x, y, dx, dy);
|
|
980
|
+
return `Scrolled ${direction} ${amount} at (${x}, ${y})`;
|
|
981
|
+
}
|
|
982
|
+
async tabMember(name, input) {
|
|
983
|
+
const ctx = this.driver.context;
|
|
984
|
+
if (name === "list_tabs") return this.browserState();
|
|
985
|
+
if (name === "new_tab") {
|
|
986
|
+
for (const existing of await ctx.pages()) this.tabId(existing);
|
|
987
|
+
const page2 = await ctx.newPage();
|
|
988
|
+
return this.browserState([{ type: "tab_opened", tab_id: this.tabId(page2) }]);
|
|
989
|
+
}
|
|
990
|
+
const page = await this.pageFor(input.tab_id, true);
|
|
991
|
+
if (name === "switch_tab") {
|
|
992
|
+
await ctx.setActivePage(page);
|
|
993
|
+
return this.browserState();
|
|
994
|
+
}
|
|
995
|
+
const wasActive = (await ctx.activePage())?.id === page.id;
|
|
996
|
+
await page.close();
|
|
997
|
+
if (wasActive) {
|
|
998
|
+
const remaining = await ctx.pages();
|
|
999
|
+
if (remaining.length > 0) await ctx.setActivePage(remaining[remaining.length - 1]);
|
|
1000
|
+
}
|
|
1001
|
+
return this.browserState();
|
|
1002
|
+
}
|
|
1003
|
+
// --- helpers -------------------------------------------------------------
|
|
1004
|
+
tabId(page) {
|
|
1005
|
+
let id = this.tabIds.get(page.id);
|
|
1006
|
+
if (!id) {
|
|
1007
|
+
this.tabSeq += 1;
|
|
1008
|
+
id = `tab-${this.tabSeq}`;
|
|
1009
|
+
this.tabIds.set(page.id, id);
|
|
1010
|
+
}
|
|
1011
|
+
return id;
|
|
1012
|
+
}
|
|
1013
|
+
async pageFor(tabId, required = false) {
|
|
1014
|
+
const ctx = this.driver.context;
|
|
1015
|
+
if (typeof tabId === "string" && tabId.length > 0) {
|
|
1016
|
+
for (const page2 of await ctx.pages()) if (this.tabId(page2) === tabId) return page2;
|
|
1017
|
+
throw new ToolsetError(
|
|
1018
|
+
`Error: no tab with tab_id ${tabId}. Call list_tabs for the current tabs.`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
if (required) throw new ToolsetError("Error: tab_id is required.");
|
|
1022
|
+
const page = await ctx.activePage();
|
|
1023
|
+
if (!page) throw new ToolsetError("Error: the browser has no open tab. Call new_tab first.");
|
|
1024
|
+
return page;
|
|
1025
|
+
}
|
|
1026
|
+
async point(page, target) {
|
|
1027
|
+
if (target.type === "coordinate") return { x: target.x, y: target.y };
|
|
1028
|
+
let r = await this.pageCall(page, {
|
|
1029
|
+
op: "resolve",
|
|
1030
|
+
ref: target.ref,
|
|
1031
|
+
scroll: true
|
|
1032
|
+
});
|
|
1033
|
+
if (r.ok && !r.inViewport) {
|
|
1034
|
+
await page.waitForTimeout(250);
|
|
1035
|
+
r = await this.pageCall(page, {
|
|
1036
|
+
op: "resolve",
|
|
1037
|
+
ref: target.ref,
|
|
1038
|
+
scroll: false
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
if (!r.ok) throw new ToolsetError(`Error: ${r.error}`);
|
|
1042
|
+
if (!r.inViewport) {
|
|
1043
|
+
throw new ToolsetError(
|
|
1044
|
+
`Error: ${target.ref} could not be scrolled into view. Try coordinates from a screenshot.`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
return { x: r.x, y: r.y };
|
|
1048
|
+
}
|
|
1049
|
+
// --- safety guards ------------------------------------------------------
|
|
1050
|
+
/** What the guards need to know about an element; null when nothing is there. */
|
|
1051
|
+
async inspect(page, target) {
|
|
1052
|
+
const r = await this.pageCall(page, { op: "inspect", target });
|
|
1053
|
+
return r.ok ? r.facts : null;
|
|
1054
|
+
}
|
|
1055
|
+
async fieldRefusal(page, target) {
|
|
1056
|
+
const facts = await this.inspect(page, target);
|
|
1057
|
+
const reason = facts && sensitiveFieldReason(facts);
|
|
1058
|
+
return reason ? refusalText(reason) : null;
|
|
1059
|
+
}
|
|
1060
|
+
/** Typing goes to whatever has focus, so that is what is checked. */
|
|
1061
|
+
async typeRefusal(page, value) {
|
|
1062
|
+
const safety = this.options.safety;
|
|
1063
|
+
if (!safety) return null;
|
|
1064
|
+
if (safety.refuseSensitiveInput) {
|
|
1065
|
+
if (containsCardNumber(value)) return refusalText("That value is a card number.");
|
|
1066
|
+
const refusal = await this.fieldRefusal(page, { type: "focused" });
|
|
1067
|
+
if (refusal) return refusal;
|
|
1068
|
+
}
|
|
1069
|
+
if (safety.blockSubmit && /[\r\n]/.test(value)) {
|
|
1070
|
+
const facts = await this.inspect(page, { type: "focused" });
|
|
1071
|
+
const reason = facts && enterSubmitReason(facts);
|
|
1072
|
+
if (reason) return refusalText(reason);
|
|
1073
|
+
}
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
/** Enter submits the focused field's form; Enter or Space presses a focused button. */
|
|
1077
|
+
async keyRefusal(page, keys) {
|
|
1078
|
+
if (!this.options.safety?.blockSubmit) return null;
|
|
1079
|
+
const pressesEnter = keys.some((k) => /(^|\+)(Enter|NumpadEnter)$/.test(k));
|
|
1080
|
+
const pressesSpace = keys.some((k) => /(^|\+)(Space| )$/.test(k));
|
|
1081
|
+
if (!pressesEnter && !pressesSpace) return null;
|
|
1082
|
+
const facts = await this.inspect(page, { type: "focused" });
|
|
1083
|
+
if (!facts) return null;
|
|
1084
|
+
const reason = facts.isButton && submitReason(facts) || pressesEnter && enterSubmitReason(facts);
|
|
1085
|
+
return reason ? refusalText(reason) : null;
|
|
1086
|
+
}
|
|
1087
|
+
pageCall(page, op) {
|
|
1088
|
+
return page.evaluate(buildPageCall(op));
|
|
1089
|
+
}
|
|
1090
|
+
async browserState(stateChanges) {
|
|
1091
|
+
const ctx = this.driver.context;
|
|
1092
|
+
const [pages, active] = await Promise.all([ctx.pages(), ctx.activePage()]);
|
|
1093
|
+
const tabs = await Promise.all(
|
|
1094
|
+
pages.slice(0, 100).map(async (page) => ({
|
|
1095
|
+
tab_id: this.tabId(page),
|
|
1096
|
+
title: sanitiseStateField(await page.title().catch(() => "")),
|
|
1097
|
+
url: sanitiseStateField(await Promise.resolve(page.url()).catch(() => "")),
|
|
1098
|
+
...active && page.id === active.id ? { active: true } : {}
|
|
1099
|
+
}))
|
|
1100
|
+
);
|
|
1101
|
+
if (tabs.length > 0 && !tabs.some((t) => t.active)) tabs[tabs.length - 1].active = true;
|
|
1102
|
+
return {
|
|
1103
|
+
type: "browser_state",
|
|
1104
|
+
tabs,
|
|
1105
|
+
...stateChanges && stateChanges.length > 0 ? { state_changes: stateChanges } : {}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
function text(value) {
|
|
1110
|
+
return { type: "text", text: value };
|
|
1111
|
+
}
|
|
1112
|
+
function image(data) {
|
|
1113
|
+
return { type: "image", source: { type: "base64", media_type: "image/png", data } };
|
|
1114
|
+
}
|
|
1115
|
+
function describeState(state) {
|
|
1116
|
+
const active = state.tabs.find((t) => t.active);
|
|
1117
|
+
return active ? `Tab ${active.tab_id}: ${active.url}` : `${state.tabs.length} tabs`;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ../browser-toolset/src/direct-tools.ts
|
|
1121
|
+
var MAX_READ_CHARS = 2e4;
|
|
1122
|
+
var TOOL_TIMEOUT_MS = 45e3;
|
|
1123
|
+
var NAVIGATION_TIMEOUT_MS = 3e4;
|
|
1124
|
+
var SCREENSHOT_TIMEOUT_MS = 1e4;
|
|
1125
|
+
var SETTLE_MS = 400;
|
|
1126
|
+
var SETTLE_LOAD_TIMEOUT_MS = 5e3;
|
|
1127
|
+
var TARGET_PROPERTIES = {
|
|
1128
|
+
ref: {
|
|
1129
|
+
type: "string",
|
|
1130
|
+
description: 'An element ref from read_page or find, e.g. "ref_12". Preferred.'
|
|
1131
|
+
},
|
|
1132
|
+
x: {
|
|
1133
|
+
type: "number",
|
|
1134
|
+
description: "Viewport x coordinate in screenshot pixels, when there is no ref."
|
|
1135
|
+
},
|
|
1136
|
+
y: {
|
|
1137
|
+
type: "number",
|
|
1138
|
+
description: "Viewport y coordinate in screenshot pixels, when there is no ref."
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
var DIRECT_TOOL_SPECS = [
|
|
1142
|
+
{
|
|
1143
|
+
name: "navigate",
|
|
1144
|
+
description: 'Go to a URL, or "back" / "forward" in history. Returns the page it landed on and a screenshot.',
|
|
1145
|
+
parameters: {
|
|
1146
|
+
type: "object",
|
|
1147
|
+
properties: { url: { type: "string", description: 'A full URL, "back" or "forward".' } },
|
|
1148
|
+
required: ["url"]
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
name: "read_page",
|
|
1153
|
+
description: 'Read the page as an accessibility tree. Each element worth acting on carries a ref (ref_N) to pass to click, type, form_input or scroll. The filter "visible" (default) covers what is on screen, "interactive" only controls, "all" the whole page. Pass a ref to read just that part of the page.',
|
|
1154
|
+
parameters: {
|
|
1155
|
+
type: "object",
|
|
1156
|
+
properties: {
|
|
1157
|
+
filter: { type: "string", enum: ["visible", "interactive", "all"] },
|
|
1158
|
+
ref: { type: "string", description: "Read only this element and its descendants." }
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
name: "find",
|
|
1164
|
+
description: 'Find elements matching a description or text (e.g. "search box", "Add to cart"). Returns up to 20 matches with refs.',
|
|
1165
|
+
parameters: {
|
|
1166
|
+
type: "object",
|
|
1167
|
+
properties: { query: { type: "string", description: "What to look for." } },
|
|
1168
|
+
required: ["query"]
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
name: "get_page_text",
|
|
1173
|
+
description: "The page's visible text as plain text, for reading content such as a policy.",
|
|
1174
|
+
parameters: { type: "object", properties: {} }
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
name: "click",
|
|
1178
|
+
description: "Click an element by ref, or at a coordinate from a screenshot. Returns the resulting page and a screenshot.",
|
|
1179
|
+
parameters: { type: "object", properties: TARGET_PROPERTIES }
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
name: "type",
|
|
1183
|
+
description: "Type text. With a ref (or coordinate), clicks that field first; without one, types into whatever has focus. Does not press Enter: use key for that.",
|
|
1184
|
+
parameters: {
|
|
1185
|
+
type: "object",
|
|
1186
|
+
properties: {
|
|
1187
|
+
text: { type: "string", description: "The text to type." },
|
|
1188
|
+
...TARGET_PROPERTIES
|
|
1189
|
+
},
|
|
1190
|
+
required: ["text"]
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
name: "form_input",
|
|
1195
|
+
description: 'Set a form control directly by ref: choose a select option (by its label or value), tick or untick a checkbox or radio ("true" / "false"), or fill an input.',
|
|
1196
|
+
parameters: {
|
|
1197
|
+
type: "object",
|
|
1198
|
+
properties: {
|
|
1199
|
+
ref: { type: "string", description: "The control ref from read_page or find." },
|
|
1200
|
+
// A string on every provider: a union type is not something every
|
|
1201
|
+
// provider's function declarations take reliably, and the page script reads
|
|
1202
|
+
// "true" / "false" as a checked state.
|
|
1203
|
+
value: { type: "string", description: 'The option, "true" / "false", or the text.' }
|
|
1204
|
+
},
|
|
1205
|
+
required: ["ref", "value"]
|
|
1206
|
+
}
|
|
1207
|
+
},
|
|
1208
|
+
{
|
|
1209
|
+
name: "key",
|
|
1210
|
+
description: 'Press a key or chord, e.g. "Enter", "Escape", "Tab", "ctrl+a". Space-separate several to press them in turn.',
|
|
1211
|
+
parameters: {
|
|
1212
|
+
type: "object",
|
|
1213
|
+
properties: { text: { type: "string", description: "The key or keys." } },
|
|
1214
|
+
required: ["text"]
|
|
1215
|
+
}
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
name: "scroll",
|
|
1219
|
+
description: "Scroll the page (or the element at a ref or coordinate) up, down, left or right. amount is in wheel notches of about 100px, default 5.",
|
|
1220
|
+
parameters: {
|
|
1221
|
+
type: "object",
|
|
1222
|
+
properties: {
|
|
1223
|
+
direction: { type: "string", enum: ["up", "down", "left", "right"] },
|
|
1224
|
+
amount: { type: "number", description: "Notches, 1 to 10." },
|
|
1225
|
+
...TARGET_PROPERTIES
|
|
1226
|
+
},
|
|
1227
|
+
required: ["direction"]
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
{
|
|
1231
|
+
name: "screenshot",
|
|
1232
|
+
description: "Look at the current page.",
|
|
1233
|
+
parameters: { type: "object", properties: {} }
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
name: "done",
|
|
1237
|
+
description: "Signal that the task is complete. Provide a concise summary of what was accomplished.",
|
|
1238
|
+
parameters: {
|
|
1239
|
+
type: "object",
|
|
1240
|
+
properties: { message: { type: "string", description: "Summary." } },
|
|
1241
|
+
required: ["message"]
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
];
|
|
1245
|
+
var DIRECT_TOOL_NAMES = DIRECT_TOOL_SPECS.map((s) => s.name);
|
|
1246
|
+
function clampRead(text2) {
|
|
1247
|
+
if (text2.length <= MAX_READ_CHARS) return text2;
|
|
1248
|
+
return `${text2.slice(0, MAX_READ_CHARS)}
|
|
1249
|
+
[truncated at ${MAX_READ_CHARS} characters: read_page with a ref or the "interactive" filter, or find, reads less]`;
|
|
1250
|
+
}
|
|
1251
|
+
function targetOf(input) {
|
|
1252
|
+
if (typeof input.ref === "string" && input.ref.trim()) {
|
|
1253
|
+
return { type: "ref", ref: input.ref.trim() };
|
|
1254
|
+
}
|
|
1255
|
+
const x = Number(input.x);
|
|
1256
|
+
const y = Number(input.y);
|
|
1257
|
+
if (input.x !== void 0 && input.y !== void 0 && Number.isFinite(x) && Number.isFinite(y)) {
|
|
1258
|
+
return { type: "coordinate", x, y };
|
|
1259
|
+
}
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
1262
|
+
function blocksToText(blocks) {
|
|
1263
|
+
return blocks.map((b) => {
|
|
1264
|
+
const block = b;
|
|
1265
|
+
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
|
1266
|
+
}).filter(Boolean).join("\n");
|
|
1267
|
+
}
|
|
1268
|
+
function withTimeout(work, ms, what) {
|
|
1269
|
+
let timer;
|
|
1270
|
+
const timeout = new Promise((_, reject) => {
|
|
1271
|
+
timer = setTimeout(
|
|
1272
|
+
() => reject(new ToolsetError(`Error: ${what} did not finish within ${ms / 1e3}s.`)),
|
|
1273
|
+
ms
|
|
1274
|
+
);
|
|
1275
|
+
});
|
|
1276
|
+
return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
|
|
1277
|
+
}
|
|
1278
|
+
var DirectBrowserTools = class {
|
|
1279
|
+
driver;
|
|
1280
|
+
executor;
|
|
1281
|
+
viewport;
|
|
1282
|
+
settleMs;
|
|
1283
|
+
toolTimeoutMs;
|
|
1284
|
+
constructor(driver, options) {
|
|
1285
|
+
this.driver = driver;
|
|
1286
|
+
this.executor = new BrowserToolsetExecutor(driver, {
|
|
1287
|
+
navigationTimeoutMs: NAVIGATION_TIMEOUT_MS,
|
|
1288
|
+
screenshotTimeoutMs: SCREENSHOT_TIMEOUT_MS,
|
|
1289
|
+
...options.safety ? { safety: options.safety } : {},
|
|
1290
|
+
...options.allowPrivateHosts ? { allowPrivateHosts: true } : {},
|
|
1291
|
+
...options.checkNavigation ? { checkNavigation: options.checkNavigation } : {}
|
|
1292
|
+
});
|
|
1293
|
+
this.viewport = options.viewport;
|
|
1294
|
+
this.settleMs = options.settleMs ?? SETTLE_MS;
|
|
1295
|
+
this.toolTimeoutMs = options.toolTimeoutMs ?? TOOL_TIMEOUT_MS;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Run one call. Never throws: a failure comes back as `error` with text the
|
|
1299
|
+
* model can act on, so a harness loop can always build its tool result.
|
|
1300
|
+
*/
|
|
1301
|
+
async run(name, input) {
|
|
1302
|
+
try {
|
|
1303
|
+
return await withTimeout(this.execute(name, input), this.toolTimeoutMs, name);
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
const text2 = err instanceof ToolsetError ? err.message : `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
1306
|
+
return { text: text2, error: text2 };
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
async execute(name, input) {
|
|
1310
|
+
switch (name) {
|
|
1311
|
+
case "navigate": {
|
|
1312
|
+
const out = await this.executor.execute("navigate", { url: input.url });
|
|
1313
|
+
return this.afterInput(blocksToText(out.filter((b) => b.type === "text")));
|
|
1314
|
+
}
|
|
1315
|
+
case "read_page": {
|
|
1316
|
+
const out = await this.executor.execute("read_page", {
|
|
1317
|
+
filter: input.filter,
|
|
1318
|
+
...typeof input.ref === "string" && input.ref ? { ref: input.ref } : {}
|
|
1319
|
+
});
|
|
1320
|
+
return { text: clampRead(blocksToText(out)) };
|
|
1321
|
+
}
|
|
1322
|
+
case "find": {
|
|
1323
|
+
const out = await this.executor.execute("find", { query: input.query });
|
|
1324
|
+
return { text: clampRead(blocksToText(out)) };
|
|
1325
|
+
}
|
|
1326
|
+
case "get_page_text": {
|
|
1327
|
+
const out = await this.executor.execute("get_page_text", {});
|
|
1328
|
+
return { text: clampRead(blocksToText(out)) };
|
|
1329
|
+
}
|
|
1330
|
+
case "click": {
|
|
1331
|
+
const target = targetOf(input);
|
|
1332
|
+
if (!target) throw new ToolsetError("Error: click needs a ref, or both x and y.");
|
|
1333
|
+
const out = await this.executor.execute("left_click", { target });
|
|
1334
|
+
return this.afterInput(blocksToText(out));
|
|
1335
|
+
}
|
|
1336
|
+
case "type": {
|
|
1337
|
+
const value = String(input.text ?? "");
|
|
1338
|
+
const target = targetOf(input);
|
|
1339
|
+
const said = [];
|
|
1340
|
+
if (target) {
|
|
1341
|
+
said.push(blocksToText(await this.executor.execute("left_click", { target })));
|
|
1342
|
+
}
|
|
1343
|
+
said.push(blocksToText(await this.executor.execute("type", { text: value })));
|
|
1344
|
+
return this.afterInput(said.join("\n"));
|
|
1345
|
+
}
|
|
1346
|
+
case "form_input": {
|
|
1347
|
+
if (typeof input.ref !== "string" || !input.ref) {
|
|
1348
|
+
throw new ToolsetError("Error: form_input needs a ref from read_page or find.");
|
|
1349
|
+
}
|
|
1350
|
+
const out = await this.executor.execute("form_input", {
|
|
1351
|
+
target: { type: "ref", ref: input.ref },
|
|
1352
|
+
value: typeof input.value === "boolean" || typeof input.value === "number" ? input.value : String(input.value ?? "")
|
|
1353
|
+
});
|
|
1354
|
+
return this.afterInput(blocksToText(out));
|
|
1355
|
+
}
|
|
1356
|
+
case "key": {
|
|
1357
|
+
const out = await this.executor.execute("key", { text: input.text });
|
|
1358
|
+
return this.afterInput(blocksToText(out));
|
|
1359
|
+
}
|
|
1360
|
+
case "scroll": {
|
|
1361
|
+
const target = targetOf(input) ?? {
|
|
1362
|
+
type: "coordinate",
|
|
1363
|
+
x: Math.round(this.viewport.width / 2),
|
|
1364
|
+
y: Math.round(this.viewport.height / 2)
|
|
1365
|
+
};
|
|
1366
|
+
const out = await this.executor.execute("scroll", {
|
|
1367
|
+
scroll_direction: input.direction,
|
|
1368
|
+
scroll_amount: input.amount ?? 5,
|
|
1369
|
+
target
|
|
1370
|
+
});
|
|
1371
|
+
return this.afterInput(blocksToText(out));
|
|
1372
|
+
}
|
|
1373
|
+
case "screenshot": {
|
|
1374
|
+
const page = await this.activePage();
|
|
1375
|
+
return {
|
|
1376
|
+
text: `Screenshot of ${await page.url()}`,
|
|
1377
|
+
screenshotBase64: await this.capture(page)
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
case "done":
|
|
1381
|
+
return { text: "Done tool invoked" };
|
|
1382
|
+
default:
|
|
1383
|
+
throw new ToolsetError(
|
|
1384
|
+
`Error: ${name} is not an available tool. Use one of: ${DIRECT_TOOL_NAMES.join(", ")}.`
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* After anything that can change the page: let it settle, then report where
|
|
1390
|
+
* the browser is with a screenshot. The agent sees the effect of its action
|
|
1391
|
+
* without spending a turn asking.
|
|
1392
|
+
*/
|
|
1393
|
+
async afterInput(said) {
|
|
1394
|
+
const page = await this.activePage();
|
|
1395
|
+
if (this.settleMs > 0) {
|
|
1396
|
+
await page.waitForTimeout(this.settleMs);
|
|
1397
|
+
await page.waitForLoadState("domcontentloaded", SETTLE_LOAD_TIMEOUT_MS).catch(() => {
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
const [url, title] = await Promise.all([
|
|
1401
|
+
Promise.resolve(page.url()).catch(() => ""),
|
|
1402
|
+
page.title().catch(() => "")
|
|
1403
|
+
]);
|
|
1404
|
+
const where = title ? `Now on ${url} (${title.slice(0, 120)})` : `Now on ${url}`;
|
|
1405
|
+
return {
|
|
1406
|
+
text: said ? `${said}
|
|
1407
|
+
${where}` : where,
|
|
1408
|
+
screenshotBase64: await this.capture(page)
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
async capture(page) {
|
|
1412
|
+
const bytes = await page.screenshot({ type: "png", timeout: SCREENSHOT_TIMEOUT_MS });
|
|
1413
|
+
return Buffer.from(bytes).toString("base64");
|
|
1414
|
+
}
|
|
1415
|
+
async activePage() {
|
|
1416
|
+
const page = await this.driver.context.activePage();
|
|
1417
|
+
if (!page) throw new ToolsetError("Error: the browser has no open page.");
|
|
1418
|
+
return page;
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
// src/mcp.ts
|
|
1423
|
+
import { z } from "zod";
|
|
1424
|
+
|
|
1425
|
+
// src/run.ts
|
|
1426
|
+
import { mkdir, mkdtemp, readFile, rm, stat } from "fs/promises";
|
|
1427
|
+
import { homedir, tmpdir } from "os";
|
|
1428
|
+
import { join } from "path";
|
|
1429
|
+
|
|
1430
|
+
// package.json
|
|
1431
|
+
var package_default = {
|
|
1432
|
+
name: "stuntdouble",
|
|
1433
|
+
version: "0.1.0",
|
|
1434
|
+
description: "Stunt Double Local: run Stunt Double checklists in a local Chrome from your own coding agent session, with the runs kept as files and a local viewer",
|
|
1435
|
+
keywords: [
|
|
1436
|
+
"stunt-double",
|
|
1437
|
+
"testing",
|
|
1438
|
+
"browser",
|
|
1439
|
+
"checklist",
|
|
1440
|
+
"mcp",
|
|
1441
|
+
"playwright",
|
|
1442
|
+
"agents"
|
|
1443
|
+
],
|
|
1444
|
+
license: "Apache-2.0",
|
|
1445
|
+
author: "Turnout Labs Ltd",
|
|
1446
|
+
homepage: "https://github.com/stunt-double/stuntkit/tree/main/packages/local#readme",
|
|
1447
|
+
bugs: {
|
|
1448
|
+
url: "https://github.com/stunt-double/stuntkit/issues"
|
|
1449
|
+
},
|
|
1450
|
+
repository: {
|
|
1451
|
+
type: "git",
|
|
1452
|
+
url: "git+https://github.com/stunt-double/stuntkit.git",
|
|
1453
|
+
directory: "packages/local"
|
|
1454
|
+
},
|
|
1455
|
+
type: "module",
|
|
1456
|
+
bin: {
|
|
1457
|
+
stuntdouble: "./dist/cli.js"
|
|
1458
|
+
},
|
|
1459
|
+
files: [
|
|
1460
|
+
"dist",
|
|
1461
|
+
"README.md",
|
|
1462
|
+
"LICENSE"
|
|
1463
|
+
],
|
|
1464
|
+
scripts: {
|
|
1465
|
+
build: "tsup",
|
|
1466
|
+
"type-check": "tsc -p tsconfig.json",
|
|
1467
|
+
test: 'node --conditions=@stunt-double/source --test "test/**/*.test.ts"',
|
|
1468
|
+
prepack: "pnpm run build"
|
|
1469
|
+
},
|
|
1470
|
+
dependencies: {
|
|
1471
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
1472
|
+
"playwright-core": "^1.64.1",
|
|
1473
|
+
zod: "^4.6.7"
|
|
1474
|
+
},
|
|
1475
|
+
devDependencies: {
|
|
1476
|
+
"@stunt-double/browser-toolset": "workspace:*"
|
|
1477
|
+
},
|
|
1478
|
+
engines: {
|
|
1479
|
+
node: ">=22.18"
|
|
1480
|
+
},
|
|
1481
|
+
publishConfig: {
|
|
1482
|
+
access: "public",
|
|
1483
|
+
registry: "https://registry.npmjs.org",
|
|
1484
|
+
provenance: true
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
// src/brief.ts
|
|
1489
|
+
var CONDITION_LABELS = {
|
|
1490
|
+
always: "always",
|
|
1491
|
+
if_previous_passed: "only if the check before it passed",
|
|
1492
|
+
if_previous_failed: "only if the check before it failed"
|
|
1493
|
+
};
|
|
1494
|
+
function checkLabel(check) {
|
|
1495
|
+
const kind = check.type === "informational" ? "informational, record what you observe" : check.type === "weighted" ? `weighted x${check.weight ?? 1}${(check.weight ?? 1) < 0 ? ", a penalty" : ""}` : "pass/fail";
|
|
1496
|
+
const parts = [kind, CONDITION_LABELS[check.run_condition]];
|
|
1497
|
+
if (check.on_failure === "stop") parts.push("the run stops if it fails");
|
|
1498
|
+
return `(${parts.join(", ")})`;
|
|
1499
|
+
}
|
|
1500
|
+
function checkLine(check) {
|
|
1501
|
+
return `- [${check.key}] ${checkLabel(check)} ${check.description}`;
|
|
1502
|
+
}
|
|
1503
|
+
function buildBrief(input) {
|
|
1504
|
+
const { actor, checklist, url, checks, resumed } = input;
|
|
1505
|
+
const persona = [
|
|
1506
|
+
`You are ${actor.name}${actor.description ? `: ${actor.description}` : ""}.`,
|
|
1507
|
+
actor.system_prompt ?? ""
|
|
1508
|
+
].filter(Boolean).join("\n");
|
|
1509
|
+
const knowledge = actor.knowledge.length ? [
|
|
1510
|
+
"What you know:",
|
|
1511
|
+
...actor.knowledge.map((k) => `### ${k.title} (${k.kind})
|
|
1512
|
+
${k.content}`)
|
|
1513
|
+
].join("\n") : "";
|
|
1514
|
+
return [
|
|
1515
|
+
persona,
|
|
1516
|
+
knowledge,
|
|
1517
|
+
`Your task: ${checklist.instructions}`,
|
|
1518
|
+
`Start at ${url}${resumed ? " (this run is resuming: earlier verdicts stand, judge only the checks below)" : ""}.`,
|
|
1519
|
+
`Checks to judge:
|
|
1520
|
+
${checks.map(checkLine).join("\n")}`,
|
|
1521
|
+
[
|
|
1522
|
+
"How to work:",
|
|
1523
|
+
"- Stay in persona: act and judge as this person would, not as a tester.",
|
|
1524
|
+
"- Read the page with read_page and find. Ask for a screenshot only when how something looks is the question.",
|
|
1525
|
+
"- Judge each check with evaluate_check as soon as you have observed it, while the evidence is on screen.",
|
|
1526
|
+
"- A passed verdict needs a locator: text visible on the page, or a CSS selector, that shows the evidence. A failure may describe something missing.",
|
|
1527
|
+
"- Cite only what you saw in this run. Never pass a check on what a page probably does.",
|
|
1528
|
+
'- When something stops you (a sign-in wall, a broken page, a CAPTCHA), judge the checks you cannot reach as "blocked" and say what stopped you.',
|
|
1529
|
+
"- Always end with finish_run and a one or two sentence summary, even when blocked or stopped."
|
|
1530
|
+
].join("\n")
|
|
1531
|
+
].filter(Boolean).join("\n\n");
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// src/driver.ts
|
|
1535
|
+
import { chromium } from "playwright-core";
|
|
1536
|
+
var pageIds = /* @__PURE__ */ new WeakMap();
|
|
1537
|
+
var pageSeq = 0;
|
|
1538
|
+
function idOf(page) {
|
|
1539
|
+
let id = pageIds.get(page);
|
|
1540
|
+
if (!id) {
|
|
1541
|
+
pageSeq += 1;
|
|
1542
|
+
id = `pw-${pageSeq}`;
|
|
1543
|
+
pageIds.set(page, id);
|
|
1544
|
+
}
|
|
1545
|
+
return id;
|
|
1546
|
+
}
|
|
1547
|
+
var PlaywrightPage = class {
|
|
1548
|
+
id;
|
|
1549
|
+
raw;
|
|
1550
|
+
constructor(page) {
|
|
1551
|
+
this.raw = page;
|
|
1552
|
+
this.id = idOf(page);
|
|
1553
|
+
}
|
|
1554
|
+
goto(url, options = {}) {
|
|
1555
|
+
return this.raw.goto(url, {
|
|
1556
|
+
...options.waitUntil ? { waitUntil: options.waitUntil } : {},
|
|
1557
|
+
...options.timeoutMs ? { timeout: options.timeoutMs } : {}
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
goBack() {
|
|
1561
|
+
return this.raw.goBack();
|
|
1562
|
+
}
|
|
1563
|
+
goForward() {
|
|
1564
|
+
return this.raw.goForward();
|
|
1565
|
+
}
|
|
1566
|
+
screenshot(options = {}) {
|
|
1567
|
+
return this.raw.screenshot(options);
|
|
1568
|
+
}
|
|
1569
|
+
evaluate(expression, arg) {
|
|
1570
|
+
if (typeof expression === "string") return this.raw.evaluate(expression);
|
|
1571
|
+
return this.raw.evaluate(expression, arg);
|
|
1572
|
+
}
|
|
1573
|
+
url() {
|
|
1574
|
+
return this.raw.url();
|
|
1575
|
+
}
|
|
1576
|
+
title() {
|
|
1577
|
+
return this.raw.title();
|
|
1578
|
+
}
|
|
1579
|
+
setViewportSize(width, height) {
|
|
1580
|
+
return this.raw.setViewportSize({ width, height });
|
|
1581
|
+
}
|
|
1582
|
+
waitForTimeout(ms) {
|
|
1583
|
+
return this.raw.waitForTimeout(ms);
|
|
1584
|
+
}
|
|
1585
|
+
waitForSelector(selector, options = {}) {
|
|
1586
|
+
return this.raw.waitForSelector(selector, options);
|
|
1587
|
+
}
|
|
1588
|
+
waitForLoadState(state, timeoutMs) {
|
|
1589
|
+
return this.raw.waitForLoadState(state, timeoutMs ? { timeout: timeoutMs } : void 0);
|
|
1590
|
+
}
|
|
1591
|
+
locator(selector) {
|
|
1592
|
+
return this.raw.locator(selector).first();
|
|
1593
|
+
}
|
|
1594
|
+
click(x, y, options = {}) {
|
|
1595
|
+
return this.raw.mouse.click(x, y, options);
|
|
1596
|
+
}
|
|
1597
|
+
hover(x, y) {
|
|
1598
|
+
return this.raw.mouse.move(x, y);
|
|
1599
|
+
}
|
|
1600
|
+
async scroll(x, y, deltaX, deltaY) {
|
|
1601
|
+
await this.raw.mouse.move(x, y);
|
|
1602
|
+
await this.raw.mouse.wheel(deltaX, deltaY);
|
|
1603
|
+
}
|
|
1604
|
+
async dragAndDrop(fromX, fromY, toX, toY) {
|
|
1605
|
+
await this.raw.mouse.move(fromX, fromY);
|
|
1606
|
+
await this.raw.mouse.down();
|
|
1607
|
+
await this.raw.mouse.move(toX, toY, { steps: 10 });
|
|
1608
|
+
await this.raw.mouse.up();
|
|
1609
|
+
}
|
|
1610
|
+
type(text2) {
|
|
1611
|
+
return this.raw.keyboard.type(text2);
|
|
1612
|
+
}
|
|
1613
|
+
keyPress(key) {
|
|
1614
|
+
return this.raw.keyboard.press(key);
|
|
1615
|
+
}
|
|
1616
|
+
close() {
|
|
1617
|
+
return this.raw.close();
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
var PlaywrightContext = class {
|
|
1621
|
+
context;
|
|
1622
|
+
active;
|
|
1623
|
+
policy = {};
|
|
1624
|
+
routed = false;
|
|
1625
|
+
constructor(context) {
|
|
1626
|
+
this.context = context;
|
|
1627
|
+
context.on("page", (page) => {
|
|
1628
|
+
this.active = page;
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
activePage() {
|
|
1632
|
+
const open = this.context.pages();
|
|
1633
|
+
if (!this.active || this.active.isClosed()) this.active = open[open.length - 1];
|
|
1634
|
+
return this.active ? new PlaywrightPage(this.active) : void 0;
|
|
1635
|
+
}
|
|
1636
|
+
pages() {
|
|
1637
|
+
return this.context.pages().map((p) => new PlaywrightPage(p));
|
|
1638
|
+
}
|
|
1639
|
+
async newPage(url) {
|
|
1640
|
+
const page = await this.context.newPage();
|
|
1641
|
+
this.active = page;
|
|
1642
|
+
if (url) await page.goto(url);
|
|
1643
|
+
return new PlaywrightPage(page);
|
|
1644
|
+
}
|
|
1645
|
+
async setActivePage(page) {
|
|
1646
|
+
const raw = this.context.pages().find((p) => idOf(p) === page.id);
|
|
1647
|
+
if (!raw) throw new Error(`No open tab with id ${page.id}`);
|
|
1648
|
+
this.active = raw;
|
|
1649
|
+
await raw.bringToFront();
|
|
1650
|
+
}
|
|
1651
|
+
clearCookies() {
|
|
1652
|
+
return this.context.clearCookies();
|
|
1653
|
+
}
|
|
1654
|
+
async addInitScript(source) {
|
|
1655
|
+
await this.context.addInitScript(source);
|
|
1656
|
+
}
|
|
1657
|
+
/** Abort requests to blocked hosts, or to anything outside the allow list. */
|
|
1658
|
+
async setDomainPolicy(policy) {
|
|
1659
|
+
this.policy = policy;
|
|
1660
|
+
if (this.routed) return;
|
|
1661
|
+
this.routed = true;
|
|
1662
|
+
await this.context.route("**/*", (route) => {
|
|
1663
|
+
let host = "";
|
|
1664
|
+
try {
|
|
1665
|
+
host = new URL(route.request().url()).hostname;
|
|
1666
|
+
} catch {
|
|
1667
|
+
return route.continue();
|
|
1668
|
+
}
|
|
1669
|
+
const { allowedDomains, blockedDomains } = this.policy;
|
|
1670
|
+
const blocked = (blockedDomains ?? []).some((d) => hostMatchesDomain(host, d)) || allowedDomains !== void 0 && allowedDomains.length > 0 && !allowedDomains.some((d) => hostMatchesDomain(host, d));
|
|
1671
|
+
return blocked ? route.abort("blockedbyclient") : route.continue();
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
var PlaywrightDriver = class _PlaywrightDriver {
|
|
1676
|
+
context;
|
|
1677
|
+
browserContext;
|
|
1678
|
+
refusal = null;
|
|
1679
|
+
constructor(context) {
|
|
1680
|
+
this.browserContext = context;
|
|
1681
|
+
this.context = new PlaywrightContext(context);
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* The installed Google Chrome, or Playwright's own Chromium when Chrome is
|
|
1685
|
+
* missing and `npx playwright install chromium` has been run.
|
|
1686
|
+
*/
|
|
1687
|
+
static async launch(options) {
|
|
1688
|
+
const settings = {
|
|
1689
|
+
headless: options.headless,
|
|
1690
|
+
viewport: options.viewport,
|
|
1691
|
+
hasTouch: options.touch,
|
|
1692
|
+
...options.locale ? { locale: options.locale } : {}
|
|
1693
|
+
};
|
|
1694
|
+
let context;
|
|
1695
|
+
try {
|
|
1696
|
+
context = await chromium.launchPersistentContext(options.profileDir, {
|
|
1697
|
+
...settings,
|
|
1698
|
+
channel: "chrome"
|
|
1699
|
+
});
|
|
1700
|
+
} catch (chromeError) {
|
|
1701
|
+
try {
|
|
1702
|
+
context = await chromium.launchPersistentContext(options.profileDir, settings);
|
|
1703
|
+
} catch {
|
|
1704
|
+
throw new Error(
|
|
1705
|
+
`Could not start Chrome (${chromeError.message.split("\n")[0]}). Install Google Chrome, or run \`npx playwright install chromium\`.`
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
const driver = new _PlaywrightDriver(context);
|
|
1710
|
+
const refuse = (request) => {
|
|
1711
|
+
const reason = request.isNavigationRequest() ? options.refuseNavigation(request.url()) : null;
|
|
1712
|
+
if (reason && !request.frame().parentFrame()) driver.refusal = reason;
|
|
1713
|
+
return reason;
|
|
1714
|
+
};
|
|
1715
|
+
await context.route(
|
|
1716
|
+
"**/*",
|
|
1717
|
+
(route) => refuse(route.request()) ? route.abort("blockedbyclient") : route.fallback()
|
|
1718
|
+
);
|
|
1719
|
+
context.on("request", (request) => {
|
|
1720
|
+
if (request.redirectedFrom() && refuse(request)) {
|
|
1721
|
+
request.frame().goto("about:blank").catch(() => {
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
if (context.pages().length === 0) await driver.context.newPage();
|
|
1726
|
+
return driver;
|
|
1727
|
+
}
|
|
1728
|
+
takeRefusal() {
|
|
1729
|
+
const refusal = this.refusal;
|
|
1730
|
+
this.refusal = null;
|
|
1731
|
+
return refusal;
|
|
1732
|
+
}
|
|
1733
|
+
/** Instruction-style actions need a model of their own. Nothing here calls them. */
|
|
1734
|
+
async act() {
|
|
1735
|
+
throw new Error("act is not supported by this driver: use the browser toolset instead");
|
|
1736
|
+
}
|
|
1737
|
+
async extract() {
|
|
1738
|
+
throw new Error("extract is not supported by this driver: use the browser toolset instead");
|
|
1739
|
+
}
|
|
1740
|
+
close() {
|
|
1741
|
+
return this.browserContext.close();
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
// src/evidence.ts
|
|
1746
|
+
var MAX_LOCATOR_TEXT = 120;
|
|
1747
|
+
var MAX_LOCATOR_SELECTOR = 400;
|
|
1748
|
+
var SETTLE_MS2 = 350;
|
|
1749
|
+
function cleanLocator(locator) {
|
|
1750
|
+
const clean = (value) => value?.replace(/\s+/g, " ").trim() || null;
|
|
1751
|
+
const selector = clean(locator?.selector);
|
|
1752
|
+
return {
|
|
1753
|
+
selector: selector && selector.length <= MAX_LOCATOR_SELECTOR ? selector : null,
|
|
1754
|
+
text: clean(locator?.text)?.slice(0, MAX_LOCATOR_TEXT) ?? null
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
async function focusEvidence(page, query) {
|
|
1758
|
+
if (!query.selector && !query.text) return null;
|
|
1759
|
+
const via = await page.evaluate(findAndScroll, query).catch(() => null);
|
|
1760
|
+
if (via) await page.waitForTimeout(SETTLE_MS2).catch(() => {
|
|
1761
|
+
});
|
|
1762
|
+
return via;
|
|
1763
|
+
}
|
|
1764
|
+
function findAndScroll(query) {
|
|
1765
|
+
const centre = { block: "center", inline: "center" };
|
|
1766
|
+
const shown = (el) => {
|
|
1767
|
+
const rect = el.getBoundingClientRect();
|
|
1768
|
+
return rect.width > 0 && rect.height > 0 && el.checkVisibility({ visibilityProperty: true, opacityProperty: true });
|
|
1769
|
+
};
|
|
1770
|
+
const norm = (s) => (s ?? "").replace(/\s+/g, " ").toLowerCase();
|
|
1771
|
+
const wanted = query.text ? norm(query.text) : null;
|
|
1772
|
+
const labelled = (el, fragment) => {
|
|
1773
|
+
const labels = ["placeholder", "aria-label", "alt", "title"].map((a) => el.getAttribute(a));
|
|
1774
|
+
if ("value" in el && typeof el.value === "string") labels.push(el.value);
|
|
1775
|
+
return labels.some((l) => norm(l).includes(fragment));
|
|
1776
|
+
};
|
|
1777
|
+
const rendered = (el) => norm(el instanceof HTMLElement ? el.innerText : "");
|
|
1778
|
+
if (query.selector) {
|
|
1779
|
+
let matches = [];
|
|
1780
|
+
try {
|
|
1781
|
+
matches = Array.from(document.querySelectorAll(query.selector));
|
|
1782
|
+
} catch {
|
|
1783
|
+
}
|
|
1784
|
+
const el = matches.find(
|
|
1785
|
+
(m) => m !== document.documentElement && m !== document.body && m.tagName !== "MAIN" && shown(m) && (!wanted || labelled(m, wanted) || rendered(m).includes(wanted))
|
|
1786
|
+
);
|
|
1787
|
+
if (el) {
|
|
1788
|
+
el.scrollIntoView(centre);
|
|
1789
|
+
return "selector";
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
if (!wanted) return null;
|
|
1793
|
+
const elements = [];
|
|
1794
|
+
const collect = (root) => {
|
|
1795
|
+
for (const el of Array.from(root.querySelectorAll("*"))) {
|
|
1796
|
+
elements.push(el);
|
|
1797
|
+
if (el.shadowRoot) collect(el.shadowRoot);
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
collect(document.body);
|
|
1801
|
+
let best = null;
|
|
1802
|
+
let bestLength = Number.POSITIVE_INFINITY;
|
|
1803
|
+
for (const el of elements) {
|
|
1804
|
+
if (["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(el.tagName)) continue;
|
|
1805
|
+
let length = 0;
|
|
1806
|
+
if (!labelled(el, wanted)) {
|
|
1807
|
+
const text2 = rendered(el);
|
|
1808
|
+
length = text2.includes(wanted) ? text2.length : Number.POSITIVE_INFINITY;
|
|
1809
|
+
}
|
|
1810
|
+
if (length >= bestLength || !shown(el)) continue;
|
|
1811
|
+
best = el;
|
|
1812
|
+
bestLength = length;
|
|
1813
|
+
}
|
|
1814
|
+
if (!best) return null;
|
|
1815
|
+
best.scrollIntoView(centre);
|
|
1816
|
+
return "text";
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// src/run.ts
|
|
1820
|
+
var PACKAGE = `${package_default.name}@${package_default.version}`;
|
|
1821
|
+
var STOPPED = "This run was stopped from the viewer. Do not use the browser again: call finish_run now with a summary of what you found.";
|
|
1822
|
+
var NOT_JUDGED = "Not judged before the run finished.";
|
|
1823
|
+
var PRIVATE_REFUSED = "Local and private addresses other than the app under test are not reachable.";
|
|
1824
|
+
var LOG_TEXT_CHARS = 500;
|
|
1825
|
+
var PRESETS = {
|
|
1826
|
+
desktop: { width: 1280, height: 720, touch: false },
|
|
1827
|
+
tablet: { width: 1024, height: 1366, touch: true },
|
|
1828
|
+
mobile: { width: 390, height: 844, touch: true }
|
|
1829
|
+
};
|
|
1830
|
+
function rebase(url, localBase) {
|
|
1831
|
+
if (!localBase) return url;
|
|
1832
|
+
const target = new URL(url);
|
|
1833
|
+
return new URL(target.pathname + target.search + target.hash, new URL(localBase).origin).href;
|
|
1834
|
+
}
|
|
1835
|
+
function stamp(date = /* @__PURE__ */ new Date()) {
|
|
1836
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
1837
|
+
return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
|
1838
|
+
}
|
|
1839
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1840
|
+
var LocalSession = class {
|
|
1841
|
+
root;
|
|
1842
|
+
launch;
|
|
1843
|
+
active = null;
|
|
1844
|
+
ui = null;
|
|
1845
|
+
constructor(options) {
|
|
1846
|
+
this.root = sdDir(options.project);
|
|
1847
|
+
this.launch = options.launch ?? PlaywrightDriver.launch;
|
|
1848
|
+
}
|
|
1849
|
+
/** The viewer's address, starting it on first use. */
|
|
1850
|
+
async openUi(path = "/") {
|
|
1851
|
+
this.ui ??= await startUi({ root: this.root });
|
|
1852
|
+
await writeJsonAtomic(join(this.root, ".live", "server.json"), {
|
|
1853
|
+
port: this.ui.port,
|
|
1854
|
+
token: this.ui.token,
|
|
1855
|
+
pid: process.pid
|
|
1856
|
+
});
|
|
1857
|
+
return this.ui.link(path);
|
|
1858
|
+
}
|
|
1859
|
+
async startRun(input) {
|
|
1860
|
+
if (this.active) return this.resumeActive(input);
|
|
1861
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(input.checklist)) {
|
|
1862
|
+
throw new Error(`"${input.checklist}" is not a checklist slug (checklists/<slug>.json).`);
|
|
1863
|
+
}
|
|
1864
|
+
const config = await readJson(
|
|
1865
|
+
join(this.root, "stuntdouble.json"),
|
|
1866
|
+
ConfigSchema,
|
|
1867
|
+
".stuntdouble/stuntdouble.json"
|
|
1868
|
+
);
|
|
1869
|
+
let checklist = await readJson(
|
|
1870
|
+
join(this.root, "checklists", `${input.checklist}.json`),
|
|
1871
|
+
ChecklistSchema,
|
|
1872
|
+
`.stuntdouble/checklists/${input.checklist}.json`
|
|
1873
|
+
);
|
|
1874
|
+
const actor = await readJson(
|
|
1875
|
+
join(this.root, "actors", `${checklist.actor}.json`),
|
|
1876
|
+
ActorSchema,
|
|
1877
|
+
`.stuntdouble/actors/${checklist.actor}.json`
|
|
1878
|
+
);
|
|
1879
|
+
await this.markInterrupted();
|
|
1880
|
+
let run;
|
|
1881
|
+
let dir;
|
|
1882
|
+
if (input.resume) {
|
|
1883
|
+
const entry = (await readRuns(this.root)).find(
|
|
1884
|
+
(e) => "run" in e && e.run.checklist === input.checklist && e.run.runner.kind === "local" && (e.run.status === "interrupted" || e.run.status === "stopped")
|
|
1885
|
+
);
|
|
1886
|
+
if (!entry || !("run" in entry)) {
|
|
1887
|
+
throw new Error(`No interrupted or stopped run of ${input.checklist} to resume.`);
|
|
1888
|
+
}
|
|
1889
|
+
dir = join(this.root, "runs", entry.id);
|
|
1890
|
+
checklist = ChecklistSchema.parse(entry.run.checklist_snapshot);
|
|
1891
|
+
await this.clearStop(entry.id);
|
|
1892
|
+
run = {
|
|
1893
|
+
...entry.run,
|
|
1894
|
+
results: entry.run.results.filter((r) => r.evidence !== NOT_JUDGED),
|
|
1895
|
+
status: "running",
|
|
1896
|
+
pid: process.pid,
|
|
1897
|
+
finished_at: null,
|
|
1898
|
+
score: null,
|
|
1899
|
+
summary: null
|
|
1900
|
+
};
|
|
1901
|
+
} else {
|
|
1902
|
+
dir = "";
|
|
1903
|
+
run = {
|
|
1904
|
+
format: "stuntdouble.run/1",
|
|
1905
|
+
id: "",
|
|
1906
|
+
runner: { kind: "local", package: PACKAGE, judged: false },
|
|
1907
|
+
status: "running",
|
|
1908
|
+
pid: process.pid,
|
|
1909
|
+
url: rebase(checklist.url, config.local_base_url),
|
|
1910
|
+
started_at: now(),
|
|
1911
|
+
finished_at: null,
|
|
1912
|
+
checklist: input.checklist,
|
|
1913
|
+
actor: checklist.actor,
|
|
1914
|
+
checklist_snapshot: { ...checklist, cloud: void 0 },
|
|
1915
|
+
results: [],
|
|
1916
|
+
score: null,
|
|
1917
|
+
summary: null,
|
|
1918
|
+
cloud: null
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
const trusted = [run.url, checklist.url, config.local_base_url].filter((u) => Boolean(u)).map((u) => new URL(u));
|
|
1922
|
+
const trustedHosts = [...new Set(trusted.map((u) => u.hostname))];
|
|
1923
|
+
const trustedOrigins = new Set(trusted.map((u) => u.origin));
|
|
1924
|
+
const refuseNavigation = (href) => {
|
|
1925
|
+
const url = URL.parse(href);
|
|
1926
|
+
return url && isPrivateHost(url.hostname) && !trustedOrigins.has(url.origin) ? PRIVATE_REFUSED : null;
|
|
1927
|
+
};
|
|
1928
|
+
const preset = PRESETS[checklist.browser_config?.preset ?? "desktop"];
|
|
1929
|
+
const toolOptions = {
|
|
1930
|
+
viewport: checklist.browser_config?.viewport ?? preset,
|
|
1931
|
+
allowPrivateHosts: true,
|
|
1932
|
+
checkNavigation: refuseNavigation
|
|
1933
|
+
};
|
|
1934
|
+
const tempProfile = actor.persist_login ? null : await mkdtemp(join(tmpdir(), "stuntdouble-profile-"));
|
|
1935
|
+
const profileDir = tempProfile ?? join(homedir(), ".stuntdouble", "profiles", checklist.actor);
|
|
1936
|
+
await mkdir(profileDir, { recursive: true });
|
|
1937
|
+
let driver;
|
|
1938
|
+
try {
|
|
1939
|
+
driver = await this.launch({
|
|
1940
|
+
profileDir,
|
|
1941
|
+
headless: config.headless,
|
|
1942
|
+
viewport: toolOptions.viewport,
|
|
1943
|
+
touch: checklist.browser_config?.touch ?? preset.touch,
|
|
1944
|
+
locale: checklist.browser_config?.locale,
|
|
1945
|
+
refuseNavigation
|
|
1946
|
+
});
|
|
1947
|
+
} catch (err) {
|
|
1948
|
+
if (tempProfile) await rm(tempProfile, { recursive: true, force: true });
|
|
1949
|
+
throw err;
|
|
1950
|
+
}
|
|
1951
|
+
if (!input.resume) {
|
|
1952
|
+
await mkdir(join(this.root, "runs"), { recursive: true });
|
|
1953
|
+
for (let n = 1; !dir; n++) {
|
|
1954
|
+
const id = `${stamp()}-${input.checklist}${n > 1 ? `-${n}` : ""}`;
|
|
1955
|
+
const made = await mkdir(join(this.root, "runs", id)).then(
|
|
1956
|
+
() => true,
|
|
1957
|
+
(err) => {
|
|
1958
|
+
if (err.code === "EEXIST") return false;
|
|
1959
|
+
throw err;
|
|
1960
|
+
}
|
|
1961
|
+
);
|
|
1962
|
+
if (made) [run.id, dir] = [id, join(this.root, "runs", id)];
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
const gitignore = join(this.root, ".gitignore");
|
|
1966
|
+
if (!await stat(gitignore).catch(() => null)) await writeFileAtomic(gitignore, ".live/\n");
|
|
1967
|
+
this.active = {
|
|
1968
|
+
dir,
|
|
1969
|
+
run,
|
|
1970
|
+
checklist,
|
|
1971
|
+
actor,
|
|
1972
|
+
driver,
|
|
1973
|
+
trusted: new DirectBrowserTools(driver, toolOptions),
|
|
1974
|
+
guarded: config.safety === "third_party" ? new DirectBrowserTools(driver, { ...toolOptions, safety: INDEX_SESSION_SAFETY }) : null,
|
|
1975
|
+
trustedHosts,
|
|
1976
|
+
tempProfile,
|
|
1977
|
+
everyScreenshot: config.model_screenshots === "every_action",
|
|
1978
|
+
stopped: false
|
|
1979
|
+
};
|
|
1980
|
+
await this.save();
|
|
1981
|
+
await this.log("start_run", input, input.resume ? "Resumed" : "Started");
|
|
1982
|
+
const opened = await this.browserTool("navigate", { url: run.url });
|
|
1983
|
+
const pending = this.pending();
|
|
1984
|
+
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
1985
|
+
return [
|
|
1986
|
+
`Run ${run.id} ${input.resume ? "resumed" : "started"}. Watch it or stop it at ${viewer}`,
|
|
1987
|
+
buildBrief({
|
|
1988
|
+
actor,
|
|
1989
|
+
checklist,
|
|
1990
|
+
url: run.url,
|
|
1991
|
+
checks: pending,
|
|
1992
|
+
resumed: Boolean(input.resume)
|
|
1993
|
+
}),
|
|
1994
|
+
opened.isError ? `The start page did not load: ${opened.text}
|
|
1995
|
+
Try navigate again, or judge the checks you cannot reach as "blocked".` : opened.text,
|
|
1996
|
+
this.nextStep()
|
|
1997
|
+
].join("\n\n");
|
|
1998
|
+
}
|
|
1999
|
+
/** One of the browser toolset's direct tools, on the active run's browser. */
|
|
2000
|
+
async browserTool(name, input) {
|
|
2001
|
+
const active = this.requireActive();
|
|
2002
|
+
if (await this.stopRequested(active)) return { text: STOPPED, isError: true };
|
|
2003
|
+
const page = await active.driver.context.activePage();
|
|
2004
|
+
const host = page ? URL.parse(await page.url())?.hostname ?? "" : "";
|
|
2005
|
+
const tools = active.guarded && host && !active.trustedHosts.some((t) => hostMatchesDomain(host, t)) ? active.guarded : active.trusted;
|
|
2006
|
+
const result = await tools.run(name, input);
|
|
2007
|
+
const refusal = active.driver.takeRefusal();
|
|
2008
|
+
if (refusal) {
|
|
2009
|
+
const text2 = `Error: Navigation refused. ${refusal}`;
|
|
2010
|
+
await this.log(name, redactInput(name, input), text2);
|
|
2011
|
+
return { text: text2, isError: true };
|
|
2012
|
+
}
|
|
2013
|
+
if (result.screenshotBase64)
|
|
2014
|
+
await this.saveFrame(Buffer.from(result.screenshotBase64, "base64"));
|
|
2015
|
+
await this.log(name, redactInput(name, input), result.text);
|
|
2016
|
+
const image2 = result.screenshotBase64 && (name === "screenshot" || active.everyScreenshot) ? result.screenshotBase64 : void 0;
|
|
2017
|
+
return { text: result.text, ...image2 ? { image: image2 } : {}, isError: Boolean(result.error) };
|
|
2018
|
+
}
|
|
2019
|
+
async evaluateCheck(input) {
|
|
2020
|
+
const active = this.requireActive();
|
|
2021
|
+
if (await this.stopRequested(active)) throw new Error(STOPPED);
|
|
2022
|
+
const { run, checklist } = active;
|
|
2023
|
+
const check = checklist.checks.find((c) => c.key === input.check);
|
|
2024
|
+
if (!check) {
|
|
2025
|
+
throw new Error(
|
|
2026
|
+
`No check "${input.check}" in this run. The keys are: ${checklist.checks.map((c) => c.key).join(", ")}.`
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
const earlier = run.results.find((r) => r.check === check.key);
|
|
2030
|
+
if (earlier) {
|
|
2031
|
+
throw new Error(
|
|
2032
|
+
`[${check.key}] already has a verdict (${earlier.status}). Each check gets one.`
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
if (check.type === "informational" && (input.status === "passed" || input.status === "failed")) {
|
|
2036
|
+
throw new Error(
|
|
2037
|
+
`[${check.key}] is informational: record what you observed with status "informational".`
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
if (check.type !== "informational" && input.status === "informational") {
|
|
2041
|
+
throw new Error(
|
|
2042
|
+
`[${check.key}] is scored: judge it "passed" or "failed", or "blocked" if you could not reach it.`
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
const page = await active.driver.context.activePage();
|
|
2046
|
+
const query = cleanLocator(input.locator);
|
|
2047
|
+
const found = page ? await focusEvidence(page, query) : null;
|
|
2048
|
+
if (input.status === "passed" && !found) {
|
|
2049
|
+
throw new Error(
|
|
2050
|
+
query.text || query.selector ? `Not recorded: the locator ${JSON.stringify(query.text ?? query.selector)} is not visible on the current page. Go back to where you saw the evidence, or cite text that is on screen now.` : 'Not recorded: a passed verdict needs a locator, {"text": "..."} visible on the page or {"selector": "..."}.'
|
|
2051
|
+
);
|
|
2052
|
+
}
|
|
2053
|
+
let screenshot;
|
|
2054
|
+
if (page) {
|
|
2055
|
+
try {
|
|
2056
|
+
const png = await page.screenshot({ type: "png", timeout: 1e4 });
|
|
2057
|
+
screenshot = `shots/${check.key}.png`;
|
|
2058
|
+
await writeFileAtomic(join(active.dir, screenshot), png);
|
|
2059
|
+
await this.saveFrame(png);
|
|
2060
|
+
} catch {
|
|
2061
|
+
screenshot = void 0;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
run.results.push({
|
|
2065
|
+
check: check.key,
|
|
2066
|
+
status: input.status === "blocked" ? "error" : input.status,
|
|
2067
|
+
evidence: input.evidence,
|
|
2068
|
+
...query.text || query.selector ? {
|
|
2069
|
+
locator: {
|
|
2070
|
+
...query.text ? { text: query.text } : {},
|
|
2071
|
+
...query.selector ? { selector: query.selector } : {}
|
|
2072
|
+
}
|
|
2073
|
+
} : {},
|
|
2074
|
+
...screenshot ? { screenshot } : {},
|
|
2075
|
+
at: now()
|
|
2076
|
+
});
|
|
2077
|
+
const statuses = new Map(run.results.map((r) => [r.check, r.status]));
|
|
2078
|
+
const halted = resolveHaltedChecks({
|
|
2079
|
+
checks: checklist.checks.map((c) => ({
|
|
2080
|
+
id: c.key,
|
|
2081
|
+
description: c.description,
|
|
2082
|
+
onFailure: c.on_failure
|
|
2083
|
+
})),
|
|
2084
|
+
statuses
|
|
2085
|
+
});
|
|
2086
|
+
const skipped = halted.length ? halted : this.conditionSkips(statuses);
|
|
2087
|
+
for (const s of skipped) {
|
|
2088
|
+
run.results.push({ check: s.checkId, status: "skipped", evidence: s.reason, at: now() });
|
|
2089
|
+
}
|
|
2090
|
+
await this.save();
|
|
2091
|
+
const recorded = `Recorded [${check.key}] as ${input.status}.`;
|
|
2092
|
+
await this.log("evaluate_check", input, recorded);
|
|
2093
|
+
const notes = skipped.map((s) => `[${s.checkId}] ${s.reason}`);
|
|
2094
|
+
if (halted.length) notes.push("The run has stopped.");
|
|
2095
|
+
return [recorded, ...notes, this.nextStep()].join("\n");
|
|
2096
|
+
}
|
|
2097
|
+
async finishRun(input) {
|
|
2098
|
+
const active = this.requireActive();
|
|
2099
|
+
const stopped = await this.stopRequested(active);
|
|
2100
|
+
const { run, checklist } = active;
|
|
2101
|
+
for (const s of this.conditionSkips(new Map(run.results.map((r) => [r.check, r.status])))) {
|
|
2102
|
+
run.results.push({ check: s.checkId, status: "skipped", evidence: s.reason, at: now() });
|
|
2103
|
+
}
|
|
2104
|
+
for (const check of this.pending()) {
|
|
2105
|
+
run.results.push({
|
|
2106
|
+
check: check.key,
|
|
2107
|
+
status: "error",
|
|
2108
|
+
evidence: NOT_JUDGED,
|
|
2109
|
+
at: now()
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
const score = scoreLocalRun(checklist, run.results);
|
|
2113
|
+
run.score = {
|
|
2114
|
+
score: score.score,
|
|
2115
|
+
unit: score.scoreUnit,
|
|
2116
|
+
outcome: score.outcome,
|
|
2117
|
+
pass_threshold: score.passThreshold,
|
|
2118
|
+
grade: score.grade
|
|
2119
|
+
};
|
|
2120
|
+
run.summary = input.summary;
|
|
2121
|
+
run.status = stopped ? "stopped" : "completed";
|
|
2122
|
+
run.finished_at = now();
|
|
2123
|
+
await this.save();
|
|
2124
|
+
await this.log("finish_run", input, `Finished: ${score.outcome}`);
|
|
2125
|
+
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
2126
|
+
await this.endBrowser();
|
|
2127
|
+
const line = (r) => `- [${r.check}] ${r.evidence}`;
|
|
2128
|
+
const failed = run.results.filter((r) => r.status === "failed");
|
|
2129
|
+
const blocked = run.results.filter((r) => r.status === "error");
|
|
2130
|
+
const counts = describeRunScore(score, {
|
|
2131
|
+
passedCount: run.results.filter((r) => r.status === "passed").length,
|
|
2132
|
+
totalCount: checklist.checks.length
|
|
2133
|
+
});
|
|
2134
|
+
return [
|
|
2135
|
+
`Run ${run.id} ${run.status}: ${score.outcome}. ${counts}. Self-judged: the actor recorded its own verdicts.`,
|
|
2136
|
+
failed.length ? `Failed:
|
|
2137
|
+
${failed.map(line).join("\n")}` : "",
|
|
2138
|
+
blocked.length ? `Not judged:
|
|
2139
|
+
${blocked.map(line).join("\n")}` : "",
|
|
2140
|
+
`Report: ${viewer}`
|
|
2141
|
+
].filter(Boolean).join("\n\n");
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* start_run while this process still has a run open, as when the person
|
|
2145
|
+
* interrupts the actor: resume carries on with it in the same browser.
|
|
2146
|
+
*/
|
|
2147
|
+
async resumeActive(input) {
|
|
2148
|
+
const active = this.requireActive();
|
|
2149
|
+
const { run } = active;
|
|
2150
|
+
if (!input.resume || run.checklist !== input.checklist) {
|
|
2151
|
+
throw new Error(
|
|
2152
|
+
`Run ${run.id} of ${run.checklist} is still going: call start_run with checklist "${run.checklist}" and resume to carry on with it, or finish_run to end it.`
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
active.stopped = false;
|
|
2156
|
+
run.status = "running";
|
|
2157
|
+
await this.clearStop(run.id);
|
|
2158
|
+
await this.save();
|
|
2159
|
+
await this.log("start_run", input, "Resumed");
|
|
2160
|
+
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
2161
|
+
return [
|
|
2162
|
+
`Run ${run.id} resumed. Watch it or stop it at ${viewer}`,
|
|
2163
|
+
buildBrief({
|
|
2164
|
+
actor: active.actor,
|
|
2165
|
+
checklist: active.checklist,
|
|
2166
|
+
url: run.url,
|
|
2167
|
+
checks: this.pending(),
|
|
2168
|
+
resumed: true
|
|
2169
|
+
}),
|
|
2170
|
+
"The browser is still open where the run left off: read the page before acting.",
|
|
2171
|
+
this.nextStep()
|
|
2172
|
+
].join("\n\n");
|
|
2173
|
+
}
|
|
2174
|
+
/** On shutdown: an unfinished run is marked interrupted, so it can be resumed. */
|
|
2175
|
+
async close() {
|
|
2176
|
+
if (this.active) {
|
|
2177
|
+
this.active.run.status = this.active.stopped ? "stopped" : "interrupted";
|
|
2178
|
+
await this.save().catch(() => {
|
|
2179
|
+
});
|
|
2180
|
+
await this.endBrowser();
|
|
2181
|
+
}
|
|
2182
|
+
await this.ui?.close();
|
|
2183
|
+
this.ui = null;
|
|
2184
|
+
}
|
|
2185
|
+
requireActive() {
|
|
2186
|
+
if (!this.active) throw new Error("No run is going: call start_run first.");
|
|
2187
|
+
return this.active;
|
|
2188
|
+
}
|
|
2189
|
+
/** Checks with no result yet, in run order. */
|
|
2190
|
+
pending() {
|
|
2191
|
+
const active = this.requireActive();
|
|
2192
|
+
const judged = new Set(active.run.results.map((r) => r.check));
|
|
2193
|
+
return active.checklist.checks.filter((c) => !judged.has(c.key));
|
|
2194
|
+
}
|
|
2195
|
+
conditionSkips(statuses) {
|
|
2196
|
+
return resolveSkippedChecks({
|
|
2197
|
+
checks: this.requireActive().checklist.checks.map((c) => ({
|
|
2198
|
+
id: c.key,
|
|
2199
|
+
description: c.description,
|
|
2200
|
+
runCondition: c.run_condition
|
|
2201
|
+
})),
|
|
2202
|
+
statuses
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
nextStep() {
|
|
2206
|
+
const pending = this.pending();
|
|
2207
|
+
if (pending.length === 0)
|
|
2208
|
+
return "Every check has a verdict: call finish_run with a short summary.";
|
|
2209
|
+
const next = pending[0];
|
|
2210
|
+
return `Next: [${next.key}] ${checkLabel(next)} ${next.description} (${pending.length} left).`;
|
|
2211
|
+
}
|
|
2212
|
+
/** The viewer's Stop writes the run id to `.live/stop`; the run notices on its next call. */
|
|
2213
|
+
async stopRequested(active) {
|
|
2214
|
+
if (active.stopped) return true;
|
|
2215
|
+
const flag = await readFile(join(this.root, ".live", "stop"), "utf8").catch(() => "");
|
|
2216
|
+
if (flag.trim() !== active.run.id) return false;
|
|
2217
|
+
active.stopped = true;
|
|
2218
|
+
active.run.status = "stopped";
|
|
2219
|
+
await this.save();
|
|
2220
|
+
await this.log("stop", {}, "Stopped from the viewer");
|
|
2221
|
+
return true;
|
|
2222
|
+
}
|
|
2223
|
+
/** Runs left `running` by a process that is gone, or by this one before a restart. */
|
|
2224
|
+
async markInterrupted() {
|
|
2225
|
+
for (const entry of await readRuns(this.root)) {
|
|
2226
|
+
if (!("run" in entry) || entry.run.status !== "running") continue;
|
|
2227
|
+
if (entry.run.pid !== process.pid && isAlive(entry.run.pid)) continue;
|
|
2228
|
+
await writeJsonAtomic(join(this.root, "runs", entry.id, "run.json"), {
|
|
2229
|
+
...entry.run,
|
|
2230
|
+
status: "interrupted"
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
async endBrowser() {
|
|
2235
|
+
const active = this.active;
|
|
2236
|
+
if (!active) return;
|
|
2237
|
+
this.active = null;
|
|
2238
|
+
await active.driver.close().catch(() => {
|
|
2239
|
+
});
|
|
2240
|
+
if (active.tempProfile) await rm(active.tempProfile, { recursive: true, force: true });
|
|
2241
|
+
await this.clearStop(active.run.id);
|
|
2242
|
+
}
|
|
2243
|
+
async clearStop(id) {
|
|
2244
|
+
const stopFile = join(this.root, ".live", "stop");
|
|
2245
|
+
const flag = await readFile(stopFile, "utf8").catch(() => "");
|
|
2246
|
+
if (flag.trim() === id) await rm(stopFile, { force: true });
|
|
2247
|
+
}
|
|
2248
|
+
async save() {
|
|
2249
|
+
const active = this.requireActive();
|
|
2250
|
+
await writeJsonAtomic(join(active.dir, "run.json"), active.run);
|
|
2251
|
+
}
|
|
2252
|
+
async log(tool, input, text2) {
|
|
2253
|
+
const active = this.requireActive();
|
|
2254
|
+
await appendJsonl(join(active.dir, "activity.jsonl"), {
|
|
2255
|
+
at: now(),
|
|
2256
|
+
tool,
|
|
2257
|
+
input,
|
|
2258
|
+
text: text2.length > LOG_TEXT_CHARS ? `${text2.slice(0, LOG_TEXT_CHARS)}...` : text2
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
/** The viewer's live frame, best effort. ponytail: one per project, so two sessions running at once share it. */
|
|
2262
|
+
async saveFrame(png) {
|
|
2263
|
+
await writeFileAtomic(join(this.root, ".live", "latest.png"), png).catch(() => {
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
};
|
|
2267
|
+
function scoreLocalRun(checklist, results) {
|
|
2268
|
+
const scoring = newChecklistScoring({
|
|
2269
|
+
scoreType: checklist.score_type,
|
|
2270
|
+
scoreUnit: checklist.score_unit,
|
|
2271
|
+
passThreshold: checklist.pass_threshold,
|
|
2272
|
+
scoreBands: checklist.score_bands,
|
|
2273
|
+
checks: checklist.checks.map((c) => ({ checkType: c.type, weight: c.weight }))
|
|
2274
|
+
});
|
|
2275
|
+
return scoreChecklistRun({
|
|
2276
|
+
checks: checklist.checks.map((c) => ({
|
|
2277
|
+
id: c.key,
|
|
2278
|
+
checkType: c.type,
|
|
2279
|
+
weight: c.weight ?? 1,
|
|
2280
|
+
onFailure: c.on_failure
|
|
2281
|
+
})),
|
|
2282
|
+
results: results.map((r) => ({ checkId: r.check, status: r.status })),
|
|
2283
|
+
passThreshold: scoring.pass_threshold,
|
|
2284
|
+
scoreBands: scoring.score_bands,
|
|
2285
|
+
scoreType: scoring.score_type,
|
|
2286
|
+
scoreUnit: scoring.score_unit
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// src/mcp.ts
|
|
2291
|
+
var reply = ({ text: text2, image: image2, isError }) => ({
|
|
2292
|
+
content: [
|
|
2293
|
+
{ type: "text", text: text2 },
|
|
2294
|
+
...image2 ? [{ type: "image", data: image2, mimeType: "image/png" }] : []
|
|
2295
|
+
],
|
|
2296
|
+
...isError ? { isError: true } : {}
|
|
2297
|
+
});
|
|
2298
|
+
var queue = Promise.resolve();
|
|
2299
|
+
function attempt(work) {
|
|
2300
|
+
const run = queue.then(async () => {
|
|
2301
|
+
try {
|
|
2302
|
+
const out = await work();
|
|
2303
|
+
return reply(typeof out === "string" ? { text: out } : out);
|
|
2304
|
+
} catch (err) {
|
|
2305
|
+
return reply({ text: err.message, isError: true });
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
queue = run;
|
|
2309
|
+
return run;
|
|
2310
|
+
}
|
|
2311
|
+
function shapeOf(spec) {
|
|
2312
|
+
const required = new Set(spec.parameters.required ?? []);
|
|
2313
|
+
return Object.fromEntries(
|
|
2314
|
+
Object.entries(spec.parameters.properties).map(([key, raw]) => {
|
|
2315
|
+
const prop = raw;
|
|
2316
|
+
let schema = prop.enum ? z.enum(prop.enum) : prop.type === "number" ? z.number() : z.string();
|
|
2317
|
+
if (prop.description) schema = schema.describe(prop.description);
|
|
2318
|
+
return [key, required.has(key) ? schema : schema.optional()];
|
|
2319
|
+
})
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
function createServer(session) {
|
|
2323
|
+
const server = new McpServer({ name: "stuntdouble", version: PACKAGE.split("@")[1] });
|
|
2324
|
+
for (const spec of DIRECT_TOOL_SPECS.filter((s) => s.name !== "done")) {
|
|
2325
|
+
server.registerTool(
|
|
2326
|
+
spec.name,
|
|
2327
|
+
{ description: spec.description, inputSchema: shapeOf(spec) },
|
|
2328
|
+
(input) => attempt(() => session.browserTool(spec.name, input))
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
server.registerTool(
|
|
2332
|
+
"start_run",
|
|
2333
|
+
{
|
|
2334
|
+
description: "Start a local run of a checklist from .stuntdouble/checklists/<checklist>.json: opens a fresh Chrome at the checklist's URL, and returns the viewer link, the actor's brief and the checks to judge. If a file is invalid, returns each field to fix. With resume, continues the run still open in this session, or else the newest interrupted or stopped run of that checklist, judging only the checks without a verdict.",
|
|
2335
|
+
inputSchema: {
|
|
2336
|
+
checklist: z.string().describe('The checklist slug, e.g. "checkout".'),
|
|
2337
|
+
resume: z.boolean().optional().describe("Continue the last unfinished run instead.")
|
|
2338
|
+
}
|
|
2339
|
+
},
|
|
2340
|
+
(input) => attempt(() => session.startRun(input))
|
|
2341
|
+
);
|
|
2342
|
+
server.registerTool(
|
|
2343
|
+
"evaluate_check",
|
|
2344
|
+
{
|
|
2345
|
+
description: 'Record the verdict on one check, as soon as you have observed it. A passed verdict needs a locator for evidence visible on the page now; a failure may describe something missing. Use "informational" only for informational checks, and "blocked" when you could not reach what the check is about. Saves a screenshot of the evidence and returns the next check.',
|
|
2346
|
+
inputSchema: {
|
|
2347
|
+
check: z.string().describe('The check key, e.g. "guest-checkout".'),
|
|
2348
|
+
status: z.enum(["passed", "failed", "informational", "blocked"]),
|
|
2349
|
+
evidence: z.string().min(1).describe("What you observed, in a sentence or two, quoting the page where you can."),
|
|
2350
|
+
locator: z.object({
|
|
2351
|
+
text: z.string().optional().describe("Distinctive text visible on the page."),
|
|
2352
|
+
selector: z.string().optional().describe("A CSS selector for the element.")
|
|
2353
|
+
}).optional().describe("Where the evidence is on the page. Required for passed.")
|
|
2354
|
+
}
|
|
2355
|
+
},
|
|
2356
|
+
(input) => attempt(() => session.evaluateCheck(input))
|
|
2357
|
+
);
|
|
2358
|
+
server.registerTool(
|
|
2359
|
+
"finish_run",
|
|
2360
|
+
{
|
|
2361
|
+
description: "End the run: settles the checks left, scores it the way the cloud does, writes run.json, closes the browser and returns the outcome. Always call it, even when blocked or stopped.",
|
|
2362
|
+
inputSchema: {
|
|
2363
|
+
summary: z.string().min(1).describe("One or two sentences on what you found.")
|
|
2364
|
+
}
|
|
2365
|
+
},
|
|
2366
|
+
(input) => attempt(() => session.finishRun(input))
|
|
2367
|
+
);
|
|
2368
|
+
server.registerTool(
|
|
2369
|
+
"open_ui",
|
|
2370
|
+
{
|
|
2371
|
+
description: "The local viewer for this project: every run with its verdicts, screenshots and activity, and the checklists and actors. Returns a link to open in a browser."
|
|
2372
|
+
},
|
|
2373
|
+
() => attempt(() => session.openUi())
|
|
2374
|
+
);
|
|
2375
|
+
return server;
|
|
2376
|
+
}
|
|
2377
|
+
async function serveMcp() {
|
|
2378
|
+
const session = new LocalSession({ project: projectDir() });
|
|
2379
|
+
const server = createServer(session);
|
|
2380
|
+
let closing;
|
|
2381
|
+
const shutdown = () => closing ??= session.close().finally(() => process.exit(0));
|
|
2382
|
+
process.stdin.on("close", shutdown);
|
|
2383
|
+
process.on("SIGINT", shutdown);
|
|
2384
|
+
process.on("SIGTERM", shutdown);
|
|
2385
|
+
await server.connect(new StdioServerTransport());
|
|
2386
|
+
}
|
|
2387
|
+
export {
|
|
2388
|
+
createServer,
|
|
2389
|
+
serveMcp
|
|
2390
|
+
};
|
|
2391
|
+
//# sourceMappingURL=mcp-HXJQN2C7.js.map
|