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.
- 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 +120 -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 +355 -34
- package/lib/browser-electron/remote-host.js +29 -1
- 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
|
|
@@ -189,9 +225,13 @@ export class ElectronBrowserProvider {
|
|
|
189
225
|
}), timeoutMs, signal, `browser: execute timed out after ${timeoutMs}ms`);
|
|
190
226
|
if (result.exceptionDetails !== undefined) {
|
|
191
227
|
const detail = result.exceptionDetails;
|
|
192
|
-
|
|
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 };
|
|
193
231
|
}
|
|
194
|
-
|
|
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 };
|
|
195
235
|
}
|
|
196
236
|
catch (error) {
|
|
197
237
|
throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error });
|
|
@@ -229,7 +269,8 @@ export class ElectronBrowserProvider {
|
|
|
229
269
|
y: Math.round(r.y + r.height / 2),
|
|
230
270
|
})
|
|
231
271
|
}
|
|
232
|
-
|
|
272
|
+
const challenge = ${CHALLENGE_DETECT_EXPRESSION}
|
|
273
|
+
return { url, title, elements: out, truncated: out.length >= cap, challenge }
|
|
233
274
|
})()`;
|
|
234
275
|
// Same hang guard as execute: a renderer that has not committed after
|
|
235
276
|
// navigate would otherwise block snapshot forever.
|
|
@@ -240,6 +281,19 @@ export class ElectronBrowserProvider {
|
|
|
240
281
|
const value = result.value;
|
|
241
282
|
return value;
|
|
242
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
|
+
}
|
|
243
297
|
/** Fetch page content in a requested format. */
|
|
244
298
|
async content(session, request, signal) {
|
|
245
299
|
const tab = this.activeTab(this.session(session));
|
|
@@ -295,16 +349,199 @@ export class ElectronBrowserProvider {
|
|
|
295
349
|
}
|
|
296
350
|
/** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
|
|
297
351
|
async click(session, request, signal) {
|
|
298
|
-
const
|
|
352
|
+
const s = this.session(session);
|
|
353
|
+
const { handle } = this.activeTab(s);
|
|
299
354
|
signal?.throwIfAborted();
|
|
300
355
|
await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: request.x, y: request.y, button: 'left', clickCount: 1 });
|
|
301
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);
|
|
302
358
|
}
|
|
303
359
|
/** Type into the focused element. */
|
|
304
360
|
async type(session, request, signal) {
|
|
305
|
-
const
|
|
361
|
+
const s = this.session(session);
|
|
362
|
+
const { handle } = this.activeTab(s);
|
|
306
363
|
signal?.throwIfAborted();
|
|
307
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;
|
|
308
545
|
}
|
|
309
546
|
/** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
|
|
310
547
|
async screenshot(session, request, signal) {
|
|
@@ -321,8 +558,92 @@ export class ElectronBrowserProvider {
|
|
|
321
558
|
if (typeof data !== 'string') {
|
|
322
559
|
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
|
|
323
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
|
+
}
|
|
324
574
|
return { dataUrl: `data:image/png;base64,${data}` };
|
|
325
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
|
+
}
|
|
326
647
|
/** Close the session and destroy all its views. Idempotent. */
|
|
327
648
|
close(session) {
|
|
328
649
|
const existing = this.sessions.get(session);
|
|
@@ -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
|
|
@@ -371,6 +384,21 @@ class DeferredRemoteView {
|
|
|
371
384
|
const view = await this.materialized;
|
|
372
385
|
return view.sendCommand(method, params);
|
|
373
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
|
+
}
|
|
374
402
|
}
|
|
375
403
|
/** Reject a promise if it does not settle within the budget. */
|
|
376
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
|
};
|