dsh-builtin-browser 0.1.7 → 0.1.9
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.en.md +21 -3
- package/README.md +21 -3
- package/lib/browser/runtime.d.ts +2 -1
- package/lib/browser/runtime.js +15 -2
- package/lib/browser/types.d.ts +2 -1
- package/lib/browser-electron/entry.js +6 -1
- package/lib/browser-electron/host-main.js +26 -2
- package/lib/browser-electron/provider.js +50 -20
- package/lib/browser-electron/remote-host.d.ts +7 -4
- package/lib/browser-electron/remote-host.js +65 -15
- package/lib/index.d.ts +8 -7
- package/lib/index.js +7 -6
- package/lib/tool-browser/index.d.ts +1 -1
- package/lib/tool-browser/index.js +44 -20
- package/package.json +2 -2
- package/src/browser/runtime.ts +14 -2
- package/src/browser/types.ts +2 -1
- package/src/browser-electron/entry.ts +6 -1
- package/src/browser-electron/host-main.ts +22 -2
- package/src/browser-electron/provider.ts +67 -21
- package/src/browser-electron/remote-host.ts +62 -12
- package/src/index.ts +12 -6
- package/src/tool-browser/index.ts +44 -21
|
@@ -20,6 +20,8 @@ export const name = 'tool-browser';
|
|
|
20
20
|
export const inject = ['tools', 'browser', 'systemPrompt'];
|
|
21
21
|
/** Per-task browser sessions, keyed by the calling DSH session id. */
|
|
22
22
|
const sessionsByTask = new Map();
|
|
23
|
+
/** In-flight first-open per task key, so concurrent first calls share one session. */
|
|
24
|
+
const pendingOpens = new Map();
|
|
23
25
|
/**
|
|
24
26
|
* Action restriction state: an allow-list of browser tool names, or undefined
|
|
25
27
|
* for unrestricted. When set, any browser_* tool not in the list is refused
|
|
@@ -48,25 +50,29 @@ function taskKey(exec) {
|
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
52
|
* Resolve the calling task's browser session, opening one on first use.
|
|
53
|
+
* Concurrent first calls for the same key share a single open.
|
|
51
54
|
* @param browser - the seam service.
|
|
52
55
|
* @param key - the task key (see {@link taskKey}).
|
|
53
56
|
* @returns the task's session id.
|
|
54
57
|
*/
|
|
55
58
|
async function ensureSession(browser, key) {
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
const existing = sessionsByTask.get(key);
|
|
60
|
+
if (existing !== undefined)
|
|
61
|
+
return existing;
|
|
62
|
+
const pending = pendingOpens.get(key);
|
|
63
|
+
if (pending !== undefined)
|
|
64
|
+
return pending;
|
|
65
|
+
const opening = browser.open().then(session => { sessionsByTask.set(key, session); pendingOpens.delete(key); return session; }, error => { pendingOpens.delete(key); throw error; });
|
|
66
|
+
pendingOpens.set(key, opening);
|
|
67
|
+
return opening;
|
|
62
68
|
}
|
|
63
|
-
/** Coerce a tool-provided string value back to boolean/number when
|
|
69
|
+
/** Coerce a tool-provided string value back to boolean/number only when lossless. */
|
|
64
70
|
function parseFillValue(v) {
|
|
65
71
|
if (v === 'true')
|
|
66
72
|
return true;
|
|
67
73
|
if (v === 'false')
|
|
68
74
|
return false;
|
|
69
|
-
if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v))
|
|
75
|
+
if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v) && String(Number(v)) === v)
|
|
70
76
|
return Number(v);
|
|
71
77
|
return v ?? '';
|
|
72
78
|
}
|
|
@@ -84,8 +90,8 @@ function formatSnapshot(snapshot) {
|
|
|
84
90
|
/** Register all browser tools with `ctx.tools`. */
|
|
85
91
|
export function apply(ctx, config = {}) {
|
|
86
92
|
const timeoutMs = config.timeoutMs ?? 60_000;
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
// Re-apply resets the restriction: an omitted allowedActions lifts it.
|
|
94
|
+
restrictedTo = config.allowedActions !== undefined ? [...config.allowedActions] : undefined;
|
|
89
95
|
ctx.systemPrompt.section({
|
|
90
96
|
name: 'tool:browser',
|
|
91
97
|
// Tool guidance band is 100-199; 150 keeps clear of the common 110/120
|
|
@@ -107,6 +113,7 @@ export function apply(ctx, config = {}) {
|
|
|
107
113
|
properties: {
|
|
108
114
|
url: { type: 'string', required: true },
|
|
109
115
|
title: { type: 'string' },
|
|
116
|
+
truncated: { type: 'boolean' },
|
|
110
117
|
elements: {
|
|
111
118
|
type: 'array',
|
|
112
119
|
required: true,
|
|
@@ -168,6 +175,7 @@ export function apply(ctx, config = {}) {
|
|
|
168
175
|
properties: {
|
|
169
176
|
url: { type: 'string', required: true },
|
|
170
177
|
title: { type: 'string' },
|
|
178
|
+
truncated: { type: 'boolean' },
|
|
171
179
|
elements: {
|
|
172
180
|
type: 'array',
|
|
173
181
|
required: true,
|
|
@@ -546,6 +554,7 @@ export function apply(ctx, config = {}) {
|
|
|
546
554
|
timeoutMs,
|
|
547
555
|
isConcurrencySafe: () => true,
|
|
548
556
|
async execute(args, exec) {
|
|
557
|
+
assertAllowed('browser_switch_tab');
|
|
549
558
|
const browser = ctx.get('browser');
|
|
550
559
|
if (browser === undefined)
|
|
551
560
|
throw new Error('tool-browser: browser service unavailable');
|
|
@@ -629,7 +638,11 @@ export function apply(ctx, config = {}) {
|
|
|
629
638
|
return [{ type: 'text', text: '(no recorded operations yet)' }];
|
|
630
639
|
return [{
|
|
631
640
|
type: 'text',
|
|
632
|
-
text: entries.map(e =>
|
|
641
|
+
text: entries.map(e => {
|
|
642
|
+
const rawParams = JSON.stringify(e.params);
|
|
643
|
+
const shownParams = rawParams.length > 300 ? rawParams.slice(0, 300) + '…' : rawParams;
|
|
644
|
+
return `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${shownParams}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`;
|
|
645
|
+
}).join('\n'),
|
|
633
646
|
}];
|
|
634
647
|
},
|
|
635
648
|
},
|
|
@@ -738,9 +751,7 @@ export function apply(ctx, config = {}) {
|
|
|
738
751
|
const browser = ctx.get('browser');
|
|
739
752
|
if (browser === undefined)
|
|
740
753
|
throw new Error('tool-browser: browser service unavailable');
|
|
741
|
-
const
|
|
742
|
-
const session = sessionsByTask.get(key) ?? await browser.open();
|
|
743
|
-
sessionsByTask.set(key, session);
|
|
754
|
+
const session = await ensureSession(browser, taskKey(exec));
|
|
744
755
|
const tabs = await browser.listTabs(session);
|
|
745
756
|
return { session, tabs: tabs.map(t => ({ id: t.id, url: t.url, active: t.active })) };
|
|
746
757
|
},
|
|
@@ -763,8 +774,14 @@ export function apply(ctx, config = {}) {
|
|
|
763
774
|
const key = taskKey(exec);
|
|
764
775
|
const session = sessionsByTask.get(key);
|
|
765
776
|
if (session !== undefined) {
|
|
766
|
-
|
|
767
|
-
|
|
777
|
+
try {
|
|
778
|
+
await browser.close(session);
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
// Always forget the mapping so the next call opens a fresh session,
|
|
782
|
+
// even if the provider close threw (the session is half-closed).
|
|
783
|
+
sessionsByTask.delete(key);
|
|
784
|
+
}
|
|
768
785
|
}
|
|
769
786
|
return { reset: true };
|
|
770
787
|
},
|
|
@@ -787,8 +804,15 @@ export function apply(ctx, config = {}) {
|
|
|
787
804
|
isConcurrencySafe: () => false,
|
|
788
805
|
async execute(args, exec) {
|
|
789
806
|
// Always allowed so the guard can be lifted.
|
|
790
|
-
|
|
791
|
-
|
|
807
|
+
const allowed = args.allowed ?? [];
|
|
808
|
+
const unknown = allowed.filter((t) => !t.startsWith('browser_'));
|
|
809
|
+
if (unknown.length > 0) {
|
|
810
|
+
throw new Error(`browser_restrict: unknown tool name(s) ${unknown.map(t => `"${t}"`).join(', ')} (must start with "browser_")`);
|
|
811
|
+
}
|
|
812
|
+
// Empty list (or omitted) lifts the restriction; a non-empty list is the
|
|
813
|
+
// new allow-list.
|
|
814
|
+
restrictedTo = allowed.length === 0 ? undefined : [...allowed];
|
|
815
|
+
return { restrictedTo: restrictedTo === undefined ? [] : [...restrictedTo] };
|
|
792
816
|
},
|
|
793
817
|
}));
|
|
794
818
|
ctx.tools.register(defineTool({
|
|
@@ -829,8 +853,8 @@ export function apply(ctx, config = {}) {
|
|
|
829
853
|
}
|
|
830
854
|
/** Test hook: inspect and reset the plugin-level session map (used by tests). */
|
|
831
855
|
export const internals = {
|
|
832
|
-
/**
|
|
833
|
-
get sessions() { return sessionsByTask; },
|
|
856
|
+
/** A copy of the per-task session map (task key -> provider session id). */
|
|
857
|
+
get sessions() { return new Map(sessionsByTask); },
|
|
834
858
|
/** Drop one task's mapping without closing the provider session. */
|
|
835
859
|
clearSession(key = 'default') { sessionsByTask.delete(key); },
|
|
836
860
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Shared real browser
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
package/src/browser/runtime.ts
CHANGED
|
@@ -250,9 +250,21 @@ export class BrowserRuntime extends Service {
|
|
|
250
250
|
return this.resolveProvider().restoreAuth(session, cookies)
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
/** Close the session through the selected provider. Idempotent
|
|
253
|
+
/** Close the session through the selected provider. Idempotent; a missing
|
|
254
|
+
* provider is treated as already-closed so teardown paths stay no-ops. */
|
|
254
255
|
async close(session: BrowserSessionId): Promise<void> {
|
|
255
|
-
|
|
256
|
+
try {
|
|
257
|
+
await this.resolveProvider().close(session)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const code = error instanceof BrowserError ? (error as { code?: string }).code : undefined
|
|
260
|
+
if (code === 'BROWSER_PROVIDER_UNAVAILABLE'
|
|
261
|
+
|| code === 'BROWSER_PROVIDER_CONFIGURED_MISSING'
|
|
262
|
+
|| code === 'BROWSER_PROVIDER_CONFIGURED_UNAVAILABLE'
|
|
263
|
+
|| code === 'BROWSER_PROVIDER_AMBIGUOUS') {
|
|
264
|
+
return // provider gone; nothing to close
|
|
265
|
+
}
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
256
268
|
}
|
|
257
269
|
}
|
|
258
270
|
|
package/src/browser/types.ts
CHANGED
|
@@ -313,7 +313,8 @@ export interface BrowserProvider {
|
|
|
313
313
|
export interface BrowserHistoryEntry {
|
|
314
314
|
/** Monotonic sequence number within the session. */
|
|
315
315
|
readonly seq: number
|
|
316
|
-
/** The operation name: navigate | execute | click | type
|
|
316
|
+
/** The operation name. Replayable: navigate | execute | click | type.
|
|
317
|
+
* Also recorded (non-replayable): fill | download | flushAuth | restoreAuth. */
|
|
317
318
|
readonly action: string
|
|
318
319
|
/** The operation's arguments. */
|
|
319
320
|
readonly params: Record<string, unknown>
|
|
@@ -49,10 +49,15 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
49
49
|
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
50
50
|
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
51
51
|
const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
|
|
52
|
+
// Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
|
|
53
|
+
// is bound to the seam's own fiber (the browser row), so a reload of this
|
|
54
|
+
// row would otherwise collide with the still-registered provider
|
|
55
|
+
// (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
|
|
56
|
+
const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }))
|
|
52
57
|
ctx.effect(() => () => {
|
|
58
|
+
unregister()
|
|
53
59
|
if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
|
|
54
60
|
host.dispose()
|
|
55
61
|
}
|
|
56
62
|
})
|
|
57
|
-
ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }))
|
|
58
63
|
}
|
|
@@ -59,11 +59,13 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
|
|
|
59
59
|
window.on('closed', () => { window = undefined })
|
|
60
60
|
}
|
|
61
61
|
const view = new WebContentsView()
|
|
62
|
+
// New views start hidden: with several sessions/tabs only the shown
|
|
63
|
+
// one may be visible (they stack in contentView child order).
|
|
62
64
|
view.setVisible(false)
|
|
63
65
|
window.contentView.addChildView(view)
|
|
64
66
|
const [width, height] = window.getContentSize()
|
|
65
67
|
view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 })
|
|
66
|
-
view.setVisible(true)
|
|
68
|
+
if (views.size === 0) view.setVisible(true)
|
|
67
69
|
view.webContents.debugger.attach(CDP_VERSION)
|
|
68
70
|
views.set(viewId, { webContentsView: view })
|
|
69
71
|
reply(msg.id, { ok: true })
|
|
@@ -86,7 +88,19 @@ async function handle(op: string, msg: { id: number; viewId?: string; method?: s
|
|
|
86
88
|
const viewId = msg.viewId
|
|
87
89
|
if (viewId === undefined) throw new Error('showView missing viewId')
|
|
88
90
|
const entry = views.get(viewId)
|
|
89
|
-
if (entry !== undefined)
|
|
91
|
+
if (entry !== undefined) {
|
|
92
|
+
// Hide every other view, then show and RAISE the target so the
|
|
93
|
+
// human actually sees the active tab/session (topmost child wins).
|
|
94
|
+
for (const v of views.values()) {
|
|
95
|
+
if (v === entry) continue
|
|
96
|
+
try { v.webContentsView.setVisible(false) } catch { /* destroyed */ }
|
|
97
|
+
}
|
|
98
|
+
entry.webContentsView.setVisible(true)
|
|
99
|
+
if (window !== undefined) {
|
|
100
|
+
window.contentView.removeChildView(entry.webContentsView)
|
|
101
|
+
window.contentView.addChildView(entry.webContentsView)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
90
104
|
reply(msg.id, { ok: true })
|
|
91
105
|
return
|
|
92
106
|
}
|
|
@@ -220,6 +234,12 @@ void app.whenReady().then(() => {
|
|
|
220
234
|
socket.on('error', error => {
|
|
221
235
|
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
|
|
222
236
|
})
|
|
237
|
+
// The parent owns our lifetime: when it closes the socket (dispose) or dies
|
|
238
|
+
// without cleanup, exit so no zombie Electron window is left behind.
|
|
239
|
+
socket.on('close', () => {
|
|
240
|
+
process.stderr.write('[dsh-browser host] parent connection closed, exiting\n')
|
|
241
|
+
app.exit(0)
|
|
242
|
+
})
|
|
223
243
|
// Keep the process alive until the parent closes the socket or kills us.
|
|
224
244
|
})
|
|
225
245
|
|
|
@@ -39,11 +39,11 @@ const CHALLENGE_DETECT_EXPRESSION = `(() => {
|
|
|
39
39
|
const lower = (title + '\\n' + bodyText).toLowerCase()
|
|
40
40
|
const frameSrcs = [...document.querySelectorAll('iframe')].map(f => f.src || '').join(' ')
|
|
41
41
|
const framesLower = frameSrcs.toLowerCase()
|
|
42
|
-
const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl
|
|
42
|
+
const hasCfInterstitial = /just a moment|checking your browser|attention required|cf_chl/i.test(lower)
|
|
43
43
|
|| !!document.querySelector('#challenge-running, #challenge-stage, #cf-chl-container')
|
|
44
44
|
const hasHCaptcha = !!window.hcaptcha || !!document.querySelector('.h-captcha') || /hcaptcha\\.com/i.test(framesLower)
|
|
45
45
|
const hasRecaptcha = !!window.grecaptcha || !!document.querySelector('.g-recaptcha') || /recaptcha\\/api|google\\.com\\/recaptcha/i.test(framesLower)
|
|
46
|
-
const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile/i.test(lower)
|
|
46
|
+
const hasTurnstile = !!window.turnstile || /challenges\\.cloudflare\\.com/i.test(framesLower) || /turnstile|challenge-platform/i.test(lower)
|
|
47
47
|
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)
|
|
48
48
|
if (hasCfInterstitial) return { blocked: true, kind: 'cloudflare', reason: 'Cloudflare "Just a moment" interstitial' }
|
|
49
49
|
if (hasHCaptcha) return { blocked: true, kind: 'hcaptcha', reason: 'hCaptcha verification' }
|
|
@@ -114,8 +114,10 @@ interface Session {
|
|
|
114
114
|
readonly id: BrowserSessionId
|
|
115
115
|
readonly tabs: Tab[]
|
|
116
116
|
activeIndex: number
|
|
117
|
-
/** Chronological operation log (navigate/execute/click/type/
|
|
117
|
+
/** Chronological operation log (navigate/execute/click/type/fill/download/auth). */
|
|
118
118
|
readonly history: BrowserHistoryEntry[]
|
|
119
|
+
/** Monotonic sequence counter for history entries (survives truncation). */
|
|
120
|
+
nextSeq: number
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
/** Provider config: navigation admission defaults and snapshot caps. */
|
|
@@ -207,7 +209,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
207
209
|
open(): Promise<BrowserSessionId> {
|
|
208
210
|
const handle = this.host.createView()
|
|
209
211
|
const id = `browser:${randomUUID()}`
|
|
210
|
-
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [] })
|
|
212
|
+
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 })
|
|
211
213
|
return Promise.resolve(id)
|
|
212
214
|
}
|
|
213
215
|
|
|
@@ -302,15 +304,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
302
304
|
}
|
|
303
305
|
}
|
|
304
306
|
signal?.throwIfAborted()
|
|
305
|
-
|
|
307
|
+
// Page.navigate can hang on an unreachable/slow host; bound it like the
|
|
308
|
+
// evaluate paths so a wedged navigation surfaces as an error instead of
|
|
309
|
+
// blocking the tool call forever.
|
|
310
|
+
const timeoutMs = 30_000
|
|
311
|
+
const result = await withTimeout(
|
|
312
|
+
handle.sendCommand(CDP_PAGE_NAVIGATE, { url } satisfies CdpNavigateParams),
|
|
313
|
+
timeoutMs,
|
|
314
|
+
signal,
|
|
315
|
+
`browser: navigation timed out after ${timeoutMs}ms`,
|
|
316
|
+
)
|
|
306
317
|
// Page.navigate resolves even when the navigation fails; surface the
|
|
307
318
|
// failure instead of leaving a silent white screen.
|
|
308
319
|
const errorText = (result as { errorText?: string }).errorText
|
|
309
320
|
if (typeof errorText === 'string' && errorText !== '') {
|
|
310
|
-
this.record(s, 'navigate', { url }, false, { error: errorText })
|
|
311
321
|
throw new BrowserError(`browser: navigation to "${url}" failed: ${errorText}`, 'BROWSER_NAVIGATION_FAILED')
|
|
312
322
|
}
|
|
313
323
|
this.record(s, 'navigate', { url }, true)
|
|
324
|
+
this.showActive(s)
|
|
314
325
|
} catch (error) {
|
|
315
326
|
if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
|
|
316
327
|
this.record(s, 'navigate', { url }, false, { error: String(error) })
|
|
@@ -362,9 +373,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
362
373
|
return { ok: false, exception }
|
|
363
374
|
}
|
|
364
375
|
const value = (result.result as { value?: unknown } | undefined)?.value ?? null
|
|
365
|
-
this.record(s, 'execute', {
|
|
376
|
+
this.record(s, 'execute', {
|
|
377
|
+
script: request.script,
|
|
378
|
+
...request.args !== undefined && request.args.length > 0 ? { args: request.args } : {},
|
|
379
|
+
}, true, { result: typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value).slice(0, 500) })
|
|
366
380
|
return { ok: true, value }
|
|
367
381
|
} catch (error) {
|
|
382
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
383
|
+
throw new BrowserError(`browser: execute timed out after ${request.timeoutMs ?? 30_000}ms`, 'BROWSER_EXECUTE_TIMEOUT', { cause: error })
|
|
384
|
+
}
|
|
368
385
|
throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error })
|
|
369
386
|
}
|
|
370
387
|
}
|
|
@@ -476,13 +493,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
476
493
|
return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
|
|
477
494
|
})()`
|
|
478
495
|
// Honor a per-call timeout: content evaluation can hang on a heavy page,
|
|
479
|
-
// so a caller-supplied budget bounds it.
|
|
480
|
-
//
|
|
496
|
+
// so a caller-supplied budget bounds it. Unlike a bare signal entry check,
|
|
497
|
+
// withTimeout also interrupts a call already in flight.
|
|
481
498
|
const timeoutMs = request.timeoutMs ?? 30_000
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
499
|
+
const result = await withTimeout(
|
|
500
|
+
handleSendEvaluate(tab.handle, script),
|
|
501
|
+
timeoutMs,
|
|
502
|
+
signal,
|
|
503
|
+
`browser: content timed out after ${timeoutMs}ms`,
|
|
504
|
+
)
|
|
486
505
|
if (!result.ok) throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED')
|
|
487
506
|
const value = result.value as { ok: boolean; reason?: string; content?: string; truncated?: boolean }
|
|
488
507
|
if (!value.ok) throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED')
|
|
@@ -505,7 +524,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
505
524
|
const { handle } = this.activeTab(s)
|
|
506
525
|
signal?.throwIfAborted()
|
|
507
526
|
await handle.sendCommand('Input.insertText', { text: request.text } satisfies CdpInsertTextParams)
|
|
508
|
-
|
|
527
|
+
// Store the full text so replay re-issues the same input; the history
|
|
528
|
+
// tool truncates long values when rendering.
|
|
529
|
+
this.record(s, 'type', { text: request.text }, true)
|
|
509
530
|
}
|
|
510
531
|
|
|
511
532
|
/**
|
|
@@ -565,7 +586,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
565
586
|
return vis.length > 0 ? vis : all
|
|
566
587
|
}
|
|
567
588
|
for (const spec of specs) {
|
|
568
|
-
|
|
589
|
+
let els
|
|
590
|
+
try {
|
|
591
|
+
els = candidates(spec)
|
|
592
|
+
} catch (e) {
|
|
593
|
+
// A malformed selector must not abort the whole batch; report the
|
|
594
|
+
// field as failed and continue with the rest.
|
|
595
|
+
out.push({ ok: false, error: String(e), target: describe(spec) })
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
569
598
|
if (els.length === 0) { out.push({ ok: false, error: 'field not found', target: describe(spec) }); continue }
|
|
570
599
|
const el = els[0]
|
|
571
600
|
const tag = el.tagName
|
|
@@ -625,7 +654,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
625
654
|
let submitted = false
|
|
626
655
|
if (${submitFlag}) {
|
|
627
656
|
let last = null
|
|
628
|
-
for (const spec of specs) {
|
|
657
|
+
for (const spec of specs) {
|
|
658
|
+
try { const els = candidates(spec); if (els.length > 0) { last = els[0]; break } } catch { /* skip */ }
|
|
659
|
+
}
|
|
629
660
|
const form = last && (last.form || last.closest('form'))
|
|
630
661
|
if (form) { form.requestSubmit(); submitted = true }
|
|
631
662
|
}
|
|
@@ -710,7 +741,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
710
741
|
// a clip this yields the full-page image (CDP default is the viewport).
|
|
711
742
|
params.captureBeyondViewport = true
|
|
712
743
|
}
|
|
713
|
-
|
|
744
|
+
// Bound the capture: a view that has not painted can wedge
|
|
745
|
+
// Page.captureScreenshot indefinitely (observed after view re-showing).
|
|
746
|
+
const timeoutMs = 30_000
|
|
747
|
+
const result = await withTimeout(
|
|
748
|
+
handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params),
|
|
749
|
+
timeoutMs,
|
|
750
|
+
signal,
|
|
751
|
+
`browser: screenshot timed out after ${timeoutMs}ms`,
|
|
752
|
+
)
|
|
714
753
|
const data = result.data
|
|
715
754
|
if (typeof data !== 'string') {
|
|
716
755
|
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED')
|
|
@@ -739,7 +778,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
739
778
|
detail?: { result?: string; error?: string },
|
|
740
779
|
): void {
|
|
741
780
|
const entry: BrowserHistoryEntry = {
|
|
742
|
-
seq: s.
|
|
781
|
+
seq: s.nextSeq++,
|
|
743
782
|
action,
|
|
744
783
|
params,
|
|
745
784
|
ok,
|
|
@@ -796,8 +835,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
796
835
|
case 'execute': {
|
|
797
836
|
const script = entry.params.script
|
|
798
837
|
if (typeof script !== 'string') throw new BrowserError(`browser: history seq ${seq} execute has no script`, 'BROWSER_HISTORY_INVALID')
|
|
799
|
-
const
|
|
800
|
-
|
|
838
|
+
const recordedArgs = entry.params.args
|
|
839
|
+
const args = Array.isArray(recordedArgs) ? recordedArgs.filter((a): a is string => typeof a === 'string') : undefined
|
|
840
|
+
const result = await this.execute(session, { script, ...args !== undefined && args.length > 0 ? { args } : {} })
|
|
841
|
+
this.record(s, 'replay', { seq, of: entry.action, script, ...args !== undefined && args.length > 0 ? { args } : {} }, result.ok, result.ok ? { result: String(result.value) } : { error: result.exception })
|
|
801
842
|
return
|
|
802
843
|
}
|
|
803
844
|
default:
|
|
@@ -872,7 +913,12 @@ function withTimeout<T>(
|
|
|
872
913
|
const timer = setTimeout(() => {
|
|
873
914
|
if (done) return
|
|
874
915
|
done = true
|
|
875
|
-
|
|
916
|
+
// A fired timeout must also release the abort listener; { once: true }
|
|
917
|
+
// only releases it on the next abort, which may never come.
|
|
918
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
919
|
+
const error = new Error(message)
|
|
920
|
+
error.name = 'TimeoutError'
|
|
921
|
+
reject(error)
|
|
876
922
|
}, ms)
|
|
877
923
|
const finish = (fn: () => void): void => {
|
|
878
924
|
if (done) return
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Self-hosted Electron browser host (parent side): an
|
|
3
3
|
* {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
|
|
4
4
|
* Electron child process (host-main.js) and drives it over line-delimited
|
|
5
|
-
* JSON-RPC on
|
|
6
|
-
* desktop shell's electronViewHost (plain dsh web):
|
|
7
|
-
* enough — the browser window appears on first use.
|
|
5
|
+
* JSON-RPC on a loopback TCP socket. This is what makes the plugin work on
|
|
6
|
+
* surfaces without a desktop shell's electronViewHost (plain dsh web):
|
|
7
|
+
* installing the plugin is enough — the browser window appears on first use.
|
|
8
8
|
*
|
|
9
9
|
* Protocol (one JSON object per line, both directions):
|
|
10
10
|
* -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
|
|
@@ -159,10 +159,13 @@ class ElectronChildClient {
|
|
|
159
159
|
private socket: import('node:net').Socket | undefined
|
|
160
160
|
private connected = false
|
|
161
161
|
private outbox: string[] = []
|
|
162
|
+
/** Set once the child has exited; further calls fail fast instead of queueing. */
|
|
163
|
+
private dead = false
|
|
162
164
|
|
|
163
165
|
constructor(
|
|
164
166
|
private readonly hostMainPath: string,
|
|
165
167
|
private readonly port: number,
|
|
168
|
+
private readonly onExit?: () => void,
|
|
166
169
|
) {
|
|
167
170
|
const electron = resolveElectronPath()
|
|
168
171
|
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
@@ -181,24 +184,45 @@ class ElectronChildClient {
|
|
|
181
184
|
// Diagnostics only; never parse stderr as protocol.
|
|
182
185
|
process.stderr.write(`[dsh-browser host] ${String(chunk)}`)
|
|
183
186
|
})
|
|
187
|
+
// A failed spawn (bad/corrupt binary) emits 'error' — without a listener
|
|
188
|
+
// that would crash the whole DSH process.
|
|
189
|
+
this.child.on('error', error => {
|
|
190
|
+
process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`)
|
|
191
|
+
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
|
|
192
|
+
})
|
|
184
193
|
this.child.on('exit', (code, signal) => {
|
|
185
|
-
|
|
186
|
-
for (const pending of this.pending.values()) pending.reject(err)
|
|
187
|
-
this.pending.clear()
|
|
194
|
+
this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`))
|
|
188
195
|
})
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
/** Reject everything in flight, mark the client dead, and notify the host. */
|
|
199
|
+
private fail(err: Error): void {
|
|
200
|
+
if (this.dead) return
|
|
201
|
+
this.dead = true
|
|
202
|
+
this.connected = false
|
|
203
|
+
for (const pending of this.pending.values()) pending.reject(err)
|
|
204
|
+
this.pending.clear()
|
|
205
|
+
this.outbox = []
|
|
206
|
+
this.onExit?.()
|
|
207
|
+
}
|
|
208
|
+
|
|
191
209
|
/** Accept the child's connection (called by the server). */
|
|
192
210
|
attach(socket: import('node:net').Socket): void {
|
|
193
211
|
this.socket = socket
|
|
194
212
|
this.connected = true
|
|
195
213
|
socket.setEncoding('utf8')
|
|
214
|
+
// Without an 'error' listener a remote reset (ECONNRESET/EPIPE) throws an
|
|
215
|
+
// uncaught 'error' event and crashes the whole DSH process; 'close' below
|
|
216
|
+
// does the cleanup.
|
|
217
|
+
socket.on('error', error => {
|
|
218
|
+
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`)
|
|
219
|
+
})
|
|
196
220
|
socket.on('data', chunk => this.onData(chunk))
|
|
197
221
|
socket.on('close', () => {
|
|
198
222
|
this.connected = false
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
223
|
+
if (!this.dead) {
|
|
224
|
+
this.fail(new Error('dsh-builtin-browser: browser host connection closed'))
|
|
225
|
+
}
|
|
202
226
|
})
|
|
203
227
|
// Flush anything queued while disconnected.
|
|
204
228
|
if (this.outbox.length > 0) {
|
|
@@ -232,6 +256,9 @@ class ElectronChildClient {
|
|
|
232
256
|
|
|
233
257
|
/** Send one command and await the reply. */
|
|
234
258
|
call<T = unknown>(op: string, payload: Record<string, unknown> = {}): Promise<T> {
|
|
259
|
+
if (this.dead) {
|
|
260
|
+
return Promise.reject(new Error('dsh-builtin-browser: browser host is not running'))
|
|
261
|
+
}
|
|
235
262
|
const id = this.nextId++
|
|
236
263
|
const line = JSON.stringify({ id, op, ...payload })
|
|
237
264
|
return new Promise<T>((resolve, reject) => {
|
|
@@ -292,10 +319,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
292
319
|
private pendingSocket: Socket | undefined
|
|
293
320
|
private readonly views = new Map<string, ElectronViewHandle>()
|
|
294
321
|
private readyPromise: Promise<void> | undefined
|
|
322
|
+
private disposed = false
|
|
295
323
|
|
|
296
324
|
constructor(private readonly hostMainPath: string) {}
|
|
297
325
|
|
|
298
|
-
/** Ensure the child is up and ready (lazy on first use). */
|
|
326
|
+
/** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
|
|
299
327
|
private ready(): Promise<void> {
|
|
300
328
|
if (this.readyPromise !== undefined) return this.readyPromise
|
|
301
329
|
this.readyPromise = this.start()
|
|
@@ -312,10 +340,15 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
312
340
|
server.once('error', reject)
|
|
313
341
|
server.listen(0, '127.0.0.1', () => resolve())
|
|
314
342
|
})
|
|
343
|
+
// A later server error (rare on a loopback ephemeral port) must not crash
|
|
344
|
+
// the process; the client's fail path handles the actual recovery.
|
|
345
|
+
server.on('error', error => {
|
|
346
|
+
process.stderr.write(`[dsh-browser host] rpc server error: ${String(error)}\n`)
|
|
347
|
+
})
|
|
315
348
|
const address = server.address()
|
|
316
349
|
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
317
350
|
this.server = server
|
|
318
|
-
this.client = new ElectronChildClient(this.hostMainPath, port)
|
|
351
|
+
this.client = new ElectronChildClient(this.hostMainPath, port, () => this.onChildExit())
|
|
319
352
|
if (this.pendingSocket !== undefined) {
|
|
320
353
|
this.client.attach(this.pendingSocket)
|
|
321
354
|
this.pendingSocket = undefined
|
|
@@ -324,6 +357,18 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
324
357
|
await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready')
|
|
325
358
|
}
|
|
326
359
|
|
|
360
|
+
/** The child died: tear down so the next use starts a fresh child. */
|
|
361
|
+
private onChildExit(): void {
|
|
362
|
+
if (this.disposed) return
|
|
363
|
+
this.client = undefined
|
|
364
|
+
this.server?.close()
|
|
365
|
+
this.server = undefined
|
|
366
|
+
this.pendingSocket = undefined
|
|
367
|
+
this.readyPromise = undefined
|
|
368
|
+
// Keep the views map: handles still resolve to ids; a fresh child simply
|
|
369
|
+
// has no such views yet, and reset_session reopens clean sessions.
|
|
370
|
+
}
|
|
371
|
+
|
|
327
372
|
createView(): ElectronViewHandle {
|
|
328
373
|
// The seam is synchronous; the provider uses the handle immediately, so
|
|
329
374
|
// commands are deferred until the child is up and the view materialized.
|
|
@@ -344,7 +389,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
344
389
|
}
|
|
345
390
|
|
|
346
391
|
showView(handle: ElectronViewHandle): void {
|
|
347
|
-
|
|
392
|
+
// Fire-and-forget by design (visibility is best-effort), but a rejected
|
|
393
|
+
// promise must not become an unhandled rejection (crash on Node >= 15).
|
|
394
|
+
void this.ready()
|
|
395
|
+
.then(() => this.client?.call('showView', { viewId: handle.id }))
|
|
396
|
+
.catch(() => { /* host unavailable */ })
|
|
348
397
|
}
|
|
349
398
|
|
|
350
399
|
destroyView(handle: ElectronViewHandle): void {
|
|
@@ -358,6 +407,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
358
407
|
|
|
359
408
|
/** Shut the child and the RPC server down. */
|
|
360
409
|
dispose(): void {
|
|
410
|
+
this.disposed = true
|
|
361
411
|
this.client?.kill()
|
|
362
412
|
this.client = undefined
|
|
363
413
|
this.server?.close()
|
package/src/index.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-browser plugin entry: aggregates the shared-browser capability
|
|
3
|
-
* The cordis.patch.yml rows reference subpath exports:
|
|
4
|
-
* - `dsh-browser/browser`
|
|
5
|
-
* - `dsh-browser/browser-electron`
|
|
6
|
-
* - `dsh-browser/tool-browser`
|
|
2
|
+
* dsh-builtin-browser plugin entry: aggregates the shared-browser capability
|
|
3
|
+
* pieces. The cordis.patch.yml rows reference subpath exports:
|
|
4
|
+
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
|
+
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
|
+
* - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
7
|
* This root entry only re-exports for programmatic use; the loader rows are
|
|
8
8
|
* the composition surface.
|
|
9
|
-
* @module dsh-browser
|
|
9
|
+
* @module dsh-builtin-browser
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
export { BrowserError } from './browser/types.ts'
|
|
13
13
|
export type {
|
|
14
|
+
BrowserChallenge,
|
|
14
15
|
BrowserClickRequest,
|
|
15
16
|
BrowserContentFormat,
|
|
16
17
|
BrowserContentRequest,
|
|
17
18
|
BrowserContentResult,
|
|
18
19
|
BrowserExecuteRequest,
|
|
19
20
|
BrowserExecuteResult,
|
|
21
|
+
BrowserFillField,
|
|
22
|
+
BrowserFillRequest,
|
|
23
|
+
BrowserFillResult,
|
|
20
24
|
BrowserNavigateRequest,
|
|
21
25
|
BrowserOpenRequest,
|
|
22
26
|
BrowserProvider,
|
|
@@ -27,7 +31,9 @@ export type {
|
|
|
27
31
|
BrowserSnapshotResult,
|
|
28
32
|
BrowserTab,
|
|
29
33
|
BrowserTypeRequest,
|
|
34
|
+
ExportedCookie,
|
|
30
35
|
} from './browser/types.ts'
|
|
31
36
|
export { BrowserRuntime } from './browser/runtime.ts'
|
|
32
37
|
export { ElectronBrowserProvider } from './browser-electron/provider.ts'
|
|
33
38
|
export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts'
|
|
39
|
+
export { RemoteElectronViewHost, defaultHostMainPath } from './browser-electron/remote-host.ts'
|