@vellumai/assistant 0.4.21 → 0.4.23
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/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +55 -44
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -75
- package/src/__tests__/headless-browser-interactions.test.ts +0 -4
- package/src/__tests__/ipc-snapshot.test.ts +0 -54
- package/src/__tests__/resolve-guardian-trust-class.test.ts +61 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -4
- package/src/config/system-prompt.ts +1 -0
- package/src/config/templates/BOOTSTRAP.md +21 -31
- package/src/config/templates/SOUL.md +19 -9
- package/src/daemon/computer-use-session.ts +5 -3
- package/src/daemon/daemon-control.ts +3 -0
- package/src/daemon/handlers/browser.ts +2 -48
- package/src/daemon/handlers/config-voice.ts +155 -33
- package/src/daemon/handlers/dictation.ts +361 -214
- package/src/daemon/ipc-contract/browser.ts +4 -74
- package/src/daemon/ipc-contract/surfaces.ts +51 -48
- package/src/daemon/ipc-contract-inventory.json +0 -7
- package/src/daemon/session-agent-loop.ts +2 -1
- package/src/daemon/session-runtime-assembly.ts +477 -247
- package/src/daemon/session-surfaces.ts +5 -3
- package/src/daemon/session-tool-setup.ts +27 -13
- package/src/memory/migrations/102-alter-table-columns.ts +254 -37
- package/src/memory/schema.ts +1227 -1035
- package/src/tools/browser/browser-execution.ts +314 -331
- package/src/tools/browser/browser-handoff.ts +11 -37
- package/src/tools/browser/browser-manager.ts +271 -264
- package/src/tools/browser/browser-screencast.ts +19 -75
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import type { ImageContent } from
|
|
2
|
-
import { getLogger } from
|
|
3
|
-
import { truncate } from
|
|
4
|
-
import { credentialBroker } from
|
|
1
|
+
import type { ImageContent } from "../../providers/types.js";
|
|
2
|
+
import { getLogger } from "../../util/logger.js";
|
|
3
|
+
import { truncate } from "../../util/truncate.js";
|
|
4
|
+
import { credentialBroker } from "../credentials/broker.js";
|
|
5
5
|
import {
|
|
6
6
|
isPrivateOrLocalHost,
|
|
7
7
|
parseUrl,
|
|
8
8
|
resolveHostAddresses,
|
|
9
9
|
resolveRequestAddress,
|
|
10
10
|
sanitizeUrlForOutput,
|
|
11
|
-
} from
|
|
12
|
-
import type { ToolContext, ToolExecutionResult } from
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
} from "../network/url-safety.js";
|
|
12
|
+
import type { ToolContext, ToolExecutionResult } from "../types.js";
|
|
13
|
+
import {
|
|
14
|
+
detectAuthChallenge,
|
|
15
|
+
detectCaptchaChallenge,
|
|
16
|
+
formatAuthChallenge,
|
|
17
|
+
} from "./auth-detector.js";
|
|
18
|
+
import type { PageResponse, RouteHandler } from "./browser-manager.js";
|
|
19
|
+
import { browserManager } from "./browser-manager.js";
|
|
16
20
|
import {
|
|
17
21
|
ensureScreencast,
|
|
18
|
-
getElementBounds,
|
|
19
22
|
getSender,
|
|
20
23
|
stopAllScreencasts,
|
|
21
24
|
stopBrowserScreencast,
|
|
22
|
-
|
|
23
|
-
updateHighlights,
|
|
24
|
-
updatePagesList,
|
|
25
|
-
} from './browser-screencast.js';
|
|
25
|
+
} from "./browser-screencast.js";
|
|
26
26
|
|
|
27
|
-
const log = getLogger(
|
|
27
|
+
const log = getLogger("headless-browser");
|
|
28
28
|
|
|
29
29
|
// ── Constants ────────────────────────────────────────────────────────
|
|
30
30
|
|
|
@@ -35,11 +35,11 @@ export const ACTION_TIMEOUT_MS = 10_000;
|
|
|
35
35
|
export const MAX_SNAPSHOT_ELEMENTS = 150;
|
|
36
36
|
|
|
37
37
|
export const INTERACTIVE_SELECTOR = [
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
"a[href]",
|
|
39
|
+
"button",
|
|
40
|
+
"input",
|
|
41
|
+
"select",
|
|
42
|
+
"textarea",
|
|
43
43
|
'[role="button"]',
|
|
44
44
|
'[role="link"]',
|
|
45
45
|
'[role="checkbox"]',
|
|
@@ -50,7 +50,7 @@ export const INTERACTIVE_SELECTOR = [
|
|
|
50
50
|
'[role="combobox"]',
|
|
51
51
|
'[role="listbox"]',
|
|
52
52
|
'[contenteditable="true"]',
|
|
53
|
-
].join(
|
|
53
|
+
].join(", ");
|
|
54
54
|
|
|
55
55
|
export type SnapshotElement = {
|
|
56
56
|
eid: string;
|
|
@@ -69,15 +69,23 @@ export function resolveSelector(
|
|
|
69
69
|
sessionId: string,
|
|
70
70
|
input: Record<string, unknown>,
|
|
71
71
|
): { selector: string | null; error: string | null } {
|
|
72
|
-
const elementId =
|
|
73
|
-
|
|
72
|
+
const elementId =
|
|
73
|
+
typeof input.element_id === "string" ? input.element_id : null;
|
|
74
|
+
const rawSelector =
|
|
75
|
+
typeof input.selector === "string" ? input.selector : null;
|
|
74
76
|
|
|
75
77
|
if (!elementId && !rawSelector) {
|
|
76
|
-
return {
|
|
78
|
+
return {
|
|
79
|
+
selector: null,
|
|
80
|
+
error: "Error: Either element_id or selector is required.",
|
|
81
|
+
};
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
if (elementId) {
|
|
80
|
-
const resolved = browserManager.resolveSnapshotSelector(
|
|
85
|
+
const resolved = browserManager.resolveSnapshotSelector(
|
|
86
|
+
sessionId,
|
|
87
|
+
elementId,
|
|
88
|
+
);
|
|
81
89
|
if (!resolved) {
|
|
82
90
|
return {
|
|
83
91
|
selector: null,
|
|
@@ -97,15 +105,18 @@ export async function executeBrowserNavigate(
|
|
|
97
105
|
context: ToolContext,
|
|
98
106
|
): Promise<ToolExecutionResult> {
|
|
99
107
|
if (context.signal?.aborted) {
|
|
100
|
-
return { content:
|
|
108
|
+
return { content: "Error: operation was cancelled", isError: true };
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
const parsedUrl = parseUrl(input.url);
|
|
104
112
|
if (!parsedUrl) {
|
|
105
|
-
return {
|
|
113
|
+
return {
|
|
114
|
+
content: "Error: url is required and must be a valid HTTP(S) URL",
|
|
115
|
+
isError: true,
|
|
116
|
+
};
|
|
106
117
|
}
|
|
107
|
-
if (parsedUrl.protocol !==
|
|
108
|
-
return { content:
|
|
118
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
119
|
+
return { content: "Error: url must use http or https", isError: true };
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
const allowPrivateNetwork = input.allow_private_network === true;
|
|
@@ -140,13 +151,15 @@ export async function executeBrowserNavigate(
|
|
|
140
151
|
// Start screencast if a sender is registered for this session
|
|
141
152
|
const sender = getSender(context.sessionId);
|
|
142
153
|
if (sender) {
|
|
143
|
-
await ensureScreencast(context.sessionId
|
|
144
|
-
updateBrowserStatus(context.sessionId, sender, 'navigating', `Navigating to ${safeRequestedUrl}`);
|
|
154
|
+
await ensureScreencast(context.sessionId);
|
|
145
155
|
}
|
|
146
156
|
|
|
147
157
|
try {
|
|
148
158
|
const page = await browserManager.getOrCreateSessionPage(context.sessionId);
|
|
149
|
-
log.debug(
|
|
159
|
+
log.debug(
|
|
160
|
+
{ url: safeRequestedUrl, sessionId: context.sessionId },
|
|
161
|
+
"Navigating",
|
|
162
|
+
);
|
|
150
163
|
|
|
151
164
|
// Install request interception to block redirects/sub-requests to private networks.
|
|
152
165
|
// This prevents SSRF bypass via server-side redirects and DNS rebinding attacks,
|
|
@@ -159,7 +172,10 @@ export async function executeBrowserNavigate(
|
|
|
159
172
|
// resolves to a public IP then later to a private one. Blocked results are
|
|
160
173
|
// never cached so they are always re-resolved.
|
|
161
174
|
const DNS_CACHE_TTL_MS = 5_000;
|
|
162
|
-
const dnsCache = new Map<
|
|
175
|
+
const dnsCache = new Map<
|
|
176
|
+
string,
|
|
177
|
+
{ addresses: string[]; blockedAddress?: string; cachedAt: number }
|
|
178
|
+
>();
|
|
163
179
|
routeHandler = async (route, request) => {
|
|
164
180
|
try {
|
|
165
181
|
const reqUrl = request.url();
|
|
@@ -174,8 +190,11 @@ export async function executeBrowserNavigate(
|
|
|
174
190
|
// Check hostname against private/local patterns
|
|
175
191
|
if (isPrivateOrLocalHost(reqParsed.hostname)) {
|
|
176
192
|
blockedUrl = sanitizeUrlForOutput(reqParsed);
|
|
177
|
-
log.warn(
|
|
178
|
-
|
|
193
|
+
log.warn(
|
|
194
|
+
{ blockedUrl },
|
|
195
|
+
"Blocked navigation to private network target via redirect",
|
|
196
|
+
);
|
|
197
|
+
await route.abort("blockedbyclient");
|
|
179
198
|
return;
|
|
180
199
|
}
|
|
181
200
|
|
|
@@ -184,36 +203,44 @@ export async function executeBrowserNavigate(
|
|
|
184
203
|
// DNS rebinding where a hostname flips from public to private IP.
|
|
185
204
|
let cached = dnsCache.get(reqParsed.hostname);
|
|
186
205
|
const now = Date.now();
|
|
187
|
-
if (cached &&
|
|
206
|
+
if (cached && now - cached.cachedAt > DNS_CACHE_TTL_MS) {
|
|
188
207
|
dnsCache.delete(reqParsed.hostname);
|
|
189
208
|
cached = undefined;
|
|
190
209
|
}
|
|
191
|
-
const resolution =
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
210
|
+
const resolution =
|
|
211
|
+
cached ??
|
|
212
|
+
(await (async () => {
|
|
213
|
+
const res = await resolveRequestAddress(
|
|
214
|
+
reqParsed.hostname,
|
|
215
|
+
resolveHostAddresses,
|
|
216
|
+
false,
|
|
217
|
+
);
|
|
218
|
+
// Only cache allowed results; blocked results must be re-resolved
|
|
219
|
+
if (!res.blockedAddress) {
|
|
220
|
+
dnsCache.set(reqParsed.hostname, { ...res, cachedAt: now });
|
|
221
|
+
}
|
|
222
|
+
return res;
|
|
223
|
+
})());
|
|
203
224
|
if (resolution.blockedAddress) {
|
|
204
225
|
blockedUrl = sanitizeUrlForOutput(reqParsed);
|
|
205
|
-
log.warn(
|
|
206
|
-
|
|
226
|
+
log.warn(
|
|
227
|
+
{ blockedUrl, resolvedTo: resolution.blockedAddress },
|
|
228
|
+
"Blocked navigation: DNS resolves to private address",
|
|
229
|
+
);
|
|
230
|
+
await route.abort("blockedbyclient");
|
|
207
231
|
return;
|
|
208
232
|
}
|
|
209
233
|
|
|
210
234
|
await route.continue();
|
|
211
235
|
} catch (err) {
|
|
212
236
|
// Route may already be handled if the page navigated or was closed
|
|
213
|
-
log.debug(
|
|
237
|
+
log.debug(
|
|
238
|
+
{ err },
|
|
239
|
+
"Route handler error (route likely already handled)",
|
|
240
|
+
);
|
|
214
241
|
}
|
|
215
242
|
};
|
|
216
|
-
await page.route(
|
|
243
|
+
await page.route("**/*", routeHandler);
|
|
217
244
|
}
|
|
218
245
|
|
|
219
246
|
// Use domcontentloaded but with a shorter timeout — if it times out,
|
|
@@ -224,19 +251,22 @@ export async function executeBrowserNavigate(
|
|
|
224
251
|
const urlBeforeNav = page.url();
|
|
225
252
|
try {
|
|
226
253
|
response = await page.goto(parsedUrl.href, {
|
|
227
|
-
waitUntil:
|
|
254
|
+
waitUntil: "domcontentloaded",
|
|
228
255
|
timeout: NAVIGATE_TIMEOUT_MS,
|
|
229
256
|
});
|
|
230
257
|
} catch (navErr) {
|
|
231
258
|
const navMsg = navErr instanceof Error ? navErr.message : String(navErr);
|
|
232
|
-
if (navMsg.includes(
|
|
259
|
+
if (navMsg.includes("Timeout") || navMsg.includes("timeout")) {
|
|
233
260
|
// If the page URL never changed from before navigation, the page
|
|
234
261
|
// never actually loaded — re-throw instead of reporting success.
|
|
235
262
|
if (page.url() === urlBeforeNav && urlBeforeNav !== parsedUrl.href) {
|
|
236
263
|
throw navErr;
|
|
237
264
|
}
|
|
238
265
|
navigationTimedOut = true;
|
|
239
|
-
log.info(
|
|
266
|
+
log.info(
|
|
267
|
+
{ url: safeRequestedUrl },
|
|
268
|
+
"Navigation timed out waiting for domcontentloaded, continuing with partial load",
|
|
269
|
+
);
|
|
240
270
|
} else {
|
|
241
271
|
throw navErr;
|
|
242
272
|
}
|
|
@@ -244,7 +274,7 @@ export async function executeBrowserNavigate(
|
|
|
244
274
|
|
|
245
275
|
// Remove the route handler now that navigation is complete
|
|
246
276
|
if (routeHandler) {
|
|
247
|
-
await page.unroute(
|
|
277
|
+
await page.unroute("**/*", routeHandler);
|
|
248
278
|
routeHandler = null;
|
|
249
279
|
}
|
|
250
280
|
|
|
@@ -255,9 +285,6 @@ export async function executeBrowserNavigate(
|
|
|
255
285
|
}
|
|
256
286
|
|
|
257
287
|
if (blockedUrl) {
|
|
258
|
-
if (sender) {
|
|
259
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
260
|
-
}
|
|
261
288
|
return {
|
|
262
289
|
content: `Error: Navigation blocked. A request targeted a local/private network address (${blockedUrl}). Set allow_private_network=true if you explicitly need it.`,
|
|
263
290
|
isError: true,
|
|
@@ -300,12 +327,14 @@ export async function executeBrowserNavigate(
|
|
|
300
327
|
const lines: string[] = [
|
|
301
328
|
`Requested URL: ${safeRequestedUrl}`,
|
|
302
329
|
`Final URL: ${safeFinalUrl}`,
|
|
303
|
-
`Status: ${status ??
|
|
304
|
-
`Title: ${title ||
|
|
330
|
+
`Status: ${status ?? "unknown"}`,
|
|
331
|
+
`Title: ${title || "(none)"}`,
|
|
305
332
|
];
|
|
306
333
|
|
|
307
334
|
if (navigationTimedOut) {
|
|
308
|
-
lines.push(
|
|
335
|
+
lines.push(
|
|
336
|
+
`Note: Page is still loading (domcontentloaded timed out). The page should still be interactive — use browser_snapshot to check.`,
|
|
337
|
+
);
|
|
309
338
|
}
|
|
310
339
|
|
|
311
340
|
if (finalUrl !== parsedUrl.href) {
|
|
@@ -321,17 +350,16 @@ export async function executeBrowserNavigate(
|
|
|
321
350
|
|
|
322
351
|
// Many CAPTCHA interstitials (e.g. Cloudflare "Just a moment") auto-resolve
|
|
323
352
|
// within a few seconds. Wait and re-check before handing off to the user.
|
|
324
|
-
if (challenge?.type ===
|
|
325
|
-
log.info(
|
|
353
|
+
if (challenge?.type === "captcha") {
|
|
354
|
+
log.info("CAPTCHA detected, waiting up to 5s for auto-resolve");
|
|
326
355
|
for (let i = 0; i < 5; i++) {
|
|
327
356
|
if (context.signal?.aborted) {
|
|
328
|
-
|
|
329
|
-
return { content: 'Navigation cancelled.', isError: true };
|
|
357
|
+
return { content: "Navigation cancelled.", isError: true };
|
|
330
358
|
}
|
|
331
359
|
await new Promise((r) => setTimeout(r, 1000));
|
|
332
360
|
const still = await detectCaptchaChallenge(page);
|
|
333
361
|
if (!still) {
|
|
334
|
-
log.info(
|
|
362
|
+
log.info("CAPTCHA auto-resolved");
|
|
335
363
|
// Re-check for auth challenge now that CAPTCHA is gone —
|
|
336
364
|
// the page may have loaded a login form behind it.
|
|
337
365
|
challenge = await detectAuthChallenge(page);
|
|
@@ -341,76 +369,99 @@ export async function executeBrowserNavigate(
|
|
|
341
369
|
}
|
|
342
370
|
|
|
343
371
|
if (challenge) {
|
|
344
|
-
if (challenge.type ===
|
|
372
|
+
if (challenge.type === "captcha") {
|
|
345
373
|
// CAPTCHA persisted after auto-resolve wait — hand off to user
|
|
346
|
-
if (
|
|
347
|
-
const { startHandoff } = await import(
|
|
348
|
-
await startHandoff(context.sessionId,
|
|
349
|
-
reason:
|
|
350
|
-
message:
|
|
374
|
+
if (sender) {
|
|
375
|
+
const { startHandoff } = await import("./browser-handoff.js");
|
|
376
|
+
await startHandoff(context.sessionId, {
|
|
377
|
+
reason: "captcha",
|
|
378
|
+
message:
|
|
379
|
+
"Cloudflare verification detected. Please solve the CAPTCHA in the Chrome window. The browser will automatically detect when you're done and resume.",
|
|
351
380
|
bringToFront: true,
|
|
352
381
|
});
|
|
353
382
|
const newUrl = page.url();
|
|
354
383
|
const newTitle = await page.title();
|
|
355
|
-
lines.push(
|
|
356
|
-
lines.push(
|
|
384
|
+
lines.push("");
|
|
385
|
+
lines.push(
|
|
386
|
+
`CAPTCHA solved by user. Current page: ${newTitle} (${newUrl})`,
|
|
387
|
+
);
|
|
357
388
|
|
|
358
389
|
// Re-check for auth challenges — the page behind the CAPTCHA may have a login form
|
|
359
390
|
const postCaptchaAuth = await detectAuthChallenge(page);
|
|
360
391
|
if (postCaptchaAuth) {
|
|
361
|
-
lines.push(
|
|
392
|
+
lines.push("");
|
|
362
393
|
lines.push(formatAuthChallenge(postCaptchaAuth));
|
|
363
|
-
lines.push(
|
|
364
|
-
lines.push(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
lines.push(
|
|
368
|
-
|
|
394
|
+
lines.push("");
|
|
395
|
+
lines.push(
|
|
396
|
+
"Handle this by using browser tools to interact with the login form:",
|
|
397
|
+
);
|
|
398
|
+
lines.push(
|
|
399
|
+
"1. Use browser_snapshot to find the sign-in form elements",
|
|
400
|
+
);
|
|
401
|
+
lines.push(
|
|
402
|
+
"2. Use browser_fill_credential to fill email/password from credential_store",
|
|
403
|
+
);
|
|
404
|
+
lines.push(
|
|
405
|
+
"3. For SMS/email verification codes, use ui_show with a form to ask the user for the code mid-turn",
|
|
406
|
+
);
|
|
407
|
+
lines.push(
|
|
408
|
+
"4. Do NOT give up or tell the user to sign in manually — handle the login flow yourself",
|
|
409
|
+
);
|
|
369
410
|
}
|
|
370
411
|
} else {
|
|
371
|
-
lines.push(
|
|
372
|
-
lines.push(
|
|
373
|
-
|
|
412
|
+
lines.push("");
|
|
413
|
+
lines.push(
|
|
414
|
+
"⚠️ CAPTCHA/Cloudflare verification detected on this page.",
|
|
415
|
+
);
|
|
416
|
+
lines.push(
|
|
417
|
+
"The user needs to solve this challenge manually. Please inform the user that the page requires human verification before the content can be accessed.",
|
|
418
|
+
);
|
|
374
419
|
}
|
|
375
420
|
} else {
|
|
376
421
|
// Login / 2FA / OAuth — the agent should handle these itself
|
|
377
422
|
// using browser tools + credential_store. Don't hand off.
|
|
378
|
-
lines.push(
|
|
423
|
+
lines.push("");
|
|
379
424
|
lines.push(formatAuthChallenge(challenge));
|
|
380
|
-
lines.push(
|
|
381
|
-
lines.push(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
lines.push(
|
|
385
|
-
|
|
425
|
+
lines.push("");
|
|
426
|
+
lines.push(
|
|
427
|
+
"Handle this by using browser tools to interact with the login form:",
|
|
428
|
+
);
|
|
429
|
+
lines.push(
|
|
430
|
+
"1. Use browser_snapshot to find the sign-in form elements",
|
|
431
|
+
);
|
|
432
|
+
lines.push(
|
|
433
|
+
"2. Use browser_fill_credential to fill email/password from credential_store",
|
|
434
|
+
);
|
|
435
|
+
lines.push(
|
|
436
|
+
"3. For SMS/email verification codes, use ui_show with a form to ask the user for the code mid-turn",
|
|
437
|
+
);
|
|
438
|
+
lines.push(
|
|
439
|
+
"4. Do NOT give up or tell the user to sign in manually — handle the login flow yourself",
|
|
440
|
+
);
|
|
386
441
|
}
|
|
387
442
|
}
|
|
388
443
|
} catch {
|
|
389
444
|
// Auth/CAPTCHA detection is best-effort; don't fail navigation
|
|
390
445
|
}
|
|
391
446
|
|
|
392
|
-
|
|
393
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
394
|
-
await updatePagesList(context.sessionId, sender);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return { content: lines.join('\n'), isError: false };
|
|
447
|
+
return { content: lines.join("\n"), isError: false };
|
|
398
448
|
} catch (err) {
|
|
399
449
|
// Best-effort cleanup of route handler on error
|
|
400
450
|
if (routeHandler) {
|
|
401
451
|
try {
|
|
402
|
-
const page = await browserManager.getOrCreateSessionPage(
|
|
403
|
-
|
|
404
|
-
|
|
452
|
+
const page = await browserManager.getOrCreateSessionPage(
|
|
453
|
+
context.sessionId,
|
|
454
|
+
);
|
|
455
|
+
await page.unroute("**/*", routeHandler);
|
|
456
|
+
} catch {
|
|
457
|
+
/* ignore cleanup errors */
|
|
458
|
+
}
|
|
405
459
|
}
|
|
406
460
|
|
|
407
461
|
// If the route handler blocked a redirect to a private network address,
|
|
408
462
|
// page.goto() throws. Return the clear security message instead of the
|
|
409
463
|
// raw Playwright error (which could leak credentials from the URL).
|
|
410
464
|
if (blockedUrl) {
|
|
411
|
-
if (sender) {
|
|
412
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
413
|
-
}
|
|
414
465
|
return {
|
|
415
466
|
content: `Error: Navigation blocked. A request targeted a local/private network address (${blockedUrl}). Set allow_private_network=true if you explicitly need it.`,
|
|
416
467
|
isError: true,
|
|
@@ -418,10 +469,7 @@ export async function executeBrowserNavigate(
|
|
|
418
469
|
}
|
|
419
470
|
|
|
420
471
|
const msg = err instanceof Error ? err.message : String(err);
|
|
421
|
-
log.error({ err, url: safeRequestedUrl },
|
|
422
|
-
if (sender) {
|
|
423
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
424
|
-
}
|
|
472
|
+
log.error({ err, url: safeRequestedUrl }, "Navigation failed");
|
|
425
473
|
return { content: `Error: Navigation failed: ${msg}`, isError: true };
|
|
426
474
|
}
|
|
427
475
|
}
|
|
@@ -472,32 +520,34 @@ export async function executeBrowserSnapshot(
|
|
|
472
520
|
// Format output
|
|
473
521
|
const lines: string[] = [
|
|
474
522
|
`URL: ${currentUrl}`,
|
|
475
|
-
`Title: ${title ||
|
|
476
|
-
|
|
523
|
+
`Title: ${title || "(none)"}`,
|
|
524
|
+
"",
|
|
477
525
|
];
|
|
478
526
|
|
|
479
527
|
if (elements.length === 0) {
|
|
480
|
-
lines.push(
|
|
528
|
+
lines.push("(no interactive elements found)");
|
|
481
529
|
} else {
|
|
482
530
|
for (const el of elements) {
|
|
483
531
|
let desc = `<${el.tag}`;
|
|
484
532
|
for (const [key, val] of Object.entries(el.attrs)) {
|
|
485
533
|
desc += ` ${key}="${val}"`;
|
|
486
534
|
}
|
|
487
|
-
desc +=
|
|
535
|
+
desc += ">";
|
|
488
536
|
if (el.text) {
|
|
489
537
|
desc += ` ${el.text}`;
|
|
490
538
|
}
|
|
491
539
|
lines.push(`[${el.eid}] ${desc}`);
|
|
492
540
|
}
|
|
493
|
-
lines.push(
|
|
494
|
-
lines.push(
|
|
541
|
+
lines.push("");
|
|
542
|
+
lines.push(
|
|
543
|
+
`${elements.length} interactive element${elements.length === 1 ? "" : "s"} found.`,
|
|
544
|
+
);
|
|
495
545
|
}
|
|
496
546
|
|
|
497
|
-
return { content: lines.join(
|
|
547
|
+
return { content: lines.join("\n"), isError: false };
|
|
498
548
|
} catch (err) {
|
|
499
549
|
const msg = err instanceof Error ? err.message : String(err);
|
|
500
|
-
log.error({ err },
|
|
550
|
+
log.error({ err }, "Snapshot failed");
|
|
501
551
|
return { content: `Error: Snapshot failed: ${msg}`, isError: true };
|
|
502
552
|
}
|
|
503
553
|
}
|
|
@@ -512,26 +562,30 @@ export async function executeBrowserScreenshot(
|
|
|
512
562
|
|
|
513
563
|
try {
|
|
514
564
|
const page = await browserManager.getOrCreateSessionPage(context.sessionId);
|
|
515
|
-
const buffer = await page.screenshot({
|
|
516
|
-
|
|
565
|
+
const buffer = await page.screenshot({
|
|
566
|
+
type: "jpeg",
|
|
567
|
+
quality: 80,
|
|
568
|
+
fullPage,
|
|
569
|
+
});
|
|
570
|
+
const base64Data = buffer.toString("base64");
|
|
517
571
|
|
|
518
572
|
const imageBlock: ImageContent = {
|
|
519
|
-
type:
|
|
573
|
+
type: "image" as const,
|
|
520
574
|
source: {
|
|
521
|
-
type:
|
|
522
|
-
media_type:
|
|
575
|
+
type: "base64" as const,
|
|
576
|
+
media_type: "image/jpeg",
|
|
523
577
|
data: base64Data,
|
|
524
578
|
},
|
|
525
579
|
};
|
|
526
580
|
|
|
527
581
|
return {
|
|
528
|
-
content: `Screenshot captured (${buffer.length} bytes, ${fullPage ?
|
|
582
|
+
content: `Screenshot captured (${buffer.length} bytes, ${fullPage ? "full page" : "viewport"})`,
|
|
529
583
|
isError: false,
|
|
530
584
|
contentBlocks: [imageBlock],
|
|
531
585
|
};
|
|
532
586
|
} catch (err) {
|
|
533
587
|
const msg = err instanceof Error ? err.message : String(err);
|
|
534
|
-
log.error({ err },
|
|
588
|
+
log.error({ err }, "Screenshot failed");
|
|
535
589
|
return { content: `Error: Screenshot failed: ${msg}`, isError: true };
|
|
536
590
|
}
|
|
537
591
|
}
|
|
@@ -545,19 +599,22 @@ export async function executeBrowserClose(
|
|
|
545
599
|
try {
|
|
546
600
|
const sender = getSender(context.sessionId);
|
|
547
601
|
if (sender) {
|
|
548
|
-
await stopBrowserScreencast(context.sessionId
|
|
602
|
+
await stopBrowserScreencast(context.sessionId);
|
|
549
603
|
}
|
|
550
604
|
|
|
551
605
|
if (input.close_all_pages === true) {
|
|
552
606
|
await stopAllScreencasts();
|
|
553
607
|
await browserManager.closeAllPages();
|
|
554
|
-
return {
|
|
608
|
+
return {
|
|
609
|
+
content: "All browser pages and context closed.",
|
|
610
|
+
isError: false,
|
|
611
|
+
};
|
|
555
612
|
}
|
|
556
613
|
await browserManager.closeSessionPage(context.sessionId);
|
|
557
|
-
return { content:
|
|
614
|
+
return { content: "Browser page closed for this session.", isError: false };
|
|
558
615
|
} catch (err) {
|
|
559
616
|
const msg = err instanceof Error ? err.message : String(err);
|
|
560
|
-
log.error({ err },
|
|
617
|
+
log.error({ err }, "Close failed");
|
|
561
618
|
return { content: `Error: Close failed: ${msg}`, isError: true };
|
|
562
619
|
}
|
|
563
620
|
}
|
|
@@ -571,33 +628,16 @@ export async function executeBrowserClick(
|
|
|
571
628
|
const { selector, error } = resolveSelector(context.sessionId, input);
|
|
572
629
|
if (error) return { content: error, isError: true };
|
|
573
630
|
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
await ensureScreencast(context.sessionId, sender);
|
|
577
|
-
updateBrowserStatus(context.sessionId, sender, 'interacting', 'Clicking element');
|
|
578
|
-
const bounds = await getElementBounds(context.sessionId, selector!);
|
|
579
|
-
if (bounds) {
|
|
580
|
-
updateHighlights(context.sessionId, sender, [{ ...bounds, label: 'Clicking element' }]);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const timeout = typeof input.timeout === 'number' ? input.timeout : ACTION_TIMEOUT_MS;
|
|
631
|
+
const timeout =
|
|
632
|
+
typeof input.timeout === "number" ? input.timeout : ACTION_TIMEOUT_MS;
|
|
585
633
|
|
|
586
634
|
try {
|
|
587
635
|
const page = await browserManager.getOrCreateSessionPage(context.sessionId);
|
|
588
636
|
await page.click(selector!, { timeout });
|
|
589
|
-
if (sender) {
|
|
590
|
-
updateHighlights(context.sessionId, sender, []);
|
|
591
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
592
|
-
}
|
|
593
637
|
return { content: `Clicked element: ${selector}`, isError: false };
|
|
594
638
|
} catch (err) {
|
|
595
639
|
const msg = err instanceof Error ? err.message : String(err);
|
|
596
|
-
log.error({ err, selector },
|
|
597
|
-
if (sender) {
|
|
598
|
-
updateHighlights(context.sessionId, sender, []);
|
|
599
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
600
|
-
}
|
|
640
|
+
log.error({ err, selector }, "Click failed");
|
|
601
641
|
return { content: `Error: Click failed: ${msg}`, isError: true };
|
|
602
642
|
}
|
|
603
643
|
}
|
|
@@ -611,28 +651,19 @@ export async function executeBrowserType(
|
|
|
611
651
|
const { selector, error } = resolveSelector(context.sessionId, input);
|
|
612
652
|
if (error) return { content: error, isError: true };
|
|
613
653
|
|
|
614
|
-
const text = typeof input.text ===
|
|
654
|
+
const text = typeof input.text === "string" ? input.text : "";
|
|
615
655
|
if (!text) {
|
|
616
|
-
return { content:
|
|
656
|
+
return { content: "Error: text is required.", isError: true };
|
|
617
657
|
}
|
|
618
658
|
|
|
619
659
|
const clearFirst = input.clear_first !== false; // default true
|
|
620
660
|
const pressEnter = input.press_enter === true;
|
|
621
661
|
|
|
622
|
-
const sender = getSender(context.sessionId);
|
|
623
|
-
if (sender) {
|
|
624
|
-
await ensureScreencast(context.sessionId, sender);
|
|
625
|
-
updateBrowserStatus(context.sessionId, sender, 'interacting', 'Typing text');
|
|
626
|
-
const bounds = await getElementBounds(context.sessionId, selector!);
|
|
627
|
-
if (bounds) {
|
|
628
|
-
updateHighlights(context.sessionId, sender, [{ ...bounds, label: 'Typing' }]);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
662
|
try {
|
|
633
663
|
const page = await browserManager.getOrCreateSessionPage(context.sessionId);
|
|
634
664
|
|
|
635
|
-
const fillTimeout =
|
|
665
|
+
const fillTimeout =
|
|
666
|
+
typeof input.timeout === "number" ? input.timeout : ACTION_TIMEOUT_MS;
|
|
636
667
|
|
|
637
668
|
if (clearFirst) {
|
|
638
669
|
await page.fill(selector!, text, { timeout: fillTimeout });
|
|
@@ -647,25 +678,16 @@ export async function executeBrowserType(
|
|
|
647
678
|
}
|
|
648
679
|
|
|
649
680
|
if (pressEnter) {
|
|
650
|
-
await page.press(selector!,
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
if (sender) {
|
|
654
|
-
updateHighlights(context.sessionId, sender, []);
|
|
655
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
681
|
+
await page.press(selector!, "Enter");
|
|
656
682
|
}
|
|
657
683
|
|
|
658
684
|
const lines = [`Typed into element: ${selector}`];
|
|
659
|
-
if (clearFirst) lines.push(
|
|
660
|
-
if (pressEnter) lines.push(
|
|
661
|
-
return { content: lines.join(
|
|
685
|
+
if (clearFirst) lines.push("(cleared existing content first)");
|
|
686
|
+
if (pressEnter) lines.push("(pressed Enter after typing)");
|
|
687
|
+
return { content: lines.join("\n"), isError: false };
|
|
662
688
|
} catch (err) {
|
|
663
689
|
const msg = err instanceof Error ? err.message : String(err);
|
|
664
|
-
log.error({ err, selector },
|
|
665
|
-
if (sender) {
|
|
666
|
-
updateHighlights(context.sessionId, sender, []);
|
|
667
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
668
|
-
}
|
|
690
|
+
log.error({ err, selector }, "Type failed");
|
|
669
691
|
return { content: `Error: Type failed: ${msg}`, isError: true };
|
|
670
692
|
}
|
|
671
693
|
}
|
|
@@ -676,129 +698,86 @@ export async function executeBrowserPressKey(
|
|
|
676
698
|
input: Record<string, unknown>,
|
|
677
699
|
context: ToolContext,
|
|
678
700
|
): Promise<ToolExecutionResult> {
|
|
679
|
-
const key = typeof input.key ===
|
|
701
|
+
const key = typeof input.key === "string" ? input.key : "";
|
|
680
702
|
if (!key) {
|
|
681
|
-
return { content:
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
const sender = getSender(context.sessionId);
|
|
685
|
-
if (sender) {
|
|
686
|
-
await ensureScreencast(context.sessionId, sender);
|
|
687
|
-
updateBrowserStatus(context.sessionId, sender, 'interacting', `Pressing ${key}`);
|
|
703
|
+
return { content: "Error: key is required.", isError: true };
|
|
688
704
|
}
|
|
689
705
|
|
|
690
706
|
try {
|
|
691
707
|
const page = await browserManager.getOrCreateSessionPage(context.sessionId);
|
|
692
708
|
|
|
693
709
|
// If element_id or selector is provided, press key on that element
|
|
694
|
-
const elementId =
|
|
695
|
-
|
|
710
|
+
const elementId =
|
|
711
|
+
typeof input.element_id === "string" ? input.element_id : null;
|
|
712
|
+
const rawSelector =
|
|
713
|
+
typeof input.selector === "string" ? input.selector : null;
|
|
696
714
|
|
|
697
715
|
if (elementId || rawSelector) {
|
|
698
716
|
const { selector, error } = resolveSelector(context.sessionId, input);
|
|
699
717
|
if (error) {
|
|
700
|
-
if (sender) {
|
|
701
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
702
|
-
}
|
|
703
718
|
return { content: error, isError: true };
|
|
704
719
|
}
|
|
705
|
-
if (sender) {
|
|
706
|
-
const bounds = await getElementBounds(context.sessionId, selector!);
|
|
707
|
-
if (bounds) {
|
|
708
|
-
updateHighlights(context.sessionId, sender, [{ ...bounds, label: `Pressing ${key}` }]);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
720
|
await page.press(selector!, key);
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
return { content: `Pressed "${key}" on element: ${selector}`, isError: false };
|
|
721
|
+
return {
|
|
722
|
+
content: `Pressed "${key}" on element: ${selector}`,
|
|
723
|
+
isError: false,
|
|
724
|
+
};
|
|
717
725
|
}
|
|
718
726
|
|
|
719
727
|
// No target -> press key on the page (focused element)
|
|
720
728
|
await page.keyboard.press(key);
|
|
721
|
-
if (sender) {
|
|
722
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
723
|
-
}
|
|
724
729
|
return { content: `Pressed "${key}"`, isError: false };
|
|
725
730
|
} catch (err) {
|
|
726
731
|
const msg = err instanceof Error ? err.message : String(err);
|
|
727
|
-
log.error({ err, key },
|
|
728
|
-
if (sender) {
|
|
729
|
-
updateHighlights(context.sessionId, sender, []);
|
|
730
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
731
|
-
}
|
|
732
|
+
log.error({ err, key }, "Press key failed");
|
|
732
733
|
return { content: `Error: Press key failed: ${msg}`, isError: true };
|
|
733
734
|
}
|
|
734
735
|
}
|
|
735
736
|
|
|
736
|
-
|
|
737
737
|
// ── browser_scroll ───────────────────────────────────────────────────
|
|
738
738
|
|
|
739
739
|
export async function executeBrowserScroll(
|
|
740
740
|
input: Record<string, unknown>,
|
|
741
741
|
context: ToolContext,
|
|
742
742
|
): Promise<ToolExecutionResult> {
|
|
743
|
-
const direction = typeof input.direction ===
|
|
744
|
-
if (!direction || ![
|
|
745
|
-
return {
|
|
743
|
+
const direction = typeof input.direction === "string" ? input.direction : "";
|
|
744
|
+
if (!direction || !["up", "down", "left", "right"].includes(direction)) {
|
|
745
|
+
return {
|
|
746
|
+
content:
|
|
747
|
+
"Error: direction is required and must be one of: up, down, left, right.",
|
|
748
|
+
isError: true,
|
|
749
|
+
};
|
|
746
750
|
}
|
|
747
751
|
|
|
748
|
-
const amount =
|
|
749
|
-
|
|
750
|
-
const sender = getSender(context.sessionId);
|
|
751
|
-
if (sender) {
|
|
752
|
-
await ensureScreencast(context.sessionId, sender);
|
|
753
|
-
updateBrowserStatus(context.sessionId, sender, 'interacting', `Scrolling ${direction}`);
|
|
754
|
-
}
|
|
752
|
+
const amount =
|
|
753
|
+
typeof input.amount === "number" ? Math.abs(input.amount) : 500;
|
|
755
754
|
|
|
756
755
|
try {
|
|
757
756
|
const page = await browserManager.getOrCreateSessionPage(context.sessionId);
|
|
758
757
|
|
|
759
|
-
// If element_id or selector is provided, scroll within that element
|
|
760
|
-
const elementId = typeof input.element_id === 'string' ? input.element_id : null;
|
|
761
|
-
const rawSelector = typeof input.selector === 'string' ? input.selector : null;
|
|
762
|
-
|
|
763
|
-
if (elementId || rawSelector) {
|
|
764
|
-
const { selector, error } = resolveSelector(context.sessionId, input);
|
|
765
|
-
if (error) {
|
|
766
|
-
if (sender) updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
767
|
-
return { content: error, isError: true };
|
|
768
|
-
}
|
|
769
|
-
// Move mouse to element center before scrolling
|
|
770
|
-
const bounds = await getElementBounds(context.sessionId, selector!);
|
|
771
|
-
if (bounds) {
|
|
772
|
-
// Convert screencast coords back to page coords for mouse.move
|
|
773
|
-
const result = await page.evaluate(`(() => ({ vw: window.innerWidth, vh: window.innerHeight }))()`) as { vw: number; vh: number };
|
|
774
|
-
const scale = Math.min(SCREENCAST_WIDTH / result.vw, SCREENCAST_HEIGHT / result.vh);
|
|
775
|
-
const pageX = (bounds.x + bounds.w / 2) / scale;
|
|
776
|
-
const pageY = (bounds.y + bounds.h / 2) / scale;
|
|
777
|
-
await page.mouse.move(pageX, pageY);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
758
|
let deltaX = 0;
|
|
782
759
|
let deltaY = 0;
|
|
783
760
|
switch (direction) {
|
|
784
|
-
case
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
case
|
|
761
|
+
case "up":
|
|
762
|
+
deltaY = -amount;
|
|
763
|
+
break;
|
|
764
|
+
case "down":
|
|
765
|
+
deltaY = amount;
|
|
766
|
+
break;
|
|
767
|
+
case "left":
|
|
768
|
+
deltaX = -amount;
|
|
769
|
+
break;
|
|
770
|
+
case "right":
|
|
771
|
+
deltaX = amount;
|
|
772
|
+
break;
|
|
788
773
|
}
|
|
789
774
|
|
|
790
775
|
await page.mouse.wheel(deltaX, deltaY);
|
|
791
776
|
|
|
792
|
-
if (sender) {
|
|
793
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
794
|
-
}
|
|
795
777
|
return { content: `Scrolled ${direction} by ${amount}px`, isError: false };
|
|
796
778
|
} catch (err) {
|
|
797
779
|
const msg = err instanceof Error ? err.message : String(err);
|
|
798
|
-
log.error({ err, direction },
|
|
799
|
-
if (sender) {
|
|
800
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
801
|
-
}
|
|
780
|
+
log.error({ err, direction }, "Scroll failed");
|
|
802
781
|
return { content: `Error: Scroll failed: ${msg}`, isError: true };
|
|
803
782
|
}
|
|
804
783
|
}
|
|
@@ -812,22 +791,15 @@ export async function executeBrowserSelectOption(
|
|
|
812
791
|
const { selector, error } = resolveSelector(context.sessionId, input);
|
|
813
792
|
if (error) return { content: error, isError: true };
|
|
814
793
|
|
|
815
|
-
const value = typeof input.value ===
|
|
816
|
-
const label = typeof input.label ===
|
|
817
|
-
const index = typeof input.index ===
|
|
794
|
+
const value = typeof input.value === "string" ? input.value : undefined;
|
|
795
|
+
const label = typeof input.label === "string" ? input.label : undefined;
|
|
796
|
+
const index = typeof input.index === "number" ? input.index : undefined;
|
|
818
797
|
|
|
819
798
|
if (value === undefined && label === undefined && index === undefined) {
|
|
820
|
-
return {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if (sender) {
|
|
825
|
-
await ensureScreencast(context.sessionId, sender);
|
|
826
|
-
updateBrowserStatus(context.sessionId, sender, 'interacting', 'Selecting option');
|
|
827
|
-
const bounds = await getElementBounds(context.sessionId, selector!);
|
|
828
|
-
if (bounds) {
|
|
829
|
-
updateHighlights(context.sessionId, sender, [{ ...bounds, label: 'Selecting option' }]);
|
|
830
|
-
}
|
|
799
|
+
return {
|
|
800
|
+
content: "Error: One of value, label, or index is required.",
|
|
801
|
+
isError: true,
|
|
802
|
+
};
|
|
831
803
|
}
|
|
832
804
|
|
|
833
805
|
try {
|
|
@@ -840,20 +812,19 @@ export async function executeBrowserSelectOption(
|
|
|
840
812
|
|
|
841
813
|
await page.selectOption(selector!, option);
|
|
842
814
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
return {
|
|
815
|
+
const desc =
|
|
816
|
+
value !== undefined
|
|
817
|
+
? `value="${value}"`
|
|
818
|
+
: label !== undefined
|
|
819
|
+
? `label="${label}"`
|
|
820
|
+
: `index=${index}`;
|
|
821
|
+
return {
|
|
822
|
+
content: `Selected option (${desc}) on element: ${selector}`,
|
|
823
|
+
isError: false,
|
|
824
|
+
};
|
|
850
825
|
} catch (err) {
|
|
851
826
|
const msg = err instanceof Error ? err.message : String(err);
|
|
852
|
-
log.error({ err, selector },
|
|
853
|
-
if (sender) {
|
|
854
|
-
updateHighlights(context.sessionId, sender, []);
|
|
855
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
856
|
-
}
|
|
827
|
+
log.error({ err, selector }, "Select option failed");
|
|
857
828
|
return { content: `Error: Select option failed: ${msg}`, isError: true };
|
|
858
829
|
}
|
|
859
830
|
}
|
|
@@ -867,32 +838,14 @@ export async function executeBrowserHover(
|
|
|
867
838
|
const { selector, error } = resolveSelector(context.sessionId, input);
|
|
868
839
|
if (error) return { content: error, isError: true };
|
|
869
840
|
|
|
870
|
-
const sender = getSender(context.sessionId);
|
|
871
|
-
if (sender) {
|
|
872
|
-
await ensureScreencast(context.sessionId, sender);
|
|
873
|
-
updateBrowserStatus(context.sessionId, sender, 'interacting', 'Hovering element');
|
|
874
|
-
const bounds = await getElementBounds(context.sessionId, selector!);
|
|
875
|
-
if (bounds) {
|
|
876
|
-
updateHighlights(context.sessionId, sender, [{ ...bounds, label: 'Hovering' }]);
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
|
|
880
841
|
try {
|
|
881
842
|
const page = await browserManager.getOrCreateSessionPage(context.sessionId);
|
|
882
843
|
await page.hover(selector!, { timeout: ACTION_TIMEOUT_MS });
|
|
883
844
|
|
|
884
|
-
if (sender) {
|
|
885
|
-
updateHighlights(context.sessionId, sender, []);
|
|
886
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
887
|
-
}
|
|
888
845
|
return { content: `Hovered element: ${selector}`, isError: false };
|
|
889
846
|
} catch (err) {
|
|
890
847
|
const msg = err instanceof Error ? err.message : String(err);
|
|
891
|
-
log.error({ err, selector },
|
|
892
|
-
if (sender) {
|
|
893
|
-
updateHighlights(context.sessionId, sender, []);
|
|
894
|
-
updateBrowserStatus(context.sessionId, sender, 'idle');
|
|
895
|
-
}
|
|
848
|
+
log.error({ err, selector }, "Hover failed");
|
|
896
849
|
return { content: `Error: Hover failed: ${msg}`, isError: true };
|
|
897
850
|
}
|
|
898
851
|
}
|
|
@@ -904,31 +857,45 @@ export async function executeBrowserWaitFor(
|
|
|
904
857
|
context: ToolContext,
|
|
905
858
|
): Promise<ToolExecutionResult> {
|
|
906
859
|
if (context.signal?.aborted) {
|
|
907
|
-
return { content:
|
|
860
|
+
return { content: "Error: operation was cancelled", isError: true };
|
|
908
861
|
}
|
|
909
862
|
|
|
910
|
-
const selector =
|
|
911
|
-
|
|
912
|
-
|
|
863
|
+
const selector =
|
|
864
|
+
typeof input.selector === "string" && input.selector
|
|
865
|
+
? input.selector
|
|
866
|
+
: null;
|
|
867
|
+
const text = typeof input.text === "string" && input.text ? input.text : null;
|
|
868
|
+
const duration = typeof input.duration === "number" ? input.duration : null;
|
|
913
869
|
|
|
914
870
|
const modeCount = [selector, text, duration].filter((v) => v != null).length;
|
|
915
871
|
if (modeCount === 0) {
|
|
916
|
-
return {
|
|
872
|
+
return {
|
|
873
|
+
content: "Error: Exactly one of selector, text, or duration is required.",
|
|
874
|
+
isError: true,
|
|
875
|
+
};
|
|
917
876
|
}
|
|
918
877
|
if (modeCount > 1) {
|
|
919
|
-
return {
|
|
878
|
+
return {
|
|
879
|
+
content:
|
|
880
|
+
"Error: Provide exactly one of selector, text, or duration (not multiple).",
|
|
881
|
+
isError: true,
|
|
882
|
+
};
|
|
920
883
|
}
|
|
921
884
|
|
|
922
|
-
const timeout =
|
|
923
|
-
|
|
924
|
-
|
|
885
|
+
const timeout =
|
|
886
|
+
typeof input.timeout === "number"
|
|
887
|
+
? Math.min(input.timeout, MAX_WAIT_MS)
|
|
888
|
+
: MAX_WAIT_MS;
|
|
925
889
|
|
|
926
890
|
try {
|
|
927
891
|
const page = await browserManager.getOrCreateSessionPage(context.sessionId);
|
|
928
892
|
|
|
929
893
|
if (selector) {
|
|
930
894
|
await page.waitForSelector(selector, { timeout });
|
|
931
|
-
return {
|
|
895
|
+
return {
|
|
896
|
+
content: `Element matching "${selector}" appeared.`,
|
|
897
|
+
isError: false,
|
|
898
|
+
};
|
|
932
899
|
}
|
|
933
900
|
|
|
934
901
|
if (text) {
|
|
@@ -937,7 +904,10 @@ export async function executeBrowserWaitFor(
|
|
|
937
904
|
`document.body?.innerText?.includes(${escaped})`,
|
|
938
905
|
{ timeout },
|
|
939
906
|
);
|
|
940
|
-
return {
|
|
907
|
+
return {
|
|
908
|
+
content: `Text "${truncate(text, 80)}" appeared on page.`,
|
|
909
|
+
isError: false,
|
|
910
|
+
};
|
|
941
911
|
}
|
|
942
912
|
|
|
943
913
|
// duration mode (milliseconds)
|
|
@@ -946,7 +916,7 @@ export async function executeBrowserWaitFor(
|
|
|
946
916
|
return { content: `Waited ${waitMs}ms.`, isError: false };
|
|
947
917
|
} catch (err) {
|
|
948
918
|
const msg = err instanceof Error ? err.message : String(err);
|
|
949
|
-
log.error({ err },
|
|
919
|
+
log.error({ err }, "Wait failed");
|
|
950
920
|
return { content: `Error: Wait failed: ${msg}`, isError: true };
|
|
951
921
|
}
|
|
952
922
|
}
|
|
@@ -969,14 +939,15 @@ export async function executeBrowserExtract(
|
|
|
969
939
|
)) as string;
|
|
970
940
|
|
|
971
941
|
if (textContent.length > MAX_EXTRACT_LENGTH) {
|
|
972
|
-
textContent =
|
|
942
|
+
textContent =
|
|
943
|
+
textContent.slice(0, MAX_EXTRACT_LENGTH) + "\n... (truncated)";
|
|
973
944
|
}
|
|
974
945
|
|
|
975
946
|
const lines: string[] = [
|
|
976
947
|
`URL: ${currentUrl}`,
|
|
977
|
-
`Title: ${title ||
|
|
978
|
-
|
|
979
|
-
textContent ||
|
|
948
|
+
`Title: ${title || "(none)"}`,
|
|
949
|
+
"",
|
|
950
|
+
textContent || "(empty page)",
|
|
980
951
|
];
|
|
981
952
|
|
|
982
953
|
if (includeLinks) {
|
|
@@ -991,18 +962,18 @@ export async function executeBrowserExtract(
|
|
|
991
962
|
`)) as Array<{ text: string; href: string }>;
|
|
992
963
|
|
|
993
964
|
if (links.length > 0) {
|
|
994
|
-
lines.push(
|
|
995
|
-
lines.push(
|
|
965
|
+
lines.push("");
|
|
966
|
+
lines.push("Links:");
|
|
996
967
|
for (const link of links) {
|
|
997
|
-
lines.push(` [${link.text ||
|
|
968
|
+
lines.push(` [${link.text || "(no text)"}](${link.href})`);
|
|
998
969
|
}
|
|
999
970
|
}
|
|
1000
971
|
}
|
|
1001
972
|
|
|
1002
|
-
return { content: lines.join(
|
|
973
|
+
return { content: lines.join("\n"), isError: false };
|
|
1003
974
|
} catch (err) {
|
|
1004
975
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1005
|
-
log.error({ err },
|
|
976
|
+
log.error({ err }, "Extract failed");
|
|
1006
977
|
return { content: `Error: Extract failed: ${msg}`, isError: true };
|
|
1007
978
|
}
|
|
1008
979
|
}
|
|
@@ -1013,14 +984,14 @@ export async function executeBrowserFillCredential(
|
|
|
1013
984
|
input: Record<string, unknown>,
|
|
1014
985
|
context: ToolContext,
|
|
1015
986
|
): Promise<ToolExecutionResult> {
|
|
1016
|
-
const service = typeof input.service ===
|
|
1017
|
-
const field = typeof input.field ===
|
|
987
|
+
const service = typeof input.service === "string" ? input.service : "";
|
|
988
|
+
const field = typeof input.field === "string" ? input.field : "";
|
|
1018
989
|
|
|
1019
990
|
if (!service) {
|
|
1020
|
-
return { content:
|
|
991
|
+
return { content: "Error: service is required.", isError: true };
|
|
1021
992
|
}
|
|
1022
993
|
if (!field) {
|
|
1023
|
-
return { content:
|
|
994
|
+
return { content: "Error: field is required.", isError: true };
|
|
1024
995
|
}
|
|
1025
996
|
|
|
1026
997
|
const { selector, error } = resolveSelector(context.sessionId, input);
|
|
@@ -1035,7 +1006,7 @@ export async function executeBrowserFillCredential(
|
|
|
1035
1006
|
let pageDomain: string | undefined;
|
|
1036
1007
|
try {
|
|
1037
1008
|
const pageUrl = page.url();
|
|
1038
|
-
if (pageUrl && pageUrl !==
|
|
1009
|
+
if (pageUrl && pageUrl !== "about:blank") {
|
|
1039
1010
|
const parsed = new URL(pageUrl);
|
|
1040
1011
|
pageDomain = parsed.hostname;
|
|
1041
1012
|
}
|
|
@@ -1046,7 +1017,7 @@ export async function executeBrowserFillCredential(
|
|
|
1046
1017
|
const result = await credentialBroker.browserFill({
|
|
1047
1018
|
service,
|
|
1048
1019
|
field,
|
|
1049
|
-
toolName:
|
|
1020
|
+
toolName: "browser_fill_credential",
|
|
1050
1021
|
domain: pageDomain,
|
|
1051
1022
|
fill: async (value) => {
|
|
1052
1023
|
await page.fill(selector!, value);
|
|
@@ -1054,37 +1025,49 @@ export async function executeBrowserFillCredential(
|
|
|
1054
1025
|
});
|
|
1055
1026
|
|
|
1056
1027
|
if (!result.success) {
|
|
1057
|
-
const reason = result.reason ??
|
|
1058
|
-
if (
|
|
1028
|
+
const reason = result.reason ?? "unknown error";
|
|
1029
|
+
if (
|
|
1030
|
+
reason.includes("No credential found") ||
|
|
1031
|
+
reason.includes("no stored value")
|
|
1032
|
+
) {
|
|
1059
1033
|
return {
|
|
1060
1034
|
content: `No credential stored for ${service}/${field}. Use credential_store to save it first.`,
|
|
1061
1035
|
isError: true,
|
|
1062
1036
|
};
|
|
1063
1037
|
}
|
|
1064
|
-
if (reason.includes(
|
|
1038
|
+
if (reason.includes("not allowed to use credential")) {
|
|
1065
1039
|
return {
|
|
1066
1040
|
content: `Policy denied: ${reason} Update the credential's allowed_tools via credential_store if this tool should have access.`,
|
|
1067
1041
|
isError: true,
|
|
1068
1042
|
};
|
|
1069
1043
|
}
|
|
1070
|
-
if (
|
|
1044
|
+
if (
|
|
1045
|
+
reason.includes("not allowed for credential") ||
|
|
1046
|
+
reason.includes("no page domain was provided")
|
|
1047
|
+
) {
|
|
1071
1048
|
return {
|
|
1072
1049
|
content: `Domain policy denied: ${reason} Navigate to an allowed domain before filling this credential.`,
|
|
1073
1050
|
isError: true,
|
|
1074
1051
|
};
|
|
1075
1052
|
}
|
|
1076
|
-
log.error({ selector, reason },
|
|
1077
|
-
return {
|
|
1053
|
+
log.error({ selector, reason }, "Fill credential failed");
|
|
1054
|
+
return {
|
|
1055
|
+
content: `Error: Fill credential failed: ${reason}`,
|
|
1056
|
+
isError: true,
|
|
1057
|
+
};
|
|
1078
1058
|
}
|
|
1079
1059
|
|
|
1080
1060
|
if (pressEnter) {
|
|
1081
|
-
await page.press(selector!,
|
|
1061
|
+
await page.press(selector!, "Enter");
|
|
1082
1062
|
}
|
|
1083
1063
|
|
|
1084
|
-
return {
|
|
1064
|
+
return {
|
|
1065
|
+
content: `Filled ${field} for ${service} into the target element.`,
|
|
1066
|
+
isError: false,
|
|
1067
|
+
};
|
|
1085
1068
|
} catch (err) {
|
|
1086
1069
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1087
|
-
log.error({ err },
|
|
1070
|
+
log.error({ err }, "Fill credential failed");
|
|
1088
1071
|
return { content: `Error: Fill credential failed: ${msg}`, isError: true };
|
|
1089
1072
|
}
|
|
1090
1073
|
}
|