dsh-builtin-browser 0.1.2 → 0.1.4
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 +16 -1
- package/README.md +16 -1
- package/cordis.patch.yml +5 -7
- package/lib/browser-electron/entry.d.ts +10 -6
- package/lib/browser-electron/entry.js +18 -4
- package/lib/browser-electron/host-main.d.ts +16 -0
- package/lib/browser-electron/host-main.js +151 -0
- package/lib/browser-electron/remote-host.d.ts +43 -0
- package/lib/browser-electron/remote-host.js +380 -0
- package/package.json +11 -2
package/README.en.md
CHANGED
|
@@ -16,7 +16,22 @@ This plugin provides the **host-side capability** (browser seam, Electron CDP pr
|
|
|
16
16
|
## Requirements
|
|
17
17
|
|
|
18
18
|
- DeepSeek Harness (dsh) with the `web` profile
|
|
19
|
-
-
|
|
19
|
+
- **Electron runtime** (optional peer dependency): the desktop shell carries it; on plain `dsh web` the plugin must be able to locate an Electron binary (see below)
|
|
20
|
+
- With a desktop shell (`ctx.electronViewHost`) the shell's embedded view is used; without one the plugin **self-hosts**: it spawns its own Electron window and the `browser_*` tools still work
|
|
21
|
+
|
|
22
|
+
**Electron lookup order**: ① `require('electron')` (peer installed) → ② DSH install anchors → ③ `node_modules/.pnpm` virtual store → ④ `ELECTRON_PATH` env var. A clear error tells you when none is found.
|
|
23
|
+
|
|
24
|
+
### Verified versions
|
|
25
|
+
|
|
26
|
+
| Component | Version |
|
|
27
|
+
|---|---|
|
|
28
|
+
| DeepSeek Harness (dsh) | `0.1.0-rc.5` |
|
|
29
|
+
| Electron | `43.4.0` |
|
|
30
|
+
| Node.js | `22.20.0` |
|
|
31
|
+
| dsh-builtin-browser | `0.1.3` |
|
|
32
|
+
| OS | Windows 10 (10.0.26200) |
|
|
33
|
+
|
|
34
|
+
> The plugin declares `electron >= 30`; other platforms (macOS/Linux) run the same protocol but were only verified on the Windows environment above.
|
|
20
35
|
|
|
21
36
|
## Install
|
|
22
37
|
|
package/README.md
CHANGED
|
@@ -16,7 +16,22 @@
|
|
|
16
16
|
## 环境要求
|
|
17
17
|
|
|
18
18
|
- DeepSeek Harness(dsh)且安装了 `web` profile
|
|
19
|
-
-
|
|
19
|
+
- **Electron 运行时**(可选 peer 依赖):桌面外壳自带;纯 `dsh web` 下需要能定位到 Electron 二进制(见下)
|
|
20
|
+
- 有桌面外壳(`ctx.electronViewHost`)时用外壳嵌入视图;没有时插件**自托管**:自己拉起一个 Electron 窗口,`browser_*` 工具照常可用
|
|
21
|
+
|
|
22
|
+
**Electron 定位顺序**:① `require('electron')`(peer 依赖已装)→ ② DSH 安装锚点 → ③ `node_modules/.pnpm` 虚拟仓库 → ④ `ELECTRON_PATH` 环境变量。找不到时工具会报清晰的错误提示。
|
|
23
|
+
|
|
24
|
+
### 验证过的版本
|
|
25
|
+
|
|
26
|
+
| 组件 | 版本 |
|
|
27
|
+
|---|---|
|
|
28
|
+
| DeepSeek Harness(dsh) | `0.1.0-rc.5` |
|
|
29
|
+
| Electron | `43.4.0` |
|
|
30
|
+
| Node.js | `22.20.0` |
|
|
31
|
+
| dsh-builtin-browser | `0.1.3` |
|
|
32
|
+
| 操作系统 | Windows 10 (10.0.26200) |
|
|
33
|
+
|
|
34
|
+
> 插件声明 `electron >= 30`;其他平台(如 macOS/Linux)按同一协议运行,但仅在上表 Windows 环境实测。
|
|
20
35
|
|
|
21
36
|
## 安装
|
|
22
37
|
|
package/cordis.patch.yml
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
# dsh-builtin-browser bundle patch: mounts the shared-browser capability into a
|
|
2
|
-
# profile composition. The browser seam (ctx.browser) is always mounted
|
|
3
|
-
# Electron provider
|
|
4
|
-
# (the desktop shell
|
|
5
|
-
#
|
|
6
|
-
#
|
|
2
|
+
# profile composition. The browser seam (ctx.browser) is always mounted. The
|
|
3
|
+
# Electron provider and browser_* tools are ALWAYS registered: when the host
|
|
4
|
+
# provides `electronViewHost` (the desktop shell) that embedded view is used,
|
|
5
|
+
# and otherwise the plugin self-hosts its own Electron window, so installing
|
|
6
|
+
# the plugin is enough for the browser to work anywhere.
|
|
7
7
|
- insert:
|
|
8
8
|
- id: browser
|
|
9
9
|
name: dsh-builtin-browser/browser
|
|
10
10
|
|
|
11
11
|
- id: browser-electron
|
|
12
12
|
name: dsh-builtin-browser/browser-electron
|
|
13
|
-
disabled: !!js ctx.get('electronViewHost') === undefined
|
|
14
13
|
config:
|
|
15
14
|
viewHost: !!js ctx.get('electronViewHost')
|
|
16
15
|
|
|
17
16
|
- id: tool-browser
|
|
18
17
|
name: dsh-builtin-browser/tool-browser
|
|
19
|
-
disabled: !!js ctx.get('electronViewHost') === undefined
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Electron browser provider plugin entry: registers the Electron-backed
|
|
3
3
|
* `BrowserProvider` with `ctx.browser`. The provider needs a view host (real
|
|
4
|
-
* Electron `WebContentsView` objects)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Electron `WebContentsView` objects). When a desktop shell supplies
|
|
5
|
+
* `ctx.electronViewHost`, that host is used (embedded, human-machine shared
|
|
6
|
+
* view). Otherwise the plugin self-hosts: it spawns its own Electron child
|
|
7
|
+
* (`host-main.js`) and drives it over a local TCP JSON-RPC socket, so
|
|
8
|
+
* installing the plugin is enough for `browser_*` tools to work on any
|
|
9
|
+
* surface.
|
|
7
10
|
* @module dsh-browser/browser-electron
|
|
8
11
|
*/
|
|
9
12
|
import type { Context } from '@deepseek-ai/cordis';
|
|
@@ -12,14 +15,15 @@ import type { BrowserRuntime } from '../browser/runtime.ts';
|
|
|
12
15
|
import type { ElectronBrowserViewHost } from './provider.ts';
|
|
13
16
|
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.ts';
|
|
14
17
|
export type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts';
|
|
18
|
+
export { RemoteElectronViewHost, defaultHostMainPath } from './remote-host.ts';
|
|
15
19
|
/** Cordis plugin name used by loader diagnostics. */
|
|
16
20
|
export declare const name = "browser-electron";
|
|
17
21
|
/** The browser seam this provider registers into. */
|
|
18
22
|
export declare const inject: string[];
|
|
19
|
-
/** Plugin config:
|
|
23
|
+
/** Plugin config: an optional externally-supplied view host. */
|
|
20
24
|
export interface Config {
|
|
21
|
-
/**
|
|
22
|
-
readonly viewHost
|
|
25
|
+
/** View host supplied by a desktop shell; absent -> self-host. */
|
|
26
|
+
readonly viewHost?: ElectronBrowserViewHost;
|
|
23
27
|
/** Allow navigation only to HTTP(S) URLs. Default true. */
|
|
24
28
|
readonly httpOnly?: boolean;
|
|
25
29
|
}
|
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Electron browser provider plugin entry: registers the Electron-backed
|
|
3
3
|
* `BrowserProvider` with `ctx.browser`. The provider needs a view host (real
|
|
4
|
-
* Electron `WebContentsView` objects)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Electron `WebContentsView` objects). When a desktop shell supplies
|
|
5
|
+
* `ctx.electronViewHost`, that host is used (embedded, human-machine shared
|
|
6
|
+
* view). Otherwise the plugin self-hosts: it spawns its own Electron child
|
|
7
|
+
* (`host-main.js`) and drives it over a local TCP JSON-RPC socket, so
|
|
8
|
+
* installing the plugin is enough for `browser_*` tools to work on any
|
|
9
|
+
* surface.
|
|
7
10
|
* @module dsh-browser/browser-electron
|
|
8
11
|
*/
|
|
9
12
|
import z from '@deepseek-ai/schemastery';
|
|
10
13
|
import { ElectronBrowserProvider } from "./provider.js";
|
|
14
|
+
import { defaultHostMainPath, RemoteElectronViewHost } from "./remote-host.js";
|
|
11
15
|
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from "./provider.js";
|
|
16
|
+
export { RemoteElectronViewHost, defaultHostMainPath } from "./remote-host.js";
|
|
12
17
|
/** Cordis plugin name used by loader diagnostics. */
|
|
13
18
|
export const name = 'browser-electron';
|
|
14
19
|
/** The browser seam this provider registers into. */
|
|
15
20
|
export const inject = ['browser'];
|
|
16
21
|
export const Config = z.object({
|
|
22
|
+
// Absent on surfaces without a desktop shell; the plugin self-hosts then.
|
|
17
23
|
viewHost: z.any(),
|
|
18
24
|
httpOnly: z.boolean().default(true),
|
|
19
25
|
});
|
|
20
26
|
/** Register the Electron browser provider with `ctx.browser`. */
|
|
21
27
|
export function apply(ctx, config) {
|
|
22
|
-
|
|
28
|
+
// External host (desktop shell) wins; otherwise self-host. The self-hosted
|
|
29
|
+
// child is disposed with the fiber, mirroring the shell's lifetime.
|
|
30
|
+
const host = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath());
|
|
31
|
+
ctx.effect(() => () => {
|
|
32
|
+
if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
|
|
33
|
+
host.dispose();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, { httpOnly: config.httpOnly }));
|
|
23
37
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-hosted Electron browser host (child side): the Electron main process
|
|
3
|
+
* spawned by {@link RemoteElectronViewHost}. Owns one `BrowserWindow` plus
|
|
4
|
+
* `WebContentsView`s and their `webContents.debugger` (CDP), and answers
|
|
5
|
+
* line-delimited JSON-RPC on stdio.
|
|
6
|
+
*
|
|
7
|
+
* Protocol (one JSON object per line, both directions):
|
|
8
|
+
* <- { id, op: 'ping' } | { id, op: 'createView', viewId } |
|
|
9
|
+
* { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
|
|
10
|
+
* { id, op: 'command', viewId, method, params }
|
|
11
|
+
* -> { id, ok: true, result? } | { id, ok: false, err }
|
|
12
|
+
*
|
|
13
|
+
* The parent never parses stderr, so diagnostics may go there freely.
|
|
14
|
+
* @module dsh-browser/browser-electron/host-main
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-hosted Electron browser host (child side): the Electron main process
|
|
3
|
+
* spawned by {@link RemoteElectronViewHost}. Owns one `BrowserWindow` plus
|
|
4
|
+
* `WebContentsView`s and their `webContents.debugger` (CDP), and answers
|
|
5
|
+
* line-delimited JSON-RPC on stdio.
|
|
6
|
+
*
|
|
7
|
+
* Protocol (one JSON object per line, both directions):
|
|
8
|
+
* <- { id, op: 'ping' } | { id, op: 'createView', viewId } |
|
|
9
|
+
* { id, op: 'destroyView', viewId } | { id, op: 'showView', viewId } |
|
|
10
|
+
* { id, op: 'command', viewId, method, params }
|
|
11
|
+
* -> { id, ok: true, result? } | { id, ok: false, err }
|
|
12
|
+
*
|
|
13
|
+
* The parent never parses stderr, so diagnostics may go there freely.
|
|
14
|
+
* @module dsh-browser/browser-electron/host-main
|
|
15
|
+
*/
|
|
16
|
+
import { app, BrowserWindow, WebContentsView } from 'electron';
|
|
17
|
+
import { createInterface } from 'node:readline';
|
|
18
|
+
import { createConnection } from 'node:net';
|
|
19
|
+
/** CDP protocol version attached to every view's debugger. */
|
|
20
|
+
const CDP_VERSION = '1.3';
|
|
21
|
+
/** Views by the id the parent assigned at createView time. */
|
|
22
|
+
const views = new Map();
|
|
23
|
+
/** The single browser window; created lazily on first createView. */
|
|
24
|
+
let window;
|
|
25
|
+
/** The RPC socket to the parent; set when the connection is established. */
|
|
26
|
+
let rpcSocket;
|
|
27
|
+
/** Reply to the parent over the RPC socket. */
|
|
28
|
+
function reply(id, payload) {
|
|
29
|
+
if (rpcSocket === undefined) {
|
|
30
|
+
process.stderr.write(`[dsh-browser host] reply without socket (id=${id})\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
rpcSocket.write(JSON.stringify({ id, ...payload }) + '\n');
|
|
34
|
+
}
|
|
35
|
+
/** Handle one command. */
|
|
36
|
+
async function handle(op, msg) {
|
|
37
|
+
try {
|
|
38
|
+
switch (op) {
|
|
39
|
+
case 'ping':
|
|
40
|
+
reply(msg.id, { ok: true });
|
|
41
|
+
return;
|
|
42
|
+
case 'createView': {
|
|
43
|
+
const viewId = msg.viewId;
|
|
44
|
+
if (viewId === undefined)
|
|
45
|
+
throw new Error('createView missing viewId');
|
|
46
|
+
if (window === undefined) {
|
|
47
|
+
window = new BrowserWindow({ width: 1400, height: 900, show: true, title: 'dsh-browser' });
|
|
48
|
+
window.on('closed', () => { window = undefined; });
|
|
49
|
+
}
|
|
50
|
+
const view = new WebContentsView();
|
|
51
|
+
view.setVisible(false);
|
|
52
|
+
window.contentView.addChildView(view);
|
|
53
|
+
const [width, height] = window.getContentSize();
|
|
54
|
+
view.setBounds({ x: 0, y: 0, width: width ?? 0, height: height ?? 0 });
|
|
55
|
+
view.setVisible(true);
|
|
56
|
+
view.webContents.debugger.attach(CDP_VERSION);
|
|
57
|
+
views.set(viewId, { webContentsView: view });
|
|
58
|
+
reply(msg.id, { ok: true });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
case 'destroyView': {
|
|
62
|
+
const viewId = msg.viewId;
|
|
63
|
+
if (viewId === undefined)
|
|
64
|
+
throw new Error('destroyView missing viewId');
|
|
65
|
+
const entry = views.get(viewId);
|
|
66
|
+
if (entry !== undefined) {
|
|
67
|
+
views.delete(viewId);
|
|
68
|
+
try {
|
|
69
|
+
entry.webContentsView.webContents.debugger.detach();
|
|
70
|
+
}
|
|
71
|
+
catch { /* already detached */ }
|
|
72
|
+
entry.webContentsView.webContents.close();
|
|
73
|
+
window?.contentView.removeChildView(entry.webContentsView);
|
|
74
|
+
}
|
|
75
|
+
reply(msg.id, { ok: true });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
case 'showView': {
|
|
79
|
+
const viewId = msg.viewId;
|
|
80
|
+
if (viewId === undefined)
|
|
81
|
+
throw new Error('showView missing viewId');
|
|
82
|
+
const entry = views.get(viewId);
|
|
83
|
+
if (entry !== undefined)
|
|
84
|
+
entry.webContentsView.setVisible(true);
|
|
85
|
+
reply(msg.id, { ok: true });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
case 'command': {
|
|
89
|
+
const viewId = msg.viewId;
|
|
90
|
+
if (viewId === undefined)
|
|
91
|
+
throw new Error('command missing viewId');
|
|
92
|
+
const entry = views.get(viewId);
|
|
93
|
+
if (entry === undefined)
|
|
94
|
+
throw new Error(`command: unknown view ${viewId}`);
|
|
95
|
+
const method = msg.method;
|
|
96
|
+
if (typeof method !== 'string')
|
|
97
|
+
throw new Error('command missing method');
|
|
98
|
+
const result = await entry.webContentsView.webContents.debugger.sendCommand(method, msg.params ?? {});
|
|
99
|
+
reply(msg.id, { ok: true, result });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
throw new Error(`unknown op ${op}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
reply(msg.id, { ok: false, err: String(error) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Electron entry: connect back to the parent's RPC server (port from
|
|
112
|
+
* `--rpc-port`) and serve line-delimited JSON-RPC. `ELECTRON_RUN_AS_NODE` is
|
|
113
|
+
* cleared by the parent so `require('electron')` works; this file is loaded as
|
|
114
|
+
* the app entry so `app` is available immediately.
|
|
115
|
+
*/
|
|
116
|
+
void app.whenReady().then(() => {
|
|
117
|
+
const portArg = process.argv.indexOf('--rpc-port');
|
|
118
|
+
const port = portArg >= 0 ? Number(process.argv[portArg + 1]) : NaN;
|
|
119
|
+
if (!Number.isFinite(port)) {
|
|
120
|
+
process.stderr.write('[dsh-browser host] missing --rpc-port\n');
|
|
121
|
+
app.exit(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const socket = createConnection({ host: '127.0.0.1', port });
|
|
125
|
+
rpcSocket = socket;
|
|
126
|
+
socket.setEncoding('utf8');
|
|
127
|
+
const rl = createInterface({ input: socket });
|
|
128
|
+
rl.on('line', line => {
|
|
129
|
+
const text = line.trim();
|
|
130
|
+
if (text === '')
|
|
131
|
+
return;
|
|
132
|
+
let msg;
|
|
133
|
+
try {
|
|
134
|
+
msg = JSON.parse(text);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return; // non-protocol noise
|
|
138
|
+
}
|
|
139
|
+
if (typeof msg.id !== 'number' || typeof msg.op !== 'string')
|
|
140
|
+
return;
|
|
141
|
+
void handle(msg.op, msg).catch(() => { });
|
|
142
|
+
});
|
|
143
|
+
socket.on('error', error => {
|
|
144
|
+
process.stderr.write(`[dsh-browser host] socket error: ${String(error)}\n`);
|
|
145
|
+
});
|
|
146
|
+
// Keep the process alive until the parent closes the socket or kills us.
|
|
147
|
+
});
|
|
148
|
+
// Diagnostics go to stderr, which the parent never parses as protocol.
|
|
149
|
+
process.on('uncaughtException', error => {
|
|
150
|
+
process.stderr.write(`[dsh-browser host] uncaught: ${String(error)}\n`);
|
|
151
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-hosted Electron browser host (parent side): an
|
|
3
|
+
* {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
|
|
4
|
+
* Electron child process (host-main.js) and drives it over line-delimited
|
|
5
|
+
* JSON-RPC on stdio. This is what makes the plugin work on surfaces without a
|
|
6
|
+
* desktop shell's electronViewHost (plain dsh web): installing the plugin is
|
|
7
|
+
* enough — the browser window appears on first use.
|
|
8
|
+
*
|
|
9
|
+
* Protocol (one JSON object per line, both directions):
|
|
10
|
+
* -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
|
|
11
|
+
* { id, op: 'showView', viewId } | { id, op: 'command', viewId, method, params }
|
|
12
|
+
* <- { id, ok: true, result? } | { id, ok: false, err }
|
|
13
|
+
*
|
|
14
|
+
* The child is Electron's main process; host-main.js owns the BrowserWindow,
|
|
15
|
+
* WebContentsViews, and webContents.debugger (CDP).
|
|
16
|
+
* @module dsh-browser/browser-electron/remote-host
|
|
17
|
+
*/
|
|
18
|
+
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts';
|
|
19
|
+
/**
|
|
20
|
+
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
21
|
+
* keeps it alive until dispose(). Fallback when no desktop shell provides
|
|
22
|
+
* ctx.electronViewHost.
|
|
23
|
+
*/
|
|
24
|
+
export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
25
|
+
private readonly hostMainPath;
|
|
26
|
+
private client;
|
|
27
|
+
private server;
|
|
28
|
+
private pendingSocket;
|
|
29
|
+
private readonly views;
|
|
30
|
+
private readyPromise;
|
|
31
|
+
constructor(hostMainPath: string);
|
|
32
|
+
/** Ensure the child is up and ready (lazy on first use). */
|
|
33
|
+
private ready;
|
|
34
|
+
private start;
|
|
35
|
+
createView(): ElectronViewHandle;
|
|
36
|
+
private ensureView;
|
|
37
|
+
showView(handle: ElectronViewHandle): void;
|
|
38
|
+
destroyView(handle: ElectronViewHandle): void;
|
|
39
|
+
/** Shut the child and the RPC server down. */
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
42
|
+
/** Default host-main path relative to this module's build output. */
|
|
43
|
+
export declare function defaultHostMainPath(): string;
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-hosted Electron browser host (parent side): an
|
|
3
|
+
* {@link ElectronBrowserViewHost} implementation that spawns the plugin's own
|
|
4
|
+
* Electron child process (host-main.js) and drives it over line-delimited
|
|
5
|
+
* JSON-RPC on stdio. This is what makes the plugin work on surfaces without a
|
|
6
|
+
* desktop shell's electronViewHost (plain dsh web): installing the plugin is
|
|
7
|
+
* enough — the browser window appears on first use.
|
|
8
|
+
*
|
|
9
|
+
* Protocol (one JSON object per line, both directions):
|
|
10
|
+
* -> { id, op: 'createView' } | { id, op: 'destroyView', viewId } |
|
|
11
|
+
* { id, op: 'showView', viewId } | { id, op: 'command', viewId, method, params }
|
|
12
|
+
* <- { id, ok: true, result? } | { id, ok: false, err }
|
|
13
|
+
*
|
|
14
|
+
* The child is Electron's main process; host-main.js owns the BrowserWindow,
|
|
15
|
+
* WebContentsViews, and webContents.debugger (CDP).
|
|
16
|
+
* @module dsh-browser/browser-electron/remote-host
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { createServer } from 'node:net';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
/** How long to wait for the child to signal readiness before failing. */
|
|
25
|
+
const READY_TIMEOUT_MS = 20_000;
|
|
26
|
+
/**
|
|
27
|
+
* Locate the Electron binary, trying, in order:
|
|
28
|
+
* 1. require('electron') from this module (optional peer dependency),
|
|
29
|
+
* 2. require.resolve('electron') against DSH install anchors (global dsh
|
|
30
|
+
* package, npm global root) — desktop-shell environments satisfy this,
|
|
31
|
+
* 3. a pnpm virtual store under common workspace layouts,
|
|
32
|
+
* 4. ELECTRON_PATH (explicit override).
|
|
33
|
+
* @returns the path to the Electron executable.
|
|
34
|
+
*/
|
|
35
|
+
function resolveElectronPath() {
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
// 1. Optional peer dependency.
|
|
38
|
+
try {
|
|
39
|
+
const resolved = require('electron');
|
|
40
|
+
if (typeof resolved === 'string' && resolved.length > 0)
|
|
41
|
+
return resolved;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// continue probing
|
|
45
|
+
}
|
|
46
|
+
// 2. DSH install anchors: the global dsh package and the npm global root
|
|
47
|
+
// both carry electron junctions under pnpm layouts.
|
|
48
|
+
const anchors = [];
|
|
49
|
+
const globalPrefix = process.env.npm_config_prefix ?? process.env.PREFIX;
|
|
50
|
+
if (globalPrefix !== undefined) {
|
|
51
|
+
anchors.push(join(globalPrefix, 'node_modules'));
|
|
52
|
+
anchors.push(join(globalPrefix, 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'));
|
|
53
|
+
}
|
|
54
|
+
if (process.env.DSH_HOME !== undefined) {
|
|
55
|
+
anchors.push(join(process.env.DSH_HOME, 'profiles', 'node_modules'));
|
|
56
|
+
}
|
|
57
|
+
for (const anchor of anchors) {
|
|
58
|
+
try {
|
|
59
|
+
const resolved = require.resolve('electron', { paths: [anchor] });
|
|
60
|
+
if (typeof resolved === 'string' && resolved.length > 0) {
|
|
61
|
+
// electron's index.js is not the binary; find the dist executable
|
|
62
|
+
// next to the package root.
|
|
63
|
+
const exe = electronExeBeside(resolved);
|
|
64
|
+
if (exe !== undefined)
|
|
65
|
+
return exe;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// continue probing
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 3. pnpm virtual store, walking up from several candidate roots: this
|
|
73
|
+
// module (its real path may differ from where the profile resolves it when
|
|
74
|
+
// installed via a junction/link), the process CWD, and the Node executable
|
|
75
|
+
// dir (DSH CLI typically runs from the workspace root).
|
|
76
|
+
const roots = new Set([
|
|
77
|
+
fileURLToPath(new URL('.', import.meta.url)),
|
|
78
|
+
process.cwd(),
|
|
79
|
+
dirname(process.execPath),
|
|
80
|
+
]);
|
|
81
|
+
for (const root of roots) {
|
|
82
|
+
let dir = root;
|
|
83
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
84
|
+
const store = join(dir, 'node_modules', '.pnpm');
|
|
85
|
+
if (existsSync(store)) {
|
|
86
|
+
const exe = findElectronExe(store);
|
|
87
|
+
if (exe !== undefined)
|
|
88
|
+
return exe;
|
|
89
|
+
}
|
|
90
|
+
const parent = join(dir, '..');
|
|
91
|
+
if (parent === dir)
|
|
92
|
+
break;
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// 4. Explicit override.
|
|
97
|
+
const override = process.env.ELECTRON_PATH;
|
|
98
|
+
if (typeof override === 'string' && override.length > 0 && existsSync(override))
|
|
99
|
+
return override;
|
|
100
|
+
throw new Error('dsh-builtin-browser: cannot locate the Electron binary. Install electron in the host profile ' +
|
|
101
|
+
'(e.g. `dsh plugin --profile web add electron`) or set ELECTRON_PATH to the electron executable.');
|
|
102
|
+
}
|
|
103
|
+
/** From an electron package entry file, find the dist executable beside it. */
|
|
104
|
+
function electronExeBeside(entry) {
|
|
105
|
+
const candidates = [
|
|
106
|
+
join(dirname(entry), 'dist', 'electron.exe'),
|
|
107
|
+
join(dirname(entry), 'dist', 'electron'),
|
|
108
|
+
join(dirname(entry), '..', 'dist', 'electron.exe'),
|
|
109
|
+
join(dirname(entry), '..', 'dist', 'electron'),
|
|
110
|
+
];
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
if (existsSync(candidate))
|
|
113
|
+
return candidate;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
/** dirname without importing node:path's dirname separately. */
|
|
118
|
+
function dirname(p) {
|
|
119
|
+
const i = p.lastIndexOf('/');
|
|
120
|
+
const j = p.lastIndexOf('\\');
|
|
121
|
+
const k = Math.max(i, j);
|
|
122
|
+
return k < 0 ? p : p.slice(0, k);
|
|
123
|
+
}
|
|
124
|
+
/** Search a pnpm virtual store for an Electron dist binary. */
|
|
125
|
+
function findElectronExe(store) {
|
|
126
|
+
const entries = existsSync(store) ? readdirSync(store) : [];
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!entry.startsWith('electron@'))
|
|
129
|
+
continue;
|
|
130
|
+
const candidates = [
|
|
131
|
+
join(store, entry, 'node_modules', 'electron', 'dist', 'electron.exe'),
|
|
132
|
+
join(store, entry, 'node_modules', 'electron', 'dist', 'electron'),
|
|
133
|
+
];
|
|
134
|
+
for (const candidate of candidates) {
|
|
135
|
+
if (existsSync(candidate))
|
|
136
|
+
return candidate;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Line-delimited JSON-RPC client over a local TCP socket. Electron's main
|
|
143
|
+
* process on Windows does not receive piped stdin, so the parent listens on a
|
|
144
|
+
* loopback port and passes it to the child via `--rpc-port`; the child
|
|
145
|
+
* connects back and speaks the same one-JSON-per-line protocol.
|
|
146
|
+
*/
|
|
147
|
+
class ElectronChildClient {
|
|
148
|
+
hostMainPath;
|
|
149
|
+
port;
|
|
150
|
+
child;
|
|
151
|
+
pending = new Map();
|
|
152
|
+
nextId = 1;
|
|
153
|
+
buffer = '';
|
|
154
|
+
socket;
|
|
155
|
+
connected = false;
|
|
156
|
+
outbox = [];
|
|
157
|
+
constructor(hostMainPath, port) {
|
|
158
|
+
this.hostMainPath = hostMainPath;
|
|
159
|
+
this.port = port;
|
|
160
|
+
const electron = resolveElectronPath();
|
|
161
|
+
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
162
|
+
// Node, breaking require('electron'); NODE_OPTIONS can inject flags that
|
|
163
|
+
// break the child. Rebuild the env without either.
|
|
164
|
+
const env = { ...process.env };
|
|
165
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
166
|
+
delete env.NODE_OPTIONS;
|
|
167
|
+
this.child = spawn(electron, [hostMainPath, '--rpc-port', String(port)], {
|
|
168
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
169
|
+
windowsHide: false,
|
|
170
|
+
env,
|
|
171
|
+
});
|
|
172
|
+
this.child.stderr.setEncoding('utf8');
|
|
173
|
+
this.child.stderr.on('data', chunk => {
|
|
174
|
+
// Diagnostics only; never parse stderr as protocol.
|
|
175
|
+
process.stderr.write(`[dsh-browser host] ${String(chunk)}`);
|
|
176
|
+
});
|
|
177
|
+
this.child.on('exit', (code, signal) => {
|
|
178
|
+
const err = new Error(`dsh-builtin-browser: browser host exited (code=${String(code)} signal=${String(signal)})`);
|
|
179
|
+
for (const pending of this.pending.values())
|
|
180
|
+
pending.reject(err);
|
|
181
|
+
this.pending.clear();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Accept the child's connection (called by the server). */
|
|
185
|
+
attach(socket) {
|
|
186
|
+
this.socket = socket;
|
|
187
|
+
this.connected = true;
|
|
188
|
+
socket.setEncoding('utf8');
|
|
189
|
+
socket.on('data', chunk => this.onData(chunk));
|
|
190
|
+
socket.on('close', () => {
|
|
191
|
+
this.connected = false;
|
|
192
|
+
const err = new Error('dsh-builtin-browser: browser host connection closed');
|
|
193
|
+
for (const pending of this.pending.values())
|
|
194
|
+
pending.reject(err);
|
|
195
|
+
this.pending.clear();
|
|
196
|
+
});
|
|
197
|
+
// Flush anything queued while disconnected.
|
|
198
|
+
if (this.outbox.length > 0) {
|
|
199
|
+
for (const line of this.outbox)
|
|
200
|
+
socket.write(line + '\n');
|
|
201
|
+
this.outbox = [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
onData(chunk) {
|
|
205
|
+
this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
206
|
+
let nl;
|
|
207
|
+
while ((nl = this.buffer.indexOf('\n')) >= 0) {
|
|
208
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
209
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
210
|
+
if (line === '')
|
|
211
|
+
continue;
|
|
212
|
+
let msg;
|
|
213
|
+
try {
|
|
214
|
+
msg = JSON.parse(line);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Non-protocol line; ignore.
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (typeof msg.id !== 'number')
|
|
221
|
+
continue;
|
|
222
|
+
const pending = this.pending.get(msg.id);
|
|
223
|
+
if (pending === undefined)
|
|
224
|
+
continue;
|
|
225
|
+
this.pending.delete(msg.id);
|
|
226
|
+
if (msg.ok === true)
|
|
227
|
+
pending.resolve(msg.result);
|
|
228
|
+
else
|
|
229
|
+
pending.reject(new Error(msg.err ?? 'browser host command failed'));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Send one command and await the reply. */
|
|
233
|
+
call(op, payload = {}) {
|
|
234
|
+
const id = this.nextId++;
|
|
235
|
+
const line = JSON.stringify({ id, op, ...payload });
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
this.pending.set(id, { resolve: resolve, reject });
|
|
238
|
+
if (this.connected && this.socket !== undefined) {
|
|
239
|
+
this.socket.write(line + '\n');
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
// Not connected yet: queue; attach() flushes on the child's arrival.
|
|
243
|
+
this.outbox.push(line);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/** Terminate the child. */
|
|
248
|
+
kill() {
|
|
249
|
+
try {
|
|
250
|
+
this.socket?.destroy();
|
|
251
|
+
}
|
|
252
|
+
catch { /* already closed */ }
|
|
253
|
+
this.child.kill();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** One view in the child: its id, used for every command. */
|
|
257
|
+
class RemoteView {
|
|
258
|
+
id;
|
|
259
|
+
client;
|
|
260
|
+
constructor(id, client) {
|
|
261
|
+
this.id = id;
|
|
262
|
+
this.client = client;
|
|
263
|
+
}
|
|
264
|
+
sendCommand(method, params) {
|
|
265
|
+
return this.client.call('command', {
|
|
266
|
+
viewId: this.id,
|
|
267
|
+
method,
|
|
268
|
+
params: params ?? {},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
274
|
+
* keeps it alive until dispose(). Fallback when no desktop shell provides
|
|
275
|
+
* ctx.electronViewHost.
|
|
276
|
+
*/
|
|
277
|
+
export class RemoteElectronViewHost {
|
|
278
|
+
hostMainPath;
|
|
279
|
+
client;
|
|
280
|
+
server;
|
|
281
|
+
pendingSocket;
|
|
282
|
+
views = new Map();
|
|
283
|
+
readyPromise;
|
|
284
|
+
constructor(hostMainPath) {
|
|
285
|
+
this.hostMainPath = hostMainPath;
|
|
286
|
+
}
|
|
287
|
+
/** Ensure the child is up and ready (lazy on first use). */
|
|
288
|
+
ready() {
|
|
289
|
+
if (this.readyPromise !== undefined)
|
|
290
|
+
return this.readyPromise;
|
|
291
|
+
this.readyPromise = this.start();
|
|
292
|
+
return this.readyPromise;
|
|
293
|
+
}
|
|
294
|
+
async start() {
|
|
295
|
+
// Listen on an ephemeral loopback port; the child connects back.
|
|
296
|
+
const server = createServer(socket => {
|
|
297
|
+
if (this.client !== undefined)
|
|
298
|
+
this.client.attach(socket);
|
|
299
|
+
else
|
|
300
|
+
this.pendingSocket = socket;
|
|
301
|
+
});
|
|
302
|
+
await new Promise((resolve, reject) => {
|
|
303
|
+
server.once('error', reject);
|
|
304
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
305
|
+
});
|
|
306
|
+
const address = server.address();
|
|
307
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
308
|
+
this.server = server;
|
|
309
|
+
this.client = new ElectronChildClient(this.hostMainPath, port);
|
|
310
|
+
if (this.pendingSocket !== undefined) {
|
|
311
|
+
this.client.attach(this.pendingSocket);
|
|
312
|
+
this.pendingSocket = undefined;
|
|
313
|
+
}
|
|
314
|
+
// Wait for the child's connection + readiness ping.
|
|
315
|
+
await withTimeout(this.client.call('ping'), READY_TIMEOUT_MS, 'browser host did not become ready');
|
|
316
|
+
}
|
|
317
|
+
createView() {
|
|
318
|
+
// The seam is synchronous; the provider uses the handle immediately, so
|
|
319
|
+
// commands are deferred until the child is up and the view materialized.
|
|
320
|
+
const id = `view:${Math.random().toString(36).slice(2, 10)}`;
|
|
321
|
+
const view = new DeferredRemoteView(id, () => this.ensureView(id));
|
|
322
|
+
this.views.set(id, view);
|
|
323
|
+
return view;
|
|
324
|
+
}
|
|
325
|
+
async ensureView(id) {
|
|
326
|
+
await this.ready();
|
|
327
|
+
const client = this.client;
|
|
328
|
+
if (client === undefined)
|
|
329
|
+
throw new Error('browser host unavailable');
|
|
330
|
+
await client.call('createView', { viewId: id });
|
|
331
|
+
const view = new RemoteView(id, client);
|
|
332
|
+
this.views.set(id, view);
|
|
333
|
+
return view;
|
|
334
|
+
}
|
|
335
|
+
showView(handle) {
|
|
336
|
+
void this.ready().then(() => this.client?.call('showView', { viewId: handle.id }));
|
|
337
|
+
}
|
|
338
|
+
destroyView(handle) {
|
|
339
|
+
const view = this.views.get(handle.id);
|
|
340
|
+
if (view === undefined)
|
|
341
|
+
return;
|
|
342
|
+
this.views.delete(handle.id);
|
|
343
|
+
void this.ready()
|
|
344
|
+
.then(() => this.client?.call('destroyView', { viewId: handle.id }))
|
|
345
|
+
.catch(() => { });
|
|
346
|
+
}
|
|
347
|
+
/** Shut the child and the RPC server down. */
|
|
348
|
+
dispose() {
|
|
349
|
+
this.client?.kill();
|
|
350
|
+
this.client = undefined;
|
|
351
|
+
this.server?.close();
|
|
352
|
+
this.server = undefined;
|
|
353
|
+
this.readyPromise = undefined;
|
|
354
|
+
this.views.clear();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** A view handle that waits for child readiness before issuing commands. */
|
|
358
|
+
class DeferredRemoteView {
|
|
359
|
+
id;
|
|
360
|
+
materialize;
|
|
361
|
+
constructor(id, materialize) {
|
|
362
|
+
this.id = id;
|
|
363
|
+
this.materialize = materialize;
|
|
364
|
+
}
|
|
365
|
+
async sendCommand(method, params) {
|
|
366
|
+
const view = await this.materialize();
|
|
367
|
+
return view.sendCommand(method, params);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/** Reject a promise if it does not settle within the budget. */
|
|
371
|
+
function withTimeout(promise, ms, message) {
|
|
372
|
+
return new Promise((resolve, reject) => {
|
|
373
|
+
const timer = setTimeout(() => reject(new Error(`${message} (${ms}ms)`)), ms);
|
|
374
|
+
promise.then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/** Default host-main path relative to this module's build output. */
|
|
378
|
+
export function defaultHostMainPath() {
|
|
379
|
+
return fileURLToPath(new URL('./host-main.js', import.meta.url));
|
|
380
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-builtin-browser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Shared real browser capability for DeepSeek Harness: a visible browser the human watches and can take over, driven by the agent over CDP (snapshot/execute/content/tab management); host-side seam, provider, and browser_* tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"types": "./lib/tool-browser/index.d.ts",
|
|
23
23
|
"default": "./lib/tool-browser/index.js"
|
|
24
24
|
},
|
|
25
|
+
"./host-main": {
|
|
26
|
+
"default": "./lib/browser-electron/host-main.js"
|
|
27
|
+
},
|
|
25
28
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
26
29
|
"./package.json": "./package.json"
|
|
27
30
|
},
|
|
@@ -63,7 +66,13 @@
|
|
|
63
66
|
"peerDependencies": {
|
|
64
67
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
65
68
|
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
|
66
|
-
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1"
|
|
69
|
+
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
70
|
+
"electron": ">= 30.0.0"
|
|
71
|
+
},
|
|
72
|
+
"peerDependenciesMeta": {
|
|
73
|
+
"electron": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
67
76
|
},
|
|
68
77
|
"devDependencies": {
|
|
69
78
|
"@types/node": "^22.0.0",
|