diff --git a/extensions/dsh-browser/README.md b/extensions/dsh-browser/README.md index 711efd03a..9b9fec3f5 100644 --- a/extensions/dsh-browser/README.md +++ b/extensions/dsh-browser/README.md @@ -21,6 +21,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 | @@ -117,13 +120,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..ec9d0dbdc 100644 --- a/extensions/dsh-browser/README.zh.md +++ b/extensions/dsh-browser/README.zh.md @@ -21,6 +21,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` | 按宿主能力启用图片选择、纯图片发送和持久历史预览 | | 引用你划选的内容 | 侧栏输入框 | 你在页面里选中的文字会变成输入框里的引用,随下一条消息一起发送 | @@ -117,13 +120,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.json b/extensions/dsh-browser/manifest.json index d1311c27b..5bb562c16 100644 --- a/extensions/dsh-browser/manifest.json +++ b/extensions/dsh-browser/manifest.json @@ -13,7 +13,8 @@ "scripting", "webNavigation", "alarms", - "notifications" + "notifications", + "debugger" ], "host_permissions": [ "http://*/*", diff --git a/extensions/dsh-browser/src/background/authorization.ts b/extensions/dsh-browser/src/background/authorization.ts index d263a0587..b513a32a6 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, @@ -169,3 +263,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/index.ts b/extensions/dsh-browser/src/background/index.ts index 2ca9ea6a3..0941d5835 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 } @@ -20,6 +21,8 @@ * 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: 'devtools.status', supported, enabled, attached, tabId?, error? } + * bg → panel: { type: 'devtools.detach.result', id, ok, error? } * bg → panel: { type: 'status', state: BridgeState, caps? } * bg → panel: { type: 'event', frame: ServerFrame } * bg → panel: { type: 'approval.request', request } @@ -39,9 +42,10 @@ 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 } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import { BridgeClient, type BridgeState } from './bridge.ts' import { createRpc } from './rpc.ts' +import { listTabFrames } from './frames.ts' import { dispatchOpenTab, dispatchToolCall, @@ -75,6 +79,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 +95,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,6 +115,7 @@ const SETTINGS_DEFAULTS: Settings = { token: '', sharePageContent: 'auto', unrestrictedBrowserAccess: false, + allowDevTools: false, trustedActionOrigins: [], approvalNotifications: true, autoResumeSession: true, @@ -178,6 +192,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 +225,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 +312,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 +346,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 +374,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, @@ -339,6 +406,26 @@ function broadcastStatus(): void { } } +/** 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 +492,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 +980,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 +1172,7 @@ function routeToolCall(call: ToolCall): void { let settle!: () => void const activeCall: ActiveToolCall = { controller, + name: call.name, unrestrictedAccess, committed: false, revocationRequested: false, @@ -1116,6 +1226,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 +1315,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) @@ -1278,7 +1462,12 @@ 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) } @@ -1334,6 +1523,7 @@ chrome.runtime.onConnect.addListener((port) => { if (bridge === null || bridge.state === 'stopped') return startBridge() }) try { port.postMessage({ type: 'status', state: bridge?.state ?? ('stopped' as BridgeState), caps }) } catch { /* port closed */ } + broadcastDevToolsState() void affinityReady.then(async () => { await syncActiveTab() try { port.postMessage({ type: 'tab-affinity', state: tabAffinity.snapshot() }) } catch { /* port closed */ } @@ -1448,6 +1638,31 @@ 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 '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 +1778,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) { @@ -1694,6 +1912,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) => { diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 6d3d93602..a27804bfd 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 DevToolsStatus, 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' @@ -470,6 +470,7 @@ function ApprovalDialog({ return () => { window.removeEventListener('keydown', onKeyDown) } }, [request.id, onDecision]) + const devTools = request.devTools === true return (
@@ -478,7 +479,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 +497,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. @@ -795,6 +823,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,6 +831,11 @@ 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]) + // 每次连接重启(连接配置变更/断线重连)都新建会话。状态消息逐条监听: // React 会把 stopped/connecting 等瞬时状态合并进同一帧渲染,依赖渲染 // 状态无法可靠观察到"连接已重置",因此在这里按消息粒度判定。 @@ -1993,6 +2027,34 @@ export function App(): React.JSX.Element { />