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.
Files changed (57) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +114 -33
  3. package/README.md +108 -32
  4. package/client.js +478 -29
  5. package/cordis.patch.yml +21 -0
  6. package/desktop-bridge/install.mjs +66 -6
  7. package/desktop-bridge/plugin-browser-bridge.js +426 -167
  8. package/docs/architecture.md +14 -4
  9. package/docs/tool-reference.md +13 -13
  10. package/docs/user-guide.md +58 -3
  11. package/docs/why-browser.md +1 -1
  12. package/lib/browser/types.d.ts +34 -3
  13. package/lib/browser-electron/bridge-connection.d.ts +49 -26
  14. package/lib/browser-electron/bridge-connection.js +185 -22
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  16. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  17. package/lib/browser-electron/entry.d.ts +1 -1
  18. package/lib/browser-electron/entry.js +133 -40
  19. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  20. package/lib/browser-electron/ephemeral-profile.js +146 -0
  21. package/lib/browser-electron/history-store.d.ts +36 -2
  22. package/lib/browser-electron/history-store.js +76 -7
  23. package/lib/browser-electron/host-main.js +86 -28
  24. package/lib/browser-electron/provider.d.ts +106 -3
  25. package/lib/browser-electron/provider.js +407 -60
  26. package/lib/browser-electron/remote-host.d.ts +52 -3
  27. package/lib/browser-electron/remote-host.js +259 -25
  28. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  29. package/lib/browser-electron/rpc-ops.js +36 -0
  30. package/lib/browser-electron/settings-route.d.ts +28 -0
  31. package/lib/browser-electron/settings-route.js +68 -0
  32. package/lib/browser-electron/settings-store.d.ts +40 -0
  33. package/lib/browser-electron/settings-store.js +160 -23
  34. package/lib/browser-electron/system-browser.d.ts +102 -12
  35. package/lib/browser-electron/system-browser.js +411 -51
  36. package/lib/index.js +0 -1
  37. package/lib/tool-browser/element-fields.d.ts +145 -0
  38. package/lib/tool-browser/element-fields.js +152 -0
  39. package/lib/tool-browser/index.js +149 -74
  40. package/package.json +10 -4
  41. package/scripts/verify-host-compat.mjs +195 -0
  42. package/src/browser/types.ts +34 -3
  43. package/src/browser-electron/bridge-connection.ts +179 -22
  44. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  45. package/src/browser-electron/entry.ts +132 -39
  46. package/src/browser-electron/ephemeral-profile.ts +152 -0
  47. package/src/browser-electron/history-store.ts +81 -9
  48. package/src/browser-electron/host-main.ts +115 -36
  49. package/src/browser-electron/provider.ts +492 -69
  50. package/src/browser-electron/remote-host.ts +276 -38
  51. package/src/browser-electron/rpc-ops.ts +41 -0
  52. package/src/browser-electron/settings-route.ts +87 -0
  53. package/src/browser-electron/settings-store.ts +173 -24
  54. package/src/browser-electron/system-browser.ts +408 -39
  55. package/src/index.ts +3 -2
  56. package/src/tool-browser/element-fields.ts +167 -0
  57. 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 credential access.
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.bundled": "内置 Electron(随插件提供,无需安装)",
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 的导出与恢复都会被拒绝(BROWSER_AUTH_DISABLED)—— 不只是导出:恢复走的是同一个开关。桌面端请注意:Agent 驱动的是侧栏里那一个页面(人机同页),因此它与该页面共用同一个 partition —— 这也正是它能读到登录态的原因,属于有意接受的沙箱边界变化;关闭本项即拒绝读取。",
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
- "browser.bundled": "Bundled Electron (ships with the plugin, nothing to install)",
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 both export and restore (BROWSER_AUTH_DISABLED) — not just export: restore goes through the same switch. 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.",
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", padding: "5px 0" },
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 [settings, setSettings] = useState(null)
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 (settings === null) {
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("select", {
478
+ h(Dropdown, {
257
479
  key: "s",
258
- style: styles.select,
480
+ label: t("vision.strategy"),
259
481
  value: settings.vision.strategy,
260
482
  disabled: busy,
261
- onChange: event => patch({ vision: { strategy: event.target.value } }),
262
- }, [
263
- h("option", { key: "auto", value: "auto" }, t("vision.auto")),
264
- h("option", { key: "nonVisual", value: "nonVisual" }, t("vision.nonVisual")),
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("select", {
498
+ h(Dropdown, {
276
499
  key: "s",
277
- style: styles.select,
500
+ label: t("browser.channel"),
278
501
  value: settings.browser.channel,
279
502
  disabled: busy,
280
- onChange: event => patch({ browser: { channel: event.target.value } }),
281
- }, [
282
- h("option", { key: "bundled", value: "bundled" }, t("browser.bundled")),
283
- h("option", { key: "auto", value: "auto" }, t("browser.auto")),
284
- h("option", { key: "chrome", value: "chrome" }, t("browser.chrome")),
285
- h("option", { key: "edge", value: "edge" }, t("browser.edge")),
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: {}