dsh-builtin-browser 0.1.6 → 0.1.7

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.
@@ -0,0 +1,862 @@
1
+ /**
2
+ * Model-facing browser tools over `ctx.browser`: `browser_open`,
3
+ * `browser_snapshot`, `browser_execute`, `browser_content`,
4
+ * `browser_screenshot`, and tab management (`browser_list_tabs`,
5
+ * `browser_switch_tab`, `browser_close_tab`, `browser_reset`).
6
+ *
7
+ * The tool layer owns only the model-facing schema, argument validation, and
8
+ * result formatting — never provider selection or page driving, which belong
9
+ * to the seam. Session lifecycle is owned here at the plugin level: each
10
+ * calling task (a DSH session) gets its own browser session — the first
11
+ * `browser_open` (or any tool when no session exists) opens it, and later
12
+ * tools in the same task reuse it. Concurrent tasks therefore never fight
13
+ * over tabs, history, or navigation state.
14
+ * @module dsh-browser/tool-browser
15
+ */
16
+
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ import { defineTool } from '@deepseek-ai/dsh-tools'
19
+ import type {} from '@deepseek-ai/dsh-system-prompt'
20
+ import type { BrowserSessionId } from '../browser/types.ts'
21
+
22
+ /** Plugin name used by loader diagnostics. */
23
+ export const name = 'tool-browser'
24
+ /** The tool registry, browser seam, and system-prompt registry this tool layer consumes. */
25
+ export const inject = ['tools', 'browser', 'systemPrompt']
26
+
27
+ /** Plugin config: tool timeouts and session defaults. */
28
+ export interface Config {
29
+ /** Cooperative tool-call budget in ms. Default 60000. */
30
+ readonly timeoutMs?: number
31
+ /** Whether to offer tab-management tools. Default true. */
32
+ readonly tabTools?: boolean
33
+ /** Optional initial allow-list of browser tool names; other tools are refused. */
34
+ readonly allowedActions?: readonly string[]
35
+ }
36
+
37
+ /** Per-task browser sessions, keyed by the calling DSH session id. */
38
+ const sessionsByTask = new Map<string, BrowserSessionId>()
39
+
40
+ /**
41
+ * Action restriction state: an allow-list of browser tool names, or undefined
42
+ * for unrestricted. When set, any browser_* tool not in the list is refused
43
+ * (browser_restrict itself is always allowed so the guard can be lifted).
44
+ */
45
+ let restrictedTo: readonly string[] | undefined
46
+
47
+ /**
48
+ * Guard one browser tool call against the active restriction. Refuses calls
49
+ * not on the allow-list when a restriction is in effect.
50
+ * @param toolName - the browser tool about to run.
51
+ */
52
+ function assertAllowed(toolName: string): void {
53
+ if (restrictedTo === undefined) return
54
+ if (restrictedTo.includes(toolName)) return
55
+ throw new Error(`browser action "${toolName}" is restricted (allow-list: ${restrictedTo.join(', ')})`)
56
+ }
57
+
58
+ /**
59
+ * The task key for a tool call: the calling DSH session id, or the shared
60
+ * default key when the call carries no agent context (CLI probes, tests).
61
+ * @param exec - the tool-execution context; only its optional agent id is read.
62
+ */
63
+ function taskKey(exec: { agent?: { id?: string } } | undefined): string {
64
+ return exec?.agent?.id ?? 'default'
65
+ }
66
+
67
+ /**
68
+ * Resolve the calling task's browser session, opening one on first use.
69
+ * @param browser - the seam service.
70
+ * @param key - the task key (see {@link taskKey}).
71
+ * @returns the task's session id.
72
+ */
73
+ async function ensureSession(browser: NonNullable<Context['browser']>, key: string): Promise<BrowserSessionId> {
74
+ let session = sessionsByTask.get(key)
75
+ if (session === undefined) {
76
+ session = await browser.open()
77
+ sessionsByTask.set(key, session)
78
+ }
79
+ return session
80
+ }
81
+
82
+ /** Coerce a tool-provided string value back to boolean/number when it looks like one. */
83
+ function parseFillValue(v: string | undefined): string | number | boolean {
84
+ if (v === 'true') return true
85
+ if (v === 'false') return false
86
+ if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v)) return Number(v)
87
+ return v ?? ''
88
+ }
89
+
90
+ /** Format a snapshot element list for the model. */
91
+ function formatSnapshot(snapshot: {
92
+ url: string
93
+ title?: string
94
+ elements: readonly { ref: number; kind: string; label: string; x: number; y: number }[]
95
+ truncated?: boolean
96
+ challenge?: { blocked: boolean; kind?: string; reason?: string }
97
+ }): string {
98
+ const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label} (${el.x},${el.y})`)
99
+ const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`
100
+ const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)'
101
+ const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : ''
102
+ const banner = snapshot.challenge?.blocked === true
103
+ ? `\n\nCHALLENGE: ${snapshot.challenge.reason ?? 'human-verification'}. Do NOT keep retrying — ask the human to complete it in the shared browser window, then re-snapshot.`
104
+ : ''
105
+ return `${header}\n\n${body}${tail}${banner}`
106
+ }
107
+
108
+ /** Register all browser tools with `ctx.tools`. */
109
+ export function apply(ctx: Context, config: Config = {}): void {
110
+ const timeoutMs = config.timeoutMs ?? 60_000
111
+ if (config.allowedActions !== undefined) restrictedTo = [...config.allowedActions]
112
+
113
+ ctx.systemPrompt.section({
114
+ name: 'tool:browser',
115
+ // Tool guidance band is 100-199; 150 keeps clear of the common 110/120
116
+ // tool sections so ordering does not depend on plugin load sequence.
117
+ order: 150,
118
+ text: 'Use the browser_* tools to operate a real shared browser the human can see and take over. Locate elements by snapshot reference numbers (browser_snapshot) and drive them with browser_execute (DOM-referenced JS, native setters for framework inputs). For form filling prefer browser_fill, which handles controlled inputs, selects, checkboxes and radio groups in one batch. browser_screenshot is for visual confirmation, not primary targeting. Keep the human informed of what you are doing on the page. Each task gets its own browser session: your tabs and history are isolated from other tasks, so do not assume another task\'s navigation state is visible to you. If a snapshot or browser_challenge reports a human-verification challenge (CAPTCHA), stop retrying and ask the human to complete it in the shared browser window, then re-check.',
119
+ })
120
+
121
+ ctx.tools.register(defineTool({
122
+ name: 'browser_open',
123
+ description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot.',
124
+ parameters: {
125
+ url: { type: 'string', required: true, description: 'The URL to open (HTTP/HTTPS).' },
126
+ newTab: { type: 'boolean', description: 'Open in a new tab instead of the active one.' },
127
+ },
128
+ output: {
129
+ schema: {
130
+ type: 'object',
131
+ additionalProperties: false,
132
+ properties: {
133
+ url: { type: 'string', required: true },
134
+ title: { type: 'string' },
135
+ elements: {
136
+ type: 'array',
137
+ required: true,
138
+ items: {
139
+ type: 'object',
140
+ additionalProperties: false,
141
+ properties: {
142
+ ref: { type: 'number', required: true },
143
+ kind: { type: 'string', required: true },
144
+ label: { type: 'string', required: true },
145
+ x: { type: 'number', required: true },
146
+ y: { type: 'number', required: true },
147
+ },
148
+ },
149
+ },
150
+ challenge: {
151
+ type: 'object',
152
+ additionalProperties: false,
153
+ properties: {
154
+ blocked: { type: 'boolean', required: true },
155
+ kind: { type: 'string' },
156
+ reason: { type: 'string' },
157
+ },
158
+ },
159
+ },
160
+ },
161
+ render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
162
+ },
163
+ timeoutMs,
164
+ isConcurrencySafe: () => true,
165
+ async execute(args, exec) {
166
+ assertAllowed('browser_open')
167
+ const browser = ctx.get('browser')
168
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
169
+ const session = await ensureSession(browser, taskKey(exec))
170
+ await browser.openUrl(session, {
171
+ url: args.url,
172
+ ...args.newTab === true ? { newTab: true } : {},
173
+ }, exec.signal)
174
+ const snapshot = await browser.snapshot(session, exec.signal)
175
+ return {
176
+ url: snapshot.url,
177
+ ...snapshot.title !== undefined ? { title: snapshot.title } : {},
178
+ elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y })),
179
+ truncated: snapshot.truncated,
180
+ ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
181
+ }
182
+ },
183
+ }))
184
+
185
+ ctx.tools.register(defineTool({
186
+ name: 'browser_snapshot',
187
+ description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite. Use this to understand an interactive page before driving it.',
188
+ parameters: {},
189
+ output: {
190
+ schema: {
191
+ type: 'object',
192
+ additionalProperties: false,
193
+ properties: {
194
+ url: { type: 'string', required: true },
195
+ title: { type: 'string' },
196
+ elements: {
197
+ type: 'array',
198
+ required: true,
199
+ items: {
200
+ type: 'object',
201
+ additionalProperties: false,
202
+ properties: {
203
+ ref: { type: 'number', required: true },
204
+ kind: { type: 'string', required: true },
205
+ label: { type: 'string', required: true },
206
+ x: { type: 'number', required: true },
207
+ y: { type: 'number', required: true },
208
+ },
209
+ },
210
+ },
211
+ challenge: {
212
+ type: 'object',
213
+ additionalProperties: false,
214
+ properties: {
215
+ blocked: { type: 'boolean', required: true },
216
+ kind: { type: 'string' },
217
+ reason: { type: 'string' },
218
+ },
219
+ },
220
+ },
221
+ },
222
+ render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
223
+ },
224
+ timeoutMs,
225
+ isConcurrencySafe: () => true,
226
+ async execute(_args, exec) {
227
+ const browser = ctx.get('browser')
228
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
229
+ const session = await ensureSession(browser, taskKey(exec))
230
+ const snapshot = await browser.snapshot(session, exec.signal)
231
+ return {
232
+ url: snapshot.url,
233
+ ...snapshot.title !== undefined ? { title: snapshot.title } : {},
234
+ elements: snapshot.elements.map(el => ({ ref: el.ref, kind: el.kind, label: el.label, x: el.x, y: el.y })),
235
+ truncated: snapshot.truncated,
236
+ ...snapshot.challenge !== undefined ? { challenge: snapshot.challenge } : {},
237
+ }
238
+ },
239
+ }))
240
+
241
+ ctx.tools.register(defineTool({
242
+ name: 'browser_challenge',
243
+ description: 'Check whether a human-verification challenge (CAPTCHA / bot detection: Cloudflare "Just a moment", reCAPTCHA, hCaptcha, Turnstile) is blocking the current page. When blocked, do NOT keep retrying automated steps — ask the human to complete the verification in the shared browser window, then re-check with browser_snapshot.',
244
+ parameters: {},
245
+ output: {
246
+ schema: {
247
+ type: 'object',
248
+ additionalProperties: false,
249
+ properties: {
250
+ blocked: { type: 'boolean', required: true },
251
+ kind: { type: 'string' },
252
+ reason: { type: 'string' },
253
+ hint: { type: 'string' },
254
+ },
255
+ },
256
+ render: (_args, value) => [{
257
+ type: 'text',
258
+ text: value.blocked
259
+ ? `Challenge detected: ${value.reason ?? value.kind ?? 'human-verification'}. ${value.hint ?? ''}`
260
+ : 'No human-verification challenge detected.',
261
+ }],
262
+ },
263
+ timeoutMs,
264
+ isConcurrencySafe: () => true,
265
+ async execute(_args, exec) {
266
+ const browser = ctx.get('browser')
267
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
268
+ const session = await ensureSession(browser, taskKey(exec))
269
+ const challenge = await browser.detectChallenge(session, exec.signal)
270
+ return {
271
+ blocked: challenge.blocked,
272
+ ...challenge.kind !== undefined ? { kind: challenge.kind } : {},
273
+ ...challenge.reason !== undefined ? { reason: challenge.reason } : {},
274
+ hint: challenge.blocked
275
+ ? 'Ask the human to complete the verification in the shared browser window (the page is visible to them), then re-check with browser_snapshot.'
276
+ : '',
277
+ }
278
+ },
279
+ }))
280
+
281
+ ctx.tools.register(defineTool({
282
+ name: 'browser_execute',
283
+ description: 'Execute JavaScript in the shared-browser page context. This is the primary way to interact with page elements: focus, fill inputs (use the native value setter for framework-controlled inputs, then dispatch an input event), click buttons (element.click() or a constructed MouseEvent). Returns the evaluation result by value, or the exception text.',
284
+ parameters: {
285
+ script: { type: 'string', required: true, description: 'The JavaScript expression to evaluate in the page context.' },
286
+ args: { type: 'array', items: { type: 'string' }, description: 'Optional arguments injected into the script scope as arguments[0..n].' },
287
+ },
288
+ output: {
289
+ schema: {
290
+ type: 'object',
291
+ additionalProperties: false,
292
+ properties: {
293
+ ok: { type: 'boolean', required: true },
294
+ value: { type: 'string' },
295
+ exception: { type: 'string' },
296
+ },
297
+ },
298
+ render: (_args, value) => [{ type: 'text', text: value.ok ? `Result: ${String(value.value)}` : `Exception: ${value.exception}` }],
299
+ },
300
+ timeoutMs,
301
+ isConcurrencySafe: () => false, // page JS can be stateful
302
+ async execute(args, exec) {
303
+ assertAllowed('browser_execute')
304
+ const browser = ctx.get('browser')
305
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
306
+ const session = await ensureSession(browser, taskKey(exec))
307
+ const result = await browser.execute(session, {
308
+ script: args.script,
309
+ args: args.args ?? [],
310
+ }, exec.signal)
311
+ if (result.ok) {
312
+ const raw = result.value
313
+ const value = typeof raw === 'string' ? raw : JSON.stringify(raw ?? null)
314
+ return { ok: true, value }
315
+ }
316
+ return { ok: false, exception: result.exception }
317
+ },
318
+ }))
319
+
320
+ ctx.tools.register(defineTool({
321
+ name: 'browser_content',
322
+ description: 'Fetch the current shared-browser page content in a chosen format: html (raw DOM), markdown (structured reading), txt (plain text), or json. Optionally scope to a CSS selector and cap the length. Use this to read page content, not to interact.',
323
+ parameters: {
324
+ format: { type: 'string', required: true, enum: ['html', 'markdown', 'txt', 'json'], description: 'Output format.' },
325
+ selector: { type: 'string', description: 'CSS selector limiting the fetch to one region (e.g. #main).' },
326
+ maxChars: { type: 'number', description: 'Maximum characters of returned content.' },
327
+ timeoutMs: { type: 'number', description: 'Evaluation timeout in ms (default 30000).' },
328
+ },
329
+ output: {
330
+ schema: {
331
+ type: 'object',
332
+ additionalProperties: false,
333
+ properties: {
334
+ content: { type: 'string', required: true },
335
+ truncated: { type: 'boolean', required: true },
336
+ },
337
+ },
338
+ render: (_args, value) => [{ type: 'text', text: value.content + (value.truncated ? '\n(truncated)' : '') }],
339
+ },
340
+ timeoutMs,
341
+ isConcurrencySafe: () => true,
342
+ async execute(args, exec) {
343
+ const browser = ctx.get('browser')
344
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
345
+ const session = await ensureSession(browser, taskKey(exec))
346
+ const result = await browser.content(session, {
347
+ format: args.format,
348
+ ...args.selector !== undefined ? { selector: args.selector } : {},
349
+ ...args.maxChars !== undefined ? { maxChars: args.maxChars } : {},
350
+ ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
351
+ }, exec.signal)
352
+ return { content: result.content, truncated: result.truncated }
353
+ },
354
+ }))
355
+
356
+ ctx.tools.register(defineTool({
357
+ name: 'browser_click',
358
+ description: 'Click at viewport coordinates (CSS pixels) in the shared browser. Use with browser_screenshot: have a vision model locate an element on the screenshot, then click its coordinates — this covers icons, image buttons, and canvas elements that DOM snapshots cannot target. Coordinates are relative to the visible viewport, same as the screenshot.',
359
+ parameters: {
360
+ x: { type: 'number', required: true, description: 'Viewport x coordinate (CSS px), e.g. from a vision model reading the screenshot.' },
361
+ y: { type: 'number', required: true, description: 'Viewport y coordinate (CSS px).' },
362
+ },
363
+ output: {
364
+ schema: { type: 'object', additionalProperties: false, properties: { clicked: { type: 'boolean', required: true } } },
365
+ render: (_args, value) => [{ type: 'text', text: value.clicked ? 'Clicked.' : 'Click failed.' }],
366
+ },
367
+ timeoutMs,
368
+ isConcurrencySafe: () => false,
369
+ async execute(args, exec) {
370
+ assertAllowed('browser_click')
371
+ const browser = ctx.get('browser')
372
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
373
+ const session = await ensureSession(browser, taskKey(exec))
374
+ await browser.click(session, { x: args.x, y: args.y }, exec.signal)
375
+ return { clicked: true }
376
+ },
377
+ }))
378
+
379
+ ctx.tools.register(defineTool({
380
+ name: 'browser_type',
381
+ description: 'Type text into the focused element of the shared browser. Use after browser_execute focuses an input (e.g. el.focus()), or after a click lands in a field. Text is inserted at the current focus via CDP Input.insertText.',
382
+ parameters: {
383
+ text: { type: 'string', required: true, description: 'The text to insert.' },
384
+ },
385
+ output: {
386
+ schema: { type: 'object', additionalProperties: false, properties: { typed: { type: 'boolean', required: true } } },
387
+ render: (_args, value) => [{ type: 'text', text: value.typed ? `Typed ${String(_args.text).length} chars.` : 'Type failed.' }],
388
+ },
389
+ timeoutMs,
390
+ isConcurrencySafe: () => false,
391
+ async execute(args, exec) {
392
+ assertAllowed('browser_type')
393
+ const browser = ctx.get('browser')
394
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
395
+ const session = await ensureSession(browser, taskKey(exec))
396
+ await browser.type(session, { text: args.text }, exec.signal)
397
+ return { typed: true }
398
+ },
399
+ }))
400
+
401
+ ctx.tools.register(defineTool({
402
+ name: 'browser_fill',
403
+ description: 'Fill a form in one batch: pass fields with a CSS selector or name/label/placeholder text and the value to set (string, number, or boolean for checkbox/radio; for selects or radio groups pass the option value or visible text). Values are applied with the native setter plus input/change events, so React/Vue controlled inputs update correctly. Optionally submit the containing form. Prefer this over hand-written browser_execute for form filling; per-field failures are reported instead of throwing.',
404
+ parameters: {
405
+ fields: {
406
+ type: 'array',
407
+ required: true,
408
+ items: {
409
+ type: 'object',
410
+ additionalProperties: true,
411
+ properties: {
412
+ selector: { type: 'string', description: 'CSS selector; when present, candidates are scoped to it.' },
413
+ name: { type: 'string', description: 'Match by the field\'s name attribute.' },
414
+ label: { type: 'string', description: 'Match by associated <label> text or aria-label.' },
415
+ placeholder: { type: 'string', description: 'Match by placeholder text.' },
416
+ kind: { type: 'string', enum: ['text', 'textarea', 'checkbox', 'radio', 'select'], description: 'Field kind; defaults to text.' },
417
+ value: { type: 'string', description: 'Value to set (string form; booleans/numbers accepted as strings).' },
418
+ },
419
+ },
420
+ },
421
+ submit: { type: 'boolean', description: 'Submit the containing form after filling (default false).' },
422
+ },
423
+ output: {
424
+ schema: {
425
+ type: 'object',
426
+ additionalProperties: false,
427
+ properties: {
428
+ fields: {
429
+ type: 'array',
430
+ required: true,
431
+ items: {
432
+ type: 'object',
433
+ additionalProperties: false,
434
+ properties: {
435
+ ok: { type: 'boolean', required: true },
436
+ target: { type: 'string', required: true },
437
+ method: { type: 'string' },
438
+ error: { type: 'string' },
439
+ },
440
+ },
441
+ },
442
+ submitted: { type: 'boolean', required: true },
443
+ },
444
+ },
445
+ render: (_args, value) => [{
446
+ type: 'text',
447
+ text: (() => {
448
+ const fields = value.fields as { ok: boolean; target: string; method?: string; error?: string }[]
449
+ const failed = fields.filter(f => !f.ok)
450
+ const lines = fields.map(f => `${f.ok ? 'OK' : 'FAIL'} ${f.target}${f.ok ? ` (${f.method ?? 'input'})` : `: ${f.error ?? 'unknown error'}`}`)
451
+ const head = failed.length === 0
452
+ ? `Filled ${fields.length}/${fields.length} fields${value.submitted ? ' and submitted the form' : ''}.`
453
+ : `Filled ${fields.length - failed.length}/${fields.length} fields; ${failed.length} failed:`
454
+ return head + '\n' + lines.join('\n')
455
+ })(),
456
+ }],
457
+ },
458
+ timeoutMs,
459
+ isConcurrencySafe: () => false,
460
+ async execute(args, exec) {
461
+ assertAllowed('browser_fill')
462
+ const browser = ctx.get('browser')
463
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
464
+ const session = await ensureSession(browser, taskKey(exec))
465
+ const fields = (args.fields ?? []).map((f: { selector?: string; name?: string; label?: string; placeholder?: string; kind?: string; value?: string }) => ({
466
+ ...f.selector !== undefined ? { selector: f.selector } : {},
467
+ ...f.name !== undefined ? { name: f.name } : {},
468
+ ...f.label !== undefined ? { label: f.label } : {},
469
+ ...f.placeholder !== undefined ? { placeholder: f.placeholder } : {},
470
+ ...f.kind !== undefined ? { kind: f.kind as 'text' | 'textarea' | 'checkbox' | 'radio' | 'select' } : {},
471
+ value: parseFillValue(f.value),
472
+ }))
473
+ const result = await browser.fillForm(session, {
474
+ fields,
475
+ ...args.submit === true ? { submit: true } : {},
476
+ }, exec.signal)
477
+ return {
478
+ fields: result.fields.map(f => ({ ok: f.ok, target: f.target, ...f.method !== undefined ? { method: f.method } : {}, ...f.error !== undefined ? { error: f.error } : {} })),
479
+ submitted: result.submitted,
480
+ }
481
+ },
482
+ }))
483
+
484
+ ctx.tools.register(defineTool({
485
+ name: 'browser_screenshot',
486
+ description: 'Capture the current shared-browser page as a PNG screenshot. Use for visual confirmation of layout, charts, designs, or CAPTCHAs, or to feed a vision tool (read_image) that locates elements visually. Supports optional full-page capture and optional save-to-file (the saved path can be passed to read_image for vision-based element location).',
487
+ parameters: {
488
+ fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
489
+ savePath: { type: 'string', description: 'Absolute file path to also save the PNG to (e.g. for read_image vision location).' },
490
+ },
491
+ output: {
492
+ schema: {
493
+ type: 'object',
494
+ additionalProperties: false,
495
+ properties: {
496
+ dataUrl: { type: 'string', required: true, description: 'Base64 PNG data URL of the screenshot.' },
497
+ path: { type: 'string', description: 'The file path the screenshot was saved to, when savePath was given.' },
498
+ },
499
+ },
500
+ render: (_args, value) => [{ type: 'text', text: `Screenshot captured (${Math.round(value.dataUrl.length * 3 / 4 / 1024)} KiB)${value.path !== undefined ? ` saved to ${value.path}` : ''}.` }],
501
+ },
502
+ timeoutMs,
503
+ isConcurrencySafe: () => true,
504
+ async execute(args, exec) {
505
+ const browser = ctx.get('browser')
506
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
507
+ const session = await ensureSession(browser, taskKey(exec))
508
+ const shot = await browser.screenshot(session, {
509
+ ...args.fullPage === true ? { fullPage: true } : {},
510
+ ...args.savePath !== undefined ? { savePath: args.savePath } : {},
511
+ }, exec.signal)
512
+ return {
513
+ dataUrl: shot.dataUrl,
514
+ ...shot.path !== undefined ? { path: shot.path } : {},
515
+ }
516
+ },
517
+ }))
518
+
519
+ if (config.tabTools !== false) {
520
+ ctx.tools.register(defineTool({
521
+ name: 'browser_list_tabs',
522
+ description: 'List the shared-browser session\'s tabs with their URLs and which is active.',
523
+ parameters: {},
524
+ output: {
525
+ schema: {
526
+ type: 'object',
527
+ additionalProperties: false,
528
+ properties: {
529
+ tabs: {
530
+ type: 'array',
531
+ required: true,
532
+ items: {
533
+ type: 'object',
534
+ additionalProperties: false,
535
+ properties: {
536
+ id: { type: 'string', required: true },
537
+ url: { type: 'string', required: true },
538
+ active: { type: 'boolean', required: true },
539
+ },
540
+ },
541
+ },
542
+ },
543
+ },
544
+ render: (_args, value) => [{
545
+ type: 'text',
546
+ text: (value.tabs as { id: string; url: string; active: boolean }[])
547
+ .map(t => `${t.active ? '*' : ' '} ${t.id} ${t.url}`).join('\n'),
548
+ }],
549
+ },
550
+ timeoutMs,
551
+ isConcurrencySafe: () => true,
552
+ async execute(_args, exec) {
553
+ const browser = ctx.get('browser')
554
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
555
+ const session = await ensureSession(browser, taskKey(exec))
556
+ const tabs = await browser.listTabs(session)
557
+ return { tabs: tabs.map(t => ({ id: t.id, url: t.url, active: t.active })) }
558
+ },
559
+ }))
560
+
561
+ ctx.tools.register(defineTool({
562
+ name: 'browser_switch_tab',
563
+ description: 'Switch the shared browser to a tab by id (from browser_list_tabs).',
564
+ parameters: {
565
+ tabId: { type: 'string', required: true, description: 'The tab id to switch to.' },
566
+ },
567
+ output: {
568
+ schema: { type: 'object', additionalProperties: false, properties: { switched: { type: 'boolean', required: true } } },
569
+ render: (_args, value) => [{ type: 'text', text: value.switched ? 'Switched.' : 'Tab not found.' }],
570
+ },
571
+ timeoutMs,
572
+ isConcurrencySafe: () => true,
573
+ async execute(args, exec) {
574
+ const browser = ctx.get('browser')
575
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
576
+ const session = await ensureSession(browser, taskKey(exec))
577
+ await browser.switchTab(session, args.tabId)
578
+ return { switched: true }
579
+ },
580
+ }))
581
+
582
+ ctx.tools.register(defineTool({
583
+ name: 'browser_close_tab',
584
+ description: 'Close a tab in the shared browser by id. Closing the active tab activates the next.',
585
+ parameters: {
586
+ tabId: { type: 'string', required: true, description: 'The tab id to close.' },
587
+ },
588
+ output: {
589
+ schema: { type: 'object', additionalProperties: false, properties: { closed: { type: 'boolean', required: true } } },
590
+ render: (_args, value) => [{ type: 'text', text: value.closed ? 'Closed.' : 'Tab not found.' }],
591
+ },
592
+ timeoutMs,
593
+ isConcurrencySafe: () => true,
594
+ async execute(args, exec) {
595
+ const browser = ctx.get('browser')
596
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
597
+ const session = await ensureSession(browser, taskKey(exec))
598
+ await browser.closeTab(session, args.tabId)
599
+ return { closed: true }
600
+ },
601
+ }))
602
+
603
+ ctx.tools.register(defineTool({
604
+ name: 'browser_reset',
605
+ description: 'Close every tab in the shared browser and start fresh with one blank tab.',
606
+ parameters: {},
607
+ output: {
608
+ schema: { type: 'object', additionalProperties: false, properties: { reset: { type: 'boolean', required: true } } },
609
+ render: (_args, value) => [{ type: 'text', text: value.reset ? 'Browser reset.' : 'Failed.' }],
610
+ },
611
+ timeoutMs,
612
+ isConcurrencySafe: () => true,
613
+ async execute(_args, exec) {
614
+ assertAllowed('browser_reset')
615
+ const browser = ctx.get('browser')
616
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
617
+ const session = await ensureSession(browser, taskKey(exec))
618
+ await browser.reset(session)
619
+ return { reset: true }
620
+ },
621
+ }))
622
+ }
623
+
624
+ ctx.tools.register(defineTool({
625
+ name: 'browser_history',
626
+ description: 'List the shared browser session\'s recorded operation history (navigate/execute/click/type), newest last, with per-step success/error. Use to understand what the agent did and to pick a step to replay.',
627
+ parameters: {},
628
+ output: {
629
+ schema: {
630
+ type: 'object',
631
+ additionalProperties: false,
632
+ properties: {
633
+ entries: {
634
+ type: 'array',
635
+ required: true,
636
+ items: {
637
+ type: 'object',
638
+ additionalProperties: false,
639
+ properties: {
640
+ seq: { type: 'number', required: true },
641
+ action: { type: 'string', required: true },
642
+ ok: { type: 'boolean', required: true },
643
+ params: { type: 'object', additionalProperties: true, required: true },
644
+ result: { type: 'string' },
645
+ error: { type: 'string' },
646
+ },
647
+ },
648
+ },
649
+ },
650
+ },
651
+ render: (_args, value) => {
652
+ const entries = value.entries as Array<{ seq: number; action: string; ok: boolean; params: Record<string, unknown>; result?: string; error?: string }>
653
+ if (entries.length === 0) return [{ type: 'text', text: '(no recorded operations yet)' }]
654
+ return [{
655
+ type: 'text',
656
+ text: entries.map(e =>
657
+ `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${JSON.stringify(e.params)}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`,
658
+ ).join('\n'),
659
+ }]
660
+ },
661
+ },
662
+ timeoutMs,
663
+ isConcurrencySafe: () => true,
664
+ async execute(_args, exec) {
665
+ const browser = ctx.get('browser')
666
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
667
+ const session = await ensureSession(browser, taskKey(exec))
668
+ const entries = await browser.history(session)
669
+ const rendered = entries.map(e => {
670
+ const row: { seq: number; action: string; ok: boolean; params: unknown; result?: string; error?: string } = {
671
+ seq: e.seq,
672
+ action: e.action,
673
+ ok: e.ok,
674
+ params: JSON.parse(JSON.stringify(e.params)),
675
+ }
676
+ if (e.result !== undefined) row.result = e.result
677
+ if (e.error !== undefined) row.error = e.error
678
+ return row
679
+ })
680
+ return { entries: rendered as never }
681
+ },
682
+ }))
683
+
684
+ ctx.tools.register(defineTool({
685
+ name: 'browser_replay',
686
+ description: 'Replay one recorded browser operation by its history sequence number (from browser_history). Navigate/click/type are re-issued against the current page; execute re-runs its script. The replayed step is appended to history as a new entry.',
687
+ parameters: {
688
+ seq: { type: 'number', required: true, description: 'The history entry sequence number to replay.' },
689
+ },
690
+ output: {
691
+ schema: { type: 'object', additionalProperties: false, properties: { replayed: { type: 'boolean', required: true } } },
692
+ render: (_args, value) => [{ type: 'text', text: value.replayed ? 'Replayed.' : 'Replay failed.' }],
693
+ },
694
+ timeoutMs,
695
+ isConcurrencySafe: () => false,
696
+ async execute(args, exec) {
697
+ assertAllowed('browser_replay')
698
+ const browser = ctx.get('browser')
699
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
700
+ const session = await ensureSession(browser, taskKey(exec))
701
+ await browser.replay(session, args.seq)
702
+ return { replayed: true }
703
+ },
704
+ }))
705
+
706
+ ctx.tools.register(defineTool({
707
+ name: 'browser_download',
708
+ description: 'Download a URL to a local file, keeping the browser session\'s cookies and login state. Use for fetching files behind authentication or from the current page context. Available on the self-hosted browser; the desktop shell delegates downloads to the real browser UI.',
709
+ parameters: {
710
+ url: { type: 'string', required: true, description: 'The URL to download.' },
711
+ savePath: { type: 'string', required: true, description: 'Absolute path of the file to write.' },
712
+ },
713
+ output: {
714
+ schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true } } },
715
+ render: (_args, value) => [{ type: 'text', text: `Downloaded to ${value.path}.` }],
716
+ },
717
+ timeoutMs,
718
+ isConcurrencySafe: () => false,
719
+ async execute(args, exec) {
720
+ assertAllowed('browser_download')
721
+ const browser = ctx.get('browser')
722
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
723
+ const session = await ensureSession(browser, taskKey(exec))
724
+ const result = await browser.download(session, { url: args.url, savePath: args.savePath }, exec.signal)
725
+ return { path: result.path }
726
+ },
727
+ }))
728
+
729
+ ctx.tools.register(defineTool({
730
+ name: 'browser_session',
731
+ description: 'Show THIS task\'s browser session: its id and open tabs. Each task (DSH session) has its own browser session, so this reflects what your task drives. The window is shared with the human and other tasks, but tab sets and history are isolated per task.',
732
+ parameters: {},
733
+ output: {
734
+ schema: {
735
+ type: 'object',
736
+ additionalProperties: false,
737
+ properties: {
738
+ session: { type: 'string', required: true },
739
+ tabs: {
740
+ type: 'array',
741
+ required: true,
742
+ items: {
743
+ type: 'object',
744
+ additionalProperties: false,
745
+ properties: {
746
+ id: { type: 'string', required: true },
747
+ url: { type: 'string', required: true },
748
+ active: { type: 'boolean', required: true },
749
+ },
750
+ },
751
+ },
752
+ },
753
+ },
754
+ render: (_args, value) => [{
755
+ type: 'text',
756
+ text: `Session ${value.session}\n${(value.tabs as { id: string; url: string; active: boolean }[]).map(t => `${t.active ? '*' : ' '} ${t.id} ${t.url}`).join('\n')}`,
757
+ }],
758
+ },
759
+ timeoutMs,
760
+ isConcurrencySafe: () => true,
761
+ async execute(_args, exec) {
762
+ const browser = ctx.get('browser')
763
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
764
+ const key = taskKey(exec)
765
+ const session = sessionsByTask.get(key) ?? await browser.open()
766
+ sessionsByTask.set(key, session)
767
+ const tabs = await browser.listTabs(session)
768
+ return { session, tabs: tabs.map(t => ({ id: t.id, url: t.url, active: t.active })) }
769
+ },
770
+ }))
771
+
772
+ ctx.tools.register(defineTool({
773
+ name: 'browser_reset_session',
774
+ description: 'Reset THIS task\'s browser session: close it entirely so the next browser_* call starts a fresh session with one blank tab. Other tasks\' sessions are untouched. Use when a session is in a bad state or you want a clean slate.',
775
+ parameters: {},
776
+ output: {
777
+ schema: { type: 'object', additionalProperties: false, properties: { reset: { type: 'boolean', required: true } } },
778
+ render: (_args, value) => [{ type: 'text', text: value.reset ? 'This task\'s browser session was closed; the next call starts fresh.' : 'Failed.' }],
779
+ },
780
+ timeoutMs,
781
+ isConcurrencySafe: () => true,
782
+ async execute(_args, exec) {
783
+ assertAllowed('browser_reset_session')
784
+ const browser = ctx.get('browser')
785
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
786
+ const key = taskKey(exec)
787
+ const session = sessionsByTask.get(key)
788
+ if (session !== undefined) {
789
+ await browser.close(session)
790
+ sessionsByTask.delete(key)
791
+ }
792
+ return { reset: true }
793
+ },
794
+ }))
795
+
796
+ ctx.tools.register(defineTool({
797
+ name: 'browser_restrict',
798
+ description: 'Restrict which browser actions are allowed, to prevent stray clicks/navigation. Pass a list of browser tool names (e.g. ["browser_snapshot","browser_content","browser_click"]) — any other browser_* call is refused. Pass an empty list or omit to lift the restriction. Read-only tools (snapshot/content/screenshot/session) are never blocked.',
799
+ parameters: {
800
+ allowed: {
801
+ type: 'array',
802
+ items: { type: 'string' },
803
+ description: 'Allow-list of browser tool names; empty clears the restriction.',
804
+ },
805
+ },
806
+ output: {
807
+ schema: { type: 'object', additionalProperties: false, properties: { restrictedTo: { type: 'array', required: true, items: { type: 'string' } } } },
808
+ render: (_args, value) => [{ type: 'text', text: (value.restrictedTo as string[]).length > 0 ? `Restricted to: ${(value.restrictedTo as string[]).join(', ')}` : 'Restriction lifted.' }],
809
+ },
810
+ timeoutMs,
811
+ isConcurrencySafe: () => false,
812
+ async execute(args, exec) {
813
+ // Always allowed so the guard can be lifted.
814
+ restrictedTo = (args.allowed ?? []).filter((t: string) => t.startsWith('browser_'))
815
+ return { restrictedTo: [...restrictedTo] }
816
+ },
817
+ }))
818
+
819
+ ctx.tools.register(defineTool({
820
+ name: 'browser_auth',
821
+ description: 'Export or restore the browser session\'s cookies (login state). Use "flush" to get a JSON cookie list (save it to a file to persist logins), or "restore" with that list to put logins back (e.g. after the browser host restarted). Available on the self-hosted browser.',
822
+ parameters: {
823
+ action: { type: 'string', required: true, enum: ['flush', 'restore'], description: 'flush = export cookies; restore = import cookies.' },
824
+ cookies: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Cookie list to restore (required when action=restore).' },
825
+ },
826
+ output: {
827
+ schema: {
828
+ type: 'object',
829
+ additionalProperties: false,
830
+ properties: {
831
+ cookies: { type: 'array', items: { type: 'object', additionalProperties: true } },
832
+ restored: { type: 'number' },
833
+ },
834
+ },
835
+ render: (_args, value) => [{ type: 'text', text: value.cookies !== undefined ? `Exported ${(value.cookies as unknown[]).length} cookies.` : `Restored ${value.restored} cookies.` }],
836
+ },
837
+ timeoutMs,
838
+ isConcurrencySafe: () => false,
839
+ async execute(args, exec) {
840
+ assertAllowed('browser_auth')
841
+ const browser = ctx.get('browser')
842
+ if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
843
+ const session = await ensureSession(browser, taskKey(exec))
844
+ if (args.action === 'flush') {
845
+ const cookies = await browser.flushAuth(session)
846
+ return { cookies: cookies.map(c => ({ ...c })) as never }
847
+ }
848
+ const list = (args.cookies ?? []) as unknown[]
849
+ const restored = await browser.restoreAuth(session, list as never)
850
+ return { restored }
851
+ },
852
+ }))
853
+ }
854
+
855
+ /** Test hook: inspect and reset the plugin-level session map (used by tests). */
856
+ export const internals = {
857
+ /** The per-task session map (task key -> provider session id). */
858
+ get sessions(): ReadonlyMap<string, BrowserSessionId> { return sessionsByTask },
859
+ /** Drop one task's mapping without closing the provider session. */
860
+ clearSession(key = 'default'): void { sessionsByTask.delete(key) },
861
+ }
862
+