dsh-builtin-browser 0.1.8 → 0.1.10
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/lib/browser/runtime.d.ts +2 -1
- package/lib/browser/runtime.js +15 -2
- package/lib/browser/types.d.ts +2 -1
- package/lib/browser-electron/entry.js +6 -1
- package/lib/browser-electron/host-main.js +50 -14
- package/lib/browser-electron/provider.js +61 -24
- package/lib/browser-electron/remote-host.d.ts +7 -4
- package/lib/browser-electron/remote-host.js +117 -28
- package/lib/index.d.ts +8 -7
- package/lib/index.js +7 -6
- package/lib/tool-browser/index.d.ts +1 -1
- package/lib/tool-browser/index.js +44 -20
- package/package.json +1 -1
- package/src/browser/runtime.ts +14 -2
- package/src/browser/types.ts +2 -1
- package/src/browser-electron/entry.ts +6 -1
- package/src/browser-electron/host-main.ts +47 -14
- package/src/browser-electron/provider.ts +88 -25
- package/src/browser-electron/remote-host.ts +115 -25
- package/src/index.ts +12 -6
- package/src/tool-browser/index.ts +44 -21
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Self-hosted Electron browser host (parent side): an
|
|
3
3
|
* {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
|
|
4
4
|
* Electron child process (host-main.js) and drives it over line-delimited
|
|
5
|
-
* JSON-RPC on
|
|
6
|
-
* desktop shell's electronViewHost (plain dsh web):
|
|
7
|
-
* enough — the browser window appears on first use.
|
|
5
|
+
* JSON-RPC on a loopback TCP socket. This is what makes the plugin work on
|
|
6
|
+
* surfaces without a desktop shell's electronViewHost (plain dsh web):
|
|
7
|
+
* installing the plugin is enough — the browser window appears on first use.
|
|
8
8
|
*
|
|
9
9
|
* Protocol (one JSON object per line, both directions):
|
|
10
10
|
* -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
|
|
@@ -23,6 +23,8 @@ import { createServer } from 'node:net';
|
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
/** How long to wait for the child to signal readiness before failing. */
|
|
25
25
|
const READY_TIMEOUT_MS = 20_000;
|
|
26
|
+
/** Safety cap on a single RPC reply line (base64 downloads are the big ones). */
|
|
27
|
+
const MAX_RPC_BUFFER_BYTES = 512 * 1024 * 1024;
|
|
26
28
|
/**
|
|
27
29
|
* Locate the Electron binary, trying, in order:
|
|
28
30
|
* 1. require('electron') from this module (optional peer dependency),
|
|
@@ -147,6 +149,7 @@ function findElectronExe(store) {
|
|
|
147
149
|
class ElectronChildClient {
|
|
148
150
|
hostMainPath;
|
|
149
151
|
port;
|
|
152
|
+
onExit;
|
|
150
153
|
child;
|
|
151
154
|
pending = new Map();
|
|
152
155
|
nextId = 1;
|
|
@@ -154,9 +157,12 @@ class ElectronChildClient {
|
|
|
154
157
|
socket;
|
|
155
158
|
connected = false;
|
|
156
159
|
outbox = [];
|
|
157
|
-
|
|
160
|
+
/** Set once the child has exited; further calls fail fast instead of queueing. */
|
|
161
|
+
dead = false;
|
|
162
|
+
constructor(hostMainPath, port, onExit) {
|
|
158
163
|
this.hostMainPath = hostMainPath;
|
|
159
164
|
this.port = port;
|
|
165
|
+
this.onExit = onExit;
|
|
160
166
|
const electron = resolveElectronPath();
|
|
161
167
|
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
162
168
|
// Node, breaking require('electron'); NODE_OPTIONS can inject flags that
|
|
@@ -174,25 +180,45 @@ class ElectronChildClient {
|
|
|
174
180
|
// Diagnostics only; never parse stderr as protocol.
|
|
175
181
|
process.stderr.write(`[dsh-browser host] ${String(chunk)}`);
|
|
176
182
|
});
|
|
183
|
+
// A failed spawn (bad/corrupt binary) emits 'error' — without a listener
|
|
184
|
+
// that would crash the whole DSH process.
|
|
185
|
+
this.child.on('error', error => {
|
|
186
|
+
process.stderr.write(`[dsh-browser host] spawn error: ${String(error)}\n`);
|
|
187
|
+
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`));
|
|
188
|
+
});
|
|
177
189
|
this.child.on('exit', (code, signal) => {
|
|
178
|
-
|
|
179
|
-
for (const pending of this.pending.values())
|
|
180
|
-
pending.reject(err);
|
|
181
|
-
this.pending.clear();
|
|
190
|
+
this.fail(new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`));
|
|
182
191
|
});
|
|
183
192
|
}
|
|
193
|
+
/** Reject everything in flight, mark the client dead, and notify the host. */
|
|
194
|
+
fail(err) {
|
|
195
|
+
if (this.dead)
|
|
196
|
+
return;
|
|
197
|
+
this.dead = true;
|
|
198
|
+
this.connected = false;
|
|
199
|
+
for (const pending of this.pending.values())
|
|
200
|
+
pending.reject(err);
|
|
201
|
+
this.pending.clear();
|
|
202
|
+
this.outbox = [];
|
|
203
|
+
this.onExit?.();
|
|
204
|
+
}
|
|
184
205
|
/** Accept the child's connection (called by the server). */
|
|
185
206
|
attach(socket) {
|
|
186
207
|
this.socket = socket;
|
|
187
208
|
this.connected = true;
|
|
188
209
|
socket.setEncoding('utf8');
|
|
210
|
+
// Without an 'error' listener a remote reset (ECONNRESET/EPIPE) throws an
|
|
211
|
+
// uncaught 'error' event and crashes the whole DSH process; 'close' below
|
|
212
|
+
// does the cleanup.
|
|
213
|
+
socket.on('error', error => {
|
|
214
|
+
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`);
|
|
215
|
+
});
|
|
189
216
|
socket.on('data', chunk => this.onData(chunk));
|
|
190
217
|
socket.on('close', () => {
|
|
191
218
|
this.connected = false;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.pending.clear();
|
|
219
|
+
if (!this.dead) {
|
|
220
|
+
this.fail(new Error('dsh-builtin-browser: browser host connection closed'));
|
|
221
|
+
}
|
|
196
222
|
});
|
|
197
223
|
// Flush anything queued while disconnected.
|
|
198
224
|
if (this.outbox.length > 0) {
|
|
@@ -203,6 +229,14 @@ class ElectronChildClient {
|
|
|
203
229
|
}
|
|
204
230
|
onData(chunk) {
|
|
205
231
|
this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
232
|
+
// Safety net: a pathological child (or a reply larger than expected)
|
|
233
|
+
// must not grow the parent's memory without bound. The child caps
|
|
234
|
+
// downloads at 256 MiB, so a healthy stream never approaches this.
|
|
235
|
+
if (this.buffer.length > MAX_RPC_BUFFER_BYTES) {
|
|
236
|
+
this.buffer = '';
|
|
237
|
+
this.fail(new Error(`dsh-builtin-browser: RPC reply exceeded ${MAX_RPC_BUFFER_BYTES} bytes`));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
206
240
|
let nl;
|
|
207
241
|
while ((nl = this.buffer.indexOf('\n')) >= 0) {
|
|
208
242
|
const line = this.buffer.slice(0, nl).trim();
|
|
@@ -231,6 +265,9 @@ class ElectronChildClient {
|
|
|
231
265
|
}
|
|
232
266
|
/** Send one command and await the reply. */
|
|
233
267
|
call(op, payload = {}) {
|
|
268
|
+
if (this.dead) {
|
|
269
|
+
return Promise.reject(new Error('dsh-builtin-browser: browser host is not running'));
|
|
270
|
+
}
|
|
234
271
|
const id = this.nextId++;
|
|
235
272
|
const line = JSON.stringify({ id, op, ...payload });
|
|
236
273
|
return new Promise((resolve, reject) => {
|
|
@@ -294,15 +331,30 @@ export class RemoteElectronViewHost {
|
|
|
294
331
|
pendingSocket;
|
|
295
332
|
views = new Map();
|
|
296
333
|
readyPromise;
|
|
334
|
+
disposed = false;
|
|
297
335
|
constructor(hostMainPath) {
|
|
298
336
|
this.hostMainPath = hostMainPath;
|
|
299
337
|
}
|
|
300
|
-
/** Ensure the child is up and ready (lazy on first use). */
|
|
338
|
+
/** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
|
|
301
339
|
ready() {
|
|
302
340
|
if (this.readyPromise !== undefined)
|
|
303
341
|
return this.readyPromise;
|
|
304
|
-
|
|
305
|
-
|
|
342
|
+
const started = this.start();
|
|
343
|
+
const wrapped = started.catch(error => {
|
|
344
|
+
// A failed startup must not poison the host forever: tear down whatever
|
|
345
|
+
// was half-created and let the next call retry from scratch.
|
|
346
|
+
if (this.readyPromise === wrapped) {
|
|
347
|
+
this.readyPromise = undefined;
|
|
348
|
+
this.client?.kill();
|
|
349
|
+
this.client = undefined;
|
|
350
|
+
this.server?.close();
|
|
351
|
+
this.server = undefined;
|
|
352
|
+
this.pendingSocket = undefined;
|
|
353
|
+
}
|
|
354
|
+
throw error;
|
|
355
|
+
});
|
|
356
|
+
this.readyPromise = wrapped;
|
|
357
|
+
return wrapped;
|
|
306
358
|
}
|
|
307
359
|
async start() {
|
|
308
360
|
// Listen on an ephemeral loopback port; the child connects back.
|
|
@@ -316,10 +368,15 @@ export class RemoteElectronViewHost {
|
|
|
316
368
|
server.once('error', reject);
|
|
317
369
|
server.listen(0, '127.0.0.1', () => resolve());
|
|
318
370
|
});
|
|
371
|
+
// A later server error (rare on a loopback ephemeral port) must not crash
|
|
372
|
+
// the process; the client's fail path handles the actual recovery.
|
|
373
|
+
server.on('error', error => {
|
|
374
|
+
process.stderr.write(`[dsh-browser host] rpc server error: ${String(error)}\n`);
|
|
375
|
+
});
|
|
319
376
|
const address = server.address();
|
|
320
377
|
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
321
378
|
this.server = server;
|
|
322
|
-
this.client = new ElectronChildClient(this.hostMainPath, port);
|
|
379
|
+
this.client = new ElectronChildClient(this.hostMainPath, port, () => this.onChildExit());
|
|
323
380
|
if (this.pendingSocket !== undefined) {
|
|
324
381
|
this.client.attach(this.pendingSocket);
|
|
325
382
|
this.pendingSocket = undefined;
|
|
@@ -327,6 +384,18 @@ export class RemoteElectronViewHost {
|
|
|
327
384
|
// Wait for the child's connection + readiness ping.
|
|
328
385
|
await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready');
|
|
329
386
|
}
|
|
387
|
+
/** The child died: tear down so the next use starts a fresh child. */
|
|
388
|
+
onChildExit() {
|
|
389
|
+
if (this.disposed)
|
|
390
|
+
return;
|
|
391
|
+
this.client = undefined;
|
|
392
|
+
this.server?.close();
|
|
393
|
+
this.server = undefined;
|
|
394
|
+
this.pendingSocket = undefined;
|
|
395
|
+
this.readyPromise = undefined;
|
|
396
|
+
// Keep the views map: handles still resolve to ids; a fresh child simply
|
|
397
|
+
// has no such views yet, and reset_session reopens clean sessions.
|
|
398
|
+
}
|
|
330
399
|
createView() {
|
|
331
400
|
// The seam is synchronous; the provider uses the handle immediately, so
|
|
332
401
|
// commands are deferred until the child is up and the view materialized.
|
|
@@ -341,12 +410,21 @@ export class RemoteElectronViewHost {
|
|
|
341
410
|
if (client === undefined)
|
|
342
411
|
throw new Error('browser host unavailable');
|
|
343
412
|
await client.call('createView', { viewId: id });
|
|
413
|
+
// If the view was destroyed while the createView RPC was in flight, do
|
|
414
|
+
// not re-insert a stale entry that would resurrect a dead child view.
|
|
415
|
+
if (this.views.get(id) === undefined) {
|
|
416
|
+
throw new Error('browser: view destroyed while starting');
|
|
417
|
+
}
|
|
344
418
|
const view = new RemoteView(id, client);
|
|
345
419
|
this.views.set(id, view);
|
|
346
420
|
return view;
|
|
347
421
|
}
|
|
348
422
|
showView(handle) {
|
|
349
|
-
|
|
423
|
+
// Fire-and-forget by design (visibility is best-effort), but a rejected
|
|
424
|
+
// promise must not become an unhandled rejection (crash on Node >= 15).
|
|
425
|
+
void this.ready()
|
|
426
|
+
.then(() => this.client?.call('showView', { viewId: handle.id }))
|
|
427
|
+
.catch(() => { });
|
|
350
428
|
}
|
|
351
429
|
destroyView(handle) {
|
|
352
430
|
const view = this.views.get(handle.id);
|
|
@@ -359,6 +437,7 @@ export class RemoteElectronViewHost {
|
|
|
359
437
|
}
|
|
360
438
|
/** Shut the child and the RPC server down. */
|
|
361
439
|
dispose() {
|
|
440
|
+
this.disposed = true;
|
|
362
441
|
this.client?.kill();
|
|
363
442
|
this.client = undefined;
|
|
364
443
|
this.server?.close();
|
|
@@ -376,27 +455,37 @@ class DeferredRemoteView {
|
|
|
376
455
|
this.id = id;
|
|
377
456
|
this.materialize = materialize;
|
|
378
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* Materialize once and cache: every sendCommand on the same handle must
|
|
460
|
+
* target the SAME child view (re-materializing would re-run createView and
|
|
461
|
+
* duplicate the window). A FAILED materialization is reset so a later call
|
|
462
|
+
* (e.g. after the host restarted) can retry instead of being poisoned.
|
|
463
|
+
*/
|
|
464
|
+
materializeOnce() {
|
|
465
|
+
if (this.materialized === undefined) {
|
|
466
|
+
const pending = this.materialize();
|
|
467
|
+
this.materialized = pending.catch(error => {
|
|
468
|
+
if (this.materialized === pending)
|
|
469
|
+
this.materialized = undefined;
|
|
470
|
+
throw error;
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return this.materialized;
|
|
474
|
+
}
|
|
379
475
|
async sendCommand(method, params) {
|
|
380
|
-
|
|
381
|
-
// target the SAME child view. Re-materializing would re-run createView
|
|
382
|
-
// (duplicate window) and drop the established view.
|
|
383
|
-
this.materialized ??= this.materialize();
|
|
384
|
-
const view = await this.materialized;
|
|
476
|
+
const view = await this.materializeOnce();
|
|
385
477
|
return view.sendCommand(method, params);
|
|
386
478
|
}
|
|
387
479
|
async download(url, savePath) {
|
|
388
|
-
|
|
389
|
-
const view = await this.materialized;
|
|
480
|
+
const view = await this.materializeOnce();
|
|
390
481
|
return view.download(url, savePath);
|
|
391
482
|
}
|
|
392
483
|
async flushAuth() {
|
|
393
|
-
|
|
394
|
-
const view = await this.materialized;
|
|
484
|
+
const view = await this.materializeOnce();
|
|
395
485
|
return view.flushAuth();
|
|
396
486
|
}
|
|
397
487
|
async restoreAuth(cookies) {
|
|
398
|
-
|
|
399
|
-
const view = await this.materialized;
|
|
488
|
+
const view = await this.materializeOnce();
|
|
400
489
|
return view.restoreAuth(cookies);
|
|
401
490
|
}
|
|
402
491
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-browser plugin entry: aggregates the shared-browser capability
|
|
3
|
-
* The cordis.patch.yml rows reference subpath exports:
|
|
4
|
-
* - `dsh-browser/browser`
|
|
5
|
-
* - `dsh-browser/browser-electron`
|
|
6
|
-
* - `dsh-browser/tool-browser`
|
|
2
|
+
* dsh-builtin-browser plugin entry: aggregates the shared-browser capability
|
|
3
|
+
* pieces. The cordis.patch.yml rows reference subpath exports:
|
|
4
|
+
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
|
+
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
|
+
* - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
7
|
* This root entry only re-exports for programmatic use; the loader rows are
|
|
8
8
|
* the composition surface.
|
|
9
|
-
* @module dsh-browser
|
|
9
|
+
* @module dsh-builtin-browser
|
|
10
10
|
*/
|
|
11
11
|
export { BrowserError } from './browser/types.ts';
|
|
12
|
-
export type { BrowserClickRequest, BrowserContentFormat, BrowserContentRequest, BrowserContentResult, BrowserExecuteRequest, BrowserExecuteResult, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSessionId, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, } from './browser/types.ts';
|
|
12
|
+
export type { BrowserChallenge, BrowserClickRequest, BrowserContentFormat, BrowserContentRequest, BrowserContentResult, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillField, BrowserFillRequest, BrowserFillResult, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSessionId, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, ExportedCookie, } from './browser/types.ts';
|
|
13
13
|
export { BrowserRuntime } from './browser/runtime.ts';
|
|
14
14
|
export { ElectronBrowserProvider } from './browser-electron/provider.ts';
|
|
15
15
|
export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts';
|
|
16
|
+
export { RemoteElectronViewHost, defaultHostMainPath } from './browser-electron/remote-host.ts';
|
package/lib/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-browser plugin entry: aggregates the shared-browser capability
|
|
3
|
-
* The cordis.patch.yml rows reference subpath exports:
|
|
4
|
-
* - `dsh-browser/browser`
|
|
5
|
-
* - `dsh-browser/browser-electron`
|
|
6
|
-
* - `dsh-browser/tool-browser`
|
|
2
|
+
* dsh-builtin-browser plugin entry: aggregates the shared-browser capability
|
|
3
|
+
* pieces. The cordis.patch.yml rows reference subpath exports:
|
|
4
|
+
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
|
+
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
|
+
* - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
7
|
* This root entry only re-exports for programmatic use; the loader rows are
|
|
8
8
|
* the composition surface.
|
|
9
|
-
* @module dsh-browser
|
|
9
|
+
* @module dsh-builtin-browser
|
|
10
10
|
*/
|
|
11
11
|
export { BrowserError } from "./browser/types.js";
|
|
12
12
|
export { BrowserRuntime } from "./browser/runtime.js";
|
|
13
13
|
export { ElectronBrowserProvider } from "./browser-electron/provider.js";
|
|
14
|
+
export { RemoteElectronViewHost, defaultHostMainPath } from "./browser-electron/remote-host.js";
|
|
@@ -32,7 +32,7 @@ export interface Config {
|
|
|
32
32
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
33
33
|
/** Test hook: inspect and reset the plugin-level session map (used by tests). */
|
|
34
34
|
export declare const internals: {
|
|
35
|
-
/**
|
|
35
|
+
/** A copy of the per-task session map (task key -> provider session id). */
|
|
36
36
|
readonly sessions: ReadonlyMap<string, BrowserSessionId>;
|
|
37
37
|
/** Drop one task's mapping without closing the provider session. */
|
|
38
38
|
clearSession(key?: string): void;
|
|
@@ -20,6 +20,8 @@ export const name = 'tool-browser';
|
|
|
20
20
|
export const inject = ['tools', 'browser', 'systemPrompt'];
|
|
21
21
|
/** Per-task browser sessions, keyed by the calling DSH session id. */
|
|
22
22
|
const sessionsByTask = new Map();
|
|
23
|
+
/** In-flight first-open per task key, so concurrent first calls share one session. */
|
|
24
|
+
const pendingOpens = new Map();
|
|
23
25
|
/**
|
|
24
26
|
* Action restriction state: an allow-list of browser tool names, or undefined
|
|
25
27
|
* for unrestricted. When set, any browser_* tool not in the list is refused
|
|
@@ -48,25 +50,29 @@ function taskKey(exec) {
|
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
52
|
* Resolve the calling task's browser session, opening one on first use.
|
|
53
|
+
* Concurrent first calls for the same key share a single open.
|
|
51
54
|
* @param browser - the seam service.
|
|
52
55
|
* @param key - the task key (see {@link taskKey}).
|
|
53
56
|
* @returns the task's session id.
|
|
54
57
|
*/
|
|
55
58
|
async function ensureSession(browser, key) {
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
const existing = sessionsByTask.get(key);
|
|
60
|
+
if (existing !== undefined)
|
|
61
|
+
return existing;
|
|
62
|
+
const pending = pendingOpens.get(key);
|
|
63
|
+
if (pending !== undefined)
|
|
64
|
+
return pending;
|
|
65
|
+
const opening = browser.open().then(session => { sessionsByTask.set(key, session); pendingOpens.delete(key); return session; }, error => { pendingOpens.delete(key); throw error; });
|
|
66
|
+
pendingOpens.set(key, opening);
|
|
67
|
+
return opening;
|
|
62
68
|
}
|
|
63
|
-
/** Coerce a tool-provided string value back to boolean/number when
|
|
69
|
+
/** Coerce a tool-provided string value back to boolean/number only when lossless. */
|
|
64
70
|
function parseFillValue(v) {
|
|
65
71
|
if (v === 'true')
|
|
66
72
|
return true;
|
|
67
73
|
if (v === 'false')
|
|
68
74
|
return false;
|
|
69
|
-
if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v))
|
|
75
|
+
if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v) && String(Number(v)) === v)
|
|
70
76
|
return Number(v);
|
|
71
77
|
return v ?? '';
|
|
72
78
|
}
|
|
@@ -84,8 +90,8 @@ function formatSnapshot(snapshot) {
|
|
|
84
90
|
/** Register all browser tools with `ctx.tools`. */
|
|
85
91
|
export function apply(ctx, config = {}) {
|
|
86
92
|
const timeoutMs = config.timeoutMs ?? 60_000;
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
// Re-apply resets the restriction: an omitted allowedActions lifts it.
|
|
94
|
+
restrictedTo = config.allowedActions !== undefined ? [...config.allowedActions] : undefined;
|
|
89
95
|
ctx.systemPrompt.section({
|
|
90
96
|
name: 'tool:browser',
|
|
91
97
|
// Tool guidance band is 100-199; 150 keeps clear of the common 110/120
|
|
@@ -107,6 +113,7 @@ export function apply(ctx, config = {}) {
|
|
|
107
113
|
properties: {
|
|
108
114
|
url: { type: 'string', required: true },
|
|
109
115
|
title: { type: 'string' },
|
|
116
|
+
truncated: { type: 'boolean' },
|
|
110
117
|
elements: {
|
|
111
118
|
type: 'array',
|
|
112
119
|
required: true,
|
|
@@ -168,6 +175,7 @@ export function apply(ctx, config = {}) {
|
|
|
168
175
|
properties: {
|
|
169
176
|
url: { type: 'string', required: true },
|
|
170
177
|
title: { type: 'string' },
|
|
178
|
+
truncated: { type: 'boolean' },
|
|
171
179
|
elements: {
|
|
172
180
|
type: 'array',
|
|
173
181
|
required: true,
|
|
@@ -546,6 +554,7 @@ export function apply(ctx, config = {}) {
|
|
|
546
554
|
timeoutMs,
|
|
547
555
|
isConcurrencySafe: () => true,
|
|
548
556
|
async execute(args, exec) {
|
|
557
|
+
assertAllowed('browser_switch_tab');
|
|
549
558
|
const browser = ctx.get('browser');
|
|
550
559
|
if (browser === undefined)
|
|
551
560
|
throw new Error('tool-browser: browser service unavailable');
|
|
@@ -629,7 +638,11 @@ export function apply(ctx, config = {}) {
|
|
|
629
638
|
return [{ type: 'text', text: '(no recorded operations yet)' }];
|
|
630
639
|
return [{
|
|
631
640
|
type: 'text',
|
|
632
|
-
text: entries.map(e =>
|
|
641
|
+
text: entries.map(e => {
|
|
642
|
+
const rawParams = JSON.stringify(e.params);
|
|
643
|
+
const shownParams = rawParams.length > 300 ? rawParams.slice(0, 300) + '…' : rawParams;
|
|
644
|
+
return `#${e.seq} ${e.action} ${e.ok ? 'ok' : 'FAIL'} ${shownParams}${e.result !== undefined ? ` -> ${e.result}` : ''}${e.error !== undefined ? ` !! ${e.error}` : ''}`;
|
|
645
|
+
}).join('\n'),
|
|
633
646
|
}];
|
|
634
647
|
},
|
|
635
648
|
},
|
|
@@ -738,9 +751,7 @@ export function apply(ctx, config = {}) {
|
|
|
738
751
|
const browser = ctx.get('browser');
|
|
739
752
|
if (browser === undefined)
|
|
740
753
|
throw new Error('tool-browser: browser service unavailable');
|
|
741
|
-
const
|
|
742
|
-
const session = sessionsByTask.get(key) ?? await browser.open();
|
|
743
|
-
sessionsByTask.set(key, session);
|
|
754
|
+
const session = await ensureSession(browser, taskKey(exec));
|
|
744
755
|
const tabs = await browser.listTabs(session);
|
|
745
756
|
return { session, tabs: tabs.map(t => ({ id: t.id, url: t.url, active: t.active })) };
|
|
746
757
|
},
|
|
@@ -763,8 +774,14 @@ export function apply(ctx, config = {}) {
|
|
|
763
774
|
const key = taskKey(exec);
|
|
764
775
|
const session = sessionsByTask.get(key);
|
|
765
776
|
if (session !== undefined) {
|
|
766
|
-
|
|
767
|
-
|
|
777
|
+
try {
|
|
778
|
+
await browser.close(session);
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
// Always forget the mapping so the next call opens a fresh session,
|
|
782
|
+
// even if the provider close threw (the session is half-closed).
|
|
783
|
+
sessionsByTask.delete(key);
|
|
784
|
+
}
|
|
768
785
|
}
|
|
769
786
|
return { reset: true };
|
|
770
787
|
},
|
|
@@ -787,8 +804,15 @@ export function apply(ctx, config = {}) {
|
|
|
787
804
|
isConcurrencySafe: () => false,
|
|
788
805
|
async execute(args, exec) {
|
|
789
806
|
// Always allowed so the guard can be lifted.
|
|
790
|
-
|
|
791
|
-
|
|
807
|
+
const allowed = args.allowed ?? [];
|
|
808
|
+
const unknown = allowed.filter((t) => !t.startsWith('browser_'));
|
|
809
|
+
if (unknown.length > 0) {
|
|
810
|
+
throw new Error(`browser_restrict: unknown tool name(s) ${unknown.map(t => `"${t}"`).join(', ')} (must start with "browser_")`);
|
|
811
|
+
}
|
|
812
|
+
// Empty list (or omitted) lifts the restriction; a non-empty list is the
|
|
813
|
+
// new allow-list.
|
|
814
|
+
restrictedTo = allowed.length === 0 ? undefined : [...allowed];
|
|
815
|
+
return { restrictedTo: restrictedTo === undefined ? [] : [...restrictedTo] };
|
|
792
816
|
},
|
|
793
817
|
}));
|
|
794
818
|
ctx.tools.register(defineTool({
|
|
@@ -829,8 +853,8 @@ export function apply(ctx, config = {}) {
|
|
|
829
853
|
}
|
|
830
854
|
/** Test hook: inspect and reset the plugin-level session map (used by tests). */
|
|
831
855
|
export const internals = {
|
|
832
|
-
/**
|
|
833
|
-
get sessions() { return sessionsByTask; },
|
|
856
|
+
/** A copy of the per-task session map (task key -> provider session id). */
|
|
857
|
+
get sessions() { return new Map(sessionsByTask); },
|
|
834
858
|
/** Drop one task's mapping without closing the provider session. */
|
|
835
859
|
clearSession(key = 'default') { sessionsByTask.delete(key); },
|
|
836
860
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|
package/src/browser/runtime.ts
CHANGED
|
@@ -250,9 +250,21 @@ export class BrowserRuntime extends Service {
|
|
|
250
250
|
return this.resolveProvider().restoreAuth(session, cookies)
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
/** Close the session through the selected provider. Idempotent
|
|
253
|
+
/** Close the session through the selected provider. Idempotent; a missing
|
|
254
|
+
* provider is treated as already-closed so teardown paths stay no-ops. */
|
|
254
255
|
async close(session: BrowserSessionId): Promise<void> {
|
|
255
|
-
|
|
256
|
+
try {
|
|
257
|
+
await this.resolveProvider().close(session)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const code = error instanceof BrowserError ? (error as { code?: string }).code : undefined
|
|
260
|
+
if (code === 'BROWSER_PROVIDER_UNAVAILABLE'
|
|
261
|
+
|| code === 'BROWSER_PROVIDER_CONFIGURED_MISSING'
|
|
262
|
+
|| code === 'BROWSER_PROVIDER_CONFIGURED_UNAVAILABLE'
|
|
263
|
+
|| code === 'BROWSER_PROVIDER_AMBIGUOUS') {
|
|
264
|
+
return // provider gone; nothing to close
|
|
265
|
+
}
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
256
268
|
}
|
|
257
269
|
}
|
|
258
270
|
|
package/src/browser/types.ts
CHANGED
|
@@ -313,7 +313,8 @@ export interface BrowserProvider {
|
|
|
313
313
|
export interface BrowserHistoryEntry {
|
|
314
314
|
/** Monotonic sequence number within the session. */
|
|
315
315
|
readonly seq: number
|
|
316
|
-
/** The operation name: navigate | execute | click | type
|
|
316
|
+
/** The operation name. Replayable: navigate | execute | click | type.
|
|
317
|
+
* Also recorded (non-replayable): fill | download | flushAuth | restoreAuth. */
|
|
317
318
|
readonly action: string
|
|
318
319
|
/** The operation's arguments. */
|
|
319
320
|
readonly params: Record<string, unknown>
|
|
@@ -49,10 +49,15 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
49
49
|
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
50
50
|
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
51
51
|
const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
|
|
52
|
+
// Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
|
|
53
|
+
// is bound to the seam's own fiber (the browser row), so a reload of this
|
|
54
|
+
// row would otherwise collide with the still-registered provider
|
|
55
|
+
// (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
|
|
56
|
+
const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }))
|
|
52
57
|
ctx.effect(() => () => {
|
|
58
|
+
unregister()
|
|
53
59
|
if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
|
|
54
60
|
host.dispose()
|
|
55
61
|
}
|
|
56
62
|
})
|
|
57
|
-
ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }))
|
|
58
63
|
}
|