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
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin browser bridge (desktop side).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* The `dsh-builtin-browser` plugin runs inside the Node-mode desktop host, where
|
|
6
|
+
* no Electron API exists — so today it spawns its own Electron window, completely
|
|
7
|
+
* parallel to the desktop shell. To make the sidebar carry the agent's page
|
|
8
|
+
* instead (one page, human and agent on it), somebody with Electron access has to
|
|
9
|
+
* own the view. That somebody is this process.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IT DOES
|
|
12
|
+
* Exposes the desktop's webview guests (the sidebar browser's pages) over a
|
|
13
|
+
* token-authenticated loopback socket, and forwards CDP to them through
|
|
14
|
+
* `webContents.debugger`. It deliberately does NOT create windows or views yet:
|
|
15
|
+
* the first question is whether a sidebar guest can be driven from outside at
|
|
16
|
+
* all, and that is what `list` + `cdp` answer.
|
|
17
|
+
*
|
|
18
|
+
* HOW IT IS STARTED
|
|
19
|
+
* `lib/main.js` imports this module once the app is ready (see the bridge import
|
|
20
|
+
* appended before its `export {}`), and the endpoint is published to
|
|
21
|
+
* `$DSH_HOME/dsh-builtin-browser-bridge.json` so the plugin can find it without
|
|
22
|
+
* any user configuration.
|
|
23
|
+
*/
|
|
24
|
+
import { createServer } from 'node:net'
|
|
25
|
+
import { randomBytes } from 'node:crypto'
|
|
26
|
+
import { writeFileSync, mkdirSync } from 'node:fs'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
import { app, webContents } from 'electron'
|
|
29
|
+
|
|
30
|
+
/** Absolute path of the endpoint file the plugin reads. */
|
|
31
|
+
function endpointFile() {
|
|
32
|
+
const home = process.env.DSH_HOME ?? ''
|
|
33
|
+
return join(home, 'dsh-builtin-browser-bridge.json')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One CDP command against a guest, serialized per guest. */
|
|
37
|
+
const queues = new Map()
|
|
38
|
+
|
|
39
|
+
function guestById(id) {
|
|
40
|
+
for (const contents of webContents.getAllWebContents()) {
|
|
41
|
+
if (contents.id === id) return contents
|
|
42
|
+
}
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Describe one candidate guest. */
|
|
47
|
+
function describe(contents) {
|
|
48
|
+
let url = ''
|
|
49
|
+
let title = ''
|
|
50
|
+
try { url = contents.getURL() } catch { /* destroyed */ }
|
|
51
|
+
try { title = contents.getTitle() } catch { /* destroyed */ }
|
|
52
|
+
return {
|
|
53
|
+
id: contents.id,
|
|
54
|
+
type: contents.getType(),
|
|
55
|
+
url,
|
|
56
|
+
title,
|
|
57
|
+
destroyed: contents.isDestroyed(),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send one CDP command to a guest, attaching its debugger on first use.
|
|
63
|
+
* Attaching is per-webContents and idempotent; a destroyed guest drops the
|
|
64
|
+
* attachment so a later guest with a recycled id is not mistaken for this one.
|
|
65
|
+
*/
|
|
66
|
+
async function sendCdp(id, method, params) {
|
|
67
|
+
const guest = guestById(id)
|
|
68
|
+
if (guest === undefined || guest.isDestroyed()) throw new Error(`guest ${id} is not available`)
|
|
69
|
+
const previous = queues.get(id) ?? Promise.resolve()
|
|
70
|
+
const next = previous.then(async () => {
|
|
71
|
+
const current = guestById(id)
|
|
72
|
+
if (current === undefined || current.isDestroyed()) throw new Error(`guest ${id} is not available`)
|
|
73
|
+
if (!current.debugger.isAttached()) current.debugger.attach('1.3')
|
|
74
|
+
return await current.debugger.sendCommand(method, params ?? {})
|
|
75
|
+
})
|
|
76
|
+
// Keep the chain alive on failure so one bad command does not wedge the guest.
|
|
77
|
+
queues.set(id, next.catch(() => undefined))
|
|
78
|
+
return await next
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Handle one JSON request line; every answer is a JSON line too. */
|
|
82
|
+
async function handle(request) {
|
|
83
|
+
const op = String(request?.op ?? '')
|
|
84
|
+
if (op === 'list') {
|
|
85
|
+
const all = webContents.getAllWebContents().map(describe)
|
|
86
|
+
return { ok: true, guests: all, sidebar: all.filter(g => g.type === 'webview') }
|
|
87
|
+
}
|
|
88
|
+
if (op === 'cdp') {
|
|
89
|
+
const id = Number(request.id)
|
|
90
|
+
if (!Number.isFinite(id)) throw new Error('cdp requires a numeric id')
|
|
91
|
+
const result = await sendCdp(id, String(request.method ?? ''), request.params)
|
|
92
|
+
return { ok: true, result }
|
|
93
|
+
}
|
|
94
|
+
if (op === 'ensureSidebar') {
|
|
95
|
+
const url = request.url === undefined ? '' : String(request.url)
|
|
96
|
+
// Materialize a sidebar guest, optionally navigating it.
|
|
97
|
+
//
|
|
98
|
+
// The sidebar browser creates its guest lazily: an un-navigated sidebar is
|
|
99
|
+
// only an address bar and owns no webContents at all, so "there is no guest"
|
|
100
|
+
// is the normal state rather than a failure. When one already exists this is
|
|
101
|
+
// a no-op; otherwise the shell's own UI is driven to open the right sidebar
|
|
102
|
+
// and submit an address — the same thing a human would do, and the only
|
|
103
|
+
// supported way to make the sidebar own a page.
|
|
104
|
+
const existing = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
|
|
105
|
+
if (existing.length > 0) {
|
|
106
|
+
const first = existing[0]
|
|
107
|
+
if (request.url !== undefined && String(request.url) !== '') {
|
|
108
|
+
await sendCdp(first.id, 'Page.navigate', { url: String(request.url) })
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, created: false, id: first.id }
|
|
111
|
+
}
|
|
112
|
+
const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
|
|
113
|
+
if (shell === undefined) throw new Error('no shell window to drive')
|
|
114
|
+
// 1. Open the sidebar and put the caret in its address field IF that field is
|
|
115
|
+
// reachable. It is not always: the sidebar may show another panel, or the
|
|
116
|
+
// browser tab may be present but not active, in which case no address input
|
|
117
|
+
// exists in the document at all. That must not be fatal — the restore route
|
|
118
|
+
// below needs no address bar, and treating "no input" as a hard error is
|
|
119
|
+
// exactly what made the first call after a restart fail.
|
|
120
|
+
const prepared = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
121
|
+
expression: shellPrepareSidebar(),
|
|
122
|
+
returnByValue: true,
|
|
123
|
+
})
|
|
124
|
+
const verdict = String(prepared?.result?.value ?? '')
|
|
125
|
+
// 2. Materialize a guest. Two routes, cheapest first:
|
|
126
|
+
// a. the sidebar's own "restore last page" affordance — it navigates
|
|
127
|
+
// without depending on how the address field handles focus or input;
|
|
128
|
+
// b. otherwise type into the address field and submit with REAL key input
|
|
129
|
+
// (synthetic events do not move React's controlled input, and the field
|
|
130
|
+
// also loses focus to re-renders, so both halves are needed).
|
|
131
|
+
const restored = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
132
|
+
expression: `(() => {
|
|
133
|
+
const nodes = Array.from(document.querySelectorAll('button,a,[role=button],div[role=link]'));
|
|
134
|
+
const hit = nodes.find(n => /恢复页面|恢复上次|上次打开/.test((n.textContent || '').trim()));
|
|
135
|
+
if (hit === undefined) return 'NO_RESTORE';
|
|
136
|
+
hit.click();
|
|
137
|
+
return 'RESTORED';
|
|
138
|
+
})()`,
|
|
139
|
+
returnByValue: true,
|
|
140
|
+
})
|
|
141
|
+
if (restored?.result?.value !== 'RESTORED') {
|
|
142
|
+
if (url !== '') {
|
|
143
|
+
// Set the value the way React accepts it, then submit with real input.
|
|
144
|
+
await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
145
|
+
expression: `(() => {
|
|
146
|
+
const inputs = Array.from(document.querySelectorAll('input')).filter(i => /HTTP|地址|url/i.test((i.placeholder || '') + (i.getAttribute('aria-label') || '')));
|
|
147
|
+
if (inputs.length === 0) return 'NO_ADDRESS_BAR';
|
|
148
|
+
const input = inputs[inputs.length - 1];
|
|
149
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
150
|
+
setter.call(input, ${JSON.stringify(String(request.url ?? ''))});
|
|
151
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
152
|
+
input.focus();
|
|
153
|
+
return 'TYPED';
|
|
154
|
+
})()`,
|
|
155
|
+
returnByValue: true,
|
|
156
|
+
})
|
|
157
|
+
for (const type of ['keyDown', 'keyUp']) {
|
|
158
|
+
await sendCdp(shell.id, 'Input.dispatchKeyEvent', {
|
|
159
|
+
type,
|
|
160
|
+
key: 'Enter',
|
|
161
|
+
code: 'Enter',
|
|
162
|
+
windowsVirtualKeyCode: 13,
|
|
163
|
+
nativeVirtualKeyCode: 13,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// The guest attaches asynchronously once the renderer creates the webview.
|
|
169
|
+
for (let attempt = 0; attempt < 24; attempt++) {
|
|
170
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
171
|
+
const created = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
|
|
172
|
+
if (created.length > 0) {
|
|
173
|
+
// Now that a guest exists, navigation is a plain CDP call: no UI involved.
|
|
174
|
+
if (url !== '') {
|
|
175
|
+
await sendCdp(created[0].id, 'Page.navigate', { url }).catch(() => undefined)
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, created: true, id: created[0].id, via: restored?.result?.value === 'RESTORED' ? 'restore' : 'address' }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`the sidebar did not create a browser guest (prepare=${String(verdict)}, restore=${String(restored?.result?.value)})`)
|
|
181
|
+
}
|
|
182
|
+
if (op === 'ensureTabs') {
|
|
183
|
+
// Grow the sidebar's browser tab strip to `count` tabs and report every guest.
|
|
184
|
+
//
|
|
185
|
+
// Tabs are the renderer's, so this cannot create them directly — it drives the
|
|
186
|
+
// sidebar's own "new tab" control, the same affordance a human uses, and then
|
|
187
|
+
// reports the guest ids. Whatever already exists is reused, so repeated calls
|
|
188
|
+
// are cheap and a human-opened tab is never orphaned.
|
|
189
|
+
const want = Math.max(1, Math.min(8, Number(request.count ?? 1)))
|
|
190
|
+
const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
|
|
191
|
+
if (shell === undefined) throw new Error('no shell window to drive')
|
|
192
|
+
let lastVerdict = ''
|
|
193
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
194
|
+
const guests = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
|
|
195
|
+
if (guests.length >= want) return { ok: true, ids: guests.map(contents => contents.id), verdict: lastVerdict }
|
|
196
|
+
const clicked = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
197
|
+
expression: `(() => {
|
|
198
|
+
const nodes = Array.from(document.querySelectorAll('button,[role=button]'));
|
|
199
|
+
const hit = nodes.find(b => /新标签页|新建标签|new tab/i.test((b.getAttribute('aria-label') || '') + (b.textContent || '') + (b.getAttribute('title') || '')));
|
|
200
|
+
if (hit === undefined) return 'NO_NEW_TAB_CONTROL';
|
|
201
|
+
hit.click();
|
|
202
|
+
return 'CLICKED';
|
|
203
|
+
})()`,
|
|
204
|
+
returnByValue: true,
|
|
205
|
+
})
|
|
206
|
+
lastVerdict = String(clicked?.result?.value ?? '')
|
|
207
|
+
if (lastVerdict === 'NO_NEW_TAB_CONTROL') {
|
|
208
|
+
throw new Error('the sidebar exposes no new-tab control (is the browser tab open?)')
|
|
209
|
+
}
|
|
210
|
+
await new Promise(resolve => setTimeout(resolve, 600))
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`the sidebar stopped at fewer than ${want} tabs`)
|
|
213
|
+
}
|
|
214
|
+
if (op === 'closeSidebarBrowser') {
|
|
215
|
+
// Release the sidebar's browser pages by closing the tabs that host them.
|
|
216
|
+
//
|
|
217
|
+
// This is the desktop equivalent of "close the browser window": the guests are
|
|
218
|
+
// destroyed, so that agent's session ends and its next use starts a fresh page —
|
|
219
|
+
// while cookies and history live in the partition and on disk, and therefore
|
|
220
|
+
// survive (requirements §3).
|
|
221
|
+
//
|
|
222
|
+
// `titles` narrows the release to specific pages. It matters as soon as more
|
|
223
|
+
// than one session is running: each one owns its own tab (requirements §4), and
|
|
224
|
+
// ending one session must not tear down another's page. With no titles given the
|
|
225
|
+
// call means "release everything", which is only appropriate when the caller
|
|
226
|
+
// knows it owns them all.
|
|
227
|
+
const wanted = Array.isArray(request.titles) ? request.titles.map(title => String(title).slice(0, 24)).filter(title => title !== '') : undefined
|
|
228
|
+
const guests = webContents.getAllWebContents().filter(contents => contents.getType() === 'webview')
|
|
229
|
+
const titles = []
|
|
230
|
+
for (const guest of guests) {
|
|
231
|
+
try {
|
|
232
|
+
if (guest.isDestroyed()) continue
|
|
233
|
+
const title = guest.getTitle()
|
|
234
|
+
// With a filter, only the guest we intend to close contributes its title —
|
|
235
|
+
// otherwise a tab belonging to another session could match by accident.
|
|
236
|
+
if (wanted !== undefined && !wanted.some(w => title.startsWith(w))) continue
|
|
237
|
+
titles.push(title)
|
|
238
|
+
} catch { /* gone mid-read */ }
|
|
239
|
+
}
|
|
240
|
+
if (guests.length === 0) return { ok: true, closed: 0 }
|
|
241
|
+
const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
|
|
242
|
+
if (shell === undefined) throw new Error('no shell window to drive')
|
|
243
|
+
if (titles.length === 0 && wanted !== undefined) return { ok: true, closed: 0 }
|
|
244
|
+
const clicked = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
245
|
+
expression: `(() => {
|
|
246
|
+
const wanted = ${JSON.stringify(titles.map(title => title.slice(0, 24)))};
|
|
247
|
+
let closed = 0;
|
|
248
|
+
for (const tab of Array.from(document.querySelectorAll('[role=tab]'))) {
|
|
249
|
+
const label = (tab.textContent || '').trim();
|
|
250
|
+
if (!wanted.some(w => label.startsWith(w))) continue;
|
|
251
|
+
const own = Array.from(tab.querySelectorAll('button')).find(b => /关闭|close/i.test((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '')));
|
|
252
|
+
const sibling = Array.from(tab.parentElement ? tab.parentElement.querySelectorAll('button') : []).find(b => /关闭|close/i.test((b.getAttribute('aria-label') || '') + (b.getAttribute('title') || '')));
|
|
253
|
+
const button = own ?? sibling;
|
|
254
|
+
if (button !== undefined) { button.click(); closed += 1; }
|
|
255
|
+
}
|
|
256
|
+
return closed;
|
|
257
|
+
})()`,
|
|
258
|
+
returnByValue: true,
|
|
259
|
+
})
|
|
260
|
+
return { ok: true, closed: Number(clicked?.result?.value ?? 0) }
|
|
261
|
+
}
|
|
262
|
+
if (op === 'collapseSidebar') {
|
|
263
|
+
// Fold the sidebar away so the agent works without occupying the screen. The
|
|
264
|
+
// guest keeps running — collapsing is presentation, exactly as §3 asks.
|
|
265
|
+
const shell = webContents.getAllWebContents().find(contents => contents.getType() === 'window')
|
|
266
|
+
if (shell === undefined) throw new Error('no shell window to drive')
|
|
267
|
+
const folded = await sendCdp(shell.id, 'Runtime.evaluate', {
|
|
268
|
+
expression: `(() => {
|
|
269
|
+
const button = Array.from(document.querySelectorAll('button')).find(b => /收起右侧边栏/.test((b.getAttribute('aria-label') || '') + (b.textContent || '')));
|
|
270
|
+
if (button === undefined) return 'NOT_OPEN';
|
|
271
|
+
button.click();
|
|
272
|
+
return 'COLLAPSED';
|
|
273
|
+
})()`,
|
|
274
|
+
returnByValue: true,
|
|
275
|
+
})
|
|
276
|
+
return { ok: true, result: folded?.result?.value }
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`unknown op ${JSON.stringify(op)}`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* In-page script that opens the right sidebar and focuses its address field.
|
|
283
|
+
*
|
|
284
|
+
* Only preparation happens here; the address itself is typed with real input
|
|
285
|
+
* (see `ensureSidebar`), because a controlled React input ignores text assigned
|
|
286
|
+
* through synthetic events.
|
|
287
|
+
* @returns a self-contained expression evaluating to a short verdict string.
|
|
288
|
+
*/
|
|
289
|
+
function shellPrepareSidebar() {
|
|
290
|
+
return `(() => {
|
|
291
|
+
const labelOf = el => ((el.getAttribute && el.getAttribute('aria-label')) || el.textContent || '').trim();
|
|
292
|
+
let opened = false;
|
|
293
|
+
for (const button of Array.from(document.querySelectorAll('button'))) {
|
|
294
|
+
if (/打开右侧边栏|右侧边栏/.test(labelOf(button))) { button.click(); opened = true; break; }
|
|
295
|
+
}
|
|
296
|
+
const inputs = Array.from(document.querySelectorAll('input')).filter(i => /HTTP|地址|url/i.test((i.placeholder || '') + (i.getAttribute('aria-label') || '')));
|
|
297
|
+
if (inputs.length === 0) return 'NO_ADDRESS_BAR:' + JSON.stringify(Array.from(document.querySelectorAll('input')).map(i => i.placeholder).slice(0, 6));
|
|
298
|
+
inputs[inputs.length - 1].focus();
|
|
299
|
+
return opened ? 'OPENED_AND_FOCUSED' : 'FOCUSED';
|
|
300
|
+
})()`
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Publish the endpoint, and keep it published.
|
|
305
|
+
*
|
|
306
|
+
* A single write at startup is not enough: two shells can overlap during a
|
|
307
|
+
* restart (the outgoing one may write LAST, leaving the file pointing at a dead
|
|
308
|
+
* pid), and a reader that trusts it then fails with ECONNREFUSED for reasons
|
|
309
|
+
* that have nothing to do with the plugin. So the file is rewritten periodically
|
|
310
|
+
* by the process that actually owns the listener, and readers are told the pid
|
|
311
|
+
* so they can verify liveness themselves.
|
|
312
|
+
*/
|
|
313
|
+
function publishEndpoint(port, token) {
|
|
314
|
+
const payload = {
|
|
315
|
+
port,
|
|
316
|
+
token,
|
|
317
|
+
pid: process.pid,
|
|
318
|
+
startedAt: new Date().toISOString(),
|
|
319
|
+
updatedAt: new Date().toISOString(),
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const file = endpointFile()
|
|
323
|
+
mkdirSync(join(file, '..'), { recursive: true })
|
|
324
|
+
writeFileSync(file, JSON.stringify(payload, null, 2) + '\n')
|
|
325
|
+
return true
|
|
326
|
+
} catch (error) {
|
|
327
|
+
console.error('[dsh-browser-bridge] could not publish the endpoint file:', error)
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Start the bridge: loopback listener + published endpoint file + token. */
|
|
333
|
+
export function start() {
|
|
334
|
+
const token = randomBytes(24).toString('hex')
|
|
335
|
+
const server = createServer(socket => {
|
|
336
|
+
let buffer = ''
|
|
337
|
+
let authed = false
|
|
338
|
+
socket.on('data', chunk => {
|
|
339
|
+
buffer += chunk.toString('utf8')
|
|
340
|
+
let newline = buffer.indexOf('\n')
|
|
341
|
+
while (newline !== -1) {
|
|
342
|
+
const line = buffer.slice(0, newline).trim()
|
|
343
|
+
buffer = buffer.slice(newline + 1)
|
|
344
|
+
newline = buffer.indexOf('\n')
|
|
345
|
+
if (line === '') continue
|
|
346
|
+
let request
|
|
347
|
+
try { request = JSON.parse(line) } catch { socket.write(JSON.stringify({ ok: false, error: 'bad json' }) + '\n'); continue }
|
|
348
|
+
if (!authed) {
|
|
349
|
+
if (request.token !== token) { socket.write(JSON.stringify({ ok: false, error: 'bad token' }) + '\n'); socket.destroy(); return }
|
|
350
|
+
authed = true
|
|
351
|
+
// The authenticating message is NOT a command: a client that sends the
|
|
352
|
+
// token on its own line would otherwise be answered `unknown op ""`.
|
|
353
|
+
// (A client may also put the token on its first real request; both work.)
|
|
354
|
+
if (typeof request.op !== 'string' || request.op === '') continue
|
|
355
|
+
}
|
|
356
|
+
void handle(request)
|
|
357
|
+
.then(answer => socket.write(JSON.stringify(answer) + '\n'))
|
|
358
|
+
.catch(error => socket.write(JSON.stringify({ ok: false, error: String(error?.message ?? error) }) + '\n'))
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
socket.on('error', () => { /* client went away */ })
|
|
362
|
+
})
|
|
363
|
+
server.on('error', error => { console.error('[dsh-browser-bridge] listen failed:', error) })
|
|
364
|
+
// Loopback only, port chosen by the OS: the endpoint file is the discovery
|
|
365
|
+
// mechanism, so a fixed port would only add collisions.
|
|
366
|
+
server.listen(0, '127.0.0.1', () => {
|
|
367
|
+
const address = server.address()
|
|
368
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
369
|
+
if (publishEndpoint(port, token)) {
|
|
370
|
+
console.log(`[dsh-browser-bridge] listening on 127.0.0.1:${port} (pid ${process.pid})`)
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
// Re-publish: a reader must never be handed an endpoint owned by a dead shell.
|
|
374
|
+
const keepalive = setInterval(() => {
|
|
375
|
+
const address = server.address()
|
|
376
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
377
|
+
if (port !== 0) publishEndpoint(port, token)
|
|
378
|
+
}, 15_000)
|
|
379
|
+
keepalive.unref?.()
|
|
380
|
+
app.on('will-quit', () => {
|
|
381
|
+
clearInterval(keepalive)
|
|
382
|
+
try { server.close() } catch { /* already closed */ }
|
|
383
|
+
})
|
|
384
|
+
return server
|
|
385
|
+
}
|
package/docs/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
| --- | --- |
|
|
7
7
|
| 了解插件为什么存在、与无头方案的区别 | [为什么做共享真实浏览器](why-browser.md) |
|
|
8
8
|
| 安装、配置、日常使用与常见问题 | [用户指南](user-guide.md) |
|
|
9
|
-
| 全部
|
|
9
|
+
| 全部 34 个工具的参数、输出与示例 | [工具参考](tool-reference.md) |
|
|
10
10
|
| 了解 seam / provider / 工具三层与自托管实现 | [架构说明](architecture.md) |
|
|
11
11
|
| 回到项目首页 | [README](../README.md) |
|
|
12
12
|
|
package/docs/architecture.md
CHANGED
|
@@ -37,7 +37,7 @@ agent (browser_* 工具)
|
|
|
37
37
|
|
|
38
38
|
### 工具层(`src/tool-browser/`)
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
34 个 `browser_*` 工具,按**调用方任务**(`exec.agent.id`)维护独立浏览器会话:
|
|
41
41
|
|
|
42
42
|
- 会话缓存 `sessionsByTask`:同一任务复用同一会话,并发首开去重;
|
|
43
43
|
- `browser_reset_session` 关闭本任务会话并遗忘映射(即使 close 抛错也清除,下次调用重建);
|
package/docs/tool-reference.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 工具参考
|
|
2
2
|
|
|
3
|
-
全部
|
|
3
|
+
全部 34 个 `browser_*` 工具。守卫列:✅ 表示该动作受 `browser_restrict` 白名单约束;只读工具永不拦截。
|
|
4
4
|
|
|
5
5
|
## 页面与导航
|
|
6
6
|
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
| 工具 | 参数 | 输出 | 守卫 | 说明 |
|
|
50
50
|
| --- | --- | --- | --- | --- |
|
|
51
51
|
| `browser_history` | – | `{ entries[] }` | – | 操作日志(最新在后),含 seq/action/ok/params/result/error |
|
|
52
|
+
| `browser_visited` | `limit`(默认 30,上限 200), `domain`(主机名包含匹配) | `{ count, entries[] }` | – | **持久化浏览历史**(访问过的页面,最新在前):与 cookie 同址落盘(`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`),关闭浏览器与重启 DSH 后仍在;上限 5000 条或 90 天。重开某条用 `browser_open`;与 `browser_history`(会话内操作日志,随会话消失)是两件事;可在设置里关闭记录 |
|
|
52
53
|
| `browser_replay` | `seq`(必填) | `{ replayed }` | ✅ | 按序号回放某一步(navigate/execute/click/type/scroll/key) |
|
|
53
54
|
| `browser_download` | `url`(必填), `savePath`(必填) | `{ path }` | ✅ | 带会话 cookie 下载到本地(仅 http(s);`savePath` 必须为绝对路径且位于 `downloadDir` 内——默认系统下载目录,自动识别 `Downloads`/`下载`/`下載` 与 `XDG_DOWNLOAD_DIR`;不覆盖已有文件;上限 256MB,受 CORS 约束;由子进程直接落盘) |
|
|
54
55
|
|
package/lib/browser/runtime.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Context, Service } from '@deepseek-ai/cordis';
|
|
10
10
|
import z from '@deepseek-ai/schemastery';
|
|
11
|
-
import type { BrowserA11yRequest, BrowserA11yResult, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserDownloadRequest, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, BrowserChallenge, ExportedCookie } from './types.js';
|
|
11
|
+
import type { BrowserA11yRequest, BrowserA11yResult, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentRequest, BrowserContentResult, BrowserDownloadRequest, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserHistoryEntry, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, BrowserChallenge, ExportedCookie, VisitedPage } from './types.js';
|
|
12
12
|
export { BrowserError, } from './types.js';
|
|
13
13
|
export type { BrowserA11yNode, BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentFormat, BrowserElementTarget, BrowserGetValueRequest, BrowserGetValueResult, BrowserScrapeField, BrowserScrapeRequest, BrowserScrapeResult, BrowserSelectRequest, BrowserSelectResult, BrowserSetValueRequest, BrowserSetValueResult, BrowserWaitRequest, BrowserWaitResult, BrowserScrollRequest, BrowserKeyRequest, BrowserContentRequest, BrowserContentResult, BrowserDownloadRequest, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillField, BrowserFillRequest, BrowserFillResult, BrowserHistoryEntry, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSessionId, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, ExportedCookie, } from './types.js';
|
|
14
14
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -118,6 +118,23 @@ export declare class BrowserRuntime extends Service {
|
|
|
118
118
|
detectChallenge(session: BrowserSessionId, signal?: AbortSignal): Promise<BrowserChallenge>;
|
|
119
119
|
/** Return the session's chronological operation log through the provider. */
|
|
120
120
|
history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]>;
|
|
121
|
+
/**
|
|
122
|
+
* Persisted visits, newest first — the record that outlives the browser
|
|
123
|
+
* process, as opposed to {@link history}'s session-scoped operation log.
|
|
124
|
+
* @param options - result cap and an optional hostname filter.
|
|
125
|
+
* @returns the retained visits (empty when history recording is disabled).
|
|
126
|
+
*/
|
|
127
|
+
visited(options?: {
|
|
128
|
+
readonly limit?: number;
|
|
129
|
+
readonly domain?: string;
|
|
130
|
+
}): readonly VisitedPage[];
|
|
131
|
+
/**
|
|
132
|
+
* Whether a session is still live. Sessions can end outside the tool layer
|
|
133
|
+
* (the human closed its window), so a cached id must be re-checked before
|
|
134
|
+
* reuse; a provider that does not implement the check counts as live.
|
|
135
|
+
* @param session - the session id to test.
|
|
136
|
+
*/
|
|
137
|
+
exists(session: BrowserSessionId): boolean;
|
|
121
138
|
/** Replay one recorded operation by sequence number through the provider. */
|
|
122
139
|
replay(session: BrowserSessionId, seq: number): Promise<void>;
|
|
123
140
|
/** Download a URL to a local file through the provider. */
|
package/lib/browser/runtime.js
CHANGED
|
@@ -202,6 +202,25 @@ export class BrowserRuntime extends Service {
|
|
|
202
202
|
async history(session) {
|
|
203
203
|
return this.resolveProvider().history(session);
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Persisted visits, newest first — the record that outlives the browser
|
|
207
|
+
* process, as opposed to {@link history}'s session-scoped operation log.
|
|
208
|
+
* @param options - result cap and an optional hostname filter.
|
|
209
|
+
* @returns the retained visits (empty when history recording is disabled).
|
|
210
|
+
*/
|
|
211
|
+
visited(options) {
|
|
212
|
+
return this.resolveProvider().visited(options);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Whether a session is still live. Sessions can end outside the tool layer
|
|
216
|
+
* (the human closed its window), so a cached id must be re-checked before
|
|
217
|
+
* reuse; a provider that does not implement the check counts as live.
|
|
218
|
+
* @param session - the session id to test.
|
|
219
|
+
*/
|
|
220
|
+
exists(session) {
|
|
221
|
+
const provider = this.resolveProvider();
|
|
222
|
+
return provider.exists === undefined ? true : provider.exists(session);
|
|
223
|
+
}
|
|
205
224
|
/** Replay one recorded operation by sequence number through the provider. */
|
|
206
225
|
async replay(session, seq) {
|
|
207
226
|
return this.resolveProvider().replay(session, seq);
|
package/lib/browser/types.d.ts
CHANGED
|
@@ -544,11 +544,44 @@ export interface BrowserProvider {
|
|
|
544
544
|
restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number>;
|
|
545
545
|
/** Return the session's chronological operation log. */
|
|
546
546
|
history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]>;
|
|
547
|
+
/**
|
|
548
|
+
* Persisted visits, newest first: what survives the browser process, so a
|
|
549
|
+
* human or an Agent can find a page again after the interface was closed.
|
|
550
|
+
* @param options - result cap and an optional hostname filter.
|
|
551
|
+
* @returns the retained visits (empty when history is disabled).
|
|
552
|
+
*/
|
|
553
|
+
visited(options?: {
|
|
554
|
+
readonly limit?: number;
|
|
555
|
+
readonly domain?: string;
|
|
556
|
+
}): readonly VisitedPage[];
|
|
557
|
+
/**
|
|
558
|
+
* Optional: whether a session is still live. A session can end outside the
|
|
559
|
+
* tool layer — the human closed the browser window — so a cached id must be
|
|
560
|
+
* checked before it is reused. Providers that never end a session on their own
|
|
561
|
+
* can omit this, and callers treat absence as "still live".
|
|
562
|
+
* @param session - the session id to test.
|
|
563
|
+
*/
|
|
564
|
+
exists?(session: BrowserSessionId): boolean;
|
|
547
565
|
/** Replay one recorded operation by sequence number. */
|
|
548
566
|
replay(session: BrowserSessionId, seq: number): Promise<void>;
|
|
549
567
|
/** Close the session and destroy its backing surface. Idempotent. */
|
|
550
568
|
close(session: BrowserSessionId): Promise<void>;
|
|
551
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* One visited page in the persistent browsing history — the durable record that
|
|
572
|
+
* outlives sessions, unlike {@link BrowserHistoryEntry}, which is a
|
|
573
|
+
* session-scoped operation log (navigate/click/type/…).
|
|
574
|
+
*/
|
|
575
|
+
export interface VisitedPage {
|
|
576
|
+
/** Epoch milliseconds when the visit was recorded. */
|
|
577
|
+
readonly at: number;
|
|
578
|
+
/** Absolute URL of the visited document. */
|
|
579
|
+
readonly url: string;
|
|
580
|
+
/** Document title at visit time, when the page exposed one. */
|
|
581
|
+
readonly title?: string;
|
|
582
|
+
/** Session/task label that drove the visit, when the provider knows it. */
|
|
583
|
+
readonly session?: string;
|
|
584
|
+
}
|
|
552
585
|
/** One recorded browser operation, in chronological order (seq 1, 2, 3…). */
|
|
553
586
|
export interface BrowserHistoryEntry {
|
|
554
587
|
/** Monotonic sequence number within the session. */
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
/** Where the shell publishes its bridge endpoint. */
|
|
21
|
+
export interface BridgeEndpoint {
|
|
22
|
+
readonly port: number;
|
|
23
|
+
readonly token: string;
|
|
24
|
+
readonly pid: number;
|
|
25
|
+
readonly updatedAt?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A reusable connection to the bridge.
|
|
29
|
+
*
|
|
30
|
+
* Requests are serialised by construction: one socket, one in-flight request at a
|
|
31
|
+
* time, answered in order. A broken socket is discarded so the next call dials
|
|
32
|
+
* again — a dead connection must never become a dead plugin.
|
|
33
|
+
*/
|
|
34
|
+
export declare class BridgeConnection {
|
|
35
|
+
private readonly endpoint;
|
|
36
|
+
private socket;
|
|
37
|
+
private buffer;
|
|
38
|
+
private readonly queue;
|
|
39
|
+
/**
|
|
40
|
+
* @param endpoint - the shell's published bridge endpoint.
|
|
41
|
+
*/
|
|
42
|
+
constructor(endpoint: BridgeEndpoint);
|
|
43
|
+
/**
|
|
44
|
+
* Send one request, reusing the socket if it is still healthy.
|
|
45
|
+
* @param request - the request body (the token is added here).
|
|
46
|
+
* @param timeoutMs - how long to wait for the answer before dropping the socket.
|
|
47
|
+
* @returns the bridge's answer.
|
|
48
|
+
*/
|
|
49
|
+
call(request: Record<string, unknown>, timeoutMs?: number): Promise<Record<string, unknown>>;
|
|
50
|
+
/** Close the socket and fail anything still waiting on it. */
|
|
51
|
+
close(): void;
|
|
52
|
+
/** The live socket, connecting and authenticating it on first use. */
|
|
53
|
+
private ensureSocket;
|
|
54
|
+
/** Resolve queued requests in order as answers arrive. */
|
|
55
|
+
private onData;
|
|
56
|
+
/** Tear the socket down, failing pending work. */
|
|
57
|
+
private reset;
|
|
58
|
+
}
|