dsh-builtin-browser 0.2.0 → 0.3.1

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.
Files changed (34) hide show
  1. package/README.en.md +50 -5
  2. package/README.md +62 -10
  3. package/client.js +44 -8
  4. package/desktop-bridge/install.mjs +98 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +385 -0
  6. package/lib/browser-electron/bridge-connection.d.ts +58 -0
  7. package/lib/browser-electron/bridge-connection.js +133 -0
  8. package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
  9. package/lib/browser-electron/desktop-bridge-host.js +297 -0
  10. package/lib/browser-electron/entry.js +81 -13
  11. package/lib/browser-electron/history-store.d.ts +5 -1
  12. package/lib/browser-electron/history-store.js +12 -1
  13. package/lib/browser-electron/provider.d.ts +42 -1
  14. package/lib/browser-electron/provider.js +96 -24
  15. package/lib/browser-electron/remote-host.d.ts +9 -1
  16. package/lib/browser-electron/remote-host.js +17 -3
  17. package/lib/browser-electron/settings-store.d.ts +5 -0
  18. package/lib/browser-electron/settings-store.js +24 -1
  19. package/lib/browser-electron/system-browser.d.ts +132 -0
  20. package/lib/browser-electron/system-browser.js +380 -0
  21. package/lib/browser-electron/virtual-cursor.d.ts +27 -2
  22. package/lib/browser-electron/virtual-cursor.js +77 -7
  23. package/lib/tool-browser/index.js +51 -15
  24. package/package.json +2 -1
  25. package/src/browser-electron/bridge-connection.ts +145 -0
  26. package/src/browser-electron/desktop-bridge-host.ts +305 -0
  27. package/src/browser-electron/entry.ts +84 -13
  28. package/src/browser-electron/history-store.ts +11 -2
  29. package/src/browser-electron/provider.ts +124 -25
  30. package/src/browser-electron/remote-host.ts +17 -2
  31. package/src/browser-electron/settings-store.ts +30 -1
  32. package/src/browser-electron/system-browser.ts +371 -0
  33. package/src/browser-electron/virtual-cursor.ts +76 -6
  34. package/src/tool-browser/index.ts +51 -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
+ }
@@ -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
+ }
@@ -0,0 +1,133 @@
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
+ import { connect } from 'node:net';
21
+ /**
22
+ * A reusable connection to the bridge.
23
+ *
24
+ * Requests are serialised by construction: one socket, one in-flight request at a
25
+ * time, answered in order. A broken socket is discarded so the next call dials
26
+ * again — a dead connection must never become a dead plugin.
27
+ */
28
+ export class BridgeConnection {
29
+ endpoint;
30
+ socket;
31
+ buffer = '';
32
+ queue = [];
33
+ /**
34
+ * @param endpoint - the shell's published bridge endpoint.
35
+ */
36
+ constructor(endpoint) {
37
+ this.endpoint = endpoint;
38
+ }
39
+ /**
40
+ * Send one request, reusing the socket if it is still healthy.
41
+ * @param request - the request body (the token is added here).
42
+ * @param timeoutMs - how long to wait for the answer before dropping the socket.
43
+ * @returns the bridge's answer.
44
+ */
45
+ async call(request, timeoutMs = 20_000) {
46
+ const socket = this.ensureSocket();
47
+ return await new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => {
49
+ const index = this.queue.findIndex(entry => entry.resolve === resolve);
50
+ if (index !== -1)
51
+ this.queue.splice(index, 1);
52
+ const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`);
53
+ this.reset(error);
54
+ reject(error);
55
+ }, timeoutMs);
56
+ this.queue.push({
57
+ resolve: answer => { clearTimeout(timer); resolve(answer); },
58
+ reject: error => { clearTimeout(timer); reject(error); },
59
+ });
60
+ try {
61
+ socket.write(JSON.stringify({ token: this.endpoint.token, ...request }) + '\n');
62
+ }
63
+ catch (error) {
64
+ clearTimeout(timer);
65
+ this.queue.pop();
66
+ const failure = error instanceof Error ? error : new Error(String(error));
67
+ this.reset(failure);
68
+ reject(failure);
69
+ }
70
+ });
71
+ }
72
+ /** Close the socket and fail anything still waiting on it. */
73
+ close() {
74
+ this.reset(new Error('dsh-builtin-browser: bridge closed'));
75
+ }
76
+ /** The live socket, connecting and authenticating it on first use. */
77
+ ensureSocket() {
78
+ if (this.socket !== undefined)
79
+ return this.socket;
80
+ const socket = connect({ host: '127.0.0.1', port: this.endpoint.port });
81
+ socket.setEncoding('utf8');
82
+ socket.on('data', chunk => this.onData(String(chunk)));
83
+ socket.on('error', error => this.reset(error));
84
+ socket.on('close', () => this.reset(new Error('dsh-builtin-browser: bridge connection closed')));
85
+ this.buffer = '';
86
+ this.socket = socket;
87
+ socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n');
88
+ return socket;
89
+ }
90
+ /** Resolve queued requests in order as answers arrive. */
91
+ onData(chunk) {
92
+ this.buffer += chunk;
93
+ let newline = this.buffer.indexOf('\n');
94
+ while (newline !== -1) {
95
+ const line = this.buffer.slice(0, newline).trim();
96
+ this.buffer = this.buffer.slice(newline + 1);
97
+ newline = this.buffer.indexOf('\n');
98
+ if (line === '')
99
+ continue;
100
+ const entry = this.queue.shift();
101
+ if (entry === undefined)
102
+ continue;
103
+ try {
104
+ const answer = JSON.parse(line);
105
+ // A rejected token means the shell restarted with a new one, so this socket
106
+ // can never succeed again: answer (the caller needs the error) and drop it,
107
+ // so the next call dials afresh instead of retrying a doomed credential.
108
+ if (answer.ok === false && answer.error === 'bad token') {
109
+ entry.resolve(answer);
110
+ this.reset(new Error('dsh-builtin-browser: the bridge rejected our token'));
111
+ continue;
112
+ }
113
+ entry.resolve(answer);
114
+ }
115
+ catch (error) {
116
+ entry.reject(new Error(`dsh-builtin-browser: malformed bridge answer (${String(error)})`));
117
+ }
118
+ }
119
+ }
120
+ /** Tear the socket down, failing pending work. */
121
+ reset(error) {
122
+ if (this.socket !== undefined) {
123
+ const socket = this.socket;
124
+ this.socket = undefined;
125
+ socket.removeAllListeners();
126
+ socket.destroy();
127
+ }
128
+ this.buffer = '';
129
+ const pending = this.queue.splice(0, this.queue.length);
130
+ for (const entry of pending)
131
+ entry.reject(error);
132
+ }
133
+ }