diff --git a/README.i18n.yaml b/README.i18n.yaml index 8a63c95eb..9f19ca61d 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -1,5 +1,5 @@ # Bilingual consistency record for the repository README files. # README.md is the default English page; README.zh.md is its Chinese counterpart. # Values are git blob hashes from the last confirmed-consistent review. -README.md: 1163cc9c9a7332a95f8b2448461243f0813ffdf2 -README.zh.md: 6ce7a8314f10dcd45316906f79991e0604b0814c +README.md: 56dca7702cba4338131609d12c549fcf224e3ae1 +README.zh.md: f6d2956c563b368203c7e434ef069c9342e27be9 diff --git a/README.md b/README.md index 3f743c5b9..56dca7702 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,8 @@ Local Chrome use requires no configuration; Firefox requires the local bridge to - Make sure dsh web is running locally (default `http://127.0.0.1:3080`). - Verify the bridge is loaded: open `http://127.0.0.1:3080/ext/bridge-config`. It should return JSON such as `{"wsUrl":"ws://127.0.0.1:3080/ext/bridge"}`. If it returns a web page instead of JSON, the running dsh predates the bridge registration — restart dsh and refresh the page; the extension reconnects on its own. -- The extension probes ports 3080, 3081, 3090, and 14389 automatically. If dsh runs on another port — or you use a remote `--host 0.0.0.0` deployment — set the address (and bridge token) in the panel settings. Firefox always requires the token. +- Discovery runs once when the panel opens. A filled-in address is tried first; if a **loopback** address does not answer, the panel makes one automatic pass (last working URL → `dsh web` defaults → the plugin's fixed beacon ports 43189–43198 → one sweep of the platform's ephemeral port range). When that pass finds nothing the status reads **No service found** and nothing retries until you reopen the panel or press **Detect**. +- DSH Desktop assigns a random local Web port on every launch, which is why the bridge plugin also listens on a fixed loopback beacon port. If the panel cannot find it, open **Settings → Detect** to list every local instance and click the one to use; the address can still be typed by hand. Remote `--host` deployments need the address and token filled in manually; Firefox always requires the token. ## Development diff --git a/README.zh.md b/README.zh.md index fc8fe4d45..f6d2956c5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -148,7 +148,8 @@ Chrome 本机使用无需配置;Firefox 需要填写上述本地桥 token。 - 确认本机 dsh web 正在运行(默认 `http://127.0.0.1:3080`)。 - 确认桥接已加载:浏览器打开 `http://127.0.0.1:3080/ext/bridge-config`,应返回类似 `{"wsUrl":"ws://127.0.0.1:3080/ext/bridge"}` 的 JSON。如果返回的是网页而不是 JSON,说明当前运行的 dsh 早于桥接注册——重启 dsh 并刷新页面即可,扩展会自动重连。 -- 扩展会自动探测 3080/3081/3090/14389 端口。若 dsh 运行在其它端口,或使用 `--host 0.0.0.0` 远程部署,请在面板设置中填写地址与桥接 token。Firefox 始终需要 token。 +- 面板打开时探测一次:已填地址优先尝试;回环地址连不上时再自动探测一次(上次成功的地址 → `dsh web` 默认端口 → 插件的固定信标端口 43189–43198 → 扫描系统临时端口区间)。都没找到时状态显示「未找到服务」,在重新打开面板或点「自动检测」之前不会重试。 +- DSH Desktop 每次启动都会重新随机分配本地 Web 端口,所以桥接插件同时监听一个固定回环信标端口。面板找不到时,打开**设置 → 自动检测**列出本机所有实例,点击其中一条即可填入地址;地址仍可手动填写。远程 `--host` 部署需要手动填写地址和 token;Firefox 始终需要 token。 ## 开发 diff --git a/extensions/dsh-browser/README.i18n.yaml b/extensions/dsh-browser/README.i18n.yaml index c591a0b25..fd08334de 100644 --- a/extensions/dsh-browser/README.i18n.yaml +++ b/extensions/dsh-browser/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write dsh-browser/extensions/dsh-browser/README.md -README.md: 6b6ea0b302a340fca44d6f461ebf84a7b5419c89 -README.zh.md: 36f25412f7e1431aa89a8ac41ef045c9b7676790 +README.md: 15ab9e04efa09d46679fa24d82170bbe7dd33fb9 +README.zh.md: 0c056575a7a2f1bb197f24adba35f80c0ede671c diff --git a/extensions/dsh-browser/README.md b/extensions/dsh-browser/README.md index 711efd03a..15ab9e04e 100644 --- a/extensions/dsh-browser/README.md +++ b/extensions/dsh-browser/README.md @@ -6,6 +6,7 @@ The **browser-operation end** of dsh: the model reads and operates the browser p **Two explicit channels**: browser pages are still rendered as structured text (a numbered interactive-element inventory), so browser tools never take screenshots. Separately, a dsh 0.1.2 host can advertise multimodal image limits; the side panel then accepts PNG, JPEG, WebP, and GIF attachments and renders their durable history references. + ## What the model can do | Capability | Action | Notes | @@ -21,6 +22,9 @@ The **browser-operation end** of dsh: the model reads and operates the browser p | Close tab | `browser_close_tab` | Close a listed tab | | Read region | `browser_get_text` | Lazy-loaded content / partial text | | Wait | `browser_wait` | Page load and render-settle detection | +| Inspect elements / CSS | `browser_devtools_elements` / `browser_devtools_set_element_style` / `browser_devtools_set_element_attribute` | High privilege: computed styles, box model, matched rules, live CSS and attribute edits for debugging | +| Run page JavaScript | `browser_console_eval` / `browser_console_logs` | High privilege: evaluate in the page's own context and read recorded console output | +| Capture network | `browser_devtools_network` / `browser_devtools_list_requests` / `browser_devtools_get_request` / `browser_devtools_request_body` | High privilege: captured request list, per-request detail, and bodies (credential headers redacted by default) | | Chat with images | `session.prompt` / `session.attachment` | Host-gated image selection, image-only sends, and durable history previews | | Quote what you highlight | side panel composer | The text you select in the page becomes a quote in the composer and rides along with your next message | @@ -95,7 +99,7 @@ The recommended zero-configuration command does not require Git or a local clone Both commands load the same bundle from the local `web` profile. Port 3080 is used by default; append `--port ` when it is occupied. - **DSH Desktop users**: the Desktop app assigns a random local Web port by default (`dsh-desktop.port: 0`), so port-based auto-discovery cannot predict it. Pin the port to `43189` in Desktop settings (see the [deepseek-harness-desktop user guide](https://github.com/anywhere-labs/deepseek-harness-desktop/blob/master/docs/user-guide.en.md)); auto-discovery covers that port. Alternatively, enter `http://127.0.0.1:` manually in the side-panel settings. + **DSH Desktop users**: the Desktop app launches its host with `--port 0`, so the OS assigns a different local Web port on every start and no fixed port list can predict it. The bridge plugin therefore also listens on a fixed loopback discovery beacon (`43189`–`43198`, first free port) that advertises the real bridge URL. Discovery runs once when the panel opens: a filled-in address is tried first, and only a loopback address that does not answer falls back to one automatic pass. When nothing is found the status reads "No service found" and nothing retries; open **Settings → Detect** to list every local instance and pick one, or type `http://127.0.0.1:` by hand. To pin the port instead, override the `webserver` row in `~/.dsh/profiles/desktop/cordis.patch.yml` — patch `config` is replaced wholesale, so repeat `host` as well. Loading or reloading the extension is passive: it does not probe local ports or open a WebSocket until the side panel is opened. A healthy connection established by the user may remain available for background approvals after the panel closes, but it will not reconnect without an open panel if it drops or another browser replaces it. @@ -105,6 +109,42 @@ Pages that were already open before extension installation or reload are instrum For extension-only development, load `extensions/dsh-browser/dist/` from `chrome://extensions`, or run `build:firefox` and load `extensions/dsh-browser/dist-firefox/manifest.json` from `about:debugging#/runtime/this-firefox`. Rebuild and reload after code changes. +## Composer controls: permission, model, commands, and skills + +The composer carries the same controls the Web GUI exposes, backed by Host +methods the bridge owns: + +- **Permission preset** (left chip): the deployment's preset table + (`read-only`, `workspace-write`, `danger-full-access`, plus `auto` when the + experimental Auto integration is mounted) with the Session's current value. + Selecting a preset runs the Host's own `/permission ` command, so the + `permission/preset` selection and both knob events land in the Session log + exactly as a Web GUI selection would. `Full access` and `Auto review` ask for + an explicit confirmation first; a derived `custom` state is shown but never + selectable, and the menu footnotes the default for future chats. Before the + first message creates the chat, the choice is remembered and applied at + materialization, before that first prompt is admitted. +- **Model and reasoning effort** (right chip): the Session's model directory + grouped by provider, plus the reasoning efforts the selected model declares + (`Default` included). A selection only shapes the **next** assembled turn, so + the chip stays usable while a turn is running. When the directory cannot be + read, the row says why and offers a retry instead of going silently grey; a + missed `turn/end` is repaired from the Host's `running` flag the next time the + panel becomes visible. +- **`/` menu**: typing `/` in an empty line opens the Host command directory + (`/compact`, `/permission`, `/plan`, `/goal`, `/feedback`, `/export`, …) and + the Session's user-invocable skills, including skills marked + `modelInvocable: false` (badged `User only`) that exist precisely for explicit + invocation. `↑`/`↓` move, `Enter`/`Tab` accept, `Esc` closes; picking a row + inserts its exact text so its input stays editable. On submit the panel + arbitrates exactly like the Host adapter: a command name runs through + `commands.execute` (its output never becomes a model message), a skill name is + sent as a literal prompt so the Host injects its instructions, and an + unrecognized `/…` line is **refused** with the draft preserved — start the + line with a space to send it as ordinary text. Commands take no attachments + here, and neither is a provisional chat's directory browsable: it answers + empty until the first message creates the real Session. + ## Why browser operation stays text-only - **Snapshot as the view**: the model's entire view of the page is structured text (title/URL/main/numbered elements/forms), budgeted at 32k chars by default (plugin-configurable, negotiated to the extension via `hello.ok`). @@ -117,13 +157,24 @@ For extension-only development, load `extensions/dsh-browser/dist/` from `chrome - **Unrestricted control is explicit**: **Allow unrestricted browser control** becomes active only after the setting is saved successfully, then lets page reads, page actions, and tab list/follow/close operations run without confirmation. Calls capture their access mode when received, so enabling unrestricted control never retroactively elevates an existing restricted call. Disabling it takes effect immediately, cancels calls that have not dispatched an action, waits for already-dispatched browser operations to settle, and only then saves the restrictive setting. A rapid re-enable remains restricted until that revocation finishes, and concurrent saves persist in request order. Protected-page DOM content remains inaccessible. - **Conversation continuity**: reopening the panel resumes the most recently active browser conversation by default, falling back to the latest non-empty durable session before creating a new one. This can be disabled in Settings. +## Developer tools (opt-in, high privilege) + +The developer-tools group drives the controlled tab through the Chrome debugger protocol (`chrome.debugger`), so it can inspect and edit page elements and CSS, run JavaScript in the page's own context, and capture network traffic — including response bodies, which page-level interception cannot read reliably. + +- **Off by default**: every developer-tools call raises its own approval dialog. The dialog has its own heading and can allow once, trust the group for the current side-panel session, or deny. The blanket **Allow unrestricted browser control** switch and the persistent origin allowlist never cover this group. +- **Turn it on deliberately**: **Allow developer tools** in Settings is the only way to run these calls without a per-call prompt. Turning it off detaches the debugger immediately and cancels in-flight devtools calls; the session-scoped grant is cleared the moment the setting changes. +- **One debugger per tab**: Chrome refuses a second debugger, so the browser's own DevTools cannot stay open on the same tab while capture is attached. The tool result explains this and tells you to close DevTools on that tab. +- **Data handling**: capture is in memory only, bounded to 200 requests and 500 console entries, and resets on attach and on every top-level navigation. `Authorization`, `Cookie`, `Set-Cookie`, and similar header values are shown as `[redacted]` unless a call explicitly opts in, and bodies of authentication-shaped URLs are redacted by default. +- **Platform**: Chrome only. The Firefox build does not request the `debugger` permission, so the host never registers the group there. + ## Permissions -Chrome uses `sidePanel`; Firefox uses `sidebar_action`. Both request `storage` (settings and recent-session continuity), `notifications` (optional reminders for approvals received while the panel is closed), `tabs` + `activeTab` + `scripting` (observe tab changes and inject/message the explicitly controlled tab, including lazy recovery for pages opened before install), `webNavigation` (enumerate and bind messages to that tab's frame documents), `alarms` (background keepalive), and `http/https` (content-script injection on normal pages). Firefox's AMO manifest declares the browsing activity, website content/activity, and personal communications that the add-on sends to the configured dsh/model service. The extension never changes the visible tab or silently follows a manual switch; background operation happens only after the user chooses to stay on the original tab. +Chrome uses `sidePanel`; Firefox uses `sidebar_action`. Both request `storage` (settings and recent-session continuity), `notifications` (optional reminders for approvals received while the panel is closed), `tabs` + `activeTab` + `scripting` (observe tab changes and inject/message the explicitly controlled tab, including lazy recovery for pages opened before install), `webNavigation` (enumerate and bind messages to that tab's frame documents), `alarms` (background keepalive), and `http/https` (content-script injection on normal pages). Chrome additionally requests `debugger`, which is what the opt-in developer-tools group needs; the Firefox manifest omits it, so the group stays unavailable there. Firefox's AMO manifest declares the browsing activity, website content/activity, and personal communications that the add-on sends to the configured dsh/model service. The extension never changes the visible tab or silently follows a manual switch; background operation happens only after the user chooses to stay on the original tab. ## Known limitations - Only one extension connection at a time. An unopened browser profile never claims it; if another open panel replaces a live connection, the replaced client yields instead of starting a reconnect fight. +- Developer tools are Chrome-only, need the user's setting or a per-call approval, and cannot attach while the browser's own DevTools is open on the same tab. Attaching shows Chrome's "being debugged" banner for that tab until the call finishes or the setting is turned off. - Tab affinity is global to that extension connection rather than per chat session. - Accessible cross-origin iframes are snapshotted and operated with stable `(frame, index)` addresses. Restricted or short-lived frames are reported as unavailable without failing the whole page snapshot. - Captcha/image-only controls cannot be handled — the tool result reports "elements with no accessible name" and asks the user to complete that step manually. diff --git a/extensions/dsh-browser/README.zh.md b/extensions/dsh-browser/README.zh.md index 20f886825..0c056575a 100644 --- a/extensions/dsh-browser/README.zh.md +++ b/extensions/dsh-browser/README.zh.md @@ -6,6 +6,7 @@ dsh 的**浏览器操作端**:让模型直接读取并操作你在浏览器里 **两条明确分离的通道**:浏览器页面仍以结构化文本呈现(带编号的交互元素清单),浏览器工具不会截图;另一方面,dsh 0.1.2 宿主可声明多模态图片限制,侧栏据此接收 PNG、JPEG、WebP 和 GIF,并渲染会话中的持久图片附件。 + ## 模型能做什么 | 能力 | 动作 | 说明 | @@ -21,6 +22,9 @@ dsh 的**浏览器操作端**:让模型直接读取并操作你在浏览器里 | 关闭标签页 | `browser_close_tab` | 关闭已列出的标签页 | | 读区域 | `browser_get_text` | 懒加载内容 / 局部文本 | | 等待 | `browser_wait` | 页面加载与渲染稳定检测 | +| 查看元素与 CSS | `browser_devtools_elements` / `browser_devtools_set_element_style` / `browser_devtools_set_element_attribute` | 高权限:计算样式、盒模型、命中规则,并可现场修改 CSS 与属性来调试 | +| 执行页面 JS | `browser_console_eval` / `browser_console_logs` | 高权限:在页面自身上下文执行代码,并读取记录的控制台输出 | +| 网络抓包 | `browser_devtools_network` / `browser_devtools_list_requests` / `browser_devtools_get_request` / `browser_devtools_request_body` | 高权限:已捕获请求列表、单条详情与正文(凭据类请求头默认脱敏) | | 图片对话 | `session.prompt` / `session.attachment` | 按宿主能力启用图片选择、纯图片发送和持久历史预览 | | 引用你划选的内容 | 侧栏输入框 | 你在页面里选中的文字会变成输入框里的引用,随下一条消息一起发送 | @@ -95,7 +99,7 @@ pnpm --filter dsh-browser-extension run test 两种命令都会从本机 `web` profile 加载同一个 bundle。默认端口为 3080;如被占用,可追加 `--port `。 - **DSH Desktop 用户**:桌面版默认让系统随机分配本地 Web 端口(`dsh-desktop.port: 0`),自动探测无法预知随机端口。请在桌面版设置中把端口固定为 `43189`(见 [deepseek-harness-desktop 用户指南](https://github.com/anywhere-labs/deepseek-harness-desktop/blob/master/docs/user-guide.md)),扩展的自动探测会覆盖该端口;或直接在侧栏设置中手动填写 `http://127.0.0.1:<端口>`。 + **DSH Desktop 用户**:桌面版启动 Host 时用 `--port 0`,本地 Web 端口每次启动都由系统重新分配,固定端口列表无法预知。因此桥接插件会同时在固定的回环信标端口(`43189`–`43198`,取第一个空闲端口)上通告真实桥地址。面板打开时探测一次:已填地址优先尝试,只有回环地址连不上时才自动探测一次。都没找到时状态显示「未找到服务」,不再重试;此时打开**设置 → 自动检测**列出本机所有实例并点选,或直接手填 `http://127.0.0.1:<端口>`。若想固定端口,可在 `~/.dsh/profiles/desktop/cordis.patch.yml` 覆盖 `webserver` 行——patch 的 `config` 是整体替换,务必把 `host` 一起写出。 加载或重新加载扩展本身是被动的:只有打开侧栏后,扩展才会探测本机端口并创建 WebSocket。用户已建立的健康连接可在侧栏关闭后继续用于后台审批;但连接一旦掉线或被另一浏览器替换,没有打开侧栏时就不会重连。 @@ -105,6 +109,14 @@ pnpm --filter dsh-browser-extension run test 如果只开发扩展,Chrome 从 `chrome://extensions` 加载 `extensions/dsh-browser/dist/`;Firefox 运行 `build:firefox` 后,从 `about:debugging#/runtime/this-firefox` 加载 `extensions/dsh-browser/dist-firefox/manifest.json`。代码更新后需重新构建并重新加载。 +## 输入区控制:权限模式、模型、命令与 Skill + +输入区提供与 Web GUI 对齐的控件,全部由桥插件自有的 Host 方法支撑: + +- **权限模式**(左侧 chip):部署的预设表(`read-only`、`workspace-write`、`danger-full-access`,实验 Auto integration 挂载时还有 `auto`)加上当前会话的取值。选择预设会执行宿主自己的 `/permission ` 命令,因此 `permission/preset` 选择与两个旋钮事件都会像 Web GUI 选择一样写入会话日志。「完全权限」与「Auto review」会先要求显式确认;推导态 `custom` 只作展示、不可选;菜单底部提示新会话的默认值。首次发送消息创建会话之前,选择会被记住,并在 materialize 时、第一条提示词准入之前应用。 +- **模型与推理强度**(右侧 chip):按 provider 分组的模型目录,以及当前模型声明的推理强度(含「默认」)。选择只影响**下一轮**装配,因此回合运行中 chip 依然可用。目录读取失败时该行会说明原因并提供重试,而不是静默变灰;漏掉的 `turn/end` 会在面板下次变为可见时按宿主的 `running` 标记纠正。 +- **`/` 选单**:在空行输入 `/` 会打开宿主命令目录(`/compact`、`/permission`、`/plan`、`/goal`、`/feedback`、`/export` 等)与该会话的用户可调用 skill,其中包含标记为 `modelInvocable: false` 的 skill(带「仅用户可调用」角标)——它们正是为显式调用而存在。`↑`/`↓` 移动、`Enter`/`Tab` 接受、`Esc` 关闭;选中某行会插入其确切文本,方便继续补参数。提交时面板按与宿主适配器一致的规则裁决:命令名走 `commands.execute`(其输出永远不会变成模型消息),skill 名按字面文本发送(由宿主注入 skill 正文),无法识别的 `/…` 行会被**拒绝**且草稿保留——若想按普通文本发送,请在行首加一个空格。命令不支持附件;临时会话(尚未创建)的命令/skill 目录在第一条消息创建会话前返回空。 + ## 为什么浏览器操作仍采用纯文本 - **快照即视图**:模型对页面的全部认知 = 结构化文本(标题/URL/正文/编号元素/表单),默认预算 32k 字符(插件可配,经 `hello.ok` 协商给扩展)。 @@ -117,13 +129,24 @@ pnpm --filter dsh-browser-extension run test - **完全控制需显式开启**:**允许模型完全控制浏览器**只有设置保存成功后才会生效,启用后页面读取、页面操作以及标签页列出/跟随/关闭均无需确认。调用在收到时固定其访问模式,因此开启完全控制不会追溯提升已经开始的受限调用。关闭会立即生效:取消尚未下发操作的调用,等待已经下发到浏览器的操作完成,再保存限制设置。快速重新开启也会在旧权限撤销完成前保持受限,并发保存会按请求顺序落盘。受保护页面的 DOM 内容始终不可访问。 - **会话续接**:重新打开侧栏时默认恢复最近活跃的浏览器会话;若该会话不可用,则恢复最新的非空持久会话,最后才创建新会话。可在设置中关闭。 +## 开发者工具(需显式开启,高权限) + +开发者工具组通过 Chrome 调试协议(`chrome.debugger`)驱动受控标签页,因此可以查看并修改页面元素与 CSS、在页面自身上下文执行 JavaScript、以及抓取网络请求(含页面层拦截拿不可靠的响应正文)。 + +- **默认关闭**:每次开发者工具调用都会单独弹出审批。该弹窗有独立标题,可选择仅允许这一次、本次会话信任该组、或拒绝。**允许模型完全控制浏览器**开关与持久化 origin 白名单**都不会**覆盖这一组。 +- **需要显式开启**:只有设置里的**允许开发者工具**开启后,这些调用才会免除逐次确认。关闭会立即断开调试器并中断进行中的开发者工具调用;设置一变,本次会话的临时信任也同时清空。 +- **每个标签页只允许一个调试器**:Chrome 会拒绝第二个调试器,因此附加期间浏览器自带的开发者工具无法在同一标签页打开。工具返回会说明原因并提示先关闭该标签页的 DevTools。 +- **数据处理**:抓包只存在内存中,上限为 200 条请求与 500 条控制台记录,附加时与每次顶层导航时都会重置。`Authorization`、`Cookie`、`Set-Cookie` 等请求头默认显示为 `[redacted]`,除非某次调用显式选择;疑似认证 URL 的正文默认脱敏。 +- **平台**:仅 Chrome。Firefox 构建不申请 `debugger` 权限,因此宿主也不会在该平台注册这一组工具。 + ## 权限说明 -Chrome 使用 `sidePanel`,Firefox 使用 `sidebar_action`。两者都申请 `storage`(设置与最近会话续接)、`notifications`(侧栏关闭时可选的审批提醒)、`tabs` + `activeTab` + `scripting`(观察切页,并向用户显式选择的受控标签页注入/发消息;安装前已打开的页面也会按需补注入)、`webNavigation`(枚举该标签页中的 frame,并把消息绑定到具体文档)、`alarms`(后台保活)和 `http/https`(内容脚本注入普通网页)。Firefox AMO manifest 如实声明扩展会把浏览活动、网页内容/操作和对话内容发送给用户配置的 dsh/模型服务。扩展绝不改变用户正在看的标签页,也不会静默跟随手动切页;只有用户选择继续原页面后,助手才会在后台操作。 +Chrome 使用 `sidePanel`,Firefox 使用 `sidebar_action`。两者都申请 `storage`(设置与最近会话续接)、`notifications`(侧栏关闭时可选的审批提醒)、`tabs` + `activeTab` + `scripting`(观察切页,并向用户显式选择的受控标签页注入/发消息;安装前已打开的页面也会按需补注入)、`webNavigation`(枚举该标签页中的 frame,并把消息绑定到具体文档)、`alarms`(后台保活)和 `http/https`(内容脚本注入普通网页)。Chrome 额外申请 `debugger`,这是可选的开发者工具组所需;Firefox manifest 不申请该权限,因此该组在 Firefox 上始终不可用。Firefox AMO manifest 如实声明扩展会把浏览活动、网页内容/操作和对话内容发送给用户配置的 dsh/模型服务。扩展绝不改变用户正在看的标签页,也不会静默跟随手动切页;只有用户选择继续原页面后,助手才会在后台操作。 ## 已知限制 - 同时只有一个扩展连接桥。未打开侧栏的浏览器 Profile 不会抢占连接;另一个已打开的侧栏顶替连接后,被替换的一端会主动让权,不再反复重连互踢。 +- 开发者工具仅 Chrome 可用,需要用户开启设置或逐次审批,且在同一标签页开着浏览器自带 DevTools 时无法附加。附加期间该标签页会显示 Chrome 的“正在调试此浏览器”提示,直到调用结束或设置关闭。 - 标签页绑定属于整个扩展连接,而不是单个对话会话。 - 可访问的跨源 iframe 会进入快照,并通过稳定的 `(frame, index)` 地址执行操作;受保护或已销毁的 frame 会标记为不可访问,不影响整页快照。 - 验证码/纯图片按钮无法处理——工具结果会标注"存在无文本可访问名的元素",提示用户手动完成该步。 diff --git a/extensions/dsh-browser/manifest.firefox.json b/extensions/dsh-browser/manifest.firefox.json index 8d6dda156..3ae20857d 100644 --- a/extensions/dsh-browser/manifest.firefox.json +++ b/extensions/dsh-browser/manifest.firefox.json @@ -28,8 +28,7 @@ "notifications" ], "host_permissions": [ - "http://*/*", - "https://*/*" + "" ], "background": { "scripts": [ diff --git a/extensions/dsh-browser/manifest.json b/extensions/dsh-browser/manifest.json index d1311c27b..57d24636d 100644 --- a/extensions/dsh-browser/manifest.json +++ b/extensions/dsh-browser/manifest.json @@ -13,11 +13,11 @@ "scripting", "webNavigation", "alarms", - "notifications" + "notifications", + "debugger" ], "host_permissions": [ - "http://*/*", - "https://*/*" + "" ], "background": { "service_worker": "background.js", diff --git a/extensions/dsh-browser/src/background/authorization.ts b/extensions/dsh-browser/src/background/authorization.ts index d263a0587..c9f316fe6 100644 --- a/extensions/dsh-browser/src/background/authorization.ts +++ b/extensions/dsh-browser/src/background/authorization.ts @@ -2,7 +2,7 @@ import type { ToolCall } from './tools.ts' import type { TabFrame } from './frames.ts' -import type { ApprovalPrompt } from '../security/approval.ts' +import type { ApprovalAuthorization, ApprovalPrompt } from '../security/approval.ts' import { getUiLocale, type UiLocale } from '../i18n.ts' const PAGE_READS = new Set(['browser_snapshot', 'browser_get_text']) @@ -17,6 +17,100 @@ const STATE_CHANGING_ACTIONS = new Set([ 'browser_reload', ]) +/** + * The developer-tools group. These calls drive the page through the debugger + * (CDP) instead of the content script, so they never consult the ordinary + * origin allowlist: only the explicit developer-tools setting, a session grant + * the user made in this browser, or a fresh per-call approval can pass. + */ +export const DEVTOOLS_TOOLS = new Set([ + 'browser_devtools_elements', + 'browser_devtools_set_element_style', + 'browser_devtools_set_element_attribute', + 'browser_console_eval', + 'browser_console_logs', + 'browser_devtools_network', + 'browser_devtools_list_requests', + 'browser_devtools_get_request', + 'browser_devtools_request_body', +]) + +/** Whether a tool belongs to the developer-tools group. */ +export function isDevToolsTool(name: string): boolean { + return DEVTOOLS_TOOLS.has(name) +} + +/** + * The approval prompt for one developer-tools call. + * + * Every devtools call is high privilege, so the prompt always renders and can + * never be satisfied by the origin allowlist or unrestricted access. Only the + * model-facing presentation differs between read-shaped and action-shaped calls. + * @param call - the tool call awaiting a decision. + * @param frames - the controlled tab's frames, for the origin list. + * @param locale - UI locale for the summary text. + * @returns the prompt handed to the side panel. + */ +export function devToolsApprovalForCall( + call: ToolCall, + frames: TabFrame[], + locale: UiLocale = getUiLocale(), +): ApprovalPrompt { + const destructive = call.name === 'browser_devtools_set_element_style' + || call.name === 'browser_devtools_set_element_attribute' + || call.name === 'browser_console_eval' + return { + kind: destructive ? 'action' : 'read', + action: call.name, + summary: summarizeDevToolsAction(call, locale), + origins: uniqueOrigins(frames.filter((frame) => frame.frameId === 0), frames), + // Session trust for devtools is granted through the dedicated prompt path, + // never as a blanket "trust this origin for every browser action". + canTrust: false, + devTools: true, + } +} + +function summarizeDevToolsAction(call: ToolCall, locale: UiLocale): string { + const detail = inlineArg(call) + switch (call.name) { + case 'browser_devtools_elements': + return localized(locale, `Inspect page element (${detail})`, `查看页面元素(${detail})`) + case 'browser_devtools_set_element_style': + return localized(locale, `Modify live CSS on page element (${detail})`, `修改页面元素 CSS(${detail})`) + case 'browser_devtools_set_element_attribute': + return localized(locale, `Change page element attribute (${detail})`, `修改页面元素属性(${detail})`) + case 'browser_console_eval': + return localized( + locale, + `Run JavaScript in the page (${typeof call.args.expression === 'string' ? `${call.args.expression.length} characters, not shown here}` : 'expression not shown'})`, + `在页面中执行 JavaScript(${typeof call.args.expression === 'string' ? `${call.args.expression.length} 个字符,此处不显示内容` : '表达式内容不显示'})`, + ) + case 'browser_console_logs': + return localized(locale, 'Read the page console output', '读取页面控制台输出') + case 'browser_devtools_network': + return localized(locale, `Control network capture (${safeInline(typeof call.args.action === 'string' ? call.args.action : 'status')})`, `控制网络抓包(${safeInline(typeof call.args.action === 'string' ? call.args.action : 'status')})`) + case 'browser_devtools_list_requests': + return localized(locale, 'List captured network requests', '列出已捕获的网络请求') + case 'browser_devtools_get_request': + return localized(locale, `Read one captured request in full, including headers (index ${safeInline(String(call.args.index ?? '?'))})`, `查看单条网络请求的完整内容,含请求头(序号 ${safeInline(String(call.args.index ?? '?'))})`) + case 'browser_devtools_request_body': + return localized(locale, `Read a captured request or response body (index ${safeInline(String(call.args.index ?? '?'))})`, `读取已捕获的请求或响应正文(序号 ${safeInline(String(call.args.index ?? '?'))})`) + default: + return call.name + } +} + +/** A short, non-secret hint about which element a call targets. */ +function inlineArg(call: ToolCall): string { + if (typeof call.args.selector === 'string' && call.args.selector !== '') return safeInline(call.args.selector) + if (typeof call.args.index === 'number') { + const frame = typeof call.args.frame === 'number' && call.args.frame !== 0 ? `, frame ${call.args.frame}` : '' + return `snapshot index ${call.args.index}${frame}` + } + return 'no selector given' +} + /** Return an approval prompt, or undefined when this call needs no prompt. */ export function approvalPromptForCall( call: ToolCall, @@ -24,11 +118,23 @@ export function approvalPromptForCall( frames: TabFrame[], locale: UiLocale = getUiLocale(), ): ApprovalPrompt | undefined { + // A screenshot photographs exactly what the user sees — passwords, private + // messages, account balances included — so it always prompts, regardless of + // the page-content sharing preference. Only unrestricted access skips it. + if (call.name === 'browser_screenshot') { + return { + kind: 'read', + action: call.name, + summary: localized(locale, 'Capture a screenshot of the current page (may contain sensitive content)', '截取当前页面的可视区域截图(可能包含敏感信息)'), + origins: uniqueOrigins(frames, frames), + canTrust: false, + } + } if (PAGE_READS.has(call.name)) { if (sharePageContent !== 'ask') return undefined - const targetFrames = call.name === 'browser_snapshot' - ? frames - : frames.filter((frame) => frame.frameId === requestedFrame(call.args)) + const targetFrames = call.name === 'browser_get_text' + ? frames.filter((frame) => frame.frameId === requestedFrame(call.args)) + : frames return { kind: 'read', action: call.name, @@ -169,3 +275,36 @@ function safeInline(value: string, maxLength = 40): string { const inline = value.replace(/\s+/g, ' ').trim() return inline.length <= maxLength ? inline : `${inline.slice(0, maxLength - 1)}…` } + +/** Inputs the approval gate evaluates before a prompt is even sent. */ +export interface ApprovalGateInput { + prompt: ApprovalPrompt + /** Persisted "allow developer tools" setting. */ + devToolsAllowed: boolean + /** Ephemeral devtools grant the user made in this side-panel session. */ + devToolsSessionGrant: boolean + /** The user's blanket browser-control switch. */ + unrestrictedAccess: boolean + /** Result of the persistent + session origin allowlist for ordinary actions. */ + coveredByTrustedOrigins: boolean +} + +/** + * Decide whether an approval prompt can be skipped. + * + * Developer tools are their own consent surface: unrestricted access and the + * persistent origin allowlist deliberately do not cover them, so enabling + * either can never hand the model the debugger. Ordinary actions keep the + * existing shortcut semantics. + * + * @param input - the prompt plus the policy state it is evaluated against. + * @returns the authorization to apply, or `'prompt'` when the user must decide. + */ +export function resolveApprovalGate(input: ApprovalGateInput): ApprovalAuthorization | 'prompt' { + if (input.prompt.devTools === true) { + if (input.devToolsSessionGrant || input.devToolsAllowed) return 'approved' + return 'prompt' + } + if (input.unrestrictedAccess || input.coveredByTrustedOrigins) return 'approved' + return 'prompt' +} diff --git a/extensions/dsh-browser/src/background/bridge.ts b/extensions/dsh-browser/src/background/bridge.ts index 33c9da70a..f26e778bf 100644 --- a/extensions/dsh-browser/src/background/bridge.ts +++ b/extensions/dsh-browser/src/background/bridge.ts @@ -51,6 +51,13 @@ export class BridgeClient { private readonly probe: BridgeProbe = async () => true, /** Whether a disconnected client still has an active user-owned lease. */ private readonly shouldReconnect: () => boolean = () => true, + /** + * Capabilities this build can serve beyond the text snapshot contract. + * Advertised on every `hello`: the host registers the developer-tools group + * only for an extension that claims it, and the user's own setting still + * gates every call. + */ + private readonly capabilities: Pick = {}, ) {} /** Current coarse state (mirrors the last emitted sink value). */ @@ -152,7 +159,12 @@ export class BridgeClient { socket.send(JSON.stringify({ t: 'hello', token: this.token, - caps: { textOnly: true, snapshotMaxChars: DEFAULT_SNAPSHOT_MAX_CHARS, maxInteractiveItems: 60 }, + caps: { + textOnly: true, + snapshotMaxChars: DEFAULT_SNAPSHOT_MAX_CHARS, + maxInteractiveItems: 60, + ...this.capabilities, + }, } satisfies ClientFrame)) let authed = false diff --git a/extensions/dsh-browser/src/background/debugger-session.ts b/extensions/dsh-browser/src/background/debugger-session.ts new file mode 100644 index 000000000..058a04d09 --- /dev/null +++ b/extensions/dsh-browser/src/background/debugger-session.ts @@ -0,0 +1,271 @@ +/** + * Chrome debugger (CDP) session for one controlled tab. + * + * The developer-tools features are the only consumers: element/CSS inspection + * and edits, page-context JavaScript evaluation plus recorded console output, + * and network capture. One attachment per tab; every command is bounded by a + * timeout and settles on detach so a tool call can never hang on a dead + * session. + * + * Chrome refuses to attach while DevTools is open on the same tab; that case is + * reported as {@link DevToolsError} code `devtools-conflict` with an actionable + * message instead of a raw protocol error. + * + * @module + */ + +/** Stable failure codes the devtools tools map onto model-facing errors. */ +export type DevToolsErrorCode = + | 'devtools-unavailable' + | 'devtools-conflict' + | 'devtools-detached' + | 'devtools-timeout' + | 'devtools-command-failed' + +/** Typed devtools failure; `code` is the machine identity, `message` the human text. */ +export class DevToolsError extends Error { + constructor( + readonly code: DevToolsErrorCode, + message: string, + ) { + super(message) + this.name = 'DevToolsError' + } +} + +/** One `chrome.debugger.sendCommand` call, structurally typed for tests. */ +export type SendCommand = (target: { tabId: number }, method: string, params?: Record) => Promise + +/** The CDP command surface every developer-tools feature module depends on. */ +export interface CdpClient { + send(method: string, params?: Record): Promise +} + +/** The subset of `chrome.debugger` this module uses. */ +export interface DebuggerApi { + attach(target: { tabId: number }, version: string): Promise + detach(target: { tabId: number }): Promise + sendCommand: SendCommand + onEvent: { + addListener(listener: (source: { tabId?: number }, method: string, params?: unknown) => void): void + removeListener(listener: (source: { tabId?: number }, method: string, params?: unknown) => void): void + } + onDetach: { + addListener(listener: (source: { tabId?: number }, reason: string) => void): void + removeListener(listener: (source: { tabId?: number }, reason: string) => void): void + } +} + +/** CDP protocol version negotiated by the attachment. */ +const CDP_VERSION = '1.3' + +/** Per-command budget; a stalled page must fail the tool call, not hang it. */ +const DEFAULT_COMMAND_TIMEOUT_MS = 10_000 + +/** Bounded network buffers so a long page session cannot exhaust extension memory. */ +const NETWORK_BUFFERS = { maxTotalBufferSize: 20_000_000, maxResourceBufferSize: 5_000_000, maxPostDataSize: 1_000_000 } + +/** Observer hooks the devtools feature modules subscribe to. */ +export interface DevToolsSessionListeners { + onCdpEvent?(method: string, params: unknown): void + onDetached?(reason: string): void +} + +/** One dispatched command awaiting its single settle. */ +interface PendingCommand { + settle(error?: DevToolsError, value?: unknown): void + timer: ReturnType +} + +/** Read the browser's debugger API, or undefined when the platform lacks it. */ +export function debuggerApi(): DebuggerApi | undefined { + const candidate = (globalThis as { chrome?: { debugger?: unknown } }).chrome?.debugger as DebuggerApi | undefined + if (candidate === undefined) return undefined + return typeof candidate.attach === 'function' && typeof candidate.sendCommand === 'function' ? candidate : undefined +} + +/** Whether the running browser can serve the developer-tools group at all. */ +export function devToolsSupported(): boolean { + return debuggerApi() !== undefined +} + +/** + * One debugger attachment. Instances are not shared across tabs: the session + * owns exactly one tab and refuses commands after it detaches. + */ +export class DebuggerSession { + private pending = new Set() + private attached = false + private paused = false + private disposeEvents: (() => void) | undefined + private enabled: string[] = [] + + constructor( + readonly tabId: number, + private readonly api: DebuggerApi, + private readonly listeners: DevToolsSessionListeners = {}, + private readonly commandTimeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS, + ) {} + + /** @returns whether this session currently holds the tab's debugger. */ + get isAttached(): boolean { + return this.attached + } + + /** Mark the session paused (setting revoked): commands fail until it re-attaches. */ + pause(): void { + this.paused = true + } + + /** Allow commands again after a re-attach. */ + resume(): void { + this.paused = false + } + + /** + * Attach and enable the domains this feature set needs. Idempotent. + * @throws {DevToolsError} when another debugger owns the tab or the platform refuses. + */ + async attach(): Promise { + if (this.attached) return + try { + await this.api.attach({ tabId: this.tabId }, CDP_VERSION) + } catch (error: unknown) { + throw mapAttachFailure(error) + } + this.attached = true + this.paused = false + this.bindEvents() + this.enabled = [] + try { + await this.send('DOM.enable', {}) + this.enabled.push('DOM') + await this.send('CSS.enable', {}) + this.enabled.push('CSS') + await this.send('Runtime.enable', {}) + this.enabled.push('Runtime') + await this.send('Network.enable', NETWORK_BUFFERS) + this.enabled.push('Network') + } catch (error: unknown) { + // A partial attachment is worse than none: the caller retries cleanly. + await this.detach() + throw error + } + } + + /** CDP domains this session successfully enabled. */ + get domains(): readonly string[] { + return [...this.enabled] + } + + /** + * Send one CDP command. + * @param method - CDP method name. + * @param params - CDP parameters. + * @returns the command result. + * @throws {DevToolsError} on timeout, detach, or a CDP error response. + */ + send(method: string, params: Record = {}): Promise { + if (!this.attached || this.paused) { + return Promise.reject(new DevToolsError('devtools-detached', 'The developer-tools session is not attached to the page.')) + } + return new Promise((resolve, reject) => { + let settled = false + const settle = (error?: DevToolsError, value?: unknown): void => { + if (settled) return + settled = true + clearTimeout(timer) + this.pending.delete(entry) + if (error === undefined) resolve(value) + else reject(error) + } + const timer = setTimeout(() => { + settle(new DevToolsError( + 'devtools-timeout', + `Developer-tools command ${method} did not answer within ${this.commandTimeoutMs}ms.`, + )) + }, this.commandTimeoutMs) + const entry: PendingCommand = { settle, timer } + this.pending.add(entry) + let invocation: Promise + try { + invocation = this.api.sendCommand({ tabId: this.tabId }, method, params) as Promise + } catch (error: unknown) { + settle(toDevToolsError(error, method)) + return + } + if (invocation === undefined || typeof invocation.then !== 'function') { + // A callback-only API surface cannot be awaited here, so fail fast + // instead of leaving the caller waiting for the timeout. + settle(new DevToolsError('devtools-command-failed', `Developer-tools command ${method} could not be dispatched.`)) + return + } + invocation.then( + (value) => { settle(undefined, value) }, + (error: unknown) => { settle(toDevToolsError(error, method)) }, + ) + }) + } + + /** Detach and release every pending command. Safe to call repeatedly. */ + async detach(): Promise { + if (!this.attached) return + this.attached = false + this.releasePending('The developer-tools session detached from the page.') + this.releaseEvents() + try { + await this.api.detach({ tabId: this.tabId }) + } catch { + // Already detached (tab closed, Chromium took it over): nothing to undo. + } + } + + private releasePending(reason: string): void { + const failure = new DevToolsError('devtools-detached', reason) + for (const entry of [...this.pending]) entry.settle(failure) + } + + private bindEvents(): void { + const onEvent = (source: { tabId?: number }, method: string, params?: unknown): void => { + if (source.tabId !== this.tabId) return + this.listeners.onCdpEvent?.(method, params) + } + const onDetach = (source: { tabId?: number }, reason: string): void => { + if (source.tabId !== this.tabId) return + this.attached = false + this.releasePending(`The developer-tools session detached from the page (${reason}).`) + this.releaseEvents() + this.listeners.onDetached?.(reason) + } + this.api.onEvent.addListener(onEvent) + this.api.onDetach.addListener(onDetach) + this.disposeEvents = () => { + this.api.onEvent.removeListener(onEvent) + this.api.onDetach.removeListener(onDetach) + } + } + + private releaseEvents(): void { + this.disposeEvents?.() + this.disposeEvents = undefined + } +} + +/** Map an attach failure onto the actionable devtools error vocabulary. */ +export function mapAttachFailure(error: unknown): DevToolsError { + const detail = error instanceof Error ? error.message : String(error) + if (/another debugger|already attached|debugger is already/i.test(detail)) { + return new DevToolsError( + 'devtools-conflict', + 'Chrome DevTools (or another debugger) is currently attached to this tab. ' + + 'Close DevTools for this tab and retry, because a page can only be debugged by one client at a time.', + ) + } + return new DevToolsError('devtools-command-failed', `The debugger could not attach to this tab: ${detail}`) +} + +/** Map a CDP command rejection onto the devtools error vocabulary. */ +export function toDevToolsError(error: unknown, method: string): DevToolsError { + const detail = error instanceof Error ? error.message : String(error) + return new DevToolsError('devtools-command-failed', `Developer-tools command ${method} failed: ${detail}`) +} diff --git a/extensions/dsh-browser/src/background/devtools-controller.ts b/extensions/dsh-browser/src/background/devtools-controller.ts new file mode 100644 index 000000000..fa57f3c3c --- /dev/null +++ b/extensions/dsh-browser/src/background/devtools-controller.ts @@ -0,0 +1,764 @@ +/** + * The developer-tools controller: owns the debugger sessions, folds CDP events + * into the network/console stores, and implements the nine devtools operations + * as text-producing handlers. + * + * Everything volatile (tabs, attachments, buffers) lives here, and the Chrome + * debugger API is injected, so the whole capability is testable against a + * scripted debugger surface. + * + * @module + */ + +import { + DebuggerSession, + DevToolsError, + debuggerApi, + devToolsSupported, +} from './debugger-session.ts' +import { + NetworkCapture, + type NetworkEntry, +} from './devtools-network.ts' +import { + FrameRegistry, + attributeRisk, + attributeValue, + frameContextId, + inspectElement, + resolveNodeId, + setAttribute, + setInlineStyle, + setMatchedRuleStyle, + shortIdentity, + syncFrames, +} from './devtools-elements.ts' +import { + REDACTED, + formatBody, + formatEvaluation, + isSensitiveBodyUrl, + truncateText, +} from './devtools-serialization.ts' +import { + renderAttributeResult, + renderBody, + renderCaptureState, + renderConsoleLogs, + renderElement, + renderRequestDetail, + renderRequestList, + renderStyleEdit, +} from './devtools-format.ts' + +/** Default output budget for one developer-tools result. */ +export const DEFAULT_DEVTOOLS_OUTPUT_CHARS = 24_000 + +/** Response/request body bytes fetched per call; CDP buffers keep them bounded. */ +const BODY_CHAR_CAP = 65_536 + +/** One controlled tab the developer tools can attach to. */ +export interface DevToolsTarget { + tabId: number + url?: string + title?: string +} + +/** Runtime facts the controller needs from its owner (the background worker). */ +export interface DevToolsControllerOptions { + /** Resolve the tab the calling session is bound to. */ + resolveTarget: (sessionId?: string) => Promise + /** Debugger surface override for tests; defaults to the real `chrome.debugger`. */ + api?: import('./debugger-session.ts').DebuggerApi + /** Output budget override. */ + maxChars?: number + /** Command timeout override (tests). */ + commandTimeoutMs?: number + /** Notified whenever attachment state changes, so the panel can reflect it. */ + onStateChange?: (state: DevToolsState) => void +} + +/** Attachment state reported to the user interfaces. */ +export interface DevToolsState { + supported: boolean + attached: boolean + tabId?: number + error?: string +} + +/** The controller surface the background dispatcher uses. */ +export interface DevToolsController { + /** + * Whether the platform can serve developer tools at all. + * + * User consent is deliberately NOT checked here: the background's approval + * gate decides (a per-call dialog, or the user's standing allowance), and a + * second hard gate would make that dialog unreachable. + */ + supported(): boolean + /** Current attachment state. */ + state(): DevToolsState + /** Handle one devtools tool call by name. */ + handle(name: string, args: Record, sessionId?: string): Promise<{ text: string }> + /** Drop the attachment for a tab (tab closed, binding changed). */ + releaseTab(tabId: number): void + /** Detach everything and disable further commands (setting revoked). */ + revoke(): Promise + /** Detach every attachment while keeping the capability usable. */ + detachAll(): Promise + /** Allow commands again after the user re-enables the setting. */ + resume(): void + /** + * Attach to a tab ahead of any tool call so network capture starts before the + * page issues traffic. Only called once the user has enabled the allowance. + */ + prewarm(tabId: number): Promise +} + +interface TabContext { + tabId: number + session: DebuggerSession + capture: NetworkCapture + frames: FrameRegistry + detachedReason?: string + /** Per-method CDP event tally, so a silent capture can be explained. */ + cdpEvents: Record + /** CDP domains this session enabled successfully. */ + domains: string[] + /** Which `Network.*` event kinds this page emitted, and how often. */ + networkEvents: Record +} + +/** + * Browser-internal pages the debugger API refuses, by URL scheme. + * + * Detecting them up front turns an opaque `chrome.debugger` failure ("Cannot + * access a chrome:// URL") into an instruction the model and the user can act + * on: switch the controlled tab to a normal page. + */ +const UNDEBUGGABLE_SCHEME = /^(chrome|chrome-untrusted|devtools|edge|brave|about):/i + +/** + * Whether the controlled tab can be served by a debugger at all. + * @param url - the controlled tab's URL, when known. + * @returns undefined when debuggable, or the user-facing reason. + */ +export function undebuggableReason(url: string | undefined): string | undefined { + if (url === undefined || url === '') return undefined + const scheme = UNDEBUGGABLE_SCHEME.exec(url)?.[1] + if (scheme === undefined) return undefined + return `This page cannot be debugged: chrome.debugger refuses ${scheme}:// pages. ` + + 'Switch the controlled tab to a normal http(s) page and retry.' +} + +/** + * Create the developer-tools controller. + * @param options - runtime dependencies and limits. + * @returns the controller. + */ +export function createDevToolsController(options: DevToolsControllerOptions): DevToolsController { + const api = options.api ?? debuggerApi() + const maxChars = options.maxChars ?? DEFAULT_DEVTOOLS_OUTPUT_CHARS + const contexts = new Map() + let revoked = false + + const supported = (): boolean => api !== undefined && devToolsSupported() + + /** Attachment state that outlives a detached context, so status stays truthful. */ + const attachment: { tabId?: number; attached: boolean; reason?: string } = { attached: false } + + const state = (): DevToolsState => ({ + supported: supported(), + attached: attachment.attached, + ...(attachment.tabId === undefined ? {} : { tabId: attachment.tabId }), + ...(attachment.reason === undefined ? {} : { error: attachment.reason }), + }) + + const markAttached = (tabId: number): void => { + attachment.tabId = tabId + attachment.attached = true + attachment.reason = undefined + } + + const markDetached = (reason?: string): void => { + attachment.attached = false + if (reason !== undefined) attachment.reason = reason + } + + /** + * Replace the reported failure. A fresh attach attempt clears an older error + * instead of leaving it next to a switch the user has since enabled, while a + * refusal reports its own cause so the user knows what to change. + */ + const clearFailure = (reason?: string): void => { + attachment.reason = reason + } + + const notify = (): void => { options.onStateChange?.(state()) } + + /** + * Capture state for a status question. The background can ask this after a + * `stop` dropped the tab context, so it falls back to the shared attachment. + */ + const statusSnapshot = (tabId?: number): { attached: boolean; tabId?: number; entries: number; generation: number } => { + const resolved = tabId ?? attachment.tabId + const context = resolved === undefined ? undefined : contexts.get(resolved) + const capture = context?.capture.state() + return { + attached: context === undefined ? false : context.capture.state().attached && attachment.attached, + ...(resolved === undefined ? {} : { tabId: resolved }), + entries: capture?.entries ?? 0, + generation: capture?.generation ?? 0, + } + } + + const contextFor = async (tabId: number): Promise => { + const existing = contexts.get(tabId) + if (existing !== undefined) { + if (!existing.session.isAttached) { + // Only a real re-attach invalidates the capture; an already attached + // session keeps accumulating without losing the model's indices. + await attach(existing, !existing.capture.state().attached) + } + return existing + } + const session = new DebuggerSession( + tabId, + api!, + { + onCdpEvent: (method, params) => { handleCdpEvent(contexts.get(tabId), method, params) }, + onDetached: (reason) => { + const context = contexts.get(tabId) + if (context !== undefined) context.detachedReason = reason + markDetached(reason) + notify() + }, + }, + options.commandTimeoutMs, + ) + const context: TabContext = { + tabId, + session, + capture: new NetworkCapture(), + frames: new FrameRegistry(), + cdpEvents: {}, + domains: [], + networkEvents: {}, + } + contexts.set(tabId, context) + await attach(context, true) + return context + } + + const attach = async (context: TabContext, resetCapture: boolean): Promise => { + // A new attempt supersedes whatever the previous one reported. + clearFailure() + try { + await tryAttach(context.session) + } catch (error: unknown) { + contexts.delete(context.tabId) + markDetached(error instanceof DevToolsError ? error.message : String(error)) + notify() + throw error + } + context.session.resume() + context.detachedReason = undefined + context.domains = [...context.session.domains] + if (resetCapture) { + context.capture.clear() + context.capture.start(context.tabId) + } + markAttached(context.tabId) + // Frame numbering must be known before any index-based address resolves. + await syncFrames(context.session, context.frames).catch(() => undefined) + notify() + } + + /** Attach, recovering from a stale attachment left by a worker restart. */ + const tryAttach = async (session: DebuggerSession): Promise => { + try { + await session.attach() + } catch (error: unknown) { + if (error instanceof DevToolsError && error.code === 'devtools-conflict') { + // After an MV3 worker restart the extension believes it is detached + // while Chrome still holds the attachment. Drop it and retry once; a + // genuine DevTools user keeps the tab and sees the actionable error. + try { + await api!.detach({ tabId: session.tabId }) + await session.attach() + return + } catch { + throw error + } + } + throw error + } + } + + const handleCdpEvent = (context: TabContext | undefined, method: string, params: unknown): void => { + if (context === undefined) return + context.cdpEvents[method] = (context.cdpEvents[method] ?? 0) + 1 + context.frames.observe(method, params) + if (method === 'Page.frameNavigated' && (params as { frame?: { parentId?: string } }).frame?.parentId === undefined) { + // A new top-level document makes every captured index stale. + context.capture.ingestNavigation() + void syncFrames(context.session, context.frames).catch(() => undefined) + } + if (method.startsWith('Network.')) { + // Which network event kinds this page actually emits. Chrome withholds + // the main request event on some pages, and a tally explains a thin + // capture without dumping payloads into normal output. + const kind = method.slice('Network.'.length) + context.networkEvents[kind] = (context.networkEvents[kind] ?? 0) + 1 + } + context.capture.ingest(method, params) + } + + const targetFor = async (sessionId?: string): Promise => { + const target = await options.resolveTarget(sessionId) + return target + } + + /** Resolve the controlled tab, or the given one when the caller knows it. */ + const resolveTarget = async (tabId?: number, sessionId?: string): Promise => { + const target = await targetFor(sessionId) + if (target === undefined) return undefined + return tabId === undefined || target.tabId === tabId ? target : { tabId } + } + + /** + * Resolve the tab context for one call, enforcing the user's allowance. + * @throws {DevToolsError} when unsupported, disabled, or the tab is unknown. + */ + const requireContext = async (sessionId?: string, allowAttach = true): Promise<{ context?: TabContext; target?: DevToolsTarget }> => { + if (allowAttach) return requireAttachedContext(sessionId) + if (!supported() || revoked) return {} + const target = await targetFor(sessionId) + const context = target === undefined ? undefined : contexts.get(target.tabId) + return { ...(context === undefined ? {} : { context }), ...(target === undefined ? {} : { target }) } + } + + const requireAttachedContext = async (sessionId?: string): Promise<{ context: TabContext; target: DevToolsTarget }> => { + if (!supported()) { + throw new DevToolsError( + 'devtools-unavailable', + 'This browser build cannot provide developer tools (the debugger API is unavailable).', + ) + } + if (revoked) { + throw new DevToolsError('devtools-unavailable', 'The developer-tools session was revoked.') + } + const target = await targetFor(sessionId) + if (target === undefined) { + throw new DevToolsError('devtools-unavailable', 'No controlled tab is available for developer tools.') + } + const blocked = undebuggableReason(target.url) + if (blocked !== undefined) { + clearFailure(blocked) + notify() + throw new DevToolsError('devtools-unavailable', blocked) + } + const context = await contextFor(target.tabId) + return { context, target } + } + + const finish = (text: string): { text: string } => ({ text: truncateText(text, maxChars) }) + + return { + supported, + state, + releaseTab: (tabId) => { + const context = contexts.get(tabId) + if (context === undefined) return + contexts.delete(tabId) + markDetached() + void context.session.detach() + notify() + }, + revoke: async () => { + revoked = true + const pending = [...contexts.values()] + contexts.clear() + markDetached() + await Promise.all(pending.map(async (context) => { await context.session.detach() })) + notify() + }, + detachAll: async () => { + const pending = [...contexts.values()] + contexts.clear() + markDetached() + await Promise.all(pending.map(async (context) => { await context.session.detach() })) + notify() + }, + resume: () => { + revoked = false + notify() + }, + prewarm: async (tabId) => { + if (!supported() || revoked) { + throw new DevToolsError('devtools-unavailable', 'This browser build cannot provide developer tools.') + } + const target = await resolveTarget(tabId) + if (undebuggableReason(target?.url) !== undefined) { + // Enabling the switch is not itself a failed operation: an internal + // page simply has nothing to prewarm, and the first real call reports + // the reason if the user still has not moved to a debuggable tab. + clearFailure() + return + } + const context = await contextFor(tabId) + context.capture.start(tabId) + notify() + }, + handle: async (name, args, sessionId) => { + // `status` must answer without resolving or attaching to a tab, so the + // panel can show capture state even when the debugger is detached. + const readOnly = name === 'browser_devtools_network' && args.action === 'status' + try { + const { context, target } = await requireContext(sessionId, !readOnly) + if (context === undefined || target === undefined) { + // A status question is answerable from the shared state alone. + if (readOnly) return finish(renderCaptureState(statusSnapshot(target?.tabId), 'status')) + throw new DevToolsError('devtools-unavailable', 'No controlled tab is available for developer tools.') + } + return finish(await dispatch(context, target, name, args)) + } catch (error: unknown) { + throw toToolError(error) + } + }, + } + + /** Route one devtools operation. */ + async function dispatch( + context: TabContext, + target: DevToolsTarget, + name: string, + args: Record, + ): Promise { + switch (name) { + case 'browser_devtools_elements': + return elements(context, args) + case 'browser_devtools_set_element_style': + return setStyle(context, args) + case 'browser_devtools_set_element_attribute': + return setElementAttribute(context, args) + case 'browser_console_eval': + return consoleEval(context, args) + case 'browser_console_logs': + return consoleLogs(context, args) + case 'browser_devtools_network': + return networkControl(context, target, args) + case 'browser_devtools_list_requests': + return listRequests(context, args) + case 'browser_devtools_get_request': + return getRequest(context, args) + case 'browser_devtools_request_body': + return requestBody(context, args) + default: + throw new Error(`Unknown developer-tools operation ${name}.`) + } + } + + // ---- Elements ---- + + async function elements(context: TabContext, args: Record): Promise { + const nodeId = await resolveNodeId(context.session, locatorOf(args), context.frames) + const properties = stringList(args.properties) + const view = await inspectElement(context.session, nodeId, properties) + return renderElement(view) + } + + async function setStyle(context: TabContext, args: Record): Promise { + const declarations = declarationList(args.properties) + const nodeId = await resolveNodeId(context.session, locatorOf(args), context.frames) + const node = await context.session.send('DOM.describeNode', { nodeId, depth: 0 }) as { node?: { localName?: string; nodeName?: string; attributes?: string[] } } + const identity = shortIdentity(node.node ?? {}, pairsOf(node.node?.attributes)) + const mode = args.mode === 'rule' ? 'rule' : 'inline' + if (mode === 'rule') { + const edited = await setMatchedRuleStyle(context.session, nodeId, declarations) + return renderStyleEdit(identity, 'rule', edited.selector, edited.declarations) + } + const applied = await setInlineStyle(context.session, nodeId, declarations) + return renderStyleEdit(identity, 'inline', 'style attribute', applied) + } + + async function setElementAttribute(context: TabContext, args: Record): Promise { + const name = typeof args.name === 'string' ? args.name.trim() : '' + if (name === '') throw new Error('name is required.') + const risk = attributeRisk(name) + if (risk === 'forbidden') throw new Error(`The attribute name "${name}" cannot be written through developer tools.`) + const remove = args.remove === true + const value = typeof args.value === 'string' ? args.value : undefined + if (risk === 'script' && !remove) { + throw new Error( + `Refusing to write the script-capable attribute "${name}". ` + + 'Inline handler and resource URL attributes change what the page executes; use browser_console_eval for that.', + ) + } + const nodeId = await resolveNodeId(context.session, locatorOf(args), context.frames) + const before = await attributeValue(context.session, nodeId, name) + if (!remove && value === undefined) { + const node = await context.session.send('DOM.describeNode', { nodeId, depth: 0 }) as { node?: { localName?: string; nodeName?: string; attributes?: string[] } } + const identity = shortIdentity(node.node ?? {}, pairsOf(node.node?.attributes)) + return renderAttributeResult(identity, name, before, undefined, 'read') + } + await setAttribute(context.session, nodeId, name, value, remove) + const after = remove ? undefined : value + const node = await context.session.send('DOM.describeNode', { nodeId, depth: 0 }) as { node?: { localName?: string; nodeName?: string; attributes?: string[] } } + const identity = shortIdentity(node.node ?? {}, pairsOf(node.node?.attributes)) + return renderAttributeResult(identity, name, before, after, remove ? 'removed' : 'set') + } + + // ---- Console ---- + + async function consoleEval(context: TabContext, args: Record): Promise { + const expression = typeof args.expression === 'string' ? args.expression : '' + if (expression.trim() === '') throw new Error('expression is required.') + const frame = numberArg(args.frame) ?? 0 + const contextId = await frameContextId(context.session, frame, context.frames) + const budget = numberArg(args.maxChars) ?? Math.min(maxChars, 8_000) + let response: unknown + try { + response = await context.session.send('Runtime.evaluate', { + expression, + ...(contextId === undefined ? {} : { contextId }), + awaitPromise: args.awaitPromise === true, + // CDP hands back an opaque "object Object" for plain objects unless the + // value is serialized; JSON-compatible results are what the model wants. + // Classes/functions/DOM nodes still fall back to description + preview. + returnByValue: true, + allowUnsafeEvalBlockedByCSP: false, + userGesture: true, + includeCommandLineAPI: true, + generatePreview: args.includePreview === true, + objectGroup: 'dsh-devtools', + }) + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error) + return `Evaluation failed:\n${detail}` + } + const formatted = formatEvaluation(response, { + maxChars: Math.max(200, budget), + includePreview: args.includePreview === true, + }) + return formatted.ok ? `Evaluation result:\n${formatted.text}` : formatted.text + } + + function consoleLogs(context: TabContext, args: Record): string { + const limit = numberArg(args.limit) ?? 50 + const sinceIndex = numberArg(args.sinceIndex) + const level = typeof args.level === 'string' ? args.level : undefined + const entries = context.capture.consoleLog() + // Entry indices start at 1, so "after 0" must not mean "after nothing": + // the window filter applies only when the caller actually asks for one. + .filter((entry) => sinceIndex === undefined || entry.index > sinceIndex) + .filter((entry) => level === undefined || entry.level === level || (level === 'error' && entry.level === 'exception')) + .slice(-limit) + return renderConsoleLogs(entries, sinceIndex ?? 0) + } + + // ---- Network ---- + + async function networkControl(context: TabContext, target: DevToolsTarget, args: Record): Promise { + const action = typeof args.action === 'string' ? args.action : 'status' + if (action === 'stop') { + context.capture.stop() + contexts.delete(context.tabId) + markDetached() + await context.session.detach() + notify() + return renderCaptureState({ ...context.capture.state(), attached: false, tabId: target.tabId }, 'stop') + } + if (action === 'start') { + if (!context.session.isAttached) await attach(context, true) + else if (context.capture.state().attached !== true) context.capture.start(context.tabId) + notify() + return renderCaptureState(context.capture.state(), 'start') + } + const state = context.capture.state() + return `${renderCaptureState(state, 'status')}\n${pipelineEvidence(context)}` + } + + function listRequests(context: TabContext, args: Record): string { + const limit = numberArg(args.limit) ?? 50 + const sinceIndex = numberArg(args.sinceIndex) + const urlPattern = matcher(args.urlPattern) + const method = typeof args.method === 'string' && args.method !== '' ? args.method.toUpperCase() : undefined + const resourceType = typeof args.resourceType === 'string' && args.resourceType !== '' ? args.resourceType.toLowerCase() : undefined + const statusMin = numberArg(args.statusMin) + const statusMax = numberArg(args.statusMax) + const entries = context.capture.list().filter((entry) => { + // Request indices begin at 1, so the window filter must be opt-in. + if (sinceIndex !== undefined && entry.index <= sinceIndex) return false + if (urlPattern !== undefined && !urlPattern(entry.url)) return false + if (method !== undefined && entry.method.toUpperCase() !== method) return false + if (resourceType !== undefined && (entry.resourceType ?? '').toLowerCase() !== resourceType) return false + if (statusMin !== undefined && (entry.status ?? 0) < statusMin) return false + if (statusMax !== undefined && (entry.status ?? 0) > statusMax) return false + if (args.failedOnly === true && entry.failed === undefined) return false + return true + }) + return renderRequestList(entries, context.capture.state().generation, limit) + } + + async function getRequest(context: TabContext, args: Record): Promise { + const entry = requireEntry(context, args) + const includeSensitiveHeaders = args.includeSensitiveHeaders === true + const includeSensitiveBody = args.includeSensitiveBody === true + let body: string | undefined + let truncated = false + if (args.includeBody === true) { + const fetched = await fetchBody(context, entry, 'response') + body = fetched.text + truncated = fetched.truncated + } + const detail = renderRequestDetail(entry, { includeSensitiveHeaders, includeSensitiveBody, ...(body === undefined ? {} : { body }), bodyTruncated: truncated }) + return truncateText(detail, numberArg(args.maxChars) ?? maxChars) + } + + async function requestBody(context: TabContext, args: Record): Promise { + const entry = requireEntry(context, args) + const kind = args.kind === 'request' ? 'request' : 'response' + const includeSensitiveBody = args.includeSensitiveBody === true + if (!includeSensitiveBody && isSensitiveBodyUrl(entry.url)) { + return renderBody(entry, kind, '', { truncated: false, json: false, redacted: true }) + } + const fetched = await fetchBody(context, entry, kind) + const limited = truncateText(fetched.text, numberArg(args.maxChars) ?? maxChars) + return renderBody(entry, kind, limited, { truncated: fetched.truncated, json: fetched.json, redacted: false }) + + (fetched.redactedHeaders === true ? `\n\n(Headers containing credentials were ${REDACTED} in the request detail view.)` : '') + } + + function requireEntry(context: TabContext, args: Record): NetworkEntry { + const index = numberArg(args.index) + if (index === undefined) throw new Error('index is required and must be a captured request index.') + const entry = context.capture.get(index) + if (entry === undefined) { + const state = context.capture.state() + throw new Error( + `No captured request has index ${index} in capture generation ${state.generation} (${state.entries} entries). ` + + 'Call browser_devtools_list_requests again for current indices.', + ) + } + return entry + } + + async function fetchBody( + context: TabContext, + entry: NetworkEntry, + kind: 'request' | 'response', + ): Promise<{ text: string; truncated: boolean; json: boolean; redactedHeaders?: boolean }> { + const command = kind === 'request' ? 'Network.getRequestPostData' : 'Network.getResponseBody' + let raw: { body?: string; postData?: string; base64Encoded?: boolean } + try { + raw = (await context.session.send(command, { requestId: entry.requestId })) as typeof raw + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + kind === 'request' + ? `This request has no captured payload (${detail}).` + : `The response body is no longer available (${detail}). Chrome only keeps bodies for requests it buffered while the debugger was attached.`, + ) + } + const text = raw.body ?? raw.postData ?? '' + if (text === '') throw new Error(kind === 'request' ? 'This request has no payload.' : 'This response has an empty body.') + const decoded = raw.base64Encoded === true ? `[base64] ${text}` : text + const sliced = decoded.slice(0, BODY_CHAR_CAP) + const formatted = formatBody(sliced) + return { text: formatted.text, truncated: decoded.length > BODY_CHAR_CAP, json: formatted.json } + } + + /** One line of pipeline evidence attached to every capture status answer. */ + function pipelineEvidence(context: TabContext | undefined): string { + if (context === undefined) return 'Pipeline: no session has been created for this tab yet.' + const methods = Object.entries(context.cdpEvents) + .sort(([, left], [, right]) => right - left) + .slice(0, 6) + .map(([method, count]) => `${method}×${count}`) + const kinds = Object.entries(context.networkEvents) + .sort(([, left], [, right]) => right - left) + .map(([kind, count]) => `${kind}×${count}`) + return `Pipeline: attached=${context.session.isAttached} · domains=${context.domains.join('+') || 'none'}` + + ` · cdp events=${methods.length === 0 ? 'none received' : methods.join(', ')}` + + ` · network events=${kinds.length === 0 ? 'none' : kinds.join(', ')}` + } + + /** Turn any controller failure into the tool-facing error text. */ + function toToolError(error: unknown): Error { + if (error instanceof DevToolsError) return new Error(error.message) + if (error instanceof Error) return error + return new Error(String(error)) + } +} + +/** Extract the element locator from raw tool arguments. */ +function locatorOf(args: Record): { selector?: string; index?: number; frame?: number } { + const selector = typeof args.selector === 'string' && args.selector.trim() !== '' ? args.selector : undefined + const index = numberArg(args.index) + const frame = numberArg(args.frame) + return { + ...(selector === undefined ? {} : { selector }), + ...(index === undefined ? {} : { index }), + ...(frame === undefined ? {} : { frame }), + } +} + +/** Attribute pairs from a `DOM.describeNode` payload. */ +function pairsOf(attributes: string[] | undefined): { name: string; value: string }[] { + const pairs: { name: string; value: string }[] = [] + const list = attributes ?? [] + for (let index = 0; index + 1 < list.length; index += 2) { + pairs.push({ name: list[index]!, value: list[index + 1]! }) + } + return pairs +} + +function numberArg(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +/** Serialize the tool's declaration list, rejecting silently wrong shapes. */ +function declarationList(value: unknown): { name: string; value: string; important?: boolean }[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error('properties must be a non-empty array of { name, value } declarations.') + } + return value.map((entry) => { + const record = entry as { name?: unknown; value?: unknown; important?: unknown } + if (typeof record?.name !== 'string' || record.name.trim() === '' || typeof record.value !== 'string') { + throw new Error('Each property needs a CSS name and a string value.') + } + return { + name: record.name.trim(), + value: record.value, + ...(record.important === true ? { important: true } : {}), + } + }) +} + +/** A string list argument, ignoring non-strings. */ +function stringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const list = value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '') + return list.length === 0 ? undefined : list +} + +/** Build a URL matcher from a substring or `/regex/` pattern. */ +function matcher(pattern: unknown): ((url: string) => boolean) | undefined { + if (typeof pattern !== 'string' || pattern === '') return undefined + const slash = /^\/(.*)\/([a-z]*)$/i.exec(pattern) + if (slash !== null) { + try { + const regex = new RegExp(slash[1]!, slash[2]!.includes('i') ? 'i' : '') + return (url) => regex.test(url) + } catch { + // An invalid regex is just a substring. + } + } + return (url) => url.includes(pattern) +} + +/** Serialize one capture state for the panel. */ +export function captureStatusText(capture: NetworkCapture): string { + const state = capture.state() + return `Network capture ${state.attached ? 'active' : 'inactive'} · ${state.entries} entries · generation ${state.generation}${state.tabId === undefined ? '' : ` · tab ${state.tabId}`}` +} diff --git a/extensions/dsh-browser/src/background/devtools-elements.ts b/extensions/dsh-browser/src/background/devtools-elements.ts new file mode 100644 index 000000000..884036bae --- /dev/null +++ b/extensions/dsh-browser/src/background/devtools-elements.ts @@ -0,0 +1,721 @@ +/** + * Element and CSS inspection plus live debugging edits, driven over CDP. + * + * Every helper resolves the target element the same way: a CSS selector through + * `DOM.querySelector` on the document root, or a `browser_snapshot` inventory + * index through the `data-dsh-el` attribute the content script writes (evaluated + * in the frame's own JavaScript context so iframe inventory numbers resolve too). + * + * The resolution layer is a small port so the presentation logic can be tested + * against a scripted CDP client without Chrome. + * + * @module + */ + +import type { CdpClient } from './debugger-session.ts' + +/** Computed-style properties returned when the caller names none. */ +export const DEFAULT_COMPUTED_PROPERTIES = [ + 'display', + 'position', + 'width', + 'height', + 'margin', + 'padding', + 'color', + 'background-color', + 'font-family', + 'font-size', + 'font-weight', + 'line-height', + 'border', + 'flex-direction', + 'gap', + 'overflow', + 'opacity', + 'z-index', + 'transform', +] as const + +/** Attribute names the model must never write through the debugger. */ +const FORBIDDEN_ATTRIBUTES = new Set(['srcdoc']) + +/** Attribute names that carry inline event handlers or resource URLs. */ +const SCRIPT_ATTRIBUTES = /^on[a-z]|^(src|href|xlink:href)$/i + +/** A locator: exactly one of selector/index is used. */ +export interface ElementLocator { + selector?: string + index?: number + frame?: number +} + +/** One attribute name/value pair. */ +export interface AttributeView { + name: string + value: string +} + +/** One matched CSS rule rendered for the model. */ +export interface MatchedRuleView { + selector: string + origin: string + declarations: string[] + media?: string +} + +/** The element inspector's full report. */ +export interface ElementView { + identity: string + domPath: string + inventoryIndex?: number + box?: { x: number; y: number; width: number; height: number } + boxModel?: { content: string; padding: string; border: string; margin: string } + computed: Record + inlineStyle: string[] + matchedRules: MatchedRuleView[] + attributes: AttributeView[] + childNodeCount: number +} + +/** Frame order and default-context ids observed by the CDP session. */ +export interface DebuggerFrames { + /** + * Frames in traversal order (main frame first), matching `browser_snapshot` + * numbering. Rebuilt when the main frame navigates. + */ + order(): readonly string[] + /** The page's default execution context for a frame, when one was observed. */ + defaultContext(frameId: string): number | undefined + /** Record one `Page.frameNavigated` / `Runtime.executionContextCreated` observation. */ + observe(method: string, params: unknown): void +} + +/** A `Runtime.executionContextCreated` payload. */ +interface ExecutionContextPayload { + context?: { id?: number; origin?: string; name?: string; auxData?: { frameId?: string; isDefault?: boolean } } +} + +/** A `Page.frameNavigated` payload. */ +interface FrameNavigatedPayload { + frame?: { id?: string; parentId?: string; url?: string } +} + +/** + * Track the frame tree and each frame's default execution context from the CDP + * event stream. `Page.getFrameTree` remains the source of truth for a freshly + * attached session; this registry covers the common case without an extra + * command and always picks the *page's own* context when one is known. + */ +export class FrameRegistry implements DebuggerFrames { + private frames: string[] = [] + private contexts = new Map() + + /** @returns frames in traversal order (main frame first). */ + order(): readonly string[] { + return this.frames + } + + /** @returns the default execution context of a frame, when observed. */ + defaultContext(frameId: string): number | undefined { + return this.contexts.get(frameId) + } + + /** Replace the frame order from an authoritative `Page.getFrameTree` read. */ + setTree(ids: readonly string[]): void { + this.frames = [...ids] + } + + /** + * Fold one CDP event into the registry. + * @param method - CDP event name. + * @param params - CDP event payload. + */ + observe(method: string, params: unknown): void { + switch (method) { + case 'Runtime.executionContextCreated': { + const context = (params as ExecutionContextPayload).context + const frameId = context?.auxData?.frameId + if (frameId === undefined || context?.id === undefined) return + // Page contexts report `isDefault`; extension/isolated worlds do not. + if (context.auxData?.isDefault !== true) return + this.contexts.set(frameId, context.id) + break + } + case 'Runtime.executionContextDestroyed': { + const id = (params as { executionContextId?: number }).executionContextId + if (id === undefined) return + for (const [frameId, contextId] of this.contexts) { + if (contextId === id) this.contexts.delete(frameId) + } + break + } + case 'Runtime.executionContextsCleared': + this.contexts.clear() + break + case 'Page.frameNavigated': { + const frame = (params as FrameNavigatedPayload).frame + if (frame?.id === undefined) return + if (frame.parentId === undefined) { + // A new top-level document invalidates every previous frame and index. + this.frames = [frame.id] + this.contexts.clear() + break + } + if (!this.frames.includes(frame.id)) this.frames.push(frame.id) + break + } + case 'Page.frameDetached': { + const frameId = (params as { frameId?: string }).frameId + if (frameId === undefined) return + this.frames = this.frames.filter((id) => id !== frameId) + this.contexts.delete(frameId) + break + } + default: + break + } + } +} + +/** + * Resolve one locator to a CDP node id. + * @param client - the CDP session. + * @param locator - selector or inventory index, plus an optional frame. + * @param frames - frame registry used for index lookups outside the top frame. + * @returns the DOM node id. + * @throws when no element matches or the locator is incomplete. + */ +export async function resolveNodeId( + client: CdpClient, + locator: ElementLocator, + frames?: DebuggerFrames, +): Promise { + if (typeof locator.selector === 'string' && locator.selector.trim() !== '') { + const root = (await client.send('DOM.getDocument', { depth: 0 })) as { root?: { nodeId?: number } } + const documentNodeId = root.root?.nodeId + if (documentNodeId === undefined) throw new Error('The page document is unavailable.') + const found = (await client.send('DOM.querySelector', { nodeId: documentNodeId, selector: locator.selector })) as { nodeId?: number } + if (found.nodeId === undefined || found.nodeId === 0) { + throw new Error(`No element matches the selector ${locator.selector}.`) + } + return found.nodeId + } + if (typeof locator.index === 'number' && Number.isInteger(locator.index) && locator.index > 0) { + return resolveInventoryIndex(client, locator.index, locator.frame ?? 0, frames) + } + throw new Error('Provide exactly one of selector or index to identify the element.') +} + +/** + * Resolve a `browser_snapshot` inventory index through the `data-dsh-el` + * attribute the content script writes on every inventoried element. + */ +async function resolveInventoryIndex( + client: CdpClient, + index: number, + frame: number, + frames: DebuggerFrames | undefined, +): Promise { + const contextId = await frameContextId(client, frame, frames) + const evaluated = (await client.send('Runtime.evaluate', { + expression: `document.querySelector('[data-dsh-el="${index}"]')`, + ...contextId === undefined ? {} : { contextId }, + returnByValue: false, + objectGroup: 'dsh-devtools', + includeCommandLineAPI: false, + })) as { result?: { objectId?: string; subtype?: string } } + const objectId = evaluated.result?.objectId + if (objectId === undefined || evaluated.result?.subtype === 'null') { + throw new Error(`Inventory index ${index} no longer matches an element${frame === 0 ? '' : ` in frame ${frame}`}. Call browser_snapshot again for fresh indices.`) + } + try { + const node = (await client.send('DOM.requestNode', { objectId })) as { nodeId?: number } + if (node.nodeId === undefined) throw new Error(`Inventory index ${index} could not be resolved to a DOM node.`) + return node.nodeId + } finally { + // The remote object is only an intermediate step towards the node id. + void client.send('Runtime.releaseObject', { objectId }).catch(() => undefined) + } +} + +/** Locate the JavaScript context of one frame; the main frame needs none. */ +export async function frameContextId( + client: CdpClient, + frame: number, + frames: DebuggerFrames | undefined, +): Promise { + if (frame === 0) return undefined + const known = frames?.order() ?? [] + const frameId = known[frame] + if (frameId === undefined) { + throw new Error(`Frame ${frame} does not exist in the current page.`) + } + const context = frames?.defaultContext(frameId) + if (context !== undefined) return context + const world = (await client.send('Page.createIsolatedWorld', { + frameId, + worldName: 'dsh-devtools', + grantUniveralAccess: false, + })) as { executionContextId?: number } + if (world.executionContextId === undefined) throw new Error(`Frame ${frame} has no usable JavaScript context.`) + return world.executionContextId +} + +/** Frame id of one snapshot frame number, or undefined for the main frame. */ +export function frameIdAt(frames: DebuggerFrames | undefined, frame: number): string | undefined { + return frames?.order()[frame] +} + +/** Refresh the frame registry from `Page.getFrameTree` (authoritative at attach). */ +export async function syncFrames(client: CdpClient, frames: FrameRegistry): Promise { + const tree = (await client.send('Page.getFrameTree', {})) as { frameTree?: FrameTreeNode } + frames.setTree(flattenFrameTree(tree.frameTree).map((frame) => frame.id ?? '').filter((id) => id !== '')) +} + +interface FrameTreeNode { + frame?: { id?: string; url?: string } + childFrames?: FrameTreeNode[] +} + +/** Depth-first frame order: main frame first, then children in document order. */ +function flattenFrameTree(node: FrameTreeNode | undefined): { id?: string; url?: string }[] { + if (node?.frame === undefined) return [] + const frames = [node.frame] + for (const child of node.childFrames ?? []) frames.push(...flattenFrameTree(child)) + return frames +} + +/** Fetch everything the element inspector reports for one node. */ +export async function inspectElement( + client: CdpClient, + nodeId: number, + properties?: readonly string[], +): Promise { + const described = (await client.send('DOM.describeNode', { nodeId, depth: 0, pierce: false })) as { node?: DescribedNode } + const node = described.node ?? {} + const attributes = attributePairs(node.attributes) + const computedResponse = (await client.send('CSS.getComputedStyleForNode', { nodeId })) as { + computedStyle?: { name: string; value: string }[] + } + const computedStyles = computedResponse.computedStyle ?? [] + const requested = properties === undefined || properties.length === 0 + ? [...DEFAULT_COMPUTED_PROPERTIES] + : properties + const computed: Record = {} + for (const name of requested) { + // A duplicate request must not add a second line to the report. + if (name in computed) continue + const found = computedStyles.find((entry) => entry.name.toLowerCase() === name.toLowerCase()) + computed[name] = found === undefined ? '(not set)' : found.value + } + const matched = await matchedRules(client, nodeId) + const boxModel = await boxModelOf(client, nodeId) + const inventory = attributes.find((attribute) => attribute.name === 'data-dsh-el') + const inventoryIndex = inventory === undefined ? undefined : Number(inventory.value) + return { + identity: shortIdentity(node, attributes), + domPath: await describePath(client, nodeId, described), + ...(inventoryIndex === undefined || !Number.isInteger(inventoryIndex) ? {} : { inventoryIndex }), + ...(boxModel === undefined ? {} : { box: boxModel.box, boxModel: boxModel.boxes }), + computed, + inlineStyle: matched.inline, + matchedRules: matched.rules, + attributes, + childNodeCount: node.childNodeCount ?? 0, + } +} + +/** The `DOM.describeNode` subset this module reads. */ +interface DescribedNode { + nodeName?: string + localName?: string + attributes?: string[] + childNodeCount?: number + parentId?: number +} + +/** Read one node description. */ +async function describeNode(client: CdpClient, nodeId: number): Promise { + const described = (await client.send('DOM.describeNode', { nodeId, depth: 0, pierce: false })) as { node?: DescribedNode } + return described.node ?? {} +} + +/** Flatten `CSS.getMatchedStylesForNode` into inline declarations and rules. */ +export async function matchedRules( + client: CdpClient, + nodeId: number, +): Promise<{ inline: string[]; rules: MatchedRuleView[] }> { + const response = (await client.send('CSS.getMatchedStylesForNode', { nodeId })) as { + inlineStyle?: StylePayload + matchedCSSRules?: MatchedRulePayload[] + inherited?: { matchedCSSRules?: MatchedRulePayload[] }[] + } + const rules: MatchedRuleView[] = (response.matchedCSSRules ?? []).map((entry) => toRuleView(entry, 'author')) + for (const inherited of response.inherited ?? []) { + for (const entry of inherited.matchedCSSRules ?? []) rules.push(toRuleView(entry, 'inherited')) + } + return { inline: declarationsOf(response.inlineStyle), rules } +} + +interface StylePayload { + cssProperties?: { name: string; value: string; important?: boolean; disabled?: boolean }[] + cssText?: string + /** Editable sheet id; absent for inline styles Chrome does not expose as a sheet. */ + styleSheetId?: string + /** Token range of the declaration block inside its stylesheet. */ + range?: RangePayload +} + +interface MatchedRulePayload { + rule?: { + selectorList?: { text?: string } + origin?: string + media?: { text?: string }[] + styleSheetId?: string + style?: StylePayload + } + matchingSelectors?: number[] +} + +function toRuleView(entry: MatchedRulePayload, origin: string): MatchedRuleView { + const rule = entry.rule ?? {} + const matching = entry.matchingSelectors ?? [] + const selector = matching.length === 0 + ? rule.selectorList?.text ?? '(unknown selector)' + : matching + .map((position) => rule.selectorList?.text?.split(',')[position]?.trim() ?? '') + .filter((part) => part !== '') + .join(', ') + const media = (rule.media ?? []).map((query) => query.text ?? '').filter((text) => text !== '').join(' and ') + return { + selector: selector === '' ? rule.selectorList?.text ?? '(unknown selector)' : selector, + origin: rule.origin ?? origin, + declarations: declarationsOf(rule.style), + ...(media === '' ? {} : { media }), + } +} + +/** Every longhand a given shorthand can expand into. */ +const LONGHANDS_OF: Record = { + margin: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'], + padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'], + gap: ['row-gap', 'column-gap'], + overflow: ['overflow-x', 'overflow-y'], + background: ['background-color', 'background-image', 'background-position', 'background-size', 'background-repeat', 'background-attachment'], + border: ['border-width', 'border-style', 'border-color'], + inset: ['top', 'right', 'bottom', 'left'], +} + +/** Which shorthand family a property belongs to; longhands belong to their own. */ +export function propertyFamily(name: string): string { + const normalized = name.trim().toLowerCase() + for (const [shorthand, longhands] of Object.entries(LONGHANDS_OF)) { + if (normalized === shorthand || longhands.includes(normalized)) return shorthand + } + return normalized +} + +/** + * Canonicalize one declaration block. + * + * CDP reports both a shorthand and every longhand it expands into, which is + * most of a style report's bulk. A longhand stays only when nothing in the same + * block covers it, or when the caller explicitly edited it (dropping an edit + * would silently discard what the user asked for). + * + * @param declarations - `name: value` lines in cascade order. + * @param edited - lower-cased property names the caller supplied. + * @returns the reduced lines, in their original order. + */ +export function canonicalizeDeclarations( + declarations: readonly string[], + edited: ReadonlySet = new Set(), +): string[] { + const present = new Set() + for (const declaration of declarations) { + const separator = declaration.indexOf(':') + if (separator < 0) continue + present.add(declaration.slice(0, separator).trim().toLowerCase()) + } + const covered = new Set() + for (const [shorthand, longhands] of Object.entries(LONGHANDS_OF)) { + if (!present.has(shorthand)) continue + for (const longhand of longhands) { + if (!edited.has(longhand)) covered.add(longhand) + } + } + return declarations.filter((declaration) => { + const separator = declaration.indexOf(':') + if (separator < 0) return true + return !covered.has(declaration.slice(0, separator).trim().toLowerCase()) + }) +} + +/** + * Render one CDP style payload as `name: value` lines. + * + * Custom properties are dropped: they are theme plumbing, not an answer about + * this element, and a single page can put hundreds of them on `body`. + */ +export function declarationsOf(style: StylePayload | undefined): string[] { + const seen = new Set() + const declarations: string[] = [] + for (const property of style?.cssProperties ?? []) { + if (property.disabled === true) continue + if (property.name.startsWith('--')) continue + const name = property.name.trim().toLowerCase() + if (name === '' || seen.has(name)) continue + seen.add(name) + declarations.push(`${property.name}: ${property.value}${property.important === true ? ' !important' : ''}`) + } + return canonicalizeDeclarations(declarations) +} + +/** Box model in compact numeric form plus per-edge lists. */ +async function boxModelOf( + client: CdpClient, + nodeId: number, +): Promise<{ box: { x: number; y: number; width: number; height: number }; boxes: { content: string; padding: string; border: string; margin: string } } | undefined> { + let model: { content?: number[]; padding?: number[]; border?: number[]; margin?: number[] } | undefined + try { + const response = (await client.send('DOM.getBoxModel', { nodeId })) as { model?: typeof model } + model = response.model + } catch { + // Elements without a layout box (display:none, detached) report no model. + return undefined + } + const content = model?.content + if (content === undefined || content.length < 8) return undefined + const left = Math.min(content[0]!, content[6]!) + const top = Math.min(content[1]!, content[3]!) + const right = Math.max(content[2]!, content[4]!) + const bottom = Math.max(content[5]!, content[7]!) + return { + box: { x: round(left), y: round(top), width: round(right - left), height: round(bottom - top) }, + boxes: { + content: `${round(right - left)}×${round(bottom - top)}`, + padding: quadToEdges(model?.padding), + border: quadToEdges(model?.border), + margin: quadToEdges(model?.margin), + }, + } +} + +/** Turn one CDP quad into `top right bottom left` pixel values. */ +function quadToEdges(quad: number[] | undefined): string { + if (quad === undefined || quad.length < 8) return '0px 0px 0px 0px' + const [x1, y1, x2, , , y3, x4, y4] = quad as [number, number, number, number, number, number, number, number] + return `${round(Math.abs(y1 - y3))}px ${round(Math.abs(x2 - x1))}px ${round(Math.abs(y4 - y1))}px ${round(Math.abs(x1 - x4))}px` +} + +function round(value: number): number { + return Math.round(value * 10) / 10 +} + +/** A short element identity, preferring id then the first class. */ +export function shortIdentity(node: { nodeName?: string; localName?: string }, attributes: AttributeView[]): string { + const tag = (node.localName ?? node.nodeName ?? 'node').toLowerCase() + const id = attributes.find((attribute) => attribute.name === 'id')?.value + if (id !== undefined && id !== '') return `${tag}#${id}` + const firstClass = attributes.find((attribute) => attribute.name === 'class')?.value?.split(/\s+/).find((entry) => entry !== '') + return firstClass === undefined ? tag : `${tag}.${firstClass}` +} + +/** `DOM.describeNode` reports attributes as a flat name/value list. */ +export function attributePairs(attributes: string[] | undefined): AttributeView[] { + const pairs: AttributeView[] = [] + const list = attributes ?? [] + for (let index = 0; index + 1 < list.length; index += 2) { + pairs.push({ name: list[index]!, value: list[index + 1]! }) + } + return pairs +} + +/** + * Build a readable ancestor path for one element. + * + * The already-described target is reused, and the walk stops at the first + * unordered or unknown ancestor so a deep DOM cannot turn one inspection into a + * long chain of commands. + */ +async function describePath( + client: CdpClient, + nodeId: number, + target?: { node?: DescribedNode }, +): Promise { + const origin = target?.node ?? await describeNode(client, nodeId) + const segments: string[] = [shortIdentity(origin, attributePairs(origin.attributes))] + let current = origin + for (let depth = 0; depth < 16; depth += 1) { + const parentId = current.parentId + if (parentId === undefined || parentId <= 0) break + let parent: DescribedNode + try { + parent = await describeNode(client, parentId) + } catch { + // Some nodes (documents, shadow roots) have no describable parent. + break + } + if (parent.nodeName === undefined) break + segments.unshift(shortIdentity(parent, attributePairs(parent.attributes))) + current = parent + } + return segments.join(' > ') +} + +/** + * Apply inline (element `style` attribute) declarations. + * + * `CSS.setStyleTexts` needs a `styleSheetId` plus the declaration block's + * `range`, which Chrome only reports for inline styles it exposes as an editable + * sheet (`isInline`). When that metadata is absent the edit is applied through + * the `style` attribute, which reaches the same result without depending on CDP + * internals. + */ +export async function setInlineStyle( + client: CdpClient, + nodeId: number, + properties: readonly { name: string; value: string; important?: boolean }[], +): Promise { + const matched = (await client.send('CSS.getMatchedStylesForNode', { nodeId })) as { inlineStyle?: StylePayload } + const inline = matched.inlineStyle + const merged = mergeDeclarations(inline, properties) + const text = merged.map((declaration) => `${declaration};`).join(' ') + const styleSheetId = inline?.styleSheetId + const range = inline?.range + if (styleSheetId !== undefined && range !== undefined) { + await client.send('CSS.setStyleTexts', { + edits: [{ styleSheetId, range, text }], + nodeForPropertySyntaxValidation: nodeId, + }) + return merged + } + await client.send('DOM.setAttributeValue', { nodeId, name: 'style', value: inlineText(merged) }) + return merged +} + +/** The `style` attribute text for a declaration list. */ +function inlineText(declarations: readonly string[]): string { + return declarations.length === 0 ? '' : `${declarations.map((declaration) => `${declaration};`).join(' ')}` +} + +/** + * Edit declarations of the first author rule that matches the element. + * + * The stylesheet is edited by splicing its own source text at the CSS rule + * token ranges, so unrelated rules in the same sheet survive the write. + */ +export async function setMatchedRuleStyle( + client: CdpClient, + nodeId: number, + properties: readonly { name: string; value: string; important?: boolean }[], +): Promise<{ selector: string; declarations: string[] }> { + const response = (await client.send('CSS.getMatchedStylesForNode', { nodeId })) as { + matchedCSSRules?: MatchedRulePayload[] + } + const rule = (response.matchedCSSRules ?? []).map((entry) => entry.rule).find((candidate) => candidate?.styleSheetId !== undefined) + const styleSheetId = rule?.styleSheetId + if (rule === undefined || styleSheetId === undefined) { + throw new Error('This element has no matching author stylesheet rule to edit; use mode "inline" instead.') + } + const merged = mergeDeclarations(rule.style, properties) + const text = await spliceStyleSheet(client, styleSheetId, rule.style?.range, merged) + await client.send('CSS.setStyleSheetText', { styleSheetId, text }) + return { selector: rule.selectorList?.text ?? '(unknown selector)', declarations: merged } +} + +/** Read the sheet and replace the rule's declaration block in place. */ +async function spliceStyleSheet( + client: CdpClient, + styleSheetId: string, + range: RangePayload | undefined, + declarations: readonly string[], +): Promise { + const sheet = (await client.send('CSS.getStyleSheetText', { styleSheetId })) as { text?: string } + const source = sheet.text ?? '' + const block = `{ ${declarations.map((declaration) => `${declaration};`).join(' ')} }` + if (range === undefined) return `${source}\n${block}` + const start = offsetOf(source, range.startLine, range.startColumn) + const end = offsetOf(source, range.endLine, range.endColumn) + if (start === undefined || end === undefined || end < start) return `${source}\n${block}` + return `${source.slice(0, start)}${block}${source.slice(end)}` +} + +interface RangePayload { + startLine: number + startColumn: number + endLine: number + endColumn: number +} + +/** Convert a line/column pair into a string offset. */ +function offsetOf(text: string, line: number, column: number): number | undefined { + let offset = 0 + let currentLine = 0 + while (currentLine < line) { + const next = text.indexOf('\n', offset) + if (next < 0) return undefined + offset = next + 1 + currentLine += 1 + } + return Math.min(text.length, offset + column) +} + +/** Apply a property change set to an existing declaration list. */ +export function mergeDeclarations( + style: StylePayload | undefined, + properties: readonly { name: string; value: string; important?: boolean }[], +): string[] { + const edits = new Map() + for (const property of properties) edits.set(property.name.trim().toLowerCase(), property) + // An edit resolves its whole shorthand family: editing `margin-top` while the + // block carries `margin` (or its other edges) drops the stale family, or the + // shorthand would win the cascade and the edit would do nothing. + const editedFamilies = new Set([...edits.keys()].map((name) => propertyFamily(name))) + const declarations: string[] = [] + const seen = new Set() + for (const property of style?.cssProperties ?? []) { + const key = property.name.trim().toLowerCase() + if (property.disabled === true || seen.has(key)) continue + seen.add(key) + if (editedFamilies.has(propertyFamily(key))) continue + declarations.push(`${property.name}: ${property.value}${property.important === true ? ' !important' : ''}`) + } + for (const edit of edits.values()) { + if (edit.value === '') continue + declarations.push(`${edit.name}: ${edit.value}${edit.important === true ? ' !important' : ''}`) + } + return canonicalizeDeclarations(declarations) +} + +/** Attribute names that require extra scrutiny before a write. */ +export function attributeRisk(name: string): 'forbidden' | 'script' | 'normal' { + const normalized = name.trim().toLowerCase() + if (normalized === '' || /[\s<>]/.test(normalized)) return 'forbidden' + if (FORBIDDEN_ATTRIBUTES.has(normalized)) return 'forbidden' + if (SCRIPT_ATTRIBUTES.test(normalized)) return 'script' + return 'normal' +} + +/** Set or remove one attribute. */ +export async function setAttribute( + client: CdpClient, + nodeId: number, + name: string, + value: string | undefined, + remove: boolean, +): Promise { + if (remove) { + await client.send('DOM.removeAttribute', { nodeId, name }) + return + } + if (value === undefined) return + await client.send('DOM.setAttributeValue', { nodeId, name, value }) +} + +/** Current value of one attribute, or undefined when absent. */ +export async function attributeValue(client: CdpClient, nodeId: number, name: string): Promise { + const found = (await client.send('DOM.getAttributes', { nodeId })) as { attributes?: string[] } + return attributePairs(found.attributes).find((attribute) => attribute.name.toLowerCase() === name.toLowerCase())?.value +} diff --git a/extensions/dsh-browser/src/background/devtools-format.ts b/extensions/dsh-browser/src/background/devtools-format.ts new file mode 100644 index 000000000..8ed13b272 --- /dev/null +++ b/extensions/dsh-browser/src/background/devtools-format.ts @@ -0,0 +1,211 @@ +/** + * Presentation layer for the developer-tools tools: turn CDP-derived view + * models into the single bounded text payload each tool returns. + * + * Page-authored values are passed through {@link wrapUntrustedContent} by the + * caller that owns the budget, so these renderers stay pure and test-free of + * Chrome APIs. + * + * @module + */ + +import { + formatBytes, + formatHeaders, + formatTiming, + isSensitiveBodyUrl, + REDACTED, + truncateText, +} from './devtools-serialization.ts' +import type { ConsoleEntry, ConsoleLevel, NetworkEntry } from './devtools-network.ts' +import type { ElementView } from './devtools-elements.ts' +/** Render one inspected element report. */ +export function renderElement(view: ElementView): string { + const lines: string[] = [`Element: ${view.identity}`] + lines.push(`DOM path: ${view.domPath}`) + if (view.inventoryIndex !== undefined) lines.push(`Snapshot inventory index: ${view.inventoryIndex}`) + lines.push(`Child nodes: ${view.childNodeCount}`) + if (view.box !== undefined) { + lines.push(`Box: x ${view.box.x} y ${view.box.y} ${view.box.width}×${view.box.height}`) + } + if (view.boxModel !== undefined) { + lines.push(`Box model (content / padding / border / margin): ${view.boxModel.content} / ${view.boxModel.padding} / ${view.boxModel.border} / ${view.boxModel.margin}`) + } + lines.push('', 'Computed style:') + for (const [name, value] of Object.entries(view.computed)) lines.push(` ${name}: ${value}`) + lines.push('', 'Inline style:') + lines.push(...view.inlineStyle.length === 0 ? [' (none)'] : view.inlineStyle.map((declaration) => ` ${declaration}`)) + lines.push('', 'Matched CSS rules (most specific first):') + if (view.matchedRules.length === 0) lines.push(' (no stylesheet rules matched)') + for (const rule of view.matchedRules) { + const media = rule.media === undefined ? '' : ` @media ${rule.media}` + lines.push(` ${rule.selector} [${rule.origin}]${media}`) + lines.push(...rule.declarations.length === 0 + ? [' (empty rule)'] + : rule.declarations.map((declaration) => ` ${declaration}`)) + } + lines.push('', 'Attributes:') + lines.push(...view.attributes.length === 0 + ? [' (none)'] + : view.attributes.map((attribute) => ` ${attribute.name}="${attribute.value}"`)) + return lines.join('\n') +} + +/** Render one element-list header, shared by inspection and edits. */ +export function renderElementHeader(identity: string, note?: string): string { + return note === undefined ? `Element: ${identity}` : `Element: ${identity}\n${note}` +} + +/** Render the result of a style edit. */ +export function renderStyleEdit( + identity: string, + mode: 'inline' | 'rule', + target: string, + declarations: readonly string[], +): string { + const lines = [renderElementHeader(identity, mode === 'inline' + ? 'Applied to the inline style attribute (a page reload reverts it).' + : `Applied to the matching stylesheet rule: ${target} (reverts when the page replaces its stylesheets).`)] + lines.push('', 'Declarations now applied:') + lines.push(...declarations.length === 0 ? [' (empty)' ] : declarations.map((declaration) => ` ${declaration}`)) + return lines.join('\n') +} + +/** Render an attribute read/write result. */ +export function renderAttributeResult( + identity: string, + name: string, + before: string | undefined, + after: string | undefined, + action: 'read' | 'set' | 'removed', +): string { + const lines = [`Element: ${identity}`, `Attribute: ${name}`] + lines.push(`Before: ${before === undefined ? '(absent)' : before}`) + if (action === 'read') return lines.join('\n') + lines.push(`After: ${after === undefined ? '(removed)' : after}`) + return lines.join('\n') +} + +/** Render one console-log query result. */ +export function renderConsoleLogs(entries: readonly ConsoleEntry[], sinceIndex: number): string { + if (entries.length === 0) { + return `No console entries recorded${sinceIndex > 0 ? ` after index ${sinceIndex}` : ''}. ` + + 'The extension records output from the moment the developer-tools session attached; reload the page to capture startup logs.' + } + const lines = [`Console entries (${entries.length}):`] + for (const entry of entries) { + lines.push(`[${entry.index}] ${entry.level}: ${entry.text}`) + } + return lines.join('\n') +} + +/** Render one evaluation result, already formatted by the serialization layer. */ +export function renderEvaluation(text: string, ok: boolean): string { + return `${ok ? 'Evaluation result:' : 'Evaluation failed:'}\n${text}` +} + +/** Render the capture control answer. */ +export function renderCaptureState(state: { attached: boolean; tabId?: number; entries: number; generation: number }, action: string): string { + const lines = [`Network capture ${state.attached ? 'active' : 'inactive'}.`] + if (state.tabId !== undefined) lines.push(`Tab: ${state.tabId}`) + lines.push(`Captured requests: ${state.entries}`) + lines.push(`Capture generation: ${state.generation} (increments whenever the log resets, for example after a navigation)`) + if (action === 'start') { + lines.push('', 'Trigger the requests you want to inspect, then call browser_devtools_list_requests.') + } + if (action === 'stop') { + lines.push('', 'The debugger detached; captured entries remain listed until the next capture or navigation.') + } + return lines.join('\n') +} + +/** One line per captured request, without bodies. */ +export function renderRequestList(entries: readonly NetworkEntry[], generation: number, limit: number): string { + if (entries.length === 0) { + return 'No captured requests matched. Start capture with browser_devtools_network action=start, then trigger the page traffic again.' + } + const shown = entries.slice(-limit) + const lines = [`Captured requests (${shown.length} of ${entries.length}, generation ${generation}):`] + for (const entry of shown) { + const status = entry.failed === undefined + ? (entry.status === undefined ? '(pending)' : String(entry.status)) + : `FAILED ${entry.failed}` + const duration = entry.durationMs === undefined ? '' : ` ${entry.durationMs}ms` + const size = entry.encodedDataLength === undefined ? '' : ` ${formatBytes(entry.encodedDataLength)}` + lines.push(`[${entry.index}] ${entry.method} ${entry.url}`) + lines.push(` ${status}${duration}${size} type=${entry.resourceType ?? '?'}${entry.fromDiskCache ? ' from-disk-cache' : ''}${entry.fromServiceWorker ? ' from-service-worker' : ''}`) + if (entry.pendingRequestEvent === true) { + lines.push(' (Chrome withheld the main event for this request; method and request headers may be incomplete)') + } + } + lines.push('', 'Use browser_devtools_get_request with an index for headers, timing, and (optionally) the response body.') + return lines.join('\n') +} + +/** Full detail of one captured request. */ +export function renderRequestDetail( + entry: NetworkEntry, + options: { includeSensitiveHeaders: boolean; includeSensitiveBody: boolean; body?: string; bodyTruncated: boolean }, +): string { + const lines: string[] = [] + lines.push(`[${entry.index}] ${entry.method} ${entry.url}`) + lines.push(`Type: ${entry.resourceType ?? 'unknown'}${entry.frameId === undefined ? '' : ` · frame ${entry.frameId}`}`) + if (entry.initiator !== undefined) lines.push(`Initiator: ${entry.initiator}`) + const status = entry.failed === undefined + ? `${entry.status ?? '(pending)'} ${entry.statusText ?? ''}`.trim() + : `FAILED: ${entry.failed}` + lines.push(`Status: ${status}${entry.blockedReason === undefined ? '' : ` (blocked: ${entry.blockedReason})`}`) + if (entry.mimeType !== undefined) lines.push(`MIME: ${entry.mimeType}`) + if (entry.durationMs !== undefined) lines.push(`Duration: ${entry.durationMs}ms`) + lines.push(`Transfer size: ${formatBytes(entry.encodedDataLength)}${entry.headerSize === undefined ? '' : ` (headers ${formatBytes(entry.headerSize)})`}`) + lines.push(`Timing: ${formatTiming(entry.timing, entry.durationMs)}`) + const cache: string[] = [] + if (entry.fromDiskCache) cache.push('disk cache') + if (entry.fromServiceWorker) cache.push('service worker') + if (cache.length > 0) lines.push(`Served from: ${cache.join(', ')}`) + if (entry.redirects.length > 0) { + lines.push('', 'Redirects:') + for (const hop of entry.redirects) lines.push(` ${hop.status} ${hop.url}`) + } + lines.push('', 'Request headers:') + lines.push(...formatHeaders(entry.requestHeaders, options.includeSensitiveHeaders)) + if (entry.postData !== undefined) { + lines.push('', 'Request payload:') + const sensitive = !options.includeSensitiveBody && isSensitiveBodyUrl(entry.url) + lines.push(sensitive ? REDACTED : truncateText(entry.postData, 4_000)) + } else if (entry.hasPostData) { + lines.push('', 'This request carries a payload; read it with browser_devtools_request_body kind=request.') + } + lines.push('', 'Response headers:') + lines.push(...formatHeaders(entry.responseHeaders, options.includeSensitiveHeaders)) + if (options.body !== undefined) { + const sensitive = !options.includeSensitiveBody && isSensitiveBodyUrl(entry.url) + lines.push('', 'Response body:') + lines.push(sensitive ? REDACTED : options.body) + if (options.bodyTruncated) lines.push('(body truncated)') + } + return lines.join('\n') +} + +/** Render one body fetch. */ +export function renderBody( + entry: NetworkEntry, + kind: 'request' | 'response', + body: string, + options: { truncated: boolean; json: boolean; redacted: boolean }, +): string { + const lines = [`[${entry.index}] ${kind} body of ${entry.method} ${entry.url}`] + if (options.redacted) { + lines.push('', REDACTED, 'This request looks authentication-related; pass includeSensitiveBody=true to read it.') + return lines.join('\n') + } + lines.push(`Format: ${options.json ? 'JSON (pretty-printed)' : 'text'}${options.truncated ? ' · truncated' : ''}`) + lines.push('', body) + if (options.truncated) lines.push('', '(body truncated to the configured budget)') + return lines.join('\n') +} + +/** Levels are exposed so the tool layer can validate its own filter input. */ +export function isConsoleLevel(value: string): value is ConsoleLevel { + return value === 'log' || value === 'info' || value === 'warning' || value === 'error' || value === 'exception' +} diff --git a/extensions/dsh-browser/src/background/devtools-network.ts b/extensions/dsh-browser/src/background/devtools-network.ts new file mode 100644 index 000000000..019ba59fe --- /dev/null +++ b/extensions/dsh-browser/src/background/devtools-network.ts @@ -0,0 +1,489 @@ +/** + * Network capture for the developer-tools group: folds `Network.*` CDP events + * into a bounded in-memory request log, plus the bounded console log the + * `Runtime` domain emits. + * + * The store is deliberately passive — it never issues commands — so the same + * instance can be fed from unit tests with synthetic event payloads. + * + * @module + */ + +import { + formatConsoleArguments, + formatException, + summarizeTiming, + type CdpHeader, + type TimingSummary, +} from './devtools-serialization.ts' + +/** + * Normalize a CDP header collection. + * + * `Network.requestWillBeSent` reports headers as an array of name/value pairs + * while the extra-info and response events report a plain map; both shapes reach + * this store, so the array form is the single internal representation. + */ +export function toHeaderList(value: unknown): CdpHeader[] { + if (Array.isArray(value)) return value as CdpHeader[] + if (typeof value === 'object' && value !== null) { + return Object.entries(value as Record).map(([name, header]) => ({ + name, + value: String(header), + })) + } + return [] +} + +/** Entries kept per attached tab; the oldest are dropped first. */ +export const NETWORK_BUFFER_LIMIT = 200 + +/** Console entries kept per attached tab. */ +export const CONSOLE_BUFFER_LIMIT = 500 + +/** Redirect hops kept per entry. */ +const REDIRECT_LIMIT = 10 + +/** One captured request/response pair. */ +export interface NetworkEntry { + /** Monotonic index inside the current capture generation; stable for the model. */ + index: number + requestId: string + url: string + method: string + /** CDP resource type (Document, XHR, Fetch, …). */ + resourceType?: string + requestHeaders: CdpHeader[] + /** Request payload when the browser reported it inline. */ + postData?: string + hasPostData: boolean + /** Wall-clock-ish monotonic timestamp of `requestWillBeSent` (seconds). */ + startTime: number + frameId?: string + initiator?: string + redirects: { url: string; status: number }[] + status?: number + statusText?: string + mimeType?: string + responseHeaders: CdpHeader[] + /** `Network.loadingFinished.encodedDataLength`. */ + encodedDataLength?: number + /** `Response.encodedDataLength` observed before the body finished loading. */ + headerSize?: number + timing?: TimingSummary + fromDiskCache: boolean + fromServiceWorker: boolean + /** `Network.loadingFailed` detail, when the request never completed. */ + failed?: string + blockedReason?: string + /** Duration in ms between request start and finish/failure. */ + durationMs?: number + finished: boolean + /** + * True while only the extra-info/response events have been seen, so the + * method and URL may still be stale. Reported to the model rather than + * silently presented as authoritative. + */ + pendingRequestEvent?: boolean +} + +/** Console event kinds the extension records. */ +export type ConsoleLevel = 'log' | 'info' | 'warning' | 'error' | 'exception' + +/** One recorded console entry. */ +export interface ConsoleEntry { + index: number + level: ConsoleLevel + timestamp: number + text: string +} + +/** Which capture generation a store currently holds; the model can detect resets. */ +export interface CaptureState { + attached: boolean + tabId?: number + entries: number + generation: number +} + +/** Either wire shape: the array form or the map form. */ +type HeaderList = readonly CdpHeader[] | Record | undefined + +interface RequestWillBeSentParams { + requestId?: string + request?: { url?: string; method?: string; headers?: HeaderList; postData?: string; hasPostData?: boolean } + type?: string + timestamp?: number + frameId?: string + initiator?: { type?: string; url?: string } + redirectResponse?: { url?: string; status?: number } +} + +/** + * `Network.requestWillBeSentExtraInfo`. + * + * Chrome emits this for requests whose main `Network.requestWillBeSent` event + * is withheld or arrives late (observed on live pages); it carries the request + * headers and the request id, which is enough to start tracking the request. + */ +interface RequestExtraInfoParams { + requestId?: string + headers?: HeaderList +} + +/** `Network.responseReceivedExtraInfo`: headers and status without a `Response`. */ +interface ResponseExtraInfoParams { + requestId?: string + headers?: HeaderList + statusCode?: number + headersText?: string +} + +interface ResponseReceivedParams { + requestId?: string + type?: string + response?: { + url?: string + status?: number + statusText?: string + mimeType?: string + headers?: HeaderList + encodedDataLength?: number + timing?: unknown + fromDiskCache?: boolean + fromServiceWorker?: boolean + } +} + +interface LoadingFinishedParams { + requestId?: string + timestamp?: number + encodedDataLength?: number +} + +interface LoadingFailedParams { + requestId?: string + timestamp?: number + errorText?: string + blockedReason?: string +} + +interface ConsoleApiParams { + type?: string + args?: unknown[] + timestamp?: number +} + +/** + * Bounded network + console capture for one attached tab. + * + * A generation counter increments whenever the log resets (attach or a + * top-level navigation), so the model can tell "no such index" from "the page + * navigated and the log started over". + */ +export class NetworkCapture { + private entries = new Map() + private order: string[] = [] + private sequence = 0 + private generation = 0 + private tabId: number | undefined + private attached = false + private consoleEntries: ConsoleEntry[] = [] + private consoleSequence = 0 + + /** Mark capture active for a tab and reset the log (a fresh attachment). */ + start(tabId: number): void { + this.tabId = tabId + this.attached = true + this.clear() + } + + /** Mark capture inactive; the log stays readable until the next start. */ + stop(): void { + this.attached = false + } + + /** Drop every captured entry and start a new generation. */ + clear(): void { + this.entries.clear() + this.order = [] + this.sequence = 0 + this.consoleEntries = [] + this.consoleSequence = 0 + this.generation += 1 + } + + /** Current capture state for status reporting. */ + state(): CaptureState { + return { + attached: this.attached, + ...(this.tabId === undefined ? {} : { tabId: this.tabId }), + entries: this.order.length, + generation: this.generation, + } + } + + /** Captured requests in arrival order. */ + list(): NetworkEntry[] { + return this.order.map((id) => this.entries.get(id)).filter((entry): entry is NetworkEntry => entry !== undefined) + } + + /** One captured request by its model-facing index. */ + get(index: number): NetworkEntry | undefined { + return this.order + .map((id) => this.entries.get(id)) + .find((entry) => entry !== undefined && entry.index === index) + } + + /** Recorded console entries in arrival order. */ + consoleLog(): ConsoleEntry[] { + return [...this.consoleEntries] + } + + /** + * Fold one CDP event into the store. Unknown methods are ignored so the + * session can forward everything it observes. + * @param method - CDP event name. + * @param params - CDP event payload. + */ + ingest(method: string, params: unknown): void { + switch (method) { + case 'Network.requestWillBeSent': + this.onRequestWillBeSent(params as RequestWillBeSentParams) + break + case 'Network.requestWillBeSentExtraInfo': + this.onRequestExtraInfo(params as RequestExtraInfoParams) + break + case 'Network.responseReceived': + this.onResponseReceived(params as ResponseReceivedParams) + break + case 'Network.responseReceivedExtraInfo': + this.onResponseExtraInfo(params as ResponseExtraInfoParams) + break + case 'Network.loadingFinished': + this.onLoadingFinished(params as LoadingFinishedParams) + break + case 'Network.loadingFailed': + this.onLoadingFailed(params as LoadingFailedParams) + break + case 'Runtime.consoleAPICalled': + this.onConsoleApi(params as ConsoleApiParams) + break + case 'Runtime.exceptionThrown': + this.onExceptionThrown(params) + break + default: + break + } + } + + /** A new top-level document means every previous index is stale. */ + ingestNavigation(): void { + this.clear() + } + + private onRequestWillBeSent(params: RequestWillBeSentParams): void { + const requestId = params.requestId + if (requestId === undefined) return + const existing = this.entries.get(requestId) + if (existing !== undefined && params.redirectResponse !== undefined) { + // A redirect reuses the request id: keep one entry and record each hop, + // including where the overwritten request had been heading. + if (existing.redirects.length < REDIRECT_LIMIT) { + existing.redirects.push({ url: existing.url, status: params.redirectResponse.status ?? 0 }) + } + existing.failed = undefined + delete existing.status + this.applyRequest(existing, params) + return + } + const entry = existing ?? this.createEntry(requestId, params) + entry.pendingRequestEvent = false + this.applyRequest(entry, params) + if (existing === undefined) this.push(entry) + } + + /** The one place entry indices are minted. */ + private createEntryFromId(requestId: string): NetworkEntry { + this.sequence += 1 + return { + index: this.sequence, + requestId, + url: '', + method: 'GET', + requestHeaders: [], + hasPostData: false, + startTime: 0, + redirects: [], + responseHeaders: [], + fromDiskCache: false, + fromServiceWorker: false, + finished: false, + } + } + + private createEntry(requestId: string, params: RequestWillBeSentParams): NetworkEntry { + const entry = this.createEntryFromId(requestId) + entry.url = params.request?.url ?? '' + entry.method = params.request?.method ?? 'GET' + entry.requestHeaders = toHeaderList(params.request?.headers) + entry.hasPostData = params.request?.hasPostData === true + entry.startTime = params.timestamp ?? 0 + return entry + } + + private applyRequest(entry: NetworkEntry, params: RequestWillBeSentParams): void { + if (params.request?.url !== undefined) entry.url = params.request.url + if (params.request?.method !== undefined) entry.method = params.request.method + if (params.request?.headers !== undefined) entry.requestHeaders = toHeaderList(params.request.headers) + if (params.request?.postData !== undefined) entry.postData = params.request.postData + if (params.request?.hasPostData === true) entry.hasPostData = true + if (params.type !== undefined) entry.resourceType = params.type + if (params.frameId !== undefined) entry.frameId = params.frameId + if (params.initiator !== undefined) { + entry.initiator = params.initiator.type === undefined + ? params.initiator.url + : `${params.initiator.type}${params.initiator.url === undefined ? '' : ` ${params.initiator.url}`}` + } + } + + /** + * Start tracking a request from its extra-info event when the main + * `requestWillBeSent` never arrives. Reporting the traffic matters more than + * insisting on the one event Chrome does not always send. + */ + private onRequestExtraInfo(params: RequestExtraInfoParams): void { + const requestId = params.requestId + if (requestId === undefined) return + if (this.entries.has(requestId)) { + const existing = this.entries.get(requestId)! + if (params.headers !== undefined && existing.requestHeaders.length === 0) { + existing.requestHeaders = toHeaderList(params.headers) + } + return + } + const entry = this.createEntryFromId(requestId) + entry.requestHeaders = toHeaderList(params.headers) + // Marked so the model knows why the request line has no method or URL yet. + entry.pendingRequestEvent = true + this.push(entry) + } + + private onResponseReceived(params: ResponseReceivedParams): void { + const requestId = params.requestId + if (requestId === undefined) return + const response = params.response + if (response === undefined) return + const entry = this.entries.get(requestId) ?? this.adoptResponse(requestId, response, params.type) + if (entry === undefined) return + if (response.url !== undefined) entry.url = response.url + entry.status = response.status + entry.statusText = response.statusText + entry.mimeType = response.mimeType + entry.responseHeaders = toHeaderList(response.headers) + entry.headerSize = response.encodedDataLength + entry.fromDiskCache = response.fromDiskCache === true + entry.fromServiceWorker = response.fromServiceWorker === true + if (params.type !== undefined) entry.resourceType = params.type + entry.timing = summarize(response.timing) + } + + /** Fold a headers-only response event into an existing (or new) entry. */ + private onResponseExtraInfo(params: ResponseExtraInfoParams): void { + const requestId = params.requestId + if (requestId === undefined) return + const entry = this.entries.get(requestId) ?? this.createEntryFromId(requestId) + if (!this.entries.has(requestId)) this.push(entry) + entry.responseHeaders = toHeaderList(params.headers) + if (typeof params.statusCode === 'number' && entry.status === undefined) entry.status = params.statusCode + } + + /** Build an entry from a response alone, when no request event was seen. */ + private adoptResponse( + requestId: string, + response: { url?: string; mimeType?: string }, + resourceType?: string, + ): NetworkEntry | undefined { + const entry = this.createEntryFromId(requestId) + if (response.url !== undefined) entry.url = response.url + if (response.mimeType !== undefined) entry.mimeType = response.mimeType + if (resourceType !== undefined) entry.resourceType = resourceType + entry.pendingRequestEvent = true + this.push(entry) + return entry + } + + private onLoadingFinished(params: LoadingFinishedParams): void { + const requestId = params.requestId + if (requestId === undefined) return + const entry = this.entries.get(requestId) + if (entry === undefined) return + entry.encodedDataLength = params.encodedDataLength + entry.finished = true + if (params.timestamp !== undefined && entry.startTime > 0) { + entry.durationMs = round(params.timestamp - entry.startTime) + } + } + + private onLoadingFailed(params: LoadingFailedParams): void { + const requestId = params.requestId + if (requestId === undefined) return + const entry = this.entries.get(requestId) + if (entry === undefined) return + entry.failed = params.errorText ?? 'request failed' + if (params.blockedReason !== undefined) entry.blockedReason = params.blockedReason + if (params.timestamp !== undefined && entry.startTime > 0) { + entry.durationMs = round(params.timestamp - entry.startTime) + } + } + + private onConsoleApi(params: ConsoleApiParams): void { + const level = consoleLevel(params.type) + if (level === undefined) return + this.pushConsole(level, params.timestamp ?? 0, params.args) + } + + private onExceptionThrown(params: unknown): void { + const payload = (params ?? {}) as { timestamp?: number; exceptionDetails?: unknown } + this.pushConsole('exception', payload.timestamp ?? 0, formatException(payload.exceptionDetails)) + } + + private pushConsole(level: ConsoleLevel, timestamp: number, args: unknown): void { + this.consoleSequence += 1 + const text = typeof args === 'string' ? args : formatConsoleArguments(args as readonly unknown[]) + this.consoleEntries.push({ index: this.consoleSequence, level, timestamp, text }) + if (this.consoleEntries.length > CONSOLE_BUFFER_LIMIT) { + this.consoleEntries.splice(0, this.consoleEntries.length - CONSOLE_BUFFER_LIMIT) + } + } + + private push(entry: NetworkEntry): void { + this.entries.set(entry.requestId, entry) + this.order.push(entry.requestId) + while (this.order.length > NETWORK_BUFFER_LIMIT) { + const dropped = this.order.shift() + if (dropped !== undefined) this.entries.delete(dropped) + } + } +} + +/** CDP console types the extension records; `debug`/`trace` stay out of the way. */ +function consoleLevel(type: string | undefined): ConsoleLevel | undefined { + switch (type) { + case 'log': return 'log' + case 'info': + case 'debug': return 'info' + case 'warning': return 'warning' + case 'error': + case 'assert': return 'error' + default: return undefined + } +} + +function summarize(timing: unknown): TimingSummary | undefined { + return summarizeTiming(timing) +} + +function round(value: number): number { + return Math.round(value * 1000 * 10) / 10 +} diff --git a/extensions/dsh-browser/src/background/devtools-serialization.ts b/extensions/dsh-browser/src/background/devtools-serialization.ts new file mode 100644 index 000000000..244dd5763 --- /dev/null +++ b/extensions/dsh-browser/src/background/devtools-serialization.ts @@ -0,0 +1,295 @@ +/** + * Pure formatting helpers for the developer-tools tools. + * + * Everything a CDP command or event returns is either page-authored, transport + * detail, or a protocol object; this module turns those into bounded, + * model-readable text. It is deliberately free of Chrome APIs and of I/O so the + * whole presentation layer is unit-testable. + * + * @module + */ + +/** Header names whose values never reach the model unless the user opts in per call. */ +const SENSITIVE_HEADERS = new Set([ + 'authorization', + 'cookie', + 'set-cookie', + 'proxy-authorization', + 'x-csrf-token', + 'x-xsrf-token', + 'x-api-key', + 'api-key', + 'x-auth-token', + 'x-access-token', + 'x-session-token', +]) + +/** URL shapes whose bodies are redacted by default (credentials travel there). */ +const SENSITIVE_BODY_URL = /(?:^|[^a-z])(auth|login|logout|signin|sign-in|signup|register|oauth|token|session|password|passwd|credential|apikey|api-key)(?:[^a-z]|$)/i + +/** One CDP header entry as `Network`/`Fetch` report it. */ +export interface CdpHeader { + name: string + value: string +} + +/** Redaction marker used in place of a hidden value. */ +export const REDACTED = '[redacted]' + +/** Whether a header name is treated as a credential carrier. */ +export function isSensitiveHeader(name: string): boolean { + return SENSITIVE_HEADERS.has(name.trim().toLowerCase()) +} + +/** Whether a request URL suggests a body that should be redacted by default. */ +export function isSensitiveBodyUrl(url: string): boolean { + return SENSITIVE_BODY_URL.test(url) +} + +/** + * Render a CDP header list as indented lines. + * @param headers - header entries in wire order. + * @param sensitive - when false (default) credential-bearing values are masked. + * @returns one line per header. + */ +export function formatHeaders(headers: readonly CdpHeader[] | undefined, sensitive = false): string[] { + if (headers === undefined || headers.length === 0) return [' (none)'] + return headers.map((header) => { + const value = !sensitive && isSensitiveHeader(header.name) ? REDACTED : header.value + return ` ${header.name}: ${value}` + }) +} + +/** Truncate text and state the original length when it was cut. */ +export function truncateText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + return `${text.slice(0, Math.max(0, maxChars))}\n…(truncated; ${text.length} characters total)` +} + +/** + * Pretty-print JSON when possible, otherwise return the text unchanged. + * @param text - candidate JSON text. + * @returns the formatted body and whether it parsed. + */ +export function formatBody(text: string): { text: string; json: boolean } { + const trimmed = text.trim() + if (trimmed === '' || (trimmed[0] !== '{' && trimmed[0] !== '[')) return { text, json: false } + try { + return { text: JSON.stringify(JSON.parse(trimmed), null, 2), json: true } + } catch { + return { text, json: false } + } +} + +/** Human-readable property list from a CDP `RemoteObject` preview. */ +export function previewProperties(value: unknown): string { + const preview = (value as { preview?: { properties?: { name: string; type: string; value?: string }[]; overflow?: boolean } } | null)?.preview + if (preview?.properties === undefined || preview.properties.length === 0) return '' + const parts = preview.properties.map((property) => { + const rendered = property.value === undefined ? property.type : property.value + return `${property.name}: ${rendered}` + }) + return `{${parts.join(', ')}${preview.overflow === true ? ', …' : ''}}` +} + +/** + * A short description of a CDP `RemoteObject` when no value could be returned. + * + * `description` already names the type (`Object`, `Map(2)`, a function source), + * so it is used alone when present; only a value-less object carries a `type` + * that reads like a label ("object", "function"). + */ +export function describeRemoteObject(value: unknown): string { + const remote = value as { type?: string; subtype?: string; className?: string; description?: string } | null + if (remote === null || remote === undefined) return 'undefined' + const description = remote.description ?? remote.className ?? '' + if (description !== '') return description + return remote.subtype ?? remote.type ?? 'value' +} + +/** + * Render the value of one `Runtime.evaluate` result. + * @param result - the command's `result` field. + * @param options - preview and character budget. + * @returns the model-facing text plus whether the value round-tripped by value. + */ +export function formatEvaluationResult( + result: unknown, + options: { maxChars: number; includePreview: boolean }, +): { text: string; byValue: boolean } { + const remote = result as { type?: string; subtype?: string; value?: unknown; unserializableValue?: string; description?: string } | null + if (remote === null || remote === undefined) return { text: 'undefined', byValue: true } + if (remote.type === 'undefined') return { text: 'undefined (the expression produced no value)', byValue: true } + if (remote.unserializableValue !== undefined) return { text: remote.unserializableValue, byValue: true } + if (remote.type === 'string') return { text: String(remote.value ?? remote.description ?? ''), byValue: true } + if (remote.type === 'boolean' || remote.type === 'number' || remote.type === 'bigint') { + return { text: String(remote.value ?? remote.description ?? ''), byValue: true } + } + if (remote.subtype === 'null') return { text: 'null', byValue: true } + + const lines: string[] = [] + if (remote.type === 'function') { + lines.push(`function: ${remote.description ?? '(anonymous)'}`) + } else if ('value' in remote && remote.value !== undefined) { + try { + lines.push(truncateText(JSON.stringify(remote.value, null, 2), options.maxChars)) + } catch { + lines.push(describeRemoteObject(remote)) + } + } else { + lines.push(describeRemoteObject(remote)) + } + if (options.includePreview) { + const preview = previewProperties(remote) + if (preview !== '') lines.push(preview) + } + return { text: lines.join('\n'), byValue: false } +} + +/** + * Render one `Runtime.evaluate` result including its exception, if any. + * @param response - the raw command result. + * @param options - preview and character budget. + * @returns the model-facing text and whether the evaluation completed. + */ +export function formatEvaluation( + response: unknown, + options: { maxChars: number; includePreview: boolean }, +): { text: string; ok: boolean } { + const payload = (response ?? {}) as { result?: unknown; exceptionDetails?: unknown } + if (payload.exceptionDetails !== undefined) { + return { text: formatException(payload.exceptionDetails), ok: false } + } + const formatted = formatEvaluationResult(payload.result, options) + return { text: formatted.text, ok: true } +} + +/** Render CDP `ExceptionDetails` (thrown value, text, stack) as text. */ +export function formatException(details: unknown): string { + const exception = details as { + text?: string + lineNumber?: number + columnNumber?: number + url?: string + exception?: { description?: string; value?: unknown } + stackTrace?: { callFrames?: { functionName?: string; url?: string; lineNumber?: number; columnNumber?: number }[] } + } | null + if (exception === null || exception === undefined) return 'Evaluation failed with no details.' + const lines: string[] = [] + const thrown = exception.exception?.description + ?? (exception.exception?.value === undefined ? undefined : safeStringify(exception.exception.value)) + lines.push(`Evaluation threw: ${thrown ?? exception.text ?? 'unknown error'}`) + const location = exception.url === undefined || exception.lineNumber === undefined + ? '' + : ` at ${exception.url}:${exception.lineNumber + 1}:${(exception.columnNumber ?? 0) + 1}` + if (location !== '') lines.push(`Thrown${location}`) + for (const frame of exception.stackTrace?.callFrames?.slice(0, 8) ?? []) { + lines.push(` at ${frame.functionName === '' ? '(anonymous)' : frame.functionName ?? '(anonymous)'} (${frame.url ?? '?'}:${(frame.lineNumber ?? 0) + 1}:${(frame.columnNumber ?? 0) + 1})`) + } + return lines.join('\n') +} + +/** + * Render the arguments of one `Runtime.consoleAPICalled` event. + * @param args - CDP `RemoteObject` arguments. + * @returns a single readable line body. + */ +export function formatConsoleArguments(args: readonly unknown[] | undefined): string { + if (args === undefined || args.length === 0) return '(no arguments)' + return args.map((argument) => { + const remote = argument as { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string } | null + if (remote === null || remote === undefined) return 'undefined' + if (remote.type === 'string') return String(remote.value ?? '') + if (remote.unserializableValue !== undefined) return remote.unserializableValue + if (remote.value !== undefined && (remote.type === 'number' || remote.type === 'boolean')) return String(remote.value) + if (remote.description !== undefined) return remote.description + if (remote.value === undefined) return remote.type ?? 'value' + return safeStringify(remote.value) + }).join(' ') +} + +/** Serializable timing breakdown in milliseconds, relative to request start. */ +export interface TimingSummary { + dns: number + connect: number + tls: number + wait: number + receive: number + total: number +} + +/** + * Convert CDP `ResourceTiming` into relative millisecond phases. + * @param timing - the response timing object. + * @returns phase durations, or undefined when timing is unavailable. + */ +export function summarizeTiming(timing: unknown): TimingSummary | undefined { + const value = timing as Partial<{ + requestTime: number + dnsStart: number + dnsEnd: number + connectStart: number + connectEnd: number + sslStart: number + sslEnd: number + sendStart: number + sendEnd: number + receiveHeadersStart: number + receiveHeadersEnd: number + }> | null + if (value === null || value === undefined || typeof value.requestTime !== 'number') return undefined + const ms = (from: number | undefined, to: number | undefined): number => { + if (typeof from !== 'number' || typeof to !== 'number' || from < 0 || to < 0 || to < from) return 0 + return Math.round((to - from) * 1000 * 10) / 10 + } + const dns = ms(value.dnsStart, value.dnsEnd) + const connect = ms(value.connectStart, value.connectEnd) + const tls = ms(value.sslStart, value.sslEnd) + const receiveHeadersStart = typeof value.receiveHeadersStart === 'number' ? value.receiveHeadersStart : value.sendEnd + const wait = ms(receiveHeadersStart, value.receiveHeadersEnd) + // `requestTime` and the phase values share the monotonic clock, so the total + // is request-start to headers-received. Negative deltas (clock skew, missing + // phases) collapse to zero instead of reporting nonsense. + const totalMs = typeof value.receiveHeadersEnd === 'number' && value.receiveHeadersEnd >= value.requestTime + ? Math.round((value.receiveHeadersEnd - value.requestTime) * 1000 * 10) / 10 + : 0 + return { + dns, + connect, + tls, + wait, + receive: 0, + total: totalMs, + } +} + +/** + * Render a timing breakdown as one line. + * @param timing - phase breakdown from `ResourceTiming`. + * @param measuredMs - measured request duration, used when the phases carry no + * usable total (a cache hit reports zeros, which reads as "instant"). + */ +export function formatTiming(timing: TimingSummary | undefined, measuredMs?: number): string { + const measured = measuredMs === undefined ? '' : ` · measured ${measuredMs}ms` + if (timing === undefined) return `timing unavailable${measured}` + const total = timing.total > 0 ? timing.total : measuredMs + const renderedTotal = total === undefined || total === 0 ? 'unavailable' : `${total}ms` + return `dns ${timing.dns}ms · connect ${timing.connect}ms · tls ${timing.tls}ms · wait ${timing.wait}ms · total ${renderedTotal}` +} + +/** Format a byte count for display. */ +export function formatBytes(bytes: number | undefined): string { + if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return '?' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +/** JSON.stringify that never throws on cycles or exotic values. */ +export function safeStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value) + } catch { + return Object.prototype.toString.call(value) + } +} diff --git a/extensions/dsh-browser/src/background/discovery.ts b/extensions/dsh-browser/src/background/discovery.ts new file mode 100644 index 000000000..5c9ed94d9 --- /dev/null +++ b/extensions/dsh-browser/src/background/discovery.ts @@ -0,0 +1,251 @@ +/** + * Bridge discovery for the side panel. + * + * DSH Desktop launches its host with `--port 0`, so the local Web port changes + * on every launch. Discovery therefore runs in two stages: + * + * 1. a bounded group of known ports — the URL that worked last time, the + * `dsh web` defaults, and the plugin's fixed discovery beacon; + * 2. one sweep of the platform's ephemeral range, which is where the OS puts a + * host that has no beacon (an older plugin, or a beacon port already taken). + * + * The startup pass stops at the first answer; the settings page enumerates + * everything so the user can pick between several local instances. Nothing here + * schedules itself: the caller decides when a pass runs. + * + * @module + */ + +import { + BEACON_PORTS, + LEGACY_BRIDGE_PORTS, + BRIDGE_CONFIG_PATH, + parseBridgeConfigPayload, + type BridgeConfigPayload, +} from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' +import { SWEEP_CONCURRENCY, SWEEP_DEADLINE_MS, SWEEP_PROBE_TIMEOUT_MS } from './port-scan.ts' + +/** How long one known-port probe may take before it counts as absent. */ +export const GROUP_PROBE_TIMEOUT_MS = 1_500 + +/** How often a sweep reports progress. */ +const PROGRESS_INTERVAL_MS = 250 + +/** Where a discovered instance came from. */ +export type InstanceSource = 'fixed' | 'beacon' | 'scan' + +/** One reachable dsh bridge. */ +export interface BridgeInstance { + /** Bridge WebSocket URL, as advertised by the host. */ + wsUrl: string + /** Port of that URL, for display and ordering. */ + port: number + /** Every path that found it; more than one means the answers agreed. */ + sources: InstanceSource[] + /** Host process id, when the plugin advertises it. */ + pid?: number + /** Host start time (ISO 8601), when the plugin advertises it. */ + startedAt?: string +} + +/** Progress of one discovering pass. */ +export interface DiscoveryProgress { + /** Ports probed so far. */ + done: number + /** Ports this pass intends to probe. */ + total: number + /** Instances found so far, in discovery order. */ + instances: BridgeInstance[] +} + +/** Side effects one pass needs; all injectable so the runner stays testable. */ +export interface DiscoveryEnv { + /** Read the discovery answer of one port, or `undefined` when nothing answers. */ + probe(port: number, timeoutMs: number): Promise + /** Whether the caller still wants results (panel lease, user cancellation). */ + shouldContinue(): boolean + /** Called while a sweep runs, throttled. */ + onProgress?(progress: DiscoveryProgress): void +} + +/** One ordered batch of probes. */ +export interface DiscoveryGroup { + source: InstanceSource + ports: readonly number[] +} + +/** What one pass should probe, in order. */ +export interface DiscoveryPlan { + groups: DiscoveryGroup[] + /** Ports to sweep after the groups. */ + sweep: number[] +} + +/** Inputs for {@link planDiscovery}. */ +export interface DiscoveryPlanOptions { + /** URL that worked last time; probed before the fixed list. */ + rememberedUrl?: string + /** URL already tried and known to fail, never probed again. */ + excludeUrl?: string + /** Ports of the platform's ephemeral range (see `port-scan.ts`). */ + sweepPorts: readonly number[] +} + +/** Loopback URL of one port's discovery endpoint. */ +export function discoveryUrl(port: number): string { + return `http://127.0.0.1:${port}${BRIDGE_CONFIG_PATH}` +} + +/** Port of a bridge URL, or `undefined` when it does not parse. */ +export function portOfUrl(url: string | undefined): number | undefined { + if (url === undefined || url === '') return undefined + try { + const port = Number(new URL(url).port) + return Number.isInteger(port) && port > 0 ? port : undefined + } catch { + return undefined + } +} + +/** + * Read one discovery answer and validate it. + * @param port - loopback port to ask. + * @param timeoutMs - per-probe budget. + * @param fetchImpl - injectable fetch. + * @returns the advertised instance, or `undefined` when the port is not a dsh bridge. + */ +export async function readBridgeConfig( + port: number, + timeoutMs: number, + fetchImpl: typeof fetch = fetch, +): Promise { + try { + const response = await fetchImpl(discoveryUrl(port), { signal: AbortSignal.timeout(timeoutMs) }) + if (!response.ok) return undefined + return parseBridgeConfigPayload(await response.json()) + } catch { + return undefined + } +} + +/** + * Build the probe order for one pass. + * + * The remembered URL comes first (the common case: the next launch reused a + * port), then the `dsh web` defaults, then the beacon block. Ports appearing + * twice are probed once. + * @param options - remembered URL, an address to skip, and the sweep ports. + * @returns groups plus a sweep list that excludes what the groups already cover. + */ +export function planDiscovery(options: DiscoveryPlanOptions): DiscoveryPlan { + const probed = new Set() + const excluded = portOfUrl(options.excludeUrl) + if (excluded !== undefined) probed.add(excluded) + const groups: DiscoveryGroup[] = [] + const push = (source: InstanceSource, ports: readonly number[]): void => { + const fresh = ports.filter((port) => !probed.has(port)) + for (const port of fresh) probed.add(port) + if (fresh.length > 0) groups.push({ source, ports: fresh }) + } + const remembered = portOfUrl(options.rememberedUrl) + if (remembered !== undefined) push('fixed', [remembered]) + push('fixed', LEGACY_BRIDGE_PORTS) + push('beacon', BEACON_PORTS) + return { + groups, + sweep: options.sweepPorts.filter((port) => !probed.has(port)), + } +} + +/** + * Run one discovery pass. + * @param plan - probe order from {@link planDiscovery}. + * @param env - probe, cancellation, and progress hooks. + * @param options - `stopAtFirstHit` ends the pass as soon as a group answers (startup). + * @returns every instance found, in discovery order. + */ +export async function discoverInstances( + plan: DiscoveryPlan, + env: DiscoveryEnv, + options: { stopAtFirstHit: boolean }, +): Promise { + const found = new Map() + const record = (payload: BridgeConfigPayload, source: InstanceSource): void => { + const existing = found.get(payload.wsUrl) + if (existing === undefined) { + found.set(payload.wsUrl, { + wsUrl: payload.wsUrl, + port: portOfUrl(payload.wsUrl) ?? 0, + sources: [source], + ...(payload.pid === undefined ? {} : { pid: payload.pid }), + ...(payload.startedAt === undefined ? {} : { startedAt: payload.startedAt }), + }) + return + } + if (!existing.sources.includes(source)) existing.sources.push(source) + } + const snapshot = (): BridgeInstance[] => [...found.values()] + + for (const group of plan.groups) { + if (!env.shouldContinue()) return snapshot() + const answers = await Promise.all(group.ports.map(async port => ({ + payload: await env.probe(port, GROUP_PROBE_TIMEOUT_MS), + }))) + for (const { payload } of answers) { + if (payload !== undefined) record(payload, group.source) + } + if (options.stopAtFirstHit && found.size > 0) return snapshot() + } + + if (plan.sweep.length > 0 && env.shouldContinue()) { + await sweepPorts(plan.sweep, env, record, snapshot) + } + return snapshot() +} + +/** + * Probe a port list with bounded concurrency, collecting every answer. + * @param ports - ports to probe, in ascending order. + * @param env - probe and cancellation hooks. + * @param record - sink for one accepted answer. + * @param snapshot - current instance list, for progress reporting. + */ +async function sweepPorts( + ports: readonly number[], + env: DiscoveryEnv, + record: (payload: BridgeConfigPayload, source: InstanceSource) => void, + snapshot: () => BridgeInstance[], +): Promise { + const startedAt = Date.now() + const total = ports.length + let next = 0 + let done = 0 + let lastEmit = 0 + const emit = (force: boolean): void => { + if (env.onProgress === undefined) return + const now = Date.now() + if (!force && now - lastEmit < PROGRESS_INTERVAL_MS) return + lastEmit = now + env.onProgress({ done, total, instances: snapshot() }) + } + emit(true) + const worker = async (): Promise => { + for (;;) { + if (!env.shouldContinue()) return + if (Date.now() - startedAt > SWEEP_DEADLINE_MS) return + const index = next + next += 1 + if (index >= ports.length) return + const payload = await env.probe(ports[index] as number, SWEEP_PROBE_TIMEOUT_MS) + done += 1 + if (payload !== undefined) { + record(payload, 'scan') + emit(true) + } else { + emit(false) + } + } + } + await Promise.all(Array.from({ length: Math.min(SWEEP_CONCURRENCY, ports.length) }, worker)) + emit(true) +} diff --git a/extensions/dsh-browser/src/background/index.ts b/extensions/dsh-browser/src/background/index.ts index 2ca9ea6a3..4e3502f82 100644 --- a/extensions/dsh-browser/src/background/index.ts +++ b/extensions/dsh-browser/src/background/index.ts @@ -10,6 +10,7 @@ * panel → bg: { type: 'rpc', id, method, payload } * panel → bg: { type: 'respond', id, rpcId, result } * panel → bg: { type: 'settings', id, settings: Partial } + * panel → bg: { type: 'devtools.detach', id } * panel → bg: { type: 'session.active', sessionId } * panel → bg: { type: 'approval.response', id, decision } * panel → bg: { type: 'tab-affinity.response', revision, decision, sessionId } @@ -17,10 +18,16 @@ * panel → bg: { type: 'panel.window', windowId } * panel → bg: { type: 'selection.clear', selection? } * panel → bg: { type: 'request-status' } + * panel → bg: { type: 'discovery.start', id } + * panel → bg: { type: 'discovery.cancel' } + * bg → panel: { type: 'discovery.progress', id, done, total, instances } + * bg → panel: { type: 'discovery.result', id, ok, instances? | error? } * bg → panel: { type: 'rpc.result', id, ok, result? | error? } * bg → panel: { type: 'respond.result', id, ok, result? | error? } * bg → panel: { type: 'settings.result', id, ok, error? } - * bg → panel: { type: 'status', state: BridgeState, caps? } + * bg → panel: { type: 'devtools.status', supported, enabled, attached, tabId?, error? } + * bg → panel: { type: 'devtools.detach.result', id, ok, error? } + * bg → panel: { type: 'status', state: BridgeState, caps?, discovery, address? } * bg → panel: { type: 'event', frame: ServerFrame } * bg → panel: { type: 'approval.request', request } * bg → panel: { type: 'approval.resolved', id } @@ -39,9 +46,12 @@ import { type RespondResult, } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import type { ServerFrame } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' -import { BRIDGE_CONFIG_PATH, BRIDGE_PATH } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' +import { BRIDGE_CONFIG_PATH, BRIDGE_PATH, DEVTOOLS_CAPS_VERSION, parseBridgeConfigPayload } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import { BridgeClient, type BridgeState } from './bridge.ts' import { createRpc } from './rpc.ts' +import { discoverInstances, planDiscovery, readBridgeConfig } from './discovery.ts' +import { rangePorts, sweepRanges } from './port-scan.ts' +import { listTabFrames } from './frames.ts' import { dispatchOpenTab, dispatchToolCall, @@ -75,6 +85,9 @@ import { FocusedWindowTracker } from './focused-window.ts' import { SelectionTracker, type SelectionSource } from './selection.ts' import { parsePageSelection, parseSelectionCapture } from '../selection.ts' import { ApprovalCoordinator, type ApprovalRequestResult } from './approval-coordinator.ts' +import { devToolsSupported } from './debugger-session.ts' +import { createDevToolsController } from './devtools-controller.ts' +import { devToolsApprovalForCall, isDevToolsTool, resolveApprovalGate } from './authorization.ts' import { LEGACY_RECENT_SESSION_STORAGE_KEY, PAGE_SESSION_CONTEXT_STORAGE_KEY, @@ -88,6 +101,12 @@ export interface Settings { sharePageContent: 'ask' | 'auto' | 'off' /** Allow every browser operation without an approval prompt. */ unrestrictedBrowserAccess: boolean + /** + * High-privilege developer tools (element/CSS debugging, page JavaScript, + * network capture). Off by default: while it is off every devtools call + * raises its own approval prompt, and the origin allowlist never covers it. + */ + allowDevTools: boolean /** Origins whose state-changing actions may run without another prompt. */ trustedActionOrigins: string[] /** Show an OS notification when no side panel can display an approval. */ @@ -102,53 +121,155 @@ const SETTINGS_DEFAULTS: Settings = { token: '', sharePageContent: 'auto', unrestrictedBrowserAccess: false, + allowDevTools: false, trustedActionOrigins: [], approvalNotifications: true, autoResumeSession: true, } /** - * 自动探测的候选端口: - * - dsh web(CLI)默认 3080,端口被占时依次回退 3081 / 3090; - * - DSH Desktop 默认由系统随机分配本地 Web 端口(`dsh-desktop.port: 0`), - * 用户指南推荐固定为 43189(见 deepseek-harness-desktop docs/user-guide); - * - 14389 为历史桌面应用端口,保留兼容旧版。 + * 自动探测:DSH Desktop 启动 Host 时用 `--port 0`,本地 Web 端口每次启动都由系统 + * 随机分配(Windows/macOS 49152–65535,Linux 32768–60999),固定端口列表无法预知。 + * 因此探测分两段: + * 1. 上次成功的地址 → `dsh web` 默认端口 → 插件固定端口信标(43189–43198); + * 2. 平台临时端口区间扫描(兜底没有信标的旧插件)。 + * 只在面板打开时跑一次、或用户点「自动检测」时跑;未找到即终态,不再重试。 */ -const DISCOVERY_PORTS = [3080, 3081, 3090, 14389, 43189] const LEGACY_LOCAL_URL = 'ws://127.0.0.1:3080' +const LAST_BRIDGE_URL_KEY = 'dshLastBridgeUrl' +const DISCOVERY_LATCH_KEY = 'dshDiscoveryLatch' + +/** 一次面板租约内的自动探测闸门;`not-found` 表示已确认没有服务,不再重试。 */ +type DiscoveryLatch = 'idle' | 'running' | 'not-found' + +/** 报给面板的探测状态,与传输层状态分开。 */ +type DiscoveryState = 'idle' | 'running' | 'found' | 'not-found' + +let discoveryLatch: DiscoveryLatch = 'idle' +let discoveryState: DiscoveryState = 'idle' +/** 上次成功连接的桥地址,下次探测时优先尝试。 */ +let lastBridgeUrl: string | undefined +/** 当前连接实际使用的地址(手动填写或探测得到)。 */ +let connectedAddress: string | undefined +/** 用户点「自动检测」发起的枚举,同一时间只跑一个。 */ +let activeScan: { id: string; controller: AbortController } | undefined +/** `chrome.runtime.getPlatformInfo()` 的平台标识,用于选择扫描区间。 */ +let platformOs: string | undefined + +/** 读取平台标识(失败时按未知平台处理)。 */ +async function ensurePlatformOs(): Promise { + if (platformOs !== undefined) return + try { + const info = await chrome.runtime.getPlatformInfo?.() + platformOs = typeof info?.os === 'string' ? info.os : undefined + } catch { + platformOs = undefined + } +} -/** 探测本机 dsh 的桥地址:fetch /ext/bridge-config 直到成功。 */ -async function discoverBridge(shouldContinue: () => boolean = () => true): Promise { - for (const port of DISCOVERY_PORTS) { - if (!shouldContinue()) return undefined - try { - const response = await fetch(`http://127.0.0.1:${port}/ext/bridge-config`, { - signal: AbortSignal.timeout(1_500), - }) - if (!shouldContinue()) return undefined - if (!response.ok) continue - const body = await response.json() as { wsUrl?: unknown } - if (typeof body.wsUrl === 'string' && body.wsUrl.startsWith('ws://')) return body.wsUrl - } catch { - // 该端口没有 dsh 或未挂桥:试下一个。 +/** 回环地址判定:只有本机地址才在连接失败时回落到自动探测。 */ +function isLocalBridgeAddress(url: string): boolean { + try { + const host = new URL(url).hostname.toLowerCase() + return host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1' + } catch { + return false + } +} + +/** 记下成功的地址,供下次面板打开时优先尝试。 */ +function rememberBridgeUrl(url: string): void { + if (lastBridgeUrl === url) return + lastBridgeUrl = url + void Promise.resolve(chrome.storage.local.set({ [LAST_BRIDGE_URL_KEY]: url })).catch(() => {}) +} + +/** 跨 service worker 重启保留「未找到」,避免后台重启后偷偷重试。 */ +function persistDiscoveryLatch(value: DiscoveryLatch): void { + const area = (chrome.storage as { session?: chrome.storage.StorageArea }).session + if (area === undefined) return + void Promise.resolve(area.set({ [DISCOVERY_LATCH_KEY]: value })).catch(() => {}) +} + +/** 恢复上次运行留下的闸门与最近地址。 */ +async function loadDiscoveryMemory(): Promise { + try { + const stored = await chrome.storage.local.get(LAST_BRIDGE_URL_KEY) + const remembered = stored?.[LAST_BRIDGE_URL_KEY] + if (typeof remembered === 'string' && remembered !== '') lastBridgeUrl = remembered + } catch { + // 记忆地址只是加速手段,读不到就按无记忆处理。 + } + const area = (chrome.storage as { session?: chrome.storage.StorageArea }).session + if (area === undefined) return + try { + const stored = await area.get(DISCOVERY_LATCH_KEY) + if (stored?.[DISCOVERY_LATCH_KEY] === 'not-found') { + discoveryLatch = 'not-found' + discoveryState = 'not-found' + } + } catch { + // 同上:读不到按 idle 处理。 + } +} + +const discoveryMemory = loadDiscoveryMemory() + +/** 运行一次自动探测,返回首个命中的桥地址。 */ +async function discoverBridgeUrl( + shouldContinue: () => boolean, + excludeUrl?: string, +): Promise { + await discoveryMemory + await ensurePlatformOs() + if (!shouldContinue()) return undefined + const previousState = discoveryState + discoveryLatch = 'running' + discoveryState = 'running' + broadcastStatus() + try { + const instances = await discoverInstances(planDiscovery({ + ...(lastBridgeUrl === undefined ? {} : { rememberedUrl: lastBridgeUrl }), + ...(excludeUrl === undefined ? {} : { excludeUrl }), + sweepPorts: rangePorts(sweepRanges(platformOs)), + }), { + probe: (port, timeoutMs) => readBridgeConfig(port, timeoutMs), + shouldContinue, + }, { stopAtFirstHit: true }) + // A pass that lost its lease (panel closed, newer settings) reports nothing + // and leaves the confirmed state of the lease that replaced it untouched. + if (!shouldContinue()) { + discoveryState = previousState + return undefined } + const first = instances[0] + if (first === undefined) { + discoveryLatch = 'not-found' + discoveryState = 'not-found' + persistDiscoveryLatch('not-found') + return undefined + } + discoveryLatch = 'idle' + discoveryState = 'found' + rememberBridgeUrl(first.wsUrl) + return first.wsUrl + } finally { + if (discoveryLatch === 'running') discoveryLatch = 'idle' } - return undefined } /** Avoid opening a noisy loopback WebSocket until the local bridge responds. */ async function probeBridge(url: string): Promise { try { + if (!isLocalBridgeAddress(url)) return true const target = new URL(url) - if (target.hostname !== '127.0.0.1') return true target.protocol = target.protocol === 'wss:' ? 'https:' : 'http:' target.pathname = BRIDGE_CONFIG_PATH target.search = '' target.hash = '' const response = await fetch(target, { signal: AbortSignal.timeout(1_500) }) if (!response.ok) return false - const body = await response.json() as { wsUrl?: unknown } - return typeof body.wsUrl === 'string' && body.wsUrl.startsWith('ws://') + return parseBridgeConfigPayload(await response.json() as unknown) !== undefined } catch { return false } @@ -178,6 +299,30 @@ const transientEvents = new TransientEventCache() const tabAffinity = new TabAffinityController() const focusedWindow = new FocusedWindowTracker() const selections = new SelectionTracker() + +/** Whether this browser build can serve the developer-tools group at all. */ +const devToolsCapability = devToolsSupported() + +/** + * High-privilege developer tools. The controller refuses every call unless the + * user's setting is on, and the background still routes each call through the + * approval gate first. + */ +const devTools = createDevToolsController({ + // Consent is enforced by the approval gate in `dispatchDevToolsTool`: with the + // setting off every call raises its own dialog, and unrelated switches never + // cover this group. The controller only owns capability and target policy. + resolveTarget: async (sessionId) => { + const target = await resolveToolTab(sessionId) + if ('ok' in target || target.id === undefined) return undefined + return { + tabId: target.id, + ...(target.url === undefined ? {} : { url: target.url }), + ...(target.title === undefined ? {} : { title: target.title }), + } + }, + onStateChange: () => { broadcastDevToolsState() }, +}) const pageSessionContexts = new PageSessionContextTracker({ read: async () => (await chrome.storage.session.get(PAGE_SESSION_CONTEXT_STORAGE_KEY))[PAGE_SESSION_CONTEXT_STORAGE_KEY], write: async (value) => { @@ -187,9 +332,17 @@ const pageSessionContexts = new PageSessionContextTracker({ void chrome.storage.session.remove(LEGACY_RECENT_SESSION_STORAGE_KEY).catch(() => {}) /** Ephemeral allowlist: cleared when the last side panel closes or this worker restarts. */ const sessionTrustedActionOrigins = new Set() +/** + * Ephemeral developer-tools grant for this panel lease. Set when the user + * chooses "trust this session" on a devtools prompt; never persisted, and + * never implied by the origin allowlist or unrestricted access. + */ +let sessionDevToolsGrant = false /** Tool calls that are either withdrawable or completing an already-dispatched action. */ interface ActiveToolCall { controller: AbortController + /** Tool name, so a revocation can target the developer-tools group only. */ + name: string unrestrictedAccess: boolean committed: boolean revocationRequested: boolean @@ -266,8 +419,11 @@ async function persistSettings(next: Partial): Promise { const accessRevision = changesUnrestrictedAccess ? ++unrestrictedAccessRevision : unrestrictedAccessRevision const updated = normalizeSettings({ ...settings, ...next }) const revokesUnrestrictedAccess = settings.unrestrictedBrowserAccess && !updated.unrestrictedBrowserAccess + const changesDevTools = typeof next.allowDevTools === 'boolean' + const revokesDevTools = changesDevTools && (settings.allowDevTools || sessionDevToolsGrant) && !updated.allowDevTools settings = updated if (!updated.unrestrictedBrowserAccess) unrestrictedAccessActive = false + if (revokesDevTools) sessionDevToolsGrant = false syncSelectionWatch() let accessTransition: Promise | undefined if (revokesUnrestrictedAccess) { @@ -297,6 +453,21 @@ async function persistSettings(next: Partial): Promise { unrestrictedAccessActive = true syncSelectionWatch() } + if (changesDevTools) { + // Turning the allowance off is a revocation: detach the debugger and abort + // every in-flight devtools call so the capability cannot outlive the choice. + if (revokesDevTools) { + cancelDevToolsCalls() + await devTools.revoke() + } else if (updated.allowDevTools) { + devTools.resume() + await prewarmDevTools() + } else { + // Still off: nothing to prewarm, and no consent was granted. + devTools.resume() + } + broadcastDevToolsState() + } } function normalizeSettings(candidate: Settings): Settings { @@ -310,6 +481,9 @@ function normalizeSettings(candidate: Settings): Settings { ...candidate, sharePageContent, unrestrictedBrowserAccess: candidate.unrestrictedBrowserAccess === true, + // A browser without a debugger API can never use the developer tools, so + // the persisted choice is kept but the runtime path stays disabled. + allowDevTools: candidate.allowDevTools === true && devToolsCapability, trustedActionOrigins: trusted, approvalNotifications: candidate.approvalNotifications !== false, autoResumeSession: candidate.autoResumeSession !== false, @@ -332,13 +506,50 @@ function disarmBridgeKeepalive(): void { void Promise.resolve(chrome.alarms.clear(BRIDGE_KEEPALIVE_ALARM)).catch(() => {}) } +/** One status snapshot: transport state, capabilities, and discovery state. */ +function statusPayload(): { + type: 'status' + state: BridgeState + caps: BridgeCaps | null + discovery: DiscoveryState + address?: string +} { + return { + type: 'status', + state: bridge?.state ?? ('stopped' as BridgeState), + caps, + discovery: discoveryState, + ...(connectedAddress === undefined ? {} : { address: connectedAddress }), + } +} + function broadcastStatus(): void { - const payload = { type: 'status', state: bridge?.state ?? ('stopped' as BridgeState), caps } + const payload = statusPayload() for (const port of panelPorts) { try { port.postMessage(payload) } catch { /* port already closed */ } } } +/** Broadcast developer-tools support, attachment state, and the user's choice. */ +function broadcastDevToolsState(): void { + for (const port of panelPorts) postDevToolsState(port) +} + +/** Report developer-tools state to one panel port. */ +function postDevToolsState(port: chrome.runtime.Port): void { + const state = devTools.state() + try { + port.postMessage({ + type: 'devtools.status', + supported: state.supported, + enabled: settingsLoaded && settings.allowDevTools, + attached: state.attached, + ...(state.tabId === undefined ? {} : { tabId: state.tabId }), + ...(state.error === undefined ? {} : { error: state.error }), + }) + } catch { /* port already closed */ } +} + function broadcastTabAffinity(): void { const payload = { type: 'tab-affinity', state: tabAffinity.snapshot() } for (const port of panelPorts) { @@ -405,6 +616,15 @@ function unrestrictedAccessEnabled(): boolean { return unrestrictedAccessActive && unrestrictedRevocation === undefined } +/** + * Whether the user has opted into developer tools without a per-call prompt. + * The ephemeral session grant is applied by the approval gate instead, so this + * reports only the persisted setting. + */ +function devToolsAllowed(): boolean { + return settingsLoaded && settings.allowDevTools +} + /** Page selections are captured only in a window with an open panel. */ function selectionWatchEnabled(windowId: number): boolean { return selectionSharingEnabled() && hasPanelInWindow(windowId) @@ -884,18 +1104,31 @@ async function authorizeToolCall( unrestrictedAccess: boolean = unrestrictedAccessEnabled(), ): Promise { if (signal.aborted) return 'cancelled' - if (unrestrictedAccess) return 'approved' - if (actionCoveredByTrustedOrigins( + const gate = resolveApprovalGate({ prompt, - sessionTrustedActionOrigins, - settings.trustedActionOrigins, - )) { - return 'approved' - } + devToolsAllowed: devToolsAllowed(), + devToolsSessionGrant: sessionDevToolsGrant, + unrestrictedAccess, + coveredByTrustedOrigins: actionCoveredByTrustedOrigins( + prompt, + sessionTrustedActionOrigins, + settings.trustedActionOrigins, + ), + }) + if (gate !== 'prompt') return gate const result: ApprovalRequestResult = await approvals.request(prompt, signal, windowId, sessionId) if (signal.aborted) return 'cancelled' if (result.status !== 'decision') return result.status const { decision } = result + if (prompt.devTools === true) { + // "Trust this session" and the dedicated decision both arm the ephemeral + // devtools grant until the panel lease, the worker, or the setting ends it. + if (decision === 'trust-session' || decision === 'trust-dev-tools') { + sessionDevToolsGrant = true + return 'approved' + } + return decision === 'allow-once' ? 'approved' : 'denied' + } if (decision === 'always-allow-reads' && prompt.kind === 'read') { await persistSettings({ sharePageContent: 'auto' }) return 'approved' @@ -1063,6 +1296,7 @@ function routeToolCall(call: ToolCall): void { let settle!: () => void const activeCall: ActiveToolCall = { controller, + name: call.name, unrestrictedAccess, committed: false, revocationRequested: false, @@ -1116,6 +1350,8 @@ function routeToolCall(call: ToolCall): void { } void (isTabManagementTool(call.name) ? managementDispatch() + : isDevToolsTool(call.name) + ? dispatchDevToolsTool(call, controller.signal) : call.name === 'browser_open_tab' ? resolveOpenTabWindow(call.sessionId).then((target) => 'ok' in target ? target @@ -1203,6 +1439,78 @@ function cancelToolCall(id: string): void { if (call !== undefined && !call.committed) call.controller.abort() } +/** + * Run one developer-tools call: approve it, then hand it to the debugger + * controller. Devtools calls take a dedicated path because they never touch the + * content script and must not be satisfied by the page-sharing relaxations. + */ +async function dispatchDevToolsTool(call: ToolCall, signal: AbortSignal): Promise { + if (signal.aborted) return devToolsCancelled() + const target = await resolveToolTab(call.sessionId) + if ('ok' in target) return target + if (signal.aborted) return devToolsCancelled() + const frames = await listTabFrames(target.id!, target.url) + const prompt = devToolsApprovalForCall(call, frames) + const authorization = await authorizeToolCall(prompt, signal, target.windowId ?? 0, call.sessionId) + if (signal.aborted) return devToolsCancelled() + if (authorization !== 'approved') { + return { + ok: false, + error: { code: authorization === 'timed-out' ? 'timeout' : 'action-failed', message: approvalFailureMessage(prompt, authorization) }, + } + } + try { + return { ok: true, result: await devTools.handle(call.name, call.args, call.sessionId) } + } catch (error: unknown) { + return { + ok: false, + error: { + code: signal.aborted ? 'bridge-closed' : 'action-failed', + message: error instanceof Error ? error.message : String(error), + }, + } + } +} + +function devToolsCancelled(): ToolAnswer { + return { ok: false, error: { code: 'bridge-closed', message: 'The browser tool call was cancelled.' } } +} + +/** Abort every in-flight developer-tools call (setting revoked). */ +function cancelDevToolsCalls(): void { + for (const call of activeToolCalls.values()) { + if (isDevToolsTool(call.name) && !call.committed) call.controller.abort() + } +} + +/** + * Attach the debugger to the controlled tab as soon as the user enables the + * allowance, so network capture sees traffic from the start instead of only + * after the first devtools call. + */ +async function prewarmDevTools(): Promise { + await affinityReady + const target = await resolveToolTab() + if ('ok' in target || target.id === undefined) return + await devTools.prewarm(target.id).catch(() => { + // Attach is lazy by design: a tab that cannot be debugged right now only + // surfaces an error when a devtools call actually needs it. + }) +} + +function approvalFailureMessage(prompt: ApprovalPrompt, authorization: Exclude): string { + switch (authorization) { + case 'denied': + return `The user denied the browser approval request for "${prompt.action}".` + case 'unavailable': + return `No browser side panel was available to receive or complete the approval request for "${prompt.action}".` + case 'timed-out': + return `The browser approval request for "${prompt.action}" timed out before the user responded.` + case 'cancelled': + return `The browser approval request for "${prompt.action}" was cancelled.` + } +} + async function revokeUnrestrictedAccess(): Promise { const calls = [...unsettledToolCalls].filter((call) => call.unrestrictedAccess) const refreshes = [...activeFollowRefreshes.values()].filter((refresh) => refresh.unrestrictedAccess) @@ -1224,21 +1532,42 @@ function cancelAllToolCalls(): void { activeToolCalls.clear() } -/** (Re)start the bridge with the current settings. 零配置:地址留空时自动探测;回环连接无需 token。 */ +/** + * (Re)start the bridge with the current settings. + * + * Order of preference: a filled-in address is tried first (it is the user's + * choice), and only a *loopback* address that fails its pre-flight falls back + * to one automatic discovery pass. With no address the pass runs directly. + * Nothing retries once that pass finds nothing. + */ async function startBridge(): Promise { - const revision = ++bridgeStartRevision if (panelPorts.size === 0) return + // One automatic pass per lease: a second panel in another window joins it + // instead of launching a competing scan. Checked before the revision bump so + // a duplicate trigger cannot invalidate the pass it is deferring to. + if (discoveryLatch === 'running') return + const revision = ++bridgeStartRevision + const lease = (): boolean => revision === bridgeStartRevision && panelPorts.size > 0 let url = settings.bridgeUrl - if (url === '') { - url = await discoverBridge(() => revision === bridgeStartRevision && panelPorts.size > 0) ?? '' + if (url !== '') { + if (isLocalBridgeAddress(url) && !await probeBridge(url)) { + if (!lease()) return + url = await discoverBridgeUrl(lease, url) ?? '' + } + } else if (discoveryLatch !== 'not-found') { + url = await discoverBridgeUrl(lease) ?? '' + } else { + // Already confirmed absent in this lease: report and wait for the user. + url = '' } // Discovery is asynchronous. A panel may have closed or a newer settings // update may have started while its fetches were in flight. - if (revision !== bridgeStartRevision || panelPorts.size === 0) return + if (!lease()) return if (url === '') { bridge?.stop() bridge = null rpc = null + connectedAddress = undefined broadcastStatus() return } @@ -1251,6 +1580,7 @@ async function startBridge(): Promise { } catch { // 非法 URL 原样交给 WebSocket 构造函数报错。 } + connectedAddress = url if (bridge === null) { const client = new BridgeClient({ onStateChange: (state) => { @@ -1278,13 +1608,91 @@ async function startBridge(): Promise { broadcastStatus() void pushBudgetToControlledTab(negotiated) }, - }, probeBridge, () => panelPorts.size > 0) + }, probeBridge, () => panelPorts.size > 0, { + // Advertised as soon as the platform can serve it, so the host registers + // the developer-tools group; the user's setting and per-call approval + // still gate every one of those calls. + ...devToolsCapability ? { devTools: { version: DEVTOOLS_CAPS_VERSION } } : {}, + }) bridge = client rpc = createRpc(client) } bridge.start(url, settings.token) } +/** + * Enumerate every local instance for the settings picker. + * + * Unlike the startup pass this never stops early: the fixed and beacon groups + * run first for instant feedback, then the whole ephemeral range is swept. + * Results stream back to the requesting panel; cancellation only ends the sweep. + * @param port - panel port that asked. + * @param id - request id echoed on every message. + */ +async function runPanelDiscovery(port: chrome.runtime.Port, id: string): Promise { + activeScan?.controller.abort() + const controller = new AbortController() + activeScan = { id, controller } + discoveryLatch = 'idle' + discoveryState = 'running' + persistDiscoveryLatch('idle') + broadcastStatus() + const reply = (message: unknown): void => { + try { port.postMessage(message) } catch { /* port closed */ } + } + try { + await discoveryMemory + await ensurePlatformOs() + const instances = await discoverInstances(planDiscovery({ + ...(lastBridgeUrl === undefined ? {} : { rememberedUrl: lastBridgeUrl }), + sweepPorts: rangePorts(sweepRanges(platformOs)), + }), { + probe: (candidate, timeoutMs) => readBridgeConfig(candidate, timeoutMs), + shouldContinue: () => !controller.signal.aborted, + onProgress: (progress) => { + if (activeScan?.id !== id) return + reply({ + type: 'discovery.progress', + id, + done: progress.done, + total: progress.total, + instances: progress.instances, + }) + }, + }, { stopAtFirstHit: false }) + if (activeScan?.id !== id) return + if (controller.signal.aborted) { + // A cancelled scan keeps whatever state the bridge already had. + discoveryState = bridge?.state === 'connected' ? 'found' : 'idle' + broadcastStatus() + reply({ type: 'discovery.result', id, ok: false, error: { code: 'cancelled', message: 'discovery cancelled' } }) + return + } + if (instances.length > 0) { + discoveryLatch = 'idle' + discoveryState = 'found' + rememberBridgeUrl(instances[0]!.wsUrl) + } else { + discoveryLatch = 'not-found' + discoveryState = 'not-found' + persistDiscoveryLatch('not-found') + } + broadcastStatus() + reply({ type: 'discovery.result', id, ok: true, instances }) + } catch (error) { + if (activeScan?.id !== id) return + discoveryLatch = 'idle' + reply({ + type: 'discovery.result', + id, + ok: false, + error: { code: 'scan-failed', message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (activeScan?.id === id) activeScan = undefined + } +} + /** Gateway RPC with a helpful error when the bridge is down. */ async function gatewayRpc(method: string, payload: unknown): Promise { if (rpc === null || bridge === null || !bridge.connected) { @@ -1331,9 +1739,13 @@ chrome.runtime.onConnect.addListener((port) => { void settingsReady.then(syncSelectionWatch) void settingsReady.then(() => { if (!panelPorts.has(port)) return + // One automatic pass per panel lease: a confirmed miss stays terminal until + // the lease ends or the user asks again (settings save / 自动检测). + if (discoveryLatch === 'not-found') return if (bridge === null || bridge.state === 'stopped') return startBridge() }) - try { port.postMessage({ type: 'status', state: bridge?.state ?? ('stopped' as BridgeState), caps }) } catch { /* port closed */ } + try { port.postMessage(statusPayload()) } catch { /* port closed */ } + broadcastDevToolsState() void affinityReady.then(async () => { await syncActiveTab() try { port.postMessage({ type: 'tab-affinity', state: tabAffinity.snapshot() }) } catch { /* port closed */ } @@ -1402,6 +1814,11 @@ chrome.runtime.onConnect.addListener((port) => { await persistSettings(settingsMsg.settings) const connectionChanged = settings.bridgeUrl !== previousConnection.bridgeUrl || settings.token !== previousConnection.token + if (connectionChanged) { + // An explicit address supersedes a confirmed miss from this lease. + discoveryLatch = 'idle' + persistDiscoveryLatch('idle') + } if (panelPorts.size > 0) { if (connectionChanged) await startBridge() broadcastStatus() @@ -1448,6 +1865,42 @@ chrome.runtime.onConnect.addListener((port) => { void postResumeHint(port, registration.windowId) break } + case 'devtools.detach': { + // The user asked for an immediate detach from the settings page; keep + // the persisted allowance but drop the current attachment. + const request = message as { id?: unknown } + void devTools.detachAll().then( + () => { + broadcastDevToolsState() + if (typeof request.id === 'string') { + try { port.postMessage({ type: 'devtools.detach.result', id: request.id, ok: true }) } catch { /* port closed */ } + } + }, + (error: unknown) => { + if (typeof request.id !== 'string') return + try { + port.postMessage({ + type: 'devtools.detach.result', + id: request.id, + ok: false, + error: { message: error instanceof Error ? error.message : String(error) }, + }) + } catch { /* port closed */ } + }, + ) + break + } + case 'discovery.start': { + // Manual scan from the settings page: enumerate every local instance. + const request = message as { id?: unknown } + if (typeof request.id !== 'string') break + void runPanelDiscovery(port, request.id) + break + } + case 'discovery.cancel': { + activeScan?.controller.abort() + break + } case 'selection.clear': { // The user sent, dismissed, or explicitly abandoned this window's // quote. A send/dismiss names the value it acted on so a newer capture @@ -1563,6 +2016,9 @@ chrome.runtime.onConnect.addListener((port) => { case 'request-status': try { port.postMessage({ type: 'status', state: bridge?.state ?? ('stopped' as BridgeState), caps }) + // A push on connect cannot reach a panel that has not subscribed yet, + // so the status request is also the pull path for developer tools. + postDevToolsState(port) port.postMessage({ type: 'tab-affinity', state: tabAffinity.snapshot() }) const statusWindowId = panelWindows.get(port) if (statusWindowId !== undefined) { @@ -1603,6 +2059,12 @@ chrome.runtime.onConnect.addListener((port) => { bridge?.suspendReconnect() sessionTrustedActionOrigins.clear() approvals.notifyPending() + // A fresh panel lease re-runs the automatic pass, so a miss is not final + // for the browser session — only for the lease it was observed in. + activeScan?.controller.abort() + discoveryLatch = 'idle' + discoveryState = 'idle' + persistDiscoveryLatch('idle') if (bridge?.state !== 'connected') disarmBridgeKeepalive() } }) @@ -1694,6 +2156,12 @@ chrome.webNavigation.onCommitted.addListener(({ tabId, frameId }) => { broadcastSelections(selections.clearTab(tabId, frameId)) }) +// A closed tab cannot stay debugged; release the attachment immediately so the +// "being debugged" banner never outlives its page. +chrome.tabs.onRemoved.addListener((tabId) => { + devTools.releaseTab(tabId) +}) + // Ports are cleaned up by their own disconnect; only the window's quote is // left behind when the whole window goes away. chrome.windows.onRemoved.addListener((windowId) => { @@ -1710,6 +2178,9 @@ chrome.windows.onFocusChanged.addListener((windowId) => { chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name !== BRIDGE_KEEPALIVE_ALARM) return + // A confirmed miss is terminal for this lease: do not re-probe in the + // background, and do not wake the worker for it either. + if (discoveryLatch === 'not-found') return if (panelPorts.size === 0) { if (bridge === null || bridge.state !== 'connected') disarmBridgeKeepalive() return diff --git a/extensions/dsh-browser/src/background/port-scan.ts b/extensions/dsh-browser/src/background/port-scan.ts new file mode 100644 index 000000000..107dba555 --- /dev/null +++ b/extensions/dsh-browser/src/background/port-scan.ts @@ -0,0 +1,70 @@ +/** + * Loopback port ranges worth sweeping for a local dsh, plus the budgets that + * keep one sweep bounded. + * + * DSH Desktop launches its host with `--port 0`, so the OS assigns the port + * from its ephemeral range: 49152–65535 on Windows and macOS, 32768–60999 on + * Linux by default. The plugin's discovery beacon covers current builds; the + * sweep is the fallback for a host that predates the beacon or could not bind + * one of its ports. Sweeps run only when the user opens the panel or asks for + * the instance list — never on a timer. + * + * @module + */ + +/** One inclusive port range to probe. */ +export interface PortRange { + start: number + end: number +} + +/** + * Concurrent probes in one sweep. Loopback refusals return immediately, so a + * modest fan-out finishes a full range in a few seconds without swamping the + * network stack of a small machine. + */ +export const SWEEP_CONCURRENCY = 64 + +/** Per-probe budget; a listening loopback port answers in milliseconds. */ +export const SWEEP_PROBE_TIMEOUT_MS = 400 + +/** Wall-clock budget for one sweep, after which the remaining ports are skipped. */ +export const SWEEP_DEADLINE_MS = 20_000 + +/** + * Ephemeral port ranges for the current platform. + * @param os - `chrome.runtime.getPlatformInfo().os`, or undefined when unknown. + * @returns ranges to probe, in ascending order. + */ +export function sweepRanges(os: string | undefined): PortRange[] { + switch (os) { + case 'linux': + case 'cros': + return [{ start: 32768, end: 60999 }] + case 'win': + case 'mac': + case 'openbsd': + case 'fuchsia': + return [{ start: 49152, end: 65535 }] + default: + // Unknown platform: cover both conventions with one superset range. + return [{ start: 32768, end: 65535 }] + } +} + +/** + * Every port of the given ranges, ascending and deduplicated. + * @param ranges - ranges from {@link sweepRanges}. + */ +export function rangePorts(ranges: readonly PortRange[]): number[] { + const seen = new Set() + const ports: number[] = [] + for (const range of ranges) { + for (let port = range.start; port <= range.end; port += 1) { + if (seen.has(port)) continue + seen.add(port) + ports.push(port) + } + } + return ports.sort((left, right) => left - right) +} diff --git a/extensions/dsh-browser/src/background/tools.ts b/extensions/dsh-browser/src/background/tools.ts index 0fc3069a1..0f49e9319 100644 --- a/extensions/dsh-browser/src/background/tools.ts +++ b/extensions/dsh-browser/src/background/tools.ts @@ -655,6 +655,104 @@ async function dispatchTabManagementTool( return unavailable(`Unsupported tab-management tool: ${call.name}`) } +/** Parse the model-supplied screenshot capture options. */ +function screenshotCaptureOptions(call: ToolCall): { format: 'png' | 'jpeg'; quality: number } | ToolAnswer { + const format = call.args.format === undefined ? 'png' : call.args.format + if (format !== 'png' && format !== 'jpeg') { + return { ok: false, error: { code: 'action-failed', message: 'format must be "png" or "jpeg".' } } + } + const quality = call.args.quality === undefined ? 85 : call.args.quality + if (typeof quality !== 'number' || !Number.isInteger(quality) || quality < 1 || quality > 100) { + return { ok: false, error: { code: 'action-failed', message: 'quality must be an integer between 1 and 100.' } } + } + return { format, quality } +} + +/** Decode a captureVisibleTab data URL into base64 pixels and media type. */ +function decodeCaptureDataUrl(dataUrl: string): { data: string; mediaType: string } | undefined { + const comma = dataUrl.indexOf(',') + if (comma === -1) return undefined + const header = /^data:([^;,]+);base64$/.exec(dataUrl.slice(0, comma)) + if (header === null) return undefined + const data = dataUrl.slice(comma + 1) + return data === '' ? undefined : { data, mediaType: header[1] ?? '' } +} + +async function measureImageBytes(data: string, mediaType: string): Promise<{ width: number; height: number } | undefined> { + try { + const binary = atob(data) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + const bitmap = await createImageBitmap(new Blob([bytes as BlobPart], { type: mediaType })) + try { + return { width: bitmap.width, height: bitmap.height } + } finally { + bitmap.close() + } + } catch { + // Dimensions are advisory metadata; the capture stays deliverable. + return undefined + } +} + +/** + * Capture the controlled tab's viewport. `captureVisibleTab` photographs the + * active tab of the window, so a controlled-but-inactive tab is a hard error + * rather than a silent capture of the wrong page. + */ +async function dispatchScreenshot( + tab: Pick & { active?: boolean; windowId?: number }, + call: ToolCall, + signal?: AbortSignal, + targetStillAllowed?: () => boolean, +): Promise { + if (isCancelled(call, signal)) return cancelled() + if (targetStillAllowed?.() === false) return targetChanged() + const options = screenshotCaptureOptions(call) + if ('ok' in options) return options + let active = tab.active + let windowId = tab.windowId + if (active === undefined || windowId === undefined) { + const fresh = await findTab(tab.id!) + if (fresh === undefined) return unavailable('The controlled tab closed before the screenshot was captured.') + active = fresh.active + windowId = fresh.windowId + } + if (isCancelled(call, signal)) return cancelled() + if (targetStillAllowed?.() === false) return targetChanged() + if (active !== true) { + return unavailable('The controlled tab is not the active tab of its window, so its viewport cannot be captured. Activate the tab first, then retry.') + } + if (windowId === undefined) { + return unavailable('The controlled tab has no window, so its viewport cannot be captured.') + } + let dataUrl: string + try { + dataUrl = await chrome.tabs.captureVisibleTab(windowId, { + format: options.format, + ...(options.format === 'jpeg' ? { quality: options.quality } : {}), + }) + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error) + return unavailable(`The screenshot could not be captured: ${detail}`) + } + if (isCancelled(call, signal)) return cancelled() + if (targetStillAllowed?.() === false) return targetChanged() + const captured = decodeCaptureDataUrl(dataUrl) + if (captured === undefined) return unavailable('The browser returned the screenshot in an unsupported format.') + const dimensions = await measureImageBytes(captured.data, captured.mediaType) + return { + ok: true, + result: { + image: { + data: captured.data, + mediaType: captured.mediaType, + ...dimensions === undefined ? {} : dimensions, + }, + }, + } +} + /** * Dispatch one tool call to the selected tab's content script. * @param call - the tool call to execute. @@ -689,7 +787,7 @@ export async function dispatchToolCall( // Privacy boundary: with sharing off, no page content may leave the page. if (!tabManagement.unrestrictedAccess && sharePageContent === 'off' - && (call.name === 'browser_snapshot' || call.name === 'browser_get_text')) { + && (call.name === 'browser_snapshot' || call.name === 'browser_get_text' || call.name === 'browser_screenshot')) { return { ok: false, error: { code: 'action-failed', message: 'Page content sharing is disabled in Settings > Page content sharing.' } } } const tab = targetTab ?? (await chrome.tabs.query({ active: true, lastFocusedWindow: true }))[0] @@ -703,7 +801,7 @@ export async function dispatchToolCall( if (targetStillAllowed?.() === false) return targetChanged() const frameError = validateFrameTarget(call, frames) if (frameError !== undefined) return frameError - if (!isInjectablePage(tab.url) && !TAB_NATIVE_TOOLS.has(call.name)) { + if (!isInjectablePage(tab.url) && !TAB_NATIVE_TOOLS.has(call.name) && call.name !== 'browser_screenshot') { return unavailable('The current page DOM is protected by the browser. Only snapshot metadata, navigate, back, forward, and reload are available on this page.') } const targetError = validateElementTarget(call, tab.id, frames) @@ -734,9 +832,15 @@ export async function dispatchToolCall( if (refreshedTargetError !== undefined) return refreshedTargetError } if (!isInjectablePage(tab.url)) { + if (call.name === 'browser_screenshot') { + return unavailable('A screenshot cannot be captured on this page: the browser protects pages outside http and https.') + } return await dispatchTabNativeTool(tab.id, tab.url, tab.title, tab.windowId, call, effectiveBudget, signal, targetStillAllowed, tabManagement.commitAction) ?? unavailable('The current page DOM is protected by the browser.') } + if (call.name === 'browser_screenshot') { + return await dispatchScreenshot(tab, call, signal, targetStillAllowed) + } try { return await dispatchOnce( tab.id, diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 6d3d93602..04d6db7f7 100644 --- a/extensions/dsh-browser/src/panel/App.tsx +++ b/extensions/dsh-browser/src/panel/App.tsx @@ -13,7 +13,7 @@ import type { BridgeCaps } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import type { ServerFrame } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import type { BridgeState } from '../background/bridge.ts' import type { AffinityTab, TabAffinityDecision, TabAffinityState } from '../background/tab-affinity.ts' -import { connectPanel, PanelRpcError, type PanelApi, type PanelSettings } from './api.ts' +import { connectPanel, PanelRpcError, type BridgeInstance, type DevToolsStatus, type DiscoveryStatus, type PanelApi, type PanelSettings } from './api.ts' import { renderMarkdown } from './markdown.ts' import whaleUrl from '../../assets/icons/deepseek-256.png' import type { ApprovalDecision, ApprovalRequest } from '../security/approval.ts' @@ -64,6 +64,29 @@ import { type ComposerDraft, } from './composer.ts' import { isNearScrollBottom } from './scroll.ts' +import { + isExperimentalPreset, + isSelectablePreset, + parseSessionPermissions, + permissionLabel, + requiresRiskConfirmation, + type SessionPermissionDirectory, +} from './permissions.ts' +import { + localSlashEntries, + parseCommandDirectory, + parseSkillDirectory, + rankSlashEntries, + resolveComposerLine, + slashEntries, + slashQuery, + type ComposerCatalogs, + type ComposerCommandDirectory, + type ComposerSkillDirectory, + type HostCommand, + type HostSkill, + type SlashEntry, +} from './commands.ts' import { latestSessionTitle, projectedSessionTitle, @@ -94,6 +117,32 @@ function normalizeWebOrigin(value: string): string | null { return normalizeTrustedOrigin(value) ?? null } +/** One-line reason for an unavailable composer control, without raw stack text. */ +function describeCatalogFailure(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} + +/** + * The stable code a structured RPC failure carries, read structurally: the + * error class identity is not part of the contract, and reading it as + * `instanceof` would break any consumer that replaces the api module. + */ +function errorCodeOf(cause: unknown): string | undefined { + return typeof cause === 'object' && cause !== null && typeof (cause as { code?: unknown }).code === 'string' + ? (cause as { code: string }).code + : undefined +} + +/** True when the bridge predates a Host method the panel now calls. */ +function isUnavailableMethod(cause: unknown): boolean { + return errorCodeOf(cause) === 'not-found' || /is unavailable/iu.test(describeCatalogFailure(cause)) +} + +/** True when the Host refused a command because the Session is still deferred. */ +function isProvisionalFailure(cause: unknown): boolean { + return errorCodeOf(cause) === 'provisional-session' +} + /** One editable API-relay profile in the settings view. */ interface RelayProfileDraft { /** Stable llm-pi-ai provider-route key; empty until first save mints it. */ @@ -117,6 +166,8 @@ interface SessionModelOption { providerName: string id: string name: string + /** Reasoning efforts this model declares; empty when it declares none. */ + efforts: Array<{ id: string; name: string }> } /** Advisory directory snapshot used by the composer model switcher. */ @@ -124,6 +175,14 @@ interface SessionModelDirectory { current: { provider: string; model: string; reasoningEffort?: string } | null options: SessionModelOption[] routable: boolean | null + /** Set when the last directory read failed, so the chip can explain itself. */ + error?: string +} + +/** One pending risk confirmation before a preset switch is submitted. */ +interface PermissionConfirmation { + preset: string + label: string } /** @@ -185,6 +244,18 @@ function isAuthFailure(cause: unknown): boolean { && (cause.code.includes('credential') || /\b401\b|\b403\b|invalid[ _-]?key/i.test(cause.message)) } +/** Extract image files from a paste or drop payload; empty when it carries none. */ +export function transferredImageFiles(dataTransfer: DataTransfer | null): File[] { + if (dataTransfer === null) return [] + const files: File[] = [] + for (const item of dataTransfer.items) { + if (item.kind !== 'file' || !item.type.startsWith('image/')) continue + const file = item.getAsFile() + if (file !== null) files.push(file) + } + return files +} + async function discoverOnce( api: PanelApi, profile: RelayProfileDraft, @@ -470,6 +541,7 @@ function ApprovalDialog({ return () => { window.removeEventListener('keydown', onKeyDown) } }, [request.id, onDecision]) + const devTools = request.devTools === true return (
@@ -478,7 +550,9 @@ function ApprovalDialog({
{copy.approval.eyebrow} -

{request.kind === 'read' ? copy.approval.readTitle : copy.approval.actionTitle}

+

+ {devTools ? copy.approval.devToolsTitle : request.kind === 'read' ? copy.approval.readTitle : copy.approval.actionTitle} +

@@ -494,23 +568,48 @@ function ApprovalDialog({
- {request.kind === 'read' && ( + {!devTools && request.kind === 'read' && ( )} - {request.kind === 'action' && request.canTrust && request.origins.length === 1 && ( + {!devTools && request.kind === 'action' && request.canTrust && request.origins.length === 1 && ( )} + {devTools && ( + + )}
- {request.kind === 'read' - ? copy.approval.readFootnote - : copy.approval.actionFootnote} + {devTools + ? copy.approval.devToolsFootnote + : request.kind === 'read' + ? copy.approval.readFootnote + : copy.approval.actionFootnote}
) } +/** + * One-line description of the current developer-tools state. + * + * Before the background reports anything, the truthful description is the + * default posture: the capability is off and every call asks for approval. + */ +/** Whether the reported state carries a failure worth emphasizing. */ +function hasDevToolsError(status: DevToolsStatus | null): boolean { + return status !== null && status.error !== undefined && status.error !== '' +} + +function devToolsStatusText(status: DevToolsStatus | null, copy: PanelCopy): string { + if (status === null) return copy.settings.devToolsOff + if (!status.supported) return copy.settings.devToolsUnsupported + if (status.error !== undefined && status.error !== '') return `${copy.settings.devToolsError} ${status.error}` + if (!status.enabled) return copy.settings.devToolsOff + if (status.attached) return `${copy.settings.devToolsAttached}${status.tabId === undefined ? '' : ` (tab ${status.tabId})`}` + return copy.settings.devToolsIdle +} + /** * A page quote: the highlighted text plus where it came from. The quote is * page-authored, so it renders as plain text and never as markdown. @@ -660,6 +759,23 @@ export function App(): React.JSX.Element { const [modelSelecting, setModelSelecting] = useState(false) const [showModelPicker, setShowModelPicker] = useState(false) const modelPickerRef = useRef(null) + const [sessionPermissions, setSessionPermissions] = useState(null) + const [permissionBusy, setPermissionBusy] = useState(false) + const [showPermissionPicker, setShowPermissionPicker] = useState(false) + const [permissionConfirm, setPermissionConfirm] = useState(null) + const [permissionError, setPermissionError] = useState(null) + const permissionPickerRef = useRef(null) + const [commandDirectory, setCommandDirectory] = useState(null) + const [skillDirectory, setSkillDirectory] = useState(null) + const [showSlashMenu, setShowSlashMenu] = useState(false) + const [slashIndex, setSlashIndex] = useState(0) + const [notice, setNotice] = useState(null) + const slashMenuRef = useRef(null) + const caretRef = useRef(0) + const composerCatalogsRef = useRef<{ commands: HostCommand[]; skills: HostSkill[] }>({ + commands: [], + skills: [], + }) const [sessionTitle, setSessionTitle] = useState(null) const [resumeHint, setResumeHint] = useState<{ ready: boolean; sessionId: string | null }>({ ready: false, sessionId: null }) const [questions, setQuestions] = useState([]) @@ -795,6 +911,7 @@ export function App(): React.JSX.Element { token: raw?.token ?? '', sharePageContent: raw?.sharePageContent ?? 'auto', unrestrictedBrowserAccess: raw?.unrestrictedBrowserAccess ?? false, + allowDevTools: raw?.allowDevTools ?? false, trustedActionOrigins: raw?.trustedActionOrigins ?? [], approvalNotifications: raw?.approvalNotifications ?? true, autoResumeSession: raw?.autoResumeSession ?? true, @@ -802,15 +919,60 @@ export function App(): React.JSX.Element { }) }, []) + // Developer-tools state lives in the background: it owns the debugger + // attachment and reports every change here. + const [devToolsStatus, setDevToolsStatus] = useState(null) + useEffect(() => api.onDevToolsStatus(setDevToolsStatus), [api]) + + // Discovery state reported by the background. 'not-found' is terminal for the + // current panel lease, so the status chip can say so instead of "未连接". + const [discovery, setDiscovery] = useState('idle') + const [bridgeAddress, setBridgeAddress] = useState(null) + // Manual scan from the settings page: the only writer of this list. + const [detecting, setDetecting] = useState(false) + const [detected, setDetected] = useState([]) + const [detectAttempted, setDetectAttempted] = useState(false) + const [detectProgress, setDetectProgress] = useState<{ done: number; total: number } | null>(null) + const [detectError, setDetectError] = useState(null) + + useEffect(() => api.onDiscoveryProgress((progress) => { + setDetectProgress({ done: progress.done, total: progress.total }) + setDetected(progress.instances) + }), [api]) + + const runDetection = async (): Promise => { + if (detecting) { + await api.cancelDiscovery().catch(() => {}) + return + } + setDetecting(true) + setDetectError(null) + setDetected([]) + setDetectProgress(null) + try { + setDetected(await api.startDiscovery()) + } catch (cause) { + // Cancellation is a user action, not an error worth showing. + if (cause instanceof PanelRpcError && cause.code === 'cancelled') return + setDetectError(cause instanceof Error ? cause.message : String(cause)) + } finally { + setDetectAttempted(true) + setDetecting(false) + setDetectProgress(null) + } + } + // 每次连接重启(连接配置变更/断线重连)都新建会话。状态消息逐条监听: // React 会把 stopped/connecting 等瞬时状态合并进同一帧渲染,依赖渲染 // 状态无法可靠观察到"连接已重置",因此在这里按消息粒度判定。 const [sessionEpoch, setSessionEpoch] = useState(0) const lastStateRef = useRef(null) useEffect(() => { - const offStatus = api.onStatus((next, nextCaps) => { + const offStatus = api.onStatus((next, nextCaps, nextDiscovery, nextAddress) => { setState(next) setCaps(nextCaps) + setDiscovery(nextDiscovery) + setBridgeAddress(nextAddress ?? null) const previous = lastStateRef.current lastStateRef.current = next if (previous !== null && next !== previous && next === 'stopped') { @@ -838,10 +1000,24 @@ export function App(): React.JSX.Element { setStopping(false) setSessionChanging(false) setShowSessionPicker(false) + setSessionPermissions(null) + setPermissionBusy(false) + setShowPermissionPicker(false) + setPermissionConfirm(null) + setPermissionError(null) + setCommandDirectory(null) + setSkillDirectory(null) + setShowSlashMenu(false) + setNotice(null) stoppingRef.current = false clearQuestions() setSessionEpoch((epoch) => epoch + 1) } + if (previous !== null && next !== previous && next === 'connected' && sessionRef.current !== null) { + // A reconnect can outlive a directory read; reload instead of trusting + // whatever the previous bridge generation answered. + void refreshComposerDirectories(sessionRef.current) + } }) const offEvent = api.onEvent((frame) => { void onFrame(frame) }) const offApproval = api.onApprovalRequest((request) => { @@ -924,6 +1100,55 @@ export function App(): React.JSX.Element { } }, [showModelPicker]) + // The permission menu is a sibling overlay, so it owns its own dismissal. + useEffect(() => { + if (!showPermissionPicker) return + const onPointerDown = (event: PointerEvent): void => { + const root = permissionPickerRef.current + if (root !== null && event.target instanceof Node && root.contains(event.target)) return + setShowPermissionPicker(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') setShowPermissionPicker(false) + } + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('keydown', onKeyDown, true) + return () => { + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('keydown', onKeyDown, true) + } + }, [showPermissionPicker]) + + // The slash menu belongs to the composer: a pointer outside both closes it. + useEffect(() => { + if (!showSlashMenu) return + const onPointerDown = (event: PointerEvent): void => { + const menu = slashMenuRef.current + if (menu !== null && event.target instanceof Node && menu.contains(event.target)) return + if (event.target instanceof Element && event.target.closest('.composer-box') !== null) return + setShowSlashMenu(false) + } + document.addEventListener('pointerdown', onPointerDown, true) + return () => { document.removeEventListener('pointerdown', onPointerDown, true) } + }, [showSlashMenu]) + + // A missed `turn/end` (dropped frame, panel opened mid-turn, sleep) must not + // disable the composer forever: re-read the Host's running flag on return. + useEffect(() => { + const onVisibilityChange = (): void => { + if (document.visibilityState !== 'visible') return + const sessionId = sessionRef.current + if (sessionId === null) return + void loadSessionCatalog().then(({ items }) => { + if (sessionRef.current !== sessionId) return + const entry = items.find((item) => item.sessionId === sessionId) + if (entry !== undefined) setWorking(entry.running) + }).catch(() => {}) + } + document.addEventListener('visibilitychange', onVisibilityChange) + return () => { document.removeEventListener('visibilitychange', onVisibilityChange) } + }, []) + function applyImageProjection(sessionId: string, seq: number, value: unknown): void { if (sessionRef.current !== sessionId || !Number.isSafeInteger(seq)) return const previous = imageProjectionRef.current @@ -1040,6 +1265,12 @@ export function App(): React.JSX.Element { await refreshHistory(payload.sessionId) return } + if (payload.event.type === 'permission/preset') { + // Another panel (or the Web GUI) may have switched this Session's preset; + // the projection is the authority, so re-read it for every own Session. + if (payload.sessionId === sessionRef.current) void refreshSessionPermissions(payload.sessionId) + return + } if (payload.sessionId !== sessionRef.current) return const row = rowFromEvent(payload.event) if (row !== null) { @@ -1181,7 +1412,7 @@ export function App(): React.JSX.Element { setSessionTitle(null) sessionRuntimeRef.current.seedRunning(created.sessionId, false) applyHistory(created.sessionId, await readHistory(created.sessionId)) - await refreshSessionModels(created.sessionId) + await refreshComposerDirectories(created.sessionId) } /** Pull the advisory model directory for the active session into the composer menu. */ @@ -1196,7 +1427,11 @@ export function App(): React.JSX.Element { const result = await api.rpc<{ current?: { provider: string; model: string; reasoningEffort?: string } routable?: boolean - groups?: Array<{ id: string; name: string; models: Array<{ id: string; name: string }> }> + groups?: Array<{ + id: string + name: string + models: Array<{ id: string; name: string; reasoning?: { efforts?: Array<{ id?: unknown; name?: unknown }> } }> + }> }>('session.models', { sessionId }) if (sessionRef.current !== sessionId) return const sessionOptions = (result.groups ?? []).flatMap((group) => group.models.map((model) => ({ @@ -1204,6 +1439,7 @@ export function App(): React.JSX.Element { providerName: group.name, id: model.id, name: model.name, + efforts: parseReasoningEfforts(model.reasoning?.efforts), }))) const options = sessionOptions.length > 0 ? sessionOptions : (fromSettings?.options ?? []) setSessionModels({ @@ -1215,13 +1451,45 @@ export function App(): React.JSX.Element { routable: result.routable ?? fromSettings?.routable ?? null, options, }) - } catch { + } catch (cause) { if (sessionRef.current !== sessionId) return // Host-wide settings still list configured relays before a session exists. - setSessionModels(fromSettings) + // An empty fallback is reported instead of a silently grey chip. + setSessionModels(fromSettings === null + ? { + current: null, + options: [], + routable: false, + error: cause instanceof Error ? cause.message : String(cause), + } + : { ...fromSettings, error: describeCatalogFailure(cause) }) } } + /** Refresh every composer-side directory for one Session in one pass. */ + async function refreshComposerDirectories(sessionId: string | null = sessionRef.current): Promise { + await Promise.all([ + refreshSessionModels(sessionId), + refreshSessionPermissions(sessionId), + refreshComposerCatalogs(sessionId), + ]) + } + + /** Read the declared reasoning efforts out of one model catalog entry. */ + function parseReasoningEfforts(value: unknown): Array<{ id: string; name: string }> { + if (!Array.isArray(value)) return [] + const efforts: Array<{ id: string; name: string }> = [] + for (const entry of value) { + if (typeof entry !== 'object' || entry === null) continue + const id = typeof (entry as { id?: unknown }).id === 'string' ? (entry as { id: string }).id.trim() : '' + const name = typeof (entry as { name?: unknown }).name === 'string' + ? (entry as { name: string }).name.trim() + : id + if (id !== '') efforts.push({ id, name: name === '' ? id : name }) + } + return efforts + } + /** Build a composer directory from saved llm-pi-ai routes (works without a Host session). */ async function loadModelDirectoryFromSettings(): Promise { const described = await api.rpc<{ @@ -1230,7 +1498,7 @@ export function App(): React.JSX.Element { const providers = (described.namespaces?.find((candidate) => candidate.ns === 'llm-pi-ai') ?.value?.providers ?? {}) as Record + models?: Array<{ id: string; name?: string; reasoning?: { efforts?: unknown } }> }> const defaults = (described.namespaces?.find((candidate) => candidate.ns === 'agent-default-model') ?.value ?? {}) as { provider?: unknown; model?: unknown; reasoningEffort?: unknown } @@ -1242,6 +1510,7 @@ export function App(): React.JSX.Element { : provider, id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id, + efforts: parseReasoningEfforts(model.reasoning?.efforts), })) )).filter((option) => option.id !== '') if (options.length === 0) return { current: null, options: [], routable: false } @@ -1266,7 +1535,9 @@ export function App(): React.JSX.Element { /** Switch the next assembled turn onto the chosen provider/model. */ async function selectSessionModel(option: SessionModelOption): Promise { const sessionId = sessionRef.current - if (sessionId === null || modelSelecting || busy || working) return + // A selection only shapes the NEXT assembled turn, so a running turn (or an + // in-flight prompt) never blocks it — matching the Web GUI model picker. + if (sessionId === null || modelSelecting) return setShowModelPicker(false) if (sessionModels?.current?.provider === option.provider && sessionModels.current.model === option.id) return @@ -1279,6 +1550,10 @@ export function App(): React.JSX.Element { sessionId, provider: option.provider, model: option.id, + // Keep the effort the user already chose when it applies to the new model. + ...(option.efforts.some((effort) => effort.id === sessionModels?.current?.reasoningEffort) + ? { reasoningEffort: sessionModels?.current?.reasoningEffort } + : {}), }) if (sessionRef.current !== sessionId) return setSessionModels((current) => current === null @@ -1301,6 +1576,151 @@ export function App(): React.JSX.Element { } } + /** Pull the Session's permission directory (catalog plus its current preset). */ + async function refreshSessionPermissions(sessionId: string | null = sessionRef.current): Promise { + if (sessionId === null) { + setSessionPermissions(null) + setShowPermissionPicker(false) + return + } + try { + const result = await api.rpc('session.permissions', { sessionId }) + if (sessionRef.current !== sessionId) return + const parsed = parseSessionPermissions(result) + setSessionPermissions(parsed) + if (parsed === null) setPermissionError(copy.app.permissionUnavailable) + } catch (cause) { + if (sessionRef.current !== sessionId) return + setSessionPermissions(null) + setPermissionError(isUnavailableMethod(cause) ? copy.app.permissionUnavailable : describeCatalogFailure(cause)) + } + } + + /** + * Pull the Host command and skill directories for the composer's `/` menu. + * A provisional Session answers empty with `provisional: true`; the panel + * then keeps the last real directory and says when it becomes authoritative. + */ + async function refreshComposerCatalogs(sessionId: string | null = sessionRef.current): Promise { + if (sessionId === null) { + setCommandDirectory(null) + setSkillDirectory(null) + setShowSlashMenu(false) + return + } + const [commands, skills] = await Promise.all([ + api.rpc('commands.list', { sessionId }).catch((cause: unknown) => cause), + api.rpc('skills.list', { sessionId }).catch((cause: unknown) => cause), + ]) + if (sessionRef.current !== sessionId) return + const commandDirectory = commands instanceof Error ? null : parseCommandDirectory(commands) + const skillDirectory = skills instanceof Error ? null : parseSkillDirectory(skills) + if (commandDirectory !== null) setCommandDirectory(commandDirectory) + if (skillDirectory !== null) setSkillDirectory(skillDirectory) + if (commandDirectory !== null && !commandDirectory.provisional) { + composerCatalogsRef.current = { ...composerCatalogsRef.current, commands: commandDirectory.commands } + } + if (skillDirectory !== null && !skillDirectory.provisional) { + composerCatalogsRef.current = { ...composerCatalogsRef.current, skills: skillDirectory.skills } + } + } + + /** Switch the Session's permission preset, confirming the risky ones first. */ + async function applyPermissionPreset(preset: string): Promise { + const sessionId = sessionRef.current + if (sessionId === null || permissionBusy) return + setShowPermissionPicker(false) + setPermissionConfirm(null) + setPermissionBusy(true) + setPermissionError(null) + setNotice(null) + try { + const result = await api.rpc<{ result?: { kind?: string; text?: string } }>('session.setPermission', { + sessionId, + preset, + }) + if (sessionRef.current !== sessionId) return + if (result?.result?.kind === 'error') { + setPermissionError(copy.app.permissionSwitchFailed(result.result.text ?? preset)) + return + } + await refreshSessionPermissions(sessionId) + } catch (cause) { + if (sessionRef.current !== sessionId) return + setPermissionError(copy.app.permissionSwitchFailed(describeCatalogFailure(cause))) + } finally { + setPermissionBusy(false) + } + } + + /** Open the confirmation for a risky preset, or switch directly. */ + function requestPermissionPreset(preset: string, label: string): void { + if (!isSelectablePreset(preset) || permissionBusy) return + if (sessionPermissions?.current === preset) { + setShowPermissionPicker(false) + return + } + if (requiresRiskConfirmation(preset)) { + setShowPermissionPicker(false) + setPermissionConfirm({ preset, label }) + return + } + void applyPermissionPreset(preset) + } + + /** Run one Host command line; command output never becomes a model message. */ + async function runCommandLine(line: string): Promise { + const sessionId = sessionRef.current + if (sessionId === null) return false + setNotice(null) + setError(null) + try { + const result = await api.rpc<{ result?: { kind?: string; text?: string } }>('commands.execute', { + sessionId, + line, + }) + if (sessionRef.current !== sessionId) return true + const failure = result?.result?.kind === 'error' ? result.result.text : undefined + if (failure !== undefined) setError(copy.app.commandFailed(failure)) + else setNotice(result?.result?.text ?? copy.app.commandSucceeded) + // A permission line changed the Session's knobs; keep the chip honest. + void refreshSessionPermissions(sessionId) + return true + } catch (cause) { + if (sessionRef.current !== sessionId) return true + const reason = describeCatalogFailure(cause) + setError(isProvisionalFailure(cause) ? copy.app.commandProvisional : copy.app.commandFailed(reason)) + return false + } + } + + /** Move the next assembled turn onto a different reasoning effort. */ + async function selectReasoningEffort(option: SessionModelOption, reasoningEffort: string | undefined): Promise { + const sessionId = sessionRef.current + if (sessionId === null || modelSelecting) return + setShowModelPicker(false) + setModelSelecting(true) + setError(null) + try { + const result = await api.rpc<{ + selected: { provider: string; model: string; reasoningEffort?: string } + }>('session.selectModel', { + sessionId, + provider: option.provider, + model: option.id, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + }) + if (sessionRef.current !== sessionId) return + setSessionModels((current) => current === null + ? { current: result.selected, routable: true, options: [option] } + : { ...current, current: result.selected, routable: true }) + } catch (cause) { + if (sessionRef.current === sessionId) setError(describeCatalogFailure(cause)) + } finally { + setModelSelecting(false) + } + } + /** Load the raw host index plus workspace archive state once. */ async function loadSessionCatalog(): Promise<{ items: SessionPickerEntry[] @@ -1349,7 +1769,7 @@ export function App(): React.JSX.Element { await api.setActiveSession(hinted) setSessionTitle(projectedSessionTitle(entry) ?? sessionDisplayTitle(entry)) applyHistory(hinted, history) - await refreshSessionModels(hinted) + await refreshComposerDirectories(hinted) return } } catch { @@ -1401,7 +1821,7 @@ export function App(): React.JSX.Element { sessionRef.current = entry.sessionId setSessionTitle(projectedSessionTitle(entry) ?? sessionDisplayTitle(entry)) await refreshHistory(entry.sessionId) - await refreshSessionModels(entry.sessionId) + await refreshComposerDirectories(entry.sessionId) } catch (cause) { if (sessionTransitionRef.current === transition) { setError(cause instanceof Error ? cause.message : String(cause)) @@ -1450,7 +1870,7 @@ export function App(): React.JSX.Element { setSessionTitle(sessionId) if (history !== undefined) applyHistory(sessionId, history) else setError(historyError instanceof Error ? historyError.message : String(historyError)) - await refreshSessionModels(sessionId) + await refreshComposerDirectories(sessionId) } finally { finishSessionTransition(transition) } @@ -1545,8 +1965,47 @@ export function App(): React.JSX.Element { } } + /** Accept one `/` menu row: run a local action or insert its exact text. */ + function pickSlashEntry(entry: SlashEntry): void { + setShowSlashMenu(false) + setSlashIndex(0) + setError(null) + setNotice(null) + if (entry.kind === 'local') { + setDraft(emptyComposerDraft()) + if (entry.action === 'model') setShowModelPicker(true) + return + } + // Inserting (rather than executing) keeps the row's own input editable. + setDraft((current) => ({ ...current, text: entry.insert })) + } + + /** Same arbitration as Enter: run the highlighted row, or submit the draft. */ + function acceptSlashMenu(): void { + const picked = slashRows[slashActiveIndex] + if (picked === undefined) return + pickSlashEntry(picked) + } + + /** Directories the composer resolves one submitted line against. */ + function composerLineCatalogs(): ComposerCatalogs { + const cached = composerCatalogsRef.current + return { + // A provisional Session answers empty; the last real directory is a + // better guess than dropping the user's command on the floor. + commands: commandDirectory !== null && commandDirectory.commands.length > 0 + ? commandDirectory.commands + : cached.commands, + skills: skillDirectory !== null && skillDirectory.skills.length > 0 + ? skillDirectory.skills + : cached.skills, + local: localSlashEntries(copy), + } + } + async function send(textOverride?: string): Promise { - const text = (textOverride ?? input).trim() + const submitted = textOverride ?? input + const text = submitted.trim() const submittedImages = textOverride === undefined ? draftImages : [] // A quick-start prompt asks about the whole page, not about a quote. const submittedSelection = textOverride === undefined ? selection : null @@ -1554,6 +2013,36 @@ export function App(): React.JSX.Element { // busy state 是异步的:连续回车可能都通过 state 检查——用 ref 同步锁。 if ((text === '' && submittedImages.length === 0 && submittedSelection === null) || busy || addingImagesRef.current || sendingRef.current || sessionChangingRef.current || id === null) return + + // A line starting with `/` is a command or a skill, never an ordinary + // prompt: the Host adapter would reject an unknown one anyway, so the panel + // refuses it here and keeps the draft instead of sending something else. + const resolution = resolveComposerLine(submitted, composerLineCatalogs()) + if (resolution.kind === 'unknown-command') { + setError(copy.app.unknownCommand(resolution.name)) + return + } + if (resolution.kind === 'local') { + setDraft(emptyComposerDraft()) + setShowSlashMenu(false) + if (resolution.name === 'model') setShowModelPicker(true) + return + } + if (resolution.kind === 'command') { + if (submittedImages.length > 0 || submittedSelection !== null) { + setError(copy.app.commandAttachmentsUnsupported) + return + } + sendingRef.current = true + setDraft(emptyComposerDraft()) + setShowSlashMenu(false) + try { + await runCommandLine(resolution.line) + } finally { + sendingRef.current = false + } + return + } sendingRef.current = true const submittedDraft: ComposerDraft = { text, images: submittedImages } if (textOverride === undefined) { @@ -1575,7 +2064,7 @@ export function App(): React.JSX.Element { ...(clientTimeZone === undefined ? {} : { clientTimeZone }), }) // Deferred sessions materialize on the first prompt; refresh so chips appear. - void refreshSessionModels(id) + void refreshComposerDirectories(id) if (submittedSelection !== null) { // Keep the background authoritative while the prompt is in flight. // Conditional clearing cannot consume a newer highlight captured in @@ -1902,7 +2391,10 @@ export function App(): React.JSX.Element { } // 状态栏只显示连接状态;快照上限是技术细节,在设置页说明(见 hint)。 - const statusText = copy.status[state] + // Discovery can end in a confirmed miss; that is more useful than "未连接". + const statusText = state === 'stopped' && discovery === 'not-found' + ? copy.statusNotFound + : copy.status[state] const sessionMenuTitle = sessionTitle ?? copy.app.newSession const currentModelOption = sessionModels?.options.find((option) => ( option.provider === sessionModels.current?.provider @@ -1928,9 +2420,81 @@ export function App(): React.JSX.Element { } return groups })() + const reasoningOptions = currentModelOption?.efforts ?? [] + const currentEffort = sessionModels?.current?.reasoningEffort + const currentEffortLabel = currentEffort === undefined + ? undefined + : (reasoningOptions.find((effort) => effort.id === currentEffort)?.name ?? currentEffort) + const modelChipLabel = `${currentModelLabel}${currentEffortLabel === undefined ? '' : ` · ${currentEffortLabel}`}` + const permissionOption = sessionPermissions?.options.find((option) => ( + option.value === sessionPermissions.current + )) + const permissionChipLabel = sessionPermissions === null + ? copy.app.permissionControl + : permissionLabel(sessionPermissions.current, permissionOption, copy) + const defaultPermissionLabel = sessionPermissions === null + ? null + : permissionLabel( + sessionPermissions.defaultPreset, + sessionPermissions.options.find((option) => option.value === sessionPermissions.defaultPreset), + copy, + ) + const slashQueryText = showSlashMenu ? slashQuery(input, caretRef.current) : null + const slashRows = (() => { + const entries = slashEntries( + commandDirectory?.commands ?? composerCatalogsRef.current.commands, + skillDirectory?.skills ?? composerCatalogsRef.current.skills, + localSlashEntries(copy), + ) + return rankSlashEntries(entries, slashQueryText ?? '') + })() + // No directory yet, or a deferred Session's empty answer: the menu lists what + // the last real Session knew and says so. + const slashDirectoryProvisional = commandDirectory === null || commandDirectory.provisional + const slashActiveIndex = slashRows.length === 0 ? -1 : Math.min(slashIndex, slashRows.length - 1) const approvalDialog = !approvalReadyForSession(queuedApproval, sessionRef.current, sessionChanging) ? null : + const permissionDialog = permissionConfirm === null + ? null + : ( +
+
+
+
+ ) if (showSettings) { return ( @@ -1953,6 +2517,52 @@ export function App(): React.JSX.Element { placeholder={copy.settings.bridgePlaceholder} /> +
+
+ + + {detectProgress === null + ? copy.settings.detectHelp + : copy.settings.detectScanning(detectProgress.done, detectProgress.total)} + +
+ {detectError === null ? null : {detectError}} + {detected.length > 0 ? ( +
    + {detected.map((instance) => { + const current = bridgeAddress === instance.wsUrl + return ( +
  • + +
  • + ) + })} +
+ ) : detectAttempted && !detecting ? ( + {copy.settings.detectEmpty} + ) : null} + {bridgeAddress === null || state !== 'connected' + ? null + : {copy.settings.detectConnected(bridgeAddress)}} +
+
+ + {devToolsStatus?.attached === true && ( + + )} +