Compare commits

..
Author SHA1 Message Date
BB-fatandCursor eda8b22c43 chore: bump CLI to 0.1.7 and extension to 0.1.3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:11:22 +08:00
BB-fat c987e42da9 Merge pull request #7 from Tencent/feat/console-command
feat: add bsk console command
2026-07-07 11:27:09 +08:00
BB-fatandCursor 7f5093d97a feat: add console observation command
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 11:08:12 +08:00
BB-fat ce29ddaa53 Merge pull request #5 from hobostay/fix/borrow-confirmation-tabs-get-fail-open
fix(extension): don't fail-open the borrow gate on a transient tabs.get error
2026-07-06 11:46:39 +08:00
BB-fat 2e7998477d Merge pull request #4 from hobostay/fix/ws-event-ownership
fix(daemon): reject cross-browser session events from non-owners
2026-07-06 11:44:19 +08:00
BB-fat c9e00bb641 Merge pull request #3 from hobostay/fix/tab-return-fallback-into-agent-window
fix(extension): never return a borrowed tab into another session's Agent Window
2026-07-06 11:33:38 +08:00
Test UserandClaude ad2a12e57e fix(extension): don't fail-open the borrow gate on a transient tabs.get error
requestBorrowConfirmation returned `true` (approve the borrow) when
`chrome.tabs.get(tabId)` threw. That fail-open was not one of the
documented fail-open cases in the function's own doc comment (no
candidate window; abort signal; timeout), and the tab was already
fetched successfully moments earlier by the borrow pipeline
(validateBorrowTarget), so a failure here is a transient SW/page hiccup
— not a missing tab. Silently approving a borrow with no confirmation
UI shown defeats the entire human-in-the-loop gate for seizing a user's
logged-in tab.

On a tabs.get failure, fall back to a generic title (the numeric tab id)
and still run the overlay + OS-notification confirmation flow so the
user gets a real chance to approve or deny.

Adds two regression tests (both fail on the unpatched code). Full
extension suite green (361 tests).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 21:02:54 +08:00
Test UserandClaude 61096110c0 fix(daemon): reject cross-browser session events from non-owners
The daemon dispatched inbound `session.window_closed` and
`session.user_interrupt` events using only the payload — the sending
browser's identity was never checked against the session's owner.

Any connected extension could therefore emit window_closed with
another browser's session_id to tear down its session (forgetting it,
dropping its tool queue + interrupt marker), or emit user_interrupt
to cancel every inflight tool of another browser's session and arm
its interrupt marker. This is a cross-tenant integrity/DoS hole that
is exploitable even between two legitimate extensions, independent of
the (separately tracked) WS Origin allow-list gap.

Thread the sender's BrowserId (already in scope as `client.id` in
handle_inbound_text) into both handlers and ignore the event when the
named session is live and owned by a different browser. A session
that is already gone is left to the existing no-op forget path.

Adds four ownership tests (owner honoured / non-owner ignored for each
event); updated two existing tests for the new signature. Full bsk
suite green (165 lib + integration), no new clippy warnings.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 20:47:42 +08:00
Test UserandClaude 8e8dd3840b fix(extension): never return a borrowed tab into another session's Agent Window
When a borrowed tab's original window is gone, tab_return's fallback
picker called getLastFocused({windowTypes:["normal"]}) and only
excluded the *current* session's Agent Window. Agent Windows are
created with type:"normal", so the picker could move a returned user
tab into *another* session's Agent Window. That session could then
write to the tab (it passes enforceAgentWindow), and the tab would be
destroyed when that session stops and closes its window — a
cross-session sandbox break plus silent user-data loss.

Thread an isAgentWindowId predicate (derived from SessionManager in
the production handlers, mirroring the existing filter in
borrow-confirmation's listConfirmationCandidates) through
returnBorrowedTab -> chooseFallbackWindow so every live Agent Window
is skipped as a fallback target.

Adds two regression tests (both fail on the unpatched code).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 20:23:02 +08:00
BB-fatandCursor 9c229b2489 chore: bump CLI version to 0.1.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 18:14:26 +08:00
BB-fatandCursor 26e5f98c16 docs: open extension install page from agent guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 15:55:20 +08:00
BB-fatandCursor dc8a6d38b3 Fix browser wait during daemon startup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:04:20 +08:00
BB-fat ab6aa16dfc feat: add Git Bash PATH support to installer
- Add Add-ToBashProfile helper to append install dir to ~/.bashrc
- Converts Windows paths to Unix-style for Git Bash compatibility
- Integrate into Main install flow alongside Add-ToUserPath
- Update completion message to mention Git Bash
2026-06-23 11:00:47 +08:00
BB-fat 45c85fa64a feat: add PowerShell install script for Windows
- Add install.ps1 mirrored from install.sh patterns
  - Same env var overrides (BSK_REPO, BSK_VERSION, BSK_INSTALL_DIR)
  - Default install dir ~/.local/bin
  - Fetches latest version from version.json via Invoke-RestMethod
  - Downloads, extracts zip, installs bsk.exe
  - Adds to user PATH persistently with idempotent check
  - Supports x64 and ARM64 architecture detection
- Update README.md Windows section with irm | iex one-liner
- Update AGENT_INSTALL.md with Windows PowerShell instructions
2026-06-23 10:45:29 +08:00
BB-fat b8c4be4362 docs: move video link after intro paragraph in both READMEs 2026-06-22 17:55:42 +08:00
BB-fat 335391c2bf docs: sync video link to Chinese README 2026-06-22 17:51:20 +08:00
BB-fat 3cf81d509b chore: add demo video to README 2026-06-22 17:49:52 +08:00
BB-fat f1036a860c Initial public release 2026-06-22 17:36:26 +08:00
303 changed files with 57049 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{ts,tsx,js,jsx,json,yaml,yml,md,toml,css,html}]
indent_style = space
indent_size = 2
[*.rs]
indent_style = space
indent_size = 4
+154
View File
@@ -0,0 +1,154 @@
name: Release CLI
on:
push:
tags:
- cli-v*
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-cli-${{ github.ref }}
cancel-in-progress: false
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- uses: actions/checkout@v6
- name: Resolve CLI version
id: version
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')"
else
VERSION="${GITHUB_REF_NAME#cli-v}"
fi
VERSION="${VERSION#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=cli-v${VERSION}" >> "$GITHUB_OUTPUT"
guard:
needs: resolve
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Verify tag matches Cargo.toml workspace version
shell: bash
run: |
set -euo pipefail
CARGO_VERSION="$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')"
RELEASE_VERSION="${{ needs.resolve.outputs.version }}"
if [ "$CARGO_VERSION" != "$RELEASE_VERSION" ]; then
echo "Cargo.toml version (${CARGO_VERSION}) does not match release version (${RELEASE_VERSION})"
exit 1
fi
echo "Version guard passed: ${RELEASE_VERSION}"
build-cli:
needs: [resolve, guard]
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-latest
archive: tar.gz
- target: x86_64-apple-darwin
os: macos-latest
archive: tar.gz
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
archive: tar.gz
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
archive: tar.gz
- target: x86_64-pc-windows-msvc
os: windows-latest
archive: zip
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install musl tools (Linux)
if: runner.os == 'Linux'
shell: bash
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build bsk
shell: bash
run: cargo build --release --locked -p bsk --target "${{ matrix.target }}"
- name: Package archive (Unix)
if: matrix.archive == 'tar.gz'
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.resolve.outputs.version }}"
ARCHIVE="bsk-v${VERSION}-${{ matrix.target }}.tar.gz"
tar -czf "$ARCHIVE" -C "target/${{ matrix.target }}/release" bsk
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
- name: Package archive (Windows)
if: matrix.archive == 'zip'
shell: pwsh
run: |
$version = "${{ needs.resolve.outputs.version }}"
$archive = "bsk-v${version}-${{ matrix.target }}.zip"
Compress-Archive -Path "target/${{ matrix.target }}/release/bsk.exe" -DestinationPath $archive -Force
"ARCHIVE=$archive" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: bsk-${{ matrix.target }}
path: ${{ env.ARCHIVE }}
if-no-files-found: error
release:
needs: [resolve, build-cli]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
- name: Render version.json
shell: bash
run: |
set -euo pipefail
node scripts/render-version-json.mjs \
--version "${{ needs.resolve.outputs.version }}" \
--repo "${{ github.repository }}" \
--server-url "${{ github.server_url }}" \
--branch "${{ github.event.repository.default_branch }}" \
--out dist/version.json
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.resolve.outputs.tag }}
name: bsk CLI ${{ needs.resolve.outputs.version }}
make_latest: true
generate_release_notes: true
files: |
dist/*.tar.gz
dist/*.zip
dist/version.json
+128
View File
@@ -0,0 +1,128 @@
name: Release Extension
on:
push:
tags:
- ext-v*
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-extension-${{ github.ref }}
cancel-in-progress: false
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- uses: actions/checkout@v6
- name: Resolve extension version
id: version
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$(node -p "require('./apps/extension/package.json').version")"
else
VERSION="${GITHUB_REF_NAME#ext-v}"
fi
VERSION="${VERSION#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=ext-v${VERSION}" >> "$GITHUB_OUTPUT"
guard:
needs: resolve
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Verify tag matches extension package.json version
shell: bash
run: |
set -euo pipefail
PKG_VERSION="$(node -p "require('./apps/extension/package.json').version")"
RELEASE_VERSION="${{ needs.resolve.outputs.version }}"
if [ "$PKG_VERSION" != "$RELEASE_VERSION" ]; then
echo "apps/extension/package.json version (${PKG_VERSION}) does not match release version (${RELEASE_VERSION})"
exit 1
fi
echo "Version guard passed: ${RELEASE_VERSION}"
build-extension:
needs: [resolve, guard]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build extension zip
run: pnpm ext:build:zip
- name: Locate and rename extension zip
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.resolve.outputs.version }}"
ZIP=""
for dir in apps/extension/dist apps/extension/.output; do
if [ ! -d "$dir" ]; then
continue
fi
candidate="$(find "$dir" -maxdepth 1 -name '*chrome*.zip' -type f 2>/dev/null | head -n 1 || true)"
if [ -n "$candidate" ]; then
ZIP="$candidate"
break
fi
done
if [ -z "$ZIP" ]; then
echo "Could not find extension zip under apps/extension/dist or apps/extension/.output"
find apps/extension -name '*.zip' -type f || true
exit 1
fi
OUT="browser-skill-extension-v${VERSION}-chrome.zip"
cp "$ZIP" "$OUT"
echo "ARCHIVE=$OUT" >> "$GITHUB_ENV"
echo "Packaged ${ZIP} -> ${OUT}"
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: extension-zip
path: ${{ env.ARCHIVE }}
if-no-files-found: error
release:
needs: [resolve, build-extension]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: extension-zip
path: dist
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.resolve.outputs.tag }}
name: BrowserSkill Extension ${{ needs.resolve.outputs.version }}
make_latest: false
generate_release_notes: true
files: dist/*.zip
+19
View File
@@ -0,0 +1,19 @@
# Rust
target/
# Node
node_modules/
dist/
.output/
.wxt/
.web-extension-id
# Logs
*.log
# macOS
.DS_Store
# Environment
.env.local
.env.*.local
+5
View File
@@ -0,0 +1,5 @@
**/dist/**
**/.wxt/**
**/.output/**
**/target/**
**/node_modules/**
+23
View File
@@ -0,0 +1,23 @@
{
"extends": ["stylelint-config-standard"],
"rules": {
"import-notation": null,
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": ["source", "custom-variant", "theme", "apply"]
}
],
"at-rule-empty-line-before": [
"never",
{
"ignore": ["after-comment"]
}
],
"custom-property-empty-line-before": null,
"declaration-empty-line-before": null,
"lightness-notation": null,
"hue-degree-notation": null,
"alpha-value-notation": null
}
}
+46
View File
@@ -0,0 +1,46 @@
# browser-skill — Install Guide for AI Agents
You are an AI agent setting up browser-skill for the user. Done = `bsk doctor`
reports every check `ok` (or `na`). Never use `sudo`; you cannot install the
extension yourself (Step 3 is the user's job).
## 1. Install the CLI and skill
Run the install command from [`README.md`](README.md):
**macOS / Linux:**
```bash
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
bsk install-skill --yes
```
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
bsk install-skill --yes
```
## 2. Run `bsk doctor`
```bash
bsk doctor
```
Each `fail` row prints a `hint` — follow it and re-run once. A fresh install
where only `extension connected` fails is expected; go to Step 3.
## 3. Open the extension install page
If `extension connected` is `FAIL` (`0 browsers connected`), the user likely
has not installed or enabled the browser extension yet. Open the
[Chrome Web Store install page](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
in the user's browser, then tell the user:
> Install **BrowserSkill** from the
> [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi),
> open the popup, and wait until it turns green. Reply when done.
If opening the page fails, give the user the same link. Then run `bsk doctor`
once more. All `ok`/`na` → tell the user it's ready.
Generated
+1672
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
[workspace]
resolver = "2"
members = [
"crates/bsk-cli",
"crates/bsk-protocol",
]
default-members = ["crates/bsk-cli"]
[workspace.package]
version = "0.1.7"
edition = "2024"
rust-version = "1.85"
license = "MIT"
repository = "https://github.com/Tencent/BrowserSkill"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
semver = { version = "1", features = ["serde"] }
schemars = { version = "0.8", features = ["derive"] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
clap = { version = "4", features = ["derive"] }
console = "0.15"
dialoguer = "0.11"
dirs = "5"
fs2 = "0.4"
tempfile = "3"
nix = { version = "0.29", features = ["signal", "process"] }
rand = "0.8"
uuid = { version = "1", features = ["v4", "serde"] }
# Workspace crates (version required for `cargo publish` of dependents)
bsk-protocol = { version = "0.1.7", path = "crates/bsk-protocol" }
[profile.release]
strip = "symbols"
lto = "thin"
codegen-units = 1
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tencent
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+182
View File
@@ -0,0 +1,182 @@
# BrowserSkill
<p align="center">
<img src="docs/assets/browserskill-readme-banner.png" alt="BrowserSkill banner" />
</p>
<p align="center">
<strong>Let AI agents use your browser without interrupting your work.</strong>
</p>
<p align="center">
English · <a href="README.zh-CN.md">中文</a>
</p>
**BrowserSkill** connects Cursor, Claude Code, Codex, OpenClaw, CodeBuddy,
WorkBuddy, Pi, Hermes Agent, and other shell-capable AI agents to your already logged-in
browser.
Need the agent to touch a tab you already have open? It must borrow that tab
explicitly, return it when the task is done, and leave the rest of your browser
alone.
https://github.com/user-attachments/assets/db782c92-b1d4-4aae-a255-039675937a90
## BrowserSkill Advantages
- **Reuse real login state**: Agents can work with sites you are already signed
into, without separate test accounts.
- **Keep working uninterrupted**: browser tasks run in a separate, visible
Agent Window, so you can keep using your own browser.
- **Support any Agent**: any Agent that can call a shell can use BrowserSkill
through the `bsk` CLI, with no lock-in to a specific model, Agent framework, or
harness.
- **Built-in human-in-loop**: when a task hits captcha, login, confirmation
dialogs, or other human-only steps, the Agent can ask you to take over and
then continue afterwards.
## Runtime Environment
BrowserSkill has two local runtime pieces: the `bsk` CLI/daemon and the browser
extension.
| Runtime | Support |
| --- | --- |
| Operating systems | macOS (Apple Silicon and Intel), Linux (x64 and ARM64), Windows x64 |
| Browsers | Chrome and Microsoft Edge are supported; other Chromium-based browsers are expected to work when they support unpacked Chromium extensions; Firefox is planned |
## Quick Start
<details open>
<summary><b>Install with your Agent (recommended)</b></summary>
<br>
Already using Cursor, Claude Code, Codex, or another shell-capable agent? Just
copy this one line and send it to your agent — it will install the CLI and skill
for you, then walk you through loading the extension:
```text
Set up browser-skill on this machine by following https://raw.githubusercontent.com/Tencent/BrowserSkill/main/AGENT_INSTALL.md
```
</details>
<details>
<summary><b>Manual install</b></summary>
<br>
Install the CLI, then install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi).
#### 1. Install the `bsk` CLI
**macOS / Linux** (recommended — installs to `~/.local/bin`):
```bash
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
```
**Windows** (PowerShell — installs to `~/.local/bin`):
```powershell
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
```
Verify the binary:
```bash
bsk --version
```
#### 2. Install the browser extension
Install BrowserSkill from the [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi).
#### 3. Install the skill
BrowserSkill ships a skill that teaches your agent harness how to use `bsk`. For
these harnesses, install it in one step:
<p align="center">
<table>
<tr>
<td align="center" width="108"><a href="https://cursor.com" title="Cursor"><img src="docs/assets/harnesses/cursor.svg" height="36" alt="Cursor" /></a><br /><sub><b>Cursor</b></sub></td>
<td align="center" width="108"><a href="https://docs.anthropic.com/en/docs/claude-code" title="Claude Code"><img src="docs/assets/harnesses/claude.svg" height="36" alt="Claude Code" /></a><br /><sub><b>Claude Code</b></sub></td>
<td align="center" width="108"><a href="https://developers.openai.com/codex" title="Codex"><img src="docs/assets/harnesses/codex.svg" height="36" alt="Codex" /></a><br /><sub><b>Codex</b></sub></td>
<td align="center" width="108"><a href="https://openclaw.ai" title="OpenClaw"><img src="docs/assets/harnesses/openclaw.svg" height="36" alt="OpenClaw" /></a><br /><sub><b>OpenClaw</b></sub></td>
<td align="center" width="108"><a href="https://www.codebuddy.ai" title="CodeBuddy"><img src="docs/assets/harnesses/codebuddy.svg" height="36" alt="CodeBuddy" /></a><br /><sub><b>CodeBuddy</b></sub></td>
<td align="center" width="108"><a href="https://www.workbuddy.ai" title="WorkBuddy"><img src="docs/assets/harnesses/workbuddy.svg" height="36" alt="WorkBuddy" /></a><br /><sub><b>WorkBuddy</b></sub></td>
<td align="center" width="108"><a href="https://github.com/badlogic/pi-mono" title="Pi"><img src="docs/assets/harnesses/pi.svg" height="36" alt="Pi" /></a><br /><sub><b>Pi</b></sub></td>
<td align="center" width="108"><a href="https://github.com/NousResearch/hermes-agent" title="Hermes Agent"><img src="docs/assets/harnesses/hermes.png" height="36" alt="Hermes Agent" /></a><br /><sub><b>Hermes Agent</b></sub></td>
</tr>
</table>
</p>
```bash
bsk install-skill
```
Use <kbd>Space</kbd> to select the Agent harness you want to install into, then
press <kbd>Enter</kbd> to install the skill. Run `bsk install-skill --list` to see
internal variants and install paths.
Other shell-capable agent harnesses are supported too. Copy
[`skill/SKILL.md`](skill/SKILL.md) into your harness's skills directory as
`browser-skill/SKILL.md` to install the skill manually.
</details>
Start a new Agent session and write a prompt that needs the browser, for example:
```text
/browser-skill open example.com and summarize what is on the page.
```
## How It Works
BrowserSkill is a local bridge between your agent harness and your browser.
```mermaid
flowchart TB
subgraph Harness["Agent Harness"]
Agent["Cursor / Claude Code / Codex / OpenClaw"]
end
subgraph Local["Your Machine"]
CLI["bsk CLI"]
Daemon["bsk daemon"]
Extension["BrowserSkill extension"]
end
subgraph Browser["Browser Profile"]
AgentWindow["Agent Window"]
UserWindows["Your normal browser windows"]
end
Agent -->|"shell: bsk ..."| CLI
CLI -->|"local IPC"| Daemon
Daemon -->|"WebSocket on 127.0.0.1"| Extension
Extension -->|"automates"| AgentWindow
Extension -.->|"borrow tab only when asked"| UserWindows
style AgentWindow fill:#fff4e6,stroke:#f59e0b,stroke-width:2px,color:#111827
style UserWindows fill:#f8fafc,stroke:#cbd5e1,color:#334155
```
The agent never talks to the browser directly. It asks the `bsk` CLI to perform a
browser task; the local daemon routes that request to the extension; the
extension runs it in an Agent Window.
## For Developers
The repository is a Cargo + pnpm workspace:
- `crates/bsk-cli` — `bsk` CLI and local daemon
- `crates/bsk-protocol` — shared wire types and JSON schemas
- `apps/extension` — browser extension
- `packages/ui` and `packages/i18n` — shared extension UI support
## License
MIT
+159
View File
@@ -0,0 +1,159 @@
# BrowserSkill
<p align="center">
<img src="docs/assets/browserskill-readme-banner.png" alt="BrowserSkill 横幅" />
</p>
<p align="center">
<strong>让 AI Agent 操作你的浏览器,而不打断你的工作。</strong>
</p>
<p align="center">
<a href="README.md">English</a> · 中文
</p>
**BrowserSkill** 把 Cursor、Claude Code、Codex、OpenClaw、CodeBuddy、WorkBuddy、Pi、Hermes Agent 等支持 Shell 的 AI Agent 连接到你已登录的浏览器。
需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。
https://github.com/user-attachments/assets/db782c92-b1d4-4aae-a255-039675937a90
## BrowserSkill 的优势
- **复用真实登录态**:Agent 可以操作你已经登录的网站,不需要额外测试账号。
- **不中断你的工作**:浏览器任务在独立可见的 Agent Window 中运行,不影响你继续使用自己的浏览器。
- **支持任意 Agent**:只要 Agent 能调用 Shell,就可以通过 `bsk` CLI 使用 BrowserSkill,不绑定特定模型、Agent 框架或 harness。
- **内置 human-in-loop**:遇到 captcha、登录、确认弹窗等必须由人处理的步骤时,Agent 可以主动请求你接管,完成后再继续任务。
## 运行环境
BrowserSkill 由两个本地运行组件组成:`bsk` CLI/daemon 和浏览器扩展。
| 运行项 | 支持情况 |
| --- | --- |
| 操作系统 | macOS(Apple Silicon 和 Intel)、Linux(x64 和 ARM64)、Windows x64 |
| 浏览器 | 已支持 Chrome 和 Microsoft Edge;其他支持加载 Chromium 扩展的浏览器通常可用;Firefox 计划中 |
## 快速开始
<details open>
<summary><b>让 Agent 帮你安装(推荐)</b></summary>
<br>
已经在用 Cursor、Claude Code、Codex 或其他支持 Shell 的 Agent?只需复制下面这句话发给 Agent,它会帮你安装 CLI 和 skill,并引导你加载浏览器扩展:
```text
按照 https://raw.githubusercontent.com/Tencent/BrowserSkill/main/AGENT_INSTALL.md 的说明,在本机安装并配置 browser-skill
```
</details>
<details>
<summary><b>手动安装</b></summary>
<br>
先安装 CLI,再从 [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) 安装浏览器扩展。
#### 1. 安装 `bsk` CLI
**macOS / Linux**(推荐,安装到 `~/.local/bin`):
```bash
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
```
**Windows**:从 [最新 CLI release](https://github.com/Tencent/BrowserSkill/releases/latest)
下载 `bsk-v<version>-x86_64-pc-windows-msvc.zip`,解压后将 `bsk.exe` 加入 `PATH`。
验证二进制:
```bash
bsk --version
```
#### 2. 安装浏览器扩展
从 [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) 安装 BrowserSkill。
#### 3. 安装 skill
BrowserSkill 自带 skill,用于教 Agent harness 如何使用 `bsk`。以下 harness 可一键安装:
<p align="center">
<table>
<tr>
<td align="center" width="108"><a href="https://cursor.com" title="Cursor"><img src="docs/assets/harnesses/cursor.svg" height="36" alt="Cursor" /></a><br /><sub><b>Cursor</b></sub></td>
<td align="center" width="108"><a href="https://docs.anthropic.com/en/docs/claude-code" title="Claude Code"><img src="docs/assets/harnesses/claude.svg" height="36" alt="Claude Code" /></a><br /><sub><b>Claude Code</b></sub></td>
<td align="center" width="108"><a href="https://developers.openai.com/codex" title="Codex"><img src="docs/assets/harnesses/codex.svg" height="36" alt="Codex" /></a><br /><sub><b>Codex</b></sub></td>
<td align="center" width="108"><a href="https://openclaw.ai" title="OpenClaw"><img src="docs/assets/harnesses/openclaw.svg" height="36" alt="OpenClaw" /></a><br /><sub><b>OpenClaw</b></sub></td>
<td align="center" width="108"><a href="https://www.codebuddy.ai" title="CodeBuddy"><img src="docs/assets/harnesses/codebuddy.svg" height="36" alt="CodeBuddy" /></a><br /><sub><b>CodeBuddy</b></sub></td>
<td align="center" width="108"><a href="https://www.workbuddy.ai" title="WorkBuddy"><img src="docs/assets/harnesses/workbuddy.svg" height="36" alt="WorkBuddy" /></a><br /><sub><b>WorkBuddy</b></sub></td>
<td align="center" width="108"><a href="https://github.com/badlogic/pi-mono" title="Pi"><img src="docs/assets/harnesses/pi.svg" height="36" alt="Pi" /></a><br /><sub><b>Pi</b></sub></td>
<td align="center" width="108"><a href="https://github.com/NousResearch/hermes-agent" title="Hermes Agent"><img src="docs/assets/harnesses/hermes.png" height="36" alt="Hermes Agent" /></a><br /><sub><b>Hermes Agent</b></sub></td>
</tr>
</table>
</p>
```bash
bsk install-skill
```
用 <kbd>Space</kbd> 选择需要安装的 Agent harness,然后按 <kbd>Enter</kbd> 安装 skill。运行 `bsk install-skill --list` 可查看 internal 变体及安装路径。
其他支持 Shell 的 Agent harness 也可使用 BrowserSkill,但需手动将 [`skill/SKILL.md`](skill/SKILL.md) 复制到对应 skills 目录下的 `browser-skill/SKILL.md`。
</details>
启动一个新的 Agent 会话,写一条需要使用浏览器的 prompt,例如:
```text
/browser-skill open example.com and summarize what is on the page.
```
## 工作原理
BrowserSkill 是 Agent 运行时与浏览器之间的本地桥接层。
```mermaid
flowchart TB
subgraph Harness["Agent 运行时"]
Agent["Cursor / Claude Code / Codex / OpenClaw"]
end
subgraph Local["本机"]
CLI["bsk CLI"]
Daemon["bsk daemon"]
Extension["BrowserSkill 扩展"]
end
subgraph Browser["浏览器配置文件"]
AgentWindow["Agent Window"]
UserWindows["你的常规浏览器窗口"]
end
Agent -->|"shell: bsk ..."| CLI
CLI -->|"本地 IPC"| Daemon
Daemon -->|"127.0.0.1 WebSocket"| Extension
Extension -->|"自动化"| AgentWindow
Extension -.->|"仅在请求时借用标签"| UserWindows
style AgentWindow fill:#fff4e6,stroke:#f59e0b,stroke-width:2px,color:#111827
style UserWindows fill:#f8fafc,stroke:#cbd5e1,color:#334155
```
Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;本地 daemon 把请求路由到扩展;扩展在 Agent Window 中执行。
## 面向开发者
本仓库是 Cargo + pnpm workspace:
- `crates/bsk-cli` — `bsk` CLI 与本地 daemon
- `crates/bsk-protocol` — 共享协议类型与 JSON Schema
- `apps/extension` — 浏览器扩展
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
## 许可证
MIT
View File
+91
View File
@@ -0,0 +1,91 @@
# BrowserSkill — Privacy Policy
**Last updated:** May 25, 2026
This Privacy Policy describes how the **BrowserSkill** browser extension (the "Extension") handles information when you install and use it. BrowserSkill is published as part of the open-source [BrowserSkill](https://github.com/Tencent/BrowserSkill) project. The source code is publicly auditable.
If you have questions about this policy, please open an issue in the project repository.
---
## 1. What BrowserSkill Does
BrowserSkill is a local automation bridge that lets AI coding agents (such as Cursor, Claude Code, Codex, and OpenClaw) drive a Chromium browser through a command-line tool (`bsk`). The Extension communicates exclusively with a local daemon running on your own computer. **The Extension itself does not contact any remote server, cloud service, or AI provider.**
## 2. Single Purpose
The Extension's single purpose is to expose browser automation primitives (navigation, DOM observation, screenshots, clicks, form filling, tab management) to a locally running BrowserSkill daemon over a WebSocket connection on `127.0.0.1`, so that an AI agent invoked by the user can interact with web pages on the user's behalf.
## 3. Data the Extension Accesses
Depending on the commands the user (via their AI agent) sends to the local daemon, the Extension may access the following categories of data **on the user's local device**:
| Category | What is accessed | Why |
|---|---|---|
| **Web page content** | The DOM, accessibility tree, HTML, and visible-tab screenshots of pages opened in the BrowserSkill-controlled "Agent Window," or in user tabs the user explicitly approves for borrowing. | Required so the AI agent can read pages, locate elements, and verify results. |
| **User input simulated by the agent** | Mouse clicks, keystrokes, and form values that the AI agent dispatches through the Chrome DevTools Protocol (CDP). | Required to perform automation actions the user has asked the agent to do. |
| **Tab and window metadata** | Tab IDs, URLs, titles, window IDs of the Agent Window and any tabs the user explicitly authorizes. | Required to target automation commands at the correct tab/window. |
| **Local extension storage** | A randomly generated 8-character instance ID and an optional user-supplied label. | Used so the local daemon can recognize this browser instance across reconnects. No personal data is stored. |
| **OS notifications** | Permission to display a system notification when the agent requests to "borrow" one of the user's existing tabs. | Required to obtain explicit, per-tab user consent before the agent touches any pre-existing tab. |
## 4. Data the Extension Does **Not** Collect
BrowserSkill does **not**:
- Send any data to remote servers, the Extension's authors, or any third party.
- Call any LLM, AI, or cloud API. The Extension contains no API keys, model identifiers, or remote endpoints.
- Read or transmit cookies, browsing history, bookmarks, downloads, saved passwords, or autofill data.
- Use webcam, microphone, geolocation, or any device sensor.
- Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code.
- Track users across websites or across sessions.
- Sell, rent, share, or transfer user data — there is no user data leaving the device.
## 5. Permissions Justification
The Extension requests the following Chrome permissions. Each is used solely for the single purpose described above.
- **`debugger`** — Attach the Chrome DevTools Protocol to the Agent Window so the agent can observe and interact with pages. Used only on tabs explicitly under BrowserSkill's control.
- **`tabs`** — Inspect, create, and close tabs in the Agent Window; query tab metadata.
- **`windows`** — Create and manage the dedicated Agent Window that isolates agent activity from the user's normal browsing.
- **`alarms`** — Periodically wake the service worker to keep the local WebSocket connection alive.
- **`notifications`** — Show a system notification to obtain user approval before the agent borrows a user-owned tab.
- **`storage`** — Persist a random instance ID and optional label in `chrome.storage.local`.
- **Host permission `<all_urls>`** — Inject a small status overlay (showing "Agent Active") on pages controlled by the agent, and enable automation across whatever sites the user directs the agent to. The Extension does **not** read or transmit page content from sites the agent is not actively driving.
## 6. Where Data Goes
All Extension activity stays on the user's local device. The only network traffic the Extension generates is a WebSocket connection to `ws://127.0.0.1:52800` (loopback only). What the AI agent connected to that local daemon does with the data afterwards (for example, sending a screenshot to an LLM provider) is governed by the privacy policy of that agent or LLM provider, **not** by this policy. BrowserSkill is not a party to those communications.
## 7. Data Retention
- The instance ID and optional label persist in `chrome.storage.local` until the user uninstalls the Extension or clears extension storage.
- Page content, screenshots, DOM snapshots, and other observed data are returned to the local daemon in response to commands and are **not retained by the Extension**. They live only as long as the agent's tool call.
## 8. User Control
Users can at any time:
- Uninstall the Extension from `chrome://extensions`, which removes all stored data.
- Close the Agent Window to stop all agent automation immediately.
- Deny tab-borrow notification prompts to keep their existing tabs off-limits.
- Stop the local daemon to sever the WebSocket connection.
## 9. Children's Privacy
The Extension is a developer tool and is not directed at children under 13. It does not knowingly collect personal information from anyone, including children.
## 10. Security
Because the Extension communicates only with `127.0.0.1`, no data is exposed to the network. Users should still avoid running BrowserSkill in untrusted environments, since any local process able to bind to `127.0.0.1:52800` could send commands to the Extension. Run BrowserSkill only on machines you control.
## 11. Open Source and Auditability
BrowserSkill is open source. Anyone can verify the claims in this policy by reading the source code in the project repository. The Extension contains no obfuscated or minified code paths that hide network calls.
## 12. Changes to This Policy
If this policy changes materially, the **Last updated** date above will change and a new version will be published with the Extension. Continued use of the Extension after an update constitutes acceptance of the revised policy.
## 13. Contact
For privacy-related questions or requests, please open an issue in the BrowserSkill project repository.
+91
View File
@@ -0,0 +1,91 @@
# BrowserSkill 隐私政策
**最后更新:** 2026 年 5 月 25 日
本隐私政策说明 **BrowserSkill** 浏览器扩展(下称"本扩展")在您安装和使用过程中如何处理信息。BrowserSkill 作为开源项目 [BrowserSkill](https://github.com/Tencent/BrowserSkill) 的一部分发布,源代码完全公开,可供审计。
如有任何疑问,请在项目仓库中提交 issue 与我们联系。
---
## 1. 本扩展的功能
BrowserSkill 是一个本地浏览器自动化桥接工具,使 AI 编程助手(如 Cursor、Claude Code、Codex、OpenClaw 等)能够通过命令行工具 `bsk` 驱动 Chromium 浏览器。本扩展仅与运行在用户本机上的本地守护进程通信。**本扩展自身不会连接任何远程服务器、云服务或 AI 服务提供商。**
## 2. 单一用途(Single Purpose)
本扩展的单一用途是:通过 `127.0.0.1` 上的 WebSocket 连接,将浏览器自动化原语(页面跳转、DOM 观察、截图、点击、表单填写、标签页管理)暴露给本地运行的 BrowserSkill 守护进程,使用户调用的 AI 助手能够代表用户与网页交互。
## 3. 本扩展会访问的数据
根据用户(通过其 AI 助手)发送给本地守护进程的指令,本扩展可能会在**用户本地设备上**访问以下数据:
| 类别 | 访问内容 | 用途 |
|---|---|---|
| **网页内容** | 由 BrowserSkill 控制的"代理窗口"中打开的页面,或用户明确授权"借用"的标签页中的 DOM、可访问性树、HTML、可见区域截图。 | 用于让 AI 助手读取页面、定位元素、验证执行结果。 |
| **AI 助手模拟的用户输入** | 通过 Chrome DevTools Protocol(CDP)派发的鼠标点击、键盘输入、表单值。 | 用于执行用户委托给 AI 助手的自动化操作。 |
| **标签页与窗口元数据** | 代理窗口及用户明确授权的标签页的 ID、URL、标题、窗口 ID。 | 用于将自动化指令发送到正确的标签页/窗口。 |
| **扩展本地存储** | 一个随机生成的 8 位十六进制实例 ID 和一个可选的用户自定义标签。 | 用于本地守护进程在重连时识别此浏览器实例。不存储任何个人数据。 |
| **系统通知** | 当 AI 助手请求"借用"用户已有标签页时显示系统通知。 | 用于在助手访问任何已有标签页之前获取用户的明确同意。 |
## 4. 本扩展**不会**收集的内容
BrowserSkill **不会**:
- 向任何远程服务器、本扩展开发者或第三方发送任何数据。
- 调用任何大语言模型(LLM)、AI 或云端 API。本扩展中不包含任何 API 密钥、模型标识或远程端点。
- 读取或传输 Cookie、浏览历史、书签、下载记录、已保存密码或自动填充数据。
- 使用摄像头、麦克风、地理位置或任何设备传感器。
- 嵌入分析、遥测、崩溃报告、广告 SDK 或指纹识别代码。
- 跨网站或跨会话追踪用户。
- 出售、出租、共享或转让用户数据 —— 因为本扩展不会向设备外传输任何数据。
## 5. 权限使用说明
本扩展申请以下 Chrome 权限,每一项都仅用于上述单一用途:
- **`debugger`** —— 将 Chrome DevTools Protocol 附加到代理窗口,使助手能够观察页面并与之交互。仅作用于明确处于 BrowserSkill 控制下的标签页。
- **`tabs`** —— 在代理窗口内查询、创建、关闭标签页;读取标签页元数据。
- **`windows`** —— 创建并管理独立的代理窗口,将助手的活动与用户的日常浏览隔离开。
- **`alarms`** —— 周期性唤醒 service worker,保持本地 WebSocket 连接存活。
- **`notifications`** —— 在助手请求借用用户标签页时,通过系统通知请求用户授权。
- **`storage`** —— 在 `chrome.storage.local` 中保存随机实例 ID 与可选标签。
- **主机权限 `<all_urls>`** —— 在助手控制的页面上注入一个小型状态浮层(提示"代理已激活"),并支持用户引导助手访问任意网站。本扩展**不会**读取或传输助手未在控制的网站上的页面内容。
## 6. 数据流向
本扩展的所有活动都停留在用户本地设备上。本扩展产生的唯一网络流量是与 `ws://127.0.0.1:52800`(仅回环地址)的 WebSocket 连接。连接到该本地守护进程的 AI 助手在拿到数据之后如何处理(例如将截图发送给某个 LLM 服务),由该助手或 LLM 提供商自身的隐私政策约束,**不在本政策范围内**。BrowserSkill 不参与那些通信。
## 7. 数据保留
- 实例 ID 与可选标签会保存在 `chrome.storage.local` 中,直到用户卸载本扩展或清除扩展存储。
- 页面内容、截图、DOM 快照等观察到的数据,仅作为对指令的响应返回给本地守护进程,**本扩展不会保留这些数据**。它们仅在助手的某次工具调用期间短暂存在。
## 8. 用户控制
用户可随时:
- 在 `chrome://extensions` 中卸载本扩展,从而清除全部已存储数据。
- 关闭代理窗口,立即停止所有助手自动化操作。
- 拒绝标签页借用通知,将自己已有的标签页隔离在助手访问范围之外。
- 停止本地守护进程,断开 WebSocket 连接。
## 9. 儿童隐私
本扩展是面向开发者的工具,并非面向 13 岁以下儿童。本扩展不会有意收集任何人的个人信息,包括儿童的个人信息。
## 10. 安全性
由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到 `127.0.0.1:52800` 的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。
## 11. 开源与可审计性
BrowserSkill 是开源项目,任何人都可以通过阅读项目仓库中的源代码来核实本政策中的所有声明。本扩展不包含会隐藏网络调用的混淆或压缩代码路径。
## 12. 政策变更
如本政策发生重大变更,上方的"最后更新"日期会随之更新,新版本会随扩展一同发布。在更新发布之后继续使用本扩展,即视为接受修订后的政策。
## 13. 联系方式
如有隐私相关的问题或请求,请在 BrowserSkill 项目仓库中提交 issue。
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+37
View File
@@ -0,0 +1,37 @@
{
"name": "@browser-skill/extension",
"version": "0.1.3",
"private": true,
"type": "module",
"scripts": {
"dev": "wxt",
"build": "wxt build",
"zip": "wxt zip",
"compile": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@browser-skill/i18n": "workspace:*",
"@browser-skill/ui": "workspace:*",
"@remixicon/react": "^4.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.287",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.0",
"@wxt-dev/module-react": "^1.1.3",
"happy-dom": "^15.11.7",
"tailwindcss": "^4.3.0",
"typescript": "^5.8.0",
"vitest": "^4.1.6",
"wxt": "^0.20.0"
}
}
@@ -0,0 +1,359 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpDebuggerApi, ChromiumCdp } from "../chromium-cdp";
function fakeChromeEvent<TArgs extends unknown[]>() {
const listeners = new Set<(...args: TArgs) => void>();
return {
listeners,
addListener: vi.fn((cb: (...args: TArgs) => void) => listeners.add(cb)),
removeListener: vi.fn((cb: (...args: TArgs) => void) => listeners.delete(cb)),
fire: (...args: TArgs) => {
for (const cb of listeners) cb(...args);
},
};
}
function fakeApi() {
const onEvent = fakeChromeEvent<[chrome.debugger.Debuggee, string, unknown]>();
const onDetach = fakeChromeEvent<[chrome.debugger.Debuggee, string]>();
const api: CdpDebuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async () => ({ ok: true })),
// biome-ignore lint/suspicious/noExplicitAny: minimal chrome.events.Event shim
onEvent: onEvent as any,
// biome-ignore lint/suspicious/noExplicitAny: minimal chrome.events.Event shim
onDetach: onDetach as any,
};
return { api, onEvent, onDetach };
}
describe("ChromiumCdp", () => {
it("coalesces concurrent attach calls for the same tab", async () => {
const { api } = fakeApi();
let releaseAttach!: () => void;
const attachGate = new Promise<void>((resolve) => {
releaseAttach = resolve;
});
(api.attach as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => attachGate);
const cdp = new ChromiumCdp(api);
const first = cdp.ensureAttached(7);
const second = cdp.ensureAttached(7);
await Promise.resolve();
expect(api.attach).toHaveBeenCalledTimes(1);
releaseAttach();
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
expect(cdp.isAttached(7)).toBe(true);
});
it("attaches lazily on first send if not attached yet", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
const out = await cdp.send<{ ok: boolean }>(9, "DOM.getDocument");
expect(out).toEqual({ ok: true });
expect(api.attach).toHaveBeenCalledTimes(1);
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "DOM.getDocument", {});
});
it("propagates attach failures as thrown Error", async () => {
const { api } = fakeApi();
(api.attach as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Another debugger is already attached"),
);
const cdp = new ChromiumCdp(api);
await expect(cdp.ensureAttached(5)).rejects.toThrow(/already attached/);
expect(cdp.isAttached(5)).toBe(false);
});
it("send() rejects on chrome.runtime.lastError-style failures", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "DOM.getDocument") {
throw "frame got detached";
}
return { ok: true };
},
);
const cdp = new ChromiumCdp(api);
await expect(cdp.send(1, "DOM.getDocument")).rejects.toThrow("frame got detached");
});
it("auto-clears the attached cache on detach event", async () => {
const { api, onDetach } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(11);
expect(cdp.isAttached(11)).toBe(true);
onDetach.fire({ tabId: 11 }, "target_closed");
expect(cdp.isAttached(11)).toBe(false);
});
it("detach() is idempotent and never throws", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.detach(99); // not attached
expect(api.detach).not.toHaveBeenCalled();
await cdp.ensureAttached(7);
(api.detach as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("tab closed"));
await expect(cdp.detach(7)).resolves.toBeUndefined();
expect(cdp.isAttached(7)).toBe(false);
});
it("detachAll() iterates every cached tab", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
await cdp.ensureAttached(2);
await cdp.detachAll();
expect(api.detach).toHaveBeenCalledTimes(2);
expect(cdp.isAttached(1)).toBe(false);
expect(cdp.isAttached(2)).toBe(false);
});
it("attach enables Page domain on first send", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.send(9, "DOM.getDocument");
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "Page.enable", {});
});
it("enables console capture domains best-effort during attach", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Log.enable") throw new Error("restricted");
return {};
},
);
const cdp = new ChromiumCdp(api);
await expect(cdp.ensureAttached(12)).resolves.toBeUndefined();
expect(cdp.isAttached(12)).toBe(true);
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Runtime.enable", {});
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Log.enable", {});
await cdp.ensureConsoleCapture(12);
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
([, method]) => method === "Runtime.enable" || method === "Log.enable",
);
expect(enableCalls).toHaveLength(2);
});
it("records javascriptDialogOpening and auto-accepts", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(3);
const cursor = cdp.dialogCursor(3);
onEvent.fire({ tabId: 3 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "hello",
url: "https://example.com/",
hasBrowserHandler: false,
});
await Promise.resolve();
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 3 }, "Page.handleJavaScriptDialog", {
accept: true,
});
const dialogs = cdp.dialogsSince(3, cursor);
expect(dialogs).toHaveLength(1);
expect(dialogs[0]).toMatchObject({
tab_id: 3,
type: "alert",
message: "hello",
handled: "accepted",
sequence: 1,
});
});
it("unblocks a pending send after dialog is handled", async () => {
const { api, onEvent } = fakeApi();
let releaseEvaluate!: () => void;
const evaluateGate = new Promise<void>((resolve) => {
releaseEvaluate = resolve;
});
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Runtime.evaluate") {
await evaluateGate;
return { result: { value: 2 } };
}
return {};
},
);
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(5);
const pending = cdp.send(5, "Runtime.evaluate", { expression: "1+1" });
await Promise.resolve();
onEvent.fire({ tabId: 5 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "blocked",
url: "https://example.com/",
});
releaseEvaluate();
await expect(pending).resolves.toEqual({ result: { value: 2 } });
});
it("clears dialog state on detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(11);
onEvent.fire({ tabId: 11 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "x",
url: "https://example.com/",
});
await Promise.resolve();
expect(cdp.dialogsSince(11, 0)).toHaveLength(1);
await cdp.detach(11);
expect(cdp.dialogsSince(11, 0)).toHaveLength(0);
});
it("records Runtime console calls with bounded text and optional stack frames", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(21);
onEvent.fire({ tabId: 21 }, "Runtime.consoleAPICalled", {
type: "warn",
args: [{ value: "abcdefghi" }, { description: "Object { ok: true }" }],
timestamp: 1234.5,
stackTrace: {
callFrames: [
{
functionName: "render",
url: "https://example.test/app.js",
lineNumber: 4,
columnNumber: 8,
},
],
},
});
const withoutStack = cdp.consoleEntriesSince(21, 0, 50, 5, false);
expect(withoutStack).toMatchObject({
next_since: 1,
truncated: true,
entries: [
{
sequence: 1,
kind: "console",
level: "warn",
text: "abcde",
url: "https://example.test/app.js",
line: 5,
column: 9,
truncated: true,
},
],
});
expect(withoutStack.entries[0].stack_trace).toBeUndefined();
const withStack = cdp.consoleEntriesSince(21, 0, 50, 1000, true);
expect(withStack.entries[0].stack_trace).toEqual([
{
function_name: "render",
url: "https://example.test/app.js",
line: 5,
column: 9,
},
]);
});
it("records exceptions and engine log entries", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(22);
onEvent.fire({ tabId: 22 }, "Runtime.exceptionThrown", {
timestamp: 2000,
exceptionDetails: {
text: "Uncaught",
url: "https://example.test/app.js",
lineNumber: 40,
columnNumber: 6,
exception: { description: "TypeError: boom\n at render (app.js:41:7)" },
},
});
onEvent.fire({ tabId: 22 }, "Log.entryAdded", {
entry: {
level: "error",
text: "Failed to load resource: 404",
url: "https://example.test/missing.png",
lineNumber: 0,
timestamp: 2100,
},
});
const result = cdp.consoleEntriesSince(22, 0, 50, 1000, false);
expect(result.next_since).toBe(2);
expect(result.entries).toMatchObject([
{
sequence: 1,
kind: "exception",
level: "error",
text: "TypeError: boom",
url: "https://example.test/app.js",
line: 41,
column: 7,
},
{
sequence: 2,
kind: "log",
level: "error",
text: "Failed to load resource: 404",
url: "https://example.test/missing.png",
},
]);
});
it("filters console entries by cursor and caps result size", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(23);
for (let i = 1; i <= 3; i += 1) {
onEvent.fire({ tabId: 23 }, "Runtime.consoleAPICalled", {
type: "log",
args: [{ value: `message ${i}` }],
});
}
const result = cdp.consoleEntriesSince(23, 1, 1, 1000, false);
expect(result).toMatchObject({
next_since: 2,
truncated: true,
entries: [{ sequence: 2, text: "message 2" }],
});
});
it("clears console state on detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(24);
onEvent.fire({ tabId: 24 }, "Runtime.consoleAPICalled", {
type: "log",
args: [{ value: "x" }],
});
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(1);
await cdp.detach(24);
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0);
});
it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
await cdp.ensureAttached(2);
cdp.trackSessionTab("aa11", 1);
cdp.trackSessionTab("bb22", 1);
cdp.trackSessionTab("aa11", 2);
await cdp.detachSession("aa11");
expect(api.detach).toHaveBeenCalledTimes(1);
expect(api.detach).toHaveBeenCalledWith({ tabId: 2 });
expect(cdp.isAttached(1)).toBe(true);
expect(cdp.isAttached(2)).toBe(false);
await cdp.detachSession("bb22");
expect(api.detach).toHaveBeenCalledTimes(2);
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 1 });
expect(cdp.isAttached(1)).toBe(false);
});
});
@@ -0,0 +1,614 @@
// Chromium `BrowserDriver` implementation. Wraps `chrome.debugger.*`
// so each tool implementation can think in terms of typed `send<T>`
// calls and a single attach-once-per-tab cache.
//
// Lifecycle notes:
// * `chrome.debugger.attach()` will fail with "Another debugger is
// already attached" if invoked twice for the same tab — we track
// attachments in `attachedTabs` to coalesce. The CDP protocol
// version pinned here ("1.3") matches what intern uses and what
// Playwright targets for stable Chrome / Edge / Brave.
// * Closing a tab implicitly detaches; we do not race-clean here.
// Higher-level code calls `detach()` explicitly when a session
// stops to keep the "Agent controlling — DevTools" infobar visible
// only while we actually need it.
// * MV3 service workers can be evicted mid-call. The wrapper
// propagates `chrome.runtime.lastError` as a thrown Error so
// callers can decide whether to retry vs. surface an `cdp_failed`.
// * Native JS dialogs (`alert` / `confirm` / `prompt` / `beforeunload`)
// block CDP until dismissed. We listen for `Page.javascriptDialogOpening`,
// record the payload for tool results, and auto-accept so automation
// can continue.
import type {
ConsoleEntry,
ConsoleEntryKind,
ConsoleResult,
ConsoleStackFrame,
JavaScriptDialogInfo,
JavaScriptDialogType,
} from "@/transport/types";
/**
* Minimal slice of `chrome.debugger` the rest of the extension
* depends on. Stays as an explicit interface so vitest can inject a
* fake without monkey-patching the real `chrome` global.
*/
export interface CdpDebuggerApi {
attach(target: chrome.debugger.Debuggee, requiredVersion: string): Promise<void>;
detach(target: chrome.debugger.Debuggee): Promise<void>;
sendCommand(
target: chrome.debugger.Debuggee,
method: string,
commandParams?: object,
): Promise<unknown>;
/**
* Fires for every CDP event (`Page.lifecycleEvent`, `DOM.documentUpdated`,
* …). The first callback argument is the source debuggee; the second
* is the CDP method name; the third is the payload.
*/
onEvent: chrome.events.Event<
(source: chrome.debugger.Debuggee, method: string, params: unknown) => void
>;
/**
* Fires when Chrome unilaterally detaches us — most commonly because
* the tab navigated to a chrome:// URL or the user clicked
* "Cancel debugging" on the system infobar.
*/
onDetach: chrome.events.Event<(source: chrome.debugger.Debuggee, reason: string) => void>;
}
/**
* Production-backed [`CdpDebuggerApi`]. `onEvent` / `onDetach` are
* exposed as getters so the *module* loads in vitest (where `chrome`
* is undefined) — the actual property access only fires when a real
* caller wires the driver in `background.ts`.
*/
export const chromeDebuggerApi: CdpDebuggerApi = {
attach: (target, version) => chrome.debugger.attach(target, version),
detach: (target) => chrome.debugger.detach(target),
sendCommand: (target, method, commandParams) =>
chrome.debugger.sendCommand(target, method, commandParams),
get onEvent() {
return chrome.debugger.onEvent;
},
get onDetach() {
return chrome.debugger.onDetach;
},
};
export const CDP_PROTOCOL_VERSION = "1.3";
/** Per-tab monotonic sequence returned by [`ChromiumCdp.dialogCursor`]. */
export type DialogCursor = number;
const MAX_DIALOG_BUFFER = 32;
const MAX_DIALOG_FIELD_LENGTH = 4096;
const MAX_CONSOLE_BUFFER = 200;
const MAX_CONSOLE_FIELD_LENGTH = 4096;
const MAX_CONSOLE_STACK_FRAMES = 20;
interface ParsedDialogOpening {
type: JavaScriptDialogType;
message: string;
url?: string;
defaultPrompt?: string;
hasBrowserHandler?: boolean;
}
interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
/**
* Wrapper around `chrome.debugger` that owns the "attach once per
* tabId" cache and exposes typed `send<T>()`.
*/
export class ChromiumCdp {
private readonly api: CdpDebuggerApi;
private readonly attachedTabs = new Set<number>();
private readonly attachInFlight = new Map<number, Promise<void>>();
private readonly tabOwners = new Map<number, Set<string>>();
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
private readonly dialogSequences = new Map<number, number>();
private readonly consoleBuffers = new Map<number, ConsoleEntry[]>();
private readonly consoleSequences = new Map<number, number>();
private readonly consoleDomainsAttemptedTabs = new Set<number>();
private detachSubscription: { dispose(): void } | null = null;
private dialogSubscription: { dispose(): void } | null = null;
private consoleSubscription: { dispose(): void } | null = null;
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
this.api = api;
this.bindAutoDetach();
this.bindDialogHandler();
this.bindConsoleHandler();
}
/** Attach to `tabId` if we haven't already in this driver. */
async ensureAttached(tabId: number): Promise<void> {
if (this.attachedTabs.has(tabId)) return;
const existing = this.attachInFlight.get(tabId);
if (existing) {
await existing;
return;
}
const attach = (async () => {
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
await this.enablePageDomain(tabId);
await this.enableConsoleDomains(tabId);
this.attachedTabs.add(tabId);
})()
.catch((err) => {
// Chrome surfaces "Another debugger is already attached" when
// (rare) the user opened DevTools on the same tab. Don't swallow
// — let the caller decide how to surface it.
throw normalizeError(err);
})
.finally(() => {
this.attachInFlight.delete(tabId);
});
this.attachInFlight.set(tabId, attach);
await attach;
}
/**
* Send a CDP command and decode the result as `T`. Throws on any
* `chrome.runtime.lastError`.
*/
async send<T = unknown>(tabId: number, method: string, params?: object): Promise<T> {
if (!this.attachedTabs.has(tabId)) {
await this.ensureAttached(tabId);
}
try {
const result = await this.api.sendCommand({ tabId }, method, params ?? {});
return result as T;
} catch (err) {
throw normalizeError(err);
}
}
/** Return a cursor marking the current dialog sequence for `tabId`. */
dialogCursor(tabId: number): DialogCursor {
return this.dialogSequences.get(tabId) ?? 0;
}
/** Dialogs observed on `tabId` with sequence strictly greater than `cursor`. */
dialogsSince(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[] {
const buf = this.dialogBuffers.get(tabId) ?? [];
return buf.filter((entry) => entry.sequence > cursor);
}
/** Ensure CDP domains for console capture are enabled for this tab. */
async ensureConsoleCapture(tabId: number): Promise<void> {
if (!this.attachedTabs.has(tabId)) {
await this.ensureAttached(tabId);
return;
}
await this.enableConsoleDomains(tabId);
}
/** Console entries observed on `tabId`, bounded for agent context safety. */
consoleEntriesSince(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
includeStack: boolean,
): ConsoleResult {
const buf = this.consoleBuffers.get(tabId) ?? [];
const hasCursor = typeof since === "number";
const candidates = hasCursor
? buf.filter((entry) => entry.sequence > since)
: buf.slice(Math.max(0, buf.length - limit));
const limited = hasCursor ? candidates.slice(0, limit) : candidates;
const entries = limited.map((entry) => projectConsoleEntry(entry, maxTextChars, includeStack));
const lastReturned = entries.at(-1)?.sequence;
const nextSince = lastReturned ?? this.consoleSequences.get(tabId) ?? 0;
const droppedEntries = (this.consoleSequences.get(tabId) ?? 0) > buf.length;
const truncated =
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated);
return {
tab_id: tabId,
entries,
next_since: nextSince,
truncated,
};
}
/** Detach if attached; never throws. */
async detach(tabId: number): Promise<void> {
this.attachInFlight.delete(tabId);
if (!this.attachedTabs.has(tabId)) return;
this.attachedTabs.delete(tabId);
this.clearDialogState(tabId);
this.clearConsoleState(tabId);
try {
await this.api.detach({ tabId });
} catch (err) {
// Tab may already be gone — Chrome auto-detaches on close. Log
// at debug so production builds aren't noisy.
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
}
}
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
isAttached(tabId: number): boolean {
return this.attachedTabs.has(tabId);
}
/** Remember that `sessionId` used CDP on `tabId` so stop can detach it. */
trackSessionTab(sessionId: string, tabId: number): void {
const owners = this.tabOwners.get(tabId) ?? new Set<string>();
owners.add(sessionId);
this.tabOwners.set(tabId, owners);
}
/** Subscribe to all CDP events. Returned disposable removes the listener. */
onEvent(handler: (source: chrome.debugger.Debuggee, method: string, params: unknown) => void): {
dispose(): void;
} {
this.api.onEvent.addListener(handler);
return {
dispose: () => this.api.onEvent.removeListener(handler),
};
}
/** Best-effort detach of every cached tab. Used on session.stop. */
async detachAll(): Promise<void> {
const tabs = Array.from(this.attachedTabs);
this.attachInFlight.clear();
this.tabOwners.clear();
this.attachedTabs.clear();
this.dialogBuffers.clear();
this.dialogSequences.clear();
this.consoleBuffers.clear();
this.consoleSequences.clear();
this.consoleDomainsAttemptedTabs.clear();
await Promise.all(
tabs.map(async (tabId) => {
try {
await this.api.detach({ tabId });
} catch (err) {
console.debug("[bsk cdp] detachAll: tab already gone", { tabId, err });
}
}),
);
}
private async enablePageDomain(tabId: number): Promise<void> {
await this.api.sendCommand({ tabId }, "Page.enable", {});
}
private async enableConsoleDomains(tabId: number): Promise<void> {
if (this.consoleDomainsAttemptedTabs.has(tabId)) return;
this.consoleDomainsAttemptedTabs.add(tabId);
for (const method of ["Runtime.enable", "Log.enable"]) {
try {
await this.api.sendCommand({ tabId }, method, {});
} catch (err) {
console.debug("[bsk cdp] console domain enable failed", { tabId, method, err });
}
}
}
private bindDialogHandler(): void {
if (this.dialogSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
if (method !== "Page.javascriptDialogOpening") return;
const tabId = source.tabId;
if (typeof tabId !== "number") return;
void this.onJavaScriptDialogOpening(tabId, params);
};
this.api.onEvent.addListener(listener);
this.dialogSubscription = {
dispose: () => this.api.onEvent.removeListener(listener),
};
}
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
const parsed = parseDialogOpeningParams(params);
try {
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
if (parsed.type === "prompt") {
handleParams.promptText = parsed.defaultPrompt ?? "";
}
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
this.dialogSequences.set(tabId, sequence);
this.appendDialog(tabId, {
tab_id: tabId,
type: parsed.type,
message: parsed.message,
url: parsed.url,
default_prompt: parsed.defaultPrompt,
has_browser_handler: parsed.hasBrowserHandler,
handled: "accepted",
sequence,
});
} catch (err) {
console.debug("[bsk cdp] Page.handleJavaScriptDialog failed", { tabId, err });
}
}
private appendDialog(tabId: number, entry: JavaScriptDialogInfo): void {
const buf = this.dialogBuffers.get(tabId) ?? [];
buf.push(entry);
while (buf.length > MAX_DIALOG_BUFFER) {
buf.shift();
}
this.dialogBuffers.set(tabId, buf);
}
private clearDialogState(tabId: number): void {
this.dialogBuffers.delete(tabId);
this.dialogSequences.delete(tabId);
}
private bindConsoleHandler(): void {
if (this.consoleSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
const tabId = source.tabId;
if (typeof tabId !== "number") return;
switch (method) {
case "Runtime.consoleAPICalled":
this.appendConsole(tabId, parseConsoleApiCalled(params));
break;
case "Runtime.exceptionThrown":
this.appendConsole(tabId, parseExceptionThrown(params));
break;
case "Log.entryAdded":
this.appendConsole(tabId, parseLogEntry(params));
break;
}
};
this.api.onEvent.addListener(listener);
this.consoleSubscription = {
dispose: () => this.api.onEvent.removeListener(listener),
};
}
private appendConsole(tabId: number, entry: ParsedConsoleEntry | null): void {
if (!entry) return;
const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1;
this.consoleSequences.set(tabId, sequence);
const buf = this.consoleBuffers.get(tabId) ?? [];
buf.push({ ...entry, sequence });
while (buf.length > MAX_CONSOLE_BUFFER) {
buf.shift();
}
this.consoleBuffers.set(tabId, buf);
}
private clearConsoleState(tabId: number): void {
this.consoleBuffers.delete(tabId);
this.consoleSequences.delete(tabId);
this.consoleDomainsAttemptedTabs.delete(tabId);
}
private bindAutoDetach(): void {
if (this.detachSubscription) return;
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
if (typeof source.tabId === "number") {
this.attachedTabs.delete(source.tabId);
this.attachInFlight.delete(source.tabId);
this.tabOwners.delete(source.tabId);
this.clearDialogState(source.tabId);
this.clearConsoleState(source.tabId);
}
};
this.api.onDetach.addListener(listener);
this.detachSubscription = {
dispose: () => this.api.onDetach.removeListener(listener),
};
}
/** Remove internal Chrome event listeners; tests and SW teardown call this. */
dispose(): void {
this.detachSubscription?.dispose();
this.detachSubscription = null;
this.dialogSubscription?.dispose();
this.dialogSubscription = null;
this.consoleSubscription?.dispose();
this.consoleSubscription = null;
}
/** Detach tabs only when no other live session has claimed them. */
async detachSession(sessionId: string): Promise<void> {
const tabsToDetach: number[] = [];
for (const [tabId, owners] of this.tabOwners) {
owners.delete(sessionId);
if (owners.size === 0) {
this.tabOwners.delete(tabId);
tabsToDetach.push(tabId);
}
}
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
}
}
function parseDialogOpeningParams(params: unknown): ParsedDialogOpening {
const raw = (params ?? {}) as Record<string, unknown>;
const type = normalizeDialogType(raw.type);
const message = truncateDialogField(typeof raw.message === "string" ? raw.message : "");
const url = typeof raw.url === "string" ? truncateDialogField(raw.url) : undefined;
const defaultPrompt =
typeof raw.defaultPrompt === "string" ? truncateDialogField(raw.defaultPrompt) : undefined;
const hasBrowserHandler =
typeof raw.hasBrowserHandler === "boolean" ? raw.hasBrowserHandler : undefined;
return { type, message, url, defaultPrompt, hasBrowserHandler };
}
function normalizeDialogType(value: unknown): JavaScriptDialogType {
switch (value) {
case "alert":
case "confirm":
case "prompt":
case "beforeunload":
return value;
default:
return "alert";
}
}
function truncateDialogField(value: string): string {
if (value.length <= MAX_DIALOG_FIELD_LENGTH) return value;
return `${value.slice(0, MAX_DIALOG_FIELD_LENGTH)}... [truncated]`;
}
function parseConsoleApiCalled(params: unknown): ParsedConsoleEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const args = Array.isArray(raw.args) ? raw.args : [];
const text = args.map(remoteObjectToText).filter(Boolean).join(" ");
const stackTrace = parseStackTrace(raw.stackTrace);
const top = stackTrace[0];
return makeConsoleEntry({
kind: "console",
level: typeof raw.type === "string" ? raw.type : "log",
text,
url: top?.url,
line: top?.line,
column: top?.column,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
stack_trace: stackTrace,
});
}
function parseExceptionThrown(params: unknown): ParsedConsoleEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const details = (raw.exceptionDetails ?? {}) as Record<string, unknown>;
const exception = (details.exception ?? {}) as Record<string, unknown>;
const text =
firstLine(typeof exception.description === "string" ? exception.description : undefined) ||
firstLine(typeof details.text === "string" ? details.text : undefined) ||
"Uncaught (unknown error)";
return makeConsoleEntry({
kind: "exception",
level: "error",
text,
url: typeof details.url === "string" ? details.url : undefined,
line: typeof details.lineNumber === "number" ? details.lineNumber + 1 : undefined,
column: typeof details.columnNumber === "number" ? details.columnNumber + 1 : undefined,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
stack_trace: parseStackTrace(details.stackTrace),
});
}
function parseLogEntry(params: unknown): ParsedConsoleEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const entry = (raw.entry ?? {}) as Record<string, unknown>;
return makeConsoleEntry({
kind: "log",
level: typeof entry.level === "string" ? entry.level : "info",
text: typeof entry.text === "string" ? entry.text : "",
url: typeof entry.url === "string" ? entry.url : undefined,
line: typeof entry.lineNumber === "number" ? entry.lineNumber : undefined,
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : undefined,
stack_trace: [],
});
}
function makeConsoleEntry(input: {
kind: ConsoleEntryKind;
level: string;
text: string;
url?: string;
line?: number;
column?: number;
timestamp?: number;
stack_trace: ConsoleStackFrame[];
}): ParsedConsoleEntry {
const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH);
return {
kind: input.kind,
level: input.level,
text: projected.text,
url: truncateOptionalConsoleField(input.url),
line: input.line,
column: input.column,
timestamp: input.timestamp,
stack_trace: input.stack_trace.slice(0, MAX_CONSOLE_STACK_FRAMES).map((frame) => ({
function_name: truncateOptionalConsoleField(frame.function_name),
url: truncateOptionalConsoleField(frame.url),
line: frame.line,
column: frame.column,
})),
truncated: projected.truncated || input.stack_trace.length > MAX_CONSOLE_STACK_FRAMES,
};
}
function projectConsoleEntry(
entry: ConsoleEntry,
maxTextChars: number,
includeStack: boolean,
): ConsoleEntry {
const projected = truncateConsoleText(entry.text, maxTextChars);
return {
sequence: entry.sequence,
kind: entry.kind,
level: entry.level,
text: projected.text,
url: entry.url,
line: entry.line,
column: entry.column,
timestamp: entry.timestamp,
...(includeStack && entry.stack_trace && entry.stack_trace.length > 0
? { stack_trace: entry.stack_trace }
: {}),
truncated: entry.truncated || projected.truncated,
};
}
function remoteObjectToText(value: unknown): string {
const raw = (value ?? {}) as Record<string, unknown>;
if ("value" in raw && raw.value !== undefined) return String(raw.value);
if (typeof raw.description === "string") return raw.description;
if (typeof raw.unserializableValue === "string") return raw.unserializableValue;
if (typeof raw.type === "string") return `[${raw.type}]`;
return "";
}
function parseStackTrace(value: unknown): ConsoleStackFrame[] {
const raw = (value ?? {}) as Record<string, unknown>;
const frames = Array.isArray(raw.callFrames) ? raw.callFrames : [];
return frames
.filter(
(frame): frame is Record<string, unknown> => frame !== null && typeof frame === "object",
)
.map((frame) => ({
function_name:
typeof frame.functionName === "string" && frame.functionName.length > 0
? frame.functionName
: undefined,
url: typeof frame.url === "string" && frame.url.length > 0 ? frame.url : undefined,
line: typeof frame.lineNumber === "number" ? frame.lineNumber + 1 : undefined,
column: typeof frame.columnNumber === "number" ? frame.columnNumber + 1 : undefined,
}));
}
function firstLine(value: string | undefined): string | undefined {
if (!value) return undefined;
return value.split(/\r?\n/, 1)[0] || undefined;
}
function truncateConsoleText(
value: string,
maxChars: number,
): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false };
return { text: value.slice(0, maxChars), truncated: true };
}
function truncateOptionalConsoleField(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text;
}
function normalizeError(err: unknown): Error {
if (err instanceof Error) return err;
if (typeof err === "string") return new Error(err);
if (err && typeof err === "object" && "message" in err) {
return new Error(String((err as { message: unknown }).message));
}
return new Error("unknown chrome.debugger error");
}
@@ -0,0 +1,280 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiCloseLine } from "@remixicon/react";
import { useEffect, useRef, useState, type TransitionEvent } from "react";
import logoUrl from "../../assets/logo.png";
export interface BorrowRequestData {
id: string;
isActiveTab: boolean;
tabTitle: string;
timeoutMs: number;
onAllow: () => void;
onDeny: () => void;
}
interface Props {
requests: BorrowRequestData[];
}
const EXIT_ANIMATION_MS = 150;
export function BorrowConfirmationOverlay({ requests }: Props) {
const activeRequest = requests.find((r) => r.isActiveTab);
const inactiveRequests = requests.filter((r) => !r.isActiveTab);
return (
<>
{activeRequest && (
<BorrowBackdrop>
<BorrowRequestItem key={activeRequest.id} request={activeRequest} isModal={true} />
</BorrowBackdrop>
)}
{inactiveRequests.length > 0 && (
<BorrowToastStack>
{inactiveRequests.map((req) => (
<BorrowRequestItem key={req.id} request={req} isModal={false} />
))}
</BorrowToastStack>
)}
</>
);
}
function BorrowBackdrop({ children }: { children: React.ReactNode }) {
return (
<div
data-slot="borrow-confirmation-modal-backdrop"
className="fixed inset-0 z-[2147483646] flex items-center justify-center bg-black/40 backdrop-blur-sm"
>
{children}
</div>
);
}
function BorrowToastStack({ children }: { children: React.ReactNode }) {
return (
<div
data-slot="borrow-confirmation-toast-stack"
className="fixed top-4 right-4 z-[2147483647] flex flex-col gap-3"
>
{children}
</div>
);
}
function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; isModal: boolean }) {
const { t } = useTranslation("extension");
const totalSeconds = Math.ceil(request.timeoutMs / 1000);
const [secondsLeft, setSecondsLeft] = useState(totalSeconds);
const [exiting, setExiting] = useState(false);
const [awaitingAutoAllow, setAwaitingAutoAllow] = useState(false);
const allowedRef = useRef(false);
const onAllowRef = useRef(request.onAllow);
const onDenyRef = useRef(request.onDeny);
onAllowRef.current = request.onAllow;
onDenyRef.current = request.onDeny;
useEffect(() => {
if (secondsLeft <= 0) {
if (!allowedRef.current) {
setAwaitingAutoAllow(true);
}
return;
}
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [secondsLeft]);
function triggerAllow() {
if (allowedRef.current) return;
allowedRef.current = true;
setAwaitingAutoAllow(false);
setExiting(true);
setTimeout(() => onAllowRef.current(), EXIT_ANIMATION_MS);
}
function handleAllow() {
triggerAllow();
}
function handleDeny() {
if (allowedRef.current) return;
allowedRef.current = true;
setAwaitingAutoAllow(false);
setExiting(true);
setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS);
}
const progress = secondsLeft / totalSeconds;
const truncatedTitle =
request.tabTitle.length > 30 ? `${request.tabTitle.slice(0, 30)}…` : request.tabTitle;
const cardClass = `transition-opacity duration-150 ease-out ${exiting ? "opacity-0" : "opacity-100"}`;
if (isModal) {
return (
<div
data-slot="borrow-confirmation-modal"
className={`flex w-[400px] flex-col gap-4 rounded-2xl bg-white px-7 pb-6 pt-7 shadow-[0_25px_50px_rgba(124,45,18,0.1)] ${cardClass}`}
>
<div className="flex items-center gap-2.5">
<img src={logoUrl} alt="browser-skill" className="size-6 rounded" />
<span className="text-base font-semibold text-[#111]">
{t("borrowConfirmation.title")}
</span>
</div>
<p className="m-0 text-sm leading-relaxed text-[#555]">{t("borrowConfirmation.body")}</p>
<div className="flex items-center justify-center gap-3 py-2">
<CountdownRing
progress={progress}
seconds={secondsLeft}
onProgressTransitionEnd={(propertyName) => {
if (!awaitingAutoAllow || secondsLeft !== 0) return;
if (propertyName !== "stroke-dashoffset") return;
triggerAllow();
}}
/>
<span className="text-[13px] text-gray-500">
{t("borrowConfirmation.autoAllow", { count: secondsLeft })}
</span>
</div>
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} />
</div>
);
}
return (
<div
data-slot="borrow-confirmation-toast"
className={`flex w-80 flex-col rounded-xl border border-[#ffedd5] bg-[#FFFBF7] p-4 pb-3.5 shadow-[0_10px_40px_rgba(124,45,18,0.1)] ${cardClass}`}
>
<BorrowToastHeader onDeny={handleDeny} />
<p className="mb-2.5 text-[13px] leading-relaxed text-[#555]">
{t("borrowConfirmation.targetTab")}
<br />
<span className="font-medium text-[#333]">&quot;{truncatedTitle}&quot;</span>
</p>
<BorrowProgressBar
progress={progress}
onProgressTransitionEnd={(event) => {
if (!awaitingAutoAllow || secondsLeft !== 0) return;
if (event.propertyName !== "width") return;
triggerAllow();
}}
/>
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} gapClass="gap-2" />
</div>
);
}
function BorrowToastHeader({ onDeny }: { onDeny: () => void }) {
const { t } = useTranslation("extension");
return (
<div className="mb-2 flex items-center gap-2">
<img src={logoUrl} alt="browser-skill" className="size-[18px] rounded" />
<span className="flex-1 text-sm font-semibold text-[#111]">
{t("borrowConfirmation.titleInactive")}
</span>
<button
type="button"
data-slot="borrow-confirmation-deny-button"
onClick={onDeny}
className="cursor-pointer border-0 bg-transparent p-0.5 text-gray-500"
aria-label={t("borrowConfirmation.deny")}
>
<RiCloseLine size={16} />
</button>
</div>
);
}
function BorrowProgressBar({
progress,
onProgressTransitionEnd,
}: {
progress: number;
onProgressTransitionEnd?: (event: TransitionEvent<HTMLDivElement>) => void;
}) {
return (
<div className="mb-3 h-1 overflow-hidden rounded-sm bg-gray-100">
<div
className="h-full rounded-sm bg-orange-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${progress * 100}%` }}
onTransitionEnd={onProgressTransitionEnd}
/>
</div>
);
}
function ActionButtons({
onDeny,
onAllow,
gapClass = "gap-2.5",
}: {
onDeny: () => void;
onAllow: () => void;
gapClass?: string;
}) {
const { t } = useTranslation("extension");
return (
<div className={`flex justify-end ${gapClass}`}>
<button
type="button"
data-slot="borrow-confirmation-deny-button"
onClick={onDeny}
className="cursor-pointer rounded-lg border border-gray-200 bg-transparent px-4 py-2 text-[13px] font-medium text-gray-600"
>
{t("borrowConfirmation.deny")}
</button>
<button
type="button"
data-slot="borrow-confirmation-allow-button"
onClick={onAllow}
className="cursor-pointer rounded-lg border-0 bg-orange-500 px-4 py-2 text-[13px] font-semibold text-white"
>
{t("borrowConfirmation.allow")}
</button>
</div>
);
}
function CountdownRing({
progress,
seconds,
onProgressTransitionEnd,
}: {
progress: number;
seconds: number;
onProgressTransitionEnd?: (propertyName: string) => void;
}) {
const r = 28;
const circ = 2 * Math.PI * r;
const offset = circ * (1 - progress);
return (
<svg width={72} height={72} className="-rotate-90" aria-hidden>
<circle cx={36} cy={36} r={r} fill="none" stroke="#f3f4f6" strokeWidth={5} />
<circle
cx={36}
cy={36}
r={r}
fill="none"
stroke="#f97316"
strokeWidth={5}
strokeDasharray={circ}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-[stroke-dashoffset] duration-1000 ease-linear"
onTransitionEnd={(event) => onProgressTransitionEnd?.(event.propertyName)}
/>
<text
x={36}
y={36}
textAnchor="middle"
dominantBaseline="central"
className="fill-[#111] text-xl font-bold"
style={{ transform: "rotate(90deg)", transformOrigin: "36px 36px" }}
>
{seconds}
</text>
</svg>
);
}
@@ -0,0 +1,149 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiStopCircleLine } from "@remixicon/react";
import { useEffect, useState } from "react";
import logoUrl from "../../assets/logo.png";
export interface ControlOverlayProps {
visible: boolean;
interrupting: boolean;
automationBypass: boolean;
onInterrupt: () => void;
}
export function ControlOverlay({
visible,
interrupting,
automationBypass,
onInterrupt,
}: ControlOverlayProps) {
const { t } = useTranslation("extension");
const [show, setShow] = useState(false);
useEffect(() => {
if (visible) {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}
setShow(false);
}, [visible]);
if (!visible) return null;
const pointerEvents = automationBypass ? "none" : "auto";
return (
<>
<style>{`
@keyframes bsk-breathe {
0%, 100% {
box-shadow: inset 0 0 20px 4px rgba(249,115,22,0.25);
}
50% {
box-shadow: inset 0 0 40px 8px rgba(249,115,22,0.5);
}
}
`}</style>
<div
data-slot="control-overlay"
style={{
position: "fixed",
inset: 0,
zIndex: 2147483646,
pointerEvents: "none",
animation: "bsk-breathe 3s ease-in-out infinite",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
}}
/>
<div
onPointerDown={(event) => {
if (automationBypass) return;
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
if (automationBypass) return;
event.preventDefault();
event.stopPropagation();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 2147483646,
pointerEvents,
background: "transparent",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
}}
/>
<div
style={{
position: "fixed",
bottom: 32,
left: "50%",
transform: "translateX(-50%)",
zIndex: 2147483647,
pointerEvents,
display: "flex",
alignItems: "center",
gap: 12,
backgroundColor: "#fff",
borderRadius: 9999,
padding: "10px 10px 10px 20px",
boxShadow: "0 8px 32px rgba(124,45,18,0.16), 0 2px 8px rgba(0,0,0,0.1)",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
}}
>
<img
src={logoUrl}
alt="browser-skill"
style={{ width: 24, height: 24, borderRadius: 4, flexShrink: 0 }}
/>
<span
style={{
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{t("controlOverlay.status")}
</span>
<button
type="button"
data-slot="control-overlay-stop-all"
disabled={interrupting}
onClick={onInterrupt}
style={{
pointerEvents,
display: "flex",
alignItems: "center",
gap: 6,
border: "none",
borderRadius: 9999,
padding: "8px 20px 8px 16px",
fontSize: 15,
fontWeight: 600,
color: "#fff",
backgroundColor: interrupting ? "#9ca3af" : "#f97316",
cursor: interrupting ? "default" : "pointer",
opacity: interrupting ? 0.7 : 1,
transition: "background-color 150ms ease-out, opacity 150ms ease-out",
whiteSpace: "nowrap",
lineHeight: 1,
}}
>
<RiStopCircleLine size={18} color="#fff" />
{interrupting ? t("controlOverlay.interrupting") : t("controlOverlay.interrupt")}
</button>
</div>
</>
);
}
@@ -0,0 +1,714 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiArrowDownSLine } from "@remixicon/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import logoUrl from "../../assets/logo.png";
export interface HelpRequestData {
id: string;
prompt: string;
/** Custom overlay title; falls back to i18n when omitted. */
title?: string;
/** CSS selectors to scroll to + flash-highlight. */
selectors: string[];
onContinue: (note: string) => void;
onCancel: () => void;
}
interface Props {
request: HelpRequestData | null;
}
interface Box {
top: number;
left: number;
width: number;
height: number;
}
interface PanelPos {
top?: number;
bottom?: number;
left: number;
placement: PanelPlacement;
}
type PanelPlacement = "above" | "below";
const PANEL_GAP = 12;
const VIEWPORT_MARGIN = 16;
const PANEL_WIDTH = 420;
const FALLBACK_PANEL_H = 180;
const FALLBACK_PANEL_H_COLLAPSED = 44;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function clampDragPos(
top: number,
left: number,
panelW: number,
panelH: number,
): { top: number; left: number } {
const maxLeft = window.innerWidth - panelW - VIEWPORT_MARGIN;
const maxTop = window.innerHeight - panelH - VIEWPORT_MARGIN;
return {
top: clamp(top, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxTop)),
left: clamp(left, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxLeft)),
};
}
function boxesEqual(a: Box[], b: Box[]): boolean {
return (
a.length === b.length &&
a.every(
(box, i) =>
box.top === b[i]?.top &&
box.left === b[i]?.left &&
box.width === b[i]?.width &&
box.height === b[i]?.height,
)
);
}
function panelPosEqual(a: PanelPos | null, b: PanelPos | null): boolean {
return (
a?.top === b?.top &&
a?.bottom === b?.bottom &&
a?.left === b?.left &&
a?.placement === b?.placement
);
}
/** Union of multiple viewport boxes into one anchor rect. */
function unionBox(boxes: Box[]): Box | null {
if (boxes.length === 0) return null;
let top = Number.POSITIVE_INFINITY;
let left = Number.POSITIVE_INFINITY;
let right = Number.NEGATIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
for (const b of boxes) {
top = Math.min(top, b.top);
left = Math.min(left, b.left);
right = Math.max(right, b.left + b.width);
bottom = Math.max(bottom, b.top + b.height);
}
return { top, left, width: right - left, height: bottom - top };
}
function choosePlacement(anchor: Box, panelH: number): PanelPlacement {
const viewportH = window.innerHeight;
const anchorBottom = anchor.top + anchor.height;
const spaceBelow = viewportH - anchorBottom;
const spaceAbove = anchor.top;
const minSpace = panelH + PANEL_GAP + VIEWPORT_MARGIN;
if (spaceBelow >= minSpace) {
return "below";
}
if (spaceAbove >= minSpace) {
return "above";
}
return spaceBelow >= spaceAbove ? "below" : "above";
}
/** Place the panel near the anchor on a stable side, clamped to the viewport. */
function placePanel(
anchor: Box,
panelW: number,
panelH: number,
placement: PanelPlacement,
): PanelPos {
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
const anchorBottom = anchor.top + anchor.height;
const anchorCenterX = anchor.left + anchor.width / 2;
const minCenterX = VIEWPORT_MARGIN + panelW / 2;
const maxCenterX = viewportW - VIEWPORT_MARGIN - panelW / 2;
const left =
minCenterX <= maxCenterX ? clamp(anchorCenterX, minCenterX, maxCenterX) : viewportW / 2;
if (placement === "below") {
const top = clamp(
anchorBottom + PANEL_GAP,
VIEWPORT_MARGIN,
viewportH - panelH - VIEWPORT_MARGIN,
);
return { top, left, placement };
}
let bottom = viewportH - anchor.top + PANEL_GAP;
const maxBottom = Math.max(VIEWPORT_MARGIN, viewportH - panelH - VIEWPORT_MARGIN);
bottom = clamp(bottom, VIEWPORT_MARGIN, maxBottom);
return { bottom, left, placement };
}
/** Returns null for invalid selectors instead of throwing. */
function querySelectorSafe(selector: string): Element | null {
try {
return document.querySelector(selector);
} catch {
return null;
}
}
/** Resolve selectors to their first matching element's viewport rect. */
function measure(selectors: string[]): Box[] {
const boxes: Box[] = [];
for (const sel of selectors) {
const el = querySelectorSafe(sel);
if (!el) continue;
const r = el.getBoundingClientRect();
boxes.push({ top: r.top, left: r.left, width: r.width, height: r.height });
}
return boxes;
}
export function HelpRequestOverlay({ request }: Props) {
const { t } = useTranslation("extension");
const [note, setNote] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [boxes, setBoxes] = useState<Box[]>([]);
const [panelPos, setPanelPos] = useState<PanelPos | null>(null);
const [dragPos, setDragPos] = useState<{ top: number; left: number } | null>(null);
const [dragging, setDragging] = useState(false);
const bannerRef = useRef<HTMLDivElement>(null);
const lastScrolledIdRef = useRef<string | null>(null);
const placementRef = useRef<{ requestId: string; placement: PanelPlacement } | null>(null);
const userMovedRef = useRef(false);
const dragStartRef = useRef<{
pointerX: number;
pointerY: number;
originLeft: number;
originTop: number;
panelW: number;
panelH: number;
} | null>(null);
// Reset the note and collapse state whenever a new request appears.
useEffect(() => {
setNote("");
setCollapsed(false);
placementRef.current = null;
setDragPos(null);
setDragging(false);
userMovedRef.current = false;
dragStartRef.current = null;
}, [request?.id]);
// Scroll the first matched target into view once per distinct request id.
useLayoutEffect(() => {
if (!request) return;
if (lastScrolledIdRef.current === request.id) return;
for (const sel of request.selectors) {
const el = querySelectorSafe(sel);
if (el) {
lastScrolledIdRef.current = request.id;
el.scrollIntoView({ block: "center", behavior: "smooth" });
break;
}
}
}, [request]);
// Keep highlight boxes aligned with the page as it scrolls / resizes.
useEffect(() => {
if (!request) {
setBoxes([]);
return;
}
const update = () => {
const nextBoxes = measure(request.selectors);
setBoxes((prev) => (boxesEqual(prev, nextBoxes) ? prev : nextBoxes));
};
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
const interval = window.setInterval(update, 500);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
window.clearInterval(interval);
};
}, [request]);
const onHeaderPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select")) return;
const banner = bannerRef.current;
if (!banner) return;
const rect = banner.getBoundingClientRect();
dragStartRef.current = {
pointerX: e.clientX,
pointerY: e.clientY,
originLeft: rect.left,
originTop: rect.top,
panelW: banner.offsetWidth || PANEL_WIDTH,
panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H),
};
userMovedRef.current = true;
setDragPos({ top: rect.top, left: rect.left });
setDragging(true);
e.preventDefault();
}, [collapsed]);
useEffect(() => {
if (!dragging) return;
const onMove = (e: PointerEvent) => {
const start = dragStartRef.current;
if (!start) return;
const dx = e.clientX - start.pointerX;
const dy = e.clientY - start.pointerY;
setDragPos(
clampDragPos(start.originTop + dy, start.originLeft + dx, start.panelW, start.panelH),
);
};
const onUp = () => {
setDragging(false);
dragStartRef.current = null;
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
}, [dragging]);
// Anchor the panel near matched targets; re-run when highlights or collapse change.
useLayoutEffect(() => {
if (!request) {
setPanelPos(null);
return;
}
if (userMovedRef.current) return;
const measured = boxes.length > 0 ? boxes : measure(request.selectors);
const anchor = unionBox(measured);
const banner = bannerRef.current;
if (!anchor || !banner) {
setPanelPos(null);
return;
}
const rect = banner.getBoundingClientRect();
let panelW = banner.offsetWidth || rect.width;
let panelH = banner.offsetHeight || rect.height;
if (panelW === 0) {
panelW = Math.min(PANEL_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2);
}
if (panelH === 0) {
panelH = collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H;
}
if (placementRef.current?.requestId !== request.id) {
placementRef.current = {
requestId: request.id,
placement: choosePlacement(anchor, panelH),
};
}
const nextPos = placePanel(anchor, panelW, panelH, placementRef.current.placement);
setPanelPos((prev) => (panelPosEqual(prev, nextPos) ? prev : nextPos));
}, [request, boxes, collapsed]);
if (!request) return null;
return (
<>
<style>{`
@keyframes bsk-help-flash {
0%, 100% { box-shadow: 0 0 0 2px rgba(249,115,22,0.9), 0 0 12px 2px rgba(249,115,22,0.5); }
50% { box-shadow: 0 0 0 3px rgba(249,115,22,1), 0 0 22px 6px rgba(249,115,22,0.8); }
}
.bsk-help-banner {
--bsk-help-ease: cubic-bezier(0.32, 0.72, 0, 1);
--bsk-help-duration: 320ms;
--bsk-help-banner-width: ${PANEL_WIDTH}px;
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 2147483647;
display: flex;
flex-direction: column;
gap: 10px;
width: var(--bsk-help-banner-width);
max-width: calc(100vw - ${VIEWPORT_MARGIN * 2}px);
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 12px 40px rgba(124,45,18,0.18), 0 2px 8px rgba(0,0,0,0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
transition:
padding var(--bsk-help-duration) var(--bsk-help-ease),
gap var(--bsk-help-duration) var(--bsk-help-ease),
width var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] {
gap: 0;
width: var(--bsk-help-banner-width);
padding: 10px 12px;
}
.bsk-help-drag-strip {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
height: 12px;
margin: -8px 0 -10px;
cursor: grab;
user-select: none;
touch-action: none;
}
.bsk-help-drag-pill {
width: 32px;
height: 4px;
border-radius: 999px;
background: #d1d5db;
transition: background-color 160ms var(--bsk-help-ease);
}
.bsk-help-drag-strip:hover .bsk-help-drag-pill {
background: #9ca3af;
}
.bsk-help-banner[data-dragging="true"] .bsk-help-drag-strip {
cursor: grabbing;
}
.bsk-help-banner[data-dragging="true"] .bsk-help-drag-pill {
background: #6b7280;
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-drag-strip {
margin: -6px 0 -8px;
}
.bsk-help-header {
display: flex;
align-items: center;
gap: 10px;
}
.bsk-help-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
color: #111;
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-title {
white-space: nowrap;
}
.bsk-help-header-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
overflow: hidden;
max-width: 0;
opacity: 0;
transform: translateX(8px);
pointer-events: none;
transition:
max-width var(--bsk-help-duration) var(--bsk-help-ease),
opacity 220ms var(--bsk-help-ease),
transform var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-header-actions {
max-width: 280px;
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.bsk-help-collapse-toggle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
border: none;
background: transparent;
padding: 4px;
line-height: 1;
color: #6b7280;
margin-left: auto;
transition: margin-left var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-collapse-toggle {
margin-left: 0;
}
.bsk-help-collapse-icon {
display: flex;
transition: transform var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-collapse-icon {
transform: rotate(180deg);
}
.bsk-help-body {
display: grid;
grid-template-rows: 1fr;
min-width: 0;
transition: grid-template-rows var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-body {
position: absolute;
grid-template-rows: 0fr;
width: 0;
height: 0;
overflow: hidden;
pointer-events: none;
}
.bsk-help-body-inner {
overflow: hidden;
min-height: 0;
min-width: 0;
}
.bsk-help-body-content {
display: flex;
flex-direction: column;
gap: 10px;
opacity: 1;
transform: translateY(0);
transition:
opacity 220ms var(--bsk-help-ease),
transform var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-body-content {
opacity: 0;
transform: translateY(6px);
}
.bsk-help-prompt {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #444;
}
.bsk-help-note-input {
width: 100%;
box-sizing: border-box;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
}
.bsk-help-footer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.bsk-help-btn-cancel {
cursor: pointer;
border: 1px solid #e5e7eb;
background: transparent;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: #4b5563;
flex-shrink: 0;
}
.bsk-help-btn-continue {
cursor: pointer;
border: none;
background: #f97316;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.bsk-help-header-actions .bsk-help-btn-cancel,
.bsk-help-header-actions .bsk-help-btn-continue {
padding: 6px 12px;
}
.bsk-help-header-actions .bsk-help-btn-continue {
padding: 6px 14px;
}
.bsk-help-footer-actions .bsk-help-btn-cancel {
padding: 8px 16px;
}
.bsk-help-footer-actions .bsk-help-btn-continue {
padding: 8px 18px;
}
@media (prefers-reduced-motion: reduce) {
.bsk-help-banner,
.bsk-help-header-actions,
.bsk-help-collapse-toggle,
.bsk-help-collapse-icon,
.bsk-help-body,
.bsk-help-body-content,
.bsk-help-drag-strip,
.bsk-help-drag-pill {
transition-duration: 0.01ms !important;
}
}
`}</style>
{boxes.map((b, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: boxes are positional
key={i}
data-slot="help-highlight"
style={{
position: "fixed",
top: b.top,
left: b.left,
width: b.width,
height: b.height,
borderRadius: 6,
zIndex: 2147483646,
pointerEvents: "none",
animation: "bsk-help-flash 1.2s ease-in-out infinite",
}}
/>
))}
<div
ref={bannerRef}
data-slot="help-request-banner"
data-collapsed={collapsed ? "true" : "false"}
data-anchored={panelPos ? "true" : "false"}
data-dragging={dragging ? "true" : "false"}
data-placement={panelPos?.placement}
className="bsk-help-banner"
style={
dragPos
? {
top: dragPos.top,
left: dragPos.left,
bottom: "auto",
transform: "none",
}
: panelPos
? {
top: panelPos.top ?? "auto",
left: panelPos.left,
bottom: panelPos.bottom ?? "auto",
transform: "translateX(-50%)",
}
: undefined
}
>
<div
data-slot="help-drag-handle"
className="bsk-help-drag-strip"
role="img"
aria-label={t("helpRequest.dragHandle")}
onPointerDown={onHeaderPointerDown}
>
<span className="bsk-help-drag-pill" aria-hidden />
</div>
<div className="bsk-help-header">
<img src={logoUrl} alt="browser-skill" style={{ width: 22, height: 22, borderRadius: 4 }} />
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
<div className="bsk-help-header-actions" aria-hidden={!collapsed}>
<button
type="button"
data-slot={collapsed ? "help-cancel-button" : undefined}
className="bsk-help-btn-cancel"
tabIndex={collapsed ? 0 : -1}
onClick={() => request.onCancel()}
>
{t("helpRequest.cancel")}
</button>
<button
type="button"
data-slot={collapsed ? "help-continue-button" : undefined}
className="bsk-help-btn-continue"
tabIndex={collapsed ? 0 : -1}
onClick={() => request.onContinue(note)}
>
{t("helpRequest.continue")}
</button>
</div>
<button
type="button"
data-slot="help-collapse-toggle"
className="bsk-help-collapse-toggle"
aria-label={collapsed ? t("helpRequest.expand") : t("helpRequest.collapse")}
aria-expanded={!collapsed}
onClick={() => setCollapsed((c) => !c)}
>
<span className="bsk-help-collapse-icon">
<RiArrowDownSLine size={20} aria-hidden />
</span>
</button>
</div>
<div className="bsk-help-body" aria-hidden={collapsed}>
<div className="bsk-help-body-inner">
<div className="bsk-help-body-content">
<p className="bsk-help-prompt">{request.prompt}</p>
<input
type="text"
className="bsk-help-note-input"
aria-label={t("helpRequest.noteLabel")}
placeholder={t("helpRequest.notePlaceholder")}
value={note}
tabIndex={collapsed ? -1 : 0}
onChange={(e) => setNote(e.target.value)}
/>
<div className="bsk-help-footer-actions">
<button
type="button"
data-slot={collapsed ? undefined : "help-cancel-button"}
className="bsk-help-btn-cancel"
tabIndex={collapsed ? -1 : 0}
onClick={() => request.onCancel()}
>
{t("helpRequest.cancel")}
</button>
<button
type="button"
data-slot={collapsed ? undefined : "help-continue-button"}
className="bsk-help-btn-continue"
tabIndex={collapsed ? -1 : 0}
onClick={() => request.onContinue(note)}
>
{t("helpRequest.continue")}
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,87 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BorrowConfirmationOverlay } from "../BorrowConfirmationOverlay";
describe("BorrowConfirmationOverlay", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("auto-allows after countdown reaches zero and progress transition ends", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();
const { container } = render(
<BorrowConfirmationOverlay
requests={[
{
id: "req-1",
isActiveTab: true,
tabTitle: "Example",
timeoutMs: 5000,
onAllow,
onDeny,
},
]}
/>,
);
expect(screen.getByText("允许借用标签页?")).toBeTruthy();
for (let i = 0; i < 5; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
}
expect(screen.getByText("0 秒后自动允许")).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const progressCircle = container.querySelectorAll("svg circle")[1];
expect(progressCircle).toBeTruthy();
await act(() => {
const event = new TransitionEvent("transitionend", { bubbles: true });
Object.defineProperty(event, "propertyName", {
value: "stroke-dashoffset",
configurable: true,
});
progressCircle!.dispatchEvent(event);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(150);
});
expect(onAllow).toHaveBeenCalledTimes(1);
expect(onDeny).not.toHaveBeenCalled();
});
it("only invokes onDeny once when deny is clicked repeatedly", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();
render(
<BorrowConfirmationOverlay
requests={[
{
id: "req-2",
isActiveTab: true,
tabTitle: "Active Tab",
timeoutMs: 60_000,
onAllow,
onDeny,
},
]}
/>,
);
const denyButton = screen.getByRole("button", { name: "拒绝" });
denyButton.click();
denyButton.click();
await vi.advanceTimersByTimeAsync(150);
expect(onDeny).toHaveBeenCalledTimes(1);
expect(onAllow).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,43 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ControlOverlay } from "../ControlOverlay";
describe("ControlOverlay", () => {
afterEach(() => {
cleanup();
});
it("sets pointer-events none on blocker and pill when automationBypass is true", () => {
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={true}
onInterrupt={() => {}}
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("none");
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
expect(stopBtn).toBeTruthy();
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("none");
});
it("uses pointer-events auto on blocker when automationBypass is false", () => {
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={false}
onInterrupt={() => {}}
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("auto");
});
});
@@ -0,0 +1,418 @@
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { HelpRequestOverlay, type HelpRequestData } from "../HelpRequestOverlay";
function renderOverlay(req: HelpRequestData) {
return render(
createElement(I18nextProvider, { i18n }, createElement(HelpRequestOverlay, { request: req })),
);
}
function baseRequest(overrides: Partial<HelpRequestData> = {}): HelpRequestData {
return {
id: "r1",
prompt: "Please complete the captcha",
selectors: [],
onContinue: vi.fn(),
onCancel: vi.fn(),
...overrides,
};
}
function overlayStyles(container: HTMLElement): string {
return Array.from(container.querySelectorAll("style"))
.map((style) => style.textContent ?? "")
.join("\n");
}
describe("HelpRequestOverlay", () => {
afterEach(() => {
cleanup();
});
it("renders the prompt", () => {
renderOverlay(baseRequest());
expect(screen.getByText("Please complete the captcha")).toBeTruthy();
});
it("renders a custom title when provided", () => {
renderOverlay(baseRequest({ title: "Verify your identity" }));
expect(screen.getByText("Verify your identity")).toBeTruthy();
});
it("falls back to the default i18n title when title is omitted", () => {
renderOverlay(baseRequest());
expect(screen.getByText(i18n.t("helpRequest.title", { ns: "extension" }))).toBeTruthy();
});
it("calls onContinue with the typed note", () => {
const onContinue = vi.fn();
const { container } = renderOverlay(baseRequest({ onContinue }));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "did it" } });
const continueBtn = container.querySelector(
"[data-slot='help-continue-button']",
) as HTMLButtonElement;
fireEvent.click(continueBtn);
expect(onContinue).toHaveBeenCalledWith("did it");
});
it("calls onCancel when cancel clicked", () => {
const onCancel = vi.fn();
const { container } = renderOverlay(baseRequest({ onCancel }));
const cancelBtn = container.querySelector(
"[data-slot='help-cancel-button']",
) as HTMLButtonElement;
fireEvent.click(cancelBtn);
expect(onCancel).toHaveBeenCalled();
});
it("highlights a matched selector and reports a missing one", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 10, left: 10, width: 100, height: 40, right: 110, bottom: 50 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login", "#missing"] }));
// One highlight box rendered for the matched selector.
expect(document.querySelectorAll("[data-slot='help-highlight']").length).toBe(1);
expect((el.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
el.remove();
});
it("scrolls into view for each distinct request when switching A→B directly", () => {
const makeTarget = (id: string) => {
const el = document.createElement("div");
el.id = id;
el.getBoundingClientRect = () =>
({ top: 10, left: 10, width: 100, height: 40, right: 110, bottom: 50 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
return el;
};
const elA = makeTarget("a-target");
const elB = makeTarget("b-target");
const { rerender } = render(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, {
request: baseRequest({ id: "a", selectors: ["#a-target"] }),
}),
),
);
rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, {
request: baseRequest({ id: "b", selectors: ["#b-target"] }),
}),
),
);
expect((elA.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
expect((elB.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
elA.remove();
elB.remove();
});
it("anchors the banner near a matched selector", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(banner.getAttribute("data-anchored")).toBe("true");
expect(banner.style.top).not.toBe("");
expect(banner.style.left).not.toBe("");
expect(banner.style.bottom).toBe("auto");
expect(banner.style.transform).toBe("translateX(-50%)");
expect(banner.style.top).toBe("152px");
expect(banner.style.left).toBe("260px");
el.remove();
});
it("falls back to bottom-center when no selectors match", () => {
renderOverlay(baseRequest({ selectors: [] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(banner.getAttribute("data-anchored")).toBe("false");
expect(banner.style.top).toBe("");
expect(banner.style.left).toBe("");
});
it("keeps the above placement edge anchored while collapsing", () => {
const el = document.createElement("div");
el.id = "login";
const targetTop = window.innerHeight - 80;
const targetBottom = targetTop + 40;
el.getBoundingClientRect = () =>
({
top: targetTop,
left: 200,
width: 120,
height: 40,
right: 320,
bottom: targetBottom,
}) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(banner.getAttribute("data-placement")).toBe("above");
expect(banner.style.top).toBe("auto");
expect(banner.style.bottom).toBe(`${window.innerHeight - targetTop + 12}px`);
const bottomBefore = banner.style.bottom;
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
expect(banner.getAttribute("data-placement")).toBe("above");
expect(banner.style.top).toBe("auto");
expect(banner.style.bottom).toBe(bottomBefore);
el.remove();
});
it("removes body content from collapsed width layout", () => {
const { container } = renderOverlay(
baseRequest({
prompt:
"This is a very long prompt that should not make the collapsed help request banner wide.",
}),
);
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const body = container.querySelector(".bsk-help-body") as HTMLElement;
const styles = overlayStyles(container);
expect(banner.getAttribute("data-collapsed")).toBe("true");
expect(body.getAttribute("aria-hidden")).toBe("true");
expect(styles).toContain('.bsk-help-banner[data-collapsed="true"] .bsk-help-body');
expect(styles).toContain("position: absolute");
expect(styles).toContain("width: 0");
});
it("keeps the same banner width when collapsed", () => {
const { container } = renderOverlay(baseRequest());
const styles = overlayStyles(container);
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(banner.getAttribute("data-collapsed")).toBe("true");
expect(styles).toContain("--bsk-help-banner-width: 420px");
expect(styles).toContain("width: var(--bsk-help-banner-width)");
expect(styles).not.toContain("width: auto");
});
it("keeps collapsed header actions visible when the title is long", () => {
const { container } = renderOverlay(
baseRequest({
title:
"This title is intentionally long enough to compete with the collapsed action buttons for space",
}),
);
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const styles = overlayStyles(container);
expect(banner.getAttribute("data-collapsed")).toBe("true");
expect(styles).toMatch(
/\.bsk-help-title\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;/s,
);
expect(styles).toMatch(/\.bsk-help-header-actions\s*\{[^}]*flex-shrink:\s*0;/s);
});
it("moves the banner when dragging the top strip and freezes auto positioning", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const handle = banner.querySelector("[data-slot='help-drag-handle']") as HTMLElement;
const anchoredTop = banner.style.top;
const anchoredLeft = banner.style.left;
banner.getBoundingClientRect = () =>
({
top: 152,
left: 50,
width: 420,
height: 180,
right: 470,
bottom: 332,
}) as DOMRect;
Object.defineProperty(banner, "offsetWidth", { value: 420, configurable: true });
Object.defineProperty(banner, "offsetHeight", { value: 180, configurable: true });
fireEvent.pointerDown(handle, { button: 0, clientX: 210, clientY: 154, pointerId: 1 });
fireEvent(window, new PointerEvent("pointermove", { clientX: 260, clientY: 184 }));
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
expect(banner.style.transform).toBe("none");
expect(banner.style.top).toBe("182px");
expect(banner.style.left).toBe("100px");
el.getBoundingClientRect = () =>
({ top: 300, left: 400, width: 120, height: 40, right: 520, bottom: 340 }) as DOMRect;
fireEvent.scroll(window);
fireEvent.resize(window);
expect(banner.style.top).toBe("182px");
expect(banner.style.left).toBe("100px");
expect(banner.style.top).not.toBe(anchoredTop);
expect(banner.style.left).not.toBe(anchoredLeft);
el.remove();
});
it("does not start drag when pointer down on a button in the header", () => {
const { container } = renderOverlay(baseRequest());
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const collapseBtn = screen.getByLabelText(
i18n.t("helpRequest.collapse", { ns: "extension" }),
);
fireEvent.pointerDown(collapseBtn, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
expect(banner.getAttribute("data-dragging")).toBe("false");
expect(banner.style.transform).not.toBe("none");
});
it("resets drag position when switching to a new request", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
const { rerender } = render(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, {
request: baseRequest({ id: "a", selectors: ["#login"] }),
}),
),
);
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const handle = banner.querySelector("[data-slot='help-drag-handle']") as HTMLElement;
banner.getBoundingClientRect = () =>
({
top: 152,
left: 50,
width: 420,
height: 180,
right: 470,
bottom: 332,
}) as DOMRect;
Object.defineProperty(banner, "offsetWidth", { value: 420, configurable: true });
Object.defineProperty(banner, "offsetHeight", { value: 180, configurable: true });
fireEvent.pointerDown(handle, { button: 0, clientX: 210, clientY: 154, pointerId: 1 });
fireEvent(window, new PointerEvent("pointermove", { clientX: 260, clientY: 184 }));
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
expect(banner.style.transform).toBe("none");
rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, {
request: baseRequest({ id: "b", selectors: ["#login"] }),
}),
),
);
const bannerAfter = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(bannerAfter.style.transform).toBe("translateX(-50%)");
el.remove();
});
it("renders a labelled drag handle affordance", () => {
const { container } = renderOverlay(baseRequest());
const handle = container.querySelector("[data-slot='help-drag-handle']") as HTMLElement;
expect(handle).toBeTruthy();
expect(handle.getAttribute("aria-label")).toBe(
i18n.t("helpRequest.dragHandle", { ns: "extension" }),
);
expect(handle.querySelector(".bsk-help-drag-pill")).toBeTruthy();
});
it("moves the banner when dragging the drag handle", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const handle = banner.querySelector("[data-slot='help-drag-handle']") as HTMLElement;
banner.getBoundingClientRect = () =>
({
top: 152,
left: 50,
width: 420,
height: 180,
right: 470,
bottom: 332,
}) as DOMRect;
Object.defineProperty(banner, "offsetWidth", { value: 420, configurable: true });
Object.defineProperty(banner, "offsetHeight", { value: 180, configurable: true });
fireEvent.pointerDown(handle, { button: 0, clientX: 260, clientY: 158, pointerId: 1 });
expect(banner.getAttribute("data-dragging")).toBe("true");
fireEvent(window, new PointerEvent("pointermove", { clientX: 310, clientY: 188 }));
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
expect(banner.style.transform).toBe("none");
expect(banner.style.top).toBe("182px");
expect(banner.style.left).toBe("100px");
el.remove();
});
it("keeps the horizontal anchor stable while collapsing", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const leftBefore = banner.style.left;
const transformBefore = banner.style.transform;
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
expect(banner.style.left).toBe(leftBefore);
expect(banner.style.transform).toBe(transformBefore);
expect(banner.style.transform).toBe("translateX(-50%)");
el.remove();
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { OverlayController } from "../overlay-controller";
describe("OverlayController", () => {
it("resets agent overlays without clearing user-tab borrow requests", () => {
const controller = new OverlayController();
controller.addBorrowRequest({
id: "borrow-1",
isActiveTab: true,
tabTitle: "User page",
timeoutMs: 5000,
onAllow: vi.fn(),
onDeny: vi.fn(),
});
controller.activateAgentSession("sess-1");
controller.setAgentHelpRequest({
id: "help-1",
prompt: "Finish login",
selectors: ["#login"],
onContinue: vi.fn(),
onCancel: vi.fn(),
});
controller.setAutomationBypass(true);
controller.resetAgentOverlays("sess-1");
const state = controller.snapshot();
expect(state.borrowRequests).toHaveLength(1);
expect(state.borrowRequests[0]?.id).toBe("borrow-1");
expect(state.controlVisible).toBe(false);
expect(state.activeSessionId).toBeNull();
expect(state.activeHelp).toBeNull();
expect(state.automationBypassCount).toBe(0);
});
it("ignores a reset for a different session", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
controller.setAutomationBypass(true);
controller.resetAgentOverlays("sess-2");
const state = controller.snapshot();
expect(state.activeSessionId).toBe("sess-1");
expect(state.controlVisible).toBe(true);
expect(state.automationBypassCount).toBe(1);
});
it("returns the previous help request when replacing agent help", () => {
const controller = new OverlayController();
controller.setAgentHelpRequest({
id: "help-1",
prompt: "First request",
selectors: [],
onContinue: vi.fn(),
onCancel: vi.fn(),
});
const previous = controller.setAgentHelpRequest({
id: "help-2",
prompt: "Second request",
selectors: [],
onContinue: vi.fn(),
onCancel: vi.fn(),
});
expect(previous?.id).toBe("help-1");
expect(controller.snapshot().activeHelp?.id).toBe("help-2");
});
});
@@ -0,0 +1,95 @@
import type { BorrowRequestData } from "./BorrowConfirmationOverlay";
import type { HelpRequestData } from "./HelpRequestOverlay";
export interface OverlayState {
borrowRequests: BorrowRequestData[];
activeHelp: HelpRequestData | null;
controlVisible: boolean;
interrupting: boolean;
activeSessionId: string | null;
automationBypassCount: number;
}
type MutableOverlayState = Omit<OverlayState, "controlVisible">;
/**
* Owns overlay state by rendering scope. User-tab overlays survive Agent
* session teardown; Agent-tab overlays are cleared whenever a tab is returned.
*/
export class OverlayController {
private state: MutableOverlayState = {
borrowRequests: [],
activeHelp: null,
interrupting: false,
activeSessionId: null,
automationBypassCount: 0,
};
snapshot(): OverlayState {
return {
...this.state,
controlVisible: this.isControlVisible(),
borrowRequests: [...this.state.borrowRequests],
};
}
isControlVisible(): boolean {
return this.state.activeSessionId !== null;
}
addBorrowRequest(request: BorrowRequestData): void {
this.state.borrowRequests = [
...this.state.borrowRequests.filter((r) => r.id !== request.id),
request,
];
}
removeBorrowRequest(requestId: string): void {
this.state.borrowRequests = this.state.borrowRequests.filter((r) => r.id !== requestId);
}
activateAgentSession(sessionId: string): void {
this.state.activeSessionId = sessionId;
this.state.interrupting = false;
}
setInterrupting(interrupting: boolean): void {
if (!this.state.activeSessionId) return;
this.state.interrupting = interrupting;
}
setAgentHelpRequest(request: HelpRequestData): HelpRequestData | null {
const previous = this.state.activeHelp;
this.state.activeHelp = request;
return previous;
}
clearAgentHelpRequest(requestId?: string): void {
if (!this.state.activeHelp) return;
if (requestId && this.state.activeHelp.id !== requestId) return;
this.state.activeHelp = null;
}
setAutomationBypass(enabled: boolean): void {
if (enabled) {
this.state.automationBypassCount += 1;
} else {
this.state.automationBypassCount = Math.max(0, this.state.automationBypassCount - 1);
}
}
resetAgentOverlays(sessionId: string): HelpRequestData | null {
if (this.state.activeSessionId && this.state.activeSessionId !== sessionId) {
return null;
}
const previousHelp = this.state.activeHelp;
this.state = {
...this.state,
activeHelp: null,
interrupting: false,
activeSessionId: null,
automationBypassCount: 0,
};
return previousHelp;
}
}
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@source "../content";
@@ -0,0 +1,35 @@
import { describe, it, expect, vi } from "vitest";
// `background.ts` is a WXT entrypoint that calls the global `defineBackground`
// at module load. WXT injects that helper at build time via auto-imports;
// vitest runs the file directly so we have to stub it before the import is
// resolved. `vi.hoisted` runs before ESM imports, which is exactly what we need.
vi.hoisted(() => {
(globalThis as unknown as { defineBackground: (cb: unknown) => unknown }).defineBackground = (
cb: unknown,
) => cb;
});
import { handleOverlayInterrupt } from "@/entrypoints/background";
describe("handleOverlayInterrupt", () => {
it("sends a session.user_interrupt event to the daemon and acks ok", async () => {
const send = vi.fn().mockReturnValue(undefined);
const transport = { send } as unknown as Parameters<typeof handleOverlayInterrupt>[0];
const result = await handleOverlayInterrupt(transport, "sess-1");
expect(send).toHaveBeenCalledWith({
event: "session.user_interrupt",
payload: { session_id: "sess-1" },
});
expect(result).toEqual({ ok: true });
});
it("returns ok=false when the transport send throws", async () => {
const send = vi.fn(() => {
throw new Error("ws closed");
});
const transport = { send } as unknown as Parameters<typeof handleOverlayInterrupt>[0];
const result = await handleOverlayInterrupt(transport, "sess-1");
expect(result).toEqual({ ok: false });
});
});
@@ -0,0 +1,229 @@
import { i18n } from "@browser-skill/i18n";
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { ConnectionController } from "@/lib/connection-controller";
import {
getConnectionEnabled,
setConnectionEnabled as persistConnectionEnabled,
setLabel,
} from "@/lib/instance-id";
import { startKeepalive } from "@/lib/keepalive";
import {
OVERLAY_MSG_INTERRUPT,
OVERLAY_MSG_WHO_AM_I,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
type OverlayMessage,
} from "@/lib/overlay-bridge";
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
import { attachSessionEventHandler } from "@/session-manager/event-handler";
import { SessionManager } from "@/session-manager/manager";
import {
attachBorrowNotificationButtonHandler,
attachBorrowNotificationClickHandler,
type BorrowNotificationCopy,
defaultBorrowChromeNotifications,
defaultBorrowChromeWindows,
requestBorrowConfirmation,
} from "@/tools/borrow-confirmation";
import { ToolDispatcher } from "@/tools/dispatcher";
import { detectBrowserMeta } from "@/transport/handshake";
import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport";
export const DEFAULT_DAEMON_PORT = 52800;
export default defineBackground(() => {
const port = DEFAULT_DAEMON_PORT;
const controller = new ConnectionController();
const transport = new WSTransport({ url: `ws://127.0.0.1:${port}` });
const sessions = new SessionManager();
const cdp = new ChromiumCdp();
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
// Re-sync the storage.session flag on SW startup so a previous SW's
// stale `true` does not keep waking us on every page load until the
// first mutation (review M4/M5 round 3 m-R3-1).
void sessionsLive.refresh();
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp,
onSessionsChanged: () => {
void sessionsLive.syncFromManager();
},
approveBorrow: (ctx) =>
requestBorrowConfirmation(ctx.tabId, {
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
deps: {
// Skip every Agent Window when choosing where to render the
// overlay — Agent Windows boot on about:blank, which has no
// content script, so sendMessage would fail-open and silently
// allow the borrow without any UI shown.
isAgentWindowId: (windowId) => sessions.findByWindowId(windowId) !== null,
// Resolve i18n strings per-borrow so language switches take effect
// without re-creating the dispatcher.
notificationCopy: makeBorrowNotificationCopy(),
},
}),
helpNotificationCopy: () => ({
title: i18n.t("helpRequest.notificationTitle", { ns: "extension" }),
body: "",
}),
});
dispatcher.start();
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
attachBorrowNotificationClickHandler({
onClicked: chrome.notifications.onClicked,
windows: defaultBorrowChromeWindows,
notifications: defaultBorrowChromeNotifications,
});
} else {
console.warn(
"[browser-skill] chrome.notifications unavailable; borrow notifications will be skipped",
);
}
// The Allow / Deny buttons on the OS notification are the *explicit*
// authorization fallback when every candidate user window's content
// script was missing (extension just reloaded, page in BFCache, etc.).
// Without this listener those button clicks would land nowhere and we'd
// be back to fail-open-after-sendMessage-failure.
if (typeof chrome.notifications?.onButtonClicked?.addListener === "function") {
attachBorrowNotificationButtonHandler({
onButtonClicked: chrome.notifications.onButtonClicked,
});
} else {
console.warn(
"[browser-skill] chrome.notifications.onButtonClicked unavailable; borrow Allow/Deny buttons will be inactive",
);
}
attachSessionEventHandler({
manager: sessions,
transport,
cdp,
onSessionsChanged: () => {
void sessionsLive.syncFromManager();
},
});
// MV3 service worker keepalive + reconnect supervisor (review M4/M5
// C3 + C4). Every 30s the alarm wakes the SW; if the transport is
// not connected we force a fresh connect attempt so we never sit on
// a stale `disconnected` state when the setTimeout-based reconnect
// dies with the SW. Reconnect is skipped when the user has disabled
// the BrowserSkill connection.
startKeepalive({
transport,
shouldConnect: () => controller.isConnectionEnabled,
});
void (async () => {
const connectionEnabled = await getConnectionEnabled();
await controller.attach(transport, detectBrowserMeta(), connectionEnabled);
})().catch((err) => {
console.error("[browser-skill] controller failed to attach", err);
});
chrome.runtime.onMessage.addListener((rawMsg, sender, sendResponse) => {
const msg = rawMsg as OverlayMessage | undefined;
if (!msg || typeof msg !== "object" || !("kind" in msg)) return false;
if (msg.kind === OVERLAY_MSG_WHO_AM_I) {
const windowId = sender.tab?.windowId;
const ctx = typeof windowId === "number" ? sessions.findByWindowId(windowId) : null;
sendResponse({ sessionId: ctx?.sessionId ?? null });
return false;
}
if (msg.kind === OVERLAY_MSG_INTERRUPT) {
const req = msg as OverlayInterruptRequest;
void handleOverlayInterrupt(transport, req.sessionId).then((reply) => {
sendResponse(reply);
});
return true; // keep channel open
}
return false;
});
chrome.runtime.onConnect.addListener((connection) => {
if (connection.name !== POPUP_PORT_NAME) return;
const post = (msg: PopupInbound) => {
try {
connection.postMessage(msg);
} catch (err) {
console.debug("[browser-skill] popup post failed", err);
}
};
const unsubscribe = controller.subscribe((snap) => {
post({ kind: "snapshot", data: snap });
});
connection.onMessage.addListener((raw: unknown) => {
const msg = raw as PopupOutbound;
if (msg && typeof msg === "object" && "kind" in msg) {
if (msg.kind === "set_label") {
void setLabel(msg.value).then(() => controller.refreshLabel());
} else if (msg.kind === "set_port") {
// Placeholder for the future custom-port UI; warn loudly so
// any reintroduced popup control is caught instead of
// silently doing nothing (review M4/M5 C2).
console.warn("[browser-skill] set_port is not wired yet; ignoring", msg.value);
} else if (msg.kind === "set_connection_enabled") {
void controller.setConnectionEnabled(msg.value).then(() =>
persistConnectionEnabled(msg.value),
);
}
}
});
connection.onDisconnect.addListener(() => unsubscribe());
});
// Stash on globalThis so the SW DevTools can poke at internals.
// Dev-only to avoid leaking internals to inspectors in shipped builds
// (review M4/M5 M4).
if (import.meta.env.DEV) {
const dbg = globalThis as unknown as {
__bskController?: ConnectionController;
__bhSessions?: SessionManager;
__bhDispatcher?: ToolDispatcher;
};
dbg.__bskController = controller;
dbg.__bhSessions = sessions;
dbg.__bhDispatcher = dispatcher;
}
console.info("[browser-skill] background worker initialised");
});
/**
* Push a `session.user_interrupt` event to the daemon for `sessionId`.
* Returns `{ ok: true }` when `transport.send` accepts the frame and
* `{ ok: false }` when it throws (sink closed, transport not yet
* connected, etc.). The daemon-side cancellation is fire-and-forget
* — a failure here just means the user will need to retry the
* interrupt; no daemon state is left half-updated.
*/
export async function handleOverlayInterrupt(
transport: Pick<Transport, "send">,
sessionId: string,
): Promise<OverlayInterruptResponse> {
try {
transport.send({
event: "session.user_interrupt",
payload: { session_id: sessionId },
});
return { ok: true };
} catch (err) {
console.warn("[browser-skill] failed to send session.user_interrupt", err);
return { ok: false };
}
}
function makeBorrowNotificationCopy(): BorrowNotificationCopy {
return {
title: i18n.t("borrowConfirmation.notificationTitle", { ns: "extension" }),
body: (tabTitle: string) =>
i18n.t("borrowConfirmation.notificationBody", { ns: "extension", tabTitle }),
iconUrl: "icon/logo.png",
allowButton: i18n.t("borrowConfirmation.allow", { ns: "extension" }),
denyButton: i18n.t("borrowConfirmation.deny", { ns: "extension" }),
};
}
+289
View File
@@ -0,0 +1,289 @@
import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
import { ControlOverlay } from "@/content/ControlOverlay";
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
import overlayCss from "@/content/overlay.css?inline";
import { OverlayController } from "@/content/overlay-controller";
import {
HELP_RESPONSE,
type HelpCancelMessage,
type HelpRequestMessage,
type HelpResponseMessage,
isHelpCancelMessage,
isHelpRequestMessage,
} from "@/lib/help-bridge";
import {
OVERLAY_AUTOMATION_BYPASS,
OVERLAY_MSG_WHO_AM_I,
type OverlayAgentOverlayResetMessage,
type OverlayAutomationBypassMessage,
type OverlayWhoAmIResponse,
isOverlayAgentOverlayResetMessage,
} from "@/lib/overlay-bridge";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
import { SESSIONS_LIVE_FLAG_KEY } from "@/lib/sessions-live-flag";
import type {
BorrowCancelMessage,
BorrowRequestMessage,
BorrowResponseMessage,
} from "@/tools/borrow-confirmation";
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import React from "react";
import ReactDOM from "react-dom/client";
// Run at document_end so the overlay does not block first paint. Only attach
// in the top-level frame so iframes do not double-render overlays.
export default defineContentScript({
matches: ["<all_urls>"],
runAt: "document_end",
allFrames: false,
cssInjectionMode: "ui",
async main(ctx) {
if (window.top !== window) return;
const overlays = new OverlayController();
let activeHelpRespond: ((outcome: "continued" | "cancelled", note?: string) => void) | null =
null;
let reactRoot: ReactDOM.Root | null = null;
let overlayHost: HTMLElement | null = null;
let hostLossReported = false;
let remountInProgress = false;
const ui = await createShadowRootUi(ctx, {
name: "browser-skill-overlay",
position: "inline",
anchor: "html",
css: overlayCss,
onMount(container, _shadow, shadowHost) {
shadowHost.setAttribute("aria-hidden", "true");
shadowHost.setAttribute("data-bsk-overlay", "");
overlayHost = shadowHost;
hostLossReported = false;
const app = document.createElement("div");
app.className = "bsk-overlay-root";
container.append(app);
reactRoot = ReactDOM.createRoot(app);
renderOverlay();
return reactRoot;
},
onRemove(root) {
overlayHost = null;
root?.unmount();
reactRoot = null;
},
});
function renderOverlay() {
const overlayState = overlays.snapshot();
reactRoot?.render(
React.createElement(
I18nextProvider,
{ i18n },
React.createElement(
React.Fragment,
null,
React.createElement(BorrowConfirmationOverlay, {
requests: overlayState.borrowRequests,
}),
React.createElement(ControlOverlay, {
visible: overlayState.controlVisible && overlayState.activeHelp === null,
interrupting: overlayState.interrupting,
automationBypass: overlayState.automationBypassCount > 0,
onInterrupt: handleInterrupt,
}),
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
),
),
);
}
function resetAgentOverlayState(sessionId: string) {
const previousHelp = overlays.resetAgentOverlays(sessionId);
if (previousHelp) {
activeHelpRespond?.("cancelled");
activeHelpRespond = null;
}
renderOverlay();
}
function handleInterrupt() {
const state = overlays.snapshot();
if (state.interrupting) return;
const sessionId = state.activeSessionId;
if (!sessionId) {
console.warn("[bsk overlay] interrupt requested with no active session id");
return;
}
overlays.setInterrupting(true);
renderOverlay();
void sendInterrupt(
(msg) => chrome.runtime.sendMessage(msg),
sessionId,
).then((reply) => {
// Always retract the mask after the round trip resolves
// (success, failure, or timeout). Cancellation is fire-and-
// forget on the daemon side; the user must not be stuck
// behind a transient issue. The Agent Window stays open.
resetAgentOverlayState(sessionId);
if (!reply.ok) {
console.warn("[bsk overlay] interrupt did not get a clean ack from daemon");
}
});
}
const onMessage = (
message:
| BorrowRequestMessage
| BorrowCancelMessage
| HelpRequestMessage
| HelpCancelMessage
| OverlayAgentOverlayResetMessage
| OverlayAutomationBypassMessage,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: BorrowResponseMessage | HelpResponseMessage) => void,
) => {
if (
message &&
typeof message === "object" &&
"type" in message &&
message.type === OVERLAY_AUTOMATION_BYPASS
) {
const bypassMsg = message as OverlayAutomationBypassMessage;
overlays.setAutomationBypass(bypassMsg.enabled);
renderOverlay();
return false;
}
if (isOverlayAgentOverlayResetMessage(message)) {
resetAgentOverlayState(message.sessionId);
return false;
}
if (message.type === "borrow-cancel") {
overlays.removeBorrowRequest(message.requestId);
renderOverlay();
return false;
}
if (isHelpCancelMessage(message)) {
const state = overlays.snapshot();
if (state.activeHelp && state.activeHelp.id === message.requestId) {
activeHelpRespond?.("cancelled");
}
return false;
}
if (isHelpRequestMessage(message)) {
const helpMsg = message as HelpRequestMessage;
let responded = false;
const respond = (outcome: "continued" | "cancelled", note?: string) => {
if (responded) return;
responded = true;
const reply: HelpResponseMessage = {
type: HELP_RESPONSE,
outcome,
...(note ? { note } : {}),
};
sendResponse(reply);
activeHelpRespond = null;
overlays.clearAgentHelpRequest(helpMsg.requestId);
renderOverlay();
};
const previousHelp = overlays.setAgentHelpRequest({
id: helpMsg.requestId,
prompt: helpMsg.prompt,
...(helpMsg.title ? { title: helpMsg.title } : {}),
selectors: helpMsg.selectors,
onContinue: (note: string) => respond("continued", note.trim() ? note : undefined),
onCancel: () => respond("cancelled"),
});
if (previousHelp) {
activeHelpRespond?.("cancelled");
}
activeHelpRespond = respond;
renderOverlay();
return true; // async sendResponse
}
if (message.type === "borrow-request") {
let responded = false;
const respond = (allowed: boolean) => {
if (responded) return;
responded = true;
sendResponse({ type: "borrow-response", allowed });
overlays.removeBorrowRequest(message.requestId);
renderOverlay();
};
overlays.addBorrowRequest({
id: message.requestId,
isActiveTab: message.isActiveTab,
tabTitle: message.tabTitle,
timeoutMs: message.timeoutMs,
onAllow: () => respond(true),
onDeny: () => respond(false),
});
renderOverlay();
return true;
}
return false;
};
async function mountOverlayIfAgent(): Promise<void> {
if (!(await anySessionLive())) return;
try {
const reply = (await chrome.runtime.sendMessage({
kind: OVERLAY_MSG_WHO_AM_I,
})) as OverlayWhoAmIResponse | undefined;
if (!reply?.sessionId) return;
overlays.activateAgentSession(reply.sessionId);
renderOverlay();
} catch (err) {
console.debug("[bsk overlay] who_am_i failed", err);
}
}
ui.mount();
chrome.runtime.onMessage.addListener(onMessage);
void mountOverlayIfAgent();
const hostObserver = new MutationObserver(() => {
const connected = overlayHost?.isConnected ?? false;
if (overlays.isControlVisible() && !connected && !hostLossReported) {
hostLossReported = true;
if (!remountInProgress) {
remountInProgress = true;
try {
ui.mount();
} finally {
remountInProgress = false;
}
}
}
if (connected) {
hostLossReported = false;
}
});
hostObserver.observe(document.documentElement, { childList: true, subtree: false });
ctx.onInvalidated(() => {
hostObserver.disconnect();
chrome.runtime.onMessage.removeListener(onMessage);
});
},
});
async function anySessionLive(): Promise<boolean> {
if (!chrome.storage?.session?.get) return true;
try {
const result = (await chrome.storage.session.get({
[SESSIONS_LIVE_FLAG_KEY]: false,
})) as Record<string, unknown> | undefined;
return Boolean(result?.[SESSIONS_LIVE_FLAG_KEY]);
} catch (err) {
console.debug("[bsk overlay] sessions-live flag read failed", err);
return true;
}
}
@@ -0,0 +1,139 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SnapshotInfo } from "@/lib/connection-controller";
import { EXTENSION_VERSION } from "@/transport/handshake";
import { App } from "./App";
import { useConnectionState } from "./use-connection-state";
vi.mock("./use-connection-state", () => ({
useConnectionState: vi.fn(),
}));
const mockUseConnectionState = vi.mocked(useConnectionState);
/** Arbitrary peer fixture — only used to distinguish daemon vs extension in the UI. */
const mockDaemonVersion = "daemon-fixture";
const baseSnapshot: SnapshotInfo = {
state: "disconnected",
instanceId: "",
label: "",
extensionVersion: EXTENSION_VERSION,
handshake: null,
lastError: null,
connectionEnabled: true,
};
describe("App", () => {
const setLabel = vi.fn();
const setConnectionEnabled = vi.fn();
beforeEach(() => {
mockUseConnectionState.mockReturnValue({
snapshot: baseSnapshot,
statusState: "disconnected",
setLabel,
setConnectionEnabled,
});
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("shows status label without helper subtitle", () => {
render(<App />);
expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
});
it("uses flex gap for label field spacing", () => {
const { container } = render(<App />);
const labelField = container.querySelector("[data-slot='popup-label-field']");
expect(labelField?.className).toContain("flex");
expect(labelField?.className).toContain("flex-col");
expect(labelField?.className).toContain("gap-3");
});
it("shows single-line compact metadata and copies the instance id", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
state: "connected",
instanceId: "03c3e47f",
label: "个人 Chrome",
handshake: {
server: "bh",
version: mockDaemonVersion,
protocol_version: "1.0",
},
},
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
expect(screen.queryByText(/^扩展 v/)).toBeNull();
expect(screen.queryByText(/^daemon v/)).toBeNull();
expect(screen.getByTitle("扩展版本").textContent).toBe(EXTENSION_VERSION);
expect(screen.getByTitle("bsk 版本").textContent).toBe(mockDaemonVersion);
expect(screen.getByText("03c3e47f")).toBeTruthy();
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
expect(copyButton.textContent).toBe("");
fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("03c3e47f");
await waitFor(() => expect(copyButton.getAttribute("title")).toBe("已复制"));
});
it("renders the connection toggle with switch semantics", () => {
render(<App />);
const toggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("calls setConnectionEnabled(false) when the toggle is turned off", () => {
render(<App />);
fireEvent.click(screen.getByRole("switch", { name: "BrowserSkill 连接" }));
expect(setConnectionEnabled).toHaveBeenCalledWith(false);
});
it("shows disabled status when connection is turned off", () => {
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, connectionEnabled: false },
statusState: "disabled",
setLabel,
setConnectionEnabled,
});
render(<App />);
expect(screen.getByText("连接已关闭")).toBeTruthy();
expect(screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked")).toBe(
"false",
);
});
});
@@ -0,0 +1,209 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { Badge, Button, Input, Label, cn } from "@browser-skill/ui";
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { type ChangeEvent, useEffect, useState } from "react";
import { PROTOCOL_VERSION } from "@/transport/handshake";
import { ConnectionStatusIndicator } from "./connection-status-indicator";
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
const STATE_LABEL_KEYS = {
disconnected: "popup.stateLabel.disconnected",
connected: "popup.stateLabel.connected",
version_skew: "popup.stateLabel.version_skew",
disabled: "popup.stateLabel.disabled",
} as const satisfies Record<PopupStatusState, string>;
const STATE_BADGE_KEYS = {
disconnected: "popup.stateBadge.disconnected",
connected: "popup.stateBadge.connected",
version_skew: "popup.stateBadge.version_skew",
disabled: "popup.stateBadge.disabled",
} as const satisfies Record<PopupStatusState, string>;
function getLogoSrc() {
if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {
return chrome.runtime.getURL("icon/logo.png");
}
return "/icon/logo.png";
}
export function App() {
const { t } = useTranslation("extension");
const { snapshot, statusState, setLabel, setConnectionEnabled } = useConnectionState();
const [labelDraft, setLabelDraft] = useState(snapshot.label);
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-color-scheme: dark)");
const applyTheme = (isDark: boolean) => {
document.documentElement.classList.toggle("dark", isDark);
};
applyTheme(query.matches);
const onChange = (event: MediaQueryListEvent) => applyTheme(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
useEffect(() => {
setLabelDraft(snapshot.label);
}, [snapshot.label]);
useEffect(() => {
setCopiedInstanceId(false);
}, [snapshot.instanceId]);
const onLabelBlur = () => {
if (labelDraft !== snapshot.label) setLabel(labelDraft);
};
const isSkewed = statusState === "version_skew";
const daemonVersion = snapshot.handshake?.version ?? "—";
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
const extensionVersion = snapshot.extensionVersion || "—";
const instanceId = snapshot.instanceId || "—";
const copyInstanceId = async () => {
if (!snapshot.instanceId || !navigator.clipboard?.writeText) return;
await navigator.clipboard.writeText(snapshot.instanceId);
setCopiedInstanceId(true);
};
return (
<main
className="min-w-[320px] max-w-[340px] space-y-3 bg-background p-3 text-foreground"
data-slot="popup-root"
data-version-skew={isSkewed ? "true" : undefined}
>
<header className="flex items-center gap-2" data-slot="popup-brand-header">
<img
src={getLogoSrc()}
alt=""
className="size-7 rounded-lg"
data-slot="popup-brand-logo"
/>
<h1 className="text-sm font-semibold tracking-tight">{t("popup.brandName")}</h1>
</header>
<section
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
data-slot="popup-connection-card"
>
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
<div className="flex min-w-0 items-center gap-2">
<ConnectionStatusIndicator state={statusState} />
<span
className="truncate text-sm font-medium"
data-slot="popup-state-label"
>
{t(STATE_LABEL_KEYS[statusState])}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
variant="outline"
className="px-1.5 py-0 text-[10px] font-medium uppercase"
data-slot="popup-state-badge"
>
{t(STATE_BADGE_KEYS[statusState])}
</Badge>
<button
type="button"
role="switch"
aria-checked={snapshot.connectionEnabled}
aria-label={t("popup.connectionToggleTitle")}
data-slot="popup-connection-toggle"
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
snapshot.connectionEnabled ? "bg-primary" : "bg-muted",
)}
onClick={() => setConnectionEnabled(!snapshot.connectionEnabled)}
>
<span
className={cn(
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
snapshot.connectionEnabled ? "translate-x-4" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
</div>
</div>
{isSkewed && (
<p
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
data-slot="popup-version-skew-warning"
>
{t("popup.versionSkewWarning", {
extensionProtocol: PROTOCOL_VERSION,
daemonProtocol,
})}
</p>
)}
</section>
{snapshot.lastError && (
<div
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
data-slot="popup-error"
>
{snapshot.lastError}
</div>
)}
<div className="flex flex-col gap-3" data-slot="popup-label-field">
<Label htmlFor="bh-label" className="block text-xs text-muted-foreground">
{t("popup.labelTitle")}
</Label>
<Input
id="bh-label"
type="text"
value={labelDraft}
onChange={(event: ChangeEvent<HTMLInputElement>) => setLabelDraft(event.target.value)}
onBlur={onLabelBlur}
placeholder={t("popup.labelPlaceholder")}
className="mt-0 h-8 text-sm"
data-slot="popup-label-input"
/>
</div>
<section
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
data-slot="popup-meta"
>
<div className="flex shrink-0 items-center gap-1">
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
<span aria-hidden>/</span>
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
</div>
<div className="flex min-w-0 items-center justify-end gap-1">
<span className="shrink-0">{t("popup.instanceTitle")}</span>
<code
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
data-slot="popup-instance-id"
>
{instanceId}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="size-5 rounded-md"
disabled={!snapshot.instanceId}
aria-label={t("popup.copyInstanceId")}
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
onClick={() => {
void copyInstanceId();
}}
data-slot="popup-copy-instance-id"
>
{copiedInstanceId ? (
<RiCheckLine className="size-3" aria-hidden />
) : (
<RiFileCopyLine className="size-3" aria-hidden />
)}
</Button>
</div>
</section>
</main>
);
}
@@ -0,0 +1,29 @@
import { cn } from "@browser-skill/ui";
import type { PopupStatusState } from "./use-connection-state";
const DOT_STYLES: Record<PopupStatusState, string> = {
disconnected: "bg-muted-foreground/40",
connected: "bg-emerald-500",
version_skew: "bg-amber-500",
disabled: "bg-muted-foreground/25",
};
interface ConnectionStatusIndicatorProps {
state: PopupStatusState;
className?: string;
}
/** Flat online-status dot (Slack / GitHub style). */
export function ConnectionStatusIndicator({ state, className }: ConnectionStatusIndicatorProps) {
return (
<span
className={cn(
"size-2 shrink-0 rounded-full ring-2 ring-background",
DOT_STYLES[state],
className,
)}
data-slot={`popup-state-dot-${state}`}
aria-hidden
/>
);
}
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>browser-skill</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
@@ -0,0 +1,19 @@
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./style.css";
const container = document.getElementById("root");
if (!container) {
throw new Error("[browser-skill] popup root element missing");
}
ReactDOM.createRoot(container).render(
<React.StrictMode>
<I18nextProvider i18n={i18n}>
<App />
</I18nextProvider>
</React.StrictMode>,
);
@@ -0,0 +1,3 @@
@import "@browser-skill/ui/styles";
@source "../../";
@source "../../../../../packages/ui/src";
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolvePopupDisplayState, resolvePopupStatusState } from "./use-connection-state";
describe("resolvePopupDisplayState", () => {
it("returns wire state when not connecting", () => {
expect(resolvePopupDisplayState("connected", "disconnected")).toBe("connected");
expect(resolvePopupDisplayState("disconnected", "connected")).toBe("disconnected");
expect(resolvePopupDisplayState("version_skew", "connected")).toBe("version_skew");
});
it("holds last stable state while connecting", () => {
expect(resolvePopupDisplayState("connecting", "connected")).toBe("connected");
expect(resolvePopupDisplayState("connecting", "disconnected")).toBe("disconnected");
expect(resolvePopupDisplayState("connecting", "version_skew")).toBe("version_skew");
});
it("falls back to disconnected when no stable state yet", () => {
expect(resolvePopupDisplayState("connecting", "connecting")).toBe("disconnected");
});
});
describe("resolvePopupStatusState", () => {
it("returns disabled when connection is turned off", () => {
expect(
resolvePopupStatusState({ connectionEnabled: false, state: "connected" }, "connected"),
).toBe("disabled");
});
it("returns version_skew when enabled and wire state is version_skew", () => {
expect(
resolvePopupStatusState({ connectionEnabled: true, state: "version_skew" }, "connected"),
).toBe("version_skew");
});
it("returns display state when enabled and not version_skew", () => {
expect(
resolvePopupStatusState({ connectionEnabled: true, state: "connected" }, "connected"),
).toBe("connected");
});
});
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from "react";
import type { SnapshotInfo } from "@/lib/connection-controller";
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
import type { ConnectionState } from "@/transport/types";
export type PopupDisplayState = Exclude<ConnectionState, "connecting">;
/** Popup status including user-disabled connection preference. */
export type PopupStatusState = PopupDisplayState | "disabled";
/** Popup UI: omit transient `connecting` to avoid label/dot flicker on reconnect. */
export function resolvePopupDisplayState(
state: ConnectionState,
lastStable: ConnectionState,
): PopupDisplayState {
if (state !== "connecting") return state;
return lastStable === "connecting" ? "disconnected" : lastStable;
}
/** When the user has disabled connection, always show the disabled status. */
export function resolvePopupStatusState(
snapshot: Pick<SnapshotInfo, "connectionEnabled" | "state">,
displayState: PopupDisplayState,
): PopupStatusState {
if (!snapshot.connectionEnabled) return "disabled";
if (snapshot.state === "version_skew") return "version_skew";
return displayState;
}
const FALLBACK_SNAPSHOT: SnapshotInfo = {
state: "disconnected",
instanceId: "",
label: "",
extensionVersion: "",
handshake: null,
lastError: null,
connectionEnabled: true,
};
/**
* Live snapshot of the background's `ConnectionController` for popup UI.
*
* Posts `set_label` mutations back to the background; the incoming
* snapshot stream reflects the canonical state. The `set_port` channel
* is defined by the popup-bridge but is not currently routed to the
* background — wiring it requires `ConnectionController` to swap its
* Transport URL and persist the chosen port. Tracked for a follow-up
* milestone; the popup intentionally does not expose a control until
* the underlying support lands (review M4/M5 C2).
*/
export function useConnectionState(): {
snapshot: SnapshotInfo;
statusState: PopupStatusState;
setLabel: (value: string) => void;
setConnectionEnabled: (value: boolean) => void;
} {
const [snapshot, setSnapshot] = useState<SnapshotInfo>(FALLBACK_SNAPSHOT);
const portRef = useRef<chrome.runtime.Port | null>(null);
const lastStableRef = useRef<ConnectionState>("disconnected");
useEffect(() => {
if (typeof chrome === "undefined" || !chrome.runtime?.connect) {
return undefined;
}
const port = chrome.runtime.connect({ name: POPUP_PORT_NAME });
portRef.current = port;
const onMessage = (raw: unknown) => {
const msg = raw as PopupInbound;
if (msg?.kind === "snapshot") setSnapshot(msg.data);
};
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(() => {
portRef.current = null;
});
return () => {
port.onMessage.removeListener(onMessage);
port.disconnect();
portRef.current = null;
};
}, []);
const post = (msg: PopupOutbound) => {
try {
portRef.current?.postMessage(msg);
} catch (err) {
console.warn("[browser-skill] popup post failed", err);
}
};
const displayState = resolvePopupDisplayState(snapshot.state, lastStableRef.current);
if (snapshot.state !== "connecting") {
lastStableRef.current = snapshot.state;
}
const statusState = resolvePopupStatusState(snapshot, displayState);
return {
snapshot,
statusState,
setLabel: (value: string) => post({ kind: "set_label", value }),
setConnectionEnabled: (value: boolean) => post({ kind: "set_connection_enabled", value }),
};
}
@@ -0,0 +1,176 @@
import { ConnectionController, __testing__ } from "../connection-controller";
import type { Transport } from "../../transport/transport";
import type { ConnectionState } from "../../transport/types";
import type { ConnectionStateHandler } from "../../transport/transport";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
import type { HandshakeResult } from "../../transport/types";
vi.mock("../instance-id", () => ({
getOrCreateInstanceId: vi.fn(async () => "a1b2c3d4"),
getLabel: vi.fn(async () => "test-label"),
}));
const { computeConnectedState } = __testing__;
function handshake(
daemonProtocol: string,
minCompatibleProtocol?: string,
daemonAppVersion = "0.1.0",
): HandshakeResult {
return {
server: "browser-skill-daemon",
version: daemonAppVersion,
protocol_version: daemonProtocol,
min_compatible_peer: "0.0.0",
min_compatible_protocol: minCompatibleProtocol,
};
}
describe("computeConnectedState (protocol-based compat)", () => {
it("returns connected when protocol strings match", () => {
expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
kind: "connected",
});
});
it("returns version_skew when daemon protocol minor is newer", () => {
expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({
kind: "version_skew",
});
});
it("returns version_skew when daemon protocol string differs but floor is satisfied", () => {
expect(computeConnectedState(handshake("1", "1.0"))).toEqual({
kind: "version_skew",
});
});
it("rejects when protocol major differs", () => {
const result = computeConnectedState(handshake("2.0", "1.0"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("protocol-major mismatch");
}
});
it("rejects when extension is below daemon min_compatible_protocol", () => {
const result = computeConnectedState(handshake("1.0", "1.5"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("min_compatible_protocol");
expect(result.reason).toContain("1.5");
}
});
it("does not reject when min_compatible_protocol is missing (legacy daemon)", () => {
const result = computeConnectedState({
server: "browser-skill-daemon",
version: "0.1.0",
protocol_version: "1.0",
min_compatible_peer: "0.1.0",
});
expect(result).toEqual({ kind: "connected" });
});
it("rejects when daemon protocol is below extension floor", () => {
const result = computeConnectedState(handshake("1.0", "1.0"), "1.1");
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("below extension min_compatible_protocol");
}
});
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
const result = computeConnectedState(handshake("1.0", "not-a-protocol"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("daemon min_compatible_protocol");
expect(result.reason).toContain("not-a-protocol");
}
});
it("rejects unparseable daemon protocol", () => {
const result = computeConnectedState(handshake("oops", "1.0"));
expect(result.kind).toBe("rejected");
});
});
function makeMockTransport(initialState: ConnectionState = "disconnected") {
let state = initialState;
const stateHandlers = new Set<ConnectionStateHandler>();
const transport = {
get state() {
return state;
},
connect: vi.fn(async () => {
state = "connected";
for (const h of stateHandlers) h("connected");
}),
disconnect: vi.fn(async () => {
state = "disconnected";
for (const h of stateHandlers) h("disconnected");
}),
send: vi.fn(),
onMessage: vi.fn(() => ({ dispose: () => {} })),
onConnectionStateChange: vi.fn((handler: ConnectionStateHandler) => {
stateHandlers.add(handler);
return { dispose: () => stateHandlers.delete(handler) };
}),
emitState(next: ConnectionState) {
state = next;
for (const h of stateHandlers) h(next);
},
};
return transport as typeof transport & Transport;
}
describe("ConnectionController connectionEnabled", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not connect on attach when connection is disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
expect(transport.connect).not.toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(false);
expect(controller.snapshot().state).toBe("disconnected");
});
it("disconnects when connection is disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, true);
expect(transport.connect).toHaveBeenCalled();
await controller.setConnectionEnabled(false);
expect(transport.disconnect).toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(false);
expect(controller.snapshot().handshake).toBeNull();
expect(controller.snapshot().lastError).toBeNull();
});
it("reconnects when connection is re-enabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
await controller.setConnectionEnabled(true);
expect(transport.connect).toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(true);
});
it("ignores transport state changes while disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
transport.emitState("connected");
expect(controller.snapshot().state).toBe("disconnected");
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
HELP_CANCEL,
HELP_REQUEST,
HELP_RESPONSE,
isHelpCancelMessage,
isHelpRequestMessage,
} from "../help-bridge";
describe("help-bridge", () => {
it("exposes stable message type constants", () => {
expect(HELP_REQUEST).toBe("bsk-help-request");
expect(HELP_RESPONSE).toBe("bsk-help-response");
expect(HELP_CANCEL).toBe("bsk-help-cancel");
});
it("type-guards a help-request message", () => {
expect(
isHelpRequestMessage({
type: HELP_REQUEST,
requestId: "r1",
prompt: "log in",
selectors: ["#login"],
timeoutMs: 1000,
}),
).toBe(true);
expect(isHelpRequestMessage({ type: "borrow-request" })).toBe(false);
expect(isHelpRequestMessage(null)).toBe(false);
});
it("rejects a help-request message with missing/wrong fields", () => {
expect(isHelpRequestMessage({ type: HELP_REQUEST })).toBe(false);
expect(
isHelpRequestMessage({
type: HELP_REQUEST,
requestId: "r1",
prompt: "p",
selectors: "nope",
timeoutMs: 1,
}),
).toBe(false);
expect(
isHelpRequestMessage({
type: HELP_REQUEST,
requestId: "r1",
prompt: "p",
selectors: [123],
timeoutMs: 1,
}),
).toBe(false);
});
it("type-guards a help-cancel message", () => {
expect(
isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" }),
).toBe(true);
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(
false,
);
expect(isHelpCancelMessage(null)).toBe(false);
});
});
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import {
getConnectionEnabled,
getLabel,
getOrCreateInstanceId,
STORAGE_KEYS,
setConnectionEnabled,
setLabel,
} from "../instance-id";
function fakeStorage(initial: Record<string, unknown> = {}) {
const store = { ...initial };
return {
store,
backend: {
get: vi.fn(async (keys: string | string[]) => {
const result: Record<string, unknown> = {};
const list = Array.isArray(keys) ? keys : [keys];
for (const k of list) if (k in store) result[k] = store[k];
return result;
}),
set: vi.fn(async (items: Record<string, unknown>) => {
Object.assign(store, items);
}),
},
};
}
describe("instance-id", () => {
it("generates a new 8-char hex id when storage is empty and persists it", async () => {
const { store, backend } = fakeStorage();
const id = await getOrCreateInstanceId(backend);
expect(id).toMatch(/^[0-9a-f]{8}$/);
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
expect(backend.set).toHaveBeenCalledOnce();
});
it("returns the persisted short id on subsequent calls", async () => {
const existing = "a7f32e1c";
const { backend } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: existing });
const id = await getOrCreateInstanceId(backend);
expect(id).toBe(existing);
expect(backend.set).not.toHaveBeenCalled();
});
it("replaces legacy UUID storage with a short id", async () => {
const legacy = "abcdef01-2345-4678-89ab-cdef01234567";
const { backend, store } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: legacy });
const id = await getOrCreateInstanceId(backend);
expect(id).toMatch(/^[0-9a-f]{8}$/);
expect(id).not.toBe(legacy);
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
expect(backend.set).toHaveBeenCalledOnce();
});
it("treats non-string stored values as missing", async () => {
const { backend, store } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: 42 });
const id = await getOrCreateInstanceId(backend);
expect(id).toMatch(/^[0-9a-f]{8}$/);
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
});
it("getLabel returns empty string when label is unset", async () => {
const { backend } = fakeStorage();
expect(await getLabel(backend)).toBe("");
});
it("setLabel persists the value retrievable by getLabel", async () => {
const { backend, store } = fakeStorage();
await setLabel("Personal Chrome", backend);
expect(store[STORAGE_KEYS.LABEL]).toBe("Personal Chrome");
expect(await getLabel(backend)).toBe("Personal Chrome");
});
it("getConnectionEnabled returns true when storage is empty", async () => {
const { backend } = fakeStorage();
expect(await getConnectionEnabled(backend)).toBe(true);
});
it("getConnectionEnabled returns persisted boolean values", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.CONNECTION_ENABLED]: false });
expect(await getConnectionEnabled(backend)).toBe(false);
});
it("getConnectionEnabled treats non-boolean stored values as enabled", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.CONNECTION_ENABLED]: "false" });
expect(await getConnectionEnabled(backend)).toBe(true);
});
it("setConnectionEnabled persists the value retrievable by getConnectionEnabled", async () => {
const { backend, store } = fakeStorage();
await setConnectionEnabled(false, backend);
expect(store[STORAGE_KEYS.CONNECTION_ENABLED]).toBe(false);
expect(await getConnectionEnabled(backend)).toBe(false);
});
});
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import type { Transport } from "@/transport/transport";
import type { ConnectionState } from "@/transport/types";
import { type AlarmsApi, KEEPALIVE_ALARM_NAME, startKeepalive } from "../keepalive";
function makeAlarms(): AlarmsApi & {
triggers: Array<(alarm: { name: string }) => void>;
created: Array<{ name: string; info: { periodInMinutes: number } }>;
cleared: string[];
} {
const triggers: Array<(alarm: { name: string }) => void> = [];
const created: Array<{ name: string; info: { periodInMinutes: number } }> = [];
const cleared: string[] = [];
return {
triggers,
created,
cleared,
create: (name, info) => {
created.push({ name, info });
},
clear: (name) => {
cleared.push(name);
},
onAlarm: {
addListener: (cb) => triggers.push(cb),
removeListener: (cb) => {
const idx = triggers.indexOf(cb);
if (idx >= 0) triggers.splice(idx, 1);
},
},
};
}
function makeTransport(initialState: ConnectionState = "disconnected"): Transport & {
connectCalls: number;
} {
let state = initialState;
const transport = {
connectCalls: 0,
get state() {
return state;
},
connect: vi.fn(async () => {
state = "connected";
transport.connectCalls += 1;
}),
disconnect: vi.fn(async () => {
state = "disconnected";
}),
send: vi.fn(),
onMessage: vi.fn(() => ({ dispose: () => {} })),
onConnectionStateChange: vi.fn(() => ({ dispose: () => {} })),
} as unknown as Transport & { connectCalls: number };
return transport;
}
describe("startKeepalive", () => {
it("registers a 30s alarm and a listener on creation", () => {
const alarms = makeAlarms();
const transport = makeTransport();
const handle = startKeepalive({ transport, alarms });
expect(alarms.created).toEqual([
{ name: KEEPALIVE_ALARM_NAME, info: { periodInMinutes: 0.5 } },
]);
expect(alarms.triggers).toHaveLength(1);
handle.dispose();
expect(alarms.cleared).toEqual([KEEPALIVE_ALARM_NAME]);
expect(alarms.triggers).toHaveLength(0);
});
it("kicks transport.connect() when the alarm fires and we are disconnected", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
startKeepalive({ transport, alarms });
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
// Fire the alarm; allow microtasks to drain.
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(1);
});
it("does not call transport.connect() while connected", async () => {
const alarms = makeAlarms();
const transport = makeTransport("connected");
startKeepalive({ transport, alarms });
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
});
it("ignores alarms for other names", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
startKeepalive({ transport, alarms });
alarms.triggers[0]({ name: "someone-else" });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
});
it("swallows connect rejections so the alarm loop keeps running", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
const mockedConnect = transport.connect as unknown as {
mockImplementation: (fn: () => Promise<void>) => void;
};
mockedConnect.mockImplementation(async () => {
throw new Error("boom");
});
const handle = startKeepalive({ transport, alarms });
await expect(handle.tickForTest()).resolves.toBeUndefined();
});
it("does not reconnect when shouldConnect returns false", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
startKeepalive({ transport, alarms, shouldConnect: () => false });
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
});
it("reconnects when shouldConnect returns true", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
let enabled = false;
startKeepalive({ transport, alarms, shouldConnect: () => enabled });
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
enabled = true;
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(1);
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import {
OVERLAY_AGENT_OVERLAY_RESET,
OVERLAY_MSG_INTERRUPT,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
isOverlayAgentOverlayResetMessage,
} from "@/lib/overlay-bridge";
describe("OVERLAY_MSG_INTERRUPT", () => {
it("constant matches the wire string content scripts will send", () => {
expect(OVERLAY_MSG_INTERRUPT).toBe("overlay.interrupt");
});
it("OverlayInterruptRequest type carries kind + sessionId", () => {
const req: OverlayInterruptRequest = {
kind: OVERLAY_MSG_INTERRUPT,
sessionId: "sess-1",
};
expect(req.kind).toBe("overlay.interrupt");
expect(req.sessionId).toBe("sess-1");
});
it("OverlayInterruptResponse carries ok flag", () => {
const res: OverlayInterruptResponse = { ok: true };
expect(res.ok).toBe(true);
});
});
describe("isOverlayAgentOverlayResetMessage", () => {
it("accepts reset messages with a session id", () => {
expect(
isOverlayAgentOverlayResetMessage({
type: OVERLAY_AGENT_OVERLAY_RESET,
sessionId: "sess-1",
}),
).toBe(true);
});
it("rejects reset messages without a session id", () => {
expect(
isOverlayAgentOverlayResetMessage({
type: OVERLAY_AGENT_OVERLAY_RESET,
}),
).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from "vitest";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
describe("sendInterrupt", () => {
it("sends OVERLAY_MSG_INTERRUPT with the session id and resolves to the reply", async () => {
const sendMessage = vi.fn().mockResolvedValue({ ok: true });
const result = await sendInterrupt(sendMessage, "sess-9");
expect(sendMessage).toHaveBeenCalledWith({
kind: "overlay.interrupt",
sessionId: "sess-9",
});
expect(result).toEqual({ ok: true });
});
it("treats an absent reply as failure", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const result = await sendInterrupt(sendMessage, "sess-1");
expect(result).toEqual({ ok: false });
});
it("treats a thrown sendMessage as failure", async () => {
const sendMessage = vi.fn().mockRejectedValue(new Error("boom"));
const result = await sendInterrupt(sendMessage, "sess-1");
expect(result).toEqual({ ok: false });
});
it("resolves to ok=false if the round trip exceeds the timeout", async () => {
vi.useFakeTimers();
const sendMessage = vi.fn(() => new Promise(() => {})); // never resolves
const promise = sendInterrupt(sendMessage, "sess-t", { timeoutMs: 2000 });
vi.advanceTimersByTime(2000);
const result = await promise;
expect(result).toEqual({ ok: false });
vi.useRealTimers();
});
});
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { compareProtocol, parseProtocolMajor } from "../semver";
describe("compareProtocol", () => {
it("orders minor versions", () => {
expect(compareProtocol("1.1", "1.0")).toBeGreaterThan(0);
expect(compareProtocol("1.0", "1.1")).toBeLessThan(0);
});
it("treats major-only as zero minor", () => {
expect(compareProtocol("1", "1.0")).toBe(0);
});
it("returns null for unparseable input", () => {
expect(compareProtocol("oops", "1.0")).toBeNull();
expect(compareProtocol("1.0", "1.x")).toBeNull();
});
});
describe("parseProtocolMajor (review round 3 I2: match Rust parse_major)", () => {
// The daemon uses `value.split('.').next().and_then(|x|
// x.parse::<u64>().ok())` (see `bsk-protocol::system::parse_major`).
// The extension's parser MUST behave identically — any input the
// daemon accepts has to round-trip to the same major here, and any
// input the daemon rejects must return `null`. Pre-fix the regex
// `/^(\d+)/` happily peeled off `1` from "1x" / "1e3" / " 1.0",
// which would let `computeConnectedState` pass the protocol-major
// gate on inputs that `evaluate_handshake_compat` would reject —
// the same asymmetry round 3 I1 fought on the app-version axis.
it("rejects trailing non-digit characters (e.g. '1x')", () => {
// Rust: 'split(.) -> "1x"' -> u64::parse fails -> None
expect(parseProtocolMajor("1x")).toBeNull();
});
it("rejects scientific notation (e.g. '1e3')", () => {
// Rust u64 has no concept of "1e3"; ours must not silently
// accept it either.
expect(parseProtocolMajor("1e3")).toBeNull();
});
it("rejects empty strings", () => {
expect(parseProtocolMajor("")).toBeNull();
});
it("rejects non-numeric leading field (e.g. 'x.0')", () => {
expect(parseProtocolMajor("x.0")).toBeNull();
});
it("rejects negative numbers (e.g. '-1.0')", () => {
// The daemon's strict digit parser rejects '-1' (the sign is not
// an ASCII digit) before delegating to `u64::from_str`; /^\d+$/
// rejects it here too.
expect(parseProtocolMajor("-1.0")).toBeNull();
});
it("rejects leading whitespace (e.g. ' 1.0')", () => {
// The daemon's strict digit parser rejects leading whitespace
// (space is not an ASCII digit); we must too.
expect(parseProtocolMajor(" 1.0")).toBeNull();
});
it("rejects an explicit + sign (e.g. '+1.0')", () => {
// `u64::from_str("+1")` actually returns `Ok(1)` — the std-lib
// parser peels off a leading `+`. The daemon's strict digit
// parser (see `parse_major` in `crates/bsk-protocol/src/system.rs`)
// is what rejects this input, by requiring `/^\d+$/` on the head
// segment before delegating to `u64::from_str`. We mirror that
// reject here so the two parsers reach the same verdict.
expect(parseProtocolMajor("+1.0")).toBeNull();
});
it("rejects values past Number.MAX_SAFE_INTEGER conservatively", () => {
// Round 5 I1: the daemon now caps `parse_major` at
// `Number.MAX_SAFE_INTEGER` for parser symmetry, so both peers
// reject the entire `MAX_SAFE_INTEGER < n <= u64::MAX` band
// (previously the daemon accepted those values while the TS
// side's `!Number.isSafeInteger` guard rejected them).
const tooBig = "99999999999999999999"; // 20 digits, > 2^64 too
expect(parseProtocolMajor(tooBig)).toBeNull();
expect(parseProtocolMajor(`${tooBig}.0`)).toBeNull();
});
it("rejects MAX_SAFE_INTEGER + 1 ('9007199254740992')", () => {
// First value above the cap. Matches the daemon's
// `rejects_value_above_max_safe_integer` test on the Rust side.
expect(parseProtocolMajor("9007199254740992")).toBeNull();
});
it("rejects u64::MAX ('18446744073709551615')", () => {
// A valid u64 literal but well above `Number.MAX_SAFE_INTEGER`.
// Pre round 5 the daemon accepted this as `Some(u64::MAX)` while
// the TS side rejected it via `!Number.isSafeInteger` — exactly
// the asymmetry round 5 I1 closes.
expect(parseProtocolMajor("18446744073709551615")).toBeNull();
});
it("accepts leading zeros (e.g. '01.0' -> 1)", () => {
// The daemon's `u64::from_str` accepts leading zeros after the
// digit guard passes, so we must too.
expect(parseProtocolMajor("01.0")).toBe(1);
});
it("ignores everything past the first dot ('1.x' -> 1)", () => {
// The daemon takes only `split('.').next()`, so the minor
// segment's shape never affects the major.
expect(parseProtocolMajor("1.x")).toBe(1);
});
it("returns the parsed major for the canonical wire format ('1.0.0' -> 1)", () => {
expect(parseProtocolMajor("1.0.0")).toBe(1);
});
it("returns the parsed major for a single-segment input ('123' -> 123)", () => {
expect(parseProtocolMajor("123")).toBe(123);
});
it("accepts exactly Number.MAX_SAFE_INTEGER as the upper bound", () => {
// Boundary case: at MAX_SAFE_INTEGER the value still round-trips
// through `Number()` without precision loss, so we accept it.
// The daemon's matching test is `accepts_value_at_max_safe_integer`.
expect(parseProtocolMajor(`${Number.MAX_SAFE_INTEGER}`)).toBe(Number.MAX_SAFE_INTEGER);
});
});
@@ -0,0 +1,260 @@
import {
EXTENSION_VERSION,
MIN_COMPATIBLE_PROTOCOL,
PROTOCOL_VERSION,
performHandshake,
} from "../transport/handshake";
import type { Transport } from "../transport/transport";
import type { ConnectionState, HandshakeResult } from "../transport/types";
import { getLabel, getOrCreateInstanceId } from "./instance-id";
import { compareProtocol, parseProtocolMajor } from "./semver";
export interface SnapshotInfo {
state: ConnectionState;
instanceId: string;
label: string;
extensionVersion: string;
handshake: HandshakeResult | null;
lastError: string | null;
connectionEnabled: boolean;
}
type Listener = (s: SnapshotInfo) => void;
/**
* Orchestrates Transport + handshake lifecycle for the background SW.
*
* Owns the canonical `ConnectionState` and `HandshakeResult` cache that
* the popup queries via `chrome.runtime.connect` (wired in M4.5).
*/
export class ConnectionController {
private transport: Transport | null = null;
private currentState: ConnectionState = "disconnected";
private handshake: HandshakeResult | null = null;
private instanceId = "";
private label = "";
private lastError: string | null = null;
private connectionEnabled = true;
private listeners = new Set<Listener>();
private handshakeInFlight = false;
get isConnectionEnabled(): boolean {
return this.connectionEnabled;
}
/**
* Subscribe to snapshot changes. Fires immediately with the current
* snapshot, then again on every transition.
*/
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
listener(this.snapshot());
return () => this.listeners.delete(listener);
}
snapshot(): SnapshotInfo {
return {
state: this.currentState,
instanceId: this.instanceId,
label: this.label,
extensionVersion: EXTENSION_VERSION,
handshake: this.handshake,
lastError: this.lastError,
connectionEnabled: this.connectionEnabled,
};
}
async attach(
transport: Transport,
browser: { name: string; version: string },
connectionEnabled = true,
): Promise<void> {
this.transport = transport;
this.connectionEnabled = connectionEnabled;
this.instanceId = await getOrCreateInstanceId();
this.label = await getLabel();
transport.onConnectionStateChange((s) => {
if (!this.connectionEnabled) return;
if (s === "connected") {
void this.runHandshake(browser);
return;
}
if (s === "disconnected") {
this.handshake = null;
}
this.setState(s);
});
transport.onMessage(() => {
// Real RPC dispatching is wired in M5 (tools/dispatcher.ts).
});
if (this.connectionEnabled) {
try {
await transport.connect();
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected");
}
} else {
this.applyDisabledState();
}
}
async setConnectionEnabled(enabled: boolean): Promise<void> {
if (this.connectionEnabled === enabled) return;
this.connectionEnabled = enabled;
if (!enabled) {
await this.applyDisabledState();
return;
}
this.fire();
if (!this.transport) return;
try {
await this.transport.connect();
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected");
}
}
async refreshLabel(): Promise<void> {
this.label = await getLabel();
this.fire();
}
private async runHandshake(browser: { name: string; version: string }): Promise<void> {
if (!this.transport) return;
if (!this.connectionEnabled) return;
if (this.handshakeInFlight) return;
this.handshakeInFlight = true;
this.setState("connecting");
try {
const outcome = await performHandshake(this.transport, {
instanceId: this.instanceId,
browser,
label: this.label,
});
this.handshake = outcome.result;
const verdict = computeConnectedState(outcome.result);
if (verdict.kind === "rejected") {
this.lastError = `version_too_old: ${verdict.reason}`;
this.handshake = null;
await this.transport.disconnect().catch(() => {});
this.setState("disconnected");
return;
}
this.lastError = null;
this.setState(verdict.kind);
} catch (err) {
this.handshake = null;
this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected");
} finally {
this.handshakeInFlight = false;
}
}
private async applyDisabledState(): Promise<void> {
this.handshake = null;
this.lastError = null;
if (this.transport) {
await this.transport.disconnect().catch(() => {});
}
const was = this.currentState;
this.setState("disconnected");
if (was === "disconnected") this.fire();
}
private setState(next: ConnectionState): void {
if (this.currentState === next) return;
this.currentState = next;
this.fire();
}
private fire(): void {
const snap = this.snapshot();
for (const l of this.listeners) {
try {
l(snap);
} catch (err) {
console.error("[bh] connection snapshot listener threw", err);
}
}
}
}
/**
* Verdict of the symmetric post-handshake compat check.
*/
export type HandshakeVerdict =
| { kind: "connected" }
| { kind: "version_skew" }
| { kind: "rejected"; reason: string };
/**
* Symmetric extension-side **protocol** compat check.
*
* Mirrors `evaluate_handshake_compat` in `crates/bsk-protocol/src/system.rs`.
* Application semvers are not compared — CLI and extension ship on
* independent version lines.
*
* Ordering:
* 1. protocol major mismatch → `rejected`
* 2. daemon protocol < extension's protocol floor → `rejected`
* 3. extension protocol < daemon's protocol floor → `rejected` (skipped when absent)
* 4. protocol strings equal → `connected`
* 5. otherwise (same major, minor drift) → `version_skew`
*
* `localMinCompatibleProtocol` defaults to {@link MIN_COMPATIBLE_PROTOCOL};
* tests may inject a different floor.
*/
export function computeConnectedState(
handshake: HandshakeResult,
localMinCompatibleProtocol: string = MIN_COMPATIBLE_PROTOCOL,
): HandshakeVerdict {
const daemonMajor = parseProtocolMajor(handshake.protocol_version);
const ourMajor = parseProtocolMajor(PROTOCOL_VERSION);
if (daemonMajor === null || ourMajor === null || daemonMajor !== ourMajor) {
return {
kind: "rejected",
reason: `protocol-major mismatch (daemon protocol v${handshake.protocol_version}, extension protocol v${PROTOCOL_VERSION})`,
};
}
if (compareProtocol(handshake.protocol_version, localMinCompatibleProtocol) === null) {
return {
kind: "rejected",
reason: `daemon protocol v${handshake.protocol_version} is unparseable`,
};
}
if (compareProtocol(handshake.protocol_version, localMinCompatibleProtocol)! < 0) {
return {
kind: "rejected",
reason: `daemon protocol v${handshake.protocol_version} below extension min_compatible_protocol ${localMinCompatibleProtocol}`,
};
}
const peerFloor = handshake.min_compatible_protocol;
if (peerFloor) {
if (compareProtocol(PROTOCOL_VERSION, peerFloor) === null) {
return {
kind: "rejected",
reason: `daemon min_compatible_protocol ${peerFloor} is unparseable`,
};
}
if (compareProtocol(PROTOCOL_VERSION, peerFloor)! < 0) {
return {
kind: "rejected",
reason: `extension protocol v${PROTOCOL_VERSION} below daemon min_compatible_protocol ${peerFloor}`,
};
}
}
if (handshake.protocol_version === PROTOCOL_VERSION) {
return { kind: "connected" };
}
return { kind: "version_skew" };
}
export const __testing__ = { computeConnectedState };
+60
View File
@@ -0,0 +1,60 @@
/**
* Wire protocol for the human-in-loop overlay, sent between the
* background service worker and a tab's content script via
* `chrome.runtime.sendMessage` / `chrome.tabs.sendMessage`.
*
* Mirrors the borrow-confirmation message style (see
* `@/tools/borrow-confirmation`). The background asks a specific tab to
* enter "help mode" (render HelpRequestOverlay + hide ControlOverlay);
* the content script replies once the user clicks Continue / Cancel.
*/
export const HELP_REQUEST = "bsk-help-request";
export const HELP_RESPONSE = "bsk-help-response";
export const HELP_CANCEL = "bsk-help-cancel";
export interface HelpRequestMessage {
type: typeof HELP_REQUEST;
requestId: string;
prompt: string;
/** Custom overlay title; omitted when the extension should use its default. */
title?: string;
/** CSS selectors to scroll to + flash-highlight (may be empty). */
selectors: string[];
timeoutMs: number;
}
export interface HelpResponseMessage {
type: typeof HELP_RESPONSE;
outcome: "continued" | "cancelled";
note?: string;
}
export interface HelpCancelMessage {
type: typeof HELP_CANCEL;
requestId: string;
}
export function isHelpRequestMessage(msg: unknown): msg is HelpRequestMessage {
if (typeof msg !== "object" || msg === null) {
return false;
}
const m = msg as Record<string, unknown>;
return (
m.type === HELP_REQUEST &&
typeof m.requestId === "string" &&
typeof m.prompt === "string" &&
(m.title === undefined || typeof m.title === "string") &&
Array.isArray(m.selectors) &&
m.selectors.every((selector) => typeof selector === "string") &&
typeof m.timeoutMs === "number"
);
}
export function isHelpCancelMessage(msg: unknown): msg is HelpCancelMessage {
if (typeof msg !== "object" || msg === null) {
return false;
}
const m = msg as Record<string, unknown>;
return m.type === HELP_CANCEL && typeof m.requestId === "string";
}
+112
View File
@@ -0,0 +1,112 @@
const STORAGE_KEY = "bsk_instance_id";
const LABEL_STORAGE_KEY = "bh_label";
const CONNECTION_ENABLED_KEY = "bh_connection_enabled";
export interface StorageBackend {
get(keys: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
export function defaultStorage(): StorageBackend {
return {
get: (keys) =>
new Promise((resolve, reject) => {
try {
chrome.storage.local.get(keys, (items) => {
const err = chrome.runtime.lastError;
if (err) reject(new Error(err.message));
else resolve(items as Record<string, unknown>);
});
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
}),
set: (items) =>
new Promise((resolve, reject) => {
try {
chrome.storage.local.set(items, () => {
const err = chrome.runtime.lastError;
if (err) reject(new Error(err.message));
else resolve();
});
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
}),
};
}
/** Length of newly generated browser instance ids (8 hex chars → 32 bits). */
export const INSTANCE_ID_LENGTH = 8;
/** Matches ids produced by {@link generateShortInstanceId}. */
export const SHORT_INSTANCE_ID_PATTERN = /^[0-9a-f]{8}$/;
function generateShortInstanceId(): string {
const buf = new Uint8Array(INSTANCE_ID_LENGTH / 2);
if (typeof crypto !== "undefined") {
crypto.getRandomValues(buf);
} else {
for (let i = 0; i < buf.length; i += 1) buf[i] = Math.floor(Math.random() * 256);
}
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
}
function isPersistedInstanceId(value: unknown): value is string {
return typeof value === "string" && SHORT_INSTANCE_ID_PATTERN.test(value);
}
/**
* Read the persistent extension instance_id from `chrome.storage.local`,
* generating + persisting a fresh 8-char hex id on first use.
*
* The id is opaque to the daemon — it just needs to be stable across SW
* restarts so the daemon can correlate reconnects to the same browser.
* Legacy UUID values in storage are replaced on read so agents can use
* short `--browser` arguments.
*/
export async function getOrCreateInstanceId(
storage: StorageBackend = defaultStorage(),
): Promise<string> {
const items = await storage.get(STORAGE_KEY);
const existing = items[STORAGE_KEY];
if (isPersistedInstanceId(existing)) return existing;
const fresh = generateShortInstanceId();
await storage.set({ [STORAGE_KEY]: fresh });
return fresh;
}
export async function getLabel(storage: StorageBackend = defaultStorage()): Promise<string> {
const items = await storage.get(LABEL_STORAGE_KEY);
const raw = items[LABEL_STORAGE_KEY];
return typeof raw === "string" ? raw : "";
}
export async function setLabel(
label: string,
storage: StorageBackend = defaultStorage(),
): Promise<void> {
await storage.set({ [LABEL_STORAGE_KEY]: label });
}
/** Defaults to enabled when unset or non-boolean. */
export async function getConnectionEnabled(
storage: StorageBackend = defaultStorage(),
): Promise<boolean> {
const items = await storage.get(CONNECTION_ENABLED_KEY);
const raw = items[CONNECTION_ENABLED_KEY];
return typeof raw === "boolean" ? raw : true;
}
export async function setConnectionEnabled(
enabled: boolean,
storage: StorageBackend = defaultStorage(),
): Promise<void> {
await storage.set({ [CONNECTION_ENABLED_KEY]: enabled });
}
export const STORAGE_KEYS = {
INSTANCE_ID: STORAGE_KEY,
LABEL: LABEL_STORAGE_KEY,
CONNECTION_ENABLED: CONNECTION_ENABLED_KEY,
} as const;
+114
View File
@@ -0,0 +1,114 @@
/**
* MV3 service-worker keepalive + transport supervisor.
*
* Address review M4/M5 C3 + C4: an MV3 service worker is evicted after
* ~30s of no events. WebSocket I/O on its own does not keep the SW
* alive, so an idle daemon connection eventually loses the SW, drops
* the underlying socket, and the daemon then purges every session
* bound to this browser. `WSTransport.scheduleReconnect` uses
* `setTimeout` which also does not survive eviction.
*
* This module owns both halves of the mitigation:
* - A 30s `chrome.alarms` heartbeat that keeps the SW awake whenever
* we want the daemon link to stay live.
* - On each tick, if the transport is not connected, kick a fresh
* `connect()` so we never sit on a stale `disconnected` state just
* because the only `setTimeout` reconnect timer was lost.
*
* The alarm period is the minimum allowed by Chrome stable
* (`periodInMinutes: 0.5` → 30s), which lines up with the documented
* SW idle threshold.
*/
import type { Transport } from "@/transport/transport";
export const KEEPALIVE_ALARM_NAME = "bh-keepalive";
export const KEEPALIVE_PERIOD_MIN = 0.5; // 30s, the smallest Chrome accepts.
/** Subset of `chrome.alarms` we actually use; lets vitest inject a fake. */
export interface AlarmsApi {
create(name: string, info: { periodInMinutes: number }): void;
clear(name: string, cb?: (wasCleared: boolean) => void): void;
onAlarm: {
addListener(cb: (alarm: { name: string }) => void): void;
removeListener(cb: (alarm: { name: string }) => void): void;
};
}
function defaultAlarms(): AlarmsApi | null {
if (typeof chrome === "undefined") return null;
if (!chrome.alarms?.create || !chrome.alarms?.onAlarm) return null;
return {
create: (name, info) => chrome.alarms.create(name, info),
clear: (name, cb) => {
void chrome.alarms.clear(name).then((wasCleared) => cb?.(wasCleared));
},
onAlarm: {
addListener: (cb) => chrome.alarms.onAlarm.addListener(cb),
removeListener: (cb) => chrome.alarms.onAlarm.removeListener(cb),
},
};
}
export interface KeepaliveOptions {
transport: Transport;
/** When provided and returns false, skip reconnect attempts on alarm ticks. */
shouldConnect?: () => boolean;
alarms?: AlarmsApi;
/** Override the alarm name (tests). */
alarmName?: string;
/** Override the alarm period (tests). */
periodMin?: number;
}
export interface KeepaliveHandle {
/** Detach the alarm listener and clear the alarm. */
dispose(): void;
/** Exposed for tests; simulates one alarm tick. */
tickForTest(): Promise<void>;
}
/**
* Install a SW-revival heartbeat that also forces an idle reconnect on
* every tick. The heartbeat itself is the alarm callback firing —
* which counts as activity and keeps the SW alive.
*/
export function startKeepalive(options: KeepaliveOptions): KeepaliveHandle {
const alarms = options.alarms ?? defaultAlarms();
const name = options.alarmName ?? KEEPALIVE_ALARM_NAME;
const period = options.periodMin ?? KEEPALIVE_PERIOD_MIN;
const tick = async () => {
if (options.shouldConnect && !options.shouldConnect()) return;
if (options.transport.state === "connected") return;
try {
await options.transport.connect();
} catch (err) {
console.debug("[bsk keepalive] connect attempt failed", err);
}
};
if (!alarms) {
// No chrome.alarms in this environment (e.g. unit tests that didn't
// provide a fake). Disposing is a no-op.
return {
dispose: () => {},
tickForTest: tick,
};
}
const handler = (alarm: { name: string }) => {
if (alarm.name !== name) return;
void tick();
};
alarms.onAlarm.addListener(handler);
alarms.create(name, { periodInMinutes: period });
return {
dispose: () => {
alarms.onAlarm.removeListener(handler);
alarms.clear(name);
},
tickForTest: tick,
};
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Wire protocol for `chrome.runtime.sendMessage`-driven communication
* between the content-script control overlay and the background SW.
*
* Content script → background:
* - `{ kind: "overlay.who_am_i", tabId, windowId }` → background
* replies `{ sessionId: string | null }` so the overlay knows
* whether to render itself.
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
* daemon (via a `session.user_interrupt` WS event) to cancel
* every inflight + queued tool call for that session with
* `ErrorCode::UserAborted`. The Agent Window, CDP attachment,
* and conversation context are preserved.
*/
export const OVERLAY_MSG_WHO_AM_I = "overlay.who_am_i";
export const OVERLAY_MSG_INTERRUPT = "overlay.interrupt";
export interface OverlayWhoAmIRequest {
kind: typeof OVERLAY_MSG_WHO_AM_I;
tabId?: number;
windowId?: number;
}
export interface OverlayWhoAmIResponse {
sessionId: string | null;
}
export interface OverlayInterruptRequest {
kind: typeof OVERLAY_MSG_INTERRUPT;
sessionId: string;
}
export interface OverlayInterruptResponse {
ok: boolean;
}
/** Background → content: temporarily disable overlay click blocker for CDP clicks. */
export const OVERLAY_AUTOMATION_BYPASS = "bh-automation-bypass";
export interface OverlayAutomationBypassMessage {
type: typeof OVERLAY_AUTOMATION_BYPASS;
enabled: boolean;
}
/** Background → content: clear overlays that belong only inside an Agent tab. */
export const OVERLAY_AGENT_OVERLAY_RESET = "bh-agent-overlay-reset";
export interface OverlayAgentOverlayResetMessage {
type: typeof OVERLAY_AGENT_OVERLAY_RESET;
sessionId: string;
}
export function isOverlayAgentOverlayResetMessage(
message: unknown,
): message is OverlayAgentOverlayResetMessage {
if (!message || typeof message !== "object") return false;
const candidate = message as { type?: unknown; sessionId?: unknown };
return candidate.type === OVERLAY_AGENT_OVERLAY_RESET && typeof candidate.sessionId === "string";
}
export type OverlayMessage = OverlayWhoAmIRequest | OverlayInterruptRequest;
@@ -0,0 +1,48 @@
import {
OVERLAY_MSG_INTERRUPT,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
} from "@/lib/overlay-bridge";
const DEFAULT_TIMEOUT_MS = 2000;
export interface SendInterruptOptions {
timeoutMs?: number;
}
/**
* Round-trip an `overlay.interrupt` message to the background SW.
*
* Resolves to `{ ok: true }` only when the SW explicitly replies
* with `ok: true`. All other outcomes — undefined reply, thrown
* sendMessage, timeout — collapse to `{ ok: false }` so the caller
* can take the same retract-and-retry path regardless of cause.
*
* The 2 s soft timeout matches the design doc: the user must never
* wait longer than that to see the mask retract, even if the
* daemon is unreachable. The cancellation itself is fire-and-forget
* on the daemon side — a slow ack does not invalidate it.
*/
export async function sendInterrupt(
sendMessage: (msg: OverlayInterruptRequest) => Promise<unknown>,
sessionId: string,
options: SendInterruptOptions = {},
): Promise<OverlayInterruptResponse> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const req: OverlayInterruptRequest = { kind: OVERLAY_MSG_INTERRUPT, sessionId };
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<OverlayInterruptResponse>((resolve) => {
timer = setTimeout(() => resolve({ ok: false }), timeoutMs);
});
const send = (async () => {
try {
const reply = (await sendMessage(req)) as OverlayInterruptResponse | undefined;
return reply?.ok === true ? { ok: true } : { ok: false };
} catch {
return { ok: false };
}
})();
const result = await Promise.race([send, timeout]);
if (timer !== null) clearTimeout(timer);
return result;
}
+23
View File
@@ -0,0 +1,23 @@
import type { SnapshotInfo } from "./connection-controller";
/**
* Wire protocol for `chrome.runtime.connect({ name: "popup" })`:
* - Background pushes `{ kind: "snapshot", data: SnapshotInfo }`.
* - Popup sends `{ kind: "set_label" }`, `{ kind: "set_connection_enabled" }`, etc.
*
* NOTE(review M4/M5 C2): the `set_port` variant is defined as a
* placeholder so the future custom-port UI does not have to re-design
* the bridge, but the popup does NOT render a control that emits it
* yet and the background does NOT route it. The wiring requires
* ConnectionController to dispose its current Transport and persist
* the port in chrome.storage; tracked as a follow-up.
*/
export const POPUP_PORT_NAME = "popup";
export type PopupOutbound =
| { kind: "set_label"; value: string }
| { kind: "set_port"; value: number }
| { kind: "set_connection_enabled"; value: boolean };
export type PopupInbound = { kind: "snapshot"; data: SnapshotInfo };
+68
View File
@@ -0,0 +1,68 @@
// Protocol-version parsing and comparison used by M10.4 version-skew detection.
//
// Wire format is `MAJOR.MINOR` (see `bsk-protocol::system` in Rust), not full
// app semver — we deliberately avoid a third-party semver package.
/**
* Compare two protocol-version strings on `(major, minor)`. Returns
* `null` when either side is unparseable — mirrors
* `bsk_protocol::system::compare_protocol`.
*/
export function compareProtocol(a: string, b: string): number | null {
const pa = parseProtocolParts(a);
const pb = parseProtocolParts(b);
if (!pa || !pb) return null;
if (pa.major !== pb.major) return pa.major - pb.major;
return pa.minor - pb.minor;
}
function parseProtocolParts(value: string): { major: number; minor: number } | null {
const major = parseProtocolMajor(value);
if (major === null) return null;
const rest = value.split(".")[1];
if (rest === undefined || rest === "") {
return { major, minor: 0 };
}
if (!/^\d+$/.test(rest)) return null;
const minor = Number(rest);
if (!Number.isSafeInteger(minor) || minor < 0) return null;
return { major, minor };
}
/**
* Extract the leading numeric major component from a protocol-version
* string. The wire format is `MAJOR.MINOR` rather than full semver
* (see `bsk-protocol::system::parse_major` in
* `crates/bsk-protocol/src/system.rs`). Returns `null` when the leading
* field is missing or non-numeric — callers treat that as a hard
* reject, mirroring the daemon's `evaluate_handshake_compat`.
*
* Behaviour matches the daemon's `parse_major` exactly (review round 3
* I2, tightened in round 4 I1, capped at `MAX_SAFE_INTEGER` on the
* daemon side in round 5 I1):
*
* * Take the substring before the first '.'.
* * The substring must be a non-empty run of ASCII digits (`/^\d+$/`).
* Anything else — signs, whitespace, scientific notation, decimal
* points, suffixes such as `1x` / `1e3` — fails. Note that
* `u64::from_str("+1")` would otherwise return `Ok(1)` (the
* std-lib parser peels off a leading `+`); the daemon's
* `parse_major` enforces the same ASCII-digit guard *before*
* delegating to `u64::from_str` so an input like `"+1.0"` is
* rejected by both sides.
* * Convert via `Number`. Reject values outside the JS safe-integer
* range conservatively — parsing a u64 that big and then comparing
* equality of "major-only" protocols here would lose precision.
* The daemon caps its own `parse_major` at the same value
* (`MAX_PROTOCOL_MAJOR = 2^53 - 1`) so the entire
* `MAX_SAFE_INTEGER < n <= u64::MAX` band is rejected on both
* peers — i.e. "daemon accepts ⇒ extension accepts" and
* "daemon rejects ⇒ extension rejects" both hold byte-for-byte.
*/
export function parseProtocolMajor(value: string): number | null {
const head = value.split(".")[0];
if (!/^\d+$/.test(head)) return null;
const n = Number(head);
if (!Number.isSafeInteger(n) || n < 0) return null;
return n;
}
@@ -0,0 +1,95 @@
/**
* Boolean flag that mirrors "is at least one session live right now?"
* into `chrome.storage.session`. Read by the all-urls content script
* as a cheap pre-flight before issuing the `who_am_i` runtime message
* (review M4/M5 I3): when no session is live we never wake the SW on
* routine page loads.
*
* `chrome.storage.session` defaults to TRUSTED_CONTEXTS only, so we
* explicitly opt the area into TRUSTED_AND_UNTRUSTED_CONTEXTS the
* first time we touch it. The stored value is a single boolean and
* carries no user data.
*/
import type { SessionManager } from "@/session-manager/manager";
export const SESSIONS_LIVE_FLAG_KEY = "bh.sessions_live";
/** Subset of `chrome.storage.session` we use; lets tests inject a fake. */
export interface SessionStorageApi {
setAccessLevel?(options: { accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" }): Promise<void>;
set(items: Record<string, unknown>): Promise<void>;
remove(keys: string | string[]): Promise<void>;
}
function defaultSessionStorage(): SessionStorageApi | null {
if (typeof chrome === "undefined") return null;
if (!chrome.storage?.session?.set || !chrome.storage?.session?.remove) return null;
return {
setAccessLevel: chrome.storage.session.setAccessLevel?.bind(chrome.storage.session),
set: (items) => chrome.storage.session.set(items),
remove: (keys) => chrome.storage.session.remove(keys),
};
}
export interface SessionsLiveFlagOptions {
manager: SessionManager;
storage?: SessionStorageApi;
/** Override the storage key (tests). */
flagKey?: string;
}
export interface SessionsLiveFlagHandle {
/** Push current state to storage; useful right after attaching. */
refresh(): Promise<void>;
/** Call when SessionManager changes (start/stop/stopAll). */
syncFromManager(): Promise<void>;
}
/**
* Bind a SessionManager to a `chrome.storage.session` boolean so the
* content-script overlay can short-circuit before sending a runtime
* message. SessionManager does not currently emit change events so the
* caller is expected to invoke `syncFromManager()` after every
* start/stop. (M6 will move this to an event-driven hook.)
*/
export function attachSessionsLiveFlag(options: SessionsLiveFlagOptions): SessionsLiveFlagHandle {
const storage = options.storage ?? defaultSessionStorage();
const key = options.flagKey ?? SESSIONS_LIVE_FLAG_KEY;
let lastWritten: boolean | null = null;
const ensureAccessLevel = async () => {
if (!storage?.setAccessLevel) return;
try {
await storage.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" });
} catch (err) {
console.debug("[bh] storage.session.setAccessLevel failed (continuing)", err);
}
};
void ensureAccessLevel();
const write = async (live: boolean) => {
if (!storage) return;
if (lastWritten === live) return;
try {
if (live) {
await storage.set({ [key]: true });
} else {
await storage.remove(key);
}
lastWritten = live;
} catch (err) {
console.debug("[bh] sessions-live flag write failed", err);
}
};
const syncFromManager = async () => {
const live = options.manager.list().length > 0;
await write(live);
};
return {
refresh: syncFromManager,
syncFromManager,
};
}
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AGENT_WINDOW_HOME, chromeAgentWindowApi } from "../agent-window";
describe("chromeAgentWindowApi.ensureActiveTab", () => {
const query = vi.fn();
const update = vi.fn();
const create = vi.fn();
beforeEach(() => {
vi.stubGlobal("chrome", {
tabs: { query, update, create },
});
query.mockReset();
update.mockReset();
create.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("activates an existing tab when the window already has one", async () => {
query.mockResolvedValue([{ id: 7, active: false }]);
update.mockResolvedValue({});
await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME);
expect(query).toHaveBeenCalledWith({ windowId: 100 });
expect(update).toHaveBeenCalledWith(7, { active: true });
expect(create).not.toHaveBeenCalled();
});
it("creates about:blank when the Agent Window has no tabs", async () => {
query.mockResolvedValue([]);
create.mockResolvedValue({ id: 8 });
await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME);
expect(create).toHaveBeenCalledWith({
windowId: 100,
url: AGENT_WINDOW_HOME,
active: true,
});
expect(update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport";
import type { ConnectionState, ProtocolFrame } from "@/transport/types";
import { attachSessionEventHandler, type WindowRemovedListener } from "../event-handler";
import { SessionManager } from "../manager";
function fakeWindowEvents() {
const listeners = new Set<(windowId: number) => void>();
const api: WindowRemovedListener = {
addListener: (cb) => listeners.add(cb),
removeListener: (cb) => listeners.delete(cb),
};
return {
api,
emit(id: number) {
for (const l of listeners) l(id);
},
listenerCount: () => listeners.size,
};
}
function fakeTransport(): Transport & { sent: ProtocolFrame[] } {
const sent: ProtocolFrame[] = [];
const t: Transport & { sent: ProtocolFrame[] } = {
state: "connected" as ConnectionState,
sent,
connect: () => Promise.resolve(),
disconnect: () => Promise.resolve(),
send: (msg) => sent.push(msg),
onMessage: (_h: FrameHandler) => ({ dispose: () => {} }),
onConnectionStateChange: (_h: ConnectionStateHandler) => ({ dispose: () => {} }),
};
return t;
}
describe("attachSessionEventHandler", () => {
it("drops the local session and emits session.window_closed when the agent window closes", async () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
await manager.start("aa11");
const transport = fakeTransport();
const events = fakeWindowEvents();
const cdp = { detachSession: vi.fn(async () => {}) };
attachSessionEventHandler({
manager,
transport,
windowEvents: events.api,
cdp,
});
expect(events.listenerCount()).toBe(1);
events.emit(4242);
await vi.waitUntil(() => transport.sent.length > 0);
expect(cdp.detachSession).toHaveBeenCalledWith("aa11");
expect(manager.has("aa11")).toBe(false);
expect(transport.sent).toEqual([
{
event: "session.window_closed",
payload: { session_id: "aa11", reason: "user_closed_window" },
},
]);
});
it("reports borrowed tabs as return failures when the Agent Window was already closed", async () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
const ctx = await manager.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 0 });
const transport = fakeTransport();
const events = fakeWindowEvents();
attachSessionEventHandler({
manager,
transport,
windowEvents: events.api,
});
events.emit(4242);
for (let i = 0; i < 4; i += 1) await Promise.resolve();
expect(transport.sent).toEqual([
{
event: "session.window_closed",
payload: {
session_id: "aa11",
reason: "user_closed_window",
return_failures: [
{
tab_id: 7,
code: "cdp_failed",
message: "Agent Window was closed before borrowed tab could be returned",
},
],
},
},
]);
});
it("ignores non-agent windows", async () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const transport = fakeTransport();
const events = fakeWindowEvents();
attachSessionEventHandler({ manager, transport, windowEvents: events.api });
events.emit(9999);
await Promise.resolve();
expect(transport.sent).toEqual([]);
});
it("dispose() removes the listener", () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const transport = fakeTransport();
const events = fakeWindowEvents();
const handle = attachSessionEventHandler({
manager,
transport,
windowEvents: events.api,
});
expect(events.listenerCount()).toBe(1);
handle.dispose();
expect(events.listenerCount()).toBe(0);
});
});
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentWindowApi } from "../agent-window";
import { SessionManager } from "../manager";
function fakeAgentWindow(): AgentWindowApi & {
createMock: ReturnType<typeof vi.fn>;
removeMock: ReturnType<typeof vi.fn>;
ensureActiveTabMock: ReturnType<typeof vi.fn>;
} {
let nextId = 100;
const createMock = vi.fn(async (_url: string) => {
const id = nextId++;
return id;
});
const removeMock = vi.fn(async (_id: number) => {});
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => {});
return {
create: createMock,
remove: removeMock,
ensureActiveTab: ensureActiveTabMock,
createMock,
removeMock,
ensureActiveTabMock,
};
}
describe("SessionManager", () => {
it("creates an Agent Window when starting a session", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw, now: () => 1700000000000 });
const ctx = await sm.start("aa11");
expect(aw.createMock).toHaveBeenCalledOnce();
expect(aw.createMock).toHaveBeenCalledWith("about:blank");
expect(aw.ensureActiveTabMock).toHaveBeenCalledOnce();
expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank");
expect(ctx.sessionId).toBe("aa11");
expect(ctx.agentWindowId).toBe(100);
expect(ctx.createdAtMs).toBe(1700000000000);
expect(ctx.refStore.isEmpty()).toBe(true);
expect(ctx.borrowedTabs.size).toBe(0);
});
it("indexes the session by sessionId and agent window id", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
expect(sm.has("aa11")).toBe(true);
expect(sm.get("aa11")).toBe(ctx);
expect(sm.findByWindowId(ctx.agentWindowId)).toBe(ctx);
expect(sm.findByWindowId(99999)).toBeNull();
expect(sm.list().length).toBe(1);
});
it("rejects starting the same session twice", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
await sm.start("aa11");
await expect(sm.start("aa11")).rejects.toThrow(/already exists/);
});
it("stop() closes the Agent Window and forgets the session", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
const removed = await sm.stop("aa11");
expect(removed).toBe(ctx);
expect(aw.removeMock).toHaveBeenCalledWith(ctx.agentWindowId);
expect(sm.has("aa11")).toBe(false);
expect(sm.findByWindowId(ctx.agentWindowId)).toBeNull();
});
it("stop({ dropOnly: true }) skips the chrome.windows.remove call", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
await sm.start("aa11");
await sm.stop("aa11", { dropOnly: true });
expect(aw.removeMock).not.toHaveBeenCalled();
expect(sm.has("aa11")).toBe(false);
});
it("stopAll() drops every session and returns their ids", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
await sm.start("aa11");
await sm.start("bb22");
const dropped = await sm.stopAll();
expect(dropped.sort()).toEqual(["aa11", "bb22"]);
expect(sm.list()).toEqual([]);
});
describe("findBorrowingSession", () => {
it("returns null when no session has borrowed the tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
await sm.start("aa11");
expect(sm.findBorrowingSession(42, "aa11")).toBeNull();
expect(sm.findBorrowingSession(42, null)).toBeNull();
});
it("ignores borrows held by the calling session itself", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 3 });
expect(sm.findBorrowingSession(42, "aa11")).toBeNull();
});
it("reports the borrowing session id when a different session holds the tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const a = await sm.start("aa11");
await sm.start("bb22");
a.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 3 });
expect(sm.findBorrowingSession(42, "bb22")).toBe("aa11");
// currentSessionId=null asks "is anyone borrowing this tab?"
expect(sm.findBorrowingSession(42, null)).toBe("aa11");
});
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { RefStore } from "../ref-store";
describe("RefStore", () => {
it("stores and resolves ref → backendNodeId", () => {
const s = new RefStore();
s.set("e1", 42, { tabId: 7 });
expect(s.resolve("@e1")).toBe(42);
expect(s.resolve("e1")).toBe(42);
expect(s.resolve("@e1", { tabId: 7 })).toBe(42);
expect(s.resolve("@e1", { tabId: 8 })).toBeNull();
expect(s.resolveEntry("@e1")).toMatchObject({
backendNodeId: 42,
tabId: 7,
generation: 0,
});
expect(s.size()).toBe(1);
expect(s.isEmpty()).toBe(false);
});
it("replace() clears prior entries", () => {
const s = new RefStore();
s.set("@e1", 1);
s.set("@e2", 2);
s.replace([
["e10", { backendNodeId: 10, tabId: 1 }],
["@e11", { backendNodeId: 11, tabId: 1 }],
]);
expect(s.resolve("@e1")).toBeNull();
expect(s.resolve("@e10")).toBe(10);
expect(s.resolve("@e10", { tabId: 1 })).toBe(10);
expect(s.resolve("@e10", { tabId: 2 })).toBeNull();
expect(s.resolve("@e11")).toBe(11);
expect(s.size()).toBe(2);
});
});
@@ -0,0 +1,55 @@
/**
* Wrapper around `chrome.windows.*` for creating / closing the
* dedicated Agent Window each session owns.
*
* Kept behind an interface so the SessionManager can be unit-tested
* with a fake chrome.windows implementation (see
* `__tests__/manager.test.ts`).
*/
export interface AgentWindowApi {
create(url: string): Promise<number>;
remove(windowId: number): Promise<void>;
/**
* Guarantee the Agent Window has an active, CDP-navigable tab.
* `chrome://` pages (including the New Tab page) reject `Page.navigate`,
* so sessions bootstrap with `about:blank` instead.
*/
ensureActiveTab(windowId: number, url: string): Promise<void>;
}
/** Initial tab URL for every new session's Agent Window. */
export const AGENT_WINDOW_HOME = "about:blank";
export const chromeAgentWindowApi: AgentWindowApi = {
async create(url: string): Promise<number> {
const win = await chrome.windows.create({
type: "normal",
focused: true,
url,
});
if (typeof win?.id !== "number") {
throw new Error("[bh] chrome.windows.create returned no window id");
}
return win.id;
},
async remove(windowId: number): Promise<void> {
try {
await chrome.windows.remove(windowId);
} catch (err) {
// Window may have been closed by the user already; ignore.
console.debug("[bh] chrome.windows.remove failed", err);
}
},
async ensureActiveTab(windowId: number, url: string): Promise<void> {
const tabs = await chrome.tabs.query({ windowId });
const first = tabs.find((t) => typeof t.id === "number");
if (first?.id) {
if (!first.active) {
await chrome.tabs.update(first.id, { active: true });
}
return;
}
await chrome.tabs.create({ windowId, url, active: true });
},
};
@@ -0,0 +1,97 @@
import type { Transport } from "@/transport/transport";
import type { EventFrame } from "@/transport/types";
import type { SessionManager } from "./manager";
/**
* Listener interface that mirrors `chrome.windows.onRemoved` so vitest
* can drive the handler without a real Chrome runtime.
*/
export interface WindowRemovedListener {
addListener(cb: (windowId: number) => void): void;
removeListener(cb: (windowId: number) => void): void;
}
export interface SessionEventHandlerOptions {
manager: SessionManager;
transport: Transport;
windowEvents?: WindowRemovedListener;
cdp?: {
detachSession(sessionId: string): Promise<void>;
};
/**
* Invoked after a user-closed Agent Window has been removed from the
* SessionManager. Lets the caller refresh side caches such as the
* `chrome.storage.session` "sessions live" flag (review M4/M5 I3).
*/
onSessionsChanged?: () => void;
}
function chromeWindowEvents(): WindowRemovedListener {
return {
addListener: (cb) => chrome.windows.onRemoved.addListener(cb),
removeListener: (cb) => chrome.windows.onRemoved.removeListener(cb),
};
}
/**
* Watch for the user closing an Agent Window. When that happens we:
* 1. Drop the local SessionContext (without trying to close the window
* again — it's already gone).
* 2. Emit a `session.window_closed` event to the daemon so it can
* remove the session from its registry too.
*
* Returns a disposer that detaches the listener (used in tests).
*/
export function attachSessionEventHandler(options: SessionEventHandlerOptions): {
dispose: () => void;
} {
const { manager, transport, onSessionsChanged } = options;
const events = options.windowEvents ?? chromeWindowEvents();
const onRemoved = (windowId: number): void => {
const ctx = manager.findByWindowId(windowId);
if (!ctx) return;
const returnFailures = Array.from(ctx.borrowedTabs.keys()).map((tabId) => ({
tab_id: tabId,
code: "cdp_failed",
message: "Agent Window was closed before borrowed tab could be returned",
}));
if (returnFailures.length > 0) {
console.warn(
`[bh] Agent Window ${windowId} closed with borrowed tabs that could not be returned`,
returnFailures,
);
}
const detach = options.cdp
? options.cdp.detachSession(ctx.sessionId).catch((err) => {
console.debug("[bh] session-event cdp detach failed", err);
})
: Promise.resolve();
void detach
.then(() => manager.stop(ctx.sessionId, { dropOnly: true }))
.then(() => {
onSessionsChanged?.();
const event: EventFrame = {
event: "session.window_closed",
payload: {
session_id: ctx.sessionId,
reason: "user_closed_window",
...(returnFailures.length > 0 ? { return_failures: returnFailures } : {}),
},
};
try {
transport.send(event);
} catch (err) {
console.warn("[bh] could not push session.window_closed event", err);
}
})
.catch((err) => {
console.warn("[bh] session-event handler failed", err);
});
};
events.addListener(onRemoved);
return {
dispose: () => events.removeListener(onRemoved),
};
}
@@ -0,0 +1,181 @@
import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window";
import { RefStore } from "./ref-store";
export interface SessionContext {
sessionId: string;
agentWindowId: number;
refStore: RefStore;
borrowedTabs: Map<number, BorrowedTab>;
createdAtMs: number;
}
export interface BorrowedTab {
tabId: number;
originalWindowId: number;
originalIndex: number;
}
export interface BorrowReservation {
release(): void;
commit(entry: BorrowedTab): void;
}
export interface SessionManagerOptions {
agentWindow?: AgentWindowApi;
now?: () => number;
}
/**
* Owner of all live agent sessions inside the extension.
*
* The daemon side has its own `SessionRegistry`; this class is the
* extension-side mirror that holds the per-session Agent Window id,
* ref-store, and borrowed-tab table. Tool implementations (M6+) read
* from here to map a `session_id` back to "which Chrome window /
* which ref / which borrowed tab".
*
* Designed to be unit-testable: chrome.* is injected via `AgentWindowApi`
* so vitest never touches a real `chrome.windows` object.
*/
export class SessionManager {
private readonly sessions = new Map<string, SessionContext>();
private readonly windowIndex = new Map<number, string>();
private readonly borrowReservations = new Map<number, string>();
private readonly agentWindow: AgentWindowApi;
private readonly now: () => number;
constructor(options: SessionManagerOptions = {}) {
this.agentWindow = options.agentWindow ?? chromeAgentWindowApi;
this.now = options.now ?? Date.now;
}
has(sessionId: string): boolean {
return this.sessions.has(sessionId);
}
get(sessionId: string): SessionContext | null {
return this.sessions.get(sessionId) ?? null;
}
findByWindowId(windowId: number): SessionContext | null {
const id = this.windowIndex.get(windowId);
return id ? (this.sessions.get(id) ?? null) : null;
}
list(): SessionContext[] {
return Array.from(this.sessions.values());
}
/**
* Look up whether `tabId` is currently borrowed by some *other*
* session than the one calling. Used by M8 `tab_borrow` to refuse
* a second borrow on the same Chrome tab, and by `tab_close` to
* tell apart "user tab" from "another session's borrowed tab"
* (which we must not allow direct access to).
*
* Returns the borrowing session id when applicable, otherwise null.
*/
findBorrowingSession(tabId: number, currentSessionId: string | null): string | null {
for (const ctx of this.sessions.values()) {
if (ctx.sessionId === currentSessionId) continue;
if (ctx.borrowedTabs.has(tabId)) return ctx.sessionId;
}
const reservedBy = this.borrowReservations.get(tabId);
if (reservedBy && reservedBy !== currentSessionId) return reservedBy;
return null;
}
/**
* Reserve a tab for `tool.tab_borrow` before the handler performs any
* awaited Chrome work. This closes the cross-session race between the
* "is anyone borrowing this tab?" check and the eventual borrowedTabs
* write after `chrome.tabs.move`.
*/
tryReserveBorrow(tabId: number, sessionId: string): BorrowReservation | { borrowedBy: string } {
const borrowedBy = this.findBorrowingSession(tabId, sessionId);
if (borrowedBy) return { borrowedBy };
this.borrowReservations.set(tabId, sessionId);
let closed = false;
const release = () => {
if (closed) return;
closed = true;
if (this.borrowReservations.get(tabId) === sessionId) {
this.borrowReservations.delete(tabId);
}
};
return {
release,
commit: (entry) => {
if (closed) return;
const ctx = this.sessions.get(sessionId);
if (!ctx) {
release();
throw new Error(`session ${sessionId} disappeared during tab_borrow`);
}
if (this.borrowReservations.get(tabId) !== sessionId) {
throw new Error(`tab ${tabId} borrow reservation disappeared before commit`);
}
ctx.borrowedTabs.set(tabId, entry);
release();
},
};
}
/**
* Spin up a fresh session: open a new Agent Window with an
* `about:blank` tab and register the context.
*
* Returns the created window id so callers can echo it back to the
* daemon in the `tool.session_start` reply.
*/
async start(sessionId: string): Promise<SessionContext> {
if (this.sessions.has(sessionId)) {
throw new Error(`[bh] session ${sessionId} already exists`);
}
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
const ctx: SessionContext = {
sessionId,
agentWindowId: windowId,
refStore: new RefStore(),
borrowedTabs: new Map(),
createdAtMs: this.now(),
};
this.sessions.set(sessionId, ctx);
this.windowIndex.set(windowId, sessionId);
return ctx;
}
/**
* Tear down a session: close its Agent Window and drop the context.
*
* `dropOnly = true` skips closing the window — used when the user
* already closed it manually (M5.4 path) so we don't accidentally
* close a window that has been re-purposed.
*/
async stop(
sessionId: string,
options: { dropOnly?: boolean } = {},
): Promise<SessionContext | null> {
const ctx = this.sessions.get(sessionId);
if (!ctx) return null;
if (!options.dropOnly) {
await this.agentWindow.remove(ctx.agentWindowId);
}
this.sessions.delete(sessionId);
this.windowIndex.delete(ctx.agentWindowId);
return ctx;
}
/**
* Best-effort cleanup of every live session (emergency brake / SW
* shutdown). Returns the set of `session_id`s that were removed.
*/
async stopAll(options: { dropOnly?: boolean } = {}): Promise<string[]> {
const ids = Array.from(this.sessions.keys());
for (const id of ids) {
await this.stop(id, options);
}
return ids;
}
}
@@ -0,0 +1,91 @@
/**
* Per-session map from `@e<N>` snapshot refs to CDP `backendNodeId`.
*
* Each fresh `tool.snapshot` resets the store: M6 will call
* `replace(...)` with the new ref → backendNodeId pairs. Tools like
* `tool.click` consume the store via `resolve("@e1")`.
*
* Refs are session-scoped (§7): looking up a ref in the wrong session
* returns `null`, never silently leaks. Storing values in different
* sessions is fine; they live in independent `RefStore` instances
* inside the `SessionContext`.
*/
export type BackendNodeId = number;
export interface RefEntry {
backendNodeId: BackendNodeId;
tabId: number | null;
generation: number;
}
type RefInput = BackendNodeId | { backendNodeId: BackendNodeId; tabId: number };
export class RefStore {
private readonly map = new Map<string, RefEntry>();
private generation = 0;
size(): number {
return this.map.size;
}
isEmpty(): boolean {
return this.map.size === 0;
}
resolve(ref: string, opts: { tabId?: number } = {}): BackendNodeId | null {
const entry = this.map.get(normaliseRef(ref));
if (!entry) return null;
if (opts.tabId !== undefined && entry.tabId !== opts.tabId) return null;
return entry.backendNodeId;
}
resolveEntry(ref: string): RefEntry | null {
return this.map.get(normaliseRef(ref)) ?? null;
}
/**
* Replace the entire store with a new ref → backendNodeId mapping.
* Used after every fresh `tool.snapshot`.
*/
replace(entries: Iterable<readonly [string, RefInput]>): void {
this.map.clear();
this.generation += 1;
for (const [ref, input] of entries) this.map.set(normaliseRef(ref), this.entry(input));
}
set(ref: string, id: BackendNodeId, opts: { tabId?: number } = {}): void {
this.map.set(normaliseRef(ref), {
backendNodeId: id,
tabId: opts.tabId ?? null,
generation: this.generation,
});
}
clear(): void {
this.map.clear();
}
entries(): IterableIterator<[string, RefEntry]> {
return this.map.entries();
}
private entry(input: RefInput): RefEntry {
if (typeof input === "number") {
return {
backendNodeId: input,
tabId: null,
generation: this.generation,
};
}
return {
backendNodeId: input.backendNodeId,
tabId: input.tabId,
generation: this.generation,
};
}
}
/** Canonical RefStore key: `@e3` and `e3` both become `e3`. */
export function normaliseRef(ref: string): string {
return ref.startsWith("@") ? ref.slice(1) : ref;
}
@@ -0,0 +1,588 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetPendingBorrowDecisionsForTest,
__resetPendingBorrowNotificationsForTest,
attachBorrowNotificationButtonHandler,
attachBorrowNotificationClickHandler,
BACKGROUND_TIMEOUT_MS,
BORROW_NOTIFICATION_PREFIX,
type ChromeNotificationsForBorrow,
type ChromeTabsForBorrow,
type ChromeWindowsForBorrow,
CONFIRMATION_TIMEOUT_MS,
isInjectableContentScriptUrl,
PROGRESS_TRANSITION_MS,
requestBorrowConfirmation,
} from "../borrow-confirmation";
function makeTabsMock(): ChromeTabsForBorrow & {
get: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
} {
return {
get: vi.fn(),
sendMessage: vi.fn(),
} as never;
}
function makeWindowsMock(): ChromeWindowsForBorrow & {
getLastFocused: ReturnType<typeof vi.fn>;
getAll: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
} {
return {
getLastFocused: vi.fn(),
getAll: vi.fn(async () => []),
update: vi.fn(async () => undefined as unknown as chrome.windows.Window),
} as never;
}
function makeNotificationsMock(): ChromeNotificationsForBorrow & {
create: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
} {
return {
create: vi.fn(async (id: string) => id),
clear: vi.fn(async () => true),
} as never;
}
/** Helper that builds a `chrome.windows.Window`-shaped object with one active tab. */
function userWindowWithActiveTab(opts: {
windowId: number;
tabId: number;
url?: string;
title?: string;
}): chrome.windows.Window {
return {
id: opts.windowId,
focused: true,
incognito: false,
alwaysOnTop: false,
type: "normal",
state: "normal",
tabs: [
{
id: opts.tabId,
index: 0,
windowId: opts.windowId,
active: true,
highlighted: true,
pinned: false,
url: opts.url ?? "https://example.com/",
title: opts.title ?? "Example",
} as unknown as chrome.tabs.Tab,
],
} as unknown as chrome.windows.Window;
}
describe("requestBorrowConfirmation", () => {
let tabs: ReturnType<typeof makeTabsMock>;
let windows: ReturnType<typeof makeWindowsMock>;
let notifications: ReturnType<typeof makeNotificationsMock>;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
__resetPendingBorrowNotificationsForTest();
__resetPendingBorrowDecisionsForTest();
tabs = makeTabsMock();
windows = makeWindowsMock();
notifications = makeNotificationsMock();
});
afterEach(() => {
vi.useRealTimers();
});
it("sends confirmation to the visible active tab when borrow target is inactive", async () => {
tabs.get.mockResolvedValueOnce({
id: 42,
windowId: 300,
title: "Hidden Window Tab",
active: true,
});
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 999, tabId: 7, url: "https://news.example/" }),
);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
const allowed = await pending;
expect(allowed).toBe(true);
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
const [messageTabId, message] = tabs.sendMessage.mock.calls[0] as [
number,
{ tabId: number; isActiveTab: boolean; requestId: string; timeoutMs: number },
];
expect(messageTabId).toBe(7);
expect(message.tabId).toBe(42);
expect(message.isActiveTab).toBe(false);
expect(message.timeoutMs).toBe(CONFIRMATION_TIMEOUT_MS);
expect(typeof message.requestId).toBe("string");
expect(message.requestId.length).toBeGreaterThan(0);
});
it("sends confirmation to the borrow target when it is the active tab", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Active Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
expect(await pending).toBe(true);
const [messageTabId, message] = tabs.sendMessage.mock.calls[0] as [
number,
{ isActiveTab: boolean },
];
expect(messageTabId).toBe(42);
expect(message.isActiveTab).toBe(true);
});
it("returns false when the user explicitly denies", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: false });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
expect(await pending).toBe(false);
});
it("skips the Agent Window when it is lastFocusedWindow and falls back to a real user window", async () => {
tabs.get.mockResolvedValueOnce({
id: 42,
windowId: 300,
title: "User Tab Being Borrowed",
});
// Agent Window (id=500) was last focused — it must be ignored.
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 500, tabId: 5001, url: "about:blank" }),
);
// getAll returns both the Agent Window and a real user window.
windows.getAll.mockResolvedValueOnce([
userWindowWithActiveTab({ windowId: 500, tabId: 5001, url: "about:blank" }),
userWindowWithActiveTab({ windowId: 600, tabId: 6001, url: "https://news.example/" }),
]);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
const pending = requestBorrowConfirmation(42, {
deps: {
tabs,
windows,
notifications,
isAgentWindowId: (id) => id === 500,
},
});
await vi.runAllTimersAsync();
expect(await pending).toBe(true);
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
const [messageTabId] = tabs.sendMessage.mock.calls[0] as [number];
// Must NOT pick 5001 (Agent Window's about:blank tab).
expect(messageTabId).toBe(6001);
});
it("skips windows whose active tab is on a non-injectable URL (chrome://, web store, file:)", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Some Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 11, tabId: 111, url: "chrome://settings/" }),
);
windows.getAll.mockResolvedValueOnce([
userWindowWithActiveTab({ windowId: 11, tabId: 111, url: "chrome://settings/" }),
userWindowWithActiveTab({
windowId: 12,
tabId: 222,
url: "https://chromewebstore.google.com/category/extensions",
}),
userWindowWithActiveTab({ windowId: 14, tabId: 444, url: "file:///Users/me/page.html" }),
userWindowWithActiveTab({ windowId: 13, tabId: 333, url: "https://docs.example/" }),
]);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
expect(await pending).toBe(true);
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
const [messageTabId] = tabs.sendMessage.mock.calls[0] as [number];
expect(messageTabId).toBe(333);
});
it("falls through to the next candidate when sendMessage rejects for the first one", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 30, tabId: 300, url: "https://flaky.example/" }),
);
windows.getAll.mockResolvedValueOnce([
userWindowWithActiveTab({ windowId: 30, tabId: 300, url: "https://flaky.example/" }),
userWindowWithActiveTab({ windowId: 31, tabId: 301, url: "https://ok.example/" }),
]);
tabs.sendMessage
.mockRejectedValueOnce(new Error("Could not establish connection"))
.mockResolvedValueOnce({ type: "borrow-response", allowed: false });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
// Allowed=false from the second candidate must propagate as a real deny:
// sendMessage failure on candidate #1 is NOT a silent allow.
expect(await pending).toBe(false);
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
expect(tabs.sendMessage.mock.calls[0]?.[0]).toBe(300);
expect(tabs.sendMessage.mock.calls[1]?.[0]).toBe(301);
});
it("does NOT fail-open immediately when every candidate's sendMessage fails — waits for notification button or timeout", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 40, tabId: 400, url: "https://a.example/" }),
);
windows.getAll.mockResolvedValueOnce([
userWindowWithActiveTab({ windowId: 40, tabId: 400, url: "https://a.example/" }),
userWindowWithActiveTab({ windowId: 41, tabId: 401, url: "https://b.example/" }),
]);
tabs.sendMessage
.mockRejectedValueOnce(new Error("Could not establish connection #1"))
.mockRejectedValueOnce(new Error("Could not establish connection #2"));
let resolved = false;
let resolvedValue: boolean | undefined;
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
}).then((v) => {
resolved = true;
resolvedValue = v;
return v;
});
// Drain microtasks for sendMessage rejections + notification create.
await vi.advanceTimersByTimeAsync(0);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
// CRITICAL: previously this would already be settled(true). Now we
// require an explicit user choice (notification button) or timeout.
expect(resolved).toBe(false);
// Allow the BACKGROUND_TIMEOUT_MS fail-open to fire so the test resolves.
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
expect(await pending).toBe(true);
expect(resolvedValue).toBe(true);
const exhaustedWarn = warnSpy.mock.calls.find(
(call) =>
typeof call[0] === "string" &&
call[0].includes("every candidate user window failed sendMessage"),
);
expect(exhaustedWarn).toBeDefined();
warnSpy.mockRestore();
});
it("resolves true when the user clicks the notification Allow button after all candidates fail", async () => {
const buttonListeners: Array<(notificationId: string, buttonIndex: number) => void> = [];
const onButtonClicked = {
addListener: (cb: (notificationId: string, buttonIndex: number) => void) =>
buttonListeners.push(cb),
removeListener: (cb: (notificationId: string, buttonIndex: number) => void) => {
const i = buttonListeners.indexOf(cb);
if (i >= 0) buttonListeners.splice(i, 1);
},
hasListener: () => false,
hasListeners: () => buttonListeners.length > 0,
} as unknown as chrome.events.Event<(notificationId: string, buttonIndex: number) => void>;
attachBorrowNotificationButtonHandler({ onButtonClicked });
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 50, tabId: 500, url: "https://stale.example/" }),
);
tabs.sendMessage.mockRejectedValueOnce(new Error("Could not establish connection"));
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.waitFor(() => expect(notifications.create).toHaveBeenCalledTimes(1));
const [notificationId] = notifications.create.mock.calls[0] as [string];
// User clicks the Allow button (index 0).
for (const l of buttonListeners) l(notificationId, 0);
await vi.runAllTimersAsync();
expect(await pending).toBe(true);
});
it("resolves false when the user clicks the notification Deny button — no fail-open", async () => {
const buttonListeners: Array<(notificationId: string, buttonIndex: number) => void> = [];
const onButtonClicked = {
addListener: (cb: (notificationId: string, buttonIndex: number) => void) =>
buttonListeners.push(cb),
removeListener: (cb: (notificationId: string, buttonIndex: number) => void) => {
const i = buttonListeners.indexOf(cb);
if (i >= 0) buttonListeners.splice(i, 1);
},
hasListener: () => false,
hasListeners: () => buttonListeners.length > 0,
} as unknown as chrome.events.Event<(notificationId: string, buttonIndex: number) => void>;
attachBorrowNotificationButtonHandler({ onButtonClicked });
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 60, tabId: 600, url: "https://a.example/" }),
);
windows.getAll.mockResolvedValueOnce([
userWindowWithActiveTab({ windowId: 60, tabId: 600, url: "https://a.example/" }),
userWindowWithActiveTab({ windowId: 61, tabId: 601, url: "https://b.example/" }),
]);
tabs.sendMessage
.mockRejectedValueOnce(new Error("Could not establish connection #1"))
.mockRejectedValueOnce(new Error("Could not establish connection #2"));
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.waitFor(() => expect(notifications.create).toHaveBeenCalledTimes(1));
const [notificationId] = notifications.create.mock.calls[0] as [string];
// User clicks the Deny button (index 1).
for (const l of buttonListeners) l(notificationId, 1);
await vi.runAllTimersAsync();
// CRITICAL: the user said "Deny" via the notification button. This
// must NOT be silently flipped to allow.
expect(await pending).toBe(false);
});
it("fail-opens when no injectable user window exists at all", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Stranded Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 500, tabId: 5001, url: "about:blank" }),
);
windows.getAll.mockResolvedValueOnce([
userWindowWithActiveTab({ windowId: 500, tabId: 5001, url: "about:blank" }),
]);
const pending = requestBorrowConfirmation(42, {
deps: {
tabs,
windows,
notifications,
isAgentWindowId: (id) => id === 500,
},
});
await vi.runAllTimersAsync();
expect(await pending).toBe(true);
expect(tabs.sendMessage).not.toHaveBeenCalled();
// No notification either — there's no actionable target window.
expect(notifications.create).not.toHaveBeenCalled();
});
it("pushes a chrome notification carrying the request id and clears it on settle", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Demo" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
await pending;
expect(notifications.create).toHaveBeenCalledTimes(1);
const [notificationId, options] = notifications.create.mock.calls[0] as [
string,
chrome.notifications.NotificationOptions<true>,
];
expect(notificationId.startsWith(BORROW_NOTIFICATION_PREFIX)).toBe(true);
expect(options.type).toBe("basic");
expect(options.title).toMatch(/borrow/i);
expect(notifications.clear).toHaveBeenCalledWith(notificationId);
});
it("notification click handler focuses the target user window", async () => {
const listeners: Array<(id: string) => void> = [];
const onClicked = {
addListener: (cb: (id: string) => void) => listeners.push(cb),
removeListener: (cb: (id: string) => void) => {
const i = listeners.indexOf(cb);
if (i >= 0) listeners.splice(i, 1);
},
hasListener: () => false,
hasListeners: () => listeners.length > 0,
} as unknown as chrome.events.Event<(notificationId: string) => void>;
attachBorrowNotificationClickHandler({
onClicked,
windows,
notifications,
});
tabs.get.mockResolvedValueOnce({ id: 42, title: "Borrow Target" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 77, tabId: 4242, url: "https://docs.example/" }),
);
// Keep sendMessage pending so the request stays live while we click.
let resolveSend: (v: { type: string; allowed: boolean }) => void = () => undefined;
tabs.sendMessage.mockImplementationOnce(
() => new Promise((res) => (resolveSend = res as never)),
);
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.waitFor(() => expect(notifications.create).toHaveBeenCalledTimes(1));
const [notificationId] = notifications.create.mock.calls[0] as [string];
// Simulate user clicking the OS notification.
for (const l of listeners) l(notificationId);
await vi.runAllTimersAsync();
expect(windows.update).toHaveBeenCalledWith(77, { focused: true });
// Finish the borrow so the test resolves cleanly.
resolveSend({ type: "borrow-response", allowed: true });
await vi.runAllTimersAsync();
await pending;
});
it("fail-opens after background timeout when content script never responds", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
);
tabs.sendMessage.mockImplementationOnce(() => new Promise(() => undefined));
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
expect(await pending).toBe(true);
});
it("dismisses pending overlay and fail-opens when aborted", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Borrow Target" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 11, tabId: 7, url: "https://app.example/" }),
);
tabs.sendMessage.mockImplementationOnce(() => new Promise(() => undefined));
tabs.sendMessage.mockResolvedValueOnce(undefined);
const controller = new AbortController();
const pending = requestBorrowConfirmation(42, {
signal: controller.signal,
deps: { tabs, windows, notifications },
});
await vi.waitFor(() => expect(tabs.sendMessage).toHaveBeenCalledTimes(1));
controller.abort();
await vi.runAllTimersAsync();
expect(await pending).toBe(true);
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
const requestMessage = tabs.sendMessage.mock.calls[0][1] as { requestId: string };
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
type: string;
requestId: string;
};
expect(cancelMessage.type).toBe("borrow-cancel");
expect(cancelMessage.requestId).toBe(requestMessage.requestId);
});
it("does not fail-open when tabs.get rejects — still surfaces confirmation to a candidate", async () => {
// Regression: a transient tabs.get failure (SW teardown / page hiccup)
// used to return true immediately, approving the borrow with no
// confirmation UI. The tab was already fetched by the borrow pipeline
// moments earlier, so the failure is transient — we must fall back to a
// generic title and still ask the user.
tabs.get.mockRejectedValueOnce(new Error("tab info unavailable"));
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 50, tabId: 7, url: "https://app.example/" }),
);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: false });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
const allowed = await pending;
// The user's explicit deny must be honoured — NOT auto-allowed by the
// earlier tabs.get rejection.
expect(allowed).toBe(false);
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
expect(notifications.create).toHaveBeenCalledTimes(1);
});
it("still allows an explicit overlay allow after a tabs.get rejection", async () => {
tabs.get.mockRejectedValueOnce(new Error("tab info unavailable"));
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 50, tabId: 7, url: "https://app.example/" }),
);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
const allowed = await pending;
expect(allowed).toBe(true);
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
});
});
describe("isInjectableContentScriptUrl", () => {
it("accepts http and https only", () => {
expect(isInjectableContentScriptUrl("https://example.com/")).toBe(true);
expect(isInjectableContentScriptUrl("http://localhost:5173/")).toBe(true);
});
it("rejects file: and ftp: (require user-toggle / no longer reliably inject CS)", () => {
expect(isInjectableContentScriptUrl("file:///Users/me/page.html")).toBe(false);
expect(isInjectableContentScriptUrl("ftp://archive.example/file")).toBe(false);
});
it("rejects restricted schemes", () => {
expect(isInjectableContentScriptUrl(undefined)).toBe(false);
expect(isInjectableContentScriptUrl("")).toBe(false);
expect(isInjectableContentScriptUrl("about:blank")).toBe(false);
expect(isInjectableContentScriptUrl("chrome://settings/")).toBe(false);
expect(isInjectableContentScriptUrl("chrome-extension://abc/popup.html")).toBe(false);
expect(isInjectableContentScriptUrl("chrome-untrusted://terminal/")).toBe(false);
expect(isInjectableContentScriptUrl("edge://flags/")).toBe(false);
expect(isInjectableContentScriptUrl("devtools://devtools/bundled/inspector.html")).toBe(false);
expect(isInjectableContentScriptUrl("view-source:https://example.com/")).toBe(false);
expect(isInjectableContentScriptUrl("data:text/html,foo")).toBe(false);
expect(isInjectableContentScriptUrl("blob:https://example.com/abc")).toBe(false);
});
it("rejects the Chrome Web Store (both domains)", () => {
expect(
isInjectableContentScriptUrl("https://chrome.google.com/webstore/category/extensions"),
).toBe(false);
expect(isInjectableContentScriptUrl("https://chromewebstore.google.com/")).toBe(false);
});
});
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import type { ConsoleResult } from "@/transport/types";
import { handleConsole } from "../console";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
function makeDeps(
opts: {
get?: (tabId: number) => Promise<chrome.tabs.Tab>;
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
result?: ConsoleResult;
} = {},
) {
const result =
opts.result ??
({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
} satisfies ConsoleResult);
const ensureConsoleCapture = vi.fn(async () => {});
const consoleEntriesSince = vi.fn(() => result);
const cdp = {
send: vi.fn(),
ensureConsoleCapture,
consoleEntriesSince,
} as unknown as CdpRunner;
const tabsApi = {
get:
opts.get ??
vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query:
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
};
return { cdp, tabsApi, ensureConsoleCapture, consoleEntriesSince };
}
describe("handleConsole", () => {
it("reads the Agent Window active tab with safe defaults", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
result: {
tab_id: 7,
entries: [{ sequence: 3, kind: "console", level: "log", text: "hello", truncated: false }],
next_since: 3,
truncated: false,
},
});
const res = await handleConsole(sm, { session_id: "aa11" }, deps);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.entries[0].text).toBe("hello");
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(7);
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false);
});
it("reads an explicit tab and forwards bounded options", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab),
});
await handleConsole(
sm,
{
session_id: "aa11",
tab_id: 9,
since: 12,
limit: 500,
max_text_chars: 9999,
include_stack: true,
},
deps,
);
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9);
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096, true);
});
it("rejects invalid bounds before touching CDP", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps();
const res = await handleConsole(sm, { session_id: "aa11", limit: 0 }, deps);
expect(res).toMatchObject({ code: "invalid_params", message: /limit/ });
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
expect(deps.consoleEntriesSince).not.toHaveBeenCalled();
});
it("hides other sessions' Agent Window tabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) });
await sm.start("aa11");
await sm.start("bb22");
const deps = makeDeps({
get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab),
});
const res = await handleConsole(sm, { session_id: "aa11", tab_id: 9 }, deps);
expect(res).toMatchObject({ code: "not_found" });
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,410 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport";
import type {
ConnectionState,
ConsoleResult,
ProtocolFrame,
RequestFrame,
} from "@/transport/types";
import { ToolDispatcher } from "../dispatcher";
function fakeTransport() {
const handlers = new Set<FrameHandler>();
const stateHandlers = new Set<ConnectionStateHandler>();
const sent: ProtocolFrame[] = [];
const t: Transport = {
state: "connected" as ConnectionState,
connect: () => Promise.resolve(),
disconnect: () => Promise.resolve(),
send: (msg) => sent.push(msg),
onMessage: (h) => {
handlers.add(h);
return { dispose: () => handlers.delete(h) };
},
onConnectionStateChange: (h) => {
stateHandlers.add(h);
return { dispose: () => stateHandlers.delete(h) };
},
};
return {
transport: t,
sent,
deliver(frame: ProtocolFrame) {
for (const h of handlers) h(frame);
},
};
}
function makeRequest(method: string, params: unknown): RequestFrame {
return { id: "r-1", method, params };
}
describe("ToolDispatcher", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("routes tool.session_start to the SessionManager and replies with the window id", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
await flushMicrotasks();
expect(sessions.has("aa11")).toBe(true);
expect(sent).toHaveLength(1);
expect(sent[0]).toEqual({
id: "r-1",
result: { agent_window_id: 4242 },
});
});
it("routes tool.session_stop and replies with empty result", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
await flushMicrotasks();
expect(sessions.has("aa11")).toBe(false);
expect(sent).toHaveLength(1);
expect(sent[0]).toEqual({ id: "r-1", result: {} });
});
it("routes tool.console through the CDP console buffer", async () => {
vi.stubGlobal("chrome", {
tabs: {
query: vi.fn(async () => [{ id: 7, windowId: 4242, active: true }]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
const cdp = {
send: vi.fn(),
detachSession: vi.fn(async () => {}),
ensureConsoleCapture: vi.fn(async () => {}),
consoleEntriesSince: vi.fn(
() =>
({
tab_id: 7,
entries: [
{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false },
],
next_since: 1,
truncated: false,
}) satisfies ConsoleResult,
),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
deliver(makeRequest("tool.console", { session_id: "aa11" }));
await flushMicrotasks();
expect(cdp.ensureConsoleCapture).toHaveBeenCalledWith(7);
expect(sent[0]).toEqual({
id: "r-1",
result: {
tab_id: 7,
entries: [{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false }],
next_since: 1,
truncated: false,
},
});
});
it("detaches CDP state before stopping a session", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
const cdp = {
send: vi.fn(),
detachSession: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
await flushMicrotasks();
expect(cdp.detachSession).toHaveBeenCalledWith("aa11");
expect(sessions.has("aa11")).toBe(false);
expect(sent[0]).toEqual({ id: "r-1", result: {} });
});
it("returns not_found when stopping an unknown session", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_stop", { session_id: "zzzz" }));
await flushMicrotasks();
expect(sent[0]).toMatchObject({
id: "r-1",
error: { code: "not_found" },
});
});
it("returns unknown_method for unimplemented methods", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
// M9 wired `tool.evaluate` and `tool.wait_for_navigation`; pick a
// canary that still lives outside the extension (daemon-side
// `tool.wait_ms` cannot reach this dispatcher in production, but
// routing it here lets us keep catching regressions in the
// default branch).
deliver(makeRequest("tool.wait_ms", { duration_ms: 10 }));
await flushMicrotasks();
expect(sent[0]).toMatchObject({
id: "r-1",
error: { code: "unknown_method" },
});
});
it("ignores non-request frames", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver({ id: "ignore", result: { ok: true } });
deliver({ event: "browser.connected", payload: {} });
await flushMicrotasks();
expect(sent).toEqual([]);
});
it("stop() detaches the message handler", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
dispatcher.stop();
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
await flushMicrotasks();
expect(sent).toEqual([]);
});
it("invokes onSessionsChanged after session.start and session.stop", async () => {
const { transport, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
const onSessionsChanged = vi.fn();
const dispatcher = new ToolDispatcher({ transport, sessions, onSessionsChanged });
dispatcher.start();
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
await flushMicrotasks();
expect(onSessionsChanged).toHaveBeenCalledTimes(1);
deliver({ ...makeRequest("tool.session_stop", { session_id: "aa11" }), id: "r-2" });
await flushMicrotasks();
expect(onSessionsChanged).toHaveBeenCalledTimes(2);
});
it("disconnects the transport when send() fails so keepalive can rebuild", async () => {
const { transport, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
transport.send = () => {
throw new Error("simulated send failure");
};
const disconnect = vi.spyOn(transport, "disconnect");
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_start", { session_id: "aa22" }));
await flushMicrotasks();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it("rolls back the session_start side effects when send() fails", async () => {
const { transport, deliver } = fakeTransport();
const remove = vi.fn(async () => {});
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 5555),
remove,
ensureActiveTab: vi.fn(async () => {}),
},
});
transport.send = () => {
throw new Error("simulated send failure");
};
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_start", { session_id: "aa33" }));
await flushMicrotasks();
// Window opened then closed during rollback; SessionContext gone.
expect(remove).toHaveBeenCalledWith(5555);
expect(sessions.has("aa33")).toBe(false);
});
it("registers an AbortController per RPC and cancel() trips the matching one", async () => {
const { transport, sent, deliver } = fakeTransport();
// Slow agent-window create lets us observe the controller before
// the handler resolves.
let resolveCreate: (id: number) => void = () => {};
const createPromise = new Promise<number>((r) => {
resolveCreate = r;
});
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(() => createPromise),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_start", { session_id: "aa44" }));
// Wait for the controller to register.
for (let i = 0; i < 4; i += 1) await Promise.resolve();
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(true);
const ac = dispatcher.inflightAbortControllers.get("r-1");
expect(ac?.signal.aborted).toBe(false);
// Push a cancel for the same id.
deliver({ id: "cancel-1", method: "cancel", params: { rpc_id: "r-1" } });
await flushMicrotasks();
expect(ac?.signal.aborted).toBe(true);
// Cancel ack arrived synchronously; the slow tool replies with
// `cancelled` once the dispatcher's race observes the abort.
const ack = sent.find(
(m) =>
typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "cancel-1",
);
expect(ack).toEqual({ id: "cancel-1", result: { cancelled: true } });
const slow = sent.find(
(m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1",
);
expect(slow).toMatchObject({ id: "r-1", error: { code: "cancelled" } });
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(false);
// Drain the dangling create promise so vitest does not warn.
resolveCreate(9999);
await flushMicrotasks();
});
it("cancel for an unknown rpc_id replies with cancelled=false", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver({ id: "cancel-x", method: "cancel", params: { rpc_id: "ghost" } });
await flushMicrotasks();
expect(sent[0]).toEqual({ id: "cancel-x", result: { cancelled: false } });
});
it("stop() aborts every in-flight controller", async () => {
const { transport, deliver } = fakeTransport();
let resolveCreate: (id: number) => void = () => {};
const createPromise = new Promise<number>((r) => {
resolveCreate = r;
});
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(() => createPromise),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_start", { session_id: "aa55" }));
for (let i = 0; i < 4; i += 1) await Promise.resolve();
const ac = dispatcher.inflightAbortControllers.get("r-1");
expect(ac).toBeDefined();
dispatcher.stop();
expect(ac?.signal.aborted).toBe(true);
expect(dispatcher.inflightAbortControllers.size).toBe(0);
// Drain the dangling create promise.
resolveCreate(9999);
await flushMicrotasks();
});
});
async function flushMicrotasks() {
// The dispatcher uses an `await` chain; resolve enough microtask
// turns to drain even the deepest invocation graph (M10.2 added
// `Promise.race` wrapping on top of the existing await depth, which
// pushes the required-turn count past the original 4).
for (let i = 0; i < 16; i += 1) await Promise.resolve();
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { nodeBoundingRect, quadBoundingRect } from "../element-geometry";
import type { CdpRunner } from "../shared";
describe("quadBoundingRect", () => {
it("computes axis-aligned bounds from an 8-double quad", () => {
expect(quadBoundingRect([10, 20, 110, 20, 110, 60, 10, 60])).toEqual({
x: 10,
y: 20,
width: 100,
height: 40,
});
});
it("returns null for degenerate quads", () => {
expect(quadBoundingRect([0, 0, 0, 0, 0, 0, 0, 0])).toBeNull();
expect(quadBoundingRect([1, 2, 3])).toBeNull();
});
});
describe("nodeBoundingRect", () => {
it("falls back to visible descendant bounds for zero-size containers", async () => {
const calls: Array<{ method: string; params?: object }> = [];
const send = async (_tabId: number, method: string, params?: object) => {
calls.push({ method, params });
switch (method) {
case "DOM.getContentQuads":
return { quads: [[245, 468, 1005, 468, 1005, 468, 245, 468]] };
case "DOM.getBoxModel":
return { model: { content: [245, 468, 1005, 468, 1005, 468, 245, 468] } };
case "DOM.resolveNode":
return { object: { objectId: "node-1" } };
case "Runtime.callFunctionOn":
return { result: { value: { x: 242, y: 468, width: 769, height: 180 } } };
default:
throw new Error(`unexpected CDP call ${method}`);
}
};
const cdp = { send: send as CdpRunner["send"] };
await expect(nodeBoundingRect(cdp, 7, 555)).resolves.toEqual({
x: 242,
y: 468,
width: 769,
height: 180,
});
expect(calls.map((c) => c.method)).toEqual([
"DOM.getContentQuads",
"DOM.getBoxModel",
"DOM.resolveNode",
"Runtime.callFunctionOn",
]);
});
});
@@ -0,0 +1,289 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import { handleEvaluate } from "../evaluate";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
interface FakeCdpOptions {
windowId?: number;
evaluate?: (params: unknown) => unknown;
onDialogOpening?: () => void;
dialogBlocking?: boolean;
}
function makeFakeCdp(opts: FakeCdpOptions = {}) {
const sent: Array<{ tabId: number; method: string; params?: object }> = [];
let dialogBlocking = opts.dialogBlocking ?? false;
const dialogRecords: Array<{
tab_id: number;
type: "alert";
message: string;
handled: "accepted";
sequence: number;
}> = [];
let sequence = 0;
const sendImpl = async (tabId: number, method: string, params?: object) => {
sent.push({ tabId, method, params });
if (method === "Runtime.evaluate") {
if (dialogBlocking) {
opts.onDialogOpening?.();
dialogBlocking = false;
sequence += 1;
dialogRecords.push({
tab_id: tabId,
type: "alert",
message: "blocked",
handled: "accepted",
sequence,
});
if (opts.evaluate) return opts.evaluate(params);
return { result: { type: "number", value: 2 } };
}
if (opts.evaluate) return opts.evaluate(params);
return { result: { type: "number", value: 2 } };
}
throw new Error(`unexpected CDP call ${method}`);
};
const send = vi.fn(sendImpl);
const cdp: CdpRunner = {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
trackSessionTab: vi.fn(),
dialogCursor: vi.fn(() => 0),
dialogsSince: vi.fn((_tabId: number, cursor: number) =>
dialogRecords.filter((d) => d.sequence > cursor),
),
};
const windowId = opts.windowId ?? 100;
const tabsApi = {
get: vi.fn(async (tabId: number) => ({ id: tabId, windowId, active: true }) as chrome.tabs.Tab),
query: vi.fn(async () => [{ id: 4, windowId, active: true } as chrome.tabs.Tab]),
};
return { cdp, tabsApi, sent };
}
describe("handleEvaluate", () => {
it("rejects empty expression as invalid_params", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
const res = await handleEvaluate(
sm,
{ session_id: "aa11", expression: "" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "invalid_params" });
});
it("evaluates 1+1 and returns ok with value 2", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
evaluate: () => ({ result: { type: "number", value: 2 } }),
});
const res = await handleEvaluate(
sm,
{ session_id: "aa11", expression: "1+1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.ok).toBe(true);
expect(res.value).toBe(2);
expect(res.tab_id).toBe(4);
expect(res.error).toBeUndefined();
});
it("forwards await_promise/return_by_value with their defaults filled in", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
let observed: Record<string, unknown> | undefined;
const fake = makeFakeCdp({
evaluate: (params) => {
observed = params as Record<string, unknown>;
return { result: { type: "string", value: "ok" } };
},
});
await handleEvaluate(
sm,
{ session_id: "aa11", expression: "Promise.resolve('ok')" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(observed).toMatchObject({
expression: "Promise.resolve('ok')",
awaitPromise: true,
returnByValue: true,
throwOnSideEffect: false,
});
});
it("respects explicit await_promise=false and return_by_value=false overrides", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
let observed: Record<string, unknown> | undefined;
const fake = makeFakeCdp({
evaluate: (params) => {
observed = params as Record<string, unknown>;
return { result: { type: "string", value: "kept" } };
},
});
await handleEvaluate(
sm,
{
session_id: "aa11",
expression: "'kept'",
await_promise: false,
return_by_value: false,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(observed).toMatchObject({
awaitPromise: false,
returnByValue: false,
});
});
it("returns a JSON-safe RemoteObject descriptor when return_by_value=false", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
evaluate: () => ({
result: {
type: "object",
subtype: "promise",
className: "Promise",
objectId: "remote-object-1",
description: "Promise",
},
}),
});
const res = await handleEvaluate(
sm,
{
session_id: "aa11",
expression: "Promise.resolve(1)",
await_promise: false,
return_by_value: false,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.ok).toBe(true);
expect(res.value).toEqual({
type: "object",
subtype: "promise",
className: "Promise",
objectId: "remote-object-1",
description: "Promise",
});
});
it("returns ok=false with error text when the expression throws", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
evaluate: () => ({
result: { type: "object", subtype: "error" },
exceptionDetails: {
text: "Uncaught Error",
lineNumber: 2,
columnNumber: 8,
exception: {
description: "Error: boom\n at <anonymous>:1:7",
},
},
}),
});
const res = await handleEvaluate(
sm,
{ session_id: "aa11", expression: 'throw new Error("boom")' },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.ok).toBe(false);
expect(res.value).toBeUndefined();
expect(res.error?.text).toContain("boom");
expect(res.error?.line).toBe(3);
expect(res.error?.column).toBe(8);
});
it("stringifies unserializableValue (Infinity / NaN / BigInt)", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
evaluate: () => ({
result: { type: "number", unserializableValue: "Infinity" },
}),
});
const res = await handleEvaluate(
sm,
{ session_id: "aa11", expression: "1/0" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.ok).toBe(true);
expect(res.value).toBe("Infinity");
});
it("allows evaluating against a borrowed tab inside the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 0 });
const fake = makeFakeCdp();
fake.tabsApi.get = vi.fn(
async () => ({ id: 7, windowId: 100, active: true }) as chrome.tabs.Tab,
);
const res = await handleEvaluate(
sm,
{ session_id: "aa11", expression: "1+1", tab_id: 7 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.ok).toBe(true);
});
it("returns dialogs observed during evaluate", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ dialogBlocking: true });
const res = await handleEvaluate(
sm,
{ session_id: "aa11", expression: "alert('hi'); 1+1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.ok).toBe(true);
expect(res.dialogs).toHaveLength(1);
expect(res.dialogs?.[0]?.message).toBe("blocked");
expect(res.dialogs?.[0]?.type).toBe("alert");
});
it("rejects tabs outside the Agent Window with permission_denied", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
fake.tabsApi.get = vi.fn(
async () => ({ id: 11, windowId: 200, active: true }) as chrome.tabs.Tab,
);
const res = await handleEvaluate(
sm,
{ session_id: "aa11", expression: "1+1", tab_id: 11 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
});
});
@@ -0,0 +1,262 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { RequestHelpParams } from "@/transport/types";
import { handleRequestHelp, type RequestHelpDeps } from "../human-loop";
function fakeManager(sessionId: string, agentWindowId: number, tabId: number) {
const mgr = {
get: (id: string) =>
id === sessionId
? { sessionId, agentWindowId, refStore: { resolve: () => null }, borrowedTabs: new Map() }
: null,
findByWindowId: (wid: number) => (wid === agentWindowId ? { sessionId } : null),
} as unknown as SessionManager;
return mgr;
}
function baseParams(over: Partial<RequestHelpParams> = {}): RequestHelpParams {
return { session_id: "abcd", prompt: "log in", ...over };
}
function baseDeps(over: Partial<RequestHelpDeps> = {}): RequestHelpDeps {
return {
tabsApi: {
get: vi.fn(async () => ({ id: 5, windowId: 99, active: true, title: "Login" }) as never),
query: vi.fn(async () => [{ id: 5, windowId: 99, active: true }] as never),
},
windows: { update: vi.fn(async () => ({}) as never) },
activateTab: vi.fn(async () => {}),
sendToTab: vi.fn(async () => ({ type: "bsk-help-response", outcome: "continued", note: "ok" })),
watchTabNavigation: () => () => {},
cdp: { send: vi.fn(async () => ({})) } as unknown as RequestHelpDeps["cdp"],
notifications: null,
...over,
};
}
describe("handleRequestHelp", () => {
it("rejects unknown session", async () => {
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ session_id: "zzzz" }), baseDeps());
expect("code" in res && res.code).toBe("not_found");
});
it("brings the tab to the foreground and returns the user outcome", async () => {
const deps = baseDeps();
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
expect(deps.windows.update).toHaveBeenCalledWith(99, { focused: true });
expect(deps.activateTab).toHaveBeenCalledWith(5);
expect(res).toMatchObject({ outcome: "continued", note: "ok", tab_id: 5 });
});
it("forwards title into the help request message when provided", async () => {
const deps = baseDeps();
await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5, title: "Complete verification" }),
deps,
);
const sentMsg = (deps.sendToTab as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(sentMsg).toMatchObject({
type: "bsk-help-request",
prompt: "log in",
title: "Complete verification",
});
});
it("omits title from the help request message when not provided", async () => {
const deps = baseDeps();
await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
const sentMsg = (deps.sendToTab as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(sentMsg.title).toBeUndefined();
});
it("returns navigated when the tab navigates during the wait", async () => {
const unwatch = vi.fn();
const deps = baseDeps({
sendToTab: vi.fn(() => new Promise(() => {})),
watchTabNavigation: vi.fn((_tabId, cb) => {
queueMicrotask(() => cb());
return unwatch;
}),
});
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
expect(res).toMatchObject({ outcome: "navigated", tab_id: 5 });
expect(unwatch).toHaveBeenCalled();
});
it("returns timed_out when the wait expires", async () => {
vi.useFakeTimers();
try {
const deps = baseDeps({
sendToTab: vi.fn(() => new Promise(() => {})), // never resolves
});
const p = handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5, timeout_ms: 10 }),
deps,
);
await vi.advanceTimersByTimeAsync(20);
const res = await p;
expect(res).toMatchObject({ outcome: "timed_out", tab_id: 5 });
} finally {
vi.useRealTimers();
}
});
it("tags ref targets via CDP and reports them matched", async () => {
const mgr = {
get: (id: string) =>
id === "abcd"
? { sessionId: "abcd", agentWindowId: 99, refStore: { resolve: () => 42 }, borrowedTabs: new Map() }
: null,
findByWindowId: (wid: number) => (wid === 99 ? { sessionId: "abcd" } : null),
} as unknown as SessionManager;
const deps = baseDeps({
cdp: { send: vi.fn(async () => ({ object: { objectId: "obj-1" } })) } as unknown as RequestHelpDeps["cdp"],
});
const res = await handleRequestHelp(
mgr,
baseParams({ tab_id: 5, targets: [{ ref: "@e1" }] }),
deps,
);
const sentMsg = (deps.sendToTab as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(sentMsg.selectors).toContain('[data-bsk-help="0"]');
expect(res).toMatchObject({
outcome: "continued",
tab_id: 5,
resolved_targets: [{ matched: true, ref: "@e1" }],
});
});
it("reports ref target unmatched when ref does not resolve", async () => {
const deps = baseDeps();
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5, targets: [{ ref: "@e1" }] }),
deps,
);
expect(res).toMatchObject({
outcome: "continued",
resolved_targets: [{ matched: false, ref: "@e1" }],
});
expect("code" in res).toBe(false);
});
it("reports ref target unmatched when ref is for another tab", async () => {
const mgr = {
get: (id: string) =>
id === "abcd"
? {
sessionId: "abcd",
agentWindowId: 99,
refStore: {
resolve: (ref: string, opts: { tabId?: number }) =>
ref === "e1" && opts.tabId === 4 ? 42 : null,
},
borrowedTabs: new Map(),
}
: null,
findByWindowId: (wid: number) => (wid === 99 ? { sessionId: "abcd" } : null),
} as unknown as SessionManager;
const deps = baseDeps();
const res = await handleRequestHelp(
mgr,
baseParams({ tab_id: 5, targets: [{ ref: "@e1" }] }),
deps,
);
expect(res).toMatchObject({
outcome: "continued",
resolved_targets: [{ matched: false, ref: "@e1" }],
});
expect("code" in res).toBe(false);
});
it("returns protocol_error when sendToTab rejects", async () => {
const deps = baseDeps({
sendToTab: vi.fn(async () => {
throw new Error("no receiver");
}),
});
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
expect("code" in res && res.code).toBe("protocol_error");
});
it("returns protocol_error for a malformed help response", async () => {
const undefinedReply = baseDeps({ sendToTab: vi.fn(async () => undefined) });
const res1 = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5 }),
undefinedReply,
);
expect("code" in res1 && res1.code).toBe("protocol_error");
const badOutcome = baseDeps({
sendToTab: vi.fn(async () => ({ type: "bsk-help-response", outcome: "weird" })),
});
const res2 = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5 }),
badOutcome,
);
expect("code" in res2 && res2.code).toBe("protocol_error");
});
it("reports selector match status from CDP", async () => {
const cdpFor = (querySelectorNodeId: number) =>
({
send: vi.fn(async (_tabId: number, method: string) => {
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
if (method === "DOM.querySelector") return { nodeId: querySelectorNodeId };
return {};
}),
}) as unknown as RequestHelpDeps["cdp"];
const miss = baseDeps({ cdp: cdpFor(0) });
const resMiss = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5, targets: [{ selector: "#x" }] }),
miss,
);
expect(resMiss).toMatchObject({
resolved_targets: [{ matched: false, selector: "#x" }],
});
const hit = baseDeps({ cdp: cdpFor(42) });
const resHit = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5, targets: [{ selector: "#x" }] }),
hit,
);
expect(resHit).toMatchObject({
resolved_targets: [{ matched: true, selector: "#x" }],
});
});
it("marks selector unmatched when CDP cannot resolve the document root", async () => {
const deps = baseDeps({
cdp: {
send: vi.fn(async (_tabId: number, method: string) => {
if (method === "DOM.getDocument") throw new Error("no document");
return {};
}),
} as unknown as RequestHelpDeps["cdp"],
});
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5, targets: [{ selector: "#x" }] }),
deps,
);
expect(res).toMatchObject({
resolved_targets: [{ matched: false, selector: "#x" }],
});
});
it("returns cancelled when the signal aborts", async () => {
const ac = new AbortController();
const deps = baseDeps({ signal: ac.signal, sendToTab: vi.fn(() => new Promise(() => {})) });
const p = handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5, timeout_ms: 60_000 }), deps);
ac.abort();
const res = await p;
expect("code" in res ? res.code : (res as { outcome: string }).outcome).toMatch(/cancelled/);
});
});
@@ -0,0 +1,985 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import {
handleClick,
handleFill,
handlePress,
handleSelect,
modifiersBitfield,
parseKeySpec,
resolveKeyDescriptor,
} from "../interaction";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
const sent: Array<{ tabId: number; method: string; params?: object }> = [];
const sendImpl = async (tabId: number, method: string, params?: object) => {
sent.push({ tabId, method, params });
const h = handlers[method];
if (!h) throw new Error(`unexpected CDP call ${method}`);
return h(params);
};
const send = vi.fn(sendImpl);
const cdp: CdpRunner = {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
trackSessionTab: vi.fn(),
};
const tabsApi = {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
};
return { cdp, tabsApi, sent };
}
describe("modifiersBitfield", () => {
it("matches CDP's expected bit layout", () => {
expect(modifiersBitfield([])).toBe(0);
expect(modifiersBitfield(["alt"])).toBe(1);
expect(modifiersBitfield(["ctrl"])).toBe(2);
expect(modifiersBitfield(["meta"])).toBe(4);
expect(modifiersBitfield(["shift"])).toBe(8);
expect(modifiersBitfield(["ctrl", "shift"])).toBe(2 | 8);
expect(modifiersBitfield(["alt", "ctrl", "meta", "shift"])).toBe(15);
});
it("de-duplicates repeats", () => {
expect(modifiersBitfield(["ctrl", "ctrl"])).toBe(2);
});
});
describe("handleClick", () => {
it("rejects when neither ref nor selector is given", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({});
const res = await handleClick(
sm,
{ session_id: "aa11" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "invalid_params" });
});
it("rejects when both ref AND selector are given", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "@e1", selector: ".btn" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({
code: "invalid_params",
message: /both ref and selector/i,
});
});
it("returns not_found when ref doesn't resolve in the session", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "@e99" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "not_found", data: { reason: "ref_not_found" } });
});
it("returns not_found when ref belongs to another tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
const fake = makeFakeCdp({});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "@e3", tab_id: 5 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "not_found", data: { reason: "ref_not_found" } });
expect(fake.cdp.send).not.toHaveBeenCalled();
});
it("clicks by ref, computes the quad centre, dispatches three mouse events", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
// Quads are arrays of 8 doubles forming the four corners of the rect.
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
"Input.dispatchMouseEvent": () => ({}),
});
const res = await handleClick(
sm,
{
session_id: "aa11",
ref: "@e3",
button: "left",
click_count: 2,
modifiers: ["ctrl", "shift"],
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tab_id).toBe(4);
expect(res.used_ref).toBe("e3");
expect(res.used_selector).toBeUndefined();
expect(res.x).toBeCloseTo(60); // (10 + 110) / 2
expect(res.y).toBeCloseTo(40); // (20 + 60) / 2
// 1 scroll + 1 quad query + 3 mouse events = 5 CDP calls.
const mouse = fake.sent.filter((c) => c.method === "Input.dispatchMouseEvent");
expect(mouse).toHaveLength(3);
expect(mouse[0].params).toMatchObject({ type: "mouseMoved" });
expect(mouse[1].params).toMatchObject({
type: "mousePressed",
button: "left",
clickCount: 2,
modifiers: 2 | 8,
});
expect(mouse[2].params).toMatchObject({
type: "mouseReleased",
button: "left",
clickCount: 2,
});
});
it("enables overlay bypass before mouse events when overlay blocks the click point", async () => {
const order: string[] = [];
const bypassOverlay = vi.fn(async (_tabId: number, enabled: boolean) => {
order.push(enabled ? "bypass-on" : "bypass-off");
});
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
"Runtime.evaluate": (params: unknown) => {
const expr = String((params as { expression?: string })?.expression ?? "");
if (expr.includes("overlayHostPresent") && !expr.includes("hitIndex")) {
return { result: { value: { overlayHostPresent: true, overlayHostConnected: true } } };
}
if (expr.includes("hitIndex")) {
return {
result: {
value: { overlayHostPresent: true, overlayHostConnected: true, hitIndex: 0 },
},
};
}
throw new Error(`unexpected Runtime.evaluate: ${expr.slice(0, 80)}`);
},
"Input.dispatchMouseEvent": () => {
order.push("mouse");
return {};
},
});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "@e3" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, bypassOverlay },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(bypassOverlay).toHaveBeenCalledWith(4, true);
expect(bypassOverlay).toHaveBeenCalledWith(4, false);
expect(order.indexOf("bypass-on")).toBeLessThan(order.indexOf("mouse"));
expect(order.lastIndexOf("bypass-off")).toBeGreaterThan(order.lastIndexOf("mouse"));
});
it("disables overlay bypass when mouse dispatch throws", async () => {
const bypassOverlay = vi.fn().mockResolvedValue(undefined);
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
let mouseCalls = 0;
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
"Runtime.evaluate": (params: unknown) => {
const expr = String((params as { expression?: string })?.expression ?? "");
if (expr.includes("overlayHostPresent") && !expr.includes("hitIndex")) {
return { result: { value: { overlayHostPresent: true, overlayHostConnected: true } } };
}
if (expr.includes("hitIndex")) {
return {
result: {
value: { overlayHostPresent: true, overlayHostConnected: true, hitIndex: 0 },
},
};
}
throw new Error(`unexpected Runtime.evaluate: ${expr.slice(0, 80)}`);
},
"Input.dispatchMouseEvent": () => {
mouseCalls += 1;
if (mouseCalls === 2) throw new Error("mousePressed failed");
return {};
},
});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "@e3" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, bypassOverlay },
);
expect(res).toMatchObject({ code: "cdp_failed" });
expect(bypassOverlay).toHaveBeenCalledWith(4, true);
expect(bypassOverlay).toHaveBeenCalledWith(4, false);
});
it("skips overlay bypass when overlay does not block the click point", async () => {
const bypassOverlay = vi.fn().mockResolvedValue(undefined);
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
"Runtime.evaluate": (params: unknown) => {
const expr = String((params as { expression?: string })?.expression ?? "");
if (expr.includes("overlayHostPresent") && !expr.includes("hitIndex")) {
return { result: { value: { overlayHostPresent: false, overlayHostConnected: false } } };
}
throw new Error(`unexpected Runtime.evaluate: ${expr.slice(0, 80)}`);
},
"Input.dispatchMouseEvent": () => ({}),
});
await handleClick(
sm,
{ session_id: "aa11", ref: "@e3" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, bypassOverlay },
);
expect(bypassOverlay).not.toHaveBeenCalled();
});
it("rejects click_count=0", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "@e3", click_count: 0 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "invalid_params" });
expect(fake.sent.some((c) => c.method === "Input.dispatchMouseEvent")).toBe(false);
});
it("falls back to DOM.getBoxModel when quads are missing", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [] }),
"DOM.getBoxModel": () => ({ model: { content: [0, 0, 40, 0, 40, 20, 0, 20] } }),
"Input.dispatchMouseEvent": () => ({}),
});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "e1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.x).toBeCloseTo(20);
expect(res.y).toBeCloseTo(10);
});
it("returns permission_denied when the element has no visible box", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [] }),
"DOM.getBoxModel": () => {
throw new Error("Could not compute box model.");
},
});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "e1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({
code: "permission_denied",
message: /not visible/i,
data: { reason: "element_not_visible" },
});
});
it("clicks by selector and reports used_selector in the result", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
"DOM.getDocument": () => ({ root: { nodeId: 1 } }),
"DOM.querySelector": (p) => {
expect(p).toMatchObject({ nodeId: 1, selector: ".btn-go" });
return { nodeId: 99 };
},
"DOM.describeNode": () => ({ node: { backendNodeId: 7777 } }),
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[0, 0, 50, 0, 50, 50, 0, 50]] }),
"Input.dispatchMouseEvent": () => ({}),
});
const res = await handleClick(
sm,
{ session_id: "aa11", selector: ".btn-go" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.used_selector).toBe(".btn-go");
expect(res.used_ref).toBeUndefined();
});
it("falls back to Element.scrollIntoView when DOM.scrollIntoViewIfNeeded fails", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 100, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => {
throw new Error("method not found");
},
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"DOM.getContentQuads": () => ({ quads: [[0, 0, 10, 0, 10, 10, 0, 10]] }),
"Input.dispatchMouseEvent": () => ({}),
});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "e1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
const fallback = fake.sent.find((c) => c.method === "Runtime.callFunctionOn");
expect(fallback?.params).toMatchObject({ objectId: "obj-1" });
});
it("returns not_found when DOM.querySelector returns nodeId=0", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
"DOM.getDocument": () => ({ root: { nodeId: 1 } }),
"DOM.querySelector": () => ({ nodeId: 0 }),
});
const res = await handleClick(
sm,
{ session_id: "aa11", selector: ".missing" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "not_found", data: { reason: "selector_not_found" } });
});
it("respects the AbortSignal", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 100, { tabId: 4 });
const abort = new AbortController();
abort.abort();
const fake = makeFakeCdp({});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "e1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
expect(res).toMatchObject({ code: "cancelled" });
});
it("stops dispatching mouse events after a mid-click abort", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 100, { tabId: 4 });
const abort = new AbortController();
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[0, 0, 10, 0, 10, 10, 0, 10]] }),
"Input.dispatchMouseEvent": (p) => {
if ((p as { type?: string }).type === "mouseMoved") abort.abort();
return {};
},
});
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "e1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
expect(res).toMatchObject({ code: "cancelled" });
expect(fake.sent.filter((c) => c.method === "Input.dispatchMouseEvent")).toHaveLength(1);
});
});
describe("handleFill", () => {
it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({});
const res = await handleFill(
sm,
{ session_id: "aa11", ref: "e99", value: "hello" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "not_found", data: { reason: "ref_not_found" } });
expect(fake.cdp.send).not.toHaveBeenCalled();
});
it("rejects non-fillable elements as invalid_params", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 100, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: { backendNodeId: 100, nodeName: "DIV", attributes: [] },
}),
});
const res = await handleFill(
sm,
{ session_id: "aa11", ref: "e1", value: "hi" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({
code: "invalid_params",
message: /not fillable/i,
data: { reason: "target_not_fillable" },
});
});
it("fills an <input> via Runtime.callFunctionOn + Input.insertText", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: { backendNodeId: 555, nodeName: "INPUT", attributes: ["type", "text"] },
}),
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"Input.insertText": () => ({}),
});
const res = await handleFill(
sm,
{ session_id: "aa11", ref: "e1", value: "hello" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.value_length).toBe(5);
expect(res.tab_id).toBe(4);
expect(res.used_ref).toBe("e1");
const insert = fake.sent.find((c) => c.method === "Input.insertText");
expect(insert?.params).toEqual({ text: "hello" });
// clear_before defaults to true → callFunctionOn invoked once to
// clear, once to fire input/change after typing.
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
expect(callFns.length).toBeGreaterThanOrEqual(2);
});
it("clear_before=false skips the wipe call", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 1, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: { backendNodeId: 1, nodeName: "TEXTAREA", attributes: [] },
}),
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
"Runtime.callFunctionOn": (p) => {
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
// No "this.value = ''" clearing on a no-clear path; only the
// post-input dispatchEvent.
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
return { result: { type: "undefined" } };
},
"Input.insertText": () => ({}),
});
const res = await handleFill(
sm,
{ session_id: "aa11", ref: "e1", value: "x", clear_before: false },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.value_length).toBe(1);
});
it("treats contenteditable=true as fillable", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 7, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: {
backendNodeId: 7,
nodeName: "DIV",
attributes: ["contenteditable", "true"],
},
}),
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"Input.insertText": () => ({}),
});
const res = await handleFill(
sm,
{ session_id: "aa11", ref: "e1", value: "rich" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect("code" in res).toBe(false);
});
it("stops before Input.insertText when abort fires after clearing", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const abort = new AbortController();
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: { backendNodeId: 555, nodeName: "INPUT", attributes: ["type", "text"] },
}),
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
"Runtime.callFunctionOn": () => {
abort.abort();
return { result: { type: "undefined" } };
},
"Input.insertText": () => ({}),
});
const res = await handleFill(
sm,
{ session_id: "aa11", ref: "e1", value: "hello" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
expect(res).toMatchObject({ code: "cancelled" });
expect(fake.sent.some((c) => c.method === "Input.insertText")).toBe(false);
});
it("stops before focus when abort fires after fill scroll", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const abort = new AbortController();
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: { backendNodeId: 555, nodeName: "INPUT", attributes: ["type", "text"] },
}),
"DOM.scrollIntoViewIfNeeded": () => {
abort.abort();
return {};
},
"DOM.focus": () => ({}),
});
const res = await handleFill(
sm,
{ session_id: "aa11", ref: "e1", value: "hello" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
expect(res).toMatchObject({ code: "cancelled" });
expect(fake.sent.some((c) => c.method === "DOM.focus")).toBe(false);
});
});
describe("parseKeySpec", () => {
it("splits compound expressions into modifiers + base key", () => {
expect(parseKeySpec("Ctrl+A")).toEqual({ key: "A", modifiers: ["ctrl"] });
expect(parseKeySpec("Meta+Shift+P")).toEqual({
key: "P",
modifiers: ["meta", "shift"],
});
});
it("treats single keys as no-modifier presses", () => {
expect(parseKeySpec("Enter")).toEqual({ key: "Enter", modifiers: [] });
expect(parseKeySpec("a")).toEqual({ key: "a", modifiers: [] });
});
it("normalises modifier casing", () => {
expect(parseKeySpec("CONTROL+SHIFT+P")).toEqual({
key: "P",
modifiers: ["ctrl", "shift"],
});
});
});
describe("resolveKeyDescriptor", () => {
it("maps Enter to key/code/text", () => {
expect(resolveKeyDescriptor("Enter")).toEqual({
key: "Enter",
code: "Enter",
text: "\r",
windowsVirtualKeyCode: 13,
});
});
it("maps single lowercase letters", () => {
expect(resolveKeyDescriptor("a")).toMatchObject({ key: "a", code: "KeyA", text: "a" });
});
it("maps single uppercase letters with the right CDP code", () => {
expect(resolveKeyDescriptor("A")).toMatchObject({ key: "A", code: "KeyA", text: "A" });
});
it("maps digits", () => {
expect(resolveKeyDescriptor("3")).toMatchObject({ key: "3", code: "Digit3", text: "3" });
});
it("recognises arrow keys", () => {
expect(resolveKeyDescriptor("ArrowLeft")).toMatchObject({ code: "ArrowLeft" });
});
it("returns null for unknown keys", () => {
expect(resolveKeyDescriptor("UnknownKey")).toBeNull();
});
});
// PressResult also has a `code` field (the CDP keyboard code), so
// `"code" in res` is not enough to distinguish from an RpcError. The
// helper below narrows by checking for `key`/`tab_id` and asserts
// success without dropping the structural type information.
import type { PressResult, RpcError } from "@/transport/types";
function expectPressOk(res: PressResult | RpcError): asserts res is PressResult {
if (!("tab_id" in res) || "message" in res) {
throw new Error(`unexpected press response: ${JSON.stringify(res)}`);
}
}
describe("handlePress", () => {
it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({});
const res = await handlePress(
sm,
{ session_id: "aa11", ref: "@e99", key: "Enter" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "not_found", data: { reason: "ref_not_found" } });
expect(fake.cdp.send).not.toHaveBeenCalled();
});
it("dispatches rawKeyDown + char + keyUp for a printable letter", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
"Input.dispatchKeyEvent": () => ({}),
});
const res = await handlePress(
sm,
{ session_id: "aa11", key: "a" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expectPressOk(res);
expect(res.key).toBe("a");
expect(res.code).toBe("KeyA");
expect(res.modifiers).toEqual([]);
const calls = fake.sent.filter((c) => c.method === "Input.dispatchKeyEvent");
expect(calls.map((c) => (c.params as { type?: string }).type)).toEqual([
"rawKeyDown",
"char",
"keyUp",
]);
expect(calls[0].params).not.toHaveProperty("text");
expect(calls[1].params).toMatchObject({ text: "a" });
});
it("handles compound expressions and folds modifiers", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ "Input.dispatchKeyEvent": () => ({}) });
const res = await handlePress(
sm,
{ session_id: "aa11", key: "Ctrl+A" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expectPressOk(res);
expect(res.modifiers).toEqual(["ctrl"]);
expect(res.code).toBe("KeyA");
const keyDown = fake.sent.find(
(c) =>
c.method === "Input.dispatchKeyEvent" &&
(c.params as { type?: string }).type === "rawKeyDown",
);
expect(keyDown?.params).toMatchObject({ modifiers: 2, key: "A", code: "KeyA" });
expect(keyDown?.params).not.toHaveProperty("text");
});
it("focuses an optional target before dispatch", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"Input.dispatchKeyEvent": () => ({}),
});
const res = await handlePress(
sm,
{ session_id: "aa11", key: "Enter", ref: "@e1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expectPressOk(res);
const focus = fake.sent.find((c) => c.method === "DOM.focus");
expect(focus?.params).toEqual({ backendNodeId: 555 });
expect(res.key).toBe("Enter");
});
it("stops before focus when abort fires after press scroll", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const abort = new AbortController();
const fake = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => {
abort.abort();
return {};
},
"DOM.focus": () => ({}),
});
const res = await handlePress(
sm,
{ session_id: "aa11", key: "Enter", ref: "@e1" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
expect(res).toMatchObject({ code: "cancelled" });
expect(fake.sent.some((c) => c.method === "DOM.focus")).toBe(false);
});
it("returns invalid_params for an unknown key", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({});
const res = await handlePress(
sm,
{ session_id: "aa11", key: "TotallyMadeUpKey" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "invalid_params" });
});
it("holds the key for hold_ms between keyDown and keyUp", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ "Input.dispatchKeyEvent": () => ({}) });
const start = Date.now();
const res = await handlePress(
sm,
{ session_id: "aa11", key: "Enter", hold_ms: 50 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
const elapsed = Date.now() - start;
expectPressOk(res);
expect(elapsed).toBeGreaterThanOrEqual(40); // generous lower bound
});
it("returns cancelled after hold_ms abort while still sending keyUp", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const abort = new AbortController();
const fake = makeFakeCdp({ "Input.dispatchKeyEvent": () => ({}) });
const pressP = handlePress(
sm,
{ session_id: "aa11", key: "Escape", hold_ms: 50 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
setTimeout(() => abort.abort(), 5);
const res = await pressP;
expect(res).toMatchObject({ code: "cancelled" });
expect(
fake.sent
.filter((c) => c.method === "Input.dispatchKeyEvent")
.map((c) => (c.params as { type?: string }).type),
).toEqual(["rawKeyDown", "keyUp"]);
});
});
describe("handleSelect", () => {
const selectHandlers = (mutation: {
ok: boolean;
reason?: string;
missing?: string;
multiple?: boolean;
selected_values?: string[];
selected_labels?: string[];
}) => ({
"DOM.describeNode": () => ({
node: {
backendNodeId: 555,
nodeName: "SELECT",
attributes: mutation.multiple ? ["multiple", ""] : [],
},
}),
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-sel" } }),
"Runtime.callFunctionOn": () => ({ result: { value: mutation } }),
});
it("rejects non-select elements as target_not_select", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 100, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: { backendNodeId: 100, nodeName: "DIV", attributes: [] },
}),
});
const res = await handleSelect(
sm,
{ session_id: "aa11", ref: "e1", values: ["a"] },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_select" },
});
});
it("sets multiple values on a <select multiple>", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const fake = makeFakeCdp(
selectHandlers({
ok: true,
multiple: true,
selected_values: ["us", "ca"],
selected_labels: ["United States", "Canada"],
}),
);
const res = await handleSelect(
sm,
{ session_id: "aa11", ref: "e1", values: ["us", "ca"] },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.multiple).toBe(true);
expect(res.selected_values).toEqual(["us", "ca"]);
expect(res.selected_labels).toEqual(["United States", "Canada"]);
const callFn = fake.sent.find((c) => c.method === "Runtime.callFunctionOn");
expect(callFn?.params).toMatchObject({
arguments: [{ value: ["us", "ca"] }],
});
});
it("sets a single value on a single-select <select>", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const fake = makeFakeCdp(
selectHandlers({
ok: true,
multiple: false,
selected_values: ["us"],
selected_labels: ["United States"],
}),
);
const res = await handleSelect(
sm,
{ session_id: "aa11", ref: "e1", values: ["us"] },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.multiple).toBe(false);
expect(res.selected_values).toEqual(["us"]);
});
it("returns option_not_found when a value is missing", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const fake = makeFakeCdp(
selectHandlers({
ok: false,
reason: "option_not_found",
missing: "zz",
}),
);
const res = await handleSelect(
sm,
{ session_id: "aa11", ref: "e1", values: ["zz"] },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({
code: "invalid_params",
data: { reason: "option_not_found" },
});
});
it("rejects multiple values on a single-select <select>", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: { backendNodeId: 555, nodeName: "SELECT", attributes: [] },
}),
});
const res = await handleSelect(
sm,
{ session_id: "aa11", ref: "e1", values: ["a", "b"] },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({
code: "invalid_params",
data: { reason: "single_select_value_count" },
});
expect(fake.sent.some((c) => c.method === "Runtime.callFunctionOn")).toBe(false);
});
it("stops before mutation when abort fires after focus", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 4 });
const abort = new AbortController();
const fake = makeFakeCdp({
"DOM.describeNode": () => ({
node: { backendNodeId: 555, nodeName: "SELECT", attributes: [] },
}),
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => {
abort.abort();
return {};
},
"DOM.resolveNode": () => ({ object: { objectId: "obj-sel" } }),
"Runtime.callFunctionOn": () => ({ result: { value: { ok: true } } }),
});
const res = await handleSelect(
sm,
{ session_id: "aa11", ref: "e1", values: ["a"] },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
expect(res).toMatchObject({ code: "cancelled" });
expect(fake.sent.some((c) => c.method === "Runtime.callFunctionOn")).toBe(false);
});
});
@@ -0,0 +1,615 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import {
handleNavigate,
handleNavigateBack,
handleNavigateForward,
handleReload,
lifecycleAlreadyReached,
eventLoaderIsRelevant,
lifecycleMeetsOrExceeds,
shouldTrustReadyStateProbe,
} from "../navigation";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
interface EventListener {
(source: chrome.debugger.Debuggee, method: string, params: unknown): void;
}
/**
* Fake CDP runner that lets the test fire arbitrary lifecycle events
* after collecting all the `send` calls. Returns `{cdp, fireLifecycle}`
* so the test scripts the sequence: kick off navigate → fire load
* event → assert resolution.
*/
function makeFakeCdp(opts?: {
navigateFrameId?: string;
navigateLoaderId?: string;
initialUrl?: string;
finalUrl?: string;
historyEntries?: Array<{ id: number; url: string }>;
historyIndex?: number;
fireLifecycleDuringNavigate?: string;
fireLifecycleDuringNavigateFrameId?: string;
fireLifecycleDuringReload?: string;
fireLifecycleDuringNavigateLoaderId?: string;
beforeLoaderId?: string;
readyState?: string;
readyStateSequence?: string[];
}) {
const readyStates = opts?.readyStateSequence ?? (opts?.readyState ? [opts.readyState] : []);
let readyStateIdx = 0;
const nextReadyState = () => {
if (readyStates.length === 0) return "loading";
const value = readyStates[Math.min(readyStateIdx, readyStates.length - 1)];
if (readyStateIdx < readyStates.length - 1) readyStateIdx += 1;
return value;
};
const events: EventListener[] = [];
const sent: Array<{ tabId: number; method: string; params?: object }> = [];
const methodHandlers: Record<
string,
(tabId: number, params?: object) => object
> = {
"Page.enable": () => ({}),
"Page.setLifecycleEventsEnabled": () => ({}),
"Page.navigate": () => {
if (opts?.fireLifecycleDuringNavigate) {
const payload = {
name: opts.fireLifecycleDuringNavigate,
frameId: opts.fireLifecycleDuringNavigateFrameId ?? opts.navigateFrameId ?? "frame-1",
loaderId:
opts.fireLifecycleDuringNavigateLoaderId ??
opts.navigateLoaderId ??
"loader-after",
};
for (const listener of [...events]) {
listener({ tabId: 4 }, "Page.lifecycleEvent", payload);
}
}
return {
frameId: opts?.navigateFrameId ?? "frame-1",
loaderId: opts?.navigateLoaderId ?? "loader-after",
};
},
"Page.reload": () => {
if (opts?.fireLifecycleDuringReload) {
const payload = {
name: opts.fireLifecycleDuringReload,
frameId: opts.navigateFrameId ?? "frame-1",
loaderId: opts.navigateLoaderId ?? "loader-after",
};
for (const listener of [...events]) {
listener({ tabId: 4 }, "Page.lifecycleEvent", payload);
}
}
return {};
},
"Runtime.enable": () => ({}),
"Runtime.evaluate": () => ({ result: { value: nextReadyState() } }),
"Page.getNavigationHistory": () => ({
currentIndex: opts?.historyIndex ?? 1,
entries: opts?.historyEntries ?? [
{ id: 11, url: "https://a.example/" },
{ id: 12, url: "https://b.example/" },
],
}),
"Page.navigateToHistoryEntry": () => ({}),
"Page.getFrameTree": () => ({
frameTree: {
frame: {
id: opts?.navigateFrameId ?? "frame-1",
loaderId: opts?.beforeLoaderId ?? "loader-before",
},
},
}),
};
const sendImpl = async (tabId: number, method: string, params?: object) => {
sent.push({ tabId, method, params });
const handler = methodHandlers[method];
if (handler) return handler(tabId, params);
throw new Error(`unexpected CDP call ${method}`);
};
const send = vi.fn(sendImpl);
const cdp: CdpRunner = {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
trackSessionTab: vi.fn(),
onEvent: vi.fn((handler: EventListener) => {
events.push(handler);
return {
dispose: () => {
const idx = events.indexOf(handler);
if (idx >= 0) events.splice(idx, 1);
},
};
}),
};
const tabsApi = {
get: vi.fn(
async (tabId: number) =>
({
id: tabId,
windowId: 100,
active: true,
url: opts?.finalUrl ?? opts?.initialUrl ?? "https://example.com/",
}) as chrome.tabs.Tab,
),
query: vi.fn(async () => [
{
id: 4,
windowId: 100,
active: true,
url: opts?.initialUrl ?? "https://example.com/",
} as chrome.tabs.Tab,
]),
};
return {
cdp,
tabsApi,
sent,
fireLifecycle(
name: string,
frameId = opts?.navigateFrameId ?? "frame-1",
loaderId = opts?.navigateLoaderId ?? "loader-after",
) {
const payload = { name, frameId, loaderId };
for (const listener of [...events]) listener({ tabId: 4 }, "Page.lifecycleEvent", payload);
},
fireFrameNavigated(frameId = opts?.navigateFrameId ?? "frame-1") {
const payload = { frame: { id: frameId, url: opts?.finalUrl ?? "https://example.com/" } };
for (const listener of [...events]) listener({ tabId: 4 }, "Page.frameNavigated", payload);
},
listeners: events,
};
}
describe("lifecycleAlreadyReached", () => {
it("maps document.readyState to CDP lifecycle phases", () => {
expect(lifecycleAlreadyReached("loading", "load")).toBe(false);
expect(lifecycleAlreadyReached("loading", "DOMContentLoaded")).toBe(false);
expect(lifecycleAlreadyReached("loading", "commit")).toBe(true);
expect(lifecycleAlreadyReached("interactive", "DOMContentLoaded")).toBe(true);
expect(lifecycleAlreadyReached("interactive", "load")).toBe(false);
expect(lifecycleAlreadyReached("complete", "load")).toBe(true);
expect(lifecycleAlreadyReached("complete", "networkIdle")).toBe(false);
});
});
describe("lifecycleMeetsOrExceeds", () => {
it("treats later lifecycle phases as satisfying an earlier wait_until", () => {
expect(lifecycleMeetsOrExceeds("load", "DOMContentLoaded")).toBe(true);
expect(lifecycleMeetsOrExceeds("DOMContentLoaded", "load")).toBe(false);
expect(lifecycleMeetsOrExceeds("networkIdle", "load")).toBe(true);
});
it("does not treat paint events as satisfying load", () => {
expect(lifecycleMeetsOrExceeds("firstPaint", "load")).toBe(false);
expect(lifecycleMeetsOrExceeds("firstContentfulPaint", "load")).toBe(false);
});
});
describe("eventLoaderIsRelevant", () => {
it("rejects events from the pre-navigation loader", () => {
expect(
eventLoaderIsRelevant("loader-before", { beforeLoaderId: "loader-before" }),
).toBe(false);
expect(
eventLoaderIsRelevant("loader-after", {
beforeLoaderId: "loader-before",
loaderId: "loader-after",
}),
).toBe(true);
});
});
describe("shouldTrustReadyStateProbe", () => {
it("allows passive probes on a settled complete document", () => {
expect(
shouldTrustReadyStateProbe("complete", "load", {
mode: "passive",
beforeReadyState: "complete",
sawRelevantLifecycle: false,
}),
).toBe(true);
});
it("rejects after-navigation complete→complete with no lifecycle events", () => {
expect(
shouldTrustReadyStateProbe("complete", "load", {
mode: "after-navigation",
beforeReadyState: "complete",
sawRelevantLifecycle: false,
}),
).toBe(false);
});
it("allows after-navigation probe when lifecycle events were observed", () => {
expect(
shouldTrustReadyStateProbe("complete", "load", {
mode: "after-navigation",
beforeReadyState: "complete",
sawRelevantLifecycle: true,
}),
).toBe(true);
});
});
describe("handleNavigate", () => {
it("defaults wait_until to load and resolves on the load lifecycle", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ finalUrl: "https://example.com/landing" });
const navP = handleNavigate(
sm,
{ session_id: "aa11", url: "https://example.com/landing" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
// Wait for Page.navigate to register the listener.
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load");
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tab_id).toBe(4);
expect(res.url).toBe("https://example.com/landing");
expect(res.final_url).toBe("https://example.com/landing");
expect(res.reached).toBe("load");
// Listener cleaned up after success.
expect(fake.listeners.length).toBe(0);
});
it("registers lifecycle listeners before Page.navigate is sent", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
finalUrl: "https://example.com/fast",
fireLifecycleDuringNavigate: "load",
});
const res = await handleNavigate(
sm,
{
session_id: "aa11",
url: "https://example.com/fast",
wait_until: "load",
timeout_ms: 20,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
expect(fake.listeners.length).toBe(0);
});
it("does not treat early subframe lifecycle as direct navigate completion", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
fireLifecycleDuringNavigate: "load",
fireLifecycleDuringNavigateFrameId: "child-frame",
navigateLoaderId: "loader-after",
});
const res = await handleNavigate(
sm,
{
session_id: "aa11",
url: "https://example.com/",
wait_until: "load",
timeout_ms: 20,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("timeout");
expect(res.error_text).toMatch(/timed out/);
expect(fake.listeners.length).toBe(0);
});
it("does not complete navigate on stale loader lifecycle with readyState complete", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
readyState: "complete",
beforeLoaderId: "loader-before",
navigateLoaderId: "loader-after",
fireLifecycleDuringNavigate: "load",
fireLifecycleDuringNavigateLoaderId: "loader-before",
});
const res = await handleNavigate(
sm,
{
session_id: "aa11",
url: "https://example.com/",
wait_until: "load",
timeout_ms: 20,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("timeout");
});
it("ignores stale loader lifecycle and accepts the new navigation loader", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
beforeLoaderId: "loader-before",
navigateLoaderId: "loader-after",
fireLifecycleDuringNavigate: "load",
fireLifecycleDuringNavigateLoaderId: "loader-before",
});
const navP = handleNavigate(
sm,
{
session_id: "aa11",
url: "https://example.com/",
wait_until: "load",
timeout_ms: 1_000,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load", "frame-1", "loader-after");
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
});
it("returns reached=timeout when no matching lifecycle fires", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
const navP = handleNavigate(
sm,
{
session_id: "aa11",
url: "https://example.com/",
wait_until: "load",
timeout_ms: 20,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
// Fire an earlier lifecycle so the handler can record "last reached"
// but still time out before `load`.
fake.fireLifecycle("commit");
fake.fireLifecycle("DOMContentLoaded");
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("timeout");
expect(res.error_text).toMatch(/timed out/);
// No leftover listener after timeout.
expect(fake.listeners.length).toBe(0);
});
it("aborts when the AbortSignal is fired", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
const abort = new AbortController();
const navP = handleNavigate(
sm,
{
session_id: "aa11",
url: "https://example.com/",
wait_until: "load",
timeout_ms: 5_000,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
await new Promise((r) => setTimeout(r, 5));
abort.abort();
const res = await navP;
expect(res).toMatchObject({ code: "cancelled" });
expect(fake.listeners.length).toBe(0);
});
it("resolves wait_until=domcontentloaded when only load fires during navigate", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
fireLifecycleDuringNavigate: "load",
});
const res = await handleNavigate(
sm,
{
session_id: "aa11",
url: "https://example.com/",
wait_until: "domcontentloaded",
timeout_ms: 1_000,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("domcontentloaded");
});
it("treats Page.frameNavigated as wait_until=commit", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
const navP = handleNavigate(
sm,
{
session_id: "aa11",
url: "https://example.com/",
wait_until: "commit",
timeout_ms: 1_000,
},
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireFrameNavigated();
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("commit");
expect(fake.listeners.length).toBe(0);
});
});
describe("handleNavigateBack / Forward", () => {
it("navigates to the previous history entry", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
historyIndex: 1,
historyEntries: [
{ id: 11, url: "https://a.example/" },
{ id: 12, url: "https://b.example/" },
],
});
const navP = handleNavigateBack(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load");
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
const navigateCall = fake.sent.find((c) => c.method === "Page.navigateToHistoryEntry");
expect(navigateCall?.params).toEqual({ entryId: 11 });
expect(res.previous_url).toBe("https://b.example/");
});
it("returns invalid_params when there is no previous history", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ historyIndex: 0 });
const res = await handleNavigateBack(
sm,
{ session_id: "aa11" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "invalid_params", message: /no previous/i });
});
it("navigateForward advances by +1 history entry", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
historyIndex: 0,
historyEntries: [
{ id: 11, url: "https://a.example/" },
{ id: 12, url: "https://b.example/" },
],
});
const navP = handleNavigateForward(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load");
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
const navigateCall = fake.sent.find((c) => c.method === "Page.navigateToHistoryEntry");
expect(navigateCall?.params).toEqual({ entryId: 12 });
});
it("navigateForward returns invalid_params at the head of the history", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
historyIndex: 1,
historyEntries: [
{ id: 11, url: "https://a.example/" },
{ id: 12, url: "https://b.example/" },
],
});
const res = await handleNavigateForward(
sm,
{ session_id: "aa11" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "invalid_params", message: /no forward/i });
});
});
describe("handleReload", () => {
it("resolves when reload finishes before the waiter runs (lifecycle during reload)", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
readyState: "complete",
beforeLoaderId: "loader-before",
navigateLoaderId: "loader-after",
fireLifecycleDuringReload: "load",
});
const res = await handleReload(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
expect(fake.listeners.length).toBe(0);
});
it("resolves via readyState probe when reload settles without lifecycle events", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ readyStateSequence: ["loading", "complete"] });
const res = await handleReload(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
expect(fake.listeners.length).toBe(0);
});
it("issues Page.reload with ignoreCache=false by default", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
const navP = handleReload(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load");
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
const reloadCall = fake.sent.find((c) => c.method === "Page.reload");
expect(reloadCall?.params).toEqual({ ignoreCache: false });
expect(res.reached).toBe("load");
});
it("hard=true sets ignoreCache", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
const navP = handleReload(
sm,
{ session_id: "aa11", hard: true, wait_until: "load", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load");
await navP;
const reloadCall = fake.sent.find((c) => c.method === "Page.reload");
expect(reloadCall?.params).toEqual({ ignoreCache: true });
});
});
@@ -0,0 +1,669 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import {
type CdpAxNode,
handleGetHtml,
handleScreenshot,
handleSnapshot,
parsePngDimensions,
renderAxTree,
type ScreenshotDeps,
shouldRender,
stripDataUrlPrefix,
} from "../observation";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
// 1x1 transparent PNG, base64-encoded. Width 1, height 1.
const TINY_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==";
function makeScreenshotDeps(
opts: {
cdp?: CdpRunner;
get?: ScreenshotDeps["tabsApi"]["get"];
query?: ScreenshotDeps["tabsApi"]["query"];
captureVisibleTab?: ScreenshotDeps["captureApi"]["captureVisibleTab"];
} = {},
): ScreenshotDeps {
const get =
opts.get ??
vi.fn(async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab);
const query =
opts.query ??
vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]);
const captureVisibleTab =
opts.captureVisibleTab ?? vi.fn(async () => `data:image/png;base64,${TINY_PNG}`);
const tabsApi = { get, query };
return {
cdp: opts.cdp,
tabsApi,
captureApi: { ...tabsApi, captureVisibleTab },
};
}
function makeFakeCdp(handlers: Record<string, (params?: object) => unknown>) {
const sent: Array<{ method: string; params?: object }> = [];
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
sent.push({ method, params });
const handler = handlers[method];
if (!handler) throw new Error(`unexpected CDP call ${method}`);
return handler(params);
});
return { cdp: { send, trackSessionTab: vi.fn() } as unknown as CdpRunner, sent };
}
describe("stripDataUrlPrefix", () => {
it("strips well-formed image/* data URLs", () => {
expect(stripDataUrlPrefix(`data:image/png;base64,${TINY_PNG}`)).toBe(TINY_PNG);
expect(stripDataUrlPrefix(`data:image/jpeg;base64,abc`)).toBe("abc");
});
it("leaves plain base64 untouched", () => {
expect(stripDataUrlPrefix(TINY_PNG)).toBe(TINY_PNG);
});
});
describe("parsePngDimensions", () => {
it("parses width/height from the IHDR chunk", () => {
expect(parsePngDimensions(TINY_PNG)).toEqual({ width: 1, height: 1 });
});
it("returns null on non-PNG input", () => {
expect(parsePngDimensions("not-a-png-payload-just-random-base64-text-zzzzzzzzz")).toBeNull();
});
});
describe("handleScreenshot", () => {
const emptyGet = vi.fn(async () => {
throw new Error("tab not found");
});
it("captures the Agent Window's active tab when tab_id is omitted", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const capture = vi.fn(async (_w: number) => `data:image/png;base64,${TINY_PNG}`);
const get = vi.fn();
const query = vi.fn(async (_q: chrome.tabs.QueryInfo) => [
{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab,
]);
const res = await handleScreenshot(
sm,
{ session_id: "aa11" },
makeScreenshotDeps({ captureVisibleTab: capture, get, query }),
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tab_id).toBe(7);
expect(res.image_base64).toBe(TINY_PNG);
expect(res.format).toBe("png");
expect(res.width).toBe(1);
expect(res.height).toBe(1);
expect(capture).toHaveBeenCalledWith(100, { format: "png" });
expect(get).not.toHaveBeenCalled();
});
it("returns not_found when Agent Window has no active tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const res = await handleScreenshot(
sm,
{ session_id: "aa11" },
makeScreenshotDeps({ captureVisibleTab: vi.fn(), get: emptyGet, query: vi.fn(async () => []) }),
);
expect(res).toMatchObject({ code: "not_found" });
});
it("captures an explicit active user tab in its real window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const capture = vi.fn(async (_w: number) => `data:image/png;base64,${TINY_PNG}`);
const get = vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab);
const res = await handleScreenshot(
sm,
{ session_id: "aa11", tab_id: 9 },
makeScreenshotDeps({ captureVisibleTab: capture, get, query: vi.fn() }),
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tab_id).toBe(9);
expect(capture).toHaveBeenCalledWith(200, { format: "png" });
});
it("rejects screenshots for inactive explicit tabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const capture = vi.fn(async (_w: number) => `data:image/png;base64,${TINY_PNG}`);
const get = vi.fn(async () => ({ id: 9, windowId: 100, active: false }) as chrome.tabs.Tab);
const res = await handleScreenshot(
sm,
{ session_id: "aa11", tab_id: 9 },
makeScreenshotDeps({ captureVisibleTab: capture, get, query: vi.fn() }),
);
expect(res).toMatchObject({
code: "invalid_params",
message: /not active/,
data: { reason: "tab_not_active" },
});
expect(capture).not.toHaveBeenCalled();
});
it("hides other sessions' Agent Window tabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) });
await sm.start("aa11");
await sm.start("bb22");
const capture = vi.fn(async (_w: number) => `data:image/png;base64,${TINY_PNG}`);
const get = vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab);
const res = await handleScreenshot(
sm,
{ session_id: "aa11", tab_id: 9 },
makeScreenshotDeps({ captureVisibleTab: capture, get, query: vi.fn() }),
);
expect(res).toMatchObject({ code: "not_found" });
expect(capture).not.toHaveBeenCalled();
});
it("propagates capture errors as cdp_failed", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const capture = vi.fn(async () => {
throw new Error("captureVisibleTab refused");
});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", tab_id: 9 },
makeScreenshotDeps({
captureVisibleTab: capture,
get: vi.fn(async () => ({ id: 9, windowId: 100, active: true }) as chrome.tabs.Tab),
query: vi.fn(),
}),
);
expect(res).toMatchObject({ code: "cdp_failed", message: /captureVisibleTab refused/ });
});
it("captures a clipped PNG when ref is given", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, { tabId: 7 });
const { cdp, sent } = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
"Page.captureScreenshot": () => ({ data: TINY_PNG }),
});
const captureVisibleTab = vi.fn();
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e5", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab,
}),
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tab_id).toBe(7);
expect(res.image_base64).toBe(TINY_PNG);
expect(res.width).toBe(1);
expect(res.height).toBe(1);
expect(captureVisibleTab).not.toHaveBeenCalled();
const clip = (
sent.find((c) => c.method === "Page.captureScreenshot")?.params as {
clip?: { x: number; y: number; width: number; height: number };
}
)?.clip;
expect(clip).toMatchObject({ x: 10, y: 20, width: 100, height: 40 });
});
it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const { cdp } = makeFakeCdp({});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e99", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: true }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
expect(res).toMatchObject({ code: "not_found", data: { reason: "ref_not_found" } });
expect(cdp.send).not.toHaveBeenCalled();
});
it("accepts bare eN ref form", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e5", 999, { tabId: 7 });
const { cdp, sent } = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }),
"Page.captureScreenshot": () => ({ data: TINY_PNG }),
});
const captureVisibleTab = vi.fn();
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "e5", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: false }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab,
}),
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.image_base64).toBe(TINY_PNG);
expect(captureVisibleTab).not.toHaveBeenCalled();
expect(sent.some((c) => c.method === "Page.captureScreenshot")).toBe(true);
});
it("returns not_found when ref belongs to another tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e7", 4242, { tabId: 4 });
const { cdp } = makeFakeCdp({});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", tab_id: 5, ref: "@e7" },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 5, windowId: 100, active: true }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
expect(res).toMatchObject({ code: "not_found", data: { reason: "ref_not_found" } });
expect(cdp.send).not.toHaveBeenCalled();
});
it("returns permission_denied when element has no visible box", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 555, { tabId: 7 });
const { cdp } = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [] }),
"DOM.getBoxModel": () => {
throw new Error("no box");
},
});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e1", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: true }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
expect(res).toMatchObject({
code: "permission_denied",
message: /not visible/i,
data: { reason: "element_not_visible" },
});
});
it("propagates Page.captureScreenshot errors as cdp_failed", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e2", 888, { tabId: 7 });
const { cdp } = makeFakeCdp({
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getContentQuads": () => ({ quads: [[0, 0, 50, 0, 50, 50, 0, 50]] }),
"Page.captureScreenshot": () => {
throw new Error("capture refused");
},
});
const res = await handleScreenshot(
sm,
{ session_id: "aa11", ref: "@e2", tab_id: 7 },
makeScreenshotDeps({
cdp,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: true }) as chrome.tabs.Tab),
query: vi.fn(),
captureVisibleTab: vi.fn(),
}),
);
expect(res).toMatchObject({ code: "cdp_failed", message: /capture refused/ });
});
});
// ---------------------------------------------------------------------------
// shouldRender / renderAxTree
// ---------------------------------------------------------------------------
describe("shouldRender", () => {
it("rejects ignored or generic nodes", () => {
expect(
shouldRender({ nodeId: "1", ignored: true, role: { type: "role", value: "button" } }),
).toBe(false);
expect(shouldRender({ nodeId: "1", role: { type: "role", value: "generic" } })).toBe(false);
expect(shouldRender({ nodeId: "1", role: { type: "role", value: "" } })).toBe(false);
});
it("accepts interactive roles even without a name", () => {
expect(shouldRender({ nodeId: "1", role: { type: "role", value: "button" } })).toBe(true);
expect(shouldRender({ nodeId: "1", role: { type: "role", value: "textbox" } })).toBe(true);
});
it("accepts named structural roles", () => {
expect(
shouldRender({
nodeId: "1",
role: { type: "role", value: "heading" },
name: { type: "computedString", value: "Hello" },
}),
).toBe(true);
});
});
describe("renderAxTree", () => {
const root: CdpAxNode = {
nodeId: "1",
role: { type: "role", value: "RootWebArea" },
name: { type: "computedString", value: "Example" },
backendDOMNodeId: 100,
childIds: ["2", "3"],
};
const heading: CdpAxNode = {
nodeId: "2",
parentId: "1",
role: { type: "role", value: "heading" },
name: { type: "computedString", value: "Welcome" },
backendDOMNodeId: 200,
};
const submit: CdpAxNode = {
nodeId: "3",
parentId: "1",
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Submit" },
backendDOMNodeId: 300,
};
it("renders an indented tree with sequential @eN refs", () => {
const out = renderAxTree([root, heading, submit]);
const lines = out.text.split("\n");
expect(lines).toHaveLength(3);
expect(lines[0]).toMatch(/^@e1 RootWebArea "Example"/);
expect(lines[1]).toMatch(/^ {2}@e2 heading "Welcome"/);
expect(lines[2]).toMatch(/^ {2}@e3 button "Submit"/);
expect(out.refs.map((r) => r.ref)).toEqual(["e1", "e2", "e3"]);
expect(out.refs.map((r) => r.backendNodeId)).toEqual([100, 200, 300]);
expect(out.truncated).toBe(false);
});
it("skips ignored / generic nodes", () => {
const ignored: CdpAxNode = {
nodeId: "4",
parentId: "1",
role: { type: "role", value: "generic" },
backendDOMNodeId: 400,
};
const out = renderAxTree([root, heading, ignored, submit]);
expect(out.refs.map((r) => r.backendNodeId)).toEqual([100, 200, 300]);
expect(out.text).not.toContain("generic");
});
it("truncates when max_tokens is exceeded", () => {
const out = renderAxTree([root, heading, submit], { maxTokens: 10 });
expect(out.truncated).toBe(true);
const renderedRefs = new Set(Array.from(out.text.matchAll(/@(e\d+)/g), (m) => m[1]));
expect(out.refs.every((r) => renderedRefs.has(r.ref))).toBe(true);
expect(out.refs.length).toBeLessThan(3);
});
it("flags truncation when max_depth limits the walk", () => {
const deepRoot: CdpAxNode = {
nodeId: "1",
role: { type: "role", value: "RootWebArea" },
name: { type: "computedString", value: "Doc" },
backendDOMNodeId: 100,
childIds: ["2", "4"],
};
const middle: CdpAxNode = {
nodeId: "2",
parentId: "1",
role: { type: "role", value: "section" },
name: { type: "computedString", value: "Content" },
backendDOMNodeId: 200,
childIds: ["3"],
};
const deep: CdpAxNode = {
nodeId: "3",
parentId: "2",
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Deep" },
backendDOMNodeId: 300,
};
const sibling: CdpAxNode = {
nodeId: "4",
parentId: "1",
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Sibling" },
backendDOMNodeId: 400,
};
const out = renderAxTree([deepRoot, middle, deep, sibling], { maxDepth: 1 });
expect(out.truncated).toBe(true);
// Root + middle render; deep is past max_depth, but the root's
// same-level sibling still renders.
expect(out.refs.map((r) => r.backendNodeId)).toEqual([100, 200, 400]);
});
});
describe("handleSnapshot", () => {
function makeDeps(nodes: CdpAxNode[]) {
const sendImpl = async (_tabId: number, method: string, _params?: object) => {
if (method === "Accessibility.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes };
throw new Error(`unexpected CDP method ${method}`);
};
const send = vi.fn(sendImpl);
const trackSessionTab = vi.fn();
const cdp = {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
trackSessionTab,
};
return {
cdp,
tabsApi: {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
},
send,
trackSessionTab,
};
}
it("populates the session's RefStore with backendNodeIds", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const root: CdpAxNode = {
nodeId: "1",
role: { type: "role", value: "RootWebArea" },
name: { type: "computedString", value: "Example" },
backendDOMNodeId: 100,
childIds: ["2"],
};
const button: CdpAxNode = {
nodeId: "2",
parentId: "1",
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Click" },
backendDOMNodeId: 200,
};
const deps = makeDeps([root, button]);
const res = await handleSnapshot(sm, { session_id: "aa11" }, deps);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.ref_count).toBe(2);
expect(res.tab_id).toBe(4);
expect(res.text).toContain("@e1");
expect(res.text).toContain('@e2 button "Click"');
expect(deps.trackSessionTab).toHaveBeenCalledWith("aa11", 4);
expect(ctx.refStore.resolve("e1")).toBe(100);
expect(ctx.refStore.resolve("e1", { tabId: 4 })).toBe(100);
expect(ctx.refStore.resolve("e1", { tabId: 5 })).toBeNull();
expect(ctx.refStore.resolveEntry("e1")).toMatchObject({ backendNodeId: 100, tabId: 4 });
expect(ctx.refStore.resolve("e2")).toBe(200);
});
it("resets the RefStore on every fresh snapshot", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e9", 9999); // stale entry from a previous snapshot
const deps = makeDeps([
{
nodeId: "1",
role: { type: "role", value: "RootWebArea" },
name: { type: "computedString", value: "Doc" },
backendDOMNodeId: 1,
},
]);
await handleSnapshot(sm, { session_id: "aa11" }, deps);
expect(ctx.refStore.resolve("e9")).toBeNull();
expect(ctx.refStore.resolve("e1")).toBe(1);
});
it("surfaces CDP failures as cdp_failed", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const sendImpl = async () => {
throw new Error("debugger detached");
};
const send = vi.fn(sendImpl);
const deps = {
cdp: {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
},
tabsApi: {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
},
};
const res = await handleSnapshot(sm, { session_id: "aa11" }, deps);
expect(res).toMatchObject({ code: "cdp_failed", message: /debugger detached/ });
});
});
// ---------------------------------------------------------------------------
// handleGetHtml
// ---------------------------------------------------------------------------
describe("handleGetHtml", () => {
function makeDeps(handlers: Record<string, (params: unknown) => unknown>) {
const sendImpl = async (_tabId: number, method: string, params?: object) => {
const h = handlers[method];
if (!h) throw new Error(`unexpected CDP call ${method}`);
return h(params);
};
const send = vi.fn(sendImpl);
const trackSessionTab = vi.fn();
// Cast to the generic CdpRunner.send signature for handleGetHtml.
const cdp = {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
trackSessionTab,
};
return {
cdp,
tabsApi: {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
},
send,
trackSessionTab,
};
}
it("fetches the document HTML when no ref is given", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const html = "<html><body>hi</body></html>";
const deps = makeDeps({
"DOM.getDocument": () => ({ root: { nodeId: 1 } }),
"DOM.getOuterHTML": () => ({ outerHTML: html }),
});
const res = await handleGetHtml(sm, { session_id: "aa11" }, deps);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.html).toBe(html);
expect(res.truncated).toBe(false);
expect(res.byte_size).toBe(html.length); // ASCII bytes = code-units
expect(res.tab_id).toBe(4);
expect(deps.trackSessionTab).toHaveBeenCalledWith("aa11", 4);
expect(deps.send).toHaveBeenCalledWith(4, "DOM.getDocument", { depth: 0 });
});
it("scopes to a backendNodeId when given a ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e7", 4242, { tabId: 4 });
const deps = makeDeps({
"DOM.getOuterHTML": (params) => {
expect(params).toEqual({ backendNodeId: 4242 });
return { outerHTML: "<button>x</button>" };
},
});
const res = await handleGetHtml(sm, { session_id: "aa11", ref: "@e7" }, deps);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.html).toBe("<button>x</button>");
// Never called DOM.getDocument when ref is provided.
expect(deps.send).toHaveBeenCalledTimes(1);
});
it("returns not_found when a ref belongs to another tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e7", 4242, { tabId: 4 });
const deps = makeDeps({});
const res = await handleGetHtml(sm, { session_id: "aa11", tab_id: 5, ref: "@e7" }, deps);
expect(res).toMatchObject({ code: "not_found", data: { reason: "ref_not_found" } });
expect(deps.send).not.toHaveBeenCalled();
});
it("returns not_found when ref is unknown to the session", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const res = await handleGetHtml(sm, { session_id: "aa11", ref: "e99" }, makeDeps({}));
expect(res).toMatchObject({ code: "not_found", data: { reason: "ref_not_found" } });
});
it("truncates oversized HTML and reports the original byte_size", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const big = "x".repeat(10_000);
const deps = makeDeps({
"DOM.getDocument": () => ({ root: { nodeId: 1 } }),
"DOM.getOuterHTML": () => ({ outerHTML: big }),
});
const res = await handleGetHtml(sm, { session_id: "aa11", max_bytes: 100 }, deps);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.truncated).toBe(true);
expect(res.byte_size).toBe(10_000);
expect(res.html.length).toBe(100);
});
});
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { handleSessionStop } from "../session";
import { type AgentOverlayResetApi, type ChromeWindowsApi, type TabMutationApi } from "../tabs";
function fakeAgentWindow(ids: number[]) {
let i = 0;
const create = vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
});
const remove = vi.fn(async () => {});
const ensureActiveTab = vi.fn(async () => {});
return { create, remove, ensureActiveTab };
}
interface FakeState {
tabs: Map<number, chrome.tabs.Tab>;
windowsClosed: Set<number>;
moves: Array<{ tabId: number; windowId: number; index: number }>;
}
function makeApis(
state: FakeState,
opts?: { moveThrowsFor?: Set<number> },
): {
tabs: TabMutationApi;
windows: ChromeWindowsApi;
} {
const tabs: TabMutationApi = {
create: vi.fn(),
remove: vi.fn(async () => {}),
update: vi.fn(async (_id, _p) => undefined),
get: vi.fn(async (id) => {
const t = state.tabs.get(id);
if (!t) throw new Error(`tab ${id} not found`);
return t;
}),
move: vi.fn(async (id, props) => {
if (opts?.moveThrowsFor?.has(id)) throw new Error("simulated move failure");
state.moves.push({
tabId: id,
windowId: typeof props.windowId === "number" ? props.windowId : -1,
index: typeof props.index === "number" ? props.index : 0,
});
const t = state.tabs.get(id);
if (t && typeof props.windowId === "number") {
(t as { windowId?: number }).windowId = props.windowId;
}
return t!;
}),
};
const windows: ChromeWindowsApi = {
get: vi.fn(async (windowId: number) => {
if (state.windowsClosed.has(windowId)) {
throw new Error(`window ${windowId} closed`);
}
return { id: windowId } as chrome.windows.Window;
}),
getLastFocused: vi.fn(async () => ({ id: 500 }) as chrome.windows.Window),
create: vi.fn(async () => ({ id: 999 }) as chrome.windows.Window),
};
return { tabs, windows };
}
describe("handleSessionStop with auto-return", () => {
it("returns every borrowed tab and closes the Agent Window in the right order", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(1, { tabId: 1, originalWindowId: 200, originalIndex: 0 });
ctx.borrowedTabs.set(2, { tabId: 2, originalWindowId: 200, originalIndex: 1 });
ctx.borrowedTabs.set(3, { tabId: 3, originalWindowId: 201, originalIndex: 2 });
const state: FakeState = {
tabs: new Map([
[1, { id: 1, windowId: 100 } as chrome.tabs.Tab],
[2, { id: 2, windowId: 100 } as chrome.tabs.Tab],
[3, { id: 3, windowId: 100 } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const cdp = { detachSession: vi.fn(async () => {}) };
const order: string[] = [];
aw.remove.mockImplementation(async () => {
order.push("remove-window");
});
cdp.detachSession.mockImplementation(async () => {
order.push("cdp-detach");
});
// Wrap move to record when each tab was moved.
const baseMove = tabs.move;
tabs.move = vi.fn(async (id: number, p: chrome.tabs.MoveProperties) => {
order.push(`move-${id}`);
return baseMove(id, p);
}) as unknown as TabMutationApi["move"];
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ cdp, tabManagement: { tabs, windows } },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.returned_tab_ids?.sort((a, b) => a - b)).toEqual([1, 2, 3]);
expect(res.return_failures).toBeUndefined();
expect(ctx.borrowedTabs.size).toBe(0);
expect(sm.has("aa11")).toBe(false);
// Order: every tab move happens before cdp.detach and window remove.
const detachIdx = order.indexOf("cdp-detach");
const removeIdx = order.indexOf("remove-window");
for (const id of [1, 2, 3]) {
const moveIdx = order.indexOf(`move-${id}`);
expect(moveIdx).toBeGreaterThanOrEqual(0);
expect(moveIdx).toBeLessThan(detachIdx);
expect(moveIdx).toBeLessThan(removeIdx);
}
expect(detachIdx).toBeLessThan(removeIdx);
});
it("resets agent overlays for tabs returned during session_stop auto-cleanup", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 3 });
const state: FakeState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const agentOverlayReset = {
resetAgentOverlays: vi.fn(async () => {}),
} satisfies AgentOverlayResetApi;
const res = await handleSessionStop(sm, { session_id: "aa11" }, {
tabManagement: { tabs, windows, agentOverlayReset },
});
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(agentOverlayReset.resetAgentOverlays).toHaveBeenCalledWith(7, "aa11");
});
it("falls back when the original window is gone but still completes stop", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 3 });
const state: FakeState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
windowsClosed: new Set([200]),
moves: [],
};
const { tabs, windows } = makeApis(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows } },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.returned_tab_ids).toEqual([7]);
expect(state.moves[0].windowId).toBe(500); // lastFocused fallback
expect(sm.has("aa11")).toBe(false);
});
it("keeps the session open when any borrowed tab cannot be returned", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(1, { tabId: 1, originalWindowId: 200, originalIndex: 0 });
ctx.borrowedTabs.set(2, { tabId: 2, originalWindowId: 200, originalIndex: 1 });
const state: FakeState = {
tabs: new Map([
[1, { id: 1, windowId: 100 } as chrome.tabs.Tab],
[2, { id: 2, windowId: 100 } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows } },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
expect(res.returned_tab_ids).toEqual([2]);
expect(sm.has("aa11")).toBe(true);
expect(ctx.borrowedTabs.has(1)).toBe(true);
expect(ctx.borrowedTabs.has(2)).toBe(false);
expect(aw.remove).not.toHaveBeenCalled();
});
it("clears the RefStore before window teardown", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
// Insert a fake ref so we can verify clear() ran.
ctx.refStore.set("e1", 123, { tabId: 7 });
const state: FakeState = {
tabs: new Map(),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
expect(ctx.refStore.isEmpty()).toBe(true);
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { lookupSession } from "../shared";
function fakeAgentWindow() {
return {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
describe("lookupSession", () => {
it("returns invalid_params when session_id is missing or not a string", () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
expect(lookupSession(sm, {}, "tool.test")).toMatchObject({
code: "invalid_params",
message: "tool.test requires session_id",
});
expect(lookupSession(sm, { session_id: "" }, "tool.test")).toMatchObject({
code: "invalid_params",
});
expect(
lookupSession(sm, { session_id: 42 as unknown as string }, "tool.test"),
).toMatchObject({
code: "invalid_params",
});
});
it("returns not_found for unknown session_id", () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
expect(lookupSession(sm, { session_id: "zzzz" }, "tool.test")).toMatchObject({
code: "not_found",
message: "session zzzz unknown",
});
});
it("returns SessionContext when the session exists", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
const result = lookupSession(sm, { session_id: "aa11" }, "tool.test");
expect(result).toBe(ctx);
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { lookupSnapshotRef, resolveSnapshotRef } from "../snapshot-ref";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
},
remove: async () => {},
ensureActiveTab: async () => {},
};
}
describe("lookupSnapshotRef", () => {
it("resolves @e3 and e3 to the same backendNodeId", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual({
backendNodeId: 1234,
refKey: "e3",
});
expect(lookupSnapshotRef(ctx, "e3", 4)).toEqual({
backendNodeId: 1234,
refKey: "e3",
});
});
it("returns null for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
expect(lookupSnapshotRef(ctx, "@e99", 4)).toBeNull();
});
it("returns null when ref belongs to another tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
expect(lookupSnapshotRef(ctx, "@e3", 5)).toBeNull();
});
});
describe("resolveSnapshotRef", () => {
it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const res = resolveSnapshotRef(ctx, "@e99", 4);
expect(res).toMatchObject({
code: "not_found",
data: { reason: "ref_not_found" },
message: "ref @e99 unknown for tab 4 in session aa11",
});
});
it("returns not_found when ref belongs to another tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e7", 4242, { tabId: 4 });
const res = resolveSnapshotRef(ctx, "@e7", 5);
expect(res).toMatchObject({
code: "not_found",
data: { reason: "ref_not_found" },
message: "ref @e7 unknown for tab 5 in session aa11",
});
});
it("preserves original ref string in error message for bare eN form", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const res = resolveSnapshotRef(ctx, "e99", 4);
expect(res).toMatchObject({
code: "not_found",
message: "ref e99 unknown for tab 4 in session aa11",
});
});
it("returns backendNodeId and refKey on success", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, { tabId: 4 });
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual({
backendNodeId: 1234,
refKey: "e3",
});
});
});
@@ -0,0 +1,732 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import {
type AgentOverlayResetApi,
type ChromeWindowsApi,
handleTabBorrow,
handleTabClose,
handleTabCreate,
handleTabList,
handleTabReturn,
handleTabSelect,
NEW_TAB_DEFAULT_URL,
returnBorrowedTab,
type TabMutationApi,
} from "../tabs";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake window ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
function fakeChromeTabsApi(tabs: Partial<chrome.tabs.Tab>[]) {
return {
query: vi.fn(async (_q: chrome.tabs.QueryInfo) =>
tabs.map((t, i) => ({ id: i + 1, ...t }) as chrome.tabs.Tab),
),
};
}
describe("handleTabList", () => {
it("returns invalid_params for unknown scope values", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([10]) });
await sm.start("aa11");
const res = await handleTabList(
sm,
{ session_id: "aa11", scope: "private" } as unknown as Parameters<typeof handleTabList>[1],
fakeChromeTabsApi([]),
);
expect(res).toMatchObject({ code: "invalid_params" });
});
it("default scope=all unions user and agent tabs when scope is omitted", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const tabs = fakeChromeTabsApi([
{ windowId: 100, url: "chrome://newtab/", title: "Agent New Tab", active: true },
{ windowId: 200, url: "https://example.com", title: "Example", active: true },
]);
const res = await handleTabList(sm, { session_id: "aa11" }, tabs);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tabs).toHaveLength(2);
expect(res.tabs.map((t) => t.scope).sort()).toEqual(["agent", "user"]);
});
it("scope=user excludes the requesting session's Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const tabs = fakeChromeTabsApi([
{ windowId: 100, url: "chrome://newtab/", title: "Agent New Tab", active: true },
{ windowId: 200, url: "https://example.com", title: "Example", active: true },
{ windowId: 200, url: "https://github.com", title: "GitHub", active: false },
]);
const res = await handleTabList(sm, { session_id: "aa11", scope: "user" }, tabs);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tabs).toHaveLength(2);
expect(res.tabs.every((t) => t.scope === "user")).toBe(true);
expect(res.tabs.map((t) => t.url)).toEqual(["https://example.com", "https://github.com"]);
});
it("scope=agent returns only the requesting session's Agent Window tabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const tabs = fakeChromeTabsApi([
{ windowId: 100, url: "chrome://newtab/", title: "Agent" },
{ windowId: 200, url: "https://example.com" },
]);
const res = await handleTabList(sm, { session_id: "aa11", scope: "agent" }, tabs);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tabs).toHaveLength(1);
expect(res.tabs[0]).toMatchObject({ scope: "agent", url: "chrome://newtab/" });
});
it("hides other sessions' Agent Window tabs from scope=all (cross-session isolation)", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
await sm.start("aa11"); // agent window 100
await sm.start("bb22"); // agent window 200
const tabs = fakeChromeTabsApi([
{ windowId: 100, url: "chrome://newtab/" }, // aa11's agent window
{ windowId: 200, url: "https://internal-tools/" }, // bb22's agent window — must be hidden from aa11
{ windowId: 300, url: "https://example.com" },
]);
const res = await handleTabList(sm, { session_id: "aa11", scope: "all" }, tabs);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tabs.map((t) => t.url).sort()).toEqual(["chrome://newtab/", "https://example.com"]);
});
});
// ---------------------------------------------------------------------------
// M8 helpers
// ---------------------------------------------------------------------------
interface FakeTabState {
tabs: Map<number, chrome.tabs.Tab>;
nextTabId: number;
windowsClosed: Set<number>;
}
function makeTabMutationApi(state: FakeTabState): {
api: TabMutationApi;
spies: {
create: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
get: ReturnType<typeof vi.fn>;
move: ReturnType<typeof vi.fn>;
};
} {
const create = vi.fn(async (props: chrome.tabs.CreateProperties) => {
const id = state.nextTabId++;
const tab = {
id,
windowId: props.windowId ?? 1,
url: props.url ?? "",
pendingUrl: props.url,
active: props.active ?? true,
index: props.index ?? state.tabs.size,
} as chrome.tabs.Tab;
state.tabs.set(id, tab);
return tab;
});
const remove = vi.fn(async (tabId: number) => {
state.tabs.delete(tabId);
});
const update = vi.fn(async (tabId: number, props: chrome.tabs.UpdateProperties) => {
const t = state.tabs.get(tabId);
if (!t) throw new Error(`update: tab ${tabId} not found`);
if (props.active !== undefined) (t as { active?: boolean }).active = props.active;
return t;
});
const get = vi.fn(async (tabId: number) => {
const t = state.tabs.get(tabId);
if (!t) throw new Error(`get: tab ${tabId} not found`);
return t;
});
const move = vi.fn(async (tabId: number, props: chrome.tabs.MoveProperties) => {
const t = state.tabs.get(tabId);
if (!t) throw new Error(`move: tab ${tabId} not found`);
if (typeof props.windowId === "number") {
if (state.windowsClosed.has(props.windowId)) {
throw new Error(`move: window ${props.windowId} closed`);
}
(t as { windowId?: number }).windowId = props.windowId;
}
if (typeof props.index === "number") {
(t as { index?: number }).index = props.index === -1 ? 999 : props.index;
}
return t;
});
return {
api: { create, remove, update, get, move },
spies: { create, remove, update, get, move },
};
}
function makeWindowsApi(
state: FakeTabState,
opts?: { lastFocused?: number | null; createWindowId?: number },
): {
api: ChromeWindowsApi;
spies: {
get: ReturnType<typeof vi.fn>;
lastFocused: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
} {
const get = vi.fn(async (windowId: number) => {
if (state.windowsClosed.has(windowId)) {
throw new Error(`window ${windowId} closed`);
}
return { id: windowId } as chrome.windows.Window;
});
const lastFocused = vi.fn(async () => {
if (opts?.lastFocused === null) {
throw new Error("no last focused window");
}
return { id: opts?.lastFocused ?? 500 } as chrome.windows.Window;
});
const create = vi.fn(async (_props: chrome.windows.CreateData) => {
const id = opts?.createWindowId ?? 999;
return { id } as chrome.windows.Window;
});
return { api: { get, getLastFocused: lastFocused, create }, spies: { get, lastFocused, create } };
}
describe("handleTabCreate", () => {
it("creates a tab inside the requesting session's Agent Window with the default URL", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api, spies } = makeTabMutationApi(state);
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(spies.create).toHaveBeenCalledWith({
windowId: 100,
url: NEW_TAB_DEFAULT_URL,
active: true,
});
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
});
it("forwards explicit url + index + active=false", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api, spies } = makeTabMutationApi(state);
const res = await handleTabCreate(
sm,
{
session_id: "aa11",
url: "https://example.com/",
active: false,
index: 2,
},
{ tabs: api },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(spies.create).toHaveBeenCalledWith({
windowId: 100,
url: "https://example.com/",
active: false,
index: 2,
});
expect(res.url).toBe("https://example.com/");
});
it("rejects negative index", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api } = makeTabMutationApi(state);
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
expect(res).toMatchObject({ code: "invalid_params" });
});
});
describe("handleTabClose", () => {
it("removes a tab inside the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = {
tabs: new Map([[5, { id: 5, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 5 }, { tabs: api });
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.tab_id).toBe(5);
expect(spies.remove).toHaveBeenCalledWith(5);
});
it("rejects closing a borrowed tab — caller must tab_return first", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(8, { tabId: 8, originalWindowId: 200, originalIndex: 3 });
const state: FakeTabState = {
tabs: new Map([[8, { id: 8, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 8 }, { tabs: api });
expect(res).toMatchObject({ code: "invalid_params" });
expect(spies.remove).not.toHaveBeenCalled();
});
it("refuses to close a tab outside the Agent Window with permission_denied", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = {
tabs: new Map([[9, { id: 9, windowId: 200 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 9 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
});
it("rejects when another session has borrowed the tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
await sm.start("aa11");
const ctxB = await sm.start("bb22");
ctxB.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 1 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 200 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "borrow_conflict" } });
});
});
describe("handleTabSelect", () => {
it("activates a tab inside the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = {
tabs: new Map([[3, { id: 3, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res).toEqual({ tab_id: 3, window_id: 100 });
expect(spies.update).toHaveBeenCalledWith(3, { active: true });
});
it("rejects activating a tab in a different session's Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
await sm.start("aa11");
await sm.start("bb22");
const state: FakeTabState = {
tabs: new Map([[3, { id: 3, windowId: 200 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
});
it("rejects a borrowed tab that is no longer in the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(3, { tabId: 3, originalWindowId: 200, originalIndex: 0 });
const state: FakeTabState = {
tabs: new Map([[3, { id: 3, windowId: 200 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
});
});
describe("handleTabBorrow", () => {
it("moves the tab into the Agent Window and records the original position", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 200, index: 4 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res).toEqual({
tab_id: 7,
original_window_id: 200,
original_index: 4,
agent_window_id: 100,
});
expect(ctx.borrowedTabs.get(7)).toEqual({
tabId: 7,
originalWindowId: 200,
originalIndex: 4,
});
expect(spies.move).toHaveBeenCalledWith(7, { windowId: 100, index: -1 });
expect(spies.update).toHaveBeenCalledWith(7, { active: true });
});
it("refuses to borrow a tab already inside the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100, index: 0 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
expect(res).toMatchObject({ code: "invalid_params" });
});
it("refuses to borrow a tab already held by another session", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
await sm.start("aa11");
const ctxB = await sm.start("bb22");
ctxB.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 1 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 200, index: 0 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "borrow_conflict" } });
});
it("allows only one concurrent borrow of the same tab across sessions", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
await sm.start("aa11");
await sm.start("bb22");
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 300, index: 1 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const [a, b] = await Promise.all([
handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api }),
handleTabBorrow(sm, { session_id: "bb22", tab_id: 7 }, { tabs: api }),
]);
const results = [a, b];
expect(results.filter((r) => !("code" in r))).toHaveLength(1);
expect(results.filter((r) => "code" in r && r.code === "permission_denied")).toHaveLength(1);
expect(sm.list().filter((ctx) => ctx.borrowedTabs.has(7))).toHaveLength(1);
});
it("refuses to borrow a tab inside another session's Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
await sm.start("aa11");
await sm.start("bb22");
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 200, index: 0 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
});
it("returns cancelled when the approver declines", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 200, index: 0 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const res = await handleTabBorrow(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, approveBorrow: async () => false },
);
expect(res).toMatchObject({ code: "cancelled" });
expect(ctx.borrowedTabs.has(7)).toBe(false);
expect(spies.move).not.toHaveBeenCalled();
});
it("passes confirm to the approver and treats approver errors as cancellation", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 200, index: 0 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const approveBorrow = vi.fn(async () => {
throw new Error("confirmation dismissed");
});
const res = await handleTabBorrow(
sm,
{ session_id: "aa11", tab_id: 7, confirm: false },
{ tabs: api, approveBorrow },
);
expect(approveBorrow).toHaveBeenCalledWith({ sessionId: "aa11", tabId: 7, confirm: false });
expect(res).toMatchObject({ code: "cancelled" });
expect(spies.move).not.toHaveBeenCalled();
});
});
describe("handleTabReturn", () => {
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100, index: 999 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi } = makeWindowsApi(state);
const res = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.returned_to_window_id).toBe(200);
expect(res.returned_to_index).toBe(4);
expect(res.fallback).toBeUndefined();
expect(ctx.borrowedTabs.has(7)).toBe(false);
expect(spies.move).toHaveBeenCalledWith(7, { windowId: 200, index: 4 });
});
it("resets agent overlays in a returned tab after a successful tab_return", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100, index: 999 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const { api: windowsApi } = makeWindowsApi(state);
const agentOverlayReset = {
resetAgentOverlays: vi.fn(async () => {}),
} satisfies AgentOverlayResetApi;
const res = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi, agentOverlayReset },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(agentOverlayReset.resetAgentOverlays).toHaveBeenCalledWith(7, "aa11");
});
it("falls back to the last focused normal window when the original is gone", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([200]),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, { lastFocused: 500 });
const res = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.returned_to_window_id).toBe(500);
expect(res.fallback).toBe(true);
expect(spies.move).toHaveBeenCalledWith(7, { windowId: 500, index: -1 });
expect(winSpies.create).not.toHaveBeenCalled();
});
it("creates a new window when no normal window remains", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([200]),
};
const { api } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
lastFocused: null,
createWindowId: 777,
});
const res = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.returned_to_window_id).toBe(777);
expect(res.fallback).toBe(true);
expect(winSpies.create).toHaveBeenCalledOnce();
});
it("falls back to a new window when getLastFocused only returns the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([200]),
};
const { api } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
lastFocused: 100,
createWindowId: 777,
});
const res = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.returned_to_window_id).toBe(777);
expect(winSpies.create).toHaveBeenCalledOnce();
});
it("does not fall back into another session's Agent Window (creates a new window instead)", async () => {
// Regression: a returned borrowed tab whose original window is gone
// must never be parked in *another* session's Agent Window. Agent
// Windows are `type: "normal"`, so getLastFocused({windowTypes:
// ["normal"]}) can return session bb22's window (200). Without the
// isAgentWindowId guard the tab would be moved into window 200,
// letting bb22 write to it and destroying it when bb22 stops.
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
const ctx = await sm.start("aa11");
await sm.start("bb22"); // owns Agent Window 200
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([300]),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
lastFocused: 200, // session bb22's Agent Window
createWindowId: 777,
});
const res = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.returned_to_window_id).toBe(777);
expect(res.fallback).toBe(true);
expect(winSpies.create).toHaveBeenCalledOnce();
expect(spies.move).not.toHaveBeenCalledWith(7, expect.objectContaining({ windowId: 200 }));
});
it("returnBorrowedTab honours an explicit isAgentWindowId predicate", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([300]),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
lastFocused: 555,
createWindowId: 777,
});
const res = await returnBorrowedTab(ctx, 7, {
tabs: api,
windows: windowsApi,
isAgentWindowId: (windowId) => windowId === 555,
});
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.toWindowId).toBe(777);
expect(res.fallback).toBe(true);
expect(winSpies.create).toHaveBeenCalledOnce();
expect(spies.move).not.toHaveBeenCalledWith(7, expect.objectContaining({ windowId: 555 }));
});
it("returns not_found when the tab is not borrowed by this session", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 50, windowsClosed: new Set() };
const { api } = makeTabMutationApi(state);
const { api: windowsApi } = makeWindowsApi(state);
const res = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 9999 },
{ tabs: api, windows: windowsApi },
);
expect(res).toMatchObject({ code: "not_found" });
});
it("returnBorrowedTab surfaces a chrome.tabs.move failure as cdp_failed", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
api.move = vi.fn(async () => {
throw new Error("simulated move failure");
});
const { api: windowsApi } = makeWindowsApi(state);
const res = await returnBorrowedTab(ctx, 7, { tabs: api, windows: windowsApi });
expect(res).toMatchObject({ code: "cdp_failed" });
});
it("falls back to a new window when moving back to the original position fails", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
spies.move.mockImplementationOnce(async () => {
throw new Error("original index no longer valid");
});
const { api: windowsApi } = makeWindowsApi(state, {
lastFocused: 100,
createWindowId: 777,
});
const res = await returnBorrowedTab(ctx, 7, { tabs: api, windows: windowsApi });
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.toWindowId).toBe(777);
expect(res.fallback).toBe(true);
expect(spies.move).toHaveBeenLastCalledWith(7, { windowId: 777, index: 0 });
});
});
@@ -0,0 +1,184 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import { handleWaitForNavigation } from "../waits";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
interface EventListener {
(source: chrome.debugger.Debuggee, method: string, params: unknown): void;
}
function makeFakeCdp(
opts: { windowId?: number; mainFrameId?: string; readyState?: string | null } = {},
) {
const events: EventListener[] = [];
const sent: Array<{ tabId: number; method: string; params?: object }> = [];
const sendImpl = async (tabId: number, method: string, params?: object) => {
sent.push({ tabId, method, params });
if (method === "Page.enable") return {};
if (method === "Page.setLifecycleEventsEnabled") return {};
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: opts.mainFrameId ?? "frame-1" } } };
}
if (method === "Runtime.enable") return {};
if (method === "Runtime.evaluate") {
if (opts.readyState === null) {
throw new Error("probe failed");
}
return { result: { value: opts.readyState ?? "loading" } };
}
throw new Error(`unexpected CDP call ${method}`);
};
const send = vi.fn(sendImpl);
const cdp: CdpRunner = {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
trackSessionTab: vi.fn(),
onEvent: vi.fn((handler: EventListener) => {
events.push(handler);
return {
dispose: () => {
const idx = events.indexOf(handler);
if (idx >= 0) events.splice(idx, 1);
},
};
}),
};
const windowId = opts.windowId ?? 100;
const tabsApi = {
get: vi.fn(async (tabId: number) => ({ id: tabId, windowId, active: true }) as chrome.tabs.Tab),
query: vi.fn(async () => [{ id: 4, windowId, active: true } as chrome.tabs.Tab]),
};
return {
cdp,
tabsApi,
sent,
fireLifecycle(name: string, tabId = 4, frameId = opts.mainFrameId ?? "frame-1") {
const payload = { name, frameId, loaderId: "loader-1" };
for (const listener of [...events]) listener({ tabId }, "Page.lifecycleEvent", payload);
},
listeners: events,
};
}
describe("handleWaitForNavigation", () => {
it("resolves immediately when the page is already past the requested lifecycle", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ readyState: "complete" });
const res = await handleWaitForNavigation(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 5_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
expect(fake.listeners.length).toBe(0);
});
it("resolves on the requested lifecycle event (defaults to load)", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
const p = handleWaitForNavigation(
sm,
{ session_id: "aa11", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load");
const res = await p;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
expect(res.tab_id).toBe(4);
expect(res.error_text).toBeUndefined();
expect(fake.listeners.length).toBe(0);
});
it("ignores matching lifecycle events from subframes", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ mainFrameId: "main-frame" });
const p = handleWaitForNavigation(
sm,
{ session_id: "aa11", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load", 4, "child-frame");
await new Promise((r) => setTimeout(r, 5));
expect(fake.listeners.length).toBe(1);
fake.fireLifecycle("load", 4, "main-frame");
const res = await p;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
expect(fake.listeners.length).toBe(0);
});
it("does not treat readyState complete as networkidle (must wait for event)", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({ readyState: "complete" });
const p = handleWaitForNavigation(
sm,
{ session_id: "aa11", wait_until: "networkidle", timeout_ms: 20 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
const res = await p;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("timeout");
});
it("returns cancelled immediately when the AbortSignal is already aborted", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp();
const abort = new AbortController();
abort.abort();
const res = await handleWaitForNavigation(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 5_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi, signal: abort.signal },
);
expect(res).toMatchObject({ code: "cancelled" });
// No CDP call should have been issued.
expect(fake.sent.length).toBe(0);
});
it("allows waiting on a borrowed tab moved inside the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 0 });
const fake = makeFakeCdp();
fake.tabsApi.get = vi.fn(
async () => ({ id: 7, windowId: 100, active: true }) as chrome.tabs.Tab,
);
const p = handleWaitForNavigation(
sm,
{ session_id: "aa11", tab_id: 7, timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load", 7);
const res = await p;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
expect(res.tab_id).toBe(7);
});
});
@@ -0,0 +1,591 @@
// ---------------------------------------------------------------------------
// requestBorrowConfirmation — picks the user-visible tab that should host the
// in-page borrow confirmation overlay, asks the user for approval, and also
// surfaces an OS-level Chrome notification so the user can spot the request
// even when the Agent Window has stolen focus.
// ---------------------------------------------------------------------------
export interface BorrowRequestMessage {
type: "borrow-request";
requestId: string;
tabId: number;
tabTitle: string;
isActiveTab: boolean;
timeoutMs: number;
}
export interface BorrowCancelMessage {
type: "borrow-cancel";
requestId: string;
}
export interface BorrowResponseMessage {
type: "borrow-response";
allowed: boolean;
}
export const CONFIRMATION_TIMEOUT_MS = 5000;
const EXIT_ANIMATION_MS = 150;
/** Matches BorrowConfirmationOverlay progress ring/bar transition (duration-1000). */
export const PROGRESS_TRANSITION_MS = 1000;
// UI auto-allows after countdown + progress transition + exit fade. Background
// timeout must not fire before that chain completes or deny is ignored.
export const BACKGROUND_TIMEOUT_MS =
CONFIRMATION_TIMEOUT_MS + PROGRESS_TRANSITION_MS + EXIT_ANIMATION_MS + 500;
/** Prefix for chrome.notifications ids the click handler dispatches on. */
export const BORROW_NOTIFICATION_PREFIX = "bh-borrow:";
// ---------------------------------------------------------------------------
// Injection seams (so tests don't need to monkey-patch global chrome.*)
// ---------------------------------------------------------------------------
export interface ChromeTabsForBorrow {
get(tabId: number): Promise<chrome.tabs.Tab>;
sendMessage(tabId: number, msg: unknown): Promise<unknown>;
}
export interface ChromeWindowsForBorrow {
getLastFocused(filters: chrome.windows.QueryOptions): Promise<chrome.windows.Window>;
getAll(filters: chrome.windows.QueryOptions): Promise<chrome.windows.Window[]>;
update(windowId: number, props: chrome.windows.UpdateInfo): Promise<chrome.windows.Window>;
}
export interface ChromeNotificationsForBorrow {
create(
notificationId: string,
options: chrome.notifications.NotificationOptions<true>,
): Promise<string>;
clear(notificationId: string): Promise<boolean>;
}
export interface BorrowNotificationCopy {
/** Notification title (e.g. "Agent wants to borrow a tab"). */
title: string;
/** Notification body — receives the target tab title. */
body(tabTitle: string): string;
/** Icon URL relative to the extension root. */
iconUrl?: string;
/**
* Title for the OS notification's Allow button (button index 0). Clicking
* it grants the borrow when the in-page overlay never reached the user
* (e.g. every candidate window's content script was missing/uninjected).
*/
allowButton: string;
/**
* Title for the OS notification's Deny button (button index 1). Clicking
* it rejects the borrow as a real user-deny — never silently flipped to
* allow.
*/
denyButton: string;
}
export interface RequestBorrowConfirmationDeps {
tabs?: ChromeTabsForBorrow;
windows?: ChromeWindowsForBorrow;
/** Pass `null` to opt out of OS-level notifications (tests). */
notifications?: ChromeNotificationsForBorrow | null;
/** Returns `true` when `windowId` belongs to *any* live session's Agent Window. */
isAgentWindowId?: (windowId: number) => boolean;
notificationCopy?: BorrowNotificationCopy;
}
export interface RequestBorrowConfirmationOptions {
signal?: AbortSignal;
deps?: RequestBorrowConfirmationDeps;
}
// ---------------------------------------------------------------------------
// Pending notification registry (used by the on-click handler)
// ---------------------------------------------------------------------------
interface PendingBorrowNotification {
windowId: number;
tabId: number;
}
const pendingBorrowNotifications = new Map<string, PendingBorrowNotification>();
/**
* Registry of pending borrow promises keyed by `notificationId`. The
* `chrome.notifications.onButtonClicked` listener uses it to resolve the
* matching `requestBorrowConfirmation` call when the user clicks Allow
* (index 0) or Deny (index 1) on the OS notification.
*
* This is the *fallback* authorization path that fires when no candidate
* user window could host the in-page overlay (every `chrome.tabs.sendMessage`
* was rejected because the content script was not present). Without it the
* background-task fail-open at the end of `tryCandidate` would silently
* allow the borrow without any user-visible authorization step.
*/
const pendingBorrowDecisions = new Map<string, (allowed: boolean) => void>();
/** Test helper — clears the in-memory pending notification registry. */
export function __resetPendingBorrowNotificationsForTest(): void {
pendingBorrowNotifications.clear();
}
/** Test helper — clears the in-memory pending decision registry. */
export function __resetPendingBorrowDecisionsForTest(): void {
pendingBorrowDecisions.clear();
}
/**
* Subset of `ChromeNotificationsForBorrow` the click handler actually needs.
* Keeping the dependency narrow makes it obvious that the listener never
* creates notifications on its own.
*/
export interface BorrowNotificationCleaner {
clear(notificationId: string): Promise<boolean>;
}
/**
* Registers a global `chrome.notifications.onClicked` listener that focuses
* the user window hosting the pending borrow overlay when the user clicks the
* notification. Safe to call once at SW startup.
*/
export function attachBorrowNotificationClickHandler(deps: {
onClicked: chrome.events.Event<(notificationId: string) => void>;
windows: Pick<ChromeWindowsForBorrow, "update">;
notifications: BorrowNotificationCleaner;
}): { dispose(): void } {
const handler = (notificationId: string) => {
const pending = pendingBorrowNotifications.get(notificationId);
if (!pending) return;
void deps.windows.update(pending.windowId, { focused: true }).catch((err) => {
console.debug("[bsk borrow] focus on notification click failed", err);
});
void deps.notifications.clear(notificationId).catch((err) => {
console.debug("[bsk borrow] clear notification failed", err);
});
};
deps.onClicked.addListener(handler);
return {
dispose: () => deps.onClicked.removeListener(handler),
};
}
/**
* Registers a global `chrome.notifications.onButtonClicked` listener so the
* Allow / Deny buttons on the OS notification can resolve the matching
* pending `requestBorrowConfirmation`. This is the explicit-authorization
* fallback path used when every candidate user window's content script was
* missing — without it we'd be back to silently fail-opening the borrow.
*
* Button index convention (matches `BorrowNotificationCopy`):
* - 0 → Allow → `resolve(true)`
* - 1 → Deny → `resolve(false)`
*
* Safe to call once at SW startup. Repeated invocations attach independent
* listeners (each `dispose()` removes only its own).
*/
export function attachBorrowNotificationButtonHandler(deps: {
onButtonClicked: chrome.events.Event<(notificationId: string, buttonIndex: number) => void>;
}): { dispose(): void } {
const handler = (notificationId: string, buttonIndex: number) => {
const settle = pendingBorrowDecisions.get(notificationId);
if (!settle) return;
const allowed = buttonIndex === 0;
settle(allowed);
};
deps.onButtonClicked.addListener(handler);
return {
dispose: () => deps.onButtonClicked.removeListener(handler),
};
}
// ---------------------------------------------------------------------------
// Default chrome.* bindings (exported so background.ts can reuse them)
// ---------------------------------------------------------------------------
export const defaultBorrowChromeTabs: ChromeTabsForBorrow = {
get: (id) => chrome.tabs.get(id),
sendMessage: (id, msg) => chrome.tabs.sendMessage(id, msg),
};
export const defaultBorrowChromeWindows: ChromeWindowsForBorrow = {
getLastFocused: (f) => chrome.windows.getLastFocused(f),
getAll: (f) => chrome.windows.getAll(f),
update: (id, p) => chrome.windows.update(id, p),
};
export const defaultBorrowChromeNotifications: ChromeNotificationsForBorrow = {
create: (id, opts) =>
new Promise<string>((resolve, reject) => {
try {
chrome.notifications.create(id, opts, (resultId) => {
const err = chrome.runtime?.lastError;
if (err) {
reject(new Error(err.message ?? String(err)));
return;
}
resolve(resultId ?? id);
});
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
}),
clear: (id) =>
new Promise<boolean>((resolve) => {
try {
chrome.notifications.clear(id, (cleared) => {
resolve(cleared ?? false);
});
} catch (err) {
console.debug("[bsk borrow] notifications.clear threw", err);
resolve(false);
}
}),
};
const DEFAULT_NOTIFICATION_COPY: BorrowNotificationCopy = {
title: "BrowserSkill: Agent wants to borrow a tab",
body: (tabTitle) => `Approve or deny the borrow of "${tabTitle}".`,
iconUrl: "icon/logo.png",
allowButton: "Allow",
denyButton: "Deny",
};
// ---------------------------------------------------------------------------
// URL injection-eligibility test
// ---------------------------------------------------------------------------
const CHROME_WEB_STORE_RES = [
/^https:\/\/chrome\.google\.com\/webstore/i,
/^https:\/\/chromewebstore\.google\.com/i,
];
/**
* Returns true when the URL is one Chrome will reliably inject `<all_urls>`
* content scripts into. Conservatively we accept *only* `http(s)://`:
*
* - `file://` requires the per-extension "Allow access to file URLs"
* toggle the user almost never has on, and querying that toggle is async.
* - `ftp://` no longer hosts content scripts reliably in modern Chrome.
* - `about:` / `chrome:` / `chrome-extension:` / `edge:` / `devtools:` /
* `view-source:` / `data:` / `blob:` are blocked by the platform.
* - The Chrome Web Store (both legacy and new domains) is also blocked.
*
* Mirrors the implicit scheme list documented in
* https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns.
*/
export function isInjectableContentScriptUrl(url: string | undefined): boolean {
if (!url) return false;
for (const re of CHROME_WEB_STORE_RES) {
if (re.test(url)) return false;
}
return /^https?:\/\//i.test(url);
}
// ---------------------------------------------------------------------------
// Pick the user-visible tab(s) that may host the confirmation overlay
// ---------------------------------------------------------------------------
interface ConfirmationCandidate {
tabId: number;
windowId: number;
tabUrl?: string;
}
async function listConfirmationCandidates(
windows: ChromeWindowsForBorrow,
isAgentWindowId: (windowId: number) => boolean,
): Promise<ConfirmationCandidate[]> {
const seen = new Set<number>();
const ordered: chrome.windows.Window[] = [];
try {
const last = await windows.getLastFocused({
populate: true,
windowTypes: ["normal"],
});
if (typeof last?.id === "number") {
seen.add(last.id);
ordered.push(last);
}
} catch (err) {
console.debug("[bsk borrow] windows.getLastFocused failed", err);
}
try {
const all = await windows.getAll({
populate: true,
windowTypes: ["normal"],
});
for (const w of all) {
if (typeof w.id !== "number" || seen.has(w.id)) continue;
seen.add(w.id);
ordered.push(w);
}
} catch (err) {
console.debug("[bsk borrow] windows.getAll failed", err);
}
const candidates: ConfirmationCandidate[] = [];
for (const w of ordered) {
if (typeof w.id !== "number") continue;
// Never bounce the overlay back into a session's Agent Window — those
// pages can't host a content script when they're on the about:blank
// bootstrap URL, so sendMessage would fail-open and silently allow the
// borrow with no UI shown.
if (isAgentWindowId(w.id)) continue;
const activeTab = w.tabs?.find((t) => t.active === true) ?? null;
if (!activeTab || typeof activeTab.id !== "number") continue;
const tabUrl = activeTab.url ?? activeTab.pendingUrl ?? undefined;
if (!isInjectableContentScriptUrl(tabUrl)) continue;
candidates.push({ tabId: activeTab.id, windowId: w.id, tabUrl });
}
return candidates;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Asks the user to confirm a tab borrow via an injected in-page UI plus an
* OS-level Chrome notification.
*
* Selection of the overlay target tab:
* 1. lastFocusedWindow (preferring the user's most recent normal window);
* 2. any other normal window;
* skipping any window that belongs to a session's Agent Window or whose
* active tab cannot host a content script (chrome://, the Web Store, etc.).
* Each candidate is tried in order — if `sendMessage` rejects (page raced
* to a restricted URL between the query and the message; content script
* still booting; extension just reloaded so the existing tab has no
* listener; etc.) we move on to the next candidate.
*
* The OS notification carries Allow / Deny buttons that act as the
* *explicit-authorization fallback* when every candidate fails sendMessage:
* we no longer silently allow the borrow in that case, the promise stays
* pending until the user clicks a notification button (resolves true/false)
* or `BACKGROUND_TIMEOUT_MS` fires (last-resort fail-open so the agent
* doesn't hang forever when even the notification path is broken).
*
* Clicking the notification body (vs. its buttons) focuses the chosen user
* window so the overlay becomes visible — the previous behaviour.
*
* Returns `true` on user-allow (overlay or notification) OR last-resort
* fail-open (no candidate window at all; abort signal; timeout). Returns
* `false` only on explicit user-deny (overlay button or notification Deny
* button).
*/
export async function requestBorrowConfirmation(
tabId: number,
options: RequestBorrowConfirmationOptions = {},
): Promise<boolean> {
const { signal, deps = {} } = options;
const tabsApi = deps.tabs ?? defaultBorrowChromeTabs;
const windowsApi = deps.windows ?? defaultBorrowChromeWindows;
const notificationsApi =
deps.notifications === undefined ? defaultBorrowChromeNotifications : deps.notifications;
const isAgentWindowId = deps.isAgentWindowId ?? (() => false);
const copy = deps.notificationCopy ?? DEFAULT_NOTIFICATION_COPY;
// The borrow pipeline already fetched this tab moments earlier
// (validateBorrowTarget in tabs.ts), so a failure here is a transient
// SW/page hiccup, not a missing tab. Do NOT fail-open: silently allowing
// a borrow with no confirmation UI contradicts the gate's purpose and is
// not one of the documented fail-open cases (no candidate window; abort;
// timeout — see this function's doc comment). Fall back to a generic
// title and still surface the overlay + notification so the user gets a
// real chance to approve or deny.
let tabTitle: string;
try {
const tab = await tabsApi.get(tabId);
tabTitle = tab.title ?? tab.url ?? String(tabId);
} catch (err) {
console.warn("[bsk borrow] could not get tab info — using fallback title", {
tabId,
error: err instanceof Error ? err.message : String(err),
});
tabTitle = String(tabId);
}
const candidates = await listConfirmationCandidates(windowsApi, isAgentWindowId);
if (candidates.length === 0) {
console.warn("[bsk borrow] no injectable user window available — proceeding without overlay", {
tabId,
});
return true;
}
const requestId = createBorrowRequestId(tabId);
const notificationId = `${BORROW_NOTIFICATION_PREFIX}${requestId}`;
// Prefer the first candidate as the notification anchor: it is the window
// the user is most likely already looking at (lastFocusedWindow), so the
// notification's "click → focus that window" affordance lands them on the
// overlay immediately.
const notificationAnchor = candidates[0];
if (!notificationAnchor) {
return true;
}
return new Promise<boolean>((resolve) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
let activeMessageTabId: number | null = null;
const cleanupNotification = () => {
if (!notificationsApi) return;
pendingBorrowNotifications.delete(notificationId);
pendingBorrowDecisions.delete(notificationId);
void notificationsApi.clear(notificationId).catch((err) => {
console.debug("[bsk borrow] notification clear failed", {
notificationId,
error: err instanceof Error ? err.message : String(err),
});
});
};
const settle = (allowed: boolean) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
cleanupNotification();
resolve(allowed);
};
const onAbort = () => {
if (activeMessageTabId !== null) {
const cancelMessage: BorrowCancelMessage = {
type: "borrow-cancel",
requestId,
};
void tabsApi.sendMessage(activeMessageTabId, cancelMessage).catch((err) => {
console.debug("[bsk borrow] cancel message failed", {
tabId,
messageTabId: activeMessageTabId,
requestId,
error: err instanceof Error ? err.message : String(err),
});
});
}
settle(true);
};
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
timeout = setTimeout(() => {
console.info("[bsk borrow] confirmation timed out — proceeding", {
tabId,
messageTabId: activeMessageTabId,
});
settle(true);
}, BACKGROUND_TIMEOUT_MS);
// Surface the OS notification *before* messaging any candidate so the
// user has a parallel signal even if every content script is missing.
// The Allow / Deny buttons let the user authorize explicitly when the
// in-page overlay never reached them — without those buttons we'd have
// to fail-open after `tryCandidate` exhausts its candidates, which is
// exactly the silent-allow bug we're fixing here.
if (notificationsApi) {
pendingBorrowNotifications.set(notificationId, {
windowId: notificationAnchor.windowId,
tabId: notificationAnchor.tabId,
});
pendingBorrowDecisions.set(notificationId, settle);
notificationsApi
.create(notificationId, {
type: "basic",
iconUrl: copy.iconUrl ?? "icon/logo.png",
title: copy.title,
message: copy.body(tabTitle),
priority: 2,
requireInteraction: false,
silent: false,
buttons: [{ title: copy.allowButton }, { title: copy.denyButton }],
})
.catch((err) => {
console.debug("[bsk borrow] notifications.create failed — continuing overlay-only", {
tabId,
error: err instanceof Error ? err.message : String(err),
});
// Drop the registry entries on create failure: the click /
// button handlers must not target a notification that never
// appeared (Chrome would route a stale id to nowhere, but the
// map would leak).
pendingBorrowNotifications.delete(notificationId);
pendingBorrowDecisions.delete(notificationId);
});
}
const tryCandidate = (
index: number,
errorTrail: Array<{ candidate: ConfirmationCandidate; error: string }>,
): void => {
if (settled) return;
if (index >= candidates.length) {
// Every candidate rejected sendMessage. We deliberately do NOT
// fail-open here anymore: silently allowing a borrow with zero
// user-visible authorization UI is the bug. Instead we keep the
// promise pending and rely on:
// • the OS notification's Allow / Deny buttons for an explicit
// user choice (preferred), or
// • the BACKGROUND_TIMEOUT_MS fail-open as the last-resort
// soft-fail so the agent doesn't hang forever when even the
// notification API is unavailable.
// Keep the diagnostics so real-world frequency is still observable.
console.warn(
"[bsk borrow] every candidate user window failed sendMessage — awaiting notification button or timeout",
{
tabId,
attempts: errorTrail,
},
);
return;
}
const candidate = candidates[index];
if (!candidate) {
return;
}
const isActiveTab = candidate.tabId === tabId;
const message: BorrowRequestMessage = {
type: "borrow-request",
requestId,
tabId,
tabTitle,
isActiveTab,
timeoutMs: CONFIRMATION_TIMEOUT_MS,
};
activeMessageTabId = candidate.tabId;
tabsApi
.sendMessage(candidate.tabId, message)
.then((response) => {
if (settled) return;
const allowed = (response as BorrowResponseMessage | undefined)?.allowed !== false;
console.info("[bsk borrow] confirmation response", { tabId, allowed });
settle(allowed);
})
.catch((err) => {
if (settled) return;
const reason = err instanceof Error ? err.message : String(err);
console.warn("[bsk borrow] sendMessage to candidate user window failed — trying next", {
tabId,
messageTabId: candidate.tabId,
messageWindowId: candidate.windowId,
messageTabUrl: candidate.tabUrl,
error: reason,
});
errorTrail.push({ candidate, error: reason });
activeMessageTabId = null;
tryCandidate(index + 1, errorTrail);
});
};
tryCandidate(0, []);
});
}
function createBorrowRequestId(tabId: number): string {
const now = Date.now().toString(36);
const random = Math.random().toString(36).slice(2, 8);
return `${tabId}-${now}-${random}`;
}
+108
View File
@@ -0,0 +1,108 @@
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type { ConsoleParams, ConsoleResult, RpcError } from "@/transport/types";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
const DEFAULT_CONSOLE_LIMIT = 50;
const MAX_CONSOLE_LIMIT = 200;
const DEFAULT_MAX_TEXT_CHARS = 1000;
const MAX_TEXT_CHARS = 4096;
export interface ConsoleDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
}
function defaultConsoleDeps(): ConsoleDeps {
return {
cdp: new ChromiumCdp(),
tabsApi: chromeTabsApi,
};
}
export async function handleConsole(
manager: SessionManager,
params: ConsoleParams,
deps: ConsoleDeps = defaultConsoleDeps(),
): Promise<ConsoleResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "console");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseConsoleBounds(params);
if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
if (!deps.cdp.ensureConsoleCapture || !deps.cdp.consoleEntriesSince) {
return { code: "cdp_failed", message: "console capture requires CDP console support" };
}
try {
await deps.cdp.ensureConsoleCapture(target.tabId);
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
return deps.cdp.consoleEntriesSince(
target.tabId,
bounds.since,
bounds.limit,
bounds.maxTextChars,
bounds.includeStack,
);
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
function parseConsoleBounds(params: ConsoleParams):
| {
since: number | undefined;
limit: number;
maxTextChars: number;
includeStack: boolean;
}
| RpcError {
const since = params.since;
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
return { code: "invalid_params", message: "since must be a non-negative integer" };
}
const limit = boundedOptionalInteger(
params.limit,
DEFAULT_CONSOLE_LIMIT,
MAX_CONSOLE_LIMIT,
"limit",
);
if (isRpcError(limit)) return limit;
const maxTextChars = boundedOptionalInteger(
params.max_text_chars,
DEFAULT_MAX_TEXT_CHARS,
MAX_TEXT_CHARS,
"max_text_chars",
);
if (isRpcError(maxTextChars)) return maxTextChars;
return {
since,
limit,
maxTextChars,
includeStack: params.include_stack === true,
};
}
function boundedOptionalInteger(
value: number | undefined,
defaultValue: number,
maxValue: number,
field: string,
): number | RpcError {
if (value === undefined) return defaultValue;
if (!Number.isSafeInteger(value) || value <= 0) {
return { code: "invalid_params", message: `${field} must be a positive integer` };
}
return Math.min(value, maxValue);
}
+35
View File
@@ -0,0 +1,35 @@
import type { JavaScriptDialogInfo } from "@/transport/types";
import type { CdpRunner, DialogCursor } from "./shared";
/** Capture the current per-tab dialog sequence before issuing CDP calls. */
export function markDialogCursor(cdp: CdpRunner, tabId: number): DialogCursor {
return cdp.dialogCursor?.(tabId) ?? 0;
}
/** Collect dialogs observed on `tabId` after `cursor` was taken. */
export function collectDialogs(
cdp: CdpRunner,
tabId: number,
cursor: DialogCursor,
): JavaScriptDialogInfo[] {
return cdp.dialogsSince?.(tabId, cursor) ?? [];
}
/** Attach `dialogs` to a tool result when non-empty (wire omits empty arrays). */
export function withDialogs<T extends object>(
result: T,
dialogs: JavaScriptDialogInfo[],
): T & { dialogs?: JavaScriptDialogInfo[] } {
if (dialogs.length === 0) return result;
return { ...result, dialogs };
}
/** Convenience: collect dialogs since `cursor` and attach to `result`. */
export function attachDialogs<T extends object>(
cdp: CdpRunner,
tabId: number,
cursor: DialogCursor,
result: T,
): T & { dialogs?: JavaScriptDialogInfo[] } {
return withDialogs(result, collectDialogs(cdp, tabId, cursor));
}
+465
View File
@@ -0,0 +1,465 @@
import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge";
import type { SessionManager } from "@/session-manager/manager";
import type { Transport } from "@/transport/transport";
import type {
ClickParams,
ConsoleParams,
EvaluateParams,
FillParams,
GetHtmlParams,
NavigateBackParams,
NavigateForwardParams,
NavigateParams,
PressParams,
ProtocolFrame,
ReloadParams,
RequestFrame,
RequestHelpParams,
ResponseFrame,
RpcError,
ScreenshotParams,
SelectParams,
SnapshotParams,
WaitForNavigationParams,
} from "@/transport/types";
import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console";
import { handleEvaluate } from "./evaluate";
import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handlePress, handleSelect } from "./interaction";
import {
handleNavigate,
handleNavigateBack,
handleNavigateForward,
handleReload,
} from "./navigation";
import {
type CdpRunner,
chromeTabsCaptureApi,
handleGetHtml,
handleScreenshot,
handleSnapshot,
} from "./observation";
import {
handleSessionStart,
handleSessionStop,
type SessionStartParams,
type SessionStopParams,
} from "./session";
import { chromeTabsApi } from "./shared";
import {
type BorrowConfirmationApprover,
handleTabBorrow,
handleTabClose,
handleTabCreate,
handleTabList,
handleTabReturn,
handleTabSelect,
type TabBorrowParams,
type TabCloseParams,
type TabCreateParams,
type TabListParams,
type TabReturnParams,
type TabSelectParams,
} from "./tabs";
import { handleWaitForNavigation } from "./waits";
export interface DispatcherDeps {
transport: Transport;
sessions: SessionManager;
cdp?: CdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
/**
* Invoked whenever a dispatched RPC may have changed the live
* session set (currently `tool.session_start` and
* `tool.session_stop`). Used to refresh side caches such as the
* `chrome.storage.session` "sessions live" flag (review M4/M5 I3).
*/
onSessionsChanged?: () => void;
/** User approval for `tool.tab_borrow` (overlay in content script). */
approveBorrow?: BorrowConfirmationApprover;
/** i18n notification copy for `tool.request_help` (resolved per-call). */
helpNotificationCopy?: () => { title: string; body: string };
}
/**
* Routes RPC requests pushed by the daemon over the Transport to the
* appropriate tool implementation.
*
* M5 wires `tool.session_start` and `tool.session_stop`. M6+ tools
* will register additional method handlers here.
*
* M10.2 wires the cancel chain: every dispatched RPC owns one
* `AbortController` keyed by its wire `id` in
* [`inflightAbortControllers`]. When the daemon pushes a `cancel`
* request the dispatcher trips the matching controller; tool
* handlers that already accept a `signal` (waits, navigation,
* interaction, evaluate, tabs) react in line, and the dispatcher
* additionally races the in-flight invocation against the abort
* promise so handlers without explicit signal plumbing still respond
* promptly with `cancelled`.
*/
export class ToolDispatcher {
private readonly transport: Transport;
private readonly sessions: SessionManager;
private readonly cdp?: CdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
private readonly onSessionsChanged?: () => void;
private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string };
private subscription: { dispose(): void } | null = null;
/**
* Per-rpc-id `AbortController` registry. Populated inside
* [`dispatch`] before we await the tool handler and torn down in
* the matching `finally` so failures + send errors never leak
* controllers. Made public for tests.
*/
readonly inflightAbortControllers = new Map<string, AbortController>();
constructor(deps: DispatcherDeps) {
this.transport = deps.transport;
this.sessions = deps.sessions;
this.cdp = deps.cdp;
this.onSessionsChanged = deps.onSessionsChanged;
this.approveBorrow = deps.approveBorrow;
this.helpNotificationCopy = deps.helpNotificationCopy;
}
start(): void {
if (this.subscription) return;
this.subscription = this.transport.onMessage((msg) => {
void this.dispatch(msg);
});
}
stop(): void {
this.subscription?.dispose();
this.subscription = null;
// Trip every outstanding controller so dependent waits unblock
// before the dispatcher is GC'd.
for (const ac of this.inflightAbortControllers.values()) {
try {
ac.abort();
} catch (_) {
// ignore
}
}
this.inflightAbortControllers.clear();
}
private async dispatch(msg: ProtocolFrame): Promise<void> {
if (!isRequestFrame(msg)) return;
const req = msg as RequestFrame;
// Cancel frames take a fast path: trip the matching controller
// (if any), reply with `{cancelled}` so the daemon can answer
// its own peer, and skip the regular tool dispatch.
if (req.method === "cancel") {
const params = (req.params as { rpc_id?: string } | undefined) ?? {};
const target = typeof params.rpc_id === "string" ? params.rpc_id : "";
const ac = target ? this.inflightAbortControllers.get(target) : undefined;
if (ac) {
try {
ac.abort();
} catch (err) {
console.warn("[bsk dispatcher] AbortController.abort() threw", err);
}
}
const reply: ResponseFrame = {
id: req.id,
result: { cancelled: ac !== undefined },
};
try {
this.transport.send(reply);
} catch (sendErr) {
console.warn("[bsk dispatcher] failed to ack cancel", sendErr);
}
return;
}
const mutatesSessions =
req.method === "tool.session_start" || req.method === "tool.session_stop";
const ac = new AbortController();
this.inflightAbortControllers.set(req.id, ac);
let body: ResponseFrame;
let startedSession: string | null = null;
try {
const result = await Promise.race([this.invoke(req, ac.signal), abortPromise(ac.signal)]);
if (isRpcError(result)) {
body = { id: req.id, error: result };
} else {
body = { id: req.id, result };
if (req.method === "tool.session_start") {
startedSession = (req.params as SessionStartParams | undefined)?.session_id ?? null;
}
}
} catch (err) {
if (isAbortLikeError(err)) {
body = {
id: req.id,
error: { code: "cancelled", message: "rpc aborted by daemon cancel" },
};
} else {
body = {
id: req.id,
error: {
code: "protocol_error",
message: err instanceof Error ? err.message : String(err),
},
};
}
} finally {
this.inflightAbortControllers.delete(req.id);
}
let sent = true;
try {
this.transport.send(body);
} catch (sendErr) {
sent = false;
// Transport is dead by the time we want to reply. Drop the link
// proactively so the alarm-driven keepalive reconnects sooner
// and the daemon's pending RPC times out cleanly instead of
// waiting for the full 15s budget (review M4/M5 I9).
console.warn("[bsk dispatcher] failed to send response; dropping transport", sendErr);
void this.transport.disconnect().catch((e) => {
console.debug("[bsk dispatcher] disconnect after send failure errored", e);
});
}
if (!sent && startedSession) {
// The daemon never observed the session id we just allocated, so
// its `start_session` reservation will be cancelled. Roll back
// the Agent Window + SessionContext here so we do not leak an
// orphan window the user has to close manually (review M4/M5
// round 3 I-R3-3).
try {
const ctx = await this.sessions.stop(startedSession);
if (ctx) {
console.warn(
"[bsk dispatcher] rolled back orphan session after send failure",
startedSession,
);
}
} catch (rollbackErr) {
console.warn("[bsk dispatcher] session rollback after send failure failed", rollbackErr);
}
}
if (mutatesSessions) this.onSessionsChanged?.();
}
private async invoke(req: RequestFrame, signal: AbortSignal): Promise<unknown | RpcError> {
switch (req.method) {
case "tool.session_start":
return handleSessionStart(this.sessions, req.params as SessionStartParams);
case "tool.session_stop":
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
cdp: this.cdp,
});
case "tool.tab_list":
return handleTabList(this.sessions, req.params as TabListParams);
case "tool.tab_create":
return handleTabCreate(this.sessions, req.params as TabCreateParams);
case "tool.tab_close":
return handleTabClose(this.sessions, req.params as TabCloseParams);
case "tool.tab_select":
return handleTabSelect(this.sessions, req.params as TabSelectParams);
case "tool.tab_borrow":
return handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
signal,
approveBorrow: this.approveBorrow,
});
case "tool.tab_return":
return handleTabReturn(this.sessions, req.params as TabReturnParams);
case "tool.screenshot":
return handleScreenshot(
this.sessions,
req.params as ScreenshotParams,
this.cdp
? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi }
: undefined,
);
case "tool.console":
return handleConsole(
this.sessions,
req.params as ConsoleParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
);
case "tool.snapshot":
return handleSnapshot(
this.sessions,
req.params as SnapshotParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
);
case "tool.get_html":
return handleGetHtml(
this.sessions,
req.params as GetHtmlParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
);
case "tool.navigate":
return handleNavigate(
this.sessions,
req.params as NavigateParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.navigate_back":
return handleNavigateBack(
this.sessions,
req.params as NavigateBackParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.navigate_forward":
return handleNavigateForward(
this.sessions,
req.params as NavigateForwardParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.reload":
return handleReload(
this.sessions,
req.params as ReloadParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.click":
return handleClick(
this.sessions,
req.params as ClickParams,
this.cdp
? {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
bypassOverlay: async (tabId, enabled) => {
try {
await chrome.tabs.sendMessage(tabId, {
type: OVERLAY_AUTOMATION_BYPASS,
enabled,
});
} catch {
// Content script may be unavailable on restricted pages.
}
},
}
: undefined,
);
case "tool.fill":
return handleFill(
this.sessions,
req.params as FillParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.press":
return handlePress(
this.sessions,
req.params as PressParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.select":
return handleSelect(
this.sessions,
req.params as SelectParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.evaluate":
return handleEvaluate(
this.sessions,
req.params as EvaluateParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.wait_for_navigation":
return handleWaitForNavigation(
this.sessions,
req.params as WaitForNavigationParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
);
case "tool.request_help":
return handleRequestHelp(this.sessions, req.params as RequestHelpParams, {
tabsApi: chromeTabsApi,
windows: { update: (id, info) => chrome.windows.update(id, info) },
activateTab: async (tabId) => {
await chrome.tabs.update(tabId, { active: true });
},
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
watchTabNavigation: defaultWatchTabNavigation,
...(this.cdp ? { cdp: this.cdp } : {}),
notifications: makeHelpNotifications(),
notificationCopy: this.helpNotificationCopy?.(),
signal,
});
default:
return {
code: "unknown_method",
message: `${req.method} not implemented in extension`,
} satisfies RpcError;
}
}
}
function isRpcError(v: unknown): v is RpcError {
return (
typeof v === "object" &&
v !== null &&
"code" in v &&
"message" in v &&
typeof (v as RpcError).code === "string"
);
}
/**
* Resolves never; rejects with `AbortLikeError` as soon as the signal
* fires (or immediately if it is already aborted). Used by the
* dispatcher to race the tool invocation so handlers without explicit
* signal plumbing still surface a `cancelled` reply promptly.
*/
function abortPromise(signal: AbortSignal): Promise<never> {
return new Promise<never>((_, reject) => {
if (signal.aborted) {
reject(new AbortLikeError());
return;
}
signal.addEventListener(
"abort",
() => {
reject(new AbortLikeError());
},
{ once: true },
);
});
}
/**
* Sentinel error class so [`isAbortLikeError`] can recognise our own
* race-rejection without confusing it with a real CDP failure.
*/
class AbortLikeError extends Error {
constructor() {
super("rpc aborted by daemon cancel");
this.name = "BhAbortError";
}
}
function isAbortLikeError(err: unknown): boolean {
if (err instanceof AbortLikeError) return true;
if (err instanceof DOMException && err.name === "AbortError") return true;
if (typeof err === "object" && err !== null && (err as { name?: string }).name === "AbortError") {
return true;
}
return false;
}
function makeHelpNotifications() {
if (typeof chrome.notifications?.create !== "function") return null;
return {
create: (id: string, opts: chrome.notifications.NotificationOptions<true>) =>
new Promise<string>((resolve, reject) =>
chrome.notifications.create(id, opts, (rid) => {
const err = chrome.runtime?.lastError;
if (err) reject(new Error(err.message ?? String(err)));
else resolve(rid ?? id);
}),
),
clear: (id: string) =>
new Promise<boolean>((resolve) => chrome.notifications.clear(id, (c) => resolve(c ?? false))),
};
}
@@ -0,0 +1,260 @@
// Shared CDP helpers for scrolling nodes into view and computing
// viewport-space bounding rectangles. Used by interaction tools (click
// centre) and observation tools (element screenshot clip).
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
import { type CdpRunner, isRpcError } from "./shared";
const ELEMENT_NOT_VISIBLE_MESSAGE =
"element not visible (no content quads, box model, or visible descendant bounds)";
function elementNotVisibleError(): RpcError {
return rpcError("permission_denied", "element_not_visible", ELEMENT_NOT_VISIBLE_MESSAGE);
}
/** Viewport-space axis-aligned bounding box for CDP `Page.captureScreenshot` clip. */
export interface ViewportRect {
x: number;
y: number;
width: number;
height: number;
}
/**
* Compute the axis-aligned bounding box of an 8-double CDP content
* quad / box-model polygon. Exported for unit tests.
*/
export function quadBoundingRect(quad: number[]): ViewportRect | null {
if (quad.length !== 8) return null;
const xs = [quad[0], quad[2], quad[4], quad[6]];
const ys = [quad[1], quad[3], quad[5], quad[7]];
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const width = maxX - minX;
const height = maxY - minY;
if (width <= 0 || height <= 0) return null;
return { x: minX, y: minY, width, height };
}
function rectToQuad(rect: ViewportRect): number[] {
return [
rect.x,
rect.y,
rect.x + rect.width,
rect.y,
rect.x + rect.width,
rect.y + rect.height,
rect.x,
rect.y + rect.height,
];
}
/**
* Resolve `backendNodeId` → CDP `objectId` so we can invoke
* `Runtime.callFunctionOn` against the live JS object.
*/
export async function backendNodeToObject(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<string | RpcError> {
try {
const resolved = await cdp.send<{ object?: { objectId?: string } }>(tabId, "DOM.resolveNode", {
backendNodeId,
});
const objectId = resolved.object?.objectId;
if (typeof objectId !== "string") {
return {
code: "cdp_failed",
message: "DOM.resolveNode returned no objectId",
};
}
return objectId;
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
export async function scrollNodeIntoView(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<RpcError | null> {
try {
await cdp.send(tabId, "DOM.scrollIntoViewIfNeeded", { backendNodeId });
return null;
} catch (err) {
console.debug("[bsk element-geometry] scrollIntoViewIfNeeded failed", err);
}
const objectIdOrErr = await backendNodeToObject(cdp, tabId, backendNodeId);
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
try {
await cdp.send(tabId, "Runtime.callFunctionOn", {
objectId: objectIdOrErr,
functionDeclaration: `function() {
this.scrollIntoView({ block: 'center', inline: 'center' });
}`,
returnByValue: true,
});
return null;
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Viewport-space bounding box for `backendNodeId`. Prefers
* `DOM.getContentQuads`, then `DOM.getBoxModel`, then the union of
* visible descendant client rects for zero-size overflow containers.
*/
export async function nodeBoundingRect(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<ViewportRect | RpcError> {
const polygon = await visibleContentPolygon(cdp, tabId, backendNodeId);
if (isRpcError(polygon)) return polygon;
// visibleContentPolygon only returns quads with positive area.
return quadBoundingRect(polygon)!;
}
/**
* Compute the centroid of a CDP content quad. Quads are reported as
* 8 doubles in clockwise order: (x1, y1, x2, y2, x3, y3, x4, y4).
*/
export function quadCentre(quad: number[]): { x: number; y: number } {
const x = (quad[0] + quad[4]) / 2;
const y = (quad[1] + quad[5]) / 2;
return { x, y };
}
/** Centre of a `DOM.getBoxModel` `content` polygon (also 8 doubles). */
export function boxCentre(box: number[]): { x: number; y: number } {
return quadCentre(box);
}
async function visibleContentPolygon(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<number[] | RpcError> {
try {
const quads = await cdp.send<{ quads?: number[][] }>(tabId, "DOM.getContentQuads", {
backendNodeId,
});
const quad = quads.quads?.find((q) => q.length === 8 && quadBoundingRect(q) !== null);
if (quad) return quad;
} catch (err) {
console.debug("[bsk element-geometry] getContentQuads failed", err);
}
try {
const box = await cdp.send<{ model?: { content?: number[] } }>(tabId, "DOM.getBoxModel", {
backendNodeId,
});
const content = box.model?.content;
if (content && content.length === 8 && quadBoundingRect(content)) return content;
} catch (err) {
console.debug("[bsk element-geometry] getBoxModel failed", err);
}
const descendantRect = await descendantBoundingRect(cdp, tabId, backendNodeId);
if (descendantRect) return rectToQuad(descendantRect);
return elementNotVisibleError();
}
async function descendantBoundingRect(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<ViewportRect | null> {
const objectIdOrErr = await backendNodeToObject(cdp, tabId, backendNodeId);
if (isRpcError(objectIdOrErr)) {
console.debug("[bsk element-geometry] resolveNode for descendant bounds failed", objectIdOrErr);
return null;
}
try {
const evaluated = await cdp.send<{
result?: { value?: ViewportRect | null };
}>(tabId, "Runtime.callFunctionOn", {
objectId: objectIdOrErr,
functionDeclaration: `function() {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const rects = [];
const pushRect = (rect) => {
if (!rect || rect.width <= 0 || rect.height <= 0) return;
const left = Math.max(0, rect.left);
const top = Math.max(0, rect.top);
const right = Math.min(viewportWidth, rect.right);
const bottom = Math.min(viewportHeight, rect.bottom);
if (right <= left || bottom <= top) return;
rects.push({ left, top, right, bottom });
};
const pushElement = (el) => {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return;
}
for (const rect of el.getClientRects()) pushRect(rect);
};
if (this instanceof Element) pushElement(this);
if (typeof this.querySelectorAll === 'function') {
for (const el of this.querySelectorAll('*')) pushElement(el);
}
if (rects.length === 0) return null;
const left = Math.min(...rects.map((r) => r.left));
const top = Math.min(...rects.map((r) => r.top));
const right = Math.max(...rects.map((r) => r.right));
const bottom = Math.max(...rects.map((r) => r.bottom));
return { x: left, y: top, width: right - left, height: bottom - top };
}`,
returnByValue: true,
});
return parseViewportRect(evaluated.result?.value);
} catch (err) {
console.debug("[bsk element-geometry] descendant bounds failed", err);
return null;
}
}
function parseViewportRect(value: unknown): ViewportRect | null {
if (typeof value !== "object" || value === null) return null;
const rect = value as Partial<ViewportRect>;
const { x, y, width, height } = rect;
if (
typeof x !== "number" ||
typeof y !== "number" ||
typeof width !== "number" ||
typeof height !== "number"
) {
return null;
}
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
return { x, y, width, height };
}
/**
* Get the click point for `backendNodeId`. Prefers
* `DOM.getContentQuads` (richer for rotated / transformed elements);
* falls back to `DOM.getBoxModel`, then visible descendant bounds.
* Returns `permission_denied` with "element not visible" when every
* geometry path comes back empty / fails.
*/
export async function nodeCentre(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<{ x: number; y: number } | RpcError> {
const polygon = await visibleContentPolygon(cdp, tabId, backendNodeId);
if (isRpcError(polygon)) return polygon;
return quadCentre(polygon);
}
+17
View File
@@ -0,0 +1,17 @@
// Central RpcError builders with stable `data.reason` values for CLI
// rendering. Extension handlers attach reasons here; human-facing copy
// lives in bsk-cli `render_error.rs`.
import type { ErrorCode, RpcError, RpcErrorData, RpcErrorReason } from "@/transport/types";
export type { RpcErrorData, RpcErrorReason };
export function rpcError(
code: ErrorCode,
reason: RpcErrorReason,
message: string,
extra?: Record<string, unknown>,
): RpcError {
const data: RpcErrorData = { reason, ...extra };
return { code, message, data };
}
+180
View File
@@ -0,0 +1,180 @@
// `tool.evaluate` — runs an arbitrary JS expression in the target tab
// via CDP `Runtime.evaluate`. Design §4 / §7 + plan M9.1.
//
// Sandbox: the call resolves the target tab the same way write tools
// do (`resolveTargetTab` + `enforceAgentWindow`), so borrowed tabs
// already moved into the Agent Window are allowed but tabs in a user
// window are refused with `permission_denied`. The check matches the
// design-doc red-line that `evaluate` must not be useful as a
// token-exfil window against arbitrary user tabs (design §6).
//
// Errors:
// * RPC-level (`not_found / invalid_params / permission_denied /
// cancelled / cdp_failed`) — returned as `RpcError`.
// * JS exceptions thrown by the evaluated expression — returned as
// `EvaluateResult { ok: false, error: { text, line?, column? } }`
// so the agent can read the throw text in-band (M9 review intent).
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type { EvaluateError, EvaluateParams, EvaluateResult, RpcError } from "@/transport/types";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
enforceAgentWindow,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
export interface EvaluateDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
/** Abort hook (full chain wired in M10.2). */
signal?: AbortSignal;
}
interface CdpRemoteObject {
type?: string;
subtype?: string;
className?: string;
objectId?: string;
value?: unknown;
unserializableValue?: string;
description?: string;
}
interface CdpExceptionDetails {
text?: string;
lineNumber?: number;
columnNumber?: number;
exception?: CdpRemoteObject;
}
interface RuntimeEvaluateReply {
result?: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
}
let defaultDeps: { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } | null = null;
function getDefaultDeps(): { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } {
if (!defaultDeps) {
defaultDeps = { cdp: new ChromiumCdp(), tabsApi: chromeTabsApi };
}
return defaultDeps;
}
function abortedError(signal: AbortSignal | undefined): RpcError | null {
if (signal?.aborted) {
return { code: "cancelled", message: "evaluate aborted" };
}
return null;
}
/**
* Pull a human-readable error text out of CDP's exception payload.
* Prefers `exception.description` (the full stack) and falls back to
* `exceptionDetails.text` (the short header). Both are best-effort —
* Chrome sometimes ships an empty string when the throw value is
* `null` / `undefined` / a non-Error primitive.
*/
function extractErrorText(details: CdpExceptionDetails): string {
const desc = details.exception?.description?.trim();
if (desc) return desc;
const text = details.text?.trim();
if (text) return text;
if (details.exception?.value !== undefined) {
return `Uncaught ${String(details.exception.value)}`;
}
return "Uncaught (unknown error)";
}
function buildEvaluateError(details: CdpExceptionDetails): EvaluateError {
return {
text: extractErrorText(details),
line: typeof details.lineNumber === "number" ? details.lineNumber + 1 : undefined,
column: typeof details.columnNumber === "number" ? details.columnNumber : undefined,
};
}
/**
* Map a CDP `RemoteObject` to a JSON-safe value for the wire result.
*
* * `unserializableValue` — `Infinity`, `-Infinity`, `NaN`, BigInt
* literals — round-trips as a string so the JSON payload stays
* self-describing.
* * `value` is whatever CDP put back when `returnByValue=true`.
* * Plain `undefined` (e.g. `void 0`, statements) returns `null` so
* the wire shape stays "missing means missing".
*/
function extractValue(remote: CdpRemoteObject | undefined): unknown {
if (!remote) return null;
if (remote.unserializableValue !== undefined) {
return remote.unserializableValue;
}
if ("value" in remote) {
return remote.value;
}
const descriptor: Record<string, string> = {};
for (const key of ["type", "subtype", "className", "objectId", "description"] as const) {
const value = remote[key];
if (typeof value === "string") {
descriptor[key] = value;
}
}
if (Object.keys(descriptor).length > 0 && (remote.objectId || remote.description)) {
return descriptor;
}
return null;
}
export async function handleEvaluate(
manager: SessionManager,
params: EvaluateParams,
deps: EvaluateDeps = getDefaultDeps(),
): Promise<EvaluateResult | RpcError> {
if (!params || typeof params.expression !== "string" || params.expression.length === 0) {
return { code: "invalid_params", message: "evaluate requires a non-empty expression" };
}
const ctxOrErr = lookupSession(manager, params, "evaluate");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
const ab = abortedError(deps.signal);
if (ab) return ab;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "evaluate");
if (denied) return denied;
if (abortedError(deps.signal)) {
return { code: "cancelled", message: "evaluate aborted" };
}
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
try {
const reply = await deps.cdp.send<RuntimeEvaluateReply>(target.tabId, "Runtime.evaluate", {
expression: params.expression,
awaitPromise: params.await_promise ?? true,
returnByValue: params.return_by_value ?? true,
throwOnSideEffect: false,
});
if (reply.exceptionDetails) {
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
ok: false,
tab_id: target.tabId,
error: buildEvaluateError(reply.exceptionDetails),
});
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
ok: true,
tab_id: target.tabId,
value: extractValue(reply.result),
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
+354
View File
@@ -0,0 +1,354 @@
// `tool.request_help` — pause and ask the human to act on a tab.
//
// Brings the target tab to the foreground, resolves `ref` targets to a
// temporary attribute selector via CDP, asks the content script to enter
// help mode (HelpRequestOverlay + hidden ControlOverlay), and blocks
// until the user clicks Continue / Cancel, the wait times out, or the
// daemon cancels the RPC.
import type { SessionManager } from "@/session-manager/manager";
import {
HELP_CANCEL,
HELP_REQUEST,
HELP_RESPONSE,
type HelpCancelMessage,
type HelpRequestMessage,
type HelpResponseMessage,
} from "@/lib/help-bridge";
import type {
HelpTarget,
RequestHelpParams,
RequestHelpResult,
ResolvedTarget,
RpcError,
} from "@/transport/types";
import { lookupSnapshotRef } from "./snapshot-ref";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
enforceAgentWindow,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
const DEFAULT_HELP_TIMEOUT_MS = 300_000;
/** Attribute the overlay highlights for resolved `ref` targets. */
const HELP_ATTR = "data-bsk-help";
export interface RequestHelpNotifications {
create(id: string, options: chrome.notifications.NotificationOptions<true>): Promise<string>;
clear(id: string): Promise<boolean>;
}
export type TabNavigationUnsubscribe = () => void;
export interface RequestHelpDeps {
tabsApi: ChromeTabsApi;
windows: { update(windowId: number, info: { focused?: boolean }): Promise<chrome.windows.Window> };
/** Activate a tab inside its window. */
activateTab(tabId: number): Promise<void>;
/** Send the help-request to the tab's content script and await reply. */
sendToTab(tabId: number, msg: HelpRequestMessage | HelpCancelMessage): Promise<unknown>;
/**
* Watch for navigation on `tabId` (full page load or SPA URL change).
* Invokes `onNavigated` once; returns unsubscribe.
*/
watchTabNavigation: (tabId: number, onNavigated: () => void) => TabNavigationUnsubscribe;
cdp?: CdpRunner;
/** Pass `null` to skip OS notifications (tests). */
notifications: RequestHelpNotifications | null;
notificationCopy?: { title: string; body: string };
signal?: AbortSignal;
}
/** Default navigation watcher: full reload (`loading`) and SPA (`url`). */
export function defaultWatchTabNavigation(
tabId: number,
onNavigated: () => void,
): TabNavigationUnsubscribe {
const listener = (
updatedTabId: number,
changeInfo: chrome.tabs.TabChangeInfo,
) => {
if (updatedTabId !== tabId) return;
if (changeInfo.url !== undefined || changeInfo.status === "loading") {
chrome.tabs.onUpdated.removeListener(listener);
onNavigated();
}
};
chrome.tabs.onUpdated.addListener(listener);
return () => chrome.tabs.onUpdated.removeListener(listener);
}
let defaultDeps: RequestHelpDeps | null = null;
function getDefaultDeps(): RequestHelpDeps {
if (!defaultDeps) {
defaultDeps = {
tabsApi: chromeTabsApi,
windows: { update: (id, info) => chrome.windows.update(id, info) },
activateTab: async (tabId) => {
await chrome.tabs.update(tabId, { active: true });
},
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
watchTabNavigation: defaultWatchTabNavigation,
notifications: {
create: (id, opts) =>
new Promise((resolve, reject) =>
chrome.notifications.create(id, opts, (rid) => {
const err = chrome.runtime?.lastError;
if (err) reject(new Error(err.message ?? String(err)));
else resolve(rid ?? id);
}),
),
clear: (id) => new Promise((resolve) => chrome.notifications.clear(id, (c) => resolve(c ?? false))),
},
};
}
return defaultDeps;
}
function makeRequestId(tabId: number): string {
return `${tabId}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Tag a `ref` target's DOM node with `data-bsk-help="<i>"` via CDP so the
* content overlay can highlight it with a plain attribute selector.
* Returns the selector on success, or null when the ref can't resolve.
*/
async function tagRefTarget(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
index: number,
): Promise<string | null> {
let objectId: string | undefined;
try {
const resolved = await cdp.send<{ object?: { objectId?: string } }>(tabId, "DOM.resolveNode", {
backendNodeId,
});
objectId = resolved.object?.objectId;
if (!objectId) return null;
await cdp.send(tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function(){ this.setAttribute("${HELP_ATTR}", "${index}"); }`,
});
return `[${HELP_ATTR}="${index}"]`;
} catch {
return null;
} finally {
if (objectId) {
await cdp.send(tabId, "Runtime.releaseObject", { objectId }).catch(() => {});
}
}
}
/** Returns true when `selector` matches a live element in the page. */
async function selectorExists(
cdp: CdpRunner,
tabId: number,
rootNodeId: number,
selector: string,
): Promise<boolean> {
try {
const res = await cdp.send<{ nodeId?: number }>(tabId, "DOM.querySelector", {
nodeId: rootNodeId,
selector,
});
return typeof res.nodeId === "number" && res.nodeId !== 0;
} catch {
return false;
}
}
/** Remove every help attribute we added (best-effort cleanup). */
async function clearRefTags(cdp: CdpRunner | undefined, tabId: number): Promise<void> {
if (!cdp) return;
try {
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", { depth: 0 });
const rootId = doc.root?.nodeId;
if (rootId === undefined) return;
const { nodeIds } = await cdp.send<{ nodeIds: number[] }>(tabId, "DOM.querySelectorAll", {
nodeId: rootId,
selector: `[${HELP_ATTR}]`,
});
for (const nodeId of nodeIds ?? []) {
await cdp.send(tabId, "DOM.removeAttribute", { nodeId, name: HELP_ATTR }).catch(() => {});
}
} catch {
// best-effort
}
}
export async function handleRequestHelp(
manager: SessionManager,
params: RequestHelpParams,
deps: RequestHelpDeps = getDefaultDeps(),
): Promise<RequestHelpResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "request_help");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
if (!params.prompt || typeof params.prompt !== "string") {
return { code: "invalid_params", message: "request_help requires a prompt" };
}
if (deps.signal?.aborted) {
return { code: "cancelled", message: "request_help aborted" };
}
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "request_help");
if (denied) return denied;
const tabId = target.tabId;
// Resolve targets → selectors. `selector` passes through; `ref` is
// tagged with an attribute via CDP and reported as its attr selector.
const targets = params.targets ?? [];
const selectors: string[] = [];
const resolved: ResolvedTarget[] = [];
let rootNodeId: number | undefined;
if (deps.cdp && targets.length > 0) {
try {
const doc = await deps.cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", {
depth: 0,
});
rootNodeId = doc.root?.nodeId;
} catch {
rootNodeId = undefined;
}
}
for (let i = 0; i < targets.length; i++) {
const tgt = targets[i] as HelpTarget;
if (tgt.selector) {
selectors.push(tgt.selector);
let matched: boolean;
if (deps.cdp) {
matched =
rootNodeId !== undefined
? await selectorExists(deps.cdp, tabId, rootNodeId, tgt.selector)
: false;
} else {
matched = true;
}
resolved.push({ matched, selector: tgt.selector });
} else if (tgt.ref) {
const looked = lookupSnapshotRef(ctx, tgt.ref, tabId);
const backendNodeId = looked?.backendNodeId ?? null;
let sel: string | null = null;
if (backendNodeId !== null && deps.cdp) {
sel = await tagRefTarget(deps.cdp, tabId, backendNodeId, i);
}
if (sel) selectors.push(sel);
resolved.push({ matched: sel !== null, ref: tgt.ref });
}
}
// Bring the tab to the foreground.
await deps.windows.update(target.windowId, { focused: true }).catch(() => {});
await deps.activateTab(tabId).catch(() => {});
const requestId = makeRequestId(tabId);
const notificationId = `bsk-help:${requestId}`;
const timeoutMs = params.timeout_ms ?? DEFAULT_HELP_TIMEOUT_MS;
// OS notification (best-effort).
if (deps.notifications) {
const copy = deps.notificationCopy ?? {
title: "BrowserSkill: Agent needs your help",
body: params.prompt,
};
await deps.notifications
.create(notificationId, {
type: "basic",
iconUrl: "icon/logo.png",
title: copy.title,
message: copy.body || params.prompt,
priority: 2,
})
.catch(() => {});
}
const cleanup = async () => {
await clearRefTags(deps.cdp, tabId);
if (deps.notifications) await deps.notifications.clear(notificationId).catch(() => {});
// Retract the overlay in case we resolved via timeout / abort.
await deps
.sendToTab(tabId, { type: HELP_CANCEL, requestId } satisfies HelpCancelMessage)
.catch(() => {});
};
const request: HelpRequestMessage = {
type: HELP_REQUEST,
requestId,
prompt: params.prompt,
...(params.title ? { title: params.title } : {}),
selectors,
timeoutMs,
};
const resolvedTargets = resolved.length > 0 ? resolved : undefined;
return new Promise<RequestHelpResult | RpcError>((resolveOuter) => {
let settled = false;
const unwatchNav = deps.watchTabNavigation(tabId, () => {
finish({ outcome: "navigated", tab_id: tabId, resolved_targets: resolvedTargets });
});
const finish = (value: RequestHelpResult | RpcError) => {
if (settled) return;
settled = true;
if (timer !== null) clearTimeout(timer);
unwatchNav();
deps.signal?.removeEventListener("abort", onAbort);
void cleanup();
resolveOuter(value);
};
const onAbort = () => finish({ code: "cancelled", message: "request_help aborted" });
const timer = setTimeout(() => {
finish({ outcome: "timed_out", tab_id: tabId, resolved_targets: resolvedTargets });
}, timeoutMs) as unknown as number;
if (deps.signal) {
if (deps.signal.aborted) {
onAbort();
return;
}
deps.signal.addEventListener("abort", onAbort, { once: true });
}
deps
.sendToTab(tabId, request)
.then((reply) => {
const res = reply as HelpResponseMessage | undefined;
if (
!res ||
res.type !== HELP_RESPONSE ||
(res.outcome !== "continued" && res.outcome !== "cancelled")
) {
finish({
code: "protocol_error",
message: `invalid help response from tab ${tabId}`,
});
return;
}
finish({
outcome: res.outcome,
...(res.note ? { note: res.note } : {}),
tab_id: tabId,
resolved_targets: resolvedTargets,
});
})
.catch((err) => {
finish({
code: "protocol_error",
message: `failed to show help overlay on tab ${tabId}: ${
err instanceof Error ? err.message : String(err)
}`,
});
});
});
}
+955
View File
@@ -0,0 +1,955 @@
// DOM interaction tools — `tool.click`, `tool.fill`, `tool.press`, and
// `tool.select`.
//
// All interaction tools:
// 1. Resolve target tab (sandbox: must be inside Agent Window).
// 2. Resolve target element by `ref` (RefStore.resolve with tabId
// binding) or `selector` (DOM.querySelector + describeNode).
// 3. Scroll the node into view, then dispatch the appropriate
// `Input.*` CDP events.
// 4. Honour `AbortSignal` so canceled calls don't issue follow-up CDP
// commands.
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import type {
ClickParams,
ClickResult,
FillParams,
FillResult,
KeyModifier,
MouseButton,
PressParams,
PressResult,
SelectParams,
SelectResult,
RpcError,
} from "@/transport/types";
import {
backendNodeToObject,
boxCentre,
nodeCentre,
quadCentre,
scrollNodeIntoView,
} from "./element-geometry";
import { rpcError } from "./errors";
import { resolveSnapshotRef } from "./snapshot-ref";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
enforceAgentWindow,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
export interface InteractionDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
/** Abort hook (full chain wired in M10.2). */
signal?: AbortSignal;
defaultTimeoutMs?: number;
/** Temporarily disable overlay click blocker during CDP automation. */
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
}
const DEFAULT_TIMEOUT_MS = 30_000;
let defaultDeps: { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } | null = null;
function getDefaultDeps(): { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } {
if (!defaultDeps) {
defaultDeps = { cdp: new ChromiumCdp(), tabsApi: chromeTabsApi };
}
return defaultDeps;
}
/**
* Fold a list of `KeyModifier`s into CDP's bit layout (§4 of the
* CDP Input domain): alt=1, ctrl=2, meta=4, shift=8.
*
* Exported for unit tests.
*/
export function modifiersBitfield(mods: KeyModifier[] | undefined): number {
if (!mods) return 0;
let bits = 0;
for (const m of mods) {
switch (m) {
case "alt":
bits |= 1;
break;
case "ctrl":
bits |= 2;
break;
case "meta":
bits |= 4;
break;
case "shift":
bits |= 8;
break;
}
}
return bits;
}
function throwIfAborted(signal: AbortSignal | undefined): RpcError | null {
if (signal?.aborted) {
return { code: "cancelled", message: "interaction aborted" };
}
return null;
}
/**
* Resolve `{ref?, selector?}` into a `backendNodeId`. Returns an
* `RpcError` if the caller supplied neither (or both), or if neither
* lookup matched.
*/
async function resolveBackendNode(
cdp: CdpRunner,
ctx: SessionContext,
target: { tabId: number },
params: { ref?: string; selector?: string },
toolName: string,
): Promise<{ backendNodeId: number; usedRef?: string; usedSelector?: string } | RpcError> {
const hasRef = typeof params.ref === "string" && params.ref.length > 0;
const hasSelector = typeof params.selector === "string" && params.selector.length > 0;
if (hasRef && hasSelector) {
return {
code: "invalid_params",
message: `${toolName}: pass either ref or selector, not both`,
};
}
if (!hasRef && !hasSelector) {
return {
code: "invalid_params",
message: `${toolName} requires a ref or a selector`,
};
}
if (hasRef) {
const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId);
if (isRpcError(resolved)) return resolved;
return { backendNodeId: resolved.backendNodeId, usedRef: resolved.refKey };
}
// selector path
try {
const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", {
depth: 0,
});
const rootNodeId = doc.root?.nodeId;
if (typeof rootNodeId !== "number") {
return {
code: "cdp_failed",
message: "DOM.getDocument returned no root nodeId",
};
}
const found = await cdp.send<{ nodeId?: number }>(target.tabId, "DOM.querySelector", {
nodeId: rootNodeId,
selector: params.selector,
});
if (typeof found.nodeId !== "number" || found.nodeId === 0) {
return rpcError(
"not_found",
"selector_not_found",
`selector ${params.selector} did not match any element`,
);
}
const described = await cdp.send<{ node?: { backendNodeId?: number } }>(
target.tabId,
"DOM.describeNode",
{ nodeId: found.nodeId },
);
const backendNodeId = described.node?.backendNodeId;
if (typeof backendNodeId !== "number") {
return {
code: "cdp_failed",
message: "DOM.describeNode returned no backendNodeId",
};
}
return { backendNodeId, usedSelector: params.selector };
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
// ---------------------------------------------------------------------------
// tool.click
// ---------------------------------------------------------------------------
export async function handleClick(
manager: SessionManager,
params: ClickParams,
deps: InteractionDeps = getDefaultDeps(),
): Promise<ClickResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "click");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
const aborted = throwIfAborted(deps.signal);
if (aborted) return aborted;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "click");
if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "click");
if (isRpcError(node)) return node;
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "click aborted" };
}
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId);
if (scrollErr) return scrollErr;
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
const centre = await nodeCentre(deps.cdp, target.tabId, node.backendNodeId);
if (isRpcError(centre)) return centre;
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "click aborted" };
}
const button: MouseButton = params.button ?? "left";
const clickCount = params.click_count ?? 1;
if (clickCount < 1) {
return { code: "invalid_params", message: "click_count must be greater than zero" };
}
const modifiers = modifiersBitfield(params.modifiers);
const overlayBlocking = await checkOverlayAtPoint(deps.cdp, target.tabId, centre.x, centre.y);
let automationBypassEnabled = false;
if (overlayBlocking && deps.bypassOverlay) {
try {
await deps.bypassOverlay(target.tabId, true);
automationBypassEnabled = true;
} catch (err) {
console.debug("[bsk interaction] overlay bypass enable failed", err);
}
}
try {
// Move first so hover state activates, then press → release.
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: centre.x,
y: centre.y,
modifiers,
});
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "click aborted" };
}
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
type: "mousePressed",
x: centre.x,
y: centre.y,
button,
clickCount,
modifiers,
});
if (throwIfAborted(deps.signal)) {
try {
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
type: "mouseReleased",
x: centre.x,
y: centre.y,
button,
clickCount,
modifiers,
});
} catch (err) {
console.debug("[bsk interaction] best-effort mouseReleased after abort failed", err);
}
return { code: "cancelled", message: "click aborted" };
}
await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", {
type: "mouseReleased",
x: centre.x,
y: centre.y,
button,
clickCount,
modifiers,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
} finally {
if (automationBypassEnabled && deps.bypassOverlay) {
try {
await deps.bypassOverlay(target.tabId, false);
} catch (err) {
console.debug("[bsk interaction] overlay bypass disable failed", err);
}
}
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
x: centre.x,
y: centre.y,
});
}
interface OverlayHitInspection {
overlayHostPresent: boolean;
overlayHostConnected: boolean;
hitIndex: number;
}
/**
* Returns true when the control overlay shadow root has a visible layer
* with pointer-events blocking the click point (mirrors intern execClick).
*/
async function checkOverlayAtPoint(
cdp: CdpRunner,
tabId: number,
x: number,
y: number,
): Promise<boolean> {
try {
const hitTest = await cdp.send<{
result?: {
value?: {
overlayHostPresent?: boolean;
overlayHostConnected?: boolean;
} | null;
};
}>(tabId, "Runtime.evaluate", {
expression: `(function() {
const overlayHost = document.querySelector("[data-bsk-overlay]");
return {
overlayHostPresent: !!overlayHost,
overlayHostConnected: !!overlayHost?.isConnected,
};
})()`,
returnByValue: true,
});
const hitTarget = hitTest.result?.value ?? null;
if (!hitTarget?.overlayHostPresent || !hitTarget.overlayHostConnected) {
return false;
}
const inspection = await cdp.send<{
result?: { value?: OverlayHitInspection | null };
}>(tabId, "Runtime.evaluate", {
expression: `(function() {
const overlayHost = document.querySelector("[data-bsk-overlay]");
const shadowRoot = overlayHost instanceof HTMLElement ? overlayHost.shadowRoot : null;
const overlays = Array.from(shadowRoot?.querySelectorAll("*") ?? []);
const overlayDetails = overlays
.map((node) => {
if (!(node instanceof HTMLElement)) return null;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return {
display: style.display,
pointerEvents: style.pointerEvents,
connected: node.isConnected,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
};
})
.filter((node) => {
if (!node) return false;
return (
node.connected &&
node.display !== "none" &&
node.pointerEvents !== "none" &&
node.rect.width > 0 &&
node.rect.height > 0
);
});
const hitIndex = overlayDetails.findIndex((node) => {
const withinX = ${x} >= node.rect.x && ${x} <= node.rect.x + node.rect.width;
const withinY = ${y} >= node.rect.y && ${y} <= node.rect.y + node.rect.height;
return withinX && withinY;
});
return {
overlayHostPresent: true,
overlayHostConnected: true,
hitIndex,
};
})()`,
returnByValue: true,
});
const value = inspection.result?.value;
return (value?.hitIndex ?? -1) >= 0;
} catch (err) {
console.debug("[bsk interaction] overlay hit-test failed", err);
return false;
}
}
/**
* Describes the subset of `DOM.describeNode`'s `node` we inspect to
* decide whether the target is fillable. `attributes` is the CDP
* flat-array form (`[name, value, name, value, ...]`).
*/
interface DescribedNode {
nodeName?: string;
attributes?: string[];
}
/**
* Decide whether a node can receive `tool.fill`: native `<input>` /
* `<textarea>`, or any element flagged `contenteditable="true"`.
* Exported via `__testing__` for unit coverage.
*/
function isFillable(node: DescribedNode): boolean {
const tag = (node.nodeName ?? "").toUpperCase();
if (tag === "INPUT" || tag === "TEXTAREA") return true;
const attrs = node.attributes ?? [];
for (let i = 0; i + 1 < attrs.length; i += 2) {
if (attrs[i].toLowerCase() === "contenteditable") {
const value = attrs[i + 1].toLowerCase();
return value === "" || value === "true" || value === "plaintext-only";
}
}
return false;
}
type SelectMutationResult =
| {
ok: true;
multiple: boolean;
selected_values: string[];
selected_labels: string[];
}
| {
ok: false;
reason: "option_not_found";
missing?: string;
};
// ---------------------------------------------------------------------------
// tool.fill
// ---------------------------------------------------------------------------
export async function handleFill(
manager: SessionManager,
params: FillParams,
deps: InteractionDeps = getDefaultDeps(),
): Promise<FillResult | RpcError> {
if (!params || typeof params.value !== "string") {
return { code: "invalid_params", message: "fill requires a value string" };
}
const ctxOrErr = lookupSession(manager, params, "fill");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "fill");
if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "fill");
if (isRpcError(node)) return node;
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const described = await deps.cdp.send<{ node?: DescribedNode }>(
target.tabId,
"DOM.describeNode",
{
backendNodeId: node.backendNodeId,
},
);
if (!described.node || !isFillable(described.node)) {
return rpcError(
"invalid_params",
"target_not_fillable",
`element ${described.node?.nodeName ?? "?"} not fillable (need input/textarea/contenteditable)`,
);
}
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId);
if (scrollErr) return scrollErr;
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
await deps.cdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
const objectIdOrErr = await backendNodeToObject(deps.cdp, target.tabId, node.backendNodeId);
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
const objectId = objectIdOrErr;
const clearBefore = params.clear_before ?? true;
try {
if (clearBefore) {
// Clear input/textarea value or wipe contenteditable innerText,
// then fire `input` so frameworks observe the empty state.
await deps.cdp.send(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function() {
if (this.isContentEditable) { this.textContent = ''; }
else {
const proto = this instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
if (descriptor && descriptor.set) descriptor.set.call(this, '');
else this.value = '';
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}`,
returnByValue: true,
});
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// CDP `Input.insertText` handles IME / multi-byte input out of the
// box, much more reliably than per-key `dispatchKeyEvent`.
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Fire `input` + `change` so React / Vue controlled inputs commit.
await deps.cdp.send(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}`,
returnByValue: true,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: params.value.length,
});
}
// ---------------------------------------------------------------------------
// tool.press
// ---------------------------------------------------------------------------
const KEY_MODIFIER_SET = new Set<KeyModifier>(["alt", "ctrl", "meta", "shift"]);
/**
* Normalise a modifier token (case-insensitive, accepts `Control` →
* `ctrl`, `Cmd`/`Command` → `meta`, `Option`/`Opt` → `alt`). Returns
* `null` if the token isn't recognised as a modifier.
*/
function normaliseModifier(token: string): KeyModifier | null {
const lower = token.toLowerCase();
if (lower === "ctrl" || lower === "control") return "ctrl";
if (lower === "alt" || lower === "option" || lower === "opt") return "alt";
if (lower === "shift") return "shift";
if (lower === "meta" || lower === "cmd" || lower === "command" || lower === "super") {
return "meta";
}
return null;
}
/**
* Split a compound key spec like `Ctrl+Shift+P` into its modifier
* list and base key name. Single keys (`Enter`, `a`) come back with
* an empty modifier list.
*
* The split is `+`-delimited; modifier matching is case-insensitive
* and order-independent. Whitespace around tokens is trimmed.
*/
export function parseKeySpec(spec: string): { key: string; modifiers: KeyModifier[] } {
const parts = spec
.split("+")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (parts.length <= 1) {
return { key: spec.trim(), modifiers: [] };
}
const mods: KeyModifier[] = [];
let baseKey: string | null = null;
for (const part of parts) {
const mod = normaliseModifier(part);
if (mod) {
if (!mods.includes(mod)) mods.push(mod);
} else {
baseKey = part;
}
}
return { key: baseKey ?? parts[parts.length - 1], modifiers: mods };
}
interface KeyDescriptor {
key: string;
code: string;
text?: string;
windowsVirtualKeyCode?: number;
}
const SPECIAL_KEYMAP: Record<string, KeyDescriptor> = {
Enter: { key: "Enter", code: "Enter", text: "\r", windowsVirtualKeyCode: 13 },
Escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 },
Tab: { key: "Tab", code: "Tab", text: "\t", windowsVirtualKeyCode: 9 },
Backspace: { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 },
Delete: { key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 },
Home: { key: "Home", code: "Home", windowsVirtualKeyCode: 36 },
End: { key: "End", code: "End", windowsVirtualKeyCode: 35 },
PageUp: { key: "PageUp", code: "PageUp", windowsVirtualKeyCode: 33 },
PageDown: { key: "PageDown", code: "PageDown", windowsVirtualKeyCode: 34 },
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 },
ArrowUp: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 },
ArrowRight: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 },
ArrowDown: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 },
Space: { key: " ", code: "Space", text: " ", windowsVirtualKeyCode: 32 },
};
for (let i = 1; i <= 12; i++) {
SPECIAL_KEYMAP[`F${i}`] = {
key: `F${i}`,
code: `F${i}`,
windowsVirtualKeyCode: 111 + i,
};
}
/**
* Translate a logical key name (`Enter`, `a`, `3`, `F5`, `ArrowDown`)
* into the CDP `Input.dispatchKeyEvent` descriptor. Returns `null`
* for keys we don't recognise (caller surfaces `invalid_params`).
*/
export function resolveKeyDescriptor(key: string): KeyDescriptor | null {
if (key.length === 0) return null;
if (SPECIAL_KEYMAP[key]) return SPECIAL_KEYMAP[key];
if (key.length === 1) {
const ch = key;
const upper = ch.toUpperCase();
if (upper >= "A" && upper <= "Z") {
return {
key: ch,
code: `Key${upper}`,
text: ch,
windowsVirtualKeyCode: upper.charCodeAt(0),
};
}
if (ch >= "0" && ch <= "9") {
return {
key: ch,
code: `Digit${ch}`,
text: ch,
windowsVirtualKeyCode: ch.charCodeAt(0),
};
}
// Common punctuation falls back to `text` only. Browsers
// synthesise the right code from `text` when CDP can't find one.
return { key: ch, code: "", text: ch };
}
return null;
}
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
return new Promise((resolve) => {
let settled = false;
let onAbort: (() => void) | null = null;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
resolve();
};
const timer = setTimeout(finish, ms);
if (signal) {
onAbort = finish;
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
export async function handlePress(
manager: SessionManager,
params: PressParams,
deps: InteractionDeps = getDefaultDeps(),
): Promise<PressResult | RpcError> {
if (!params || typeof params.key !== "string" || params.key.length === 0) {
return { code: "invalid_params", message: "press requires a key string" };
}
const ctxOrErr = lookupSession(manager, params, "press");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "press aborted" };
}
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "press");
if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const parsed = parseKeySpec(params.key);
// Merge param `modifiers` with anything parsed from the compound
// key string; de-duplicate.
const mods: KeyModifier[] = [...parsed.modifiers];
for (const m of params.modifiers ?? []) {
if (KEY_MODIFIER_SET.has(m) && !mods.includes(m)) mods.push(m);
}
const descriptor = resolveKeyDescriptor(parsed.key);
if (!descriptor) {
return {
code: "invalid_params",
message: `press: unknown key "${parsed.key}"`,
};
}
// Optional focus before key dispatch.
if (params.ref || params.selector) {
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "press");
if (isRpcError(node)) return node;
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId);
if (scrollErr) return scrollErr;
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "press aborted" };
}
await deps.cdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "press aborted" };
}
const modifiers = modifiersBitfield(mods);
// Suppress `text` when any non-shift modifier is held — `Ctrl+a`
// should not also type the character "a" into the focused field.
const suppressText = mods.some((m) => m === "ctrl" || m === "meta" || m === "alt");
try {
let cancelled = false;
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
type: "rawKeyDown",
key: descriptor.key,
code: descriptor.code,
windowsVirtualKeyCode: descriptor.windowsVirtualKeyCode,
modifiers,
});
cancelled = throwIfAborted(deps.signal) !== null;
if (
!cancelled &&
!suppressText &&
typeof descriptor.text === "string" &&
descriptor.text.length > 0
) {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
type: "char",
key: descriptor.key,
code: descriptor.code,
text: descriptor.text,
modifiers,
});
cancelled = throwIfAborted(deps.signal) !== null;
}
if (!cancelled && params.hold_ms && params.hold_ms > 0) {
await sleep(params.hold_ms, deps.signal);
if (throwIfAborted(deps.signal)) {
// Still send keyUp so the page doesn't think the key is stuck
// down — best-effort.
cancelled = true;
}
}
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
type: "keyUp",
key: descriptor.key,
code: descriptor.code,
windowsVirtualKeyCode: descriptor.windowsVirtualKeyCode,
modifiers,
});
if (cancelled || throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "press aborted" };
}
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
key: descriptor.key,
code: descriptor.code,
modifiers: mods,
});
}
// ---------------------------------------------------------------------------
// tool.select
// ---------------------------------------------------------------------------
export async function handleSelect(
manager: SessionManager,
params: SelectParams,
deps: InteractionDeps = getDefaultDeps(),
): Promise<SelectResult | RpcError> {
if (!params || !Array.isArray(params.values)) {
return { code: "invalid_params", message: "select requires a values array" };
}
if (!params.values.every((value) => typeof value === "string")) {
return { code: "invalid_params", message: "select values must all be strings" };
}
const ctxOrErr = lookupSession(manager, params, "select");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "select aborted" };
}
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "select");
if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "select");
if (isRpcError(node)) return node;
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const described = await deps.cdp.send<{ node?: DescribedNode }>(
target.tabId,
"DOM.describeNode",
{ backendNodeId: node.backendNodeId },
);
if (!described.node || (described.node.nodeName ?? "").toUpperCase() !== "SELECT") {
return rpcError(
"invalid_params",
"target_not_select",
`element ${described.node?.nodeName ?? "?"} not a <select>`,
);
}
const attrs = described.node.attributes ?? [];
const isMultiple = attrs.some((attr, idx) => idx % 2 === 0 && attr.toLowerCase() === "multiple");
if (!isMultiple && params.values.length !== 1) {
return rpcError(
"invalid_params",
"single_select_value_count",
"single-select <select> requires exactly one value",
);
}
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId);
if (scrollErr) return scrollErr;
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "select aborted" };
}
await deps.cdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "select aborted" };
}
const objectIdOrErr = await backendNodeToObject(deps.cdp, target.tabId, node.backendNodeId);
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
const objectId = objectIdOrErr;
try {
const evaluated = await deps.cdp.send<{
result?: { value?: SelectMutationResult | null };
}>(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function(values) {
const select = this;
const multiple = select.multiple;
const known = new Set(Array.from(select.options).map((o) => o.value));
for (const v of values) {
if (!known.has(v)) {
return { ok: false, reason: 'option_not_found', missing: v };
}
}
const want = new Set(values);
for (const opt of select.options) {
opt.selected = want.has(opt.value);
}
select.dispatchEvent(new Event('input', { bubbles: true }));
select.dispatchEvent(new Event('change', { bubbles: true }));
const selected = Array.from(select.selectedOptions);
return {
ok: true,
multiple,
selected_values: selected.map((o) => o.value),
selected_labels: selected.map((o) => o.text),
};
}`,
arguments: [{ value: params.values }],
returnByValue: true,
});
const mutation = evaluated.result?.value;
if (!mutation?.ok) {
if (mutation?.reason === "option_not_found") {
return rpcError(
"invalid_params",
"option_not_found",
`option value ${mutation.missing ?? "?"} not found in <select>`,
);
}
return { code: "cdp_failed", message: "select mutation returned an unexpected result" };
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
multiple: mutation.multiple,
selected_values: mutation.selected_values,
selected_labels: mutation.selected_labels,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
export const __testing__ = {
DEFAULT_TIMEOUT_MS,
quadCentre,
boxCentre,
resolveBackendNode,
nodeCentre,
isFillable,
};
+785
View File
@@ -0,0 +1,785 @@
// Navigation tools — `tool.navigate`, `tool.navigate_back`,
// `tool.navigate_forward`, `tool.reload` (design §7, plan M7.1 / M7.2).
//
// Each handler:
// 1. Resolves the target tab (sandbox: must be in the session's Agent
// Window — review M7).
// 2. Attaches CDP, enables `Page` lifecycle events.
// 3. Kicks off the actual navigation (`Page.navigate`,
// `Page.navigateToHistoryEntry`, or `Page.reload`).
// 4. Awaits the requested `wait_until` lifecycle phase, bounded by
// `timeout_ms` and the caller's `AbortSignal`. Listener cleanup is
// explicit so an aborted call never leaves a dangling listener
// behind (review M7 abort guidance).
//
// The returned shape mirrors bsk-protocol's `NavigateResult` /
// `NavigateBackResult` / `NavigateForwardResult` / `ReloadResult`.
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type {
NavigateBackParams,
NavigateForwardParams,
NavigateHistoryResult,
NavigateParams,
NavigateResult,
ReloadParams,
ReloadResult,
RpcError,
WaitUntil,
} from "@/transport/types";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
enforceAgentWindow,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
export interface NavigationDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
/** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */
signal?: AbortSignal;
/** Override default timeout when the caller omits `timeout_ms`. */
defaultTimeoutMs?: number;
}
const DEFAULT_NAV_TIMEOUT_MS = 30_000;
const DEFAULT_HISTORY_TIMEOUT_MS = 15_000;
/**
* Translate a wire `wait_until` value into the lifecycle event name
* CDP emits. Exported for unit tests.
*/
export function cdpLifecycleName(wu: WaitUntil): string {
switch (wu) {
case "load":
return "load";
case "domcontentloaded":
return "DOMContentLoaded";
case "networkidle":
return "networkIdle";
case "commit":
return "commit";
}
}
interface ReadyStateProbeReply {
result?: { value?: unknown };
}
/**
* Read `document.readyState` on the tab's main execution context.
* Returns `null` when CDP cannot evaluate (detached tab, etc.).
*/
export async function probeMainFrameReadyState(
cdp: CdpRunner,
tabId: number,
): Promise<string | null> {
try {
await cdp.send(tabId, "Runtime.enable", {});
const reply = await cdp.send<ReadyStateProbeReply>(tabId, "Runtime.evaluate", {
expression: "document.readyState",
returnByValue: true,
});
const value = reply.result?.value;
return typeof value === "string" ? value : null;
} catch {
return null;
}
}
/** `wait_until` phases in navigation order (paint events excluded). */
const WAIT_UNTIL_PHASE_ORDER = ["commit", "DOMContentLoaded", "load", "networkIdle"] as const;
/**
* Whether a lifecycle event name satisfies the requested phase (exact
* match or a later phase in the navigation pipeline).
*/
export function lifecycleMeetsOrExceeds(observed: string, targetName: string): boolean {
if (observed === targetName) return true;
const oi = WAIT_UNTIL_PHASE_ORDER.indexOf(observed as (typeof WAIT_UNTIL_PHASE_ORDER)[number]);
const ti = WAIT_UNTIL_PHASE_ORDER.indexOf(targetName as (typeof WAIT_UNTIL_PHASE_ORDER)[number]);
if (oi < 0 || ti < 0) return false;
return oi >= ti;
}
/**
* Whether the main document has already reached (or passed) the CDP
* lifecycle phase named by `targetName`, inferred from
* `document.readyState`.
*/
export function lifecycleAlreadyReached(readyState: string, targetName: string): boolean {
switch (targetName) {
case "load":
return readyState === "complete";
case "DOMContentLoaded":
return readyState === "interactive" || readyState === "complete";
case "commit":
return (
readyState === "interactive" || readyState === "complete" || readyState === "loading"
);
case "networkIdle":
// Must come from `Page.lifecycleEvent`; readyState does not imply network idle.
return false;
default:
return false;
}
}
/** When a readyState probe may short-circuit an in-flight lifecycle wait. */
export type LifecycleProbeMode = "passive" | "after-navigation";
/**
* Decide whether `document.readyState` is safe to treat as "already
* done" for the requested lifecycle target.
*
* * `passive` — `wait_for_navigation` on a settled page (no navigation
* initiated by this handler).
* * `after-navigation` — following `navigate` / `reload` / history;
* rejects `complete`→`complete` with no observed lifecycle events so
* we do not confuse the previous document for the new load.
*/
export function shouldTrustReadyStateProbe(
readyState: string,
targetName: string,
context: {
mode: LifecycleProbeMode;
beforeReadyState: string | null;
sawRelevantLifecycle: boolean;
},
): boolean {
if (!lifecycleAlreadyReached(readyState, targetName)) return false;
if (context.mode === "passive") return true;
if (context.sawRelevantLifecycle) return true;
if (context.beforeReadyState === "complete" && readyState === "complete") {
return false;
}
return true;
}
let defaultDeps: { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } | null = null;
function getDefaultDeps(): { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } {
if (!defaultDeps) {
defaultDeps = {
cdp: new ChromiumCdp(),
tabsApi: chromeTabsApi,
};
}
return defaultDeps;
}
/**
* Wait for the lifecycle event matching `targetName` on `frameId`, or
* resolve early when the AbortSignal fires / `timeoutMs` elapses.
* Returns either `{ reached: "load" }` on success, or
* `{ reached: "timeout", lastReached?: "<wait_until>" }` if the
* timeout hit before the requested name.
*/
interface WaitOutcome {
reached: "match" | "timeout" | "cancelled";
lastLifecycle?: string;
}
interface LifecycleWait {
promise: Promise<WaitOutcome>;
refresh(): void;
tryProbe(mode: LifecycleProbeMode, beforeReadyState?: string | null): Promise<void>;
}
/** Loader / frame guards for active navigations (stale document rejection). */
export interface LifecycleWaitGuard {
loaderId?: string | (() => string | null | undefined);
beforeLoaderId?: string | null;
}
function currentLoaderId(guard: LifecycleWaitGuard | undefined): string {
if (!guard?.loaderId) return "";
return typeof guard.loaderId === "function" ? (guard.loaderId() ?? "") : guard.loaderId;
}
/** Reject lifecycle events from the pre-navigation loader or wrong loader. */
export function eventLoaderIsRelevant(
eventLoaderId: string | undefined,
guard: LifecycleWaitGuard | undefined,
): boolean {
const before = guard?.beforeLoaderId ?? null;
if (eventLoaderId && before && eventLoaderId === before) return false;
const expected = currentLoaderId(guard);
if (expected.length > 0) {
if (!eventLoaderId) return false;
return eventLoaderId === expected;
}
return true;
}
function startLifecycleWait(
cdp: CdpRunner,
expectedTabId: number,
frameId: string | (() => string | null | undefined),
targetName: string,
timeoutMs: number,
signal: AbortSignal | undefined,
guard?: LifecycleWaitGuard,
): LifecycleWait {
let refresh = () => {};
let tryProbe = async (_mode: LifecycleProbeMode, _beforeReadyState?: string | null) => {};
const promise = new Promise<WaitOutcome>((resolve) => {
let settled = false;
let sawRelevantLifecycle = false;
let lastLifecycle: string | undefined;
let pendingLifecycle: { name: string; frameId?: string; loaderId?: string } | null = null;
let listenerSub: { dispose(): void } | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let abortHandler: (() => void) | null = null;
const currentFrameId = () => (typeof frameId === "function" ? (frameId() ?? "") : frameId);
const noteRelevantLifecycle = (name: string) => {
sawRelevantLifecycle = true;
lastLifecycle = name;
};
const lifecycleEventMatchesFrame = (eventFrameId?: string): boolean => {
const expectedFrameId = currentFrameId();
if (expectedFrameId.length === 0) return true;
return eventFrameId === expectedFrameId;
};
const acceptLifecycleEvent = (
name: string,
eventFrameId?: string,
eventLoaderId?: string,
): boolean => {
if (!eventLoaderIsRelevant(eventLoaderId, guard)) return false;
if (!lifecycleEventMatchesFrame(eventFrameId)) return false;
noteRelevantLifecycle(name);
return true;
};
const cleanup = () => {
if (listenerSub) {
listenerSub.dispose();
listenerSub = null;
}
if (timer) {
clearTimeout(timer);
timer = null;
}
if (abortHandler && signal) {
signal.removeEventListener("abort", abortHandler);
abortHandler = null;
}
};
const finish = (outcome: WaitOutcome) => {
if (settled) return;
settled = true;
cleanup();
resolve(outcome);
};
const maybeFinishPending = () => {
if (settled || !pendingLifecycle) return;
if (!acceptLifecycleEvent(pendingLifecycle.name, pendingLifecycle.frameId, pendingLifecycle.loaderId)) {
return;
}
if (lifecycleMeetsOrExceeds(pendingLifecycle.name, targetName)) {
finish({ reached: "match", lastLifecycle: pendingLifecycle.name });
}
};
refresh = maybeFinishPending;
tryProbe = async (mode: LifecycleProbeMode, beforeReadyState: string | null = null) => {
if (settled) return;
const readyState = await probeMainFrameReadyState(cdp, expectedTabId);
if (readyState === null) return;
if (
shouldTrustReadyStateProbe(readyState, targetName, {
mode,
beforeReadyState,
sawRelevantLifecycle,
})
) {
finish({ reached: "match", lastLifecycle: targetName });
}
};
if (signal?.aborted) {
finish({ reached: "cancelled", lastLifecycle });
return;
}
if (cdp.onEvent) {
listenerSub = cdp.onEvent(
(source: chrome.debugger.Debuggee, method: string, params: unknown) => {
if (settled) return;
if (source.tabId !== expectedTabId) return;
if (targetName === "commit" && method === "Page.frameNavigated") {
const p = params as { frame?: { id?: string; parentId?: string } };
const expectedFrameId = currentFrameId();
if (expectedFrameId.length > 0) {
if (p.frame?.id !== expectedFrameId) return;
} else if (p.frame?.parentId) {
return;
}
if (!acceptLifecycleEvent("commit", p.frame?.id)) return;
finish({ reached: "match", lastLifecycle: "commit" });
return;
}
if (method !== "Page.lifecycleEvent") return;
const p = params as { name?: string; frameId?: string; loaderId?: string };
if (!p?.name) return;
if (currentFrameId().length === 0) {
// A static empty `frameId` means the caller does not know
// which frame to filter on (M9.2 `wait_for_navigation`
// observes a navigation it did not initiate). Match by
// tab-id only in that case. A callback form that
// currently returns "" is the M7 navigate path — the
// frame becomes known after `Page.navigate` resolves, so
// we still stash until `refresh()` runs.
if (typeof frameId !== "function") {
if (!acceptLifecycleEvent(p.name, p.frameId, p.loaderId)) return;
if (lifecycleMeetsOrExceeds(p.name, targetName)) {
finish({ reached: "match", lastLifecycle: p.name });
}
return;
}
if (!eventLoaderIsRelevant(p.loaderId, guard)) return;
if (!lifecycleEventMatchesFrame(p.frameId)) return;
pendingLifecycle = { name: p.name, frameId: p.frameId, loaderId: p.loaderId };
return;
}
if (!acceptLifecycleEvent(p.name, p.frameId, p.loaderId)) return;
if (lifecycleMeetsOrExceeds(p.name, targetName)) {
finish({ reached: "match", lastLifecycle: p.name });
}
},
);
}
if (signal) {
abortHandler = () => {
finish({ reached: "cancelled", lastLifecycle });
};
signal.addEventListener("abort", abortHandler, { once: true });
}
timer = setTimeout(() => {
finish({ reached: "timeout", lastLifecycle });
}, timeoutMs);
});
return {
promise,
refresh: () => refresh(),
tryProbe: (mode, beforeReadyState) => tryProbe(mode, beforeReadyState),
};
}
/**
* Wait for a lifecycle phase on an already-settled page (`passive`
* readyState probe). Used by `wait_for_navigation`.
*/
export async function waitForLifecyclePassive(
cdp: CdpRunner,
expectedTabId: number,
frameId: string,
targetName: string,
timeoutMs: number,
signal: AbortSignal | undefined,
): Promise<WaitOutcome> {
const wait = startLifecycleWait(cdp, expectedTabId, frameId, targetName, timeoutMs, signal);
await wait.tryProbe("passive");
return wait.promise;
}
interface MainFrameInfo {
frameId: string | null;
loaderId: string | null;
}
async function readMainFrameInfo(cdp: CdpRunner, tabId: number): Promise<MainFrameInfo> {
try {
const tree = await cdp.send<{
frameTree?: { frame?: { id?: string; loaderId?: string } };
}>(tabId, "Page.getFrameTree", {});
return {
frameId: tree.frameTree?.frame?.id ?? null,
loaderId: tree.frameTree?.frame?.loaderId ?? null,
};
} catch {
return { frameId: null, loaderId: null };
}
}
/** Shared tail for navigate / reload / history after the CDP command ran. */
async function finishLifecycleWait(
wait: LifecycleWait,
waitPromise: Promise<WaitOutcome>,
beforeReadyState: string | null,
): Promise<WaitOutcome> {
await wait.tryProbe("after-navigation", beforeReadyState);
return waitPromise;
}
function linkedAbortSignal(signal: AbortSignal | undefined): {
signal: AbortSignal;
abort(): void;
cleanup(): void;
} {
const controller = new AbortController();
let abortHandler: (() => void) | null = null;
if (signal?.aborted) {
controller.abort();
} else if (signal) {
abortHandler = () => controller.abort();
signal.addEventListener("abort", abortHandler, { once: true });
}
return {
signal: controller.signal,
abort: () => controller.abort(),
cleanup: () => {
if (abortHandler && signal) {
signal.removeEventListener("abort", abortHandler);
abortHandler = null;
}
},
};
}
/**
* Subscribe to `Page.lifecycleEvent` so the listener is already
* attached BEFORE the navigate / reload command is sent. Returns a
* disposable + a way to fetch the latest known frameId — useful when
* we don't know the frameId up front (history navigation re-uses the
* existing frame, so we filter by tabId only).
*/
export async function ensureCdpReady(cdp: CdpRunner, tabId: number): Promise<void> {
await cdp.send(tabId, "Page.enable", {});
await cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true });
}
async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise<string | undefined> {
try {
const t = await api.get(tabId);
return t.url ?? undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// tool.navigate
// ---------------------------------------------------------------------------
export async function handleNavigate(
manager: SessionManager,
params: NavigateParams,
deps: NavigationDeps = getDefaultDeps(),
): Promise<NavigateResult | RpcError> {
if (!params || typeof params.url !== "string" || params.url.length === 0) {
return { code: "invalid_params", message: "navigate requires a url" };
}
const ctxOrErr = lookupSession(manager, params, "navigate");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "navigate");
if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const waitUntil: WaitUntil = params.wait_until ?? "load";
const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_NAV_TIMEOUT_MS;
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil);
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
let frameId = "";
let loaderId = "";
const waitAbort = linkedAbortSignal(deps.signal);
const wait = startLifecycleWait(
deps.cdp,
target.tabId,
() => frameId,
expected,
timeoutMs,
waitAbort.signal,
{
loaderId: () => loaderId,
beforeLoaderId: beforeFrame.loaderId,
},
);
const waitPromise = wait.promise;
let nav: { frameId: string; loaderId?: string; errorText?: string };
try {
nav = await deps.cdp.send<{ frameId: string; loaderId?: string; errorText?: string }>(
target.tabId,
"Page.navigate",
{ url: params.url },
);
} catch (err) {
waitAbort.abort();
await waitPromise;
waitAbort.cleanup();
throw err;
}
frameId = nav.frameId ?? "";
loaderId = nav.loaderId ?? "";
wait.refresh();
if (nav.errorText) {
waitAbort.abort();
await waitPromise;
waitAbort.cleanup();
return {
code: "cdp_failed",
message: `Page.navigate rejected: ${nav.errorText}`,
};
}
const outcome = await finishLifecycleWait(wait, waitPromise, beforeReadyState);
waitAbort.cleanup();
if (outcome.reached === "cancelled") {
return { code: "cancelled", message: "navigate aborted" };
}
const finalUrl = await readTabUrl(deps.tabsApi, target.tabId);
if (outcome.reached === "match") {
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
url: params.url,
final_url: finalUrl,
reached: waitUntil,
});
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
url: params.url,
final_url: finalUrl,
reached: "timeout",
error_text: `timed out waiting for lifecycle "${expected}" after ${timeoutMs}ms${
outcome.lastLifecycle ? `; last observed "${outcome.lastLifecycle}"` : ""
}`,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
// ---------------------------------------------------------------------------
// Shared history navigation (back / forward / reload)
// ---------------------------------------------------------------------------
interface HistoryDeps extends NavigationDeps {}
interface HistoryEntry {
id: number;
url: string;
}
async function handleHistory(
manager: SessionManager,
params: { session_id?: string; tab_id?: number; wait_until?: WaitUntil; timeout_ms?: number },
direction: "back" | "forward",
deps: HistoryDeps,
): Promise<NavigateHistoryResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, `navigate_${direction}`);
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, `navigate_${direction}`);
if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const waitUntil: WaitUntil = params.wait_until ?? "load";
const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_HISTORY_TIMEOUT_MS;
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId);
const history = await deps.cdp.send<{ currentIndex: number; entries: HistoryEntry[] }>(
target.tabId,
"Page.getNavigationHistory",
{},
);
const idx = history.currentIndex;
const entries = history.entries ?? [];
const targetIdx = direction === "back" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= entries.length) {
return {
code: "invalid_params",
message:
direction === "back"
? "no previous history entry to navigate back to"
: "no forward history entry to navigate to",
};
}
const previousUrl = entries[idx]?.url;
const targetEntry = entries[targetIdx];
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil);
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
const waitAbort = linkedAbortSignal(deps.signal);
const wait = startLifecycleWait(
deps.cdp,
target.tabId,
beforeFrame.frameId ?? "",
expected,
timeoutMs,
waitAbort.signal,
{ beforeLoaderId: beforeFrame.loaderId },
);
const waitPromise = wait.promise;
try {
await deps.cdp.send(target.tabId, "Page.navigateToHistoryEntry", { entryId: targetEntry.id });
} catch (err) {
waitAbort.abort();
await waitPromise;
waitAbort.cleanup();
throw err;
}
const outcome = await finishLifecycleWait(wait, waitPromise, beforeReadyState);
waitAbort.cleanup();
if (outcome.reached === "cancelled") {
return { code: "cancelled", message: `navigate_${direction} aborted` };
}
const finalUrl = await readTabUrl(deps.tabsApi, target.tabId);
if (outcome.reached === "match") {
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
previous_url: previousUrl,
final_url: finalUrl,
reached: waitUntil,
});
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
previous_url: previousUrl,
final_url: finalUrl,
reached: "timeout",
error_text: `timed out waiting for lifecycle "${expected}" after ${timeoutMs}ms${
outcome.lastLifecycle ? `; last observed "${outcome.lastLifecycle}"` : ""
}`,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
export function handleNavigateBack(
manager: SessionManager,
params: NavigateBackParams,
deps: NavigationDeps = getDefaultDeps(),
): Promise<NavigateHistoryResult | RpcError> {
return handleHistory(manager, params, "back", deps);
}
export function handleNavigateForward(
manager: SessionManager,
params: NavigateForwardParams,
deps: NavigationDeps = getDefaultDeps(),
): Promise<NavigateHistoryResult | RpcError> {
return handleHistory(manager, params, "forward", deps);
}
// ---------------------------------------------------------------------------
// tool.reload
// ---------------------------------------------------------------------------
export async function handleReload(
manager: SessionManager,
params: ReloadParams,
deps: NavigationDeps = getDefaultDeps(),
): Promise<ReloadResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "reload");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "reload");
if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const waitUntil: WaitUntil = params.wait_until ?? "load";
const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_HISTORY_TIMEOUT_MS;
const ignoreCache = params.hard === true;
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId);
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil);
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
const waitAbort = linkedAbortSignal(deps.signal);
const wait = startLifecycleWait(
deps.cdp,
target.tabId,
beforeFrame.frameId ?? "",
expected,
timeoutMs,
waitAbort.signal,
{ beforeLoaderId: beforeFrame.loaderId },
);
const waitPromise = wait.promise;
try {
await deps.cdp.send(target.tabId, "Page.reload", { ignoreCache });
} catch (err) {
waitAbort.abort();
await waitPromise;
waitAbort.cleanup();
throw err;
}
const outcome = await finishLifecycleWait(wait, waitPromise, beforeReadyState);
waitAbort.cleanup();
if (outcome.reached === "cancelled") {
return { code: "cancelled", message: "reload aborted" };
}
const finalUrl = await readTabUrl(deps.tabsApi, target.tabId);
if (outcome.reached === "match") {
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
previous_url: previousUrl,
final_url: finalUrl,
reached: waitUntil,
});
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
previous_url: previousUrl,
final_url: finalUrl,
reached: "timeout",
error_text: `timed out waiting for lifecycle "${expected}" after ${timeoutMs}ms${
outcome.lastLifecycle ? `; last observed "${outcome.lastLifecycle}"` : ""
}`,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
export const __testing__ = {
DEFAULT_NAV_TIMEOUT_MS,
DEFAULT_HISTORY_TIMEOUT_MS,
};
+559
View File
@@ -0,0 +1,559 @@
// Read-only observation handlers — `tool.screenshot`, `tool.snapshot`,
// and `tool.get_html` (design §7). Each handler resolves the target
// tab (defaulting to the Agent Window's active tab when omitted) and
// returns a payload that mirrors the bsk-protocol Rust structs.
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type {
GetHtmlParams,
GetHtmlResult,
RpcError,
ScreenshotParams,
ScreenshotResult,
SnapshotParams,
SnapshotResult,
} from "@/transport/types";
import { nodeBoundingRect, scrollNodeIntoView } from "./element-geometry";
import { resolveSnapshotRef } from "./snapshot-ref";
import { rpcError } from "./errors";
import {
type ChromeTabsApi,
isRpcError,
lookupSession,
resolveTargetTab,
type CdpRunner as SharedCdpRunner,
normaliseRef as sharedNormaliseRef,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
// ---------------------------------------------------------------------------
// Shared helpers (legacy aliases — observation.ts kept exporting these
// for M6 callers; the live implementations now live in `./shared`).
// ---------------------------------------------------------------------------
export interface ChromeTabsCaptureApi extends ChromeTabsApi {
captureVisibleTab(windowId: number, opts: chrome.tabs.CaptureVisibleTabOptions): Promise<string>;
}
export const chromeTabsCaptureApi: ChromeTabsCaptureApi = {
captureVisibleTab: (windowId, opts) => chrome.tabs.captureVisibleTab(windowId, opts),
get: (tabId) => chrome.tabs.get(tabId),
query: (q) => chrome.tabs.query(q),
};
/** Re-export so the M6 test suite keeps its import path. */
export const normaliseRef = sharedNormaliseRef;
// ---------------------------------------------------------------------------
// screenshot — `tool.screenshot`
// ---------------------------------------------------------------------------
/**
* Strip the `data:image/...;base64,` prefix from a Chrome
* `captureVisibleTab` dataURL and return the raw base64 payload.
* Falls back to the input untouched when the prefix is missing
* (defensive — Chrome has always included it but we don't want to
* crash if a fork changes behaviour).
*/
export function stripDataUrlPrefix(dataUrl: string): string {
const m = /^data:image\/[a-z+]+;base64,/i.exec(dataUrl);
return m ? dataUrl.slice(m[0].length) : dataUrl;
}
/**
* Parse a PNG's IHDR chunk and return `(width, height)`. Returns
* `null` on any malformed input so callers fall back to `0/0` instead
* of throwing.
*
* PNG layout: 8-byte signature, then a 4-byte length, 4-byte type
* ("IHDR"), then the chunk data — width is bytes 16-19 BE, height is
* 20-23 BE.
*/
export function parsePngDimensions(base64: string): { width: number; height: number } | null {
try {
// atob is available in MV3 service workers.
const head = base64.length > 64 ? base64.slice(0, 64) : base64;
const bin = atob(head);
if (bin.length < 24) return null;
if (bin.charCodeAt(0) !== 0x89 || bin.charCodeAt(1) !== 0x50 || bin.charCodeAt(2) !== 0x4e) {
return null;
}
const u32 = (off: number) =>
(bin.charCodeAt(off) << 24) |
(bin.charCodeAt(off + 1) << 16) |
(bin.charCodeAt(off + 2) << 8) |
bin.charCodeAt(off + 3);
const width = u32(16) >>> 0;
const height = u32(20) >>> 0;
if (width === 0 || height === 0) return null;
return { width, height };
} catch {
return null;
}
}
export interface ScreenshotDeps {
cdp?: SharedCdpRunner;
tabsApi: ChromeTabsApi;
captureApi: ChromeTabsCaptureApi;
}
function defaultScreenshotDeps(): ScreenshotDeps {
return {
cdp: new ChromiumCdp(),
tabsApi: chromeTabsCaptureApi,
captureApi: chromeTabsCaptureApi,
};
}
async function captureElementScreenshot(
cdp: SharedCdpRunner,
tabId: number,
backendNodeId: number,
): Promise<{ image_base64: string; width: number; height: number } | RpcError> {
const scrollErr = await scrollNodeIntoView(cdp, tabId, backendNodeId);
if (scrollErr) return scrollErr;
const rectOrErr = await nodeBoundingRect(cdp, tabId, backendNodeId);
if (isRpcError(rectOrErr)) return rectOrErr;
try {
const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", {
format: "png",
clip: {
x: rectOrErr.x,
y: rectOrErr.y,
width: rectOrErr.width,
height: rectOrErr.height,
scale: 1,
},
});
const image_base64 = shot.data ?? "";
if (!image_base64) {
return { code: "cdp_failed", message: "Page.captureScreenshot returned no data" };
}
const dims = parsePngDimensions(image_base64) ?? {
width: Math.round(rectOrErr.width),
height: Math.round(rectOrErr.height),
};
return { image_base64, width: dims.width, height: dims.height };
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
export async function handleScreenshot(
manager: SessionManager,
params: ScreenshotParams,
deps: ScreenshotDeps = defaultScreenshotDeps(),
): Promise<ScreenshotResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "screenshot");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const dialogCursor = deps.cdp ? markDialogCursor(deps.cdp, target.tabId) : 0;
const withShotDialogs = <T extends object>(result: T) =>
deps.cdp ? attachDialogs(deps.cdp, target.tabId, dialogCursor, result) : result;
const ref = typeof params.ref === "string" && params.ref.length > 0 ? params.ref : null;
if (ref) {
if (!deps.cdp) {
return { code: "cdp_failed", message: "screenshot ref capture requires CDP" };
}
const node = resolveSnapshotRef(ctx, ref, target.tabId);
if (isRpcError(node)) return node;
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const captured = await captureElementScreenshot(deps.cdp, target.tabId, node.backendNodeId);
if (isRpcError(captured)) return captured;
return withShotDialogs({
image_base64: captured.image_base64,
width: captured.width,
height: captured.height,
format: "png",
tab_id: target.tabId,
});
}
if (!target.active) {
return rpcError(
"invalid_params",
"tab_not_active",
`tab ${target.tabId} is not active; screenshot can only capture the visible tab`,
);
}
try {
const dataUrl = await deps.captureApi.captureVisibleTab(target.windowId, { format: "png" });
const image_base64 = stripDataUrlPrefix(dataUrl);
const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 };
return withShotDialogs({
image_base64,
width: dims.width,
height: dims.height,
format: "png",
tab_id: target.tabId,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
// ---------------------------------------------------------------------------
// snapshot — `tool.snapshot`
// ---------------------------------------------------------------------------
/**
* Minimal CDP surface the snapshot algorithm depends on. Backed by
* `ChromiumCdp` in production; tests inject a fake. Re-exported from
* `./shared` so M6 callers see the same type.
*/
export type CdpRunner = SharedCdpRunner;
/** Subset of CDP `AXNode` we care about — see `Accessibility.AXNode`. */
export interface CdpAxNode {
nodeId: string;
parentId?: string;
backendDOMNodeId?: number;
ignored?: boolean;
role?: { type: string; value?: string };
name?: { type: string; value?: string };
description?: { value?: string };
value?: { value?: string | number | boolean };
childIds?: string[];
}
const INTERACTIVE_ROLES = new Set([
"button",
"link",
"checkbox",
"radio",
"textbox",
"combobox",
"listbox",
"option",
"switch",
"tab",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"searchbox",
"slider",
"spinbutton",
"scrollbar",
"treeitem",
]);
const STRUCTURAL_ROLES = new Set([
"heading",
"main",
"navigation",
"banner",
"contentinfo",
"complementary",
"form",
"search",
"region",
"article",
"list",
"listitem",
"table",
"row",
"cell",
"rowheader",
"columnheader",
"dialog",
"alertdialog",
"img",
"figure",
"section",
"RootWebArea",
"WebArea",
]);
const SKIP_ROLES = new Set(["generic", "none", "presentation", "InlineTextBox"]);
/**
* Decide whether an aria node should appear in the rendered tree.
* Exported for unit tests.
*/
export function shouldRender(node: CdpAxNode): boolean {
if (node.ignored) return false;
const role = node.role?.value ?? "";
if (!role) return false;
if (SKIP_ROLES.has(role)) return false;
const name = node.name?.value ?? "";
if (INTERACTIVE_ROLES.has(role)) return true;
if (STRUCTURAL_ROLES.has(role)) return true;
return name.trim().length > 0;
}
/**
* Approximate-token estimator (~4 chars / token, GPT-style). Good
* enough for `max_tokens` budgets.
*/
function estimateTokens(s: string): number {
return Math.ceil(s.length / 4);
}
interface RenderedSnapshot {
text: string;
refs: Array<{ ref: string; backendNodeId: number }>;
truncated: boolean;
}
/**
* Convert an `Accessibility.getFullAXTree` result into the
* `@e<N>`-tagged indented text plus the ref → backendNodeId map for
* the session's RefStore. Exported for unit tests.
*/
export function renderAxTree(
nodes: CdpAxNode[],
opts: { maxDepth?: number; maxTokens?: number } = {},
): RenderedSnapshot {
const byId = new Map<string, CdpAxNode>();
const childParent = new Map<string, string>();
for (const n of nodes) {
byId.set(n.nodeId, n);
}
// Detect roots: nodes whose `parentId` is undefined OR not in the
// returned set. CDP sometimes ships orphan branches.
for (const n of nodes) {
if (n.parentId && byId.has(n.parentId)) {
childParent.set(n.nodeId, n.parentId);
}
}
const roots = nodes.filter((n) => !childParent.has(n.nodeId));
const lines: string[] = [];
const refs: Array<{ ref: string; backendNodeId: number }> = [];
const maxDepth = opts.maxDepth ?? Number.POSITIVE_INFINITY;
const maxTokens = opts.maxTokens ?? Number.POSITIVE_INFINITY;
let truncated = false;
let tokenTruncated = false;
let tokenBudget = 0;
let nextRef = 1;
const walk = (node: CdpAxNode, depth: number, ancestorRendered: boolean): void => {
if (tokenTruncated) return;
const renderThis = shouldRender(node) && depth <= maxDepth;
if (renderThis) {
const role = node.role?.value ?? "";
const name = node.name?.value ?? "";
let line = `${" ".repeat(Math.min(depth, 32))}`;
let ref: string | null = null;
if (typeof node.backendDOMNodeId === "number") {
ref = `e${nextRef}`;
line += `@${ref} `;
}
line += role;
if (name.length > 0) {
const cleaned = name.replace(/\s+/g, " ").trim();
line += ` ${JSON.stringify(cleaned)}`;
}
const value = node.value?.value;
if (typeof value === "string" && value.length > 0 && value !== name) {
line += ` =${JSON.stringify(value.slice(0, 200))}`;
}
const lineTokens = estimateTokens(line) + 1; // +1 for newline
if (tokenBudget + lineTokens > maxTokens) {
truncated = true;
tokenTruncated = true;
return;
}
tokenBudget += lineTokens;
if (ref && typeof node.backendDOMNodeId === "number") {
refs.push({ ref, backendNodeId: node.backendDOMNodeId });
nextRef += 1;
}
lines.push(line);
}
const nextDepth = renderThis ? depth + 1 : ancestorRendered ? depth : depth;
if (depth + 1 > maxDepth && renderThis && (node.childIds?.length ?? 0) > 0) {
// Children would exceed depth cap — note truncation flag without
// bailing on siblings elsewhere in the tree.
truncated = true;
return;
}
for (const cid of node.childIds ?? []) {
const child = byId.get(cid);
if (!child) continue;
if (tokenTruncated) return;
walk(child, nextDepth, ancestorRendered || renderThis);
}
};
for (const r of roots) {
walk(r, 0, false);
if (tokenTruncated) break;
}
return {
text: lines.join("\n"),
refs,
truncated,
};
}
export interface SnapshotDeps {
cdp: CdpRunner;
tabsApi: {
get(tabId: number): Promise<chrome.tabs.Tab>;
query(q: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]>;
};
}
let defaultDeps: SnapshotDeps | null = null;
function getDefaultDeps(): SnapshotDeps {
if (!defaultDeps) {
defaultDeps = {
cdp: new ChromiumCdp(),
tabsApi: { get: (tabId) => chrome.tabs.get(tabId), query: (q) => chrome.tabs.query(q) },
};
}
return defaultDeps;
}
// ---------------------------------------------------------------------------
// get_html — `tool.get_html`
// ---------------------------------------------------------------------------
/**
* Default byte budget when callers don't pass `max_bytes`. Mirrors the
* `524288` value documented in the bsk-protocol Rust struct so the
* extension never differs from the spec without the caller asking.
*/
export const DEFAULT_GET_HTML_MAX_BYTES = 524_288;
/**
* Compute the UTF-8 byte length of an HTML payload (TextEncoder is
* available in MV3 service workers and happy-dom).
*/
function utf8ByteLength(s: string): number {
return new TextEncoder().encode(s).length;
}
/**
* Truncate `html` to at most `maxBytes` bytes without splitting a
* multibyte UTF-8 sequence. Returns the truncated string + a flag.
*/
function truncateBytes(html: string, maxBytes: number): { out: string; truncated: boolean } {
const enc = new TextEncoder();
const bytes = enc.encode(html);
if (bytes.length <= maxBytes) return { out: html, truncated: false };
// Walk back to a UTF-8 boundary (bytes whose high bits aren't `10`).
let end = maxBytes;
while (end > 0 && (bytes[end] & 0b1100_0000) === 0b1000_0000) end -= 1;
const out = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, end));
return { out, truncated: true };
}
export async function handleGetHtml(
manager: SessionManager,
params: GetHtmlParams,
deps: SnapshotDeps = getDefaultDeps(),
): Promise<GetHtmlResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "get_html");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const maxBytes =
params.max_bytes && params.max_bytes > 0 ? params.max_bytes : DEFAULT_GET_HTML_MAX_BYTES;
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
let html: string;
if (params.ref) {
const resolved = resolveSnapshotRef(ctx, params.ref, target.tabId);
if (isRpcError(resolved)) return resolved;
const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", {
backendNodeId: resolved.backendNodeId,
});
html = resp.outerHTML ?? "";
} else {
const doc = await deps.cdp.send<{ root?: { nodeId?: number } }>(
target.tabId,
"DOM.getDocument",
{ depth: 0 },
);
const nodeId = doc.root?.nodeId;
if (typeof nodeId !== "number") {
return {
code: "cdp_failed",
message: "DOM.getDocument returned no root nodeId",
};
}
const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", {
nodeId,
});
html = resp.outerHTML ?? "";
}
const originalBytes = utf8ByteLength(html);
const { out, truncated } = truncateBytes(html, maxBytes);
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
html: out,
truncated,
byte_size: originalBytes,
tab_id: target.tabId,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
export async function handleSnapshot(
manager: SessionManager,
params: SnapshotParams,
deps: SnapshotDeps = getDefaultDeps(),
): Promise<SnapshotResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "snapshot");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await deps.cdp.send<unknown>(target.tabId, "Accessibility.enable", {});
const result = await deps.cdp.send<{ nodes: CdpAxNode[] }>(
target.tabId,
"Accessibility.getFullAXTree",
{},
);
const rendered = renderAxTree(result.nodes ?? [], {
maxDepth: params.max_depth,
maxTokens: params.max_tokens,
});
// Reset the session-scoped ref-store for this fresh snapshot.
ctx.refStore.replace(
rendered.refs.map(
(r) => [r.ref, { backendNodeId: r.backendNodeId, tabId: target.tabId }] as const,
),
);
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
text: rendered.text,
ref_count: rendered.refs.length,
tab_id: target.tabId,
truncated: rendered.truncated,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
+166
View File
@@ -0,0 +1,166 @@
import type { SessionManager } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
export interface SessionStartParams {
session_id: string;
browser_instance_id?: string;
}
export interface SessionStartResult {
agent_window_id?: number;
}
export interface SessionStopParams {
session_id: string;
}
export interface SessionStopResult {
/** Tab ids that were returned to their original (or fallback) window. */
returned_tab_ids?: number[];
/** Tab ids whose return path failed; those entries remain borrowed so
* shutdown can be retried without closing the Agent Window. */
return_failures?: Array<{ tab_id: number; code: string; message: string }>;
}
export interface SessionStopDeps {
cdp?: {
detachSession(sessionId: string): Promise<void>;
};
/**
* Tab management deps forwarded to `returnBorrowedTab`. Defaults to
* the production `chrome.tabs` / `chrome.windows` wrappers, but
* tests can inject fakes so the auto-return path is unit-tested
* without a real browser.
*/
tabManagement?: TabManagementDeps;
}
/**
* Handler for `tool.session_start` (called by the daemon over WS).
*
* Creates the Agent Window and registers a fresh SessionContext.
*/
export async function handleSessionStart(
manager: SessionManager,
params: SessionStartParams,
): Promise<SessionStartResult | RpcError> {
if (!params?.session_id) {
return {
code: "invalid_params",
message: "session.start requires session_id",
};
}
try {
const ctx = await manager.start(params.session_id);
return { agent_window_id: ctx.agentWindowId };
} catch (err) {
// chrome.windows.create / SessionManager failures are not CDP
// failures (§4.5 reserves cdp_failed for raw CDP errors). Surface
// them as protocol_error so the CLI maps to the right exit code
// (review M4/M5 I5).
return {
code: "protocol_error",
message: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Handler for `tool.session_stop` (called by the daemon over WS).
*
* Teardown order is intentional and must not be reordered without
* thinking through the consequences (review M8.4):
*
* 1. Return every borrowed tab to its original (or fallback)
* window. We MUST do this before closing the Agent Window —
* otherwise the borrowed tab gets removed along with the window.
* 2. Clear the RefStore so any pending `@e1` resolver bails out
* cleanly (review parity with M6).
* 3. Detach CDP sessions the extension still holds for this
* session (no-op if M6/M7 didn't attach to any tab).
* 4. Close the Agent Window. SessionManager.stop() removes the
* Chrome window and forgets the context.
*
* Failures in step 1 keep the Agent Window open: a failed borrowed tab
* may still be there, so closing the window would risk losing user
* state. The daemon/CLI surface the failure and keep the session
* retryable.
*/
export async function handleSessionStop(
manager: SessionManager,
params: SessionStopParams,
deps: SessionStopDeps = {},
): Promise<SessionStopResult | RpcError> {
if (!params?.session_id) {
return {
code: "invalid_params",
message: "session.stop requires session_id",
};
}
const ctx = manager.get(params.session_id);
if (!ctx) {
return {
code: "not_found",
message: `session ${params.session_id} unknown`,
};
}
// Step 1: auto-return borrowed tabs. Iterate over a snapshot of the
// ids so deletions during iteration do not break the Map iterator.
const returnedTabIds: number[] = [];
const returnFailures: SessionStopResult["return_failures"] = [];
const borrowedIds = Array.from(ctx.borrowedTabs.keys());
for (const tabId of borrowedIds) {
try {
const tabManagement = {
...(deps.tabManagement ?? {}),
isAgentWindowId:
deps.tabManagement?.isAgentWindowId ??
((windowId: number) => manager.findByWindowId(windowId) !== null),
};
const outcome = await returnBorrowedTab(ctx, tabId, tabManagement);
if (typeof outcome === "object" && "code" in outcome) {
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
returnFailures?.push({
tab_id: tabId,
code: outcome.code,
message: outcome.message,
});
} else {
returnedTabIds.push(outcome.tabId);
ctx.borrowedTabs.delete(tabId);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[bsk session_stop] auto-return threw for tab ${tabId}: ${message}`);
returnFailures?.push({
tab_id: tabId,
code: "protocol_error",
message,
});
}
}
const result: SessionStopResult = {};
if (returnedTabIds.length > 0) result.returned_tab_ids = returnedTabIds;
if (returnFailures && returnFailures.length > 0) {
result.return_failures = returnFailures;
// A failed return means at least one borrowed user tab may still be
// inside the Agent Window. Keep the session/window alive so the user
// can retry `bsk session stop` or explicitly `bsk tab return` after the
// underlying Chrome issue is resolved.
return result;
}
// Step 2: clear the per-session RefStore (review M6/M7 parity).
ctx.refStore.clear();
// Step 3: detach CDP sessions this session opened (no-op if none).
await deps.cdp?.detachSession(params.session_id);
// Step 4: close the Agent Window and drop the context.
await manager.stop(params.session_id);
return result;
}
+175
View File
@@ -0,0 +1,175 @@
// Shared helpers used by every `tool.*` handler — target-tab
// resolution, session lookup, RpcError detection, and the minimal
// CDP-runner shape every handler depends on.
//
// Lives in its own module so M7+ navigation / interaction code reuses
// exactly the same sandbox + visibility rules as the M6 observation
// handlers (review parity).
import type { DialogCursor } from "@/browser-driver/chromium-cdp";
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import { normaliseRef } from "@/session-manager/ref-store";
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
import { rpcError } from "./errors";
/**
* Subset of `chrome.tabs` we depend on across tool handlers. Kept on
* a thin interface so vitest can inject a fake without monkey-patching
* the global `chrome` object.
*/
export interface ChromeTabsApi {
get(tabId: number): Promise<chrome.tabs.Tab>;
query(query: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]>;
}
export const chromeTabsApi: ChromeTabsApi = {
get: (tabId) => chrome.tabs.get(tabId),
query: (q) => chrome.tabs.query(q),
};
export type { DialogCursor };
/**
* Minimal CDP surface every tool handler needs. Backed by
* `ChromiumCdp` in production; tests inject a fake.
*
* `trackSessionTab` is optional so test doubles need not implement it
* — production `ChromiumCdp` always supplies it for ref-aware session
* teardown.
*/
export interface CdpRunner {
send<T = unknown>(tabId: number, method: string, params?: object): Promise<T>;
trackSessionTab?(sessionId: string, tabId: number): void;
onEvent?(handler: (source: chrome.debugger.Debuggee, method: string, params: unknown) => void): {
dispose(): void;
};
dialogCursor?(tabId: number): DialogCursor;
dialogsSince?(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[];
ensureConsoleCapture?(tabId: number): Promise<void>;
consoleEntriesSince?(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
includeStack: boolean,
): ConsoleResult;
}
/**
* Look up an active session by its `session_id` param, returning the
* matching `SessionContext` or a structured `RpcError`. Tool handlers
* call this first so every code path emits identical error messages.
*/
export function lookupSession(
manager: SessionManager,
params: { session_id?: string },
toolName: string,
): SessionContext | RpcError {
if (!params?.session_id || typeof params.session_id !== "string") {
return {
code: "invalid_params",
message: `${toolName} requires session_id`,
};
}
const ctx = manager.get(params.session_id);
if (!ctx) {
return {
code: "not_found",
message: `session ${params.session_id} unknown`,
};
}
return ctx;
}
/**
* Resolve the target tab for a tool call. Explicit `tabId` values are
* checked against the session visibility rules: user tabs and the
* current session's Agent Window are visible, other sessions' Agent
* Windows are not.
*
* Returns the resolved `{tabId, windowId, active}` triple, or an
* `RpcError` the caller propagates verbatim.
*/
export async function resolveTargetTab(
manager: SessionManager,
ctx: SessionContext,
tabId: number | undefined,
api: ChromeTabsApi,
): Promise<{ tabId: number; windowId: number; active: boolean } | RpcError> {
if (tabId !== undefined) {
if (!Number.isSafeInteger(tabId) || tabId <= 0) {
return {
code: "invalid_params",
message: "tab_id must be a positive integer",
};
}
let tab: chrome.tabs.Tab;
try {
tab = await api.get(tabId);
} catch (err) {
return {
code: "not_found",
message: err instanceof Error ? err.message : `tab ${tabId} not found`,
};
}
if (typeof tab.id !== "number" || typeof tab.windowId !== "number") {
return {
code: "not_found",
message: `tab ${tabId} not found`,
};
}
const owner = manager.findByWindowId(tab.windowId);
if (owner && owner.sessionId !== ctx.sessionId) {
return {
code: "not_found",
message: `tab ${tabId} not found in session scope`,
};
}
return { tabId: tab.id, windowId: tab.windowId, active: tab.active === true };
}
const tabs = await api.query({ active: true, windowId: ctx.agentWindowId });
const first = tabs.find((t) => typeof t.id === "number");
if (!first || typeof first.id !== "number") {
return {
code: "not_found",
message: `no active tab in Agent Window ${ctx.agentWindowId}`,
};
}
return { tabId: first.id, windowId: ctx.agentWindowId, active: first.active === true };
}
export function isRpcError(v: unknown): v is RpcError {
return (
typeof v === "object" &&
v !== null &&
"code" in v &&
"message" in v &&
typeof (v as RpcError).code === "string"
);
}
/** Re-export so M6/M7 tools keep a stable import path. */
export { normaliseRef };
/**
* Sandbox guard: M7 write tools (click / fill / press / navigate*)
* MUST refuse to touch a tab outside the session's Agent Window
* (§6 — borrowing brings the tab into the Agent Window first).
*
* Returns an `RpcError` when the resolved target sits in a user window;
* `null` on success.
*/
export function enforceAgentWindow(
ctx: SessionContext,
target: { tabId: number; windowId: number },
toolName: string,
): RpcError | null {
if (target.windowId !== ctx.agentWindowId) {
return rpcError(
"permission_denied",
"agent_window_scope",
`${toolName} can only act on tabs inside the Agent Window (tab ${target.tabId} is in window ${target.windowId}; borrow it first)`,
);
}
return null;
}
+50
View File
@@ -0,0 +1,50 @@
// Snapshot ref resolution — normalise `@eN` / `eN`, look up
// `backendNodeId` via the session RefStore, and build stable
// `ref_not_found` errors for hard-failure tool paths.
import type { SessionContext } from "@/session-manager/manager";
import { normaliseRef } from "@/session-manager/ref-store";
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
export interface SnapshotRefLookup {
backendNodeId: number;
refKey: string;
}
/**
* Soft lookup: returns `null` when the ref is unknown or bound to a
* different tab. Used by paths that report `matched: false` instead of
* emitting an RPC error (e.g. `tool.request_help`).
*/
export function lookupSnapshotRef(
ctx: SessionContext,
ref: string,
tabId: number,
): SnapshotRefLookup | null {
const refKey = normaliseRef(ref);
const backendNodeId = ctx.refStore.resolve(refKey, { tabId });
if (backendNodeId === null) return null;
return { backendNodeId, refKey };
}
/**
* Hard resolve: returns `not_found` / `ref_not_found` when the ref is
* unknown or bound to a different tab. Used by observation and
* interaction tools.
*/
export function resolveSnapshotRef(
ctx: SessionContext,
ref: string,
tabId: number,
): { backendNodeId: number; refKey: string } | RpcError {
const looked = lookupSnapshotRef(ctx, ref, tabId);
if (looked === null) {
return rpcError(
"not_found",
"ref_not_found",
`ref ${ref} unknown for tab ${tabId} in session ${ctx.sessionId}`,
);
}
return looked;
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
// `tool.wait_for_navigation` — subscribe to CDP `Page.lifecycleEvent`
// on the target tab and wait until the requested phase fires. Design
// §4 / §7, plan M9.2.
//
// Sandbox follows the same rules as the navigate / interaction tools:
// `resolveTargetTab` + `enforceAgentWindow`. Borrowed tabs already
// inside the Agent Window are allowed; user-window tabs are refused
// with `permission_denied`.
//
// Implementation: reuses the M7 helpers `ensureCdpReady` +
// `waitForLifecyclePassive` (readyState probe + event listener) from
// navigation.ts. Reads the main frame via `Page.getFrameTree` so
// subframe lifecycle events cannot satisfy a page-level wait.
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type {
RpcError,
WaitForNavigationParams,
WaitForNavigationResult,
WaitUntil,
} from "@/transport/types";
import { cdpLifecycleName, ensureCdpReady, waitForLifecyclePassive } from "./navigation";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
enforceAgentWindow,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
export interface WaitForNavigationDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
/** Abort hook (full chain wired in M10.2). */
signal?: AbortSignal;
defaultTimeoutMs?: number;
}
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
interface FrameTreeReply {
frameTree?: {
frame?: {
id?: string;
};
};
}
let defaultDeps: { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } | null = null;
function getDefaultDeps(): { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } {
if (!defaultDeps) {
defaultDeps = { cdp: new ChromiumCdp(), tabsApi: chromeTabsApi };
}
return defaultDeps;
}
export async function handleWaitForNavigation(
manager: SessionManager,
params: WaitForNavigationParams,
deps: WaitForNavigationDeps = getDefaultDeps(),
): Promise<WaitForNavigationResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "wait_for_navigation");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
if (deps.signal?.aborted) {
return { code: "cancelled", message: "wait_for_navigation aborted" };
}
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "wait_for_navigation");
if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const waitUntil: WaitUntil = params.wait_until ?? "load";
const timeoutMs = params.timeout_ms ?? deps.defaultTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
const expected = cdpLifecycleName(waitUntil);
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await ensureCdpReady(deps.cdp, target.tabId);
if (deps.signal?.aborted) {
return { code: "cancelled", message: "wait_for_navigation aborted" };
}
const frameTree = await deps.cdp.send<FrameTreeReply>(target.tabId, "Page.getFrameTree", {});
const mainFrameId = frameTree.frameTree?.frame?.id;
if (!mainFrameId) {
return { code: "cdp_failed", message: "Page.getFrameTree did not return a main frame id" };
}
if (deps.signal?.aborted) {
return { code: "cancelled", message: "wait_for_navigation aborted" };
}
const outcome = await waitForLifecyclePassive(
deps.cdp,
target.tabId,
mainFrameId,
expected,
timeoutMs,
deps.signal,
);
if (outcome.reached === "cancelled") {
return { code: "cancelled", message: "wait_for_navigation aborted" };
}
if (outcome.reached === "match") {
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
reached: waitUntil,
});
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
reached: "timeout",
error_text: `timed out waiting for lifecycle "${expected}" after ${timeoutMs}ms${
outcome.lastLifecycle ? `; last observed "${outcome.lastLifecycle}"` : ""
}`,
});
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
export const __testing__ = {
DEFAULT_WAIT_TIMEOUT_MS,
};
+85
View File
@@ -0,0 +1,85 @@
# Extension transport layer
`apps/extension/src/transport/` is the single, swappable seam between the
browser-skill extension and whatever channel it uses to talk to the local
daemon. v0.1 ships exactly one implementation —
[`ws-transport.ts`](./ws-transport.ts) — which speaks the JSON-line wire
format defined in `crates/bsk-protocol/schema/*.json` over a plain WebSocket.
## How to fork the transport
If you want to swap the WebSocket for native messaging, `chrome.runtime`
messaging, a shared `MessageChannel`, etc., implement the
[`Transport`](./transport.ts) interface and instantiate your class in
`apps/extension/src/entrypoints/background.ts` instead of `WSTransport`.
The contract is:
```ts
import type { Transport } from "@/transport/transport";
import type { ProtocolFrame, ConnectionState } from "@/transport/types";
class MyTransport implements Transport {
readonly state: ConnectionState;
connect(): Promise<void>;
disconnect(): Promise<void>;
send(msg: ProtocolFrame): void;
onMessage(handler: (msg: ProtocolFrame) => void): { dispose(): void };
onConnectionStateChange(handler: (s: ConnectionState) => void): { dispose(): void };
}
```
### Protocol-frame contract
A `ProtocolFrame` is one of three JSON shapes (mirrors `bsk-protocol::Frame`):
```jsonc
// Request — both peers may send
{ "id": "<rpc-id>", "method": "tool.snapshot", "params": { "session_id": "ab12" } }
// Response — one of result | error, never both
{ "id": "<rpc-id>", "result": { ... } }
{ "id": "<rpc-id>", "error": { "code": "not_found", "message": "session ab12 unknown" } }
// Event — no id, no response expected
{ "event": "session.window_closed", "payload": { "session_id": "ab12" } }
```
The transport is **purely a byte pipe**. It must not interpret RPC method
names, route by id, or re-order frames. Higher-level concerns
(handshake, dispatcher, ref-store) all live in `tools/` and
`session-manager/`. The transport's only obligations are:
1. **Frame-oriented delivery.** One `send()` call ⇒ exactly one frame on
the wire. Inbound bytes are split into discrete frames before being
handed to message handlers.
2. **Best-effort reconnect.** When the underlying channel breaks
unexpectedly, the transport reopens it with backoff and surfaces
`connecting` / `disconnected` via `onConnectionStateChange`.
`WSTransport` uses 1 s → 2 s → 4 s → 8 s → 16 s → 30 s (capped),
resetting on a successful reconnect.
3. **State observability.** `state` must be one of `disconnected`,
`connecting`, `connected`, or `version_skew`. Only the handshake
logic should set `version_skew` when `protocol_version` strings differ
but majors match; app semver is not compared. The bare transport
stays at `connected` once the socket is open. Handshake may still
include deprecated `min_compatible_peer` (`"0.0.0"`) alongside
`min_compatible_protocol` for backward compatibility.
4. **No throwing on malformed frames.** Drop non-JSON inbound traffic
silently; surface decode failures through logs only.
### Handshake responsibility
A fresh `Transport` instance is **not** authenticated. The first frame
sent over the wire **must** be `system.handshake`. Forks are expected to
preserve that contract — see `lib/instance-id.ts` and
`transport/handshake.ts` (added in M4.3) for the helper that owns the
extension side.
### Testing forks
`__tests__/ws-transport.test.ts` is the reference test suite. It uses a
hand-rolled `FakeSocket` so a new transport can copy its structure
verbatim and adapt the inner channel. See the suite for: connect/
disconnect, send/onMessage, malformed-frame tolerance, and exponential-
backoff reconnect verification.
@@ -0,0 +1,224 @@
import { describe, expect, it } from "vitest";
import {
detectBrowserMeta,
EXTENSION_VERSION,
MIN_COMPATIBLE_PEER,
MIN_COMPATIBLE_PROTOCOL,
PROTOCOL_VERSION,
performHandshake,
} from "../handshake";
import type { ConnectionStateHandler, FrameHandler, Transport } from "../transport";
import type { ConnectionState, HandshakeResult, ProtocolFrame } from "../types";
function fakeTransport(reply: (req: ProtocolFrame) => ProtocolFrame | null): Transport {
const handlers = new Set<FrameHandler>();
const t: Transport = {
state: "connected" as ConnectionState,
connect: () => Promise.resolve(),
disconnect: () => Promise.resolve(),
send: (msg) => {
const response = reply(msg);
if (response) {
for (const h of handlers) h(response);
}
},
onMessage: (h: FrameHandler) => {
handlers.add(h);
return {
dispose: () => {
handlers.delete(h);
},
};
},
onConnectionStateChange: (_h: ConnectionStateHandler) => ({
dispose: () => {},
}),
};
return t;
}
function deferredFakeTransport(): { transport: Transport; emit: (frame: ProtocolFrame) => void } {
const handlers = new Set<FrameHandler>();
const transport: Transport = {
state: "connected" as ConnectionState,
connect: () => Promise.resolve(),
disconnect: () => Promise.resolve(),
send: () => {},
onMessage: (h: FrameHandler) => {
handlers.add(h);
return {
dispose: () => {
handlers.delete(h);
},
};
},
onConnectionStateChange: (_h: ConnectionStateHandler) => ({
dispose: () => {},
}),
};
return {
transport,
emit: (frame) => {
for (const h of handlers) h(frame);
},
};
}
describe("performHandshake", () => {
it("sends system.handshake with identity and both compat fields", async () => {
let sentFrame: ProtocolFrame | null = null;
const transport = fakeTransport((req) => {
sentFrame = req;
return {
id: (req as { id: string }).id,
result: {
server: "browser-skill-daemon",
version: "0.1.0",
protocol_version: "1.0",
min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.0",
},
};
});
const outcome = await performHandshake(transport, {
instanceId: "abcdef01-2345-4678-89ab-cdef01234567",
browser: { name: "chrome", version: "131.0" },
label: "Personal Chrome",
rpcId: "hs-test",
});
expect(sentFrame).toEqual({
id: "hs-test",
method: "system.handshake",
params: {
client: "browser-skill-extension",
version: EXTENSION_VERSION,
protocol_version: PROTOCOL_VERSION,
instance_id: "abcdef01-2345-4678-89ab-cdef01234567",
browser: { name: "chrome", version: "131.0" },
label: "Personal Chrome",
min_compatible_peer: MIN_COMPATIBLE_PEER,
min_compatible_protocol: MIN_COMPATIBLE_PROTOCOL,
},
});
expect(outcome.result.min_compatible_peer).toBe("0.0.0");
expect(outcome.result.min_compatible_protocol).toBe("1.0");
});
it("accepts legacy daemon reply with only min_compatible_peer", async () => {
const transport = fakeTransport((req) => ({
id: (req as { id: string }).id,
result: {
server: "browser-skill-daemon",
version: "0.1.0",
protocol_version: "1.0",
min_compatible_peer: "0.1.0",
},
}));
const outcome = await performHandshake(transport, {
instanceId: "x",
browser: { name: "chrome", version: "131" },
label: "",
rpcId: "hs-legacy",
});
expect(outcome.result.protocol_version).toBe("1.0");
expect(outcome.result.min_compatible_protocol).toBeUndefined();
});
it("accepts daemon reply without legacy min_compatible_peer", async () => {
const response = {
server: "browser-skill-daemon",
version: "0.1.0",
protocol_version: "1.0",
min_compatible_protocol: "1.0",
} satisfies HandshakeResult;
const transport = fakeTransport((req) => ({
id: (req as { id: string }).id,
result: response,
}));
const outcome = await performHandshake(transport, {
instanceId: "x",
browser: { name: "chrome", version: "131" },
label: "",
rpcId: "hs-no-legacy-peer",
});
expect(outcome.result.min_compatible_peer).toBeUndefined();
expect(outcome.result.min_compatible_protocol).toBe("1.0");
});
it("rejects when the daemon responds with an error", async () => {
const transport = fakeTransport((req) => ({
id: (req as { id: string }).id,
error: { code: "version_too_old", message: "extension is too old" },
}));
await expect(
performHandshake(transport, {
instanceId: "x",
browser: { name: "chrome", version: "131" },
label: "",
rpcId: "hs-err",
}),
).rejects.toThrow(/version_too_old/);
});
it("ignores unrelated frames and resolves on the matching response id", async () => {
const { transport, emit } = deferredFakeTransport();
const pending = performHandshake(transport, {
instanceId: "x",
browser: { name: "chrome", version: "131" },
label: "",
rpcId: "hs-target",
});
emit({ event: "browser.event", payload: { ignored: true } });
emit({
id: "hs-other",
result: {
server: "browser-skill-daemon",
version: "0.1.0",
protocol_version: "1.0",
min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.0",
},
});
emit({
id: "hs-target",
result: {
server: "browser-skill-daemon",
version: "0.1.0",
protocol_version: "1.0",
min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.0",
},
});
await expect(pending).resolves.toMatchObject({
result: { server: "browser-skill-daemon", protocol_version: "1.0" },
});
});
});
describe("detectBrowserMeta", () => {
it("recognises Chrome", () => {
const meta = detectBrowserMeta(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36",
);
expect(meta).toEqual({ name: "chrome", version: "131.0.6778.86" });
});
it("recognises Edge before the Chrome fallback", () => {
const meta = detectBrowserMeta(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70",
);
expect(meta).toEqual({ name: "edge", version: "131.0.2903.70" });
});
it("falls back to chromium with unknown version for unknown UAs", () => {
expect(detectBrowserMeta("Unknown Browser/1.0")).toEqual({
name: "chromium",
version: "unknown",
});
});
});
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionState, ProtocolFrame } from "../types";
import { WSTransport } from "../ws-transport";
type WebSocketEventListener = (event: { data?: unknown } | undefined) => void;
class FakeSocket {
static instances: FakeSocket[] = [];
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
url: string;
readyState: number = 0;
sent: string[] = [];
closeCalls = 0;
listeners: Record<string, WebSocketEventListener[]> = {};
constructor(url: string) {
this.url = url;
FakeSocket.instances.push(this);
}
addEventListener(type: string, listener: WebSocketEventListener): void {
(this.listeners[type] ??= []).push(listener);
}
send(payload: string): void {
this.sent.push(payload);
}
close(): void {
this.closeCalls += 1;
this.readyState = FakeSocket.CLOSED;
this.emit("close", { code: 1000, reason: "client" });
}
open(): void {
this.readyState = FakeSocket.OPEN;
this.emit("open", undefined);
}
receive(frame: ProtocolFrame): void {
this.emit("message", { data: JSON.stringify(frame) });
}
serverClose(code = 1006): void {
this.readyState = FakeSocket.CLOSED;
this.emit("close", { code, reason: "server-gone" });
}
private emit(type: string, ev: unknown): void {
for (const l of this.listeners[type] ?? []) {
// biome-ignore lint/suspicious/noExplicitAny: minimal fake mirrors WebSocket Event shape
l(ev as any);
}
}
}
function lastSocket(): FakeSocket {
const s = FakeSocket.instances.at(-1);
if (!s) throw new Error("no FakeSocket created yet");
return s;
}
describe("WSTransport", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: false });
FakeSocket.instances = [];
});
afterEach(() => {
vi.useRealTimers();
});
it("constructs a WebSocket against the configured URL when connect() is called", async () => {
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const connectPromise = t.connect();
expect(FakeSocket.instances.length).toBe(1);
expect(lastSocket().url).toBe("ws://127.0.0.1:52800");
lastSocket().open();
await connectPromise;
expect(t.state).toBe("connected");
});
it("emits connection state transitions disconnected → connecting → connected", async () => {
const transitions: ConnectionState[] = [];
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
t.onConnectionStateChange((s) => transitions.push(s));
const p = t.connect();
lastSocket().open();
await p;
expect(transitions).toEqual(["connecting", "connected"]);
});
it("serialises outbound frames as JSON when the socket is open", async () => {
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const p = t.connect();
lastSocket().open();
await p;
t.send({ id: "1", method: "system.ping" });
expect(lastSocket().sent).toEqual([JSON.stringify({ id: "1", method: "system.ping" })]);
});
it("invokes onMessage for inbound JSON frames", async () => {
const handler = vi.fn();
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
t.onMessage(handler);
const p = t.connect();
lastSocket().open();
await p;
lastSocket().receive({ id: "1", result: { pong: true } });
expect(handler).toHaveBeenCalledWith({ id: "1", result: { pong: true } });
});
it("disconnect() closes the socket and stops reconnecting", async () => {
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const p = t.connect();
lastSocket().open();
await p;
await t.disconnect();
expect(lastSocket().closeCalls).toBeGreaterThan(0);
expect(t.state).toBe("disconnected");
await vi.advanceTimersByTimeAsync(5_000);
expect(FakeSocket.instances.length).toBe(1);
});
it("backs off exponentially across consecutive failed reconnects (1s, 2s, 4s, …, capped at 5s)", async () => {
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const states: ConnectionState[] = [];
t.onConnectionStateChange((s) => states.push(s));
const p = t.connect();
lastSocket().open();
await p;
expect(FakeSocket.instances.length).toBe(1);
// First unexpected close kicks off the backoff cascade. From this point
// we never let a reconnect attempt succeed; each new socket is closed
// immediately so the backoff counter keeps growing.
lastSocket().serverClose();
const expectedDelays = [1_000, 2_000, 4_000, 5_000, 5_000, 5_000, 5_000];
for (let i = 0; i < expectedDelays.length; i += 1) {
const beforeCount = FakeSocket.instances.length;
const advance = expectedDelays[i];
if (advance > 1) {
await vi.advanceTimersByTimeAsync(advance - 1);
expect(FakeSocket.instances.length).toBe(beforeCount);
}
await vi.advanceTimersByTimeAsync(1);
expect(FakeSocket.instances.length).toBe(beforeCount + 1);
lastSocket().serverClose();
}
expect(states.includes("connecting")).toBe(true);
expect(states.includes("disconnected")).toBe(true);
});
it("resets the backoff counter after a successful (re)connect", async () => {
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const p = t.connect();
lastSocket().open();
await p;
// First reconnect attempt at 1s, succeeds.
lastSocket().serverClose();
await vi.advanceTimersByTimeAsync(1_000);
expect(FakeSocket.instances.length).toBe(2);
lastSocket().open();
await Promise.resolve();
// After a successful reconnect, the next failure should once again wait
// only the initial delay (1s), not 2s.
lastSocket().serverClose();
const beforeCount = FakeSocket.instances.length;
await vi.advanceTimersByTimeAsync(999);
expect(FakeSocket.instances.length).toBe(beforeCount);
await vi.advanceTimersByTimeAsync(1);
expect(FakeSocket.instances.length).toBe(beforeCount + 1);
});
it("ignores malformed inbound messages (non-JSON) without throwing", async () => {
const handler = vi.fn();
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
t.onMessage(handler);
const p = t.connect();
lastSocket().open();
await p;
const socket = lastSocket();
for (const l of socket.listeners.message ?? []) {
l({ data: "{not json" });
}
expect(handler).not.toHaveBeenCalled();
expect(t.state).toBe("connected");
});
it("disposes handlers via Disposable returned from onMessage", async () => {
const handler = vi.fn();
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const disp = t.onMessage(handler);
const p = t.connect();
lastSocket().open();
await p;
lastSocket().receive({ id: "1", result: 1 });
expect(handler).toHaveBeenCalledTimes(1);
disp.dispose();
lastSocket().receive({ id: "2", result: 2 });
expect(handler).toHaveBeenCalledTimes(1);
});
});
+7
View File
@@ -0,0 +1,7 @@
// Build-time constants injected via Vite's `define` (see
// wxt.config.ts and vitest.config.ts). Declared globally so any module
// that wants to surface the extension's own semver to the daemon /
// popup / status panel can read it without re-importing package.json
// at runtime (review M3 fix to round-1).
declare const __BSK_EXT_VERSION__: string;
+135
View File
@@ -0,0 +1,135 @@
import type { Transport } from "./transport";
import type {
HandshakeParams,
HandshakeResult,
ProtocolFrame,
RequestFrame,
ResponseFrame,
} from "./types";
export const PROTOCOL_VERSION = "1.0";
/**
* Extension semver, injected at build time from `package.json` via
* Vite's `define` (see `wxt.config.ts` and `vitest.config.ts`).
*/
export const EXTENSION_VERSION: string =
typeof __BSK_EXT_VERSION__ === "string" ? __BSK_EXT_VERSION__ : "0.0.0-unset";
/**
* Lowest **protocol** version this extension accepts (e.g. `"1.0"`).
* Must stay in sync with daemon `MIN_COMPATIBLE_PROTOCOL`.
*/
export const MIN_COMPATIBLE_PROTOCOL = "1.0";
/**
* **Deprecated** — legacy app-semver floor for wire compat with old
* daemons. New code sends `"0.0.0"`; compat decisions ignore this.
*/
export const MIN_COMPATIBLE_PEER = "0.0.0";
export const CLIENT_ID = "browser-skill-extension";
export interface BrowserMeta {
name: string;
version: string;
}
export interface HandshakeInput {
instanceId: string;
browser: BrowserMeta;
label: string;
/**
* Used to make the handshake's RPC id stable in tests. Defaults to a
* random short string.
*/
rpcId?: string;
}
export interface HandshakeOutcome {
params: HandshakeParams;
result: HandshakeResult;
}
function ridToString(): string {
return `hs-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Send the mandatory first frame on a fresh connection: `system.handshake`.
*/
export function performHandshake(
transport: Transport,
input: HandshakeInput,
options: { timeoutMs?: number } = {},
): Promise<HandshakeOutcome> {
const id = input.rpcId ?? ridToString();
const params: HandshakeParams = {
client: CLIENT_ID,
version: EXTENSION_VERSION,
protocol_version: PROTOCOL_VERSION,
instance_id: input.instanceId,
browser: input.browser,
label: input.label,
min_compatible_peer: MIN_COMPATIBLE_PEER,
min_compatible_protocol: MIN_COMPATIBLE_PROTOCOL,
};
const req: RequestFrame = { id, method: "system.handshake", params };
const timeoutMs = options.timeoutMs ?? 10_000;
return new Promise<HandshakeOutcome>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("[handshake] timed out waiting for daemon response"));
}, timeoutMs);
const sub = transport.onMessage((msg: ProtocolFrame) => {
if (!isResponseFor(msg, id)) return;
cleanup();
const r = msg as ResponseFrame;
if ("error" in r) {
reject(
new Error(`[handshake] daemon rejected handshake: ${r.error.code} — ${r.error.message}`),
);
return;
}
const result = r.result as HandshakeResult;
resolve({ params, result });
});
function cleanup() {
clearTimeout(timer);
sub.dispose();
}
try {
transport.send(req);
} catch (err) {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
function isResponseFor(msg: ProtocolFrame, id: string): boolean {
return (
typeof (msg as ResponseFrame).id === "string" &&
(msg as ResponseFrame).id === id &&
("result" in (msg as object) || "error" in (msg as object))
);
}
/**
* Best-effort browser meta detection from `navigator.userAgent`.
*/
export function detectBrowserMeta(ua: string = navigator.userAgent): BrowserMeta {
const probes: Array<[RegExp, string]> = [
[/Edg\/([0-9.]+)/, "edge"],
[/OPR\/([0-9.]+)/, "opera"],
[/Brave\/([0-9.]+)/, "brave"],
[/Arc\/([0-9.]+)/, "arc"],
[/Chrome\/([0-9.]+)/, "chrome"],
];
for (const [re, name] of probes) {
const m = re.exec(ua);
if (m) return { name, version: m[1] };
}
return { name: "chromium", version: "unknown" };
}
+25
View File
@@ -0,0 +1,25 @@
import type { ConnectionState, ProtocolFrame } from "./types";
export interface Disposable {
dispose(): void;
}
export type FrameHandler = (msg: ProtocolFrame) => void;
export type ConnectionStateHandler = (state: ConnectionState) => void;
/**
* Abstraction over a bidirectional protocol channel (§4.7).
*
* v1 ships a single {@link WSTransport} implementation. Forks that want a
* different transport (e.g. native messaging, MessageChannel for embedding
* in a host page) only have to implement this interface and swap the
* concrete class in `entrypoints/background.ts`.
*/
export interface Transport {
connect(): Promise<void>;
disconnect(): Promise<void>;
send(msg: ProtocolFrame): void;
onMessage(handler: FrameHandler): Disposable;
onConnectionStateChange(handler: ConnectionStateHandler): Disposable;
readonly state: ConnectionState;
}
+508
View File
@@ -0,0 +1,508 @@
// Wire-protocol shapes mirroring bsk-protocol (see crates/bsk-protocol/schema/*.json). The
// daemon serialises Rust structs via serde; these TS types intentionally
// stay structural (interface, not class) so the same JSON parses on both
// sides without extra adapters.
export type RpcId = string;
export type ErrorCode =
| "unknown_method"
| "unsupported"
| "invalid_params"
| "not_found"
| "permission_denied"
| "timeout"
| "cdp_failed"
| "protocol_error"
| "cancelled"
| "version_too_old"
| "multiple_browsers_online"
| "no_browser_connected";
/** Stable `RpcError.data.reason` values for CLI hint selection. */
export type RpcErrorReason =
| "agent_window_scope"
| "element_not_visible"
| "ref_not_found"
| "selector_not_found"
| "target_not_fillable"
| "target_not_select"
| "option_not_found"
| "single_select_value_count"
| "tab_not_active"
| "borrow_conflict";
export interface RpcErrorData {
reason?: RpcErrorReason;
[key: string]: unknown;
}
export interface RpcError {
code: ErrorCode;
message: string;
data?: RpcErrorData;
}
export interface RequestFrame {
id: RpcId;
method: string;
params?: unknown;
}
export interface OkResponseFrame {
id: RpcId;
result: unknown;
}
export interface ErrResponseFrame {
id: RpcId;
error: RpcError;
}
export type ResponseFrame = OkResponseFrame | ErrResponseFrame;
export interface EventFrame {
event: string;
payload?: unknown;
}
export type ProtocolFrame = RequestFrame | ResponseFrame | EventFrame;
export function isRequestFrame(f: ProtocolFrame): f is RequestFrame {
return typeof (f as RequestFrame).method === "string";
}
export function isResponseFrame(f: ProtocolFrame): f is ResponseFrame {
return (
typeof (f as ResponseFrame).id === "string" &&
("result" in (f as object) || "error" in (f as object))
);
}
export function isEventFrame(f: ProtocolFrame): f is EventFrame {
return typeof (f as EventFrame).event === "string";
}
export interface BrowserPeerInfo {
name: string;
version: string;
}
export interface HandshakeParams {
client: string;
version: string;
protocol_version: string;
instance_id: string;
browser: BrowserPeerInfo;
label: string;
/**
* **Deprecated** — legacy app-semver floor for old daemons. New code
* sends `"0.0.0"` and ignores on read.
*/
min_compatible_peer?: string;
/** Lowest daemon **protocol** version this extension accepts. */
min_compatible_protocol?: string;
}
export interface HandshakeResult {
server: string;
version: string;
protocol_version: string;
/** Deprecated legacy app-semver floor; absent on newer daemons. */
min_compatible_peer?: string;
/** Protocol floor advertised by the daemon; absent on legacy daemons. */
min_compatible_protocol?: string;
}
export type ConnectionState = "disconnected" | "connecting" | "connected" | "version_skew";
// --------------------------------------------------------------------------
// M6 tool payloads — mirror bsk-protocol (crates/bsk-protocol/src/tools/*.rs)
// --------------------------------------------------------------------------
export type JavaScriptDialogType = "alert" | "confirm" | "prompt" | "beforeunload";
export type JavaScriptDialogHandledAction = "accepted" | "dismissed";
export interface JavaScriptDialogInfo {
tab_id: number;
type: JavaScriptDialogType;
message: string;
url?: string;
default_prompt?: string;
has_browser_handler?: boolean;
handled: JavaScriptDialogHandledAction;
sequence: number;
}
export type ConsoleEntryKind = "console" | "exception" | "log";
export interface ConsoleStackFrame {
function_name?: string;
url?: string;
line?: number;
column?: number;
}
export interface ConsoleEntry {
sequence: number;
kind: ConsoleEntryKind;
level: string;
text: string;
url?: string;
line?: number;
column?: number;
timestamp?: number;
stack_trace?: ConsoleStackFrame[];
truncated: boolean;
}
export interface ConsoleParams {
session_id: string;
tab_id?: number;
since?: number;
limit?: number;
max_text_chars?: number;
include_stack?: boolean;
}
export interface ConsoleResult {
tab_id: number;
entries: ConsoleEntry[];
next_since: number;
truncated: boolean;
}
export type TabScopeFilter = "user" | "agent" | "all";
export interface TabInfo {
tab_id: number;
title?: string;
url?: string;
window_id?: number;
active?: boolean;
scope?: "user" | "agent";
}
export interface TabListParams {
session_id: string;
scope?: TabScopeFilter;
}
export interface TabListResult {
tabs: TabInfo[];
}
// --- M8 tab management payloads (M8.1) ---
export interface TabCreateParams {
session_id: string;
url?: string;
active?: boolean;
index?: number;
}
export interface TabCreateResult {
tab_id: number;
window_id: number;
url: string;
}
export interface TabCloseParams {
session_id: string;
tab_id: number;
}
export interface TabCloseResult {
tab_id: number;
}
export interface TabSelectParams {
session_id: string;
tab_id: number;
}
export interface TabSelectResult {
tab_id: number;
window_id: number;
}
export interface TabBorrowParams {
session_id: string;
tab_id: number;
confirm?: boolean;
}
export interface TabBorrowResult {
tab_id: number;
original_window_id: number;
original_index: number;
agent_window_id: number;
}
export interface TabReturnParams {
session_id: string;
tab_id: number;
}
export interface TabReturnResult {
tab_id: number;
returned_to_window_id: number;
returned_to_index: number;
fallback?: boolean;
}
export interface ScreenshotParams {
session_id: string;
tab_id?: number;
/** `@eN` ref from the last `tool.snapshot`. */
ref?: string;
}
export interface ScreenshotResult {
image_base64: string;
width: number;
height: number;
format: string;
tab_id: number;
dialogs?: JavaScriptDialogInfo[];
}
export interface SnapshotParams {
session_id: string;
tab_id?: number;
max_depth?: number;
max_tokens?: number;
}
export interface SnapshotResult {
text: string;
ref_count: number;
tab_id: number;
truncated?: boolean;
dialogs?: JavaScriptDialogInfo[];
}
export interface GetHtmlParams {
session_id: string;
tab_id?: number;
ref?: string;
max_bytes?: number;
}
export interface GetHtmlResult {
html: string;
truncated?: boolean;
byte_size: number;
tab_id: number;
dialogs?: JavaScriptDialogInfo[];
}
// --------------------------------------------------------------------------
// M7 tool payloads — navigation (mirror bsk-protocol)
// --------------------------------------------------------------------------
export type WaitUntil = "load" | "domcontentloaded" | "networkidle" | "commit";
export interface NavigateParams {
session_id: string;
url: string;
tab_id?: number;
wait_until?: WaitUntil;
timeout_ms?: number;
}
export interface NavigateResult {
tab_id: number;
url: string;
final_url?: string;
reached: string;
error_text?: string;
dialogs?: JavaScriptDialogInfo[];
}
export interface NavigateBackParams {
session_id: string;
tab_id?: number;
wait_until?: WaitUntil;
timeout_ms?: number;
}
export interface NavigateForwardParams extends NavigateBackParams {}
export interface NavigateHistoryResult {
tab_id: number;
previous_url?: string;
final_url?: string;
reached: string;
error_text?: string;
dialogs?: JavaScriptDialogInfo[];
}
export interface ReloadParams {
session_id: string;
tab_id?: number;
wait_until?: WaitUntil;
timeout_ms?: number;
hard?: boolean;
}
export type ReloadResult = NavigateHistoryResult;
// --------------------------------------------------------------------------
// M7 tool payloads — interaction (mirror bsk-protocol)
// --------------------------------------------------------------------------
export type MouseButton = "left" | "middle" | "right";
export type KeyModifier = "alt" | "ctrl" | "meta" | "shift";
export interface ClickParams {
session_id: string;
ref?: string;
selector?: string;
tab_id?: number;
button?: MouseButton;
click_count?: number;
modifiers?: KeyModifier[];
timeout_ms?: number;
}
export interface ClickResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
x: number;
y: number;
dialogs?: JavaScriptDialogInfo[];
}
export interface FillParams {
session_id: string;
value: string;
ref?: string;
selector?: string;
tab_id?: number;
clear_before?: boolean;
timeout_ms?: number;
}
export interface FillResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
value_length: number;
dialogs?: JavaScriptDialogInfo[];
}
export interface PressParams {
session_id: string;
key: string;
modifiers?: KeyModifier[];
ref?: string;
selector?: string;
tab_id?: number;
hold_ms?: number;
timeout_ms?: number;
}
export interface PressResult {
tab_id: number;
key: string;
code: string;
modifiers: KeyModifier[];
dialogs?: JavaScriptDialogInfo[];
}
export interface SelectParams {
session_id: string;
values: string[];
ref?: string;
selector?: string;
tab_id?: number;
timeout_ms?: number;
}
export interface SelectResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
multiple: boolean;
selected_values: string[];
selected_labels: string[];
dialogs?: JavaScriptDialogInfo[];
}
// --------------------------------------------------------------------------
// M9 tool payloads — evaluate / wait_for_navigation / wait_ms
// --------------------------------------------------------------------------
export interface EvaluateParams {
session_id: string;
expression: string;
tab_id?: number;
await_promise?: boolean;
return_by_value?: boolean;
timeout_ms?: number;
}
export interface EvaluateError {
text: string;
line?: number;
column?: number;
}
export interface EvaluateResult {
ok: boolean;
tab_id: number;
value?: unknown;
error?: EvaluateError;
dialogs?: JavaScriptDialogInfo[];
}
export interface WaitForNavigationParams {
session_id: string;
tab_id?: number;
wait_until?: WaitUntil;
timeout_ms?: number;
}
export type WaitForNavigationReached = WaitUntil | "timeout";
export interface WaitForNavigationResult {
tab_id: number;
reached: WaitForNavigationReached;
error_text?: string;
dialogs?: JavaScriptDialogInfo[];
}
// --------------------------------------------------------------------------
// Human-in-loop payloads — request_help (mirror bsk-protocol)
// --------------------------------------------------------------------------
export interface HelpTarget {
ref?: string;
selector?: string;
}
export type HelpOutcome = "continued" | "cancelled" | "timed_out" | "navigated";
export interface ResolvedTarget {
matched: boolean;
ref?: string;
selector?: string;
}
export interface RequestHelpParams {
session_id: string;
tab_id?: number;
prompt: string;
title?: string;
targets?: HelpTarget[];
timeout_ms?: number;
}
export interface RequestHelpResult {
outcome: HelpOutcome;
note?: string;
tab_id: number;
resolved_targets?: ResolvedTarget[];
}

Some files were not shown because too many files have changed in this diff Show More