dsh-builtin-browser 0.1.5 → 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.
- package/README.md +9 -1
- package/lib/browser/runtime.d.ts +24 -8
- package/lib/browser/runtime.js +35 -7
- package/lib/browser/types.d.ts +122 -7
- package/lib/browser/types.js +1 -1
- package/lib/browser-electron/host-main.js +85 -0
- package/lib/browser-electron/provider.d.ts +49 -8
- package/lib/browser-electron/provider.js +406 -37
- package/lib/browser-electron/remote-host.js +35 -2
- package/lib/tool-browser/index.d.ts +13 -7
- package/lib/tool-browser/index.js +509 -34
- package/package.json +4 -2
- package/src/browser/runtime.ts +260 -0
- package/src/browser/types.ts +338 -0
- package/src/browser-electron/entry.ts +58 -0
- package/src/browser-electron/host-main.ts +229 -0
- package/src/browser-electron/provider.ts +922 -0
- package/src/browser-electron/remote-host.ts +421 -0
- package/src/index.ts +33 -0
- package/src/tool-browser/index.ts +862 -0
- package/src/types/electron-shim.d.ts +82 -0
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Electron-backed browser provider: `WebContentsView` sessions driven over
|
|
3
|
-
* `webContents.debugger` (CDP). The provider itself does not import Electron
|
|
3
|
+
* `webContents.debugger` (CDP). The provider itself does not import Electron — it operates through the {@link ElectronBrowserViewHost} seam, which the
|
|
4
4
|
* desktop shell implements with real Electron objects. That keeps this
|
|
5
5
|
* package testable under plain Node and leaves the Electron dependency to the
|
|
6
6
|
* shell that owns the `BrowserWindow`.
|
|
7
7
|
* @module dsh-browser/browser-electron
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { writeFileSync } from 'node:fs';
|
|
10
11
|
import { BrowserError } from "../browser/types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
|
|
14
|
+
* inside the page; returns `{ blocked, kind?, reason? }`. Marker-based and
|
|
15
|
+
* best-effort: checks for Cloudflare's interstitial, hCaptcha, reCAPTCHA,
|
|
16
|
+
* Turnstile, and generic challenge wording.
|
|
17
|
+
*/
|
|
18
|
+
const CHALLENGE_DETECT_EXPRESSION = `(() => {
|
|
19
|
+
const title = (document.title || '').trim()
|
|
20
|
+
const bodyText = (document.body && document.body.innerText || '').slice(0, 4000)
|
|
21
|
+
const lower = (title + '\\n' + bodyText).toLowerCase()
|
|
22
|
+
const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
|
|
23
|
+
const framesLower = frameSrcs.toLowerCase()
|
|
24
|
+
const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl|challenge-platform/i.test(lower)
|
|
25
|
+
|| !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
|
|
26
|
+
const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
|
|
27
|
+
const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
|
|
28
|
+
const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
|
|
29
|
+
const verifyWording = /verify you are human|verify you are not a robot|\\u4eba\\u673a\\u9a8c\\u8bc1|\\u5b89\\u5168\\u9a8c\\u8bc1|enable javascript and cookies|\\u8bf7.*\\u9a8c\\u8bc1/i.test(lower)
|
|
30
|
+
if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
|
|
31
|
+
if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
|
|
32
|
+
if (hasRecaptcha) return { blocked: true, kind: 'recaptcha', reason: 'Google reCAPTCHA verification' }
|
|
33
|
+
if (hasTurnstile) return { blocked: true, kind: 'turnstile', reason: 'Cloudflare Turnstile verification' }
|
|
34
|
+
if (verifyWording && /challenge|captcha|verification|security check|access denied|blocked|\\u9a8c\\u8bc1/i.test(lower)) {
|
|
35
|
+
return { blocked: true, kind: 'generic', reason: 'Human-verification challenge' }
|
|
36
|
+
}
|
|
37
|
+
return { blocked: false }
|
|
38
|
+
})()`;
|
|
11
39
|
/** Stable provider id registered with `ctx.browser`. */
|
|
12
40
|
export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
|
|
13
41
|
/** CDP method for a full-page screenshot capture. */
|
|
@@ -43,20 +71,16 @@ export class ElectronBrowserProvider {
|
|
|
43
71
|
return true;
|
|
44
72
|
}
|
|
45
73
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* cannot see. Closing the session resets this; the next open starts fresh.
|
|
74
|
+
* Open a NEW browser session with its own view. Every call mints a fresh
|
|
75
|
+
* session id and backing view; per-task reuse is owned by the caller (the
|
|
76
|
+
* tool layer caches one session per DSH task). Sessions are isolated from
|
|
77
|
+
* each other: each keeps its own tabs, active tab, and history, and only
|
|
78
|
+
* the active tab of a session is made visible.
|
|
52
79
|
*/
|
|
53
80
|
open() {
|
|
54
|
-
const [existing] = this.sessions.keys();
|
|
55
|
-
if (existing !== undefined)
|
|
56
|
-
return Promise.resolve(existing);
|
|
57
81
|
const handle = this.host.createView();
|
|
58
82
|
const id = `browser:${randomUUID()}`;
|
|
59
|
-
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0 });
|
|
83
|
+
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] });
|
|
60
84
|
return Promise.resolve(id);
|
|
61
85
|
}
|
|
62
86
|
/** Open a URL in the active tab (default) or a new tab. */
|
|
@@ -134,32 +158,44 @@ export class ElectronBrowserProvider {
|
|
|
134
158
|
}
|
|
135
159
|
/** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
|
|
136
160
|
async navigate(session, request, signal) {
|
|
137
|
-
const
|
|
161
|
+
const s = this.session(session);
|
|
162
|
+
const { handle } = this.activeTab(s);
|
|
138
163
|
const url = request.url;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
164
|
+
try {
|
|
165
|
+
if (this.httpOnly) {
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = new URL(url);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new BrowserError(`browser: refusing navigation to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
|
|
172
|
+
}
|
|
173
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
174
|
+
throw new BrowserError(`browser: refusing navigation to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
|
|
175
|
+
}
|
|
146
176
|
}
|
|
147
|
-
|
|
148
|
-
|
|
177
|
+
signal?.throwIfAborted();
|
|
178
|
+
const result = await handle.sendCommand(CDP_PAGE_NAVIGATE, { url });
|
|
179
|
+
// Page.navigate resolves even when the navigation fails; surface the
|
|
180
|
+
// failure instead of leaving a silent white screen.
|
|
181
|
+
const errorText = result.errorText;
|
|
182
|
+
if (typeof errorText === 'string' && errorText !== '') {
|
|
183
|
+
this.record(s, 'navigate', { url }, false, { error: errorText });
|
|
184
|
+
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
|
|
149
185
|
}
|
|
186
|
+
this.record(s, 'navigate', { url }, true);
|
|
150
187
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (typeof errorText === 'string' && errorText !== '') {
|
|
157
|
-
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED');
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (!(error instanceof BrowserError && error.code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
190
|
+
this.record(s, 'navigate', { url }, false, { error: String(error) });
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
158
193
|
}
|
|
159
194
|
}
|
|
160
195
|
/** Execute JS in the active tab's page context. */
|
|
161
196
|
async execute(session, request, signal) {
|
|
162
|
-
const
|
|
197
|
+
const s = this.session(session);
|
|
198
|
+
const { handle } = this.activeTab(s);
|
|
163
199
|
signal?.throwIfAborted();
|
|
164
200
|
try {
|
|
165
201
|
// Wrap the script in a Function so `return` statements are legal and
|
|
@@ -177,16 +213,25 @@ export class ElectronBrowserProvider {
|
|
|
177
213
|
const expression = hasArgs
|
|
178
214
|
? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
|
|
179
215
|
: `(function(){ return Function(${JSON.stringify(body)})() })()`;
|
|
180
|
-
|
|
216
|
+
// CDP Runtime.evaluate can hang indefinitely on a not-yet-loaded page
|
|
217
|
+
// (navigate returned but the renderer has not committed). Bound it so a
|
|
218
|
+
// stuck call surfaces as BROWSER_EXECUTE_TIMEOUT instead of wedging the
|
|
219
|
+
// whole tool call. The caller's signal wins when it fires first.
|
|
220
|
+
const timeoutMs = request.timeoutMs ?? 30_000;
|
|
221
|
+
const result = await withTimeout(handle.sendCommand(CDP_RUNTIME_EVALUATE, {
|
|
181
222
|
expression,
|
|
182
223
|
returnByValue: true,
|
|
183
224
|
awaitPromise: true,
|
|
184
|
-
});
|
|
225
|
+
}), timeoutMs, signal, `browser: execute timed out after ${timeoutMs}ms`);
|
|
185
226
|
if (result.exceptionDetails !== undefined) {
|
|
186
227
|
const detail = result.exceptionDetails;
|
|
187
|
-
|
|
228
|
+
const exception = detail.exception?.description ?? detail.text ?? 'unknown exception';
|
|
229
|
+
this.record(s, 'execute', { script: request.script }, false, { error: exception });
|
|
230
|
+
return { ok: false, exception };
|
|
188
231
|
}
|
|
189
|
-
|
|
232
|
+
const value = result.result?.value ?? null;
|
|
233
|
+
this.record(s, 'execute', { script: request.script }, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) });
|
|
234
|
+
return { ok: true, value };
|
|
190
235
|
}
|
|
191
236
|
catch (error) {
|
|
192
237
|
throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error });
|
|
@@ -224,14 +269,31 @@ export class ElectronBrowserProvider {
|
|
|
224
269
|
y: Math.round(r.y + r.height / 2),
|
|
225
270
|
})
|
|
226
271
|
}
|
|
227
|
-
|
|
272
|
+
const challenge = ${CHALLENGE_DETECT_EXPRESSION}
|
|
273
|
+
return { url, title, elements: out, truncated: out.length >= cap, challenge }
|
|
228
274
|
})()`;
|
|
229
|
-
|
|
275
|
+
// Same hang guard as execute: a renderer that has not committed after
|
|
276
|
+
// navigate would otherwise block snapshot forever.
|
|
277
|
+
const timeoutMs = 30_000;
|
|
278
|
+
const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`);
|
|
230
279
|
if (!result.ok)
|
|
231
280
|
throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
|
|
232
281
|
const value = result.value;
|
|
233
282
|
return value;
|
|
234
283
|
}
|
|
284
|
+
/** Check whether a human-verification challenge is blocking the active tab. */
|
|
285
|
+
async detectChallenge(session, signal) {
|
|
286
|
+
const s = this.session(session);
|
|
287
|
+
const tab = this.activeTab(s);
|
|
288
|
+
signal?.throwIfAborted();
|
|
289
|
+
const timeoutMs = 15_000;
|
|
290
|
+
const result = await withTimeout(handleSendEvaluate(tab.handle, CHALLENGE_DETECT_EXPRESSION), timeoutMs, signal, `browser: challenge detection timed out after ${timeoutMs}ms`);
|
|
291
|
+
if (!result.ok) {
|
|
292
|
+
throw new BrowserError(`browser: challenge detection failed: ${result.exception}`, 'BROWSER_CHALLENGE_DETECT_FAILED');
|
|
293
|
+
}
|
|
294
|
+
const value = result.value;
|
|
295
|
+
return { blocked: value.blocked === true, kind: value.kind, reason: value.reason };
|
|
296
|
+
}
|
|
235
297
|
/** Fetch page content in a requested format. */
|
|
236
298
|
async content(session, request, signal) {
|
|
237
299
|
const tab = this.activeTab(this.session(session));
|
|
@@ -287,16 +349,199 @@ export class ElectronBrowserProvider {
|
|
|
287
349
|
}
|
|
288
350
|
/** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
|
|
289
351
|
async click(session, request, signal) {
|
|
290
|
-
const
|
|
352
|
+
const s = this.session(session);
|
|
353
|
+
const { handle } = this.activeTab(s);
|
|
291
354
|
signal?.throwIfAborted();
|
|
292
355
|
await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: request.x, y: request.y, button: 'left', clickCount: 1 });
|
|
293
356
|
await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: request.x, y: request.y, button: 'left', clickCount: 1 });
|
|
357
|
+
this.record(s, 'click', { x: request.x, y: request.y }, true);
|
|
294
358
|
}
|
|
295
359
|
/** Type into the focused element. */
|
|
296
360
|
async type(session, request, signal) {
|
|
297
|
-
const
|
|
361
|
+
const s = this.session(session);
|
|
362
|
+
const { handle } = this.activeTab(s);
|
|
298
363
|
signal?.throwIfAborted();
|
|
299
364
|
await handle.sendCommand('Input.insertText', { text: request.text });
|
|
365
|
+
this.record(s, 'type', { text: request.text.slice(0, 200) }, true);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Fill a form's fields in one batch. Runs one page-context script that
|
|
369
|
+
* resolves each field (selector, or name/label/placeholder among visible
|
|
370
|
+
* controls), sets its value with the native prototype setter (React/Vue
|
|
371
|
+
* controlled inputs included) plus input/change events, handles
|
|
372
|
+
* select/checkbox/radio/contenteditable, and optionally submits the form.
|
|
373
|
+
*/
|
|
374
|
+
async fillForm(session, request, signal) {
|
|
375
|
+
const s = this.session(session);
|
|
376
|
+
const tab = this.activeTab(s);
|
|
377
|
+
signal?.throwIfAborted();
|
|
378
|
+
const specs = JSON.stringify(request.fields.map(f => ({
|
|
379
|
+
selector: f.selector ?? null,
|
|
380
|
+
name: f.name ?? null,
|
|
381
|
+
label: f.label ?? null,
|
|
382
|
+
placeholder: f.placeholder ?? null,
|
|
383
|
+
kind: f.kind ?? 'text',
|
|
384
|
+
value: f.value,
|
|
385
|
+
})));
|
|
386
|
+
const submitFlag = request.submit === true;
|
|
387
|
+
const script = `(() => {
|
|
388
|
+
const specs = ${specs}
|
|
389
|
+
const out = []
|
|
390
|
+
const setNative = (el, proto, value) => {
|
|
391
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
392
|
+
if (setter) setter.call(el, value)
|
|
393
|
+
else el.value = value
|
|
394
|
+
}
|
|
395
|
+
const visible = (el) => {
|
|
396
|
+
const r = el.getBoundingClientRect()
|
|
397
|
+
const cs = getComputedStyle(el)
|
|
398
|
+
return r.width >= 4 && r.height >= 4 && cs.visibility !== 'hidden' && cs.display !== 'none'
|
|
399
|
+
}
|
|
400
|
+
const describe = (spec) => spec.selector || spec.name || spec.label || spec.placeholder || '(unspecified)'
|
|
401
|
+
const matches = (el, spec) => {
|
|
402
|
+
if (spec.selector) { try { return el.matches(spec.selector) } catch { return false } }
|
|
403
|
+
if (spec.name && el.name === spec.name) return true
|
|
404
|
+
if (spec.placeholder && el.placeholder === spec.placeholder) return true
|
|
405
|
+
if (spec.label) {
|
|
406
|
+
if (el.getAttribute('aria-label') === spec.label) return true
|
|
407
|
+
if (el.id) {
|
|
408
|
+
const lbl = document.querySelector('label[for=' + JSON.stringify(el.id) + ']')
|
|
409
|
+
if (lbl && (lbl.textContent || '').trim() === spec.label) return true
|
|
410
|
+
}
|
|
411
|
+
const wrap = el.closest('label')
|
|
412
|
+
if (wrap && (wrap.textContent || '').trim() === spec.label) return true
|
|
413
|
+
}
|
|
414
|
+
return false
|
|
415
|
+
}
|
|
416
|
+
const candidates = (spec) => {
|
|
417
|
+
const all = spec.selector
|
|
418
|
+
? [...document.querySelectorAll(spec.selector)]
|
|
419
|
+
: [...document.querySelectorAll('input, textarea, select, [contenteditable="true"]')].filter(el => matches(el, spec))
|
|
420
|
+
const vis = all.filter(visible)
|
|
421
|
+
return vis.length > 0 ? vis : all
|
|
422
|
+
}
|
|
423
|
+
for (const spec of specs) {
|
|
424
|
+
const els = candidates(spec)
|
|
425
|
+
if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
|
|
426
|
+
const el = els[0]
|
|
427
|
+
const tag = el.tagName
|
|
428
|
+
const type = (el.type || '').toLowerCase()
|
|
429
|
+
try {
|
|
430
|
+
if (tag === 'SELECT') {
|
|
431
|
+
const wanted = String(spec.value)
|
|
432
|
+
if (el.multiple) {
|
|
433
|
+
const wantedList = wanted.split(',').map(x => x.trim())
|
|
434
|
+
let hit = false
|
|
435
|
+
for (const o of [...el.options]) {
|
|
436
|
+
o.selected = wantedList.includes(o.value) || wantedList.includes((o.textContent || '').trim())
|
|
437
|
+
if (o.selected) hit = true
|
|
438
|
+
}
|
|
439
|
+
if (!hit) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
|
|
440
|
+
} else {
|
|
441
|
+
let opt = [...el.options].find(o => o.value === wanted)
|
|
442
|
+
if (!opt) opt = [...el.options].find(o => (o.textContent || '').trim() === wanted)
|
|
443
|
+
if (!opt) { out.push({ ok: false, error: 'option not found: ' + wanted, target: describe(spec) }); continue }
|
|
444
|
+
setNative(el, HTMLSelectElement.prototype, opt.value)
|
|
445
|
+
}
|
|
446
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
447
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
448
|
+
out.push({ ok: true, method: 'select', target: describe(spec) })
|
|
449
|
+
} else if (type === 'file') {
|
|
450
|
+
out.push({ ok: false, error: 'file inputs cannot be set from script; use browser_download or ask the human', target: describe(spec) })
|
|
451
|
+
} else if (type === 'checkbox') {
|
|
452
|
+
const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
|
|
453
|
+
if (el.checked !== want) el.click()
|
|
454
|
+
out.push({ ok: true, method: 'checkbox', target: describe(spec) })
|
|
455
|
+
} else if (type === 'radio') {
|
|
456
|
+
const wanted = String(spec.value)
|
|
457
|
+
const radio = [...document.querySelectorAll('input[type="radio"][name=' + JSON.stringify(el.name || '') + ']')]
|
|
458
|
+
.find(r => r.value === wanted || (r === el && (spec.value === true || spec.value === 'true')))
|
|
459
|
+
if (!radio) { out.push({ ok: false, error: 'radio option not found: ' + wanted, target: describe(spec) }); continue }
|
|
460
|
+
if (!radio.checked) radio.click()
|
|
461
|
+
out.push({ ok: true, method: 'radio', target: describe(spec) })
|
|
462
|
+
} else if (el.isContentEditable) {
|
|
463
|
+
el.textContent = String(spec.value)
|
|
464
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
465
|
+
out.push({ ok: true, method: 'contenteditable', target: describe(spec) })
|
|
466
|
+
} else if (tag === 'TEXTAREA') {
|
|
467
|
+
setNative(el, HTMLTextAreaElement.prototype, String(spec.value))
|
|
468
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
469
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
470
|
+
out.push({ ok: true, method: 'textarea', target: describe(spec) })
|
|
471
|
+
} else {
|
|
472
|
+
setNative(el, HTMLInputElement.prototype, String(spec.value))
|
|
473
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
474
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
475
|
+
out.push({ ok: true, method: 'input', target: describe(spec) })
|
|
476
|
+
}
|
|
477
|
+
} catch (e) {
|
|
478
|
+
out.push({ ok: false, error: String(e), target: describe(spec) })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
let submitted = false
|
|
482
|
+
if (${submitFlag}) {
|
|
483
|
+
let last = null
|
|
484
|
+
for (const spec of specs) { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } }
|
|
485
|
+
const form = last && (last.form || last.closest('form'))
|
|
486
|
+
if (form) { form.requestSubmit(); submitted = true }
|
|
487
|
+
}
|
|
488
|
+
return { fields: out, submitted }
|
|
489
|
+
})()`;
|
|
490
|
+
const timeoutMs = request.timeoutMs ?? 30_000;
|
|
491
|
+
const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: fillForm timed out after ${timeoutMs}ms`);
|
|
492
|
+
if (!result.ok) {
|
|
493
|
+
throw new BrowserError(`browser: fillForm evaluation failed: ${result.exception}`, 'BROWSER_FILL_FAILED');
|
|
494
|
+
}
|
|
495
|
+
const value = result.value;
|
|
496
|
+
const okCount = value.fields.filter(f => f.ok).length;
|
|
497
|
+
this.record(s, 'fill', { fields: request.fields.length, submit: submitFlag }, okCount === value.fields.length, {
|
|
498
|
+
result: `${okCount}/${value.fields.length} fields filled${value.submitted ? ', form submitted' : ''}`,
|
|
499
|
+
});
|
|
500
|
+
return { fields: value.fields, submitted: value.submitted === true };
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Download a URL to a local file, keeping the session's cookies/login.
|
|
504
|
+
* Requires the self-hosted host (which implements view-level download); the
|
|
505
|
+
* desktop shell's embedded views delegate downloads to the real browser UI.
|
|
506
|
+
*/
|
|
507
|
+
async download(session, request, signal) {
|
|
508
|
+
const s = this.session(session);
|
|
509
|
+
const { handle } = this.activeTab(s);
|
|
510
|
+
signal?.throwIfAborted();
|
|
511
|
+
const downloadable = handle;
|
|
512
|
+
if (typeof downloadable.download !== 'function') {
|
|
513
|
+
throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED');
|
|
514
|
+
}
|
|
515
|
+
await downloadable.download(request.url, request.savePath);
|
|
516
|
+
this.record(s, 'download', { url: request.url, savePath: request.savePath }, true, { result: request.savePath });
|
|
517
|
+
return { path: request.savePath };
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Export the session's cookies (login state) as serializable objects.
|
|
521
|
+
* Self-hosted only; the desktop shell's embedded views use the real profile.
|
|
522
|
+
*/
|
|
523
|
+
async flushAuth(session) {
|
|
524
|
+
const s = this.session(session);
|
|
525
|
+
const { handle } = this.activeTab(s);
|
|
526
|
+
const host = handle;
|
|
527
|
+
if (typeof host.flushAuth !== 'function') {
|
|
528
|
+
throw new BrowserError('browser: auth export is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
|
|
529
|
+
}
|
|
530
|
+
const cookies = await host.flushAuth();
|
|
531
|
+
this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` });
|
|
532
|
+
return cookies;
|
|
533
|
+
}
|
|
534
|
+
/** Import cookies into the session (restore login state). Self-hosted only. */
|
|
535
|
+
async restoreAuth(session, cookies) {
|
|
536
|
+
const s = this.session(session);
|
|
537
|
+
const { handle } = this.activeTab(s);
|
|
538
|
+
const host = handle;
|
|
539
|
+
if (typeof host.restoreAuth !== 'function') {
|
|
540
|
+
throw new BrowserError('browser: auth restore is only available on the self-hosted browser', 'BROWSER_AUTH_UNSUPPORTED');
|
|
541
|
+
}
|
|
542
|
+
const restored = await host.restoreAuth(cookies);
|
|
543
|
+
this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` });
|
|
544
|
+
return restored;
|
|
300
545
|
}
|
|
301
546
|
/** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
|
|
302
547
|
async screenshot(session, request, signal) {
|
|
@@ -313,8 +558,92 @@ export class ElectronBrowserProvider {
|
|
|
313
558
|
if (typeof data !== 'string') {
|
|
314
559
|
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
|
|
315
560
|
}
|
|
561
|
+
// Optional disk write so a vision tool (e.g. modlens read_image) can read
|
|
562
|
+
// the screenshot from a file path. Fire-and-forget: a write failure must
|
|
563
|
+
// not fail the capture.
|
|
564
|
+
if (request?.savePath !== undefined) {
|
|
565
|
+
try {
|
|
566
|
+
writeFileSync(request.savePath, Buffer.from(data, 'base64'));
|
|
567
|
+
return { dataUrl: `data:image/png;base64,${data}`, path: request.savePath };
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
// Report the write problem but keep the capture usable.
|
|
571
|
+
throw new BrowserError(`browser: screenshot save to "${request.savePath}" failed: ${String(error)}`, 'BROWSER_SCREENSHOT_SAVE_FAILED', { cause: error });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
316
574
|
return { dataUrl: `data:image/png;base64,${data}` };
|
|
317
575
|
}
|
|
576
|
+
/** Append one operation to the session's history. */
|
|
577
|
+
record(s, action, params, ok, detail) {
|
|
578
|
+
const entry = {
|
|
579
|
+
seq: s.history.length + 1,
|
|
580
|
+
action,
|
|
581
|
+
params,
|
|
582
|
+
ok,
|
|
583
|
+
...detail?.result !== undefined ? { result: detail.result } : {},
|
|
584
|
+
...detail?.error !== undefined ? { error: detail.error } : {},
|
|
585
|
+
at: Date.now(),
|
|
586
|
+
};
|
|
587
|
+
s.history.push(entry);
|
|
588
|
+
// Bound memory: keep the last 500 operations.
|
|
589
|
+
if (s.history.length > 500)
|
|
590
|
+
s.history.splice(0, s.history.length - 500);
|
|
591
|
+
}
|
|
592
|
+
/** Return the session's chronological operation log (newest last). */
|
|
593
|
+
async history(session) {
|
|
594
|
+
return this.session(session).history;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Replay one recorded operation by sequence number. Navigate/click/type are
|
|
598
|
+
* re-issued against the current page; execute re-runs its script. The
|
|
599
|
+
* replayed step is appended to history as a new entry.
|
|
600
|
+
* @param session - the session id.
|
|
601
|
+
* @param seq - the recorded entry's sequence number to replay.
|
|
602
|
+
*/
|
|
603
|
+
async replay(session, seq) {
|
|
604
|
+
const s = this.session(session);
|
|
605
|
+
const entry = s.history.find(e => e.seq === seq);
|
|
606
|
+
if (entry === undefined) {
|
|
607
|
+
throw new BrowserError(`browser: no history entry with seq ${seq}`, 'BROWSER_HISTORY_UNKNOWN');
|
|
608
|
+
}
|
|
609
|
+
switch (entry.action) {
|
|
610
|
+
case 'navigate': {
|
|
611
|
+
const url = entry.params.url;
|
|
612
|
+
if (typeof url !== 'string')
|
|
613
|
+
throw new BrowserError(`browser: history seq ${seq} navigate has no url`, 'BROWSER_HISTORY_INVALID');
|
|
614
|
+
await this.navigate(session, { url });
|
|
615
|
+
this.record(s, 'replay', { seq, of: entry.action, url }, true);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
case 'click': {
|
|
619
|
+
const x = entry.params.x;
|
|
620
|
+
const y = entry.params.y;
|
|
621
|
+
if (typeof x !== 'number' || typeof y !== 'number')
|
|
622
|
+
throw new BrowserError(`browser: history seq ${seq} click has no coordinates`, 'BROWSER_HISTORY_INVALID');
|
|
623
|
+
await this.click(session, { x, y });
|
|
624
|
+
this.record(s, 'replay', { seq, of: entry.action, x, y }, true);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
case 'type': {
|
|
628
|
+
const text = entry.params.text;
|
|
629
|
+
if (typeof text !== 'string')
|
|
630
|
+
throw new BrowserError(`browser: history seq ${seq} type has no text`, 'BROWSER_HISTORY_INVALID');
|
|
631
|
+
await this.type(session, { text });
|
|
632
|
+
this.record(s, 'replay', { seq, of: entry.action, text }, true);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
case 'execute': {
|
|
636
|
+
const script = entry.params.script;
|
|
637
|
+
if (typeof script !== 'string')
|
|
638
|
+
throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID');
|
|
639
|
+
const result = await this.execute(session, { script });
|
|
640
|
+
this.record(s, 'replay', { seq, of: entry.action, script }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception });
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
default:
|
|
644
|
+
throw new BrowserError(`browser: history seq ${seq} action "${entry.action}" is not replayable`, 'BROWSER_HISTORY_NOT_REPLAYABLE');
|
|
645
|
+
}
|
|
646
|
+
}
|
|
318
647
|
/** Close the session and destroy all its views. Idempotent. */
|
|
319
648
|
close(session) {
|
|
320
649
|
const existing = this.sessions.get(session);
|
|
@@ -357,6 +686,46 @@ export class ElectronBrowserProvider {
|
|
|
357
686
|
return result.ok && typeof result.value === 'string' ? result.value : '';
|
|
358
687
|
}
|
|
359
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Bound a promise so a wedged CDP call surfaces as an error instead of
|
|
691
|
+
* hanging the tool call forever. The caller's signal, when provided, wins
|
|
692
|
+
* over the timeout if it fires first.
|
|
693
|
+
* @param promise - the operation to bound.
|
|
694
|
+
* @param ms - the timeout budget.
|
|
695
|
+
* @param signal - optional caller signal.
|
|
696
|
+
* @param message - the timeout error message.
|
|
697
|
+
* @returns the promise's value, or a rejected promise on timeout/abort.
|
|
698
|
+
*/
|
|
699
|
+
function withTimeout(promise, ms, signal, message) {
|
|
700
|
+
return new Promise((resolve, reject) => {
|
|
701
|
+
let done = false;
|
|
702
|
+
const timer = setTimeout(() => {
|
|
703
|
+
if (done)
|
|
704
|
+
return;
|
|
705
|
+
done = true;
|
|
706
|
+
reject(new Error(message));
|
|
707
|
+
}, ms);
|
|
708
|
+
const finish = (fn) => {
|
|
709
|
+
if (done)
|
|
710
|
+
return;
|
|
711
|
+
done = true;
|
|
712
|
+
clearTimeout(timer);
|
|
713
|
+
if (signal !== undefined)
|
|
714
|
+
signal.removeEventListener('abort', onAbort);
|
|
715
|
+
fn();
|
|
716
|
+
};
|
|
717
|
+
const onAbort = () => {
|
|
718
|
+
if (done)
|
|
719
|
+
return;
|
|
720
|
+
done = true;
|
|
721
|
+
clearTimeout(timer);
|
|
722
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'));
|
|
723
|
+
};
|
|
724
|
+
if (signal !== undefined)
|
|
725
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
726
|
+
promise.then(value => finish(() => resolve(value)), error => finish(() => reject(error)));
|
|
727
|
+
});
|
|
728
|
+
}
|
|
360
729
|
/**
|
|
361
730
|
* Run a `Runtime.evaluate` through a view handle and normalize the result.
|
|
362
731
|
* Shared by execute, snapshot, content, and internal URL reads.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { createServer } from 'node:net';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
@@ -268,6 +268,19 @@ class RemoteView {
|
|
|
268
268
|
params: params ?? {},
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
|
+
/** Ask the child to download a URL to a local file (keeps cookies/login). */
|
|
272
|
+
async download(url, savePath) {
|
|
273
|
+
const result = await this.client.call('download', { viewId: this.id, url, savePath });
|
|
274
|
+
writeFileSync(savePath, Buffer.from(result.base64, 'base64'));
|
|
275
|
+
}
|
|
276
|
+
/** Export the session's cookies (login state). */
|
|
277
|
+
flushAuth() {
|
|
278
|
+
return this.client.call('flushAuth', { viewId: this.id }).then(r => r.cookies);
|
|
279
|
+
}
|
|
280
|
+
/** Import cookies into the session (restore login state). */
|
|
281
|
+
restoreAuth(cookies) {
|
|
282
|
+
return this.client.call('restoreAuth', { viewId: this.id, cookies }).then(r => r.restored);
|
|
283
|
+
}
|
|
271
284
|
}
|
|
272
285
|
/**
|
|
273
286
|
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
@@ -358,14 +371,34 @@ export class RemoteElectronViewHost {
|
|
|
358
371
|
class DeferredRemoteView {
|
|
359
372
|
id;
|
|
360
373
|
materialize;
|
|
374
|
+
materialized;
|
|
361
375
|
constructor(id, materialize) {
|
|
362
376
|
this.id = id;
|
|
363
377
|
this.materialize = materialize;
|
|
364
378
|
}
|
|
365
379
|
async sendCommand(method, params) {
|
|
366
|
-
|
|
380
|
+
// Materialize once and cache: every sendCommand on the same handle must
|
|
381
|
+
// target the SAME child view. Re-materializing would re-run createView
|
|
382
|
+
// (duplicate window) and drop the established view.
|
|
383
|
+
this.materialized ??= this.materialize();
|
|
384
|
+
const view = await this.materialized;
|
|
367
385
|
return view.sendCommand(method, params);
|
|
368
386
|
}
|
|
387
|
+
async download(url, savePath) {
|
|
388
|
+
this.materialized ??= this.materialize();
|
|
389
|
+
const view = await this.materialized;
|
|
390
|
+
return view.download(url, savePath);
|
|
391
|
+
}
|
|
392
|
+
async flushAuth() {
|
|
393
|
+
this.materialized ??= this.materialize();
|
|
394
|
+
const view = await this.materialized;
|
|
395
|
+
return view.flushAuth();
|
|
396
|
+
}
|
|
397
|
+
async restoreAuth(cookies) {
|
|
398
|
+
this.materialized ??= this.materialize();
|
|
399
|
+
const view = await this.materialized;
|
|
400
|
+
return view.restoreAuth(cookies);
|
|
401
|
+
}
|
|
369
402
|
}
|
|
370
403
|
/** Reject a promise if it does not settle within the budget. */
|
|
371
404
|
function withTimeout(promise, ms, message) {
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* `browser_switch_tab`, `browser_close_tab`, `browser_reset`).
|
|
6
6
|
*
|
|
7
7
|
* The tool layer owns only the model-facing schema, argument validation, and
|
|
8
|
-
* result formatting
|
|
9
|
-
* to the seam. Session lifecycle is owned here at the plugin level:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
12
14
|
* @module dsh-browser/tool-browser
|
|
13
15
|
*/
|
|
14
16
|
import type { Context } from '@deepseek-ai/cordis';
|
|
@@ -23,11 +25,15 @@ export interface Config {
|
|
|
23
25
|
readonly timeoutMs?: number;
|
|
24
26
|
/** Whether to offer tab-management tools. Default true. */
|
|
25
27
|
readonly tabTools?: boolean;
|
|
28
|
+
/** Optional initial allow-list of browser tool names; other tools are refused. */
|
|
29
|
+
readonly allowedActions?: readonly string[];
|
|
26
30
|
}
|
|
27
31
|
/** Register all browser tools with `ctx.tools`. */
|
|
28
32
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
29
|
-
/** Test hook:
|
|
33
|
+
/** Test hook: inspect and reset the plugin-level session map (used by tests). */
|
|
30
34
|
export declare const internals: {
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
/** The per-task session map (task key -> provider session id). */
|
|
36
|
+
readonly sessions: ReadonlyMap<string, BrowserSessionId>;
|
|
37
|
+
/** Drop one task's mapping without closing the provider session. */
|
|
38
|
+
clearSession(key?: string): void;
|
|
33
39
|
};
|