dsh-builtin-browser 0.1.23 → 0.3.0
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 +91 -3
- package/README.md +98 -4
- package/client.js +334 -0
- package/cordis.patch.yml +24 -5
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- package/docs/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/tool-reference.md +2 -1
- package/lib/browser/runtime.d.ts +18 -1
- package/lib/browser/runtime.js +19 -0
- package/lib/browser/types.d.ts +33 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +195 -11
- package/lib/browser-electron/history-store.d.ts +75 -0
- package/lib/browser-electron/history-store.js +162 -0
- package/lib/browser-electron/host-main.js +28 -1
- package/lib/browser-electron/provider.d.ts +122 -1
- package/lib/browser-electron/provider.js +233 -21
- package/lib/browser-electron/remote-host.d.ts +17 -1
- package/lib/browser-electron/remote-host.js +81 -9
- package/lib/browser-electron/settings-store.d.ts +92 -0
- package/lib/browser-electron/settings-store.js +183 -0
- package/lib/browser-electron/system-browser.d.ts +100 -0
- package/lib/browser-electron/system-browser.js +322 -0
- package/lib/browser-electron/virtual-cursor.d.ts +67 -0
- package/lib/browser-electron/virtual-cursor.js +141 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +122 -16
- package/package.json +14 -1
- package/src/browser/runtime.ts +22 -0
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +217 -11
- package/src/browser-electron/history-store.ts +171 -0
- package/src/browser-electron/host-main.ts +26 -1
- package/src/browser-electron/provider.ts +284 -21
- package/src/browser-electron/remote-host.ts +84 -7
- package/src/browser-electron/settings-store.ts +227 -0
- package/src/browser-electron/system-browser.ts +318 -0
- package/src/browser-electron/virtual-cursor.ts +152 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +121 -15
|
@@ -63,8 +63,13 @@ function taskKey(exec) {
|
|
|
63
63
|
*/
|
|
64
64
|
async function ensureSession(browser, state, key, agent) {
|
|
65
65
|
const existing = state.sessionsByTask.get(key);
|
|
66
|
-
if (existing !== undefined)
|
|
67
|
-
|
|
66
|
+
if (existing !== undefined) {
|
|
67
|
+
// The human closing a browser window ends that session, so a cached id can
|
|
68
|
+
// be stale: never hand a dead session back to a tool call.
|
|
69
|
+
if (browser.exists(existing))
|
|
70
|
+
return existing;
|
|
71
|
+
state.sessionsByTask.delete(key);
|
|
72
|
+
}
|
|
68
73
|
const pending = state.pendingOpens.get(key);
|
|
69
74
|
if (pending !== undefined)
|
|
70
75
|
return pending;
|
|
@@ -117,9 +122,20 @@ function parseFillValue(v) {
|
|
|
117
122
|
return Number(v);
|
|
118
123
|
return v ?? '';
|
|
119
124
|
}
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Format a snapshot element list for the model.
|
|
127
|
+
*
|
|
128
|
+
* Coordinates are opt-in. They are only meaningful to a caller that intends to
|
|
129
|
+
* click a pixel position, which means a vision pass; for the far more common case
|
|
130
|
+
* of locating an element by role/label and passing a semantic target, they are the
|
|
131
|
+
* bulkiest and least useful part of every line. Omitting them keeps the listing
|
|
132
|
+
* readable — which is exactly what a model without image input has to work from.
|
|
133
|
+
* @param snapshot - the snapshot payload.
|
|
134
|
+
* @param options - `coords: true` to include each element's viewport position.
|
|
135
|
+
*/
|
|
136
|
+
function formatSnapshot(snapshot, options = {}) {
|
|
137
|
+
const showCoords = options.coords === true;
|
|
138
|
+
const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`);
|
|
123
139
|
const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`;
|
|
124
140
|
const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)';
|
|
125
141
|
const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : '';
|
|
@@ -188,7 +204,10 @@ export function apply(ctx, config = {}) {
|
|
|
188
204
|
},
|
|
189
205
|
},
|
|
190
206
|
},
|
|
191
|
-
|
|
207
|
+
// browser_open is an overview of what just loaded, not a locator: it has no
|
|
208
|
+
// `coords` parameter, so coordinates stay off here (browser_snapshot owns
|
|
209
|
+
// that switch for callers that actually target pixels).
|
|
210
|
+
render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
|
|
192
211
|
},
|
|
193
212
|
timeoutMs,
|
|
194
213
|
isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
|
|
@@ -214,8 +233,10 @@ export function apply(ctx, config = {}) {
|
|
|
214
233
|
}));
|
|
215
234
|
ctx.tools.register(defineTool({
|
|
216
235
|
name: 'browser_snapshot',
|
|
217
|
-
description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite.
|
|
218
|
-
parameters: {
|
|
236
|
+
description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite. This is the primary way to understand a page, and it needs no image input — every listed element can afterwards be addressed by a semantic target (browser_click, browser_type, browser_check, …) rather than by coordinates.',
|
|
237
|
+
parameters: {
|
|
238
|
+
coords: { type: 'boolean', description: 'Also print each element\'s viewport coordinates. Off by default; only useful when a vision pass is going to click a pixel position.' },
|
|
239
|
+
},
|
|
219
240
|
output: {
|
|
220
241
|
schema: {
|
|
221
242
|
type: 'object',
|
|
@@ -251,7 +272,7 @@ export function apply(ctx, config = {}) {
|
|
|
251
272
|
},
|
|
252
273
|
},
|
|
253
274
|
},
|
|
254
|
-
render: (
|
|
275
|
+
render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
|
|
255
276
|
},
|
|
256
277
|
timeoutMs,
|
|
257
278
|
isConcurrencySafe: () => true,
|
|
@@ -276,6 +297,7 @@ export function apply(ctx, config = {}) {
|
|
|
276
297
|
parameters: {
|
|
277
298
|
includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
|
|
278
299
|
maxNodes: { type: 'number', description: 'Maximum nodes (default 500, range 10-5000).' },
|
|
300
|
+
coords: { type: 'boolean', description: 'Include each element\'s viewport coordinates. Off by default: a semantic target needs no coordinates, and they are pure noise unless a vision pass is going to click a pixel position.' },
|
|
279
301
|
},
|
|
280
302
|
output: {
|
|
281
303
|
schema: {
|
|
@@ -308,14 +330,24 @@ export function apply(ctx, config = {}) {
|
|
|
308
330
|
},
|
|
309
331
|
},
|
|
310
332
|
},
|
|
311
|
-
render: (
|
|
333
|
+
render: (args, value) => {
|
|
312
334
|
const nodes = value.nodes;
|
|
335
|
+
// Built for reading, not for imaging: containment is shown as indentation
|
|
336
|
+
// (the depth was already collected and simply thrown away), an empty state
|
|
337
|
+
// list adds nothing, and coordinates appear only when asked for — a model
|
|
338
|
+
// working from the DOM never needs them, and they are the bulkiest part of
|
|
339
|
+
// every line.
|
|
340
|
+
const showCoords = args.coords === true;
|
|
313
341
|
const lines = nodes.map(n => {
|
|
314
342
|
const valuePart = n.value !== undefined && n.value !== null ? ` value="${String(n.value).slice(0, 60)}"` : '';
|
|
315
|
-
|
|
343
|
+
const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)));
|
|
344
|
+
const coordsPart = showCoords ? ` (${n.x},${n.y})` : '';
|
|
345
|
+
const statePart = Array.isArray(n.states) && n.states.length > 0 ? ` states=[${n.states.join(',')}]` : '';
|
|
346
|
+
return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${n.frame === true ? ' (iframe)' : ''}`;
|
|
316
347
|
});
|
|
317
348
|
const header = `URL: ${value.url}${value.title !== undefined ? `\nTitle: ${value.title}` : ''}`;
|
|
318
|
-
|
|
349
|
+
const hint = showCoords ? '' : '\n(coordinates omitted — pass coords: true to include them, or click by semantic target)';
|
|
350
|
+
return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}${hint}` }];
|
|
319
351
|
},
|
|
320
352
|
},
|
|
321
353
|
timeoutMs,
|
|
@@ -548,7 +580,7 @@ export function apply(ctx, config = {}) {
|
|
|
548
580
|
}));
|
|
549
581
|
ctx.tools.register(defineTool({
|
|
550
582
|
name: 'browser_click',
|
|
551
|
-
description: 'Click in the shared browser.
|
|
583
|
+
description: 'Click in the shared browser. Prefer the semantic target: pass target {by: css|text|xpath, value, index?} and the element is located from the DOM, scrolled into view and clicked at its centre — this needs no image input and is the only form that works without vision. The alternative is viewport coordinates (x/y), which are meaningful only after a vision model has located the element on a browser_screenshot; coordinate clicks are refused outright under the non-visual strategy. Provide exactly one of target or x/y.',
|
|
552
584
|
parameters: {
|
|
553
585
|
target: {
|
|
554
586
|
type: 'object',
|
|
@@ -560,8 +592,8 @@ export function apply(ctx, config = {}) {
|
|
|
560
592
|
},
|
|
561
593
|
description: 'Locate the element semantically and click it (css/text/xpath).',
|
|
562
594
|
},
|
|
563
|
-
x: { type: 'number', description: 'Viewport x coordinate (CSS px),
|
|
564
|
-
y: { type: 'number', description: 'Viewport y coordinate (CSS px).' },
|
|
595
|
+
x: { type: 'number', description: 'Viewport x coordinate (CSS px), taken from a browser_screenshot read by a vision model. Prefer a semantic target — it works without image input.' },
|
|
596
|
+
y: { type: 'number', description: 'Viewport y coordinate (CSS px), taken from the same screenshot as x.' },
|
|
565
597
|
},
|
|
566
598
|
output: {
|
|
567
599
|
schema: { type: 'object', additionalProperties: false, properties: { clicked: { type: 'boolean', required: true } } },
|
|
@@ -1039,7 +1071,7 @@ export function apply(ctx, config = {}) {
|
|
|
1039
1071
|
}));
|
|
1040
1072
|
ctx.tools.register(defineTool({
|
|
1041
1073
|
name: 'browser_screenshot',
|
|
1042
|
-
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional).
|
|
1074
|
+
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
|
|
1043
1075
|
parameters: {
|
|
1044
1076
|
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
|
|
1045
1077
|
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
@@ -1285,6 +1317,80 @@ export function apply(ctx, config = {}) {
|
|
|
1285
1317
|
return { entries: rendered };
|
|
1286
1318
|
},
|
|
1287
1319
|
}));
|
|
1320
|
+
ctx.tools.register(defineTool({
|
|
1321
|
+
name: 'browser_visited',
|
|
1322
|
+
description: 'List pages the shared browser has visited, newest first, from the PERSISTENT browsing history: it survives closing the browser and restarting DSH, so it is how you (or the human) find a page again afterwards. Reopen one by passing its url to browser_open. Not to be confused with browser_history, which is only the current session\'s operation log (navigate/click/type) and disappears with the session.',
|
|
1323
|
+
parameters: {
|
|
1324
|
+
limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
|
|
1325
|
+
domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
|
|
1326
|
+
query: { type: 'string', description: 'Only visits whose URL or title contains this text (case-insensitive).' },
|
|
1327
|
+
session: { type: 'string', description: 'Only visits recorded by this task/session label — use it to separate your own pages from another session\'s or the human\'s.' },
|
|
1328
|
+
},
|
|
1329
|
+
output: {
|
|
1330
|
+
schema: {
|
|
1331
|
+
type: 'object',
|
|
1332
|
+
additionalProperties: false,
|
|
1333
|
+
properties: {
|
|
1334
|
+
count: { type: 'number', required: true },
|
|
1335
|
+
entries: {
|
|
1336
|
+
type: 'array',
|
|
1337
|
+
required: true,
|
|
1338
|
+
items: {
|
|
1339
|
+
type: 'object',
|
|
1340
|
+
additionalProperties: false,
|
|
1341
|
+
properties: {
|
|
1342
|
+
at: { type: 'number', required: true },
|
|
1343
|
+
url: { type: 'string', required: true },
|
|
1344
|
+
title: { type: 'string' },
|
|
1345
|
+
session: { type: 'string' },
|
|
1346
|
+
},
|
|
1347
|
+
},
|
|
1348
|
+
},
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
render: (_args, value) => {
|
|
1352
|
+
const entries = value.entries;
|
|
1353
|
+
if (entries.length === 0) {
|
|
1354
|
+
return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }];
|
|
1355
|
+
}
|
|
1356
|
+
return [{
|
|
1357
|
+
type: 'text',
|
|
1358
|
+
text: entries.map(entry => {
|
|
1359
|
+
const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 19);
|
|
1360
|
+
const title = entry.title !== undefined ? `${entry.title} — ` : '';
|
|
1361
|
+
return `${when} ${title}${entry.url}${entry.session !== undefined ? ` [${entry.session}]` : ''}`;
|
|
1362
|
+
}).join('\n'),
|
|
1363
|
+
}];
|
|
1364
|
+
},
|
|
1365
|
+
},
|
|
1366
|
+
timeoutMs,
|
|
1367
|
+
isConcurrencySafe: () => true,
|
|
1368
|
+
async execute(args, _exec) {
|
|
1369
|
+
const browser = ctx.get('browser');
|
|
1370
|
+
if (browser === undefined)
|
|
1371
|
+
throw new Error('tool-browser: browser service unavailable');
|
|
1372
|
+
const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30;
|
|
1373
|
+
const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)));
|
|
1374
|
+
const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined;
|
|
1375
|
+
const query = typeof args.query === 'string' && args.query.trim() !== '' ? args.query.trim() : undefined;
|
|
1376
|
+
const session = typeof args.session === 'string' && args.session.trim() !== '' ? args.session.trim() : undefined;
|
|
1377
|
+
const entries = browser.visited({
|
|
1378
|
+
limit,
|
|
1379
|
+
...domain !== undefined ? { domain } : {},
|
|
1380
|
+
...query !== undefined ? { query } : {},
|
|
1381
|
+
...session !== undefined ? { session } : {},
|
|
1382
|
+
});
|
|
1383
|
+
return {
|
|
1384
|
+
count: entries.length,
|
|
1385
|
+
entries: entries.map(entry => ({
|
|
1386
|
+
at: entry.at,
|
|
1387
|
+
url: entry.url,
|
|
1388
|
+
...entry.title !== undefined ? { title: entry.title } : {},
|
|
1389
|
+
...entry.session !== undefined ? { session: entry.session } : {},
|
|
1390
|
+
})),
|
|
1391
|
+
};
|
|
1392
|
+
},
|
|
1393
|
+
}));
|
|
1288
1394
|
ctx.tools.register(defineTool({
|
|
1289
1395
|
name: 'browser_replay',
|
|
1290
1396
|
description: 'Replay one recorded browser operation by its history sequence number (from browser_history). Navigate/click/type are re-issued against the current page; execute re-runs its script. The replayed step is appended to history as a new entry.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"./host-main": {
|
|
26
26
|
"default": "./lib/browser-electron/host-main.js"
|
|
27
27
|
},
|
|
28
|
+
"./client": {
|
|
29
|
+
"default": "./client.js"
|
|
30
|
+
},
|
|
28
31
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
29
32
|
"./package.json": "./package.json"
|
|
30
33
|
},
|
|
@@ -32,6 +35,8 @@
|
|
|
32
35
|
"lib",
|
|
33
36
|
"src",
|
|
34
37
|
"docs",
|
|
38
|
+
"desktop-bridge",
|
|
39
|
+
"client.js",
|
|
35
40
|
"cordis.patch.yml",
|
|
36
41
|
"README.md",
|
|
37
42
|
"README.en.md",
|
|
@@ -78,6 +83,14 @@
|
|
|
78
83
|
"bundle": {
|
|
79
84
|
"patch": "./cordis.patch.yml"
|
|
80
85
|
},
|
|
86
|
+
"client": {
|
|
87
|
+
"platform": "web",
|
|
88
|
+
"inject": [
|
|
89
|
+
"@deepseek-ai/dsh-client-locale",
|
|
90
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
91
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
92
|
+
]
|
|
93
|
+
},
|
|
81
94
|
"compatibility": {
|
|
82
95
|
"dsh": ">=0.1.1-rc.1 <0.3.0",
|
|
83
96
|
"profiles": [
|
package/src/browser/runtime.ts
CHANGED
|
@@ -47,6 +47,7 @@ import type {
|
|
|
47
47
|
BrowserKeyRequest,
|
|
48
48
|
BrowserChallenge,
|
|
49
49
|
ExportedCookie,
|
|
50
|
+
VisitedPage,
|
|
50
51
|
} from './types.js'
|
|
51
52
|
import { BrowserError } from './types.js'
|
|
52
53
|
|
|
@@ -345,6 +346,27 @@ export class BrowserRuntime extends Service {
|
|
|
345
346
|
return this.resolveProvider().history(session)
|
|
346
347
|
}
|
|
347
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Persisted visits, newest first — the record that outlives the browser
|
|
351
|
+
* process, as opposed to {@link history}'s session-scoped operation log.
|
|
352
|
+
* @param options - result cap and an optional hostname filter.
|
|
353
|
+
* @returns the retained visits (empty when history recording is disabled).
|
|
354
|
+
*/
|
|
355
|
+
visited(options?: { readonly limit?: number; readonly domain?: string }): readonly VisitedPage[] {
|
|
356
|
+
return this.resolveProvider().visited(options)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Whether a session is still live. Sessions can end outside the tool layer
|
|
361
|
+
* (the human closed its window), so a cached id must be re-checked before
|
|
362
|
+
* reuse; a provider that does not implement the check counts as live.
|
|
363
|
+
* @param session - the session id to test.
|
|
364
|
+
*/
|
|
365
|
+
exists(session: BrowserSessionId): boolean {
|
|
366
|
+
const provider = this.resolveProvider()
|
|
367
|
+
return provider.exists === undefined ? true : provider.exists(session)
|
|
368
|
+
}
|
|
369
|
+
|
|
348
370
|
/** Replay one recorded operation by sequence number through the provider. */
|
|
349
371
|
async replay(session: BrowserSessionId, seq: number): Promise<void> {
|
|
350
372
|
return this.resolveProvider().replay(session, seq)
|
package/src/browser/types.ts
CHANGED
|
@@ -573,12 +573,43 @@ export interface BrowserProvider {
|
|
|
573
573
|
restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number>
|
|
574
574
|
/** Return the session's chronological operation log. */
|
|
575
575
|
history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]>
|
|
576
|
+
/**
|
|
577
|
+
* Persisted visits, newest first: what survives the browser process, so a
|
|
578
|
+
* human or an Agent can find a page again after the interface was closed.
|
|
579
|
+
* @param options - result cap and an optional hostname filter.
|
|
580
|
+
* @returns the retained visits (empty when history is disabled).
|
|
581
|
+
*/
|
|
582
|
+
visited(options?: { readonly limit?: number; readonly domain?: string }): readonly VisitedPage[]
|
|
583
|
+
/**
|
|
584
|
+
* Optional: whether a session is still live. A session can end outside the
|
|
585
|
+
* tool layer — the human closed the browser window — so a cached id must be
|
|
586
|
+
* checked before it is reused. Providers that never end a session on their own
|
|
587
|
+
* can omit this, and callers treat absence as "still live".
|
|
588
|
+
* @param session - the session id to test.
|
|
589
|
+
*/
|
|
590
|
+
exists?(session: BrowserSessionId): boolean
|
|
576
591
|
/** Replay one recorded operation by sequence number. */
|
|
577
592
|
replay(session: BrowserSessionId, seq: number): Promise<void>
|
|
578
593
|
/** Close the session and destroy its backing surface. Idempotent. */
|
|
579
594
|
close(session: BrowserSessionId): Promise<void>
|
|
580
595
|
}
|
|
581
596
|
|
|
597
|
+
/**
|
|
598
|
+
* One visited page in the persistent browsing history — the durable record that
|
|
599
|
+
* outlives sessions, unlike {@link BrowserHistoryEntry}, which is a
|
|
600
|
+
* session-scoped operation log (navigate/click/type/…).
|
|
601
|
+
*/
|
|
602
|
+
export interface VisitedPage {
|
|
603
|
+
/** Epoch milliseconds when the visit was recorded. */
|
|
604
|
+
readonly at: number
|
|
605
|
+
/** Absolute URL of the visited document. */
|
|
606
|
+
readonly url: string
|
|
607
|
+
/** Document title at visit time, when the page exposed one. */
|
|
608
|
+
readonly title?: string
|
|
609
|
+
/** Session/task label that drove the visit, when the provider knows it. */
|
|
610
|
+
readonly session?: string
|
|
611
|
+
}
|
|
612
|
+
|
|
582
613
|
/** One recorded browser operation, in chronological order (seq 1, 2, 3…). */
|
|
583
614
|
export interface BrowserHistoryEntry {
|
|
584
615
|
/** Monotonic sequence number within the session. */
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport for the desktop shell's browser bridge: one long-lived connection to a
|
|
3
|
+
* loopback TCP service.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS ITS OWN MODULE
|
|
6
|
+
* The bridge used to be reached with a fresh connection per request, which measured
|
|
7
|
+
* at ~25ms per call (TCP handshake + token exchange) against ~0.2ms when the socket
|
|
8
|
+
* is reused. A single browser action issues several CDP commands, so that cost was
|
|
9
|
+
* multiplied on every tool call — the difference between a snappy agent and a
|
|
10
|
+
* visibly sluggish one. Reuse is therefore a property of the transport, and keeping
|
|
11
|
+
* it here means the view host above does not have to think about sockets at all.
|
|
12
|
+
*
|
|
13
|
+
* PROTOCOL
|
|
14
|
+
* The first line on a connection carries the token; the service marks the socket
|
|
15
|
+
* authenticated and ignores that line as a command. Requests and answers are then
|
|
16
|
+
* newline-delimited JSON, one answer per request, in order.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-browser/browser-electron/bridge-connection
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { connect, type Socket } from 'node:net'
|
|
22
|
+
|
|
23
|
+
/** Where the shell publishes its bridge endpoint. */
|
|
24
|
+
export interface BridgeEndpoint {
|
|
25
|
+
readonly port: number
|
|
26
|
+
readonly token: string
|
|
27
|
+
readonly pid: number
|
|
28
|
+
readonly updatedAt?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One queued request, waiting for its answer. */
|
|
32
|
+
interface PendingCall {
|
|
33
|
+
readonly resolve: (answer: Record<string, unknown>) => void
|
|
34
|
+
readonly reject: (error: Error) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A reusable connection to the bridge.
|
|
39
|
+
*
|
|
40
|
+
* Requests are serialised by construction: one socket, one in-flight request at a
|
|
41
|
+
* time, answered in order. A broken socket is discarded so the next call dials
|
|
42
|
+
* again — a dead connection must never become a dead plugin.
|
|
43
|
+
*/
|
|
44
|
+
export class BridgeConnection {
|
|
45
|
+
private socket: Socket | undefined
|
|
46
|
+
private buffer = ''
|
|
47
|
+
private readonly queue: PendingCall[] = []
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param endpoint - the shell's published bridge endpoint.
|
|
51
|
+
*/
|
|
52
|
+
constructor(private readonly endpoint: BridgeEndpoint) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Send one request, reusing the socket if it is still healthy.
|
|
56
|
+
* @param request - the request body (the token is added here).
|
|
57
|
+
* @param timeoutMs - how long to wait for the answer before dropping the socket.
|
|
58
|
+
* @returns the bridge's answer.
|
|
59
|
+
*/
|
|
60
|
+
async call(request: Record<string, unknown>, timeoutMs = 20_000): Promise<Record<string, unknown>> {
|
|
61
|
+
const socket = this.ensureSocket()
|
|
62
|
+
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
const index = this.queue.findIndex(entry => entry.resolve === resolve)
|
|
65
|
+
if (index !== -1) this.queue.splice(index, 1)
|
|
66
|
+
const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`)
|
|
67
|
+
this.reset(error)
|
|
68
|
+
reject(error)
|
|
69
|
+
}, timeoutMs)
|
|
70
|
+
this.queue.push({
|
|
71
|
+
resolve: answer => { clearTimeout(timer); resolve(answer) },
|
|
72
|
+
reject: error => { clearTimeout(timer); reject(error) },
|
|
73
|
+
})
|
|
74
|
+
try {
|
|
75
|
+
socket.write(JSON.stringify({ token: this.endpoint.token, ...request }) + '\n')
|
|
76
|
+
} catch (error) {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
this.queue.pop()
|
|
79
|
+
const failure = error instanceof Error ? error : new Error(String(error))
|
|
80
|
+
this.reset(failure)
|
|
81
|
+
reject(failure)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Close the socket and fail anything still waiting on it. */
|
|
87
|
+
close(): void {
|
|
88
|
+
this.reset(new Error('dsh-builtin-browser: bridge closed'))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The live socket, connecting and authenticating it on first use. */
|
|
92
|
+
private ensureSocket(): Socket {
|
|
93
|
+
if (this.socket !== undefined) return this.socket
|
|
94
|
+
const socket = connect({ host: '127.0.0.1', port: this.endpoint.port })
|
|
95
|
+
socket.setEncoding('utf8')
|
|
96
|
+
socket.on('data', chunk => this.onData(String(chunk)))
|
|
97
|
+
socket.on('error', error => this.reset(error))
|
|
98
|
+
socket.on('close', () => this.reset(new Error('dsh-builtin-browser: bridge connection closed')))
|
|
99
|
+
this.buffer = ''
|
|
100
|
+
this.socket = socket
|
|
101
|
+
socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n')
|
|
102
|
+
return socket
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resolve queued requests in order as answers arrive. */
|
|
106
|
+
private onData(chunk: string): void {
|
|
107
|
+
this.buffer += chunk
|
|
108
|
+
let newline = this.buffer.indexOf('\n')
|
|
109
|
+
while (newline !== -1) {
|
|
110
|
+
const line = this.buffer.slice(0, newline).trim()
|
|
111
|
+
this.buffer = this.buffer.slice(newline + 1)
|
|
112
|
+
newline = this.buffer.indexOf('\n')
|
|
113
|
+
if (line === '') continue
|
|
114
|
+
const entry = this.queue.shift()
|
|
115
|
+
if (entry === undefined) continue
|
|
116
|
+
try {
|
|
117
|
+
const answer = JSON.parse(line) as Record<string, unknown>
|
|
118
|
+
// A rejected token means the shell restarted with a new one, so this socket
|
|
119
|
+
// can never succeed again: answer (the caller needs the error) and drop it,
|
|
120
|
+
// so the next call dials afresh instead of retrying a doomed credential.
|
|
121
|
+
if (answer.ok === false && answer.error === 'bad token') {
|
|
122
|
+
entry.resolve(answer)
|
|
123
|
+
this.reset(new Error('dsh-builtin-browser: the bridge rejected our token'))
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
entry.resolve(answer)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
entry.reject(new Error(`dsh-builtin-browser: malformed bridge answer (${String(error)})`))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Tear the socket down, failing pending work. */
|
|
134
|
+
private reset(error: Error): void {
|
|
135
|
+
if (this.socket !== undefined) {
|
|
136
|
+
const socket = this.socket
|
|
137
|
+
this.socket = undefined
|
|
138
|
+
socket.removeAllListeners()
|
|
139
|
+
socket.destroy()
|
|
140
|
+
}
|
|
141
|
+
this.buffer = ''
|
|
142
|
+
const pending = this.queue.splice(0, this.queue.length)
|
|
143
|
+
for (const entry of pending) entry.reject(error)
|
|
144
|
+
}
|
|
145
|
+
}
|