dsh-builtin-browser 0.4.1 → 0.4.5
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/CHANGELOG.md +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +133 -40
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +155 -76
package/client.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser settings panel (client half): the switches that decide how the shared
|
|
3
3
|
* browser behaves — history retention, login-state persistence, presentation,
|
|
4
|
-
* vision strategy and
|
|
4
|
+
* vision strategy, credential access, and which ACTIONS the agent may take —
|
|
5
|
+
* the one group of switches written where no tool can reach them.
|
|
5
6
|
*
|
|
6
7
|
* The host half owns the document and serves it at `/dsh-builtin-browser/settings`;
|
|
7
8
|
* this panel only renders it and sends patches, so the same file stays the single
|
|
@@ -18,7 +19,7 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
18
19
|
|
|
19
20
|
const react = require("react")
|
|
20
21
|
const h = react.createElement
|
|
21
|
-
const { useCallback, useEffect, useState } = react
|
|
22
|
+
const { useCallback, useEffect, useRef, useState } = react
|
|
22
23
|
|
|
23
24
|
const name = "dsh-builtin-browser"
|
|
24
25
|
const inject = ["slots", "locale"]
|
|
@@ -57,12 +58,21 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
57
58
|
"credentials.allowRead": "允许 Agent 读取 cookies / 导出登录状态",
|
|
58
59
|
"browser.title": "浏览器载体",
|
|
59
60
|
"browser.channel": "使用哪个浏览器",
|
|
60
|
-
"browser.
|
|
61
|
+
"browser.sidebar": "侧栏浏览器(在 DSH 窗口右侧打开,不额外拉窗口)",
|
|
62
|
+
"browser.bundled": "内置 Electron(随插件提供,无需安装)",
|
|
61
63
|
"browser.chrome": "本机 Chrome",
|
|
62
64
|
"browser.edge": "本机 Edge",
|
|
63
65
|
"browser.auto": "自动(优先 Chrome,其次 Edge)",
|
|
64
66
|
"browser.hint": "选本机浏览器时会以**独立 profile** 启动它:不会打开、占用或修改你日常的窗口、书签与登录状态,关闭也不会关掉你自己的浏览器;代价是它看不到你日常浏览器里已登录的站点,需要时请在那个窗口里登录一次(登录态会留在插件自己的 profile 里)。桌面端默认由官方侧栏承载页面,此选项优先于侧栏。",
|
|
65
|
-
"credentials.hint": "关闭后 browser_auth
|
|
67
|
+
"credentials.hint": "关闭后 browser_auth 的导出会被拒绝,报 BROWSER_AUTH_DISABLED —— 导出读的是整台机器共享的 cookie 罐(按当前页所属站点收敛)。它只管**读**:写入登录状态(restore)归下面「Agent 能做什么」里的写入开关。桌面端请注意:Agent 驱动的是侧栏里那一个页面(人机同页),因此它与该页面共用同一个 partition —— 这也正是它能读到登录态的原因,属于有意接受的沙箱边界变化;关闭本项即拒绝读取。",
|
|
68
|
+
"actions.title": "Agent 能做什么",
|
|
69
|
+
"actions.lead": "与 browser_restrict 不同:那一层是模型自己设的软护栏,它随时可以解除;这里的三项由你在设置里决定,模型手里的工具改不了它们。",
|
|
70
|
+
"actions.allowExecute": "允许在页面里执行脚本",
|
|
71
|
+
"actions.allowExecute.hint": "browser_execute 能在当前页面里运行任意 JavaScript —— 它的能力上限就是这个页面能做的事。关闭后该工具一律被拒(BROWSER_EXECUTE_DISABLED),模型手里的工具没有一个能改这项设置。但也别把它当成锁:设置文档由本机 HTTP 端点读写,同源页面可以写它 —— 这不是凭据系统。",
|
|
72
|
+
"actions.allowDownload": "允许下载文件到磁盘",
|
|
73
|
+
"actions.allowDownload.hint": "browser_download 会把 URL 下载到 downloadDir 内(带登录态;仍需通过目录准入,不覆盖已有文件)。关闭后一律被拒(BROWSER_DOWNLOAD_DISABLED)。截图另存走的是同一套目录准入,不受此项影响。",
|
|
74
|
+
"actions.allowCredentialWrite": "允许写入登录状态",
|
|
75
|
+
"actions.allowCredentialWrite.hint": "browser_auth 的 restore 会向任意域写入 cookie。关闭后只能导出、不能导入(BROWSER_AUTH_WRITE_DISABLED);读取仍由上面「凭据」那一项控制。",
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
const en = {
|
|
@@ -96,12 +106,21 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
96
106
|
"credentials.allowRead": "Allow the agent to read cookies / export login state",
|
|
97
107
|
"browser.title": "Browser",
|
|
98
108
|
"browser.channel": "Which browser to use",
|
|
99
|
-
|
|
109
|
+
"browser.sidebar": "Sidebar browser (opens in the DSH window right pane; no extra window)",
|
|
110
|
+
"browser.bundled": "Bundled Electron (ships with the plugin, nothing to install)",
|
|
100
111
|
"browser.chrome": "Installed Chrome",
|
|
101
112
|
"browser.edge": "Installed Edge",
|
|
102
113
|
"browser.auto": "Automatic (Chrome first, then Edge)",
|
|
103
114
|
"browser.hint": "Choosing an installed browser launches it with a **separate profile**: your everyday windows, bookmarks and logins are never opened, locked or modified, and closing the plugin never closes your browser. The trade-off is that it does not see sites you are already signed into there — sign in once in that window and the session stays in the plugin's own profile. On the desktop the shell's sidebar normally carries the page; this setting outranks it.",
|
|
104
|
-
"credentials.hint": "Off makes browser_auth refuse
|
|
115
|
+
"credentials.hint": "Off makes browser_auth refuse the export, reporting BROWSER_AUTH_DISABLED — an export reads the machine-wide shared cookie jar (narrowed to the current page's site). It governs READING only: writing login state (restore) answers to the write switch under \"What the agent may do\" below. On the desktop, note that the agent drives the sidebar's own page (one page for both parties), so it shares that page's partition — which is exactly why it can reach the login state. That is a deliberate sandbox-boundary change; this switch is how you refuse it.",
|
|
116
|
+
"actions.title": "What the agent may do",
|
|
117
|
+
"actions.lead": "Unlike browser_restrict, which is the model's own soft guardrail and can be lifted by the model at any time, these three are yours: the model's own tools cannot change them.",
|
|
118
|
+
"actions.allowExecute": "Run scripts in the page",
|
|
119
|
+
"actions.allowExecute.hint": "browser_execute runs arbitrary JavaScript in the active tab — its reach is everything that page can do. Off refuses the tool outright (BROWSER_EXECUTE_DISABLED), and no tool the model holds can change this setting. Do not read that as a lock, though: the document is served over a local HTTP endpoint and a same-origin page can write it. This is not a credential system.",
|
|
120
|
+
"actions.allowDownload": "Download files to disk",
|
|
121
|
+
"actions.allowDownload.hint": "browser_download fetches a URL into downloadDir (with login state; still subject to the directory admission gate, never overwriting an existing file). Off refuses it outright (BROWSER_DOWNLOAD_DISABLED). Saving a screenshot goes through the same directory gate and is not covered by this switch.",
|
|
122
|
+
"actions.allowCredentialWrite": "Write login state",
|
|
123
|
+
"actions.allowCredentialWrite.hint": "browser_auth's restore writes cookies to arbitrary domains. Off allows export but not import (BROWSER_AUTH_WRITE_DISABLED); reading stays under the Credentials switch above.",
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
/** Read or patch the host-owned settings document. */
|
|
@@ -120,26 +139,219 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
120
139
|
return data
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The panel's own stylesheet.
|
|
144
|
+
*
|
|
145
|
+
* Inline styles cannot express `:hover`, and the rows need one — an interactive row with no
|
|
146
|
+
* feedback reads as decoration. The colours come from DSH's own design tokens
|
|
147
|
+
* (`--dsw-alias-*`, the same ones the host's settings rows use) so this follows the active
|
|
148
|
+
* theme instead of guessing at it; each has a literal fallback because a token may be absent
|
|
149
|
+
* under a third-party theme, and an unstyled row is worse than a slightly-off one.
|
|
150
|
+
*/
|
|
151
|
+
const CSS = `
|
|
152
|
+
.dsh-bb-row {
|
|
153
|
+
border-radius: 8px;
|
|
154
|
+
padding: 7px 9px;
|
|
155
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
156
|
+
transition: background-color .12s ease;
|
|
157
|
+
}
|
|
158
|
+
/* No resting background: a permanent grey block behind every row reads as a set of buttons
|
|
159
|
+
rather than a list of settings. The highlight belongs to the pointer, not to the row. */
|
|
160
|
+
.dsh-bb-row:hover {
|
|
161
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.15));
|
|
162
|
+
}
|
|
163
|
+
.dsh-bb-row:active {
|
|
164
|
+
background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.22));
|
|
165
|
+
}
|
|
166
|
+
.dsh-bb-row:focus-within {
|
|
167
|
+
border-color: var(--dsw-alias-border-l2, rgba(128,128,128,.4));
|
|
168
|
+
}
|
|
169
|
+
/* The dropdown is drawn here rather than by the OS.
|
|
170
|
+
A native <select> paints its popup with system colours on Windows: the highlight overrides
|
|
171
|
+
whatever the option sets (white text on a white bar), and the list carries a border no CSS
|
|
172
|
+
can remove. Only the collapsed control is styleable, so a panel that matches its theme
|
|
173
|
+
everywhere else ends up with one control that does not. Everything below is ours: the
|
|
174
|
+
surface, the highlight, and the absence of that line. */
|
|
175
|
+
.dsh-bb-dd {
|
|
176
|
+
position: relative;
|
|
177
|
+
display: inline-block;
|
|
178
|
+
margin-top: 4px;
|
|
179
|
+
}
|
|
180
|
+
.dsh-bb-dd-btn {
|
|
181
|
+
display: flex;
|
|
182
|
+
align-items: center;
|
|
183
|
+
gap: 8px;
|
|
184
|
+
max-width: 100%;
|
|
185
|
+
padding: 4px 8px 4px 9px;
|
|
186
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4));
|
|
187
|
+
border-radius: 6px;
|
|
188
|
+
background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12));
|
|
189
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
190
|
+
font: inherit;
|
|
191
|
+
text-align: left;
|
|
192
|
+
cursor: pointer;
|
|
193
|
+
}
|
|
194
|
+
.dsh-bb-dd-btn:hover:not(:disabled) {
|
|
195
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.18));
|
|
196
|
+
}
|
|
197
|
+
.dsh-bb-dd-btn:disabled { opacity: .55; cursor: default; }
|
|
198
|
+
.dsh-bb-dd-caret { opacity: .6; font-size: 10px; line-height: 1; }
|
|
199
|
+
.dsh-bb-dd-list {
|
|
200
|
+
position: absolute;
|
|
201
|
+
z-index: 20;
|
|
202
|
+
top: calc(100% + 4px);
|
|
203
|
+
left: 0;
|
|
204
|
+
min-width: 100%;
|
|
205
|
+
max-width: 340px;
|
|
206
|
+
padding: 4px;
|
|
207
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4));
|
|
208
|
+
border-radius: 8px;
|
|
209
|
+
background: var(--dsw-alias-bg-layer-2, #2f2f2f);
|
|
210
|
+
box-shadow: 0 6px 20px rgba(0,0,0,.28);
|
|
211
|
+
}
|
|
212
|
+
.dsh-bb-dd-opt {
|
|
213
|
+
display: block;
|
|
214
|
+
width: 100%;
|
|
215
|
+
padding: 6px 9px;
|
|
216
|
+
border: none;
|
|
217
|
+
border-radius: 6px;
|
|
218
|
+
background: transparent;
|
|
219
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
220
|
+
font: inherit;
|
|
221
|
+
text-align: left;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
/* The same ellipse the rows use, so the panel reads as one surface — and it is ours to draw,
|
|
225
|
+
which is the whole reason this control exists. */
|
|
226
|
+
.dsh-bb-dd-opt:hover,
|
|
227
|
+
.dsh-bb-dd-opt[data-active="true"] {
|
|
228
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.18));
|
|
229
|
+
}
|
|
230
|
+
.dsh-bb-dd-opt[aria-selected="true"] { font-weight: 600; }
|
|
231
|
+
`
|
|
232
|
+
|
|
233
|
+
/** Install the stylesheet once, however many times the panel is mounted. */
|
|
234
|
+
function installStyles() {
|
|
235
|
+
if (typeof document === "undefined") return
|
|
236
|
+
if (document.getElementById("dsh-builtin-browser-styles") !== null) return
|
|
237
|
+
const tag = document.createElement("style")
|
|
238
|
+
tag.id = "dsh-builtin-browser-styles"
|
|
239
|
+
tag.textContent = CSS
|
|
240
|
+
document.head.appendChild(tag)
|
|
241
|
+
}
|
|
242
|
+
|
|
123
243
|
const styles = {
|
|
124
244
|
wrap: { display: "flex", flexDirection: "column", gap: "14px", padding: "4px 2px", fontSize: "13px" },
|
|
125
245
|
lead: { opacity: 0.75, lineHeight: 1.6, margin: 0 },
|
|
126
246
|
group: { border: "1px solid rgba(128,128,128,.28)", borderRadius: "8px", padding: "10px 12px" },
|
|
127
247
|
groupTitle: { margin: "0 0 8px", fontSize: "13px", fontWeight: 600 },
|
|
128
|
-
row: { display: "flex", gap: "10px", alignItems: "flex-start"
|
|
248
|
+
row: { display: "flex", gap: "10px", alignItems: "flex-start" },
|
|
129
249
|
box: { marginTop: "2px" },
|
|
130
250
|
label: { display: "flex", flexDirection: "column", gap: "2px", cursor: "pointer" },
|
|
131
251
|
hint: { opacity: 0.68, lineHeight: 1.5 },
|
|
132
|
-
select: { marginTop: "4px", padding: "4px 6px", borderRadius: "6px", background: "transparent", color: "inherit", border: "1px solid rgba(128,128,128,.4)" },
|
|
133
252
|
status: { opacity: 0.7, minHeight: "18px" },
|
|
134
253
|
error: { color: "#e5534b" },
|
|
135
254
|
pathLine: { opacity: 0.6, wordBreak: "break-all", lineHeight: 1.5 },
|
|
136
255
|
button: { padding: "3px 10px", borderRadius: "6px", border: "1px solid rgba(128,128,128,.4)", background: "transparent", color: "inherit", cursor: "pointer" },
|
|
137
256
|
}
|
|
138
257
|
|
|
258
|
+
/**
|
|
259
|
+
* A dropdown drawn by this panel rather than by the operating system.
|
|
260
|
+
*
|
|
261
|
+
* Replaces a native `<select>`, whose popup Windows paints with system colours: the hover
|
|
262
|
+
* highlight overrode the option's background (white text on a white bar) and the list carried
|
|
263
|
+
* a border no CSS could remove. Both are unfixable from outside the control, and this panel's
|
|
264
|
+
* two dropdowns were the only places it did not match its own theme.
|
|
265
|
+
*
|
|
266
|
+
* Keyboard and assistive-technology behaviour is reimplemented rather than inherited, which is
|
|
267
|
+
* the cost of owning the rendering: Up/Down move, Enter/Space commit, Escape closes and
|
|
268
|
+
* returns focus, Home/End jump, clicking outside closes.
|
|
269
|
+
*/
|
|
270
|
+
function Dropdown(props) {
|
|
271
|
+
const [open, setOpen] = useState(false)
|
|
272
|
+
// Index the keyboard has moved to; -1 means "the selected value, unless the user moved".
|
|
273
|
+
const [active, setActive] = useState(-1)
|
|
274
|
+
const rootRef = useRef(null)
|
|
275
|
+
const buttonRef = useRef(null)
|
|
276
|
+
|
|
277
|
+
const selected = props.options.findIndex(option => option.value === props.value)
|
|
278
|
+
const current = props.options[selected] ?? props.options[0]
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (!open) return undefined
|
|
282
|
+
const away = event => {
|
|
283
|
+
if (rootRef.current !== null && !rootRef.current.contains(event.target)) setOpen(false)
|
|
284
|
+
}
|
|
285
|
+
document.addEventListener("mousedown", away)
|
|
286
|
+
return () => document.removeEventListener("mousedown", away)
|
|
287
|
+
}, [open])
|
|
288
|
+
|
|
289
|
+
const commit = index => {
|
|
290
|
+
const option = props.options[index]
|
|
291
|
+
if (option === undefined) return
|
|
292
|
+
setOpen(false)
|
|
293
|
+
setActive(-1)
|
|
294
|
+
buttonRef.current?.focus()
|
|
295
|
+
if (option.value !== props.value) props.onChange(option.value)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const onKeyDown = event => {
|
|
299
|
+
if (event.key === "Escape") {
|
|
300
|
+
if (open) { event.preventDefault(); setOpen(false); buttonRef.current?.focus() }
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (!open && (event.key === "Enter" || event.key === " " || event.key === "ArrowDown")) {
|
|
304
|
+
event.preventDefault()
|
|
305
|
+
setOpen(true)
|
|
306
|
+
setActive(selected >= 0 ? selected : 0)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (!open) return
|
|
310
|
+
const last = props.options.length - 1
|
|
311
|
+
if (event.key === "ArrowDown") { event.preventDefault(); setActive(a => Math.min(last, (a < 0 ? selected : a) + 1)) }
|
|
312
|
+
else if (event.key === "ArrowUp") { event.preventDefault(); setActive(a => Math.max(0, (a < 0 ? selected : a) - 1)) }
|
|
313
|
+
else if (event.key === "Home") { event.preventDefault(); setActive(0) }
|
|
314
|
+
else if (event.key === "End") { event.preventDefault(); setActive(last) }
|
|
315
|
+
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); commit(active < 0 ? selected : active) }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return h("div", { key: "s", className: "dsh-bb-dd", ref: rootRef }, [
|
|
319
|
+
h("button", {
|
|
320
|
+
key: "b",
|
|
321
|
+
type: "button",
|
|
322
|
+
ref: buttonRef,
|
|
323
|
+
className: "dsh-bb-dd-btn",
|
|
324
|
+
disabled: props.disabled,
|
|
325
|
+
"aria-haspopup": "listbox",
|
|
326
|
+
"aria-expanded": open ? "true" : "false",
|
|
327
|
+
onClick: () => { setOpen(o => !o); setActive(-1) },
|
|
328
|
+
onKeyDown,
|
|
329
|
+
}, [
|
|
330
|
+
h("span", { key: "v" }, current === undefined ? "" : current.label),
|
|
331
|
+
h("span", { key: "c", className: "dsh-bb-dd-caret", "aria-hidden": "true" }, "\\u25be"),
|
|
332
|
+
]),
|
|
333
|
+
open ? h("div", {
|
|
334
|
+
key: "l",
|
|
335
|
+
className: "dsh-bb-dd-list",
|
|
336
|
+
role: "listbox",
|
|
337
|
+
"aria-label": props.label,
|
|
338
|
+
}, props.options.map((option, index) => h("button", {
|
|
339
|
+
key: option.value,
|
|
340
|
+
type: "button",
|
|
341
|
+
className: "dsh-bb-dd-opt",
|
|
342
|
+
role: "option",
|
|
343
|
+
"aria-selected": option.value === props.value ? "true" : "false",
|
|
344
|
+
"data-active": index === active ? "true" : "false",
|
|
345
|
+
onMouseEnter: () => setActive(index),
|
|
346
|
+
onClick: () => commit(index),
|
|
347
|
+
}, option.label))) : null,
|
|
348
|
+
])
|
|
349
|
+
}
|
|
350
|
+
|
|
139
351
|
/** One boolean switch bound to a settings path. */
|
|
140
352
|
function Toggle(props) {
|
|
141
353
|
const t = translate
|
|
142
|
-
return h("div", { style: styles.row }, [
|
|
354
|
+
return h("div", { style: styles.row, className: "dsh-bb-row" }, [
|
|
143
355
|
h("input", {
|
|
144
356
|
key: "i",
|
|
145
357
|
type: "checkbox",
|
|
@@ -158,7 +370,7 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
158
370
|
/** The settings page section. */
|
|
159
371
|
function SettingsRoot() {
|
|
160
372
|
const t = translate
|
|
161
|
-
const [
|
|
373
|
+
const [rawSettings, setSettings] = useState(null)
|
|
162
374
|
const [meta, setMeta] = useState({ path: "" })
|
|
163
375
|
const [error, setError] = useState("")
|
|
164
376
|
const [busy, setBusy] = useState(false)
|
|
@@ -185,13 +397,23 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
185
397
|
.then(() => setBusy(false))
|
|
186
398
|
}, [])
|
|
187
399
|
|
|
188
|
-
if (
|
|
400
|
+
if (rawSettings === null) {
|
|
189
401
|
return h("div", { style: styles.wrap }, [
|
|
190
402
|
h("p", { key: "l", style: styles.lead }, t("loading")),
|
|
191
403
|
error !== "" ? h("p", { key: "e", style: styles.error }, `${t("failed")} ${error}`) : null,
|
|
192
404
|
])
|
|
193
405
|
}
|
|
194
406
|
|
|
407
|
+
// A document served by an OLDER host has no `actions` section — which is exactly the state
|
|
408
|
+
// an update leaves behind when the host has not been restarted yet (the client bundle is
|
|
409
|
+
// read at request time, the host's code at startup). Rendering `settings.actions.allowExecute`
|
|
410
|
+
// off that threw, and the throw took the whole panel with it: the section did not merely show
|
|
411
|
+
// stale values, it refused to open. Fill the gap with the defaults an older host behaves by,
|
|
412
|
+
// which is also the honest reading — it has no such switch, so "on" is what it does.
|
|
413
|
+
const settings = rawSettings.actions === undefined
|
|
414
|
+
? { ...rawSettings, actions: { allowExecute: true, allowDownload: true, allowCredentialWrite: true } }
|
|
415
|
+
: rawSettings
|
|
416
|
+
|
|
195
417
|
const section = (titleKey, children) => h("div", { key: titleKey, style: styles.group }, [
|
|
196
418
|
h("p", { key: "t", style: styles.groupTitle }, t(titleKey)),
|
|
197
419
|
...children,
|
|
@@ -250,45 +472,76 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
250
472
|
]),
|
|
251
473
|
|
|
252
474
|
section("vision.title", [
|
|
253
|
-
h("div", { key: "row", style: styles.row }, [
|
|
475
|
+
h("div", { key: "row", style: styles.row, className: "dsh-bb-row" }, [
|
|
254
476
|
h("label", { key: "l", style: styles.label }, [
|
|
255
477
|
h("span", { key: "t" }, t("vision.strategy")),
|
|
256
|
-
h(
|
|
478
|
+
h(Dropdown, {
|
|
257
479
|
key: "s",
|
|
258
|
-
|
|
480
|
+
label: t("vision.strategy"),
|
|
259
481
|
value: settings.vision.strategy,
|
|
260
482
|
disabled: busy,
|
|
261
|
-
onChange:
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
483
|
+
onChange: value => patch({ vision: { strategy: value } }),
|
|
484
|
+
options: [
|
|
485
|
+
{ value: "auto", label: t("vision.auto") },
|
|
486
|
+
{ value: "nonVisual", label: t("vision.nonVisual") },
|
|
487
|
+
],
|
|
488
|
+
}),
|
|
266
489
|
h("span", { key: "h", style: styles.hint }, t("vision.hint")),
|
|
267
490
|
]),
|
|
268
491
|
]),
|
|
269
492
|
]),
|
|
270
493
|
|
|
271
494
|
section("browser.title", [
|
|
272
|
-
h("div", { key: "row", style: styles.row }, [
|
|
495
|
+
h("div", { key: "row", style: styles.row, className: "dsh-bb-row" }, [
|
|
273
496
|
h("label", { key: "l", style: styles.label }, [
|
|
274
497
|
h("span", { key: "t" }, t("browser.channel")),
|
|
275
|
-
h(
|
|
498
|
+
h(Dropdown, {
|
|
276
499
|
key: "s",
|
|
277
|
-
|
|
500
|
+
label: t("browser.channel"),
|
|
278
501
|
value: settings.browser.channel,
|
|
279
502
|
disabled: busy,
|
|
280
|
-
onChange:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
503
|
+
onChange: value => patch({ browser: { channel: value } }),
|
|
504
|
+
options: [
|
|
505
|
+
{ value: "sidebar", label: t("browser.sidebar") },
|
|
506
|
+
{ value: "bundled", label: t("browser.bundled") },
|
|
507
|
+
{ value: "auto", label: t("browser.auto") },
|
|
508
|
+
{ value: "chrome", label: t("browser.chrome") },
|
|
509
|
+
{ value: "edge", label: t("browser.edge") },
|
|
510
|
+
],
|
|
511
|
+
}),
|
|
287
512
|
h("span", { key: "h", style: styles.hint }, t("browser.hint")),
|
|
288
513
|
]),
|
|
289
514
|
]),
|
|
290
515
|
]),
|
|
291
516
|
|
|
517
|
+
section("actions.title", [
|
|
518
|
+
h("p", { key: "lead", style: styles.hint }, t("actions.lead")),
|
|
519
|
+
h(Toggle, {
|
|
520
|
+
key: "allowExecute",
|
|
521
|
+
labelKey: "actions.allowExecute",
|
|
522
|
+
hintKey: "actions.allowExecute.hint",
|
|
523
|
+
checked: settings.actions.allowExecute,
|
|
524
|
+
busy,
|
|
525
|
+
onChange: value => patch({ actions: { allowExecute: value } }),
|
|
526
|
+
}),
|
|
527
|
+
h(Toggle, {
|
|
528
|
+
key: "allowDownload",
|
|
529
|
+
labelKey: "actions.allowDownload",
|
|
530
|
+
hintKey: "actions.allowDownload.hint",
|
|
531
|
+
checked: settings.actions.allowDownload,
|
|
532
|
+
busy,
|
|
533
|
+
onChange: value => patch({ actions: { allowDownload: value } }),
|
|
534
|
+
}),
|
|
535
|
+
h(Toggle, {
|
|
536
|
+
key: "allowCredentialWrite",
|
|
537
|
+
labelKey: "actions.allowCredentialWrite",
|
|
538
|
+
hintKey: "actions.allowCredentialWrite.hint",
|
|
539
|
+
checked: settings.actions.allowCredentialWrite,
|
|
540
|
+
busy,
|
|
541
|
+
onChange: value => patch({ actions: { allowCredentialWrite: value } }),
|
|
542
|
+
}),
|
|
543
|
+
]),
|
|
544
|
+
|
|
292
545
|
section("credentials.title", [
|
|
293
546
|
h(Toggle, {
|
|
294
547
|
key: "allowRead",
|
|
@@ -310,7 +563,203 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
|
|
|
310
563
|
])
|
|
311
564
|
}
|
|
312
565
|
|
|
566
|
+
// ===========================================================================
|
|
567
|
+
// Conversation panel service: the one place a sidebar panel is opened.
|
|
568
|
+
//
|
|
569
|
+
// The shell mounts one right sidebar per conversation, and DSH's own sidebar
|
|
570
|
+
// browser keeps that conversation's tabs and its webview guest inside it.
|
|
571
|
+
// `ctx.sidebarRight.openTab` acts on the conversation the shell is SHOWING
|
|
572
|
+
// (`mounted`), so every path that simulated a shortcut or clicked a card put
|
|
573
|
+
// the page into whichever conversation the human happened to be reading —
|
|
574
|
+
// that is the cross-conversation bleed, and it is a property of the path, not
|
|
575
|
+
// an accident of it.
|
|
576
|
+
//
|
|
577
|
+
// `openTabIn(sessionId, …)` is the same navigation aimed at a NAMED
|
|
578
|
+
// conversation, and it works for one that is not on screen. This service is
|
|
579
|
+
// the only caller; the plugin's host half reaches it by evaluating
|
|
580
|
+
// `globalThis.__dshBuiltinBrowser.openPanel(...)` inside the shell window, so
|
|
581
|
+
// no input event, no key dispatch and no element click is involved.
|
|
582
|
+
//
|
|
583
|
+
// Guest identity is read from the panel's own `<webview>` and only inside the
|
|
584
|
+
// subtree found for THAT conversation — never from the first match in the
|
|
585
|
+
// document, which belongs to somebody else's panel.
|
|
586
|
+
// ===========================================================================
|
|
587
|
+
|
|
588
|
+
/** The conversation a sidebar element belongs to, read off its React fiber. */
|
|
589
|
+
function sessionIdOfElement(element) {
|
|
590
|
+
const key = Object.keys(element).find(
|
|
591
|
+
name => name.startsWith("__reactFiber$") || name.startsWith("__reactInternalInstance$"),
|
|
592
|
+
)
|
|
593
|
+
if (key === undefined) return null
|
|
594
|
+
let fiber = element[key]
|
|
595
|
+
let depth = 0
|
|
596
|
+
while (fiber !== null && fiber !== undefined && depth < 60) {
|
|
597
|
+
const props = fiber.memoizedProps
|
|
598
|
+
if (props !== null && typeof props === "object" && typeof props.sessionId === "string") return props.sessionId
|
|
599
|
+
fiber = fiber.return
|
|
600
|
+
depth += 1
|
|
601
|
+
}
|
|
602
|
+
return null
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* This conversation's own sidebar strip, or null when the shell has not mounted it.
|
|
607
|
+
*
|
|
608
|
+
* Its presence is the precondition `openTabIn` needs: it is minted together with the
|
|
609
|
+
* conversation's surface store, and a call for a conversation whose store was never
|
|
610
|
+
* minted silently does nothing. Detecting it here turns that silence into a reason
|
|
611
|
+
* the caller can report, instead of a page that never appears.
|
|
612
|
+
*/
|
|
613
|
+
function tabStripFor(sessionId) {
|
|
614
|
+
for (const strip of Array.from(document.querySelectorAll("[class*=_tabStrip]"))) {
|
|
615
|
+
if (sessionIdOfElement(strip) === sessionId) return strip
|
|
616
|
+
}
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Every live `<webview>` guest inside this conversation's panel subtree. */
|
|
621
|
+
function guestsInPanel(sessionId) {
|
|
622
|
+
const strip = tabStripFor(sessionId)
|
|
623
|
+
if (strip === null) return []
|
|
624
|
+
// Walk up to the first ancestor that owns a guest: the strip itself is outside the
|
|
625
|
+
// body region, so the search starts low and stops as soon as it has one. It never
|
|
626
|
+
// crosses out of this conversation's branch, which is what keeps it scoped.
|
|
627
|
+
let node = strip
|
|
628
|
+
while (node !== null && node !== undefined && node !== document.body) {
|
|
629
|
+
const found = Array.from(node.querySelectorAll("webview")).filter(view => {
|
|
630
|
+
try { return typeof view.getWebContentsId === "function" && view.getWebContentsId() > 0 }
|
|
631
|
+
catch (error) { return false }
|
|
632
|
+
})
|
|
633
|
+
if (found.length > 0) return found
|
|
634
|
+
node = node.parentElement
|
|
635
|
+
}
|
|
636
|
+
return []
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Read this conversation's current guest, preferring the one actually drawn. */
|
|
640
|
+
function guestStateFor(sessionId) {
|
|
641
|
+
const strip = tabStripFor(sessionId)
|
|
642
|
+
if (strip === null) return { panel: false, guestId: null, url: "" }
|
|
643
|
+
const guests = guestsInPanel(sessionId)
|
|
644
|
+
if (guests.length === 0) return { panel: true, guestId: null, url: "" }
|
|
645
|
+
// A sidebar keeps every tab mounted (`keepMounted`), so a background tab's guest is
|
|
646
|
+
// in the tree too. Prefer the one with a drawn box; fall back to the newest.
|
|
647
|
+
const drawn = guests.find(view => {
|
|
648
|
+
const rect = view.getBoundingClientRect()
|
|
649
|
+
return rect.width > 0 && rect.height > 0
|
|
650
|
+
})
|
|
651
|
+
const chosen = drawn === undefined ? guests[guests.length - 1] : drawn
|
|
652
|
+
let url = ""
|
|
653
|
+
try { url = String(chosen.getURL ? chosen.getURL() : "") } catch (error) { url = "" }
|
|
654
|
+
try { return { panel: true, guestId: chosen.getWebContentsId(), url } }
|
|
655
|
+
catch (error) { return { panel: true, guestId: null, url: "" } }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Publish the service every host-side panel request goes through.
|
|
660
|
+
*
|
|
661
|
+
* The binding is separate from the publication so the API exists from the first
|
|
662
|
+
* apply — a request arriving before `sidebarRight` is ready gets a named reason
|
|
663
|
+
* rather than a missing global, which is indistinguishable from a bridge that
|
|
664
|
+
* never loaded.
|
|
665
|
+
*/
|
|
666
|
+
function installPanelService(ctx) {
|
|
667
|
+
let sidebarRight = null
|
|
668
|
+
// `inject` is how a client plugin waits for a service to appear. A host that does not offer it
|
|
669
|
+
// — an older composition, or a harness that mounted this bundle for its settings panel alone —
|
|
670
|
+
// must still get the panel, so the wait is skipped and the published API names the missing
|
|
671
|
+
// service when it is called. Failing the whole apply here would take the settings panel down
|
|
672
|
+
// with it, which is exactly the shape of regression this file has been bitten by before.
|
|
673
|
+
if (typeof ctx.inject === "function") {
|
|
674
|
+
ctx.inject(["sidebarRight"], (scope) => {
|
|
675
|
+
sidebarRight = scope.sidebarRight
|
|
676
|
+
scope.effect(() => () => {
|
|
677
|
+
if (sidebarRight === scope.sidebarRight) sidebarRight = null
|
|
678
|
+
}, "dsh-builtin-browser: conversation panel binding")
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
const api = {
|
|
682
|
+
/** @returns what happened: an existing guest, a newly opened tab, or a named refusal. */
|
|
683
|
+
openPanel(sessionId, url) {
|
|
684
|
+
const wanted = typeof sessionId === "string" ? sessionId : ""
|
|
685
|
+
if (!/^session-/.test(wanted)) {
|
|
686
|
+
return { ok: false, reason: "not a conversation id: " + JSON.stringify(sessionId), panel: false, guestId: null }
|
|
687
|
+
}
|
|
688
|
+
if (sidebarRight === null || typeof sidebarRight.openTabIn !== "function") {
|
|
689
|
+
return { ok: false, reason: "the shell's sidebar service is not available", panel: false, guestId: null }
|
|
690
|
+
}
|
|
691
|
+
const state = guestStateFor(wanted)
|
|
692
|
+
if (state.guestId !== null) {
|
|
693
|
+
return { ok: true, created: false, panel: true, guestId: state.guestId, url: state.url }
|
|
694
|
+
}
|
|
695
|
+
// A mounted panel is NOT required to place a tab.
|
|
696
|
+
//
|
|
697
|
+
// `openTabIn` acts on the conversation's own surface store, and the sidebar-right service
|
|
698
|
+
// keeps one for every conversation the shell has shown — "reaches any session's store by
|
|
699
|
+
// id … on screen or not". What a DISPLAYED conversation adds is the DOM to read the guest
|
|
700
|
+
// back out of, which is a separate question. So the tab is placed either way, and `tabsIn`
|
|
701
|
+
// (a public method) is what says whether it actually landed: `openTabIn` SILENTLY ignores a
|
|
702
|
+
// conversation whose store was never minted, and a silent no-op must never be reported as
|
|
703
|
+
// success.
|
|
704
|
+
let before = 0
|
|
705
|
+
try { before = sidebarRight.tabsIn(wanted).length } catch (error) { before = 0 }
|
|
706
|
+
try {
|
|
707
|
+
sidebarRight.openTabIn(wanted, "browser", url ? { params: { url: String(url) } } : {})
|
|
708
|
+
} catch (error) {
|
|
709
|
+
return { ok: false, reason: String((error && error.message) || error), panel: state.panel, guestId: null }
|
|
710
|
+
}
|
|
711
|
+
let placed = 0
|
|
712
|
+
try { placed = sidebarRight.tabsIn(wanted).length } catch (error) { placed = before }
|
|
713
|
+
// `openTabIn` DEDUPLICATES, so "the count grew" is the wrong test for "is this
|
|
714
|
+
// conversation's surface mounted".
|
|
715
|
+
//
|
|
716
|
+
// Placing a page kind the conversation already holds leaves the count unchanged — the shell
|
|
717
|
+
// reveals the tab it has. Testing for growth therefore reported a conversation that already
|
|
718
|
+
// held the page as UNMOUNTED, and refused to drive a page that was right there: that is the
|
|
719
|
+
// "it will not reopen what it already opened" behaviour, and it bites hardest on the second
|
|
720
|
+
// open of an address, which is exactly when a human expects a fast answer.
|
|
721
|
+
//
|
|
722
|
+
// A count that started at zero and stayed there is the real signature of a surface the
|
|
723
|
+
// shell has never minted, because that is the only case where `openTabIn` is a silent no-op.
|
|
724
|
+
if (placed <= before && before === 0) {
|
|
725
|
+
// Name what was FOUND, not just what was missing: "no panel" has two very different
|
|
726
|
+
// causes — the shell has mounted no sidebar for this conversation (nothing this side can
|
|
727
|
+
// do), or it writes its session id somewhere this lookup does not read (fixable) — and a
|
|
728
|
+
// refusal that says only "not mounted" cannot tell them apart.
|
|
729
|
+
const strips = Array.from(document.querySelectorAll("[class*=_tabStrip]"))
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
reason: "this conversation's sidebar is not mounted, so a panel cannot be opened in it",
|
|
733
|
+
panel: state.panel,
|
|
734
|
+
guestId: null,
|
|
735
|
+
found: {
|
|
736
|
+
wanted: wanted,
|
|
737
|
+
strips: strips.length,
|
|
738
|
+
owners: strips.slice(0, 8).map(strip => sessionIdOfElement(strip)),
|
|
739
|
+
reactKeys: strips.length === 0 ? [] : Object.keys(strips[0]).filter(key => key.startsWith("__react")).slice(0, 4),
|
|
740
|
+
},
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return { ok: true, created: placed > before, panel: state.panel, guestId: null, tabs: placed }
|
|
744
|
+
},
|
|
745
|
+
/** Read-only: this conversation's current guest, if it has one. */
|
|
746
|
+
panelGuest(sessionId) {
|
|
747
|
+
return guestStateFor(typeof sessionId === "string" ? sessionId : "")
|
|
748
|
+
},
|
|
749
|
+
}
|
|
750
|
+
ctx.effect(() => {
|
|
751
|
+
globalThis.__dshBuiltinBrowser = api
|
|
752
|
+
return () => {
|
|
753
|
+
if (globalThis.__dshBuiltinBrowser === api) delete globalThis.__dshBuiltinBrowser
|
|
754
|
+
}
|
|
755
|
+
}, "dsh-builtin-browser: conversation panel service")
|
|
756
|
+
}
|
|
757
|
+
|
|
313
758
|
function apply(ctx) {
|
|
759
|
+
// Before anything renders: the rows need a hover state, and inline styles cannot express
|
|
760
|
+
// one. Idempotent, so a re-apply does not stack copies.
|
|
761
|
+
installStyles()
|
|
762
|
+
installPanelService(ctx)
|
|
314
763
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-builtin-browser: dictionaries")
|
|
315
764
|
const t = ctx.locale.bind(NS)
|
|
316
765
|
translate = t
|
package/cordis.patch.yml
CHANGED
|
@@ -40,3 +40,24 @@
|
|
|
40
40
|
|
|
41
41
|
- id: tool-browser
|
|
42
42
|
name: dsh-builtin-browser/tool-browser
|
|
43
|
+
# The tool layer's own switches. The `config` key is what makes them reachable at all:
|
|
44
|
+
# without it nothing at the plugin level could set them, so the operator allow-list the
|
|
45
|
+
# READMEs describe was unreachable in every factory deployment.
|
|
46
|
+
#
|
|
47
|
+
# Every field is optional, so an empty mapping uses the defaults:
|
|
48
|
+
#
|
|
49
|
+
# allowedActions: operator-level allow-list of browser tool names, applied to every
|
|
50
|
+
# task in the deployment (a task can still narrow it further with browser_restrict).
|
|
51
|
+
# READ_ONLY_TOOLS and browser_restrict are never blocked, whatever the list says.
|
|
52
|
+
# timeoutMs: cooperative per-call budget in ms (default 60000).
|
|
53
|
+
# tabTools: whether the tab-management tools are registered (default true).
|
|
54
|
+
#
|
|
55
|
+
# To restrict a deployment, replace this with, for example:
|
|
56
|
+
# config:
|
|
57
|
+
# allowedActions: [browser_snapshot, browser_a11y, browser_content, browser_click]
|
|
58
|
+
#
|
|
59
|
+
# The settings panel's "What the agent may do" switches are a SEPARATE layer with the
|
|
60
|
+
# same property — the model cannot lift them either — and they need no patch file at
|
|
61
|
+
# all. The list above restricts every task in a deployment; those three switches
|
|
62
|
+
# restrict what any task may do on this installation, and need no YAML.
|
|
63
|
+
config: {}
|