@angelky7/dsh-browser-control 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -64,8 +64,8 @@ In one line: **for "use MY browser" tasks (logged-in Bilibili, Zhihu, admin pane
64
64
 
65
65
  | File | Purpose |
66
66
  |---|---|
67
- | [DSH-Browser-Control-v1.0.19.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.0.19/DSH-Browser-Control-v1.0.19.zip) | Chrome extension (unzip and load; standard zip header, Explorer can expand it directly) |
68
- | [dsh-browser-control-plugin-v1.0.19.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.0.19/dsh-browser-control-plugin-v1.0.19.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
67
+ | [DSH-Browser-Control-v1.3.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.3.0/DSH-Browser-Control-v1.3.0.zip) | Chrome extension (unzip and load; extension code is unchanged since 1.2.0 — no reinstall needed) |
68
+ | [dsh-browser-control-plugin-v1.3.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.3.0/dsh-browser-control-plugin-v1.3.0.zip) | dsh plugin (offline fallback; online installs use Option A/B) |
69
69
 
70
70
  ## Install the Chrome extension (30 seconds)
71
71
 
@@ -95,7 +95,7 @@ npm install @angelky7/dsh-browser-control
95
95
  > **A version published minutes ago may not install yet, and that is not a broken package.** `dsh plugin add` pins the dependency to an **exact version**, while pnpm resolves it from **your configured registry** — for many users that is `registry.npmmirror.com`, which syncs from the official registry with a delay (measured: the official registry had 1.0.10 published while the mirror's `latest` still said 1.0.8). If you hit `ERR_PNPM_NO_MATCHING_VERSION ... while fetching it from https://registry.npmmirror.com/`, pick one:
96
96
  >
97
97
  > 1. Use the official registry for this install: `dsh plugin --profile web add @angelky7/dsh-browser-control@<version> --registry=https://registry.npmjs.org/`
98
- > 2. Use the offline asset from the Release, `dsh-browser-control-plugin-v<version>.zip`: unzip it, then `dsh plugin --profile web add file:<the unzipped directory>`
98
+ > 2. Use the offline asset from the Release, `dsh-browser-control-plugin-v<version>.zip`: unzip it, then point `dsh plugin --profile web add file:<…>` at **the layer that contains `package.json`** (some releases wrapped the entries in an extra folder — point at the layer that contains `package.json`)
99
99
  > 3. Wait for the mirror and retry: `curl -s https://registry.npmmirror.com/@angelky7/dsh-browser-control` — once the version appears there and `dist-tags.latest` moved, the plain command works
100
100
  >
101
101
  > **A second failure looks similar but has the opposite cause — do not touch your registry for it.** dsh profiles ship a 24-hour package-age supply-chain gate (`minimumReleaseAge: 1440` in `node_modules/.pnpm-workspace-state-v1.json`). A version younger than that is rejected with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`, and the message quotes `was published at …, within the minimumReleaseAge cutoff (…)` — the version **does** exist, it is just too new. Waiting past the cutoff is the only fix. Discriminator: `NO_MATCHING_VERSION` = your registry does not have that version; `NO_MATURE_MATCHING_VERSION` = it has it, but the version is younger than 24h.
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
104
104
 
105
105
  ```bash
106
106
  # Straight from GitHub
107
- dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.0.19"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.3.0"
108
108
 
109
109
  # Local checkout for debugging (note: the explicit file: prefix is required)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -255,6 +255,7 @@ curl -X POST http://127.0.0.1:9777/api/command \
255
255
  | `browser_find` | Query elements by CSS selector, returning count and viewport rects (read-only, v1.0.9+) |
256
256
  | `browser_dialog` | Read or set the native-dialog policy accept/dismiss/manual, plus the recent dialog log (v1.0.9+) |
257
257
  | `browser_hold` | Put or remove a cancellable take-over overlay on a tab (tells the user, stops mis-clicks); `covered:false` says why it could not cover the page. A soft layer, not a security boundary |
258
+ | `browser_cookies` | Read/set/remove cookies visible to a tab (`action: get/set/remove`; get takes a `domain` filter, set/remove need a `url` or `domain` anchor, remove re-checks and reports `remaining`). **Cookie values are credentials** — a get puts them fully into the model-visible text; see SECURITY.md |
258
259
  | `browser_tabs` | Tab management (list/open/close/activate/group/ungroup; see "What agent tabs look like") |
259
260
  | `browser_evaluate` | Run arbitrary JS |
260
261
  | `browser_screenshot` | Capture page screenshot |
package/README.md CHANGED
@@ -64,8 +64,8 @@ Chrome 扩展(CDP 驱动)
64
64
 
65
65
  | 文件 | 说明 |
66
66
  |---|---|
67
- | [DSH-Browser-Control-v1.0.19.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.0.19/DSH-Browser-Control-v1.0.19.zip) | Chrome 扩展(解压后加载;标准 zip 头,资源管理器可直接展开) |
68
- | [dsh-browser-control-plugin-v1.0.19.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.0.19/dsh-browser-control-plugin-v1.0.19.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
67
+ | [DSH-Browser-Control-v1.3.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.3.0/DSH-Browser-Control-v1.3.0.zip) | Chrome 扩展(解压后加载;与 1.2.0 相比扩展代码无改动,已装 1.2.0 不必重装) |
68
+ | [dsh-browser-control-plugin-v1.3.0.zip](https://github.com/angelky7/dsh-browser-control/releases/download/v1.3.0/dsh-browser-control-plugin-v1.3.0.zip) | dsh 插件(离线兜底,在线装直接用方式 A/B) |
69
69
 
70
70
  ## 安装 Chrome 扩展(30 秒)
71
71
 
@@ -95,7 +95,7 @@ npm install @angelky7/dsh-browser-control
95
95
  > **刚发布的新版本可能一时装不上,那不是插件坏了。** `dsh plugin add` 会把依赖钉成**确切版本号**,而 pnpm 用的是**你自己配置的源**——国内默认常常是 `registry.npmmirror.com`,它同步官方源有延迟(实测 1.0.10 在官方源已发布时,镜像的 `latest` 还停在 1.0.8)。撞上 `ERR_PNPM_NO_MATCHING_VERSION ... while fetching it from https://registry.npmmirror.com/` 时有三条路:
96
96
  >
97
97
  > 1. 这一次显式走官方源:`dsh plugin --profile web add @angelky7/dsh-browser-control@<版本> --registry=https://registry.npmjs.org/`
98
- > 2. 用 Release 里的离线包 `dsh-browser-control-plugin-v<版本>.zip`,解压后 `dsh plugin --profile web add file:<解压目录>`
98
+ > 2. 用 Release 里的离线包 `dsh-browser-control-plugin-v<版本>.zip`,解压后对**含有 `package.json` 的那一层**执行 `dsh plugin --profile web add file:<那一层>`(个别版本的 zip 会把条目包进一层子目录,以解压后**含有 `package.json` 的那一层**为准)
99
99
  > 3. 等镜像同步后重试原命令:`curl -s https://registry.npmmirror.com/@angelky7/dsh-browser-control`,看到该版本、且 `dist-tags.latest` 也跟上即可
100
100
  >
101
101
  > **另一种装不上长得像、原因完全不同,别改 registry。** dsh 的 profile 自带 24 小时「包龄」供应链闸门(`node_modules/.pnpm-workspace-state-v1.json` 里的 `minimumReleaseAge: 1440`)。发布未满 24 小时的版本被它拦下时报的是 `ERR_PNPM_NO_MATURE_MATCHING_VERSION`,而且报文里会直接写 `was published at …, within the minimumReleaseAge cutoff (…)`——版本是**存在**的,只是太新。这种只有等满 cutoff 时刻这一条路(改 registry、离线包都不必)。两个码的判据:`NO_MATCHING_VERSION` = 你配置的源里没有这个版本;`NO_MATURE_MATCHING_VERSION` = 有,但没满 24 小时。
@@ -104,7 +104,7 @@ npm install @angelky7/dsh-browser-control
104
104
 
105
105
  ```bash
106
106
  # 直接从 GitHub 安装
107
- dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.0.19"
107
+ dsh plugin --profile web add "github:angelky7/dsh-browser-control#v1.3.0"
108
108
 
109
109
  # 本地目录调试(注意:必须显式 file: 前缀)
110
110
  dsh plugin --profile web add "file:D:\path\to\dsh-browser-control"
@@ -249,6 +249,7 @@ curl -X POST http://127.0.0.1:9777/api/command \
249
249
  | `browser_find` | 按 CSS 选择器查元素,返回数量与位置尺寸(只读,v1.0.9+) |
250
250
  | `browser_dialog` | 读改原生弹窗应答策略 accept/dismiss/manual,并回最近弹窗记录(v1.0.9+) |
251
251
  | `browser_hold` | 在某个标签页上加/撤一层可取消的接管浮层(告知用户 + 防误触);`covered:false` 时说明盖不住的原因。软层,不是安全边界 |
252
+ | `browser_cookies` | 读/设/删某标签页可见的 cookie(`action: get/set/remove`;get 带 `domain` 过滤,set/remove 需要 `url` 或 `domain` 锚点,remove 复查并回报 `remaining`)。**cookie 的值是凭据**,get 会把值完整放进模型可见文本——见 SECURITY.md |
252
253
  | `browser_tabs` | 标签页管理(列表/新建/关闭/切换/归组/拆组;见「agent 开的标签长什么样」) |
253
254
  | `browser_evaluate` | 执行任意 JS |
254
255
  | `browser_screenshot` | 截取页面截图 |
package/lib/index.js CHANGED
@@ -873,7 +873,8 @@ const READ_ONLY_ALLOWED = /* @__PURE__ */ new Set([
873
873
  "wait",
874
874
  "tabs.list",
875
875
  "console.log",
876
- "network.log"
876
+ "network.log",
877
+ "cookies.get"
877
878
  ]);
878
879
  /**
879
880
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
@@ -938,6 +939,15 @@ function policyRefusal(command, flags, params) {
938
939
  * fix, so the model can tell the user what to do instead of hanging.
939
940
  * @module @deepseek-ai/dsh-browser-bridge
940
941
  */
942
+ /** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
943
+ const NAV_MAX_MS = 12e4;
944
+ /**
945
+ * Extra time handed to the bridge on top of the navigation budget. Without it a
946
+ * caller-requested budget longer than the bridge default (60s) would be beaten by
947
+ * the bridge, and the model would get `command_timeout` (effect unknown) instead
948
+ * of a navigation timeout (nothing was touched) - two very different next steps.
949
+ */
950
+ const BRIDGE_MARGIN_MS = 2e3;
941
951
  /** Cordis plugin name used by loader diagnostics. */
942
952
  const name = "browser-bridge";
943
953
  /** The tool registry this plugin contributes `browser_*` tools to. */
@@ -1235,6 +1245,10 @@ function applyBrowserTools(ctx, controller) {
1235
1245
  tabId: {
1236
1246
  type: "number",
1237
1247
  description: "Target tab; defaults to the active tab."
1248
+ },
1249
+ timeoutMs: {
1250
+ type: "number",
1251
+ description: `How long to wait for the page load to settle: defaults to 15000, capped at ${NAV_MAX_MS}. Raise it for slow pages instead of retrying; drop it to 3000-5000 when you do not need the page to finish loading and will read/wait afterwards.`
1238
1252
  }
1239
1253
  },
1240
1254
  output: {
@@ -1286,7 +1300,9 @@ function applyBrowserTools(ctx, controller) {
1286
1300
  async execute(args, exec) {
1287
1301
  const params = { url: args.url };
1288
1302
  if (args.tabId !== void 0) params.tabId = args.tabId;
1289
- const raw = await controller.execute("nav", params, exec.signal);
1303
+ const budgetMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs) ? Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs))) : 15e3;
1304
+ params.timeoutMs = budgetMs;
1305
+ const raw = await controller.execute("nav", params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
1290
1306
  const out = { tabId: raw.tabId };
1291
1307
  if (typeof raw.url === "string") out.url = raw.url;
1292
1308
  if (typeof raw.title === "string") out.title = raw.title;
@@ -1820,6 +1836,95 @@ function applyBrowserTools(ctx, controller) {
1820
1836
  return await controller.execute("hold", params, exec.signal);
1821
1837
  }
1822
1838
  }));
1839
+ ctx.tools.register(defineTool({
1840
+ name: "browser_cookies",
1841
+ description: "Read, set or remove cookies of a tab (CDP Network.*, no new permission). Cookie values are credentials — a get returns them in full and they land in the model-visible text, so filter by domain and do not point this at a page whose cookies you do not actually need. set/remove need an anchor: url, or domain (with optional path).",
1842
+ parameters: {
1843
+ action: {
1844
+ type: "string",
1845
+ enum: [
1846
+ "get",
1847
+ "set",
1848
+ "remove"
1849
+ ],
1850
+ required: true,
1851
+ description: "\"get\" lists cookies visible to the tab (domain filter optional); \"set\" writes one; \"remove\" deletes one and reports whether it is still visible afterwards."
1852
+ },
1853
+ name: {
1854
+ type: "string",
1855
+ description: "set/remove: cookie name (required)."
1856
+ },
1857
+ value: {
1858
+ type: "string",
1859
+ description: "set: cookie value (required; pass an empty string to clear the content)."
1860
+ },
1861
+ domain: {
1862
+ type: "string",
1863
+ description: "get: only cookies whose domain contains this substring. set/remove: anchor — the cookie domain."
1864
+ },
1865
+ url: {
1866
+ type: "string",
1867
+ description: "set/remove: alternative anchor — Chrome derives origin/secure defaults from it."
1868
+ },
1869
+ path: {
1870
+ type: "string",
1871
+ description: "set/remove: cookie path."
1872
+ },
1873
+ secure: {
1874
+ type: "boolean",
1875
+ description: "set: https-only cookie."
1876
+ },
1877
+ httpOnly: {
1878
+ type: "boolean",
1879
+ description: "set: hide the cookie from page scripts."
1880
+ },
1881
+ sameSite: {
1882
+ type: "string",
1883
+ description: "set: Strict | Lax | None."
1884
+ },
1885
+ expires: {
1886
+ type: "number",
1887
+ description: "set: Unix seconds; omit for a session cookie."
1888
+ },
1889
+ tabId: {
1890
+ type: "number",
1891
+ description: "Target tab; defaults to the active tab."
1892
+ }
1893
+ },
1894
+ output: {
1895
+ schema: {
1896
+ type: "object",
1897
+ additionalProperties: true
1898
+ },
1899
+ render: (_args, value) => [{
1900
+ type: "text",
1901
+ text: clampRendered(JSON.stringify(value))
1902
+ }]
1903
+ },
1904
+ presentCall: (args) => ({
1905
+ card: "generic",
1906
+ title: `Cookies ${args.action}`,
1907
+ kind: "other"
1908
+ }),
1909
+ async execute(args, exec) {
1910
+ const command = args.action === "get" ? "cookies.get" : args.action === "set" ? "cookies.set" : args.action === "remove" ? "cookies.remove" : null;
1911
+ if (command === null) throw new Error("browser_cookies action must be get, set or remove");
1912
+ const params = {};
1913
+ if (args.name !== void 0) params.name = args.name;
1914
+ if (args.value !== void 0) params.value = args.value;
1915
+ if (args.domain !== void 0) params.domain = args.domain;
1916
+ if (args.url !== void 0) params.url = args.url;
1917
+ if (args.path !== void 0) params.path = args.path;
1918
+ if (args.secure !== void 0) params.secure = args.secure;
1919
+ if (args.httpOnly !== void 0) params.httpOnly = args.httpOnly;
1920
+ if (args.sameSite !== void 0) params.sameSite = args.sameSite;
1921
+ if (args.expires !== void 0) params.expires = args.expires;
1922
+ if (args.tabId !== void 0) params.tabId = args.tabId;
1923
+ if (command === "cookies.get") return await controller.execute("cookies.get", params, exec.signal);
1924
+ if (command === "cookies.set") return await controller.execute("cookies.set", params, exec.signal);
1925
+ return await controller.execute("cookies.remove", params, exec.signal);
1926
+ }
1927
+ }));
1823
1928
  ctx.tools.register(defineTool({
1824
1929
  name: "browser_dialog",
1825
1930
  description: "Read or change how native alert/confirm/prompt dialogs are answered (accept, dismiss, or manual), and get the recent dialog log so you can prove one appeared.",
@@ -1951,6 +2056,8 @@ function applyBrowserTools(ctx, controller) {
1951
2056
  }
1952
2057
  }
1953
2058
  }));
2059
+ /** Page-side ceiling of the eval race in the extension; the bridge budget adds slack on top. */
2060
+ const EVAL_MAX_MS = 12e4;
1954
2061
  ctx.tools.register(defineTool({
1955
2062
  name: "browser_evaluate",
1956
2063
  description: "Run JavaScript in the page and get the JSON result back as a string. Prefer read-only inspection.",
@@ -1963,6 +2070,10 @@ function applyBrowserTools(ctx, controller) {
1963
2070
  tabId: {
1964
2071
  type: "number",
1965
2072
  description: "Target tab; defaults to the active tab."
2073
+ },
2074
+ timeoutMs: {
2075
+ type: "number",
2076
+ description: `Give up after this many milliseconds; defaults to 60000, capped at ${EVAL_MAX_MS}.`
1966
2077
  }
1967
2078
  },
1968
2079
  output: {
@@ -1991,10 +2102,15 @@ function applyBrowserTools(ctx, controller) {
1991
2102
  kind: "other"
1992
2103
  }),
1993
2104
  async execute(args, exec) {
1994
- const raw = await controller.execute("eval", args.tabId === void 0 ? { expression: args.expression } : {
1995
- expression: args.expression,
1996
- tabId: args.tabId
1997
- }, exec.signal);
2105
+ const params = { expression: args.expression };
2106
+ if (args.tabId !== void 0) params.tabId = args.tabId;
2107
+ let bridgeCeiling;
2108
+ if (typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)) {
2109
+ const evalBudget = Math.min(EVAL_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
2110
+ params.timeoutMs = evalBudget;
2111
+ bridgeCeiling = evalBudget + 2e3;
2112
+ }
2113
+ const raw = await controller.execute("eval", params, exec.signal, bridgeCeiling);
1998
2114
  let json;
1999
2115
  try {
2000
2116
  json = JSON.stringify(raw.value) ?? String(raw.value);
@@ -24,6 +24,15 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
24
24
  import { BridgeServer, cleanupArtifacts } from "./server.js";
25
25
  import { BridgeError, classify } from "./errors.js";
26
26
  import { policyRefusal } from "./policy.js";
27
+ /** Navigation wait ceiling (same as browser_wait); the bridge gets this plus BRIDGE_MARGIN_MS. */
28
+ const NAV_MAX_MS = 120_000;
29
+ /**
30
+ * Extra time handed to the bridge on top of the navigation budget. Without it a
31
+ * caller-requested budget longer than the bridge default (60s) would be beaten by
32
+ * the bridge, and the model would get `command_timeout` (effect unknown) instead
33
+ * of a navigation timeout (nothing was touched) - two very different next steps.
34
+ */
35
+ const BRIDGE_MARGIN_MS = 2_000;
27
36
  // Re-exported so the policy tables stay testable from the published bundle
28
37
  // (verify/verify_policy.mjs reconciles them against the extension's COMMANDS).
29
38
  export { LOCAL_DESTRUCTIVE_COMMANDS, PAGE_MUTATING_COMMANDS, READ_ONLY_ALLOWED, SCRIPT_COMMANDS, policyRefusal } from "./policy.js";
@@ -358,6 +367,7 @@ function applyBrowserTools(ctx, controller) {
358
367
  parameters: {
359
368
  url: { type: 'string', required: true, description: 'Absolute URL to open in the tab.' },
360
369
  tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
370
+ timeoutMs: { type: 'number', description: `How long to wait for the page load to settle: defaults to 15000, capped at ${NAV_MAX_MS}. Raise it for slow pages instead of retrying; drop it to 3000-5000 when you do not need the page to finish loading and will read/wait afterwards.` },
361
371
  },
362
372
  output: {
363
373
  schema: {
@@ -397,7 +407,11 @@ function applyBrowserTools(ctx, controller) {
397
407
  const params = { url: args.url };
398
408
  if (args.tabId !== undefined)
399
409
  params.tabId = args.tabId;
400
- const raw = await controller.execute('nav', params, exec.signal);
410
+ const budgetMs = typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)
411
+ ? Math.min(NAV_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)))
412
+ : 15_000;
413
+ params.timeoutMs = budgetMs;
414
+ const raw = await controller.execute('nav', params, exec.signal, budgetMs + BRIDGE_MARGIN_MS);
401
415
  // The declared schema is closed, so the fields describing a navigation that did
402
416
  // not arrive have to be carried over on purpose. The old shape check
403
417
  // (`siteUnreachable === true`) also missed the object the extension really
@@ -722,6 +736,65 @@ function applyBrowserTools(ctx, controller) {
722
736
  return await controller.execute('hold', params, exec.signal);
723
737
  },
724
738
  }));
739
+ ctx.tools.register(defineTool({
740
+ name: 'browser_cookies',
741
+ description: 'Read, set or remove cookies of a tab (CDP Network.*, no new permission). Cookie values are credentials — a get returns them in full and they land in the model-visible text, so filter by domain and do not point this at a page whose cookies you do not actually need. set/remove need an anchor: url, or domain (with optional path).',
742
+ parameters: {
743
+ action: { type: 'string', enum: ['get', 'set', 'remove'], required: true, description: '"get" lists cookies visible to the tab (domain filter optional); "set" writes one; "remove" deletes one and reports whether it is still visible afterwards.' },
744
+ name: { type: 'string', description: 'set/remove: cookie name (required).' },
745
+ value: { type: 'string', description: 'set: cookie value (required; pass an empty string to clear the content).' },
746
+ domain: { type: 'string', description: 'get: only cookies whose domain contains this substring. set/remove: anchor — the cookie domain.' },
747
+ url: { type: 'string', description: 'set/remove: alternative anchor — Chrome derives origin/secure defaults from it.' },
748
+ path: { type: 'string', description: 'set/remove: cookie path.' },
749
+ secure: { type: 'boolean', description: 'set: https-only cookie.' },
750
+ httpOnly: { type: 'boolean', description: 'set: hide the cookie from page scripts.' },
751
+ sameSite: { type: 'string', description: 'set: Strict | Lax | None.' },
752
+ expires: { type: 'number', description: 'set: Unix seconds; omit for a session cookie.' },
753
+ tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
754
+ },
755
+ output: {
756
+ schema: { type: 'object', additionalProperties: true },
757
+ render: (_args, value) => [{ type: 'text', text: clampRendered(JSON.stringify(value)) }],
758
+ },
759
+ presentCall: args => ({ card: 'generic', title: `Cookies ${args.action}`, kind: 'other' }),
760
+ async execute(args, exec) {
761
+ // 三条命令各写一个字面量调用点:verify_surface 对账的是
762
+ // `controller.execute('命令名')` 的字面量,动态拼名字会让
763
+ // 「扩展实现了但 agent 拿不到」那条断言瞎掉。
764
+ const command = args.action === 'get' ? 'cookies.get'
765
+ : args.action === 'set' ? 'cookies.set'
766
+ : args.action === 'remove' ? 'cookies.remove'
767
+ : null;
768
+ if (command === null)
769
+ throw new Error('browser_cookies action must be get, set or remove');
770
+ const params = {};
771
+ if (args.name !== undefined)
772
+ params.name = args.name;
773
+ if (args.value !== undefined)
774
+ params.value = args.value;
775
+ if (args.domain !== undefined)
776
+ params.domain = args.domain;
777
+ if (args.url !== undefined)
778
+ params.url = args.url;
779
+ if (args.path !== undefined)
780
+ params.path = args.path;
781
+ if (args.secure !== undefined)
782
+ params.secure = args.secure;
783
+ if (args.httpOnly !== undefined)
784
+ params.httpOnly = args.httpOnly;
785
+ if (args.sameSite !== undefined)
786
+ params.sameSite = args.sameSite;
787
+ if (args.expires !== undefined)
788
+ params.expires = args.expires;
789
+ if (args.tabId !== undefined)
790
+ params.tabId = args.tabId;
791
+ if (command === 'cookies.get')
792
+ return await controller.execute('cookies.get', params, exec.signal);
793
+ if (command === 'cookies.set')
794
+ return await controller.execute('cookies.set', params, exec.signal);
795
+ return await controller.execute('cookies.remove', params, exec.signal);
796
+ },
797
+ }));
725
798
  ctx.tools.register(defineTool({
726
799
  name: 'browser_dialog',
727
800
  description: 'Read or change how native alert/confirm/prompt dialogs are answered (accept, dismiss, or manual), and get the recent dialog log so you can prove one appeared.',
@@ -807,12 +880,15 @@ function applyBrowserTools(ctx, controller) {
807
880
  }
808
881
  },
809
882
  }));
883
+ /** Page-side ceiling of the eval race in the extension; the bridge budget adds slack on top. */
884
+ const EVAL_MAX_MS = 120_000;
810
885
  ctx.tools.register(defineTool({
811
886
  name: 'browser_evaluate',
812
887
  description: 'Run JavaScript in the page and get the JSON result back as a string. Prefer read-only inspection.',
813
888
  parameters: {
814
889
  expression: { type: 'string', required: true, description: 'JavaScript expression or statement sequence; awaited like a promise body.' },
815
890
  tabId: { type: 'number', description: 'Target tab; defaults to the active tab.' },
891
+ timeoutMs: { type: 'number', description: `Give up after this many milliseconds; defaults to 60000, capped at ${EVAL_MAX_MS}.` },
816
892
  },
817
893
  output: {
818
894
  schema: {
@@ -827,7 +903,19 @@ function applyBrowserTools(ctx, controller) {
827
903
  },
828
904
  presentCall: () => ({ card: 'generic', title: 'Evaluate in page', kind: 'other' }),
829
905
  async execute(args, exec) {
830
- const raw = await controller.execute('eval', args.tabId === undefined ? { expression: args.expression } : { expression: args.expression, tabId: args.tabId }, exec.signal);
906
+ const params = { expression: args.expression };
907
+ if (args.tabId !== undefined)
908
+ params.tabId = args.tabId;
909
+ // The extension races the evaluation at this budget; give the bridge 2s of
910
+ // slack so the page-side timeout reports first. Omitted → send nothing and
911
+ // let the bridge default (60s) govern, exactly as before the knob existed.
912
+ let bridgeCeiling;
913
+ if (typeof args.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)) {
914
+ const evalBudget = Math.min(EVAL_MAX_MS, Math.max(100, Math.trunc(args.timeoutMs)));
915
+ params.timeoutMs = evalBudget;
916
+ bridgeCeiling = evalBudget + 2_000;
917
+ }
918
+ const raw = await controller.execute('eval', params, exec.signal, bridgeCeiling);
831
919
  let json;
832
920
  try {
833
921
  json = JSON.stringify(raw.value) ?? String(raw.value);
@@ -24,6 +24,8 @@ export const READ_ONLY_ALLOWED = new Set([
24
24
  'content', 'find', 'snapshot', 'screenshot', 'pdf', 'wait',
25
25
  // 读扩展采集到的缓冲
26
26
  'tabs.list', 'console.log', 'network.log',
27
+ // 读浏览器自己的状态(cookie 是凭据,但 get 不改任何东西;set/remove 不在此列)
28
+ 'cookies.get',
27
29
  ]);
28
30
  /**
29
31
  * 会执行**任意页面脚本**的扩展命令。这条与只读档分开管,因为用户可能想「可以读、
package/llms.txt CHANGED
@@ -30,11 +30,12 @@ Chrome 浏览器扩展 + DeepSeek Harness 插件,让 AI Agent 通过 CDP 驱
30
30
  - `/` 与 `/api/status` 免鉴权,只报链路状态
31
31
  - 两个本机策略开关(profile 的 `browser-bridge` 配置):`readOnly: true` 拒绝一切会改变页面/浏览器状态或删本机文件的命令,`allowScript: false` 单独关掉 `browser_evaluate`(它能做其它所有工具能做的事)。闸门在把命令发给扩展**之前**,所以被拒的调用零副作用;它们只约束模型那条工具面,HTTP 面按同一 token 走、不受影响,也**不是**防有权限的人
32
32
  - 扩展弹窗里的「锁定」是另一层:只有人工通道能开与关,冻结期间命令在扩展的 `runCommand` 入口就被拒绝(连接保持),读不出锁状态时按冻结处理(fail-closed)
33
+ - `browser_cookies` 的 get 会把 **cookie 值原样**放进模型可见文本(cookie 值 = 凭据,这是这条工具的定义而不是副作用);只读档放行 get、拒 set/remove
33
34
  - `browser_hold`(接管浮层)是**软层**:它告诉用户模型在操作这一页并挡住误触,用户双击即可关掉;卡片上按「中止操作」后扩展拒绝 `click`/`input`/`press`/`nav`,但模型可以调 `hold action=off` 解除。它不是安全边界,也不是防逃逸手段——要冻住浏览器用扩展弹窗里的「锁定」
34
35
 
35
36
  ## 关键文件
36
37
 
37
- - `src/index.ts` - 21 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
38
+ - `src/index.ts` - 22 个 `browser_*` 工具注册 + settings 接线的版本兼容分支
38
39
  - `src/policy.ts` - 三张互不相同的表:只读白名单、脚本命令、插件本地破坏性命令(`browser_cleanup` 不经过 `execute()`,必须自己站闸门)
39
40
  - `src/server.ts` - WebSocket 服务 + HTTP 面(鉴权、来源校验、状态页)
40
41
  - `src/ws.ts` - RFC 6455 帧编解码
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@angelky7/dsh-browser-control",
3
3
  "description": "Local WebSocket bridge between dsh and the DSH Browser Control browser extension, with model-facing browser_* tools and a Settings-managed on/off switch",
4
- "version": "1.1.0",
4
+ "version": "1.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -37,7 +37,7 @@
37
37
  "build": "tsc -p tsconfig.json && tsdown",
38
38
  "typecheck": "tsc -p tsconfig.json --noEmit",
39
39
  "check": "npm run typecheck && npm run build && publint",
40
- "verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs"
40
+ "verify": "node verify/verify_config_patch.mjs && node verify/verify_peers.mjs && node verify/verify_surface.mjs && node verify/verify_tab_groups.mjs && node verify/verify_tabs_close_many.mjs && node verify/verify_cdp_retry.mjs && node verify/verify_snapshot_ax.mjs && node verify/verify_hard_lock.mjs && node verify/verify_policy.mjs && node verify/verify_cookies.mjs && node verify/verify_extension_e2e.mjs && node verify/verify_plugin.mjs legacy && node verify/verify_plugin.mjs conflict && node verify/verify_plugin.mjs new && node verify/verify_http.mjs && node verify/verify_errors.mjs && node verify/verify_hold.mjs && node verify/verify_protocol.mjs && node verify/verify_bundle_http.mjs && node verify/verify_nav_report.mjs && node verify/verify_nav_landing.mjs && node verify/verify_render_contract.mjs && node verify/verify_press_events.mjs && node verify/verify_nav_timeout.mjs"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",