dsh-builtin-browser 0.1.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/LICENSE +21 -0
- package/README.en.md +89 -0
- package/README.md +89 -0
- package/cordis.patch.yml +19 -0
- package/lib/browser/runtime.d.ts +84 -0
- package/lib/browser/runtime.js +138 -0
- package/lib/browser/types.d.ts +203 -0
- package/lib/browser/types.js +18 -0
- package/lib/browser-electron/entry.d.ts +30 -0
- package/lib/browser-electron/entry.js +23 -0
- package/lib/browser-electron/provider.d.ts +169 -0
- package/lib/browser-electron/provider.js +373 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +13 -0
- package/lib/tool-browser/index.d.ts +33 -0
- package/lib/tool-browser/index.js +359 -0
- package/package.json +77 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron browser provider plugin entry: registers the Electron-backed
|
|
3
|
+
* `BrowserProvider` with `ctx.browser`. The provider needs a view host (real
|
|
4
|
+
* Electron `WebContentsView` objects) that only a desktop shell can supply,
|
|
5
|
+
* so the view host arrives through plugin config (the bundle patch reads it
|
|
6
|
+
* from `ctx.get('electronViewHost')`).
|
|
7
|
+
* @module dsh-browser/browser-electron
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
10
|
+
import z from '@deepseek-ai/schemastery';
|
|
11
|
+
import type { BrowserRuntime } from '../browser/runtime.ts';
|
|
12
|
+
import type { ElectronBrowserViewHost } from './provider.ts';
|
|
13
|
+
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from './provider.ts';
|
|
14
|
+
export type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts';
|
|
15
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
16
|
+
export declare const name = "browser-electron";
|
|
17
|
+
/** The browser seam this provider registers into. */
|
|
18
|
+
export declare const inject: string[];
|
|
19
|
+
/** Plugin config: the view host supplied by the desktop shell. */
|
|
20
|
+
export interface Config {
|
|
21
|
+
/** The Electron view host supplied by the desktop shell. */
|
|
22
|
+
readonly viewHost: ElectronBrowserViewHost;
|
|
23
|
+
/** Allow navigation only to HTTP(S) URLs. Default true. */
|
|
24
|
+
readonly httpOnly?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare const Config: z<Config>;
|
|
27
|
+
/** Register the Electron browser provider with `ctx.browser`. */
|
|
28
|
+
export declare function apply(ctx: Context & {
|
|
29
|
+
browser: BrowserRuntime;
|
|
30
|
+
}, config: Config): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron browser provider plugin entry: registers the Electron-backed
|
|
3
|
+
* `BrowserProvider` with `ctx.browser`. The provider needs a view host (real
|
|
4
|
+
* Electron `WebContentsView` objects) that only a desktop shell can supply,
|
|
5
|
+
* so the view host arrives through plugin config (the bundle patch reads it
|
|
6
|
+
* from `ctx.get('electronViewHost')`).
|
|
7
|
+
* @module dsh-browser/browser-electron
|
|
8
|
+
*/
|
|
9
|
+
import z from '@deepseek-ai/schemastery';
|
|
10
|
+
import { ElectronBrowserProvider } from "./provider.js";
|
|
11
|
+
export { ELECTRON_BROWSER_PROVIDER_ID, ElectronBrowserProvider, } from "./provider.js";
|
|
12
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
13
|
+
export const name = 'browser-electron';
|
|
14
|
+
/** The browser seam this provider registers into. */
|
|
15
|
+
export const inject = ['browser'];
|
|
16
|
+
export const Config = z.object({
|
|
17
|
+
viewHost: z.any(),
|
|
18
|
+
httpOnly: z.boolean().default(true),
|
|
19
|
+
});
|
|
20
|
+
/** Register the Electron browser provider with `ctx.browser`. */
|
|
21
|
+
export function apply(ctx, config) {
|
|
22
|
+
ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost, { httpOnly: config.httpOnly }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron-backed browser provider: `WebContentsView` sessions driven over
|
|
3
|
+
* `webContents.debugger` (CDP). The provider itself does not import Electron 鈥? * it operates through the {@link ElectronBrowserViewHost} seam, which the
|
|
4
|
+
* desktop shell implements with real Electron objects. That keeps this
|
|
5
|
+
* package testable under plain Node and leaves the Electron dependency to the
|
|
6
|
+
* shell that owns the `BrowserWindow`.
|
|
7
|
+
* @module dsh-browser/browser-electron
|
|
8
|
+
*/
|
|
9
|
+
import type { BrowserContentRequest, BrowserContentResult, BrowserExecuteRequest, BrowserExecuteResult, BrowserOpenRequest, BrowserProvider, BrowserSessionId, BrowserSnapshotResult, BrowserTab } from '../browser/types.ts';
|
|
10
|
+
/** Stable provider id registered with `ctx.browser`. */
|
|
11
|
+
export declare const ELECTRON_BROWSER_PROVIDER_ID = "electron";
|
|
12
|
+
/**
|
|
13
|
+
* The minimal Electron surface this provider needs. Implemented by the
|
|
14
|
+
* desktop shell with a real `WebContentsView`; a fake implements it in tests.
|
|
15
|
+
*/
|
|
16
|
+
export interface ElectronBrowserViewHost {
|
|
17
|
+
/**
|
|
18
|
+
* Create a new browser view and return a handle to its webContents-like
|
|
19
|
+
* surface. The host owns windowing (adding the view to the window, sizing,
|
|
20
|
+
* removal); the provider owns CDP-driven behavior.
|
|
21
|
+
*/
|
|
22
|
+
createView(): ElectronViewHandle;
|
|
23
|
+
/**
|
|
24
|
+
* Destroy a view created by this host. Called on session close; idempotent
|
|
25
|
+
* for an already-destroyed view.
|
|
26
|
+
* @param handle - the handle returned by {@link createView}.
|
|
27
|
+
*/
|
|
28
|
+
destroyView(handle: ElectronViewHandle): void;
|
|
29
|
+
/**
|
|
30
|
+
* Show one view as the session's visible surface. The host keeps exactly
|
|
31
|
+
* one visible; switching tabs reorders visibility without losing state.
|
|
32
|
+
* Optional: a host without visible-tab switching treats every view as
|
|
33
|
+
* always present (acceptable for headless/probe hosts).
|
|
34
|
+
* @param handle - the handle to make visible.
|
|
35
|
+
*/
|
|
36
|
+
showView?(handle: ElectronViewHandle): void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A CDP-capable view handle. This is the subset of Electron's
|
|
40
|
+
* `WebContents`/`WebContentsView` the provider drives; the shell's real
|
|
41
|
+
* implementation adapts `webContents.debugger` to it.
|
|
42
|
+
*/
|
|
43
|
+
export interface ElectronViewHandle {
|
|
44
|
+
/** Unique id of the backing view, used for diagnostics. */
|
|
45
|
+
readonly id: string;
|
|
46
|
+
/**
|
|
47
|
+
* Send one CDP command and resolve with its result. Rejects when the
|
|
48
|
+
* debugger is not attached or the command fails.
|
|
49
|
+
* @param method - CDP method, e.g. `Page.navigate`.
|
|
50
|
+
* @param params - CDP command parameters.
|
|
51
|
+
* @returns the CDP `result` object.
|
|
52
|
+
*/
|
|
53
|
+
sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
54
|
+
}
|
|
55
|
+
/** Provider config: navigation admission defaults and snapshot caps. */
|
|
56
|
+
export interface ElectronBrowserProviderConfig {
|
|
57
|
+
/** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
|
|
58
|
+
readonly httpOnly?: boolean;
|
|
59
|
+
/** Maximum snapshot elements before truncation. Default 60. */
|
|
60
|
+
readonly snapshotMaxElements?: number;
|
|
61
|
+
/** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
|
|
62
|
+
readonly contentMaxChars?: number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* CDP method/params for `Page.navigate`, as sent to {@link ElectronViewHandle.sendCommand}.
|
|
66
|
+
*/
|
|
67
|
+
export interface CdpNavigateParams {
|
|
68
|
+
readonly url: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
|
|
72
|
+
*/
|
|
73
|
+
export interface CdpMouseParams {
|
|
74
|
+
readonly type: 'mousePressed' | 'mouseReleased';
|
|
75
|
+
readonly x: number;
|
|
76
|
+
readonly y: number;
|
|
77
|
+
readonly button: 'left';
|
|
78
|
+
readonly clickCount: number;
|
|
79
|
+
}
|
|
80
|
+
/** CDP method/params for `Input.insertText`. */
|
|
81
|
+
export interface CdpInsertTextParams {
|
|
82
|
+
readonly text: string;
|
|
83
|
+
}
|
|
84
|
+
/** CDP method/params for `Runtime.evaluate`. */
|
|
85
|
+
export interface CdpEvaluateParams {
|
|
86
|
+
readonly expression: string;
|
|
87
|
+
readonly returnByValue: boolean;
|
|
88
|
+
readonly awaitPromise?: boolean;
|
|
89
|
+
}
|
|
90
|
+
/** CDP method for a full-page screenshot capture. */
|
|
91
|
+
export declare const CDP_PAGE_CAPTURE_SCREENSHOT = "Page.captureScreenshot";
|
|
92
|
+
/** CDP method for runtime evaluation (the execute path). */
|
|
93
|
+
export declare const CDP_RUNTIME_EVALUATE = "Runtime.evaluate";
|
|
94
|
+
/** CDP method for navigation. */
|
|
95
|
+
export declare const CDP_PAGE_NAVIGATE = "Page.navigate";
|
|
96
|
+
/**
|
|
97
|
+
* Browser provider over Electron views. Sessions hold an ordered list of
|
|
98
|
+
* tabs; each tab is one view created by the host. The active tab receives
|
|
99
|
+
* every operation; switching tabs calls the host's optional `showView` and
|
|
100
|
+
* never loses state. Navigation is admitted only for HTTP(S) targets unless
|
|
101
|
+
* {@link ElectronBrowserProviderConfig.httpOnly} is disabled.
|
|
102
|
+
*/
|
|
103
|
+
export declare class ElectronBrowserProvider implements BrowserProvider {
|
|
104
|
+
private readonly host;
|
|
105
|
+
readonly id = "electron";
|
|
106
|
+
private readonly sessions;
|
|
107
|
+
private readonly httpOnly;
|
|
108
|
+
private readonly snapshotMaxElements;
|
|
109
|
+
private readonly contentMaxChars;
|
|
110
|
+
constructor(host: ElectronBrowserViewHost, config?: ElectronBrowserProviderConfig);
|
|
111
|
+
/** Usable whenever the host can create views (always in the desktop shell). */
|
|
112
|
+
available(): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Get the shared browser session: the one the host already owns, or a new
|
|
115
|
+
* one on first use. Shared-browser semantics (Cherry Studio's
|
|
116
|
+
* `getOrCreateWindow` pattern): the host's startup view and every later
|
|
117
|
+
* agent call must land on the SAME session and view, so opening again
|
|
118
|
+
* returns the existing session instead of creating a second view the human
|
|
119
|
+
* cannot see. Closing the session resets this; the next open starts fresh.
|
|
120
|
+
*/
|
|
121
|
+
open(): Promise<BrowserSessionId>;
|
|
122
|
+
/** Open a URL in the active tab (default) or a new tab. */
|
|
123
|
+
openUrl(session: BrowserSessionId, request: BrowserOpenRequest, signal?: AbortSignal): Promise<void>;
|
|
124
|
+
/** List the session's tabs with their titles. */
|
|
125
|
+
listTabs(session: BrowserSessionId): Promise<readonly BrowserTab[]>;
|
|
126
|
+
/** Switch to a tab by id, making its view visible. */
|
|
127
|
+
switchTab(session: BrowserSessionId, tabId: string): Promise<void>;
|
|
128
|
+
/** Close one tab; closing the active tab activates the next. */
|
|
129
|
+
closeTab(session: BrowserSessionId, tabId: string): Promise<void>;
|
|
130
|
+
/** Close every tab and reset to one blank tab. */
|
|
131
|
+
reset(session: BrowserSessionId): Promise<void>;
|
|
132
|
+
/** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
|
|
133
|
+
navigate(session: BrowserSessionId, request: {
|
|
134
|
+
readonly url: string;
|
|
135
|
+
}, signal?: AbortSignal): Promise<void>;
|
|
136
|
+
/** Execute JS in the active tab's page context. */
|
|
137
|
+
execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult>;
|
|
138
|
+
/** Produce an AI-friendly snapshot of the active tab. */
|
|
139
|
+
snapshot(session: BrowserSessionId, signal?: AbortSignal): Promise<BrowserSnapshotResult>;
|
|
140
|
+
/** Fetch page content in a requested format. */
|
|
141
|
+
content(session: BrowserSessionId, request: BrowserContentRequest, signal?: AbortSignal): Promise<BrowserContentResult>;
|
|
142
|
+
/** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
|
|
143
|
+
click(session: BrowserSessionId, request: {
|
|
144
|
+
readonly x: number;
|
|
145
|
+
readonly y: number;
|
|
146
|
+
}, signal?: AbortSignal): Promise<void>;
|
|
147
|
+
/** Type into the focused element. */
|
|
148
|
+
type(session: BrowserSessionId, request: {
|
|
149
|
+
readonly text: string;
|
|
150
|
+
}, signal?: AbortSignal): Promise<void>;
|
|
151
|
+
/** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
|
|
152
|
+
screenshot(session: BrowserSessionId, request?: {
|
|
153
|
+
readonly fullPage?: boolean;
|
|
154
|
+
}, signal?: AbortSignal): Promise<{
|
|
155
|
+
readonly dataUrl: string;
|
|
156
|
+
}>;
|
|
157
|
+
/** Close the session and destroy all its views. Idempotent. */
|
|
158
|
+
close(session: BrowserSessionId): Promise<void>;
|
|
159
|
+
/** Look up a session or throw the unknown-session error. */
|
|
160
|
+
private session;
|
|
161
|
+
/** The active tab of a session. */
|
|
162
|
+
private activeTab;
|
|
163
|
+
/** Append a fresh tab and make it active. */
|
|
164
|
+
private newTab;
|
|
165
|
+
/** Ask the host to show the active tab's view. */
|
|
166
|
+
private showActive;
|
|
167
|
+
/** Read the current URL of a view through CDP. */
|
|
168
|
+
private currentUrl;
|
|
169
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron-backed browser provider: `WebContentsView` sessions driven over
|
|
3
|
+
* `webContents.debugger` (CDP). The provider itself does not import Electron 鈥? * it operates through the {@link ElectronBrowserViewHost} seam, which the
|
|
4
|
+
* desktop shell implements with real Electron objects. That keeps this
|
|
5
|
+
* package testable under plain Node and leaves the Electron dependency to the
|
|
6
|
+
* shell that owns the `BrowserWindow`.
|
|
7
|
+
* @module dsh-browser/browser-electron
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { BrowserError } from "../browser/types.js";
|
|
11
|
+
/** Stable provider id registered with `ctx.browser`. */
|
|
12
|
+
export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
|
|
13
|
+
/** CDP method for a full-page screenshot capture. */
|
|
14
|
+
export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot';
|
|
15
|
+
/** CDP method for runtime evaluation (the execute path). */
|
|
16
|
+
export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate';
|
|
17
|
+
/** CDP method for navigation. */
|
|
18
|
+
export const CDP_PAGE_NAVIGATE = 'Page.navigate';
|
|
19
|
+
/** Cap on content returned by a snapshot fetch to keep the wire bounded. */
|
|
20
|
+
const SNAPSHOT_LABEL_MAX = 120;
|
|
21
|
+
/**
|
|
22
|
+
* Browser provider over Electron views. Sessions hold an ordered list of
|
|
23
|
+
* tabs; each tab is one view created by the host. The active tab receives
|
|
24
|
+
* every operation; switching tabs calls the host's optional `showView` and
|
|
25
|
+
* never loses state. Navigation is admitted only for HTTP(S) targets unless
|
|
26
|
+
* {@link ElectronBrowserProviderConfig.httpOnly} is disabled.
|
|
27
|
+
*/
|
|
28
|
+
export class ElectronBrowserProvider {
|
|
29
|
+
host;
|
|
30
|
+
id = ELECTRON_BROWSER_PROVIDER_ID;
|
|
31
|
+
sessions = new Map();
|
|
32
|
+
httpOnly;
|
|
33
|
+
snapshotMaxElements;
|
|
34
|
+
contentMaxChars;
|
|
35
|
+
constructor(host, config = {}) {
|
|
36
|
+
this.host = host;
|
|
37
|
+
this.httpOnly = config.httpOnly ?? true;
|
|
38
|
+
this.snapshotMaxElements = config.snapshotMaxElements ?? 60;
|
|
39
|
+
this.contentMaxChars = config.contentMaxChars ?? 100_000;
|
|
40
|
+
}
|
|
41
|
+
/** Usable whenever the host can create views (always in the desktop shell). */
|
|
42
|
+
available() {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get the shared browser session: the one the host already owns, or a new
|
|
47
|
+
* one on first use. Shared-browser semantics (Cherry Studio's
|
|
48
|
+
* `getOrCreateWindow` pattern): the host's startup view and every later
|
|
49
|
+
* agent call must land on the SAME session and view, so opening again
|
|
50
|
+
* returns the existing session instead of creating a second view the human
|
|
51
|
+
* cannot see. Closing the session resets this; the next open starts fresh.
|
|
52
|
+
*/
|
|
53
|
+
open() {
|
|
54
|
+
const [existing] = this.sessions.keys();
|
|
55
|
+
if (existing !== undefined)
|
|
56
|
+
return Promise.resolve(existing);
|
|
57
|
+
const handle = this.host.createView();
|
|
58
|
+
const id = `browser:${randomUUID()}`;
|
|
59
|
+
this.sessions.set(id, { id, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0 });
|
|
60
|
+
return Promise.resolve(id);
|
|
61
|
+
}
|
|
62
|
+
/** Open a URL in the active tab (default) or a new tab. */
|
|
63
|
+
async openUrl(session, request, signal) {
|
|
64
|
+
const s = this.session(session);
|
|
65
|
+
if (request.newTab === true) {
|
|
66
|
+
this.newTab(s);
|
|
67
|
+
}
|
|
68
|
+
await this.navigate(session, { url: request.url }, signal);
|
|
69
|
+
}
|
|
70
|
+
/** List the session's tabs with their titles. */
|
|
71
|
+
async listTabs(session) {
|
|
72
|
+
const s = this.session(session);
|
|
73
|
+
const result = [];
|
|
74
|
+
for (let i = 0; i < s.tabs.length; i++) {
|
|
75
|
+
const tab = s.tabs[i];
|
|
76
|
+
if (tab === undefined)
|
|
77
|
+
continue; // defensive: array can shift under concurrency
|
|
78
|
+
result.push({
|
|
79
|
+
id: tab.id,
|
|
80
|
+
url: await this.currentUrl(tab.handle).catch(() => ''),
|
|
81
|
+
active: i === s.activeIndex,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
/** Switch to a tab by id, making its view visible. */
|
|
87
|
+
switchTab(session, tabId) {
|
|
88
|
+
const s = this.session(session);
|
|
89
|
+
const index = s.tabs.findIndex(tab => tab.id === tabId);
|
|
90
|
+
if (index < 0) {
|
|
91
|
+
throw new BrowserError(`browser: tab "${tabId}" is not open in this session`, 'BROWSER_TAB_UNKNOWN');
|
|
92
|
+
}
|
|
93
|
+
s.activeIndex = index;
|
|
94
|
+
this.showActive(s);
|
|
95
|
+
return Promise.resolve();
|
|
96
|
+
}
|
|
97
|
+
/** Close one tab; closing the active tab activates the next. */
|
|
98
|
+
closeTab(session, tabId) {
|
|
99
|
+
const s = this.session(session);
|
|
100
|
+
const index = s.tabs.findIndex(tab => tab.id === tabId);
|
|
101
|
+
if (index < 0)
|
|
102
|
+
return Promise.resolve(); // idempotent
|
|
103
|
+
const removed = s.tabs[index];
|
|
104
|
+
if (removed !== undefined) {
|
|
105
|
+
s.tabs.splice(index, 1);
|
|
106
|
+
this.host.destroyView(removed.handle);
|
|
107
|
+
}
|
|
108
|
+
if (s.tabs.length === 0) {
|
|
109
|
+
// Session keeps one blank tab so it stays usable.
|
|
110
|
+
this.newTab(s);
|
|
111
|
+
}
|
|
112
|
+
else if (index < s.activeIndex) {
|
|
113
|
+
// Closing a tab before the active one shifts the array left; keep the
|
|
114
|
+
// same tab active by decrementing the index.
|
|
115
|
+
s.activeIndex -= 1;
|
|
116
|
+
}
|
|
117
|
+
else if (s.activeIndex >= s.tabs.length) {
|
|
118
|
+
// The active tab itself was closed; activate the last remaining one.
|
|
119
|
+
s.activeIndex = s.tabs.length - 1;
|
|
120
|
+
}
|
|
121
|
+
this.showActive(s);
|
|
122
|
+
return Promise.resolve();
|
|
123
|
+
}
|
|
124
|
+
/** Close every tab and reset to one blank tab. */
|
|
125
|
+
reset(session) {
|
|
126
|
+
const s = this.session(session);
|
|
127
|
+
for (const tab of s.tabs)
|
|
128
|
+
this.host.destroyView(tab.handle);
|
|
129
|
+
s.tabs.length = 0;
|
|
130
|
+
this.newTab(s);
|
|
131
|
+
s.activeIndex = 0;
|
|
132
|
+
this.showActive(s);
|
|
133
|
+
return Promise.resolve();
|
|
134
|
+
}
|
|
135
|
+
/** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
|
|
136
|
+
async navigate(session, request, signal) {
|
|
137
|
+
const { handle } = this.activeTab(this.session(session));
|
|
138
|
+
const url = request.url;
|
|
139
|
+
if (this.httpOnly) {
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = new URL(url);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new BrowserError(`browser: refusing navigation to unparseable URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
|
|
146
|
+
}
|
|
147
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
148
|
+
throw new BrowserError(`browser: refusing navigation to non-HTTP(S) URL "${url}"`, 'BROWSER_NAVIGATION_BLOCKED');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
signal?.throwIfAborted();
|
|
152
|
+
await handle.sendCommand(CDP_PAGE_NAVIGATE, { url });
|
|
153
|
+
}
|
|
154
|
+
/** Execute JS in the active tab's page context. */
|
|
155
|
+
async execute(session, request, signal) {
|
|
156
|
+
const { handle } = this.activeTab(this.session(session));
|
|
157
|
+
signal?.throwIfAborted();
|
|
158
|
+
try {
|
|
159
|
+
// Wrap the script in a Function so `return` statements are legal and
|
|
160
|
+
// request.args arrive as `arguments[0..n]`. A bare script handed to CDP
|
|
161
|
+
// Runtime.evaluate is an expression context — a leading `return` would
|
|
162
|
+
// be a syntax error, and an object-literal script (`{...}`) would parse
|
|
163
|
+
// as a block. So: if the script already starts with `return`, use it as
|
|
164
|
+
// the body verbatim; otherwise wrap it as `return (expr)` so both
|
|
165
|
+
// expression and object-literal forms evaluate to their value. Args are
|
|
166
|
+
// embedded as a JSON array literal; unserializable members become null.
|
|
167
|
+
const body = /^\s*return\b/.test(request.script)
|
|
168
|
+
? request.script
|
|
169
|
+
: `return (${request.script})`;
|
|
170
|
+
const hasArgs = request.args !== undefined && request.args.length > 0;
|
|
171
|
+
const expression = hasArgs
|
|
172
|
+
? `(function(){ const __dshArgs = ${JSON.stringify(request.args)}; return Function(${JSON.stringify(body)}).apply(null, __dshArgs) })()`
|
|
173
|
+
: `(function(){ return Function(${JSON.stringify(body)})() })()`;
|
|
174
|
+
const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
|
|
175
|
+
expression,
|
|
176
|
+
returnByValue: true,
|
|
177
|
+
awaitPromise: true,
|
|
178
|
+
});
|
|
179
|
+
if (result.exceptionDetails !== undefined) {
|
|
180
|
+
const detail = result.exceptionDetails;
|
|
181
|
+
return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' };
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, value: result.result?.value ?? null };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
throw new BrowserError(`browser: execute failed: ${String(error)}`, 'BROWSER_EXECUTE_FAILED', { cause: error });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Produce an AI-friendly snapshot of the active tab. */
|
|
190
|
+
async snapshot(session, signal) {
|
|
191
|
+
const s = this.session(session);
|
|
192
|
+
const tab = this.activeTab(s);
|
|
193
|
+
signal?.throwIfAborted();
|
|
194
|
+
const script = `(() => {
|
|
195
|
+
const cap = ${String(this.snapshotMaxElements)}
|
|
196
|
+
const url = location.href
|
|
197
|
+
const title = document.title || undefined
|
|
198
|
+
const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
|
|
199
|
+
const out = []
|
|
200
|
+
for (const el of els) {
|
|
201
|
+
if (out.length >= cap) break
|
|
202
|
+
const r = el.getBoundingClientRect()
|
|
203
|
+
const cs = getComputedStyle(el)
|
|
204
|
+
if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
|
|
205
|
+
const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'input'))
|
|
206
|
+
: el.tagName === 'TEXTAREA' ? 'textarea'
|
|
207
|
+
: el.tagName === 'SELECT' ? 'select'
|
|
208
|
+
: el.tagName === 'BUTTON' ? 'button'
|
|
209
|
+
: el.tagName === 'A' ? 'link' : 'other'
|
|
210
|
+
const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').toString().replace(/\\s+/g, ' ').trim().slice(0, ${String(SNAPSHOT_LABEL_MAX)})
|
|
211
|
+
if (!label && kind !== 'link') continue
|
|
212
|
+
out.push({
|
|
213
|
+
ref: out.length + 1,
|
|
214
|
+
kind,
|
|
215
|
+
label,
|
|
216
|
+
selector: el.id ? '#' + el.id : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
|
|
217
|
+
x: Math.round(r.x + r.width / 2),
|
|
218
|
+
y: Math.round(r.y + r.height / 2),
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
return { url, title, elements: out, truncated: out.length >= cap }
|
|
222
|
+
})()`;
|
|
223
|
+
const result = await handleSendEvaluate(tab.handle, script);
|
|
224
|
+
if (!result.ok)
|
|
225
|
+
throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
|
|
226
|
+
const value = result.value;
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
/** Fetch page content in a requested format. */
|
|
230
|
+
async content(session, request, signal) {
|
|
231
|
+
const tab = this.activeTab(this.session(session));
|
|
232
|
+
signal?.throwIfAborted();
|
|
233
|
+
const maxChars = request.maxChars ?? this.contentMaxChars;
|
|
234
|
+
const selector = request.selector ?? '';
|
|
235
|
+
const format = request.format;
|
|
236
|
+
const script = `(() => {
|
|
237
|
+
const root = ${selector === '' ? 'document.body' : `document.querySelector(${JSON.stringify(selector)})`}
|
|
238
|
+
if (!root) return { ok: false, reason: 'selector not found' }
|
|
239
|
+
const fmt = ${JSON.stringify(format)}
|
|
240
|
+
let content = ''
|
|
241
|
+
if (fmt === 'txt') content = root.innerText || ''
|
|
242
|
+
else if (fmt === 'html') content = root.outerHTML || ''
|
|
243
|
+
else if (fmt === 'json') content = JSON.stringify(root)
|
|
244
|
+
else {
|
|
245
|
+
// markdown: headings, paragraphs, links, lists (best-effort)
|
|
246
|
+
const parts = []
|
|
247
|
+
const walk = (node) => {
|
|
248
|
+
if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
|
|
249
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return
|
|
250
|
+
const tag = node.tagName.toLowerCase()
|
|
251
|
+
if (tag === 'script' || tag === 'style' || tag === 'noscript') return
|
|
252
|
+
if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4') parts.push('\\n' + '#'.repeat(Number(tag[1])) + ' ' + (node.textContent || '').trim() + '\\n')
|
|
253
|
+
else if (tag === 'a') { const t = (node.textContent || '').trim(); if (t) parts.push('[' + t + '](' + (node.href || '') + ')') }
|
|
254
|
+
else if (tag === 'li') parts.push(' - ' + (node.textContent || '').trim())
|
|
255
|
+
else if (tag === 'p' || tag === 'div' || tag === 'section' || tag === 'article') { const t = (node.textContent || '').trim(); if (t) parts.push(t + '\\n') }
|
|
256
|
+
else { for (const child of node.childNodes) walk(child) }
|
|
257
|
+
}
|
|
258
|
+
if (root.nodeType === Node.TEXT_NODE) walk(root)
|
|
259
|
+
else for (const child of root.childNodes) walk(child)
|
|
260
|
+
// Join without a separator: each part already carries its own trailing
|
|
261
|
+
// newline, so a space join would smear headings/links into run-on text.
|
|
262
|
+
content = parts.join('')
|
|
263
|
+
}
|
|
264
|
+
const truncated = content.length > ${String(maxChars)}
|
|
265
|
+
return { ok: true, content: content.slice(0, ${String(maxChars)}), truncated }
|
|
266
|
+
})()`;
|
|
267
|
+
// Honor a per-call timeout: content evaluation can hang on a heavy page,
|
|
268
|
+
// so a caller-supplied budget bounds it. AbortSignal.any merges the
|
|
269
|
+
// caller's signal with a timeout.
|
|
270
|
+
const timeoutMs = request.timeoutMs ?? 30_000;
|
|
271
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
272
|
+
const result = signal !== undefined
|
|
273
|
+
? await handleSendEvaluate(tab.handle, script, AbortSignal.any([signal, timeoutSignal]))
|
|
274
|
+
: await handleSendEvaluate(tab.handle, script, timeoutSignal);
|
|
275
|
+
if (!result.ok)
|
|
276
|
+
throw new BrowserError(`browser: content evaluation failed: ${result.exception}`, 'BROWSER_CONTENT_FAILED');
|
|
277
|
+
const value = result.value;
|
|
278
|
+
if (!value.ok)
|
|
279
|
+
throw new BrowserError(`browser: content fetch failed: ${value.reason ?? 'unknown'}`, 'BROWSER_CONTENT_FAILED');
|
|
280
|
+
return { content: value.content ?? '', truncated: value.truncated ?? false };
|
|
281
|
+
}
|
|
282
|
+
/** Click at viewport coordinates (CDP mousePressed + mouseReleased). */
|
|
283
|
+
async click(session, request, signal) {
|
|
284
|
+
const { handle } = this.activeTab(this.session(session));
|
|
285
|
+
signal?.throwIfAborted();
|
|
286
|
+
await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: request.x, y: request.y, button: 'left', clickCount: 1 });
|
|
287
|
+
await handle.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: request.x, y: request.y, button: 'left', clickCount: 1 });
|
|
288
|
+
}
|
|
289
|
+
/** Type into the focused element. */
|
|
290
|
+
async type(session, request, signal) {
|
|
291
|
+
const { handle } = this.activeTab(this.session(session));
|
|
292
|
+
signal?.throwIfAborted();
|
|
293
|
+
await handle.sendCommand('Input.insertText', { text: request.text });
|
|
294
|
+
}
|
|
295
|
+
/** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
|
|
296
|
+
async screenshot(session, request, signal) {
|
|
297
|
+
const { handle } = this.activeTab(this.session(session));
|
|
298
|
+
signal?.throwIfAborted();
|
|
299
|
+
const params = {};
|
|
300
|
+
if (request?.fullPage === true) {
|
|
301
|
+
// `captureBeyondViewport` captures the full scrollable content; without
|
|
302
|
+
// a clip this yields the full-page image (CDP default is the viewport).
|
|
303
|
+
params.captureBeyondViewport = true;
|
|
304
|
+
}
|
|
305
|
+
const result = await handle.sendCommand(CDP_PAGE_CAPTURE_SCREENSHOT, params);
|
|
306
|
+
const data = result.data;
|
|
307
|
+
if (typeof data !== 'string') {
|
|
308
|
+
throw new BrowserError('browser: screenshot returned no image data', 'BROWSER_SCREENSHOT_FAILED');
|
|
309
|
+
}
|
|
310
|
+
return { dataUrl: `data:image/png;base64,${data}` };
|
|
311
|
+
}
|
|
312
|
+
/** Close the session and destroy all its views. Idempotent. */
|
|
313
|
+
close(session) {
|
|
314
|
+
const existing = this.sessions.get(session);
|
|
315
|
+
if (existing !== undefined) {
|
|
316
|
+
this.sessions.delete(session);
|
|
317
|
+
for (const tab of existing.tabs)
|
|
318
|
+
this.host.destroyView(tab.handle);
|
|
319
|
+
}
|
|
320
|
+
return Promise.resolve();
|
|
321
|
+
}
|
|
322
|
+
/** Look up a session or throw the unknown-session error. */
|
|
323
|
+
session(session) {
|
|
324
|
+
const existing = this.sessions.get(session);
|
|
325
|
+
if (existing === undefined) {
|
|
326
|
+
throw new BrowserError(`browser: session "${session}" is not open`, 'BROWSER_SESSION_UNKNOWN');
|
|
327
|
+
}
|
|
328
|
+
return existing;
|
|
329
|
+
}
|
|
330
|
+
/** The active tab of a session. */
|
|
331
|
+
activeTab(s) {
|
|
332
|
+
const tab = s.tabs[s.activeIndex];
|
|
333
|
+
if (tab === undefined)
|
|
334
|
+
throw new BrowserError('browser: session has no active tab', 'BROWSER_TAB_UNKNOWN');
|
|
335
|
+
return tab;
|
|
336
|
+
}
|
|
337
|
+
/** Append a fresh tab and make it active. */
|
|
338
|
+
newTab(s) {
|
|
339
|
+
const handle = this.host.createView();
|
|
340
|
+
s.tabs.push({ id: `tab:${randomUUID()}`, handle });
|
|
341
|
+
s.activeIndex = s.tabs.length - 1;
|
|
342
|
+
this.showActive(s);
|
|
343
|
+
}
|
|
344
|
+
/** Ask the host to show the active tab's view. */
|
|
345
|
+
showActive(s) {
|
|
346
|
+
this.host.showView?.(this.activeTab(s).handle);
|
|
347
|
+
}
|
|
348
|
+
/** Read the current URL of a view through CDP. */
|
|
349
|
+
async currentUrl(handle) {
|
|
350
|
+
const result = await handleSendEvaluate(handle, 'location.href');
|
|
351
|
+
return result.ok && typeof result.value === 'string' ? result.value : '';
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Run a `Runtime.evaluate` through a view handle and normalize the result.
|
|
356
|
+
* Shared by execute, snapshot, content, and internal URL reads.
|
|
357
|
+
* @param handle - the view handle to evaluate in.
|
|
358
|
+
* @param expression - the JS expression.
|
|
359
|
+
* @param signal - optional abort signal; a fired signal rejects the call.
|
|
360
|
+
*/
|
|
361
|
+
async function handleSendEvaluate(handle, expression, signal) {
|
|
362
|
+
signal?.throwIfAborted();
|
|
363
|
+
const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
|
|
364
|
+
expression,
|
|
365
|
+
returnByValue: true,
|
|
366
|
+
awaitPromise: true,
|
|
367
|
+
});
|
|
368
|
+
if (result.exceptionDetails !== undefined) {
|
|
369
|
+
const detail = result.exceptionDetails;
|
|
370
|
+
return { ok: false, exception: detail.exception?.description ?? detail.text ?? 'unknown exception' };
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, value: result.result?.value ?? null };
|
|
373
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-browser plugin entry: aggregates the shared-browser capability pieces.
|
|
3
|
+
* The cordis.patch.yml rows reference subpath exports:
|
|
4
|
+
* - `dsh-browser/browser` -> the ctx.browser seam (Service Definition)
|
|
5
|
+
* - `dsh-browser/browser-electron` -> the Electron CDP provider
|
|
6
|
+
* - `dsh-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
|
+
* This root entry only re-exports for programmatic use; the loader rows are
|
|
8
|
+
* the composition surface.
|
|
9
|
+
* @module dsh-browser
|
|
10
|
+
*/
|
|
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';
|
|
13
|
+
export { BrowserRuntime } from './browser/runtime.ts';
|
|
14
|
+
export { ElectronBrowserProvider } from './browser-electron/provider.ts';
|
|
15
|
+
export type { ElectronBrowserViewHost, ElectronViewHandle } from './browser-electron/provider.ts';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-browser plugin entry: aggregates the shared-browser capability pieces.
|
|
3
|
+
* The cordis.patch.yml rows reference subpath exports:
|
|
4
|
+
* - `dsh-browser/browser` -> the ctx.browser seam (Service Definition)
|
|
5
|
+
* - `dsh-browser/browser-electron` -> the Electron CDP provider
|
|
6
|
+
* - `dsh-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
|
+
* This root entry only re-exports for programmatic use; the loader rows are
|
|
8
|
+
* the composition surface.
|
|
9
|
+
* @module dsh-browser
|
|
10
|
+
*/
|
|
11
|
+
export { BrowserError } from "./browser/types.js";
|
|
12
|
+
export { BrowserRuntime } from "./browser/runtime.js";
|
|
13
|
+
export { ElectronBrowserProvider } from "./browser-electron/provider.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing browser tools over `ctx.browser`: `browser_open`,
|
|
3
|
+
* `browser_snapshot`, `browser_execute`, `browser_content`,
|
|
4
|
+
* `browser_screenshot`, and tab management (`browser_list_tabs`,
|
|
5
|
+
* `browser_switch_tab`, `browser_close_tab`, `browser_reset`).
|
|
6
|
+
*
|
|
7
|
+
* The tool layer owns only the model-facing schema, argument validation, and
|
|
8
|
+
* result formatting 鈥?never provider selection or page driving, which belong
|
|
9
|
+
* to the seam. Session lifecycle is owned here at the plugin level: the
|
|
10
|
+
* first `browser_open` (or any tool when no session exists) opens a session;
|
|
11
|
+
* later tools reuse it. Per-agent isolation is a follow-up.
|
|
12
|
+
* @module dsh-browser/tool-browser
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
15
|
+
import type { BrowserSessionId } from '../browser/types.ts';
|
|
16
|
+
/** Plugin name used by loader diagnostics. */
|
|
17
|
+
export declare const name = "tool-browser";
|
|
18
|
+
/** The tool registry, browser seam, and system-prompt registry this tool layer consumes. */
|
|
19
|
+
export declare const inject: string[];
|
|
20
|
+
/** Plugin config: tool timeouts and session defaults. */
|
|
21
|
+
export interface Config {
|
|
22
|
+
/** Cooperative tool-call budget in ms. Default 60000. */
|
|
23
|
+
readonly timeoutMs?: number;
|
|
24
|
+
/** Whether to offer tab-management tools. Default true. */
|
|
25
|
+
readonly tabTools?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Register all browser tools with `ctx.tools`. */
|
|
28
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
29
|
+
/** Test hook: clear the plugin-level session (used by tests and on reset). */
|
|
30
|
+
export declare const internals: {
|
|
31
|
+
readonly session: BrowserSessionId | undefined;
|
|
32
|
+
clearSession(): void;
|
|
33
|
+
};
|