dsh-builtin-browser 0.1.23 → 0.2.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 +45 -2
- package/README.md +46 -3
- package/client.js +298 -0
- package/cordis.patch.yml +24 -5
- 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/entry.js +115 -0
- package/lib/browser-electron/history-store.d.ts +71 -0
- package/lib/browser-electron/history-store.js +151 -0
- package/lib/browser-electron/host-main.js +28 -1
- package/lib/browser-electron/provider.d.ts +80 -0
- package/lib/browser-electron/provider.js +142 -2
- package/lib/browser-electron/remote-host.d.ts +8 -0
- package/lib/browser-electron/remote-host.js +64 -6
- package/lib/browser-electron/settings-store.d.ts +87 -0
- package/lib/browser-electron/settings-store.js +160 -0
- package/lib/browser-electron/virtual-cursor.d.ts +42 -0
- package/lib/browser-electron/virtual-cursor.js +71 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +72 -2
- package/package.json +13 -1
- package/src/browser/runtime.ts +22 -0
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/entry.ts +135 -0
- package/src/browser-electron/history-store.ts +162 -0
- package/src/browser-electron/host-main.ts +26 -1
- package/src/browser-electron/provider.ts +166 -2
- package/src/browser-electron/remote-host.ts +67 -5
- package/src/browser-electron/settings-store.ts +198 -0
- package/src/browser-electron/virtual-cursor.ts +82 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +71 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic pointer feedback for a human watching the tab: an overlay drawn
|
|
3
|
+
* INSIDE the page, never the real system pointer. Its presence is the "the agent
|
|
4
|
+
* has taken over this tab" signal, so it also covers DOM-level operations that
|
|
5
|
+
* move no pointer at all.
|
|
6
|
+
*
|
|
7
|
+
* Implementation notes that matter:
|
|
8
|
+
* - The overlay is `position: fixed` and positioned from viewport coordinates,
|
|
9
|
+
* which is exactly what the element locator already computes and what
|
|
10
|
+
* `Input.dispatchMouseEvent` consumes.
|
|
11
|
+
* - All styling is inline and the ripple uses the Web Animations API: a
|
|
12
|
+
* `<style>` element would be subject to the page's `style-src` CSP, an
|
|
13
|
+
* injected script is not. Injected page feedback must never be silently
|
|
14
|
+
* dropped by a strict CSP.
|
|
15
|
+
* - Every call is best-effort: feedback is never allowed to fail an operation.
|
|
16
|
+
* @module dsh-browser/browser-electron/virtual-cursor
|
|
17
|
+
*/
|
|
18
|
+
/** What the pointer is doing at the reported point. */
|
|
19
|
+
export type CursorAction = 'move' | 'click';
|
|
20
|
+
/**
|
|
21
|
+
* The in-page expression that materializes or moves the overlay. Idempotent: the
|
|
22
|
+
* first call creates the nodes, later calls only move them (and pulse).
|
|
23
|
+
* @param x - viewport x in CSS pixels.
|
|
24
|
+
* @param y - viewport y in CSS pixels.
|
|
25
|
+
* @param action - whether to pulse a click ripple at the point.
|
|
26
|
+
* @returns a self-contained expression that evaluates to `true`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function cursorScript(x: number, y: number, action: CursorAction): string;
|
|
29
|
+
/**
|
|
30
|
+
* Draw (or move) the pointer overlay on one view's page.
|
|
31
|
+
*
|
|
32
|
+
* Fire-and-forget: a page that is mid-navigation, cross-origin-blocked, or
|
|
33
|
+
* otherwise unable to answer must not turn a successful operation into a
|
|
34
|
+
* failure, and the navigation that just committed may still be replacing the
|
|
35
|
+
* document this call would have painted into.
|
|
36
|
+
* @param handle - the view to paint into.
|
|
37
|
+
* @param x - viewport x in CSS pixels.
|
|
38
|
+
* @param y - viewport y in CSS pixels.
|
|
39
|
+
* @param action - whether to pulse a click ripple.
|
|
40
|
+
* @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
|
|
41
|
+
*/
|
|
42
|
+
export declare function paintCursor<TView>(handle: TView, x: number, y: number, action: CursorAction, evaluate: (handle: TView, expression: string) => Promise<unknown>): void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic pointer feedback for a human watching the tab: an overlay drawn
|
|
3
|
+
* INSIDE the page, never the real system pointer. Its presence is the "the agent
|
|
4
|
+
* has taken over this tab" signal, so it also covers DOM-level operations that
|
|
5
|
+
* move no pointer at all.
|
|
6
|
+
*
|
|
7
|
+
* Implementation notes that matter:
|
|
8
|
+
* - The overlay is `position: fixed` and positioned from viewport coordinates,
|
|
9
|
+
* which is exactly what the element locator already computes and what
|
|
10
|
+
* `Input.dispatchMouseEvent` consumes.
|
|
11
|
+
* - All styling is inline and the ripple uses the Web Animations API: a
|
|
12
|
+
* `<style>` element would be subject to the page's `style-src` CSP, an
|
|
13
|
+
* injected script is not. Injected page feedback must never be silently
|
|
14
|
+
* dropped by a strict CSP.
|
|
15
|
+
* - Every call is best-effort: feedback is never allowed to fail an operation.
|
|
16
|
+
* @module dsh-browser/browser-electron/virtual-cursor
|
|
17
|
+
*/
|
|
18
|
+
/** Element id of the overlay root, stable so repeat calls update one node. */
|
|
19
|
+
const CURSOR_ID = '__dsh_agent_cursor__';
|
|
20
|
+
/**
|
|
21
|
+
* The in-page expression that materializes or moves the overlay. Idempotent: the
|
|
22
|
+
* first call creates the nodes, later calls only move them (and pulse).
|
|
23
|
+
* @param x - viewport x in CSS pixels.
|
|
24
|
+
* @param y - viewport y in CSS pixels.
|
|
25
|
+
* @param action - whether to pulse a click ripple at the point.
|
|
26
|
+
* @returns a self-contained expression that evaluates to `true`.
|
|
27
|
+
*/
|
|
28
|
+
export function cursorScript(x, y, action) {
|
|
29
|
+
return `(() => {
|
|
30
|
+
const id = ${JSON.stringify(CURSOR_ID)}
|
|
31
|
+
let root = document.getElementById(id)
|
|
32
|
+
if (!root) {
|
|
33
|
+
root = document.createElement('div')
|
|
34
|
+
root.id = id
|
|
35
|
+
root.setAttribute('aria-hidden', 'true')
|
|
36
|
+
root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 120ms ease-out;will-change:transform'
|
|
37
|
+
const svg = '<svg width="22" height="22" viewBox="0 0 22 22" style="position:absolute;left:-2px;top:-2px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))">'
|
|
38
|
+
+ '<path d="M4 2 L4 18 L8.4 13.8 L11.2 19.6 L13.8 18.4 L11 12.8 L17 12.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.4" stroke-linejoin="round"/></svg>'
|
|
39
|
+
const ripple = '<span style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
|
|
40
|
+
root.innerHTML = svg + ripple
|
|
41
|
+
;(document.body || document.documentElement).appendChild(root)
|
|
42
|
+
}
|
|
43
|
+
root.style.transform = 'translate(${x}px, ${y}px)'
|
|
44
|
+
${action === 'click' ? `const ripple = root.lastElementChild
|
|
45
|
+
if (ripple && typeof ripple.animate === 'function') {
|
|
46
|
+
ripple.animate(
|
|
47
|
+
[{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
|
|
48
|
+
{ duration: 550, easing: 'ease-out' },
|
|
49
|
+
)
|
|
50
|
+
}` : ''}
|
|
51
|
+
return true
|
|
52
|
+
})()`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Draw (or move) the pointer overlay on one view's page.
|
|
56
|
+
*
|
|
57
|
+
* Fire-and-forget: a page that is mid-navigation, cross-origin-blocked, or
|
|
58
|
+
* otherwise unable to answer must not turn a successful operation into a
|
|
59
|
+
* failure, and the navigation that just committed may still be replacing the
|
|
60
|
+
* document this call would have painted into.
|
|
61
|
+
* @param handle - the view to paint into.
|
|
62
|
+
* @param x - viewport x in CSS pixels.
|
|
63
|
+
* @param y - viewport y in CSS pixels.
|
|
64
|
+
* @param action - whether to pulse a click ripple.
|
|
65
|
+
* @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
|
|
66
|
+
*/
|
|
67
|
+
export function paintCursor(handle, x, y, action, evaluate) {
|
|
68
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
69
|
+
return;
|
|
70
|
+
void evaluate(handle, cursorScript(Math.round(x), Math.round(y), action)).catch(() => { });
|
|
71
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -4,10 +4,24 @@
|
|
|
4
4
|
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
5
|
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
6
|
* - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
7
|
+
* The capability itself is composed by those rows; this root entry exists for
|
|
8
|
+
* programmatic imports AND because the host's client-module scan identifies a
|
|
9
|
+
* package's browser half through a loader row whose specifier resolves to the
|
|
10
|
+
* PACKAGE ROOT. A row named after a subpath (`dsh-builtin-browser/browser`) is
|
|
11
|
+
* not a package specifier and is skipped by design, which is why the patch also
|
|
12
|
+
* registers a row under the bare package name — see cordis.patch.yml.
|
|
9
13
|
* @module dsh-builtin-browser
|
|
10
14
|
*/
|
|
15
|
+
/** Plugin identity for the root row (the composition surface is the subpath rows). */
|
|
16
|
+
export declare const name = "dsh-builtin-browser";
|
|
17
|
+
/**
|
|
18
|
+
* Inert application for the root row: the browser seam, the Electron provider and
|
|
19
|
+
* the tools are mounted by the subpath rows, so this one only exists to give the
|
|
20
|
+
* package a loader row named after the package itself. Without it the host's
|
|
21
|
+
* client scan finds no row to resolve `dsh.client` from, and the settings panel
|
|
22
|
+
* never reaches the browser (the plugin works, its client half is invisible).
|
|
23
|
+
*/
|
|
24
|
+
export declare function apply(): void;
|
|
11
25
|
export { BrowserError } from './browser/types.js';
|
|
12
26
|
export type { BrowserA11yNode, BrowserA11yRequest, BrowserA11yResult, BrowserChallenge, BrowserCheckRequest, BrowserClearRequest, BrowserClickRequest, BrowserContentFormat, BrowserContentRequest, BrowserContentResult, BrowserElementTarget, BrowserExecuteRequest, BrowserExecuteResult, BrowserFillField, BrowserFillRequest, BrowserFillResult, BrowserGetValueRequest, BrowserGetValueResult, BrowserNavigateRequest, BrowserOpenRequest, BrowserProvider, BrowserScrapeField, BrowserScrapeRequest, BrowserScrapeResult, BrowserScreenshotRequest, BrowserScreenshotResult, BrowserSelectRequest, BrowserSelectResult, BrowserSessionId, BrowserSetValueRequest, BrowserSetValueResult, BrowserSnapshotElement, BrowserSnapshotResult, BrowserTab, BrowserTypeRequest, ExportedCookie, } from './browser/types.js';
|
|
13
27
|
export { BrowserRuntime } from './browser/runtime.js';
|
package/lib/index.js
CHANGED
|
@@ -4,10 +4,26 @@
|
|
|
4
4
|
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
5
|
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
6
|
* - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
7
|
+
* The capability itself is composed by those rows; this root entry exists for
|
|
8
|
+
* programmatic imports AND because the host's client-module scan identifies a
|
|
9
|
+
* package's browser half through a loader row whose specifier resolves to the
|
|
10
|
+
* PACKAGE ROOT. A row named after a subpath (`dsh-builtin-browser/browser`) is
|
|
11
|
+
* not a package specifier and is skipped by design, which is why the patch also
|
|
12
|
+
* registers a row under the bare package name — see cordis.patch.yml.
|
|
9
13
|
* @module dsh-builtin-browser
|
|
10
14
|
*/
|
|
15
|
+
/** Plugin identity for the root row (the composition surface is the subpath rows). */
|
|
16
|
+
export const name = 'dsh-builtin-browser';
|
|
17
|
+
/**
|
|
18
|
+
* Inert application for the root row: the browser seam, the Electron provider and
|
|
19
|
+
* the tools are mounted by the subpath rows, so this one only exists to give the
|
|
20
|
+
* package a loader row named after the package itself. Without it the host's
|
|
21
|
+
* client scan finds no row to resolve `dsh.client` from, and the settings panel
|
|
22
|
+
* never reaches the browser (the plugin works, its client half is invisible).
|
|
23
|
+
*/
|
|
24
|
+
export function apply() {
|
|
25
|
+
// Intentionally empty: see the module comment above.
|
|
26
|
+
}
|
|
11
27
|
export { BrowserError } from './browser/types.js';
|
|
12
28
|
export { BrowserRuntime } from './browser/runtime.js';
|
|
13
29
|
export { ElectronBrowserProvider } from './browser-electron/provider.js';
|
|
@@ -63,8 +63,13 @@ function taskKey(exec) {
|
|
|
63
63
|
*/
|
|
64
64
|
async function ensureSession(browser, state, key, agent) {
|
|
65
65
|
const existing = state.sessionsByTask.get(key);
|
|
66
|
-
if (existing !== undefined)
|
|
67
|
-
|
|
66
|
+
if (existing !== undefined) {
|
|
67
|
+
// The human closing a browser window ends that session, so a cached id can
|
|
68
|
+
// be stale: never hand a dead session back to a tool call.
|
|
69
|
+
if (browser.exists(existing))
|
|
70
|
+
return existing;
|
|
71
|
+
state.sessionsByTask.delete(key);
|
|
72
|
+
}
|
|
68
73
|
const pending = state.pendingOpens.get(key);
|
|
69
74
|
if (pending !== undefined)
|
|
70
75
|
return pending;
|
|
@@ -1285,6 +1290,71 @@ export function apply(ctx, config = {}) {
|
|
|
1285
1290
|
return { entries: rendered };
|
|
1286
1291
|
},
|
|
1287
1292
|
}));
|
|
1293
|
+
ctx.tools.register(defineTool({
|
|
1294
|
+
name: 'browser_visited',
|
|
1295
|
+
description: 'List pages the shared browser has visited, newest first, from the PERSISTENT browsing history: it survives closing the browser and restarting DSH, so it is how you (or the human) find a page again afterwards. Reopen one by passing its url to browser_open. Not to be confused with browser_history, which is only the current session\'s operation log (navigate/click/type) and disappears with the session.',
|
|
1296
|
+
parameters: {
|
|
1297
|
+
limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
|
|
1298
|
+
domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
|
|
1299
|
+
},
|
|
1300
|
+
output: {
|
|
1301
|
+
schema: {
|
|
1302
|
+
type: 'object',
|
|
1303
|
+
additionalProperties: false,
|
|
1304
|
+
properties: {
|
|
1305
|
+
count: { type: 'number', required: true },
|
|
1306
|
+
entries: {
|
|
1307
|
+
type: 'array',
|
|
1308
|
+
required: true,
|
|
1309
|
+
items: {
|
|
1310
|
+
type: 'object',
|
|
1311
|
+
additionalProperties: false,
|
|
1312
|
+
properties: {
|
|
1313
|
+
at: { type: 'number', required: true },
|
|
1314
|
+
url: { type: 'string', required: true },
|
|
1315
|
+
title: { type: 'string' },
|
|
1316
|
+
session: { type: 'string' },
|
|
1317
|
+
},
|
|
1318
|
+
},
|
|
1319
|
+
},
|
|
1320
|
+
},
|
|
1321
|
+
},
|
|
1322
|
+
render: (_args, value) => {
|
|
1323
|
+
const entries = value.entries;
|
|
1324
|
+
if (entries.length === 0) {
|
|
1325
|
+
return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }];
|
|
1326
|
+
}
|
|
1327
|
+
return [{
|
|
1328
|
+
type: 'text',
|
|
1329
|
+
text: entries.map(entry => {
|
|
1330
|
+
const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 19);
|
|
1331
|
+
const title = entry.title !== undefined ? `${entry.title} — ` : '';
|
|
1332
|
+
return `${when} ${title}${entry.url}${entry.session !== undefined ? ` [${entry.session}]` : ''}`;
|
|
1333
|
+
}).join('\n'),
|
|
1334
|
+
}];
|
|
1335
|
+
},
|
|
1336
|
+
},
|
|
1337
|
+
timeoutMs,
|
|
1338
|
+
isConcurrencySafe: () => true,
|
|
1339
|
+
async execute(args, _exec) {
|
|
1340
|
+
const browser = ctx.get('browser');
|
|
1341
|
+
if (browser === undefined)
|
|
1342
|
+
throw new Error('tool-browser: browser service unavailable');
|
|
1343
|
+
const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30;
|
|
1344
|
+
const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)));
|
|
1345
|
+
const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined;
|
|
1346
|
+
const entries = browser.visited({ limit, ...domain !== undefined ? { domain } : {} });
|
|
1347
|
+
return {
|
|
1348
|
+
count: entries.length,
|
|
1349
|
+
entries: entries.map(entry => ({
|
|
1350
|
+
at: entry.at,
|
|
1351
|
+
url: entry.url,
|
|
1352
|
+
...entry.title !== undefined ? { title: entry.title } : {},
|
|
1353
|
+
...entry.session !== undefined ? { session: entry.session } : {},
|
|
1354
|
+
})),
|
|
1355
|
+
};
|
|
1356
|
+
},
|
|
1357
|
+
}));
|
|
1288
1358
|
ctx.tools.register(defineTool({
|
|
1289
1359
|
name: 'browser_replay',
|
|
1290
1360
|
description: 'Replay one recorded browser operation by its history sequence number (from browser_history). Navigate/click/type are re-issued against the current page; execute re-runs its script. The replayed step is appended to history as a new entry.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared real browser plugin for DeepSeek Harness: install-and-use — a visible native browser the human can take over, driven by the agent over CDP. DOM-level interaction (React/Vue safe), per-task session isolation, cookie persistence (browser_auth), CAPTCHA detection, batch form fill (browser_fill), authenticated download, history/replay, action restriction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"./host-main": {
|
|
26
26
|
"default": "./lib/browser-electron/host-main.js"
|
|
27
27
|
},
|
|
28
|
+
"./client": {
|
|
29
|
+
"default": "./client.js"
|
|
30
|
+
},
|
|
28
31
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
29
32
|
"./package.json": "./package.json"
|
|
30
33
|
},
|
|
@@ -32,6 +35,7 @@
|
|
|
32
35
|
"lib",
|
|
33
36
|
"src",
|
|
34
37
|
"docs",
|
|
38
|
+
"client.js",
|
|
35
39
|
"cordis.patch.yml",
|
|
36
40
|
"README.md",
|
|
37
41
|
"README.en.md",
|
|
@@ -78,6 +82,14 @@
|
|
|
78
82
|
"bundle": {
|
|
79
83
|
"patch": "./cordis.patch.yml"
|
|
80
84
|
},
|
|
85
|
+
"client": {
|
|
86
|
+
"platform": "web",
|
|
87
|
+
"inject": [
|
|
88
|
+
"@deepseek-ai/dsh-client-locale",
|
|
89
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
90
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
91
|
+
]
|
|
92
|
+
},
|
|
81
93
|
"compatibility": {
|
|
82
94
|
"dsh": ">=0.1.1-rc.1 <0.3.0",
|
|
83
95
|
"profiles": [
|
package/src/browser/runtime.ts
CHANGED
|
@@ -47,6 +47,7 @@ import type {
|
|
|
47
47
|
BrowserKeyRequest,
|
|
48
48
|
BrowserChallenge,
|
|
49
49
|
ExportedCookie,
|
|
50
|
+
VisitedPage,
|
|
50
51
|
} from './types.js'
|
|
51
52
|
import { BrowserError } from './types.js'
|
|
52
53
|
|
|
@@ -345,6 +346,27 @@ export class BrowserRuntime extends Service {
|
|
|
345
346
|
return this.resolveProvider().history(session)
|
|
346
347
|
}
|
|
347
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Persisted visits, newest first — the record that outlives the browser
|
|
351
|
+
* process, as opposed to {@link history}'s session-scoped operation log.
|
|
352
|
+
* @param options - result cap and an optional hostname filter.
|
|
353
|
+
* @returns the retained visits (empty when history recording is disabled).
|
|
354
|
+
*/
|
|
355
|
+
visited(options?: { readonly limit?: number; readonly domain?: string }): readonly VisitedPage[] {
|
|
356
|
+
return this.resolveProvider().visited(options)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Whether a session is still live. Sessions can end outside the tool layer
|
|
361
|
+
* (the human closed its window), so a cached id must be re-checked before
|
|
362
|
+
* reuse; a provider that does not implement the check counts as live.
|
|
363
|
+
* @param session - the session id to test.
|
|
364
|
+
*/
|
|
365
|
+
exists(session: BrowserSessionId): boolean {
|
|
366
|
+
const provider = this.resolveProvider()
|
|
367
|
+
return provider.exists === undefined ? true : provider.exists(session)
|
|
368
|
+
}
|
|
369
|
+
|
|
348
370
|
/** Replay one recorded operation by sequence number through the provider. */
|
|
349
371
|
async replay(session: BrowserSessionId, seq: number): Promise<void> {
|
|
350
372
|
return this.resolveProvider().replay(session, seq)
|
package/src/browser/types.ts
CHANGED
|
@@ -573,12 +573,43 @@ export interface BrowserProvider {
|
|
|
573
573
|
restoreAuth(session: BrowserSessionId, cookies: readonly ExportedCookie[]): Promise<number>
|
|
574
574
|
/** Return the session's chronological operation log. */
|
|
575
575
|
history(session: BrowserSessionId): Promise<readonly BrowserHistoryEntry[]>
|
|
576
|
+
/**
|
|
577
|
+
* Persisted visits, newest first: what survives the browser process, so a
|
|
578
|
+
* human or an Agent can find a page again after the interface was closed.
|
|
579
|
+
* @param options - result cap and an optional hostname filter.
|
|
580
|
+
* @returns the retained visits (empty when history is disabled).
|
|
581
|
+
*/
|
|
582
|
+
visited(options?: { readonly limit?: number; readonly domain?: string }): readonly VisitedPage[]
|
|
583
|
+
/**
|
|
584
|
+
* Optional: whether a session is still live. A session can end outside the
|
|
585
|
+
* tool layer — the human closed the browser window — so a cached id must be
|
|
586
|
+
* checked before it is reused. Providers that never end a session on their own
|
|
587
|
+
* can omit this, and callers treat absence as "still live".
|
|
588
|
+
* @param session - the session id to test.
|
|
589
|
+
*/
|
|
590
|
+
exists?(session: BrowserSessionId): boolean
|
|
576
591
|
/** Replay one recorded operation by sequence number. */
|
|
577
592
|
replay(session: BrowserSessionId, seq: number): Promise<void>
|
|
578
593
|
/** Close the session and destroy its backing surface. Idempotent. */
|
|
579
594
|
close(session: BrowserSessionId): Promise<void>
|
|
580
595
|
}
|
|
581
596
|
|
|
597
|
+
/**
|
|
598
|
+
* One visited page in the persistent browsing history — the durable record that
|
|
599
|
+
* outlives sessions, unlike {@link BrowserHistoryEntry}, which is a
|
|
600
|
+
* session-scoped operation log (navigate/click/type/…).
|
|
601
|
+
*/
|
|
602
|
+
export interface VisitedPage {
|
|
603
|
+
/** Epoch milliseconds when the visit was recorded. */
|
|
604
|
+
readonly at: number
|
|
605
|
+
/** Absolute URL of the visited document. */
|
|
606
|
+
readonly url: string
|
|
607
|
+
/** Document title at visit time, when the page exposed one. */
|
|
608
|
+
readonly title?: string
|
|
609
|
+
/** Session/task label that drove the visit, when the provider knows it. */
|
|
610
|
+
readonly session?: string
|
|
611
|
+
}
|
|
612
|
+
|
|
582
613
|
/** One recorded browser operation, in chronological order (seq 1, 2, 3…). */
|
|
583
614
|
export interface BrowserHistoryEntry {
|
|
584
615
|
/** Monotonic sequence number within the session. */
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
14
15
|
import z from '@deepseek-ai/schemastery'
|
|
15
16
|
import type { BrowserRuntime } from '../browser/runtime.js'
|
|
16
17
|
import { ElectronBrowserProvider } from './provider.js'
|
|
17
18
|
import type { ElectronBrowserViewHost } from './provider.js'
|
|
18
19
|
import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
|
|
20
|
+
import { SettingsStore } from './settings-store.js'
|
|
19
21
|
|
|
20
22
|
export {
|
|
21
23
|
ELECTRON_BROWSER_PROVIDER_ID,
|
|
@@ -62,6 +64,9 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
62
64
|
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
63
65
|
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
64
66
|
const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
|
|
67
|
+
// One settings document per plugin instance: the settings panel writes it, the
|
|
68
|
+
// provider reads it live, and both ends agree on the same file.
|
|
69
|
+
const settings = new SettingsStore()
|
|
65
70
|
// Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
|
|
66
71
|
// is bound to the seam's own fiber (the browser row), so a reload of this
|
|
67
72
|
// row would otherwise collide with the still-registered provider
|
|
@@ -71,6 +76,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
71
76
|
downloadDir: config.downloadDir,
|
|
72
77
|
snapshotMaxElements: config.snapshotMaxElements,
|
|
73
78
|
contentMaxChars: config.contentMaxChars,
|
|
79
|
+
settings: () => settings.get(),
|
|
74
80
|
}))
|
|
75
81
|
ctx.effect(() => () => {
|
|
76
82
|
unregister()
|
|
@@ -78,4 +84,133 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
|
|
|
78
84
|
host.dispose()
|
|
79
85
|
}
|
|
80
86
|
})
|
|
87
|
+
installSettingsRoute(ctx, settings)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Route the settings panel reads and writes. */
|
|
91
|
+
const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
|
|
92
|
+
|
|
93
|
+
/** The host web-server surface this plugin uses, described structurally. */
|
|
94
|
+
interface WebServerHost {
|
|
95
|
+
readonly webServer: {
|
|
96
|
+
register(
|
|
97
|
+
spec: {
|
|
98
|
+
readonly kind: 'exact'
|
|
99
|
+
readonly path: string
|
|
100
|
+
readonly handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>
|
|
101
|
+
},
|
|
102
|
+
label?: string,
|
|
103
|
+
): () => void
|
|
104
|
+
}
|
|
105
|
+
effect(callback: () => (() => void) | void): void
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Expose the settings document over the host's web server, which is how the
|
|
110
|
+
* settings panel reads and writes it. A host without that service (headless /
|
|
111
|
+
* CLI surfaces) simply skips this: the panel is then unreachable and the file
|
|
112
|
+
* stays hand-editable — never a plugin-startup failure.
|
|
113
|
+
* @param ctx - the plugin context.
|
|
114
|
+
* @param settings - the settings document to expose.
|
|
115
|
+
*/
|
|
116
|
+
function installSettingsRoute(ctx: Context, settings: SettingsStore): void {
|
|
117
|
+
const inject = (ctx as unknown as {
|
|
118
|
+
inject?: (deps: readonly string[], callback: (host: WebServerHost) => void) => void
|
|
119
|
+
}).inject
|
|
120
|
+
if (typeof inject !== 'function') return
|
|
121
|
+
try {
|
|
122
|
+
inject(['webServer'], (host) => {
|
|
123
|
+
host.effect(() => host.webServer.register({
|
|
124
|
+
kind: 'exact',
|
|
125
|
+
path: SETTINGS_ROUTE,
|
|
126
|
+
handler: (request, response) => handleSettingsRequest(request, response, settings),
|
|
127
|
+
}, 'dsh-builtin-browser: settings'))
|
|
128
|
+
})
|
|
129
|
+
} catch {
|
|
130
|
+
// No web server on this surface: the settings file remains the interface.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Same-origin guard. Settings are machine-local configuration, so a page loaded
|
|
136
|
+
* elsewhere must not be able to read or rewrite them.
|
|
137
|
+
* @param request - the incoming request.
|
|
138
|
+
* @returns whether the request may touch the settings document.
|
|
139
|
+
*/
|
|
140
|
+
function sameOrigin(request: IncomingMessage): boolean {
|
|
141
|
+
const site = String(request.headers['sec-fetch-site'] ?? '').toLowerCase()
|
|
142
|
+
if (site === 'cross-site') return false
|
|
143
|
+
const origin = String(request.headers.origin ?? '').trim()
|
|
144
|
+
if (origin === 'null') return false
|
|
145
|
+
if (origin === '') return true
|
|
146
|
+
try {
|
|
147
|
+
return new URL(origin).host.toLowerCase() === String(request.headers.host ?? '').trim().toLowerCase()
|
|
148
|
+
} catch {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One settings round-trip: `GET` reads the document, `PUT`/`POST` merges a
|
|
155
|
+
* partial patch into it.
|
|
156
|
+
* @param request - the incoming request.
|
|
157
|
+
* @param response - the response to write.
|
|
158
|
+
* @param settings - the settings document.
|
|
159
|
+
*/
|
|
160
|
+
async function handleSettingsRequest(
|
|
161
|
+
request: IncomingMessage,
|
|
162
|
+
response: ServerResponse,
|
|
163
|
+
settings: SettingsStore,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
const json = (status: number, body: unknown): void => {
|
|
166
|
+
response.writeHead(status, {
|
|
167
|
+
'content-type': 'application/json; charset=utf-8',
|
|
168
|
+
'cache-control': 'no-store',
|
|
169
|
+
})
|
|
170
|
+
response.end(JSON.stringify(body))
|
|
171
|
+
}
|
|
172
|
+
if (!sameOrigin(request)) {
|
|
173
|
+
json(403, { ok: false, error: 'cross-origin request refused' })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const method = request.method ?? 'GET'
|
|
177
|
+
if (method === 'GET') {
|
|
178
|
+
json(200, { ok: true, settings: settings.get(), path: settings.path() })
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (method !== 'PUT' && method !== 'POST') {
|
|
182
|
+
response.writeHead(405, { allow: 'GET, PUT' })
|
|
183
|
+
response.end()
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const body = await readBody(request)
|
|
188
|
+
const patch: unknown = body.trim() === '' ? {} : JSON.parse(body)
|
|
189
|
+
json(200, { ok: true, settings: settings.update(patch) })
|
|
190
|
+
} catch (error) {
|
|
191
|
+
json(400, { ok: false, error: `invalid settings patch: ${String(error)}` })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Read a request body with a hard cap (the settings patch is tiny, and an
|
|
197
|
+
* unbounded read would let any same-origin caller exhaust memory).
|
|
198
|
+
* @param request - the incoming request.
|
|
199
|
+
* @param limit - maximum accepted characters.
|
|
200
|
+
* @returns the body text.
|
|
201
|
+
*/
|
|
202
|
+
function readBody(request: IncomingMessage, limit = 64 * 1024): Promise<string> {
|
|
203
|
+
return new Promise<string>((resolve, reject) => {
|
|
204
|
+
let text = ''
|
|
205
|
+
request.setEncoding('utf8')
|
|
206
|
+
request.on('data', chunk => {
|
|
207
|
+
text += String(chunk)
|
|
208
|
+
if (text.length > limit) {
|
|
209
|
+
request.destroy()
|
|
210
|
+
reject(new Error('settings patch too large'))
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
request.on('end', () => resolve(text))
|
|
214
|
+
request.on('error', reject)
|
|
215
|
+
})
|
|
81
216
|
}
|