Compare commits

..
Author SHA1 Message Date
BB-fat 6b78211e79 test(dsh-plugin): drop redundant thumbnail read-back case
Coverage already lives in the attachment-store and ring tests; this
case only re-hit readThumbnail and was flaky on CI.
2026-08-19 03:57:08 +00:00
BB-fat eebf334b40 fix(dsh-plugin): give observation waitFor more room on CI
The thumbnail read-back test timed out at the 2s waitFor budget under
CI load even though capture completed shortly after.
2026-08-19 03:50:14 +00:00
BB-fat a284ffc5ae Merge pull request #112 from Tencent/fix/dsh-plugin-id-and-edge-store
Fix/dsh plugin id and edge store
2026-08-19 11:43:13 +08:00
drakezhangandCursor e720f1ffe5 ci(dsh-plugin): publish from the npm-publish environment
The publish job named its GitHub Environment NPM_TOKEN, which is the
secret name rather than a deployment target, so the repository's
Deployments list showed a bogus NPM_TOKEN entry next to chrome-web-store.
The environment is now npm-publish; the secret it holds keeps its name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:20:26 +08:00
drakezhangandCursor 2b7e10ca2b fix(dsh-plugin-browserskill): identify the plugin by its published name
Renaming the package to @wxg-prc-cpg/browser-skill-dsh-plugin left two
identifiers behind on the old directory name, and both are resolved by
name rather than by path.

cordis.patch.yml is the specifier the loader imports, so dsh failed with
ERR_MODULE_NOT_FOUND on startup and took the whole plugin tree down with
it — installing the published package made dsh unusable. The client
bundle registers itself under the same kind of key, which the web shell
looks up by package name, so the screenshot toolview and the observation
overlay were unreachable too.

The bundle id now reads package.json instead of repeating the name, so it
cannot drift again. Quoting is required in the YAML because a leading '@'
is reserved in plain scalars.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:20:07 +08:00
drakezhangandCursor 1ef93463dd docs: list the Edge Add-ons build alongside the Chrome Web Store
The extension is now published to Edge Add-ons, but every install path
still pointed Edge users at the Chrome Web Store. Both READMEs and
AGENT_INSTALL.md now name a store per browser, and `bsk doctor` offers
both listings when no browser is connected.

Two Edge-specific gaps went with it: Edge blocks content-script injection
on its own storefront the same way Chrome does, so the borrow-confirmation
overlay must treat microsoftedge.microsoft.com/addons as non-injectable;
and the Chinese README still described the pre-install.ps1 manual Windows
setup.

Also give the DeepSeek Harness section its npm package link and the
profile-start step, so plugin users have a complete path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:19:47 +08:00
Hannah Liu c7252529f4 Merge pull request #109 from Tencent/fix/oopif-support
fix(extension): support OOPIF and cross-frame geometry
2026-08-18 21:16:39 +08:00
Ljy-0827 0b65b4b295 fix(vom): oopif fix 2026-08-18 19:28:16 +08:00
Ljy-0827 94ec7e73fc fix(vom): iframe oopif geometry 2026-08-18 19:28:16 +08:00
Ljy-0827 42403e55f2 feat(extension): support OOPIF refs with unified frame geometry 2026-08-18 19:28:16 +08:00
Hannah Liu 22019cdf07 Merge pull request #105 from Tencent/fix/vom-iframe
fix(vom): vom support observe iframe
2026-08-18 19:28:13 +08:00
Ljy-0827 8d7c0aa66c fix(extension): support iframe content in VOM observations 2026-08-18 19:18:39 +08:00
BB-fat a9489282fa fix(dsh-plugin-browserskill): keep observation frames in a 2-slot ring
Stop writing overlay thumbnails into the durable attachment store. Each
session now holds only the current and previous PNG in process memory,
reuses the id when the hash is unchanged, and drops the rest on rotate,
remove, or dispose.
2026-08-18 10:15:27 +00:00
Zhang GH d4cddd6224 Merge pull request #99 from Tencent/fix/record-empty-fill
fix(record): preserve committed empty fill steps
2026-08-18 18:12:59 +08:00
Zhang GH 8e4de6cf4b Merge pull request #91 from Tencent/feat/dsh-plugin
feat(packages): add dsh-plugin-browserskill — DeepSeek Harness tools for bsk
2026-08-18 18:12:37 +08:00
BB-fat fea66ce404 fix(dsh-plugin-browserskill): send JSON content-type on overlay interrupt
The observation HTTP fence rejects POSTs without application/json, so the
Interrupt button always got 403.
2026-08-18 09:36:15 +00:00
BB-fat 8bae85127a docs: add DeepSeek Harness plugin section to the README
Document the dsh plugin highlights and the one-line install for the web profile.
2026-08-18 03:13:58 +00:00
BB-fat edb149611b ci: publish the dsh plugin to npm as @wxg-prc-cpg/browser-skill-dsh-plugin
Add a Release dsh plugin workflow (dsh-plugin-v* tags or workflow_dispatch)
that reads NPM_TOKEN from the GitHub Environment of the same name. The
Cordis plugin id stays dsh-plugin-browserskill; only the npm package name
changes.
2026-08-17 10:00:13 +00:00
BB-fat 14c5884cbc style(dsh-plugin-browserskill): fix overlay CSS stylelint errors
Add the missing blank line before the actions comment, and hoist
.tool-button:focus-visible above the wrap-hover rules so specificity
no longer descends.
2026-08-17 09:52:09 +00:00
BB-fat e7aad9bc16 style(dsh-plugin-browserskill): wrap lines to satisfy biome
CI's frontend job failed on formatter-only diffs in the overlay and the
skill-content assertion.
2026-08-17 09:48:27 +00:00
“haonan”andCursor c8c5c4a7c4 fix(record): preserve committed empty fill steps
Allow intentional input clears to remain in exported traces instead of dropping every non-redacted blank fill.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 17:21:17 +08:00
BB-fat 3fb9e9f581 fix(dsh-plugin-browserskill): compact overlay toolbar and four-corner resize
Use icon-only actions with a short hover tip, swap the interrupt glyph to
RiStopCircleLine, and let every corner resize the card without a grip icon.
2026-08-17 07:50:53 +00:00
BB-fat 9854ee2f45 fix(dsh-plugin-browserskill): stop observation overlay from flashing between frames
Hold the last ready thumbnail until the next one decodes, and swap the
img src in place instead of remounting through a placeholder.
2026-08-17 07:27:28 +00:00
BB-fat 51085b960b fix(browser-skill): route DSH usage through injected tools 2026-08-17 07:04:36 +00:00
BB-fat 51df868008 chore(dsh-plugin-browserskill): prepare the package for npm publishing
- @browser-skill/ui and @remixicon/react move to devDependencies (the client
  bundle inlines both; the host face never imports them — verified).
- Drop private, add license: MIT, ship the repo-root MIT LICENSE in files.
- peerDependencies: add the runtime-required @deepseek-ai/schemastery, and
  the previously dangling meta entries @deepseek-ai/dsh-attachment and
  @deepseek-ai/dsh-llm (optional, matching actual type-level use).
- prepack runs the full build; npm pack now yields exactly 7 files
  (lib/index.mjs, lib/index.d.mts, lib/client.cjs, cordis.patch.yml,
  README.md, LICENSE, package.json).
- publint clean: the client face ships as lib/client.cjs (CJS extension in
  a type:module package); add lint:publint script. Built declarations pass a
  NodeNext consumer typecheck (strict, skipLibCheck false).
2026-08-14 17:13:48 +00:00
BB-fat 43ebfab7ba fix(dsh-plugin-browserskill): register the lazy suite from any fiber
Cordis contexts are fiber-owned: assigning onto an Object.create() overlay of
the apply-time context fails with 'cannot set property tools in multiple
fibers' when the reveal fires from the tools/result listener fiber (caught by
the e2e: the skill invocation matched but the suite never registered).
registerTools now tracks disposers through a plain closure over the captured
context — no context mutation anywhere. Also documents lazyTools in the
README and hardens the reveal against a failed registration (log and stay
hidden instead of latching).

Verified end to end against the real stack: first request carries 0 browser_*
schemas (skill catalog entry only); after the scripted skill invocation the
next request carries exactly the 11 browser_* tools and browser_session_start
dispatches for real.
2026-08-14 16:58:30 +00:00
BB-fat ce11325de2 feat(dsh-plugin-browserskill): reveal tool schemas only after skill invocation
Progressive disclosure, final stage: with lazyTools on (default), the eleven
browser_* tool schemas stay out of the system prompt — the browser-skill
catalog entry is the sole advertisement. One successful skill invocation
(model tool call via tools/result, or a /browser-skill user gesture via the
session append feed) registers the suite for the process lifetime, idempotent
on repeats; session resume is covered by scanning durable events for a past
tool/call+tool/result pair (or skill-invocation message) on session entry and
at apply time. lazyTools: false keeps the legacy always-on registration.

Investigation (recorded in the ticket): tools/result carries normalized
name/arguments with an isError discriminant; dsh 0.1 has no official global
visibility switch (ctx.tools.restrict is agent-scoped and throws from a plain
context), making conditional registration the intended pattern; per-step
assembly picks the new suite up on the next step, and visibility changes are
first-class inputs of tool-skill's catalog digest. registerTools now returns
the combined disposer via a prototype-preserving tracking overlay; the skill
prelude tells the model the browser_* tools unlock on skill load.

Tests: +8 (hidden-before/reveal/idempotent/gesture/history hit+miss/dispose/
scanner pairing/two-state apply wiring). 124/124 green.
2026-08-14 16:43:51 +00:00
BB-fat 4d1dcd122f feat(dsh-plugin-browserskill): publish the BSK skill through ctx.skills
The plugin exposed only tools; the BrowserSkill agent skill never reached
the harness's <available_skills> catalog. Register it as an embedded runtime
skill (progressive disclosure: catalog entry resident, body on skill-tool
invocation) via the official ctx.skills seam — silent no-op when the
composition lacks it.

Content is build-time assembled so there is one source of truth:
skill/prelude.md (committed, dsh-specific: tool<->CLI map, owned-session
semantics, plugin-only overrides) + the canonical repo-root skill/SKILL.md
verbatim (the same file crates/bsk-cli/build.rs mirrors; frontmatter
stripped, name/description supplied by the registration). Generated into
src/skill-content.generated.ts (gitignored) — registration and every
pre-step snapshot are pure in-memory reads: no disk, no process, no daemon.

Scripts: build:skill chained into build/test/typecheck (same pattern as
build:css); biome excludes the generated artifacts. Tests: +3 (catalog
content, in-memory weight, silent degradation). 116/116 green.
2026-08-14 16:17:54 +00:00
BB-fat a4e668542d fix(dsh-plugin-browserskill): address review findings (leaks, trust fence, queue race)
Blocking:
- B1: delete screenshot scratch PNGs — observation frames reuse one fixed
  per-session path and unlink after every read (finally); browser_screenshot
  unlinks once the bytes are in the attachment store (kept only when the file
  itself is the model-facing artifact).
- B2: replicate dsh's browser-trust fence on /bsk-observation/*: loopback
  Host only, Origin must match Host, sec-fetch-site: cross-site refused, POST
  requires application/json; README documents the loopback trust premise and
  the 0.0.0.0 warning.

Major:
- M1: KeyedExecutor tail chains previous+task (allSettled) so a task aborted
  while queued cannot release the next one into the running session.
- M2: client tracks the live frame per session — replacing/removing/reset
  revokes the old blob URL at once; loads settling after replacement never
  resurrect it.

Minor: drop replaced thumbRefs (+on remove); beginAction fires inside the
queue (no label overwrite while queued, no idle flash); half-initialized
session cleanup stops through the queue with one retry; SSE (re)open refetches
/state (+ snapshot flag renamed subscribed); install probe uses --version (no
daemon spawn); emulate mobile documents the width+height requirement (the
daemon refuses it alone — verified).

Nit: single BskRunOptions declaration; tails map entry dropped on drain; SSE
cleanup on res close; popOut catches requestWindow rejections; resize handle
gains keyboard control (arrows, 16px steps, aria value attrs).

Tests: +14 (fence rules, scratch lifecycle, queue race, blob revocation,
instrumentation timing, SSE resync). 113/113 green.
2026-08-14 11:56:02 +00:00
BB-fat 52aa356153 test(dsh-plugin-browserskill): build the scoped css before vitest
The client entry imports the generated bsk-ui.nomodule.css; the file is
gitignored, so a fresh CI checkout could not resolve it. Generate it as part
of the test script.
2026-08-14 11:05:05 +00:00
BB-fat d1001e14b2 feat(dsh-plugin-browserskill): reskin the overlay with BrowserSkill's own UI system
The previous pass aligned the observation overlay with the dsh shell; the
product call is the opposite direction — the floating card should read as a
BrowserSkill surface, not a shell-native widget.

- Reuse @browser-skill/ui directly (Button, cn); status dots spec'd after the
  extension popup's ConnectionStatusIndicator; Remix icons for
  interrupt/pin/warn/pop-out.
- Compile the BSK tailwind utility sheet scoped under the .bsk-obs root class
  (scripts/build-client-css.mjs + postcss-prefix-selector) and ship the oklch
  design tokens on the same scope, so nothing leaks into the host shell and
  the shell theme cannot bleed back. tsdown injects .nomodule.css verbatim
  (minified, unhashed) alongside the hashed CSS modules.
- BSK ships no shared tooltip, so the one-time interrupt semantics hint is a
  card-spec bubble (bg-card/border/12px radius), retired after first use.
- Tests: pin react/react-dom to this package's 18.x copies in vitest — the ui
  package sources peer on react ^19 and would otherwise emit react-19
  (transitional) elements that the react-18 renderer rejects. Production is
  unaffected (react stays external, provided by the shell).
- stylelint: ignore the generated bsk-ui.nomodule.css.
2026-08-14 10:00:37 +00:00
BB-fat eb71ddc1da docs(dsh-plugin-browserskill): note the top-right docking and shell-native styling 2026-08-14 08:10:05 +00:00
BB-fat b07b8fec65 fix(dsh-plugin-browserskill): dock below the shell top bar
The plain top:16 default overlapped the shell's Session log action;
dock the card and capsule at the content area's top-right (64px) so
neither collides with the header controls.
2026-08-14 08:06:28 +00:00
BB-fat 47070fdc14 feat(dsh-plugin-browserskill): default the overlay to the top-right + dsh-native styling
Review follow-ups: the card/capsule now default to the top-right (clear
of the composer), and the overlay reads as part of the shell instead of
a foreign widget:

- shared primitives everywhere: Button (outline/ghost sm) for
  Interrupt/Pop out with the shell's danger hover token, Tooltip for
  the one-time interrupt semantics hint (native span anchor — Button
  does not forward refs), StateDot for status, and outline icons
  (Stop/RightUp/ChevronDown/Warning) replacing text glyphs;
- every color/surface/shadow/label now uses --dsw-alias-* semantic
  tokens (bg-overlay, border-l1/l2, state-*-primary, label-*,
  interactive-bg-hover[-danger], brand-primary focus rings,
  dsw-shadow-lv1/lv2) — no invented tokens, no literal colors (dsh 0.1
  ships no spacing/radius scale; those stay px like dsh's own code);
- strip item focus ring uses brand-primary; focus-visible and
  reduced-motion preserved.
2026-08-14 07:57:07 +00:00
BB-fat 916229dc62 docs(dsh-plugin-browserskill): README section for the observation overlay 2026-08-14 07:05:39 +00:00
BB-fat dde707b558 fix(dsh-plugin-browserskill): report interrupt-killed commands as interrupted
A child killed via killFor (overlay interrupt) exits with a null code
and empty output, which used to render as a duplicated 'bsk x failed:
bsk x failed'. Report it as interrupted instead.
2026-08-14 07:01:44 +00:00
BB-fat e2d07574d7 fix(dsh-plugin-browserskill): prefix routes register without a trailing slash
The webserver prefix matcher joins prefix + '/', so a registered
'/bsk-observation/thumbnail/' never matched real paths.
2026-08-14 06:54:01 +00:00
BB-fat 461d204ca3 fix(dsh-plugin-browserskill): serve observation thumbnails over the plugin's own route
The session-authorized client RPC (readAttachment) refuses images that
no session log references — observation frames are plugin-owned runtime
data, so every overlay thumbnail failed with ATTACHMENT_NOT_REFERENCED.
The host now keeps the full attachment ref per frame and serves bytes
through GET /bsk-observation/thumbnail/<id> (verified readImage path);
the client loader is a plain fetch of that route.
2026-08-14 06:49:15 +00:00
BB-fat bc5d9610a3 fix(dsh-plugin-browserskill): serialize per-session commands (observation vs tool calls)
The daemon accepts only one unfinished command per session; the
thumbnail loop racing a model tool call produced 'session already has
an unfinished command' failures on both sides (found by the Phase 1-C
e2e: navigate/snapshot/click all errored while captures kept running).
Add a KeyedExecutor: every command for a session — tool calls and
observation captures alike — runs FIFO; queued tasks reject early on
abort, running tasks keep the runner's signal-driven kill.
2026-08-14 06:41:49 +00:00
BB-fat 2955260d11 fix(dsh-plugin-browserskill): register observation routes once webServer appears
ctx.get('webServer') at apply time raced the web composition's service
registration (the fallback SPA then answered /bsk-observation/* with
index.html). Ride ctx.inject(['webServer'], …) instead: the callback
runs when the service is provided and never runs in headless
compositions, keeping route registration timing-safe and optional.
2026-08-14 06:20:18 +00:00
BB-fat ce89e95211 feat(dsh-plugin-browserskill): multi-session strip, focus pin, and edge states (Phase 1-C)
- Strip (meeting-style multi-session layout): one item per session
  (mini frame + id + status dot), horizontal row with scroll overflow;
  hover reveals a per-item interrupt button that acts without
  refocusing; click pins the focus view (pin badge, click again to
  release); auto-follow picks the most recently active session but
  never steals focus for errored or dead sessions (red edge / greyed
  item instead).
- Edge states: the host flips an availability flag after repeated
  global capture failures (client shows 'browser unavailable', keeps
  the last frame, greys interrupt, and recovers on the next success);
  a session_not_found envelope marks the session dead (no more frame
  requests, grey strip item, removable as usual); lastError now clears
  when the next action starts or succeeds.
- State/SSE wire gains { available } and an 'availability' event;
  SessionObservation gains dead?.

Tests (+8, 97 total): host availability flip, dead marking +
instrumentation drop + clean removal, lastError lifecycle; client
strip render/pin/unpin/auto-follow, error no-steal, unavailable strip,
dead grey, strip-item interrupt without refocus.
2026-08-14 05:58:53 +00:00
BB-fat 24faa10659 feat(dsh-plugin-browserskill): observation overlay — floating card, interrupt, and PiP (Phase 1-B)
Client half of the PiP observation window, wired to the host
ObservationService over the /bsk-observation HTTP+SSE seam:

- ObservationClientStore: initial state fetch + SSE increments +
  on-demand thumbnail blob loading (session-authorized readAttachment,
  same path as the toolview) + interrupt POST. All I/O injectable.
- ObservationOverlay registered into the shell.overlay list seat (the
  sanctioned frame-wide surface; root is off-limits). Hidden with no
  owned sessions; appears on first start; vanishes when all stop.
- Focus view: status row (green/grey/red dot + session + action + mm:ss
  ticking), breathing thumbnail (new frames fade in, failures keep the
  last frame with a warning badge, pre-navigate placeholder), and the
  action area.
- Interrupt: single click, no confirm; greys into 'Interrupting…' and
  back; disabled when nothing is in flight; one-time tooltip explains
  the semantics (stops the current action only; the run continues).
- Card: drag-move via the header, corner-handle resize (min 240x180,
  max 80% viewport), both clamped and kept for the page lifetime;
  collapsible status capsule.
- PiP: Pop out (user gesture) opens Document PiP with the card's
  current size, portals the same content in, and clones the document's
  style nodes; pagehide falls back to the card with state intact;
  unsupported browsers hide the button; nothing auto-pops.

Tests (+17, 89 total): store (fetch/SSE apply/malformed frames/
thumbnail lifecycle/interrupt wire/stop cleanup) and overlay (hidden->
visible lifecycle, status row, thumbnail via loader, interrupt
disabled/active/hint-once, capsule, resize clamps, move clamps, PiP
unsupported, PiP pop/fallback). Styling follows web-styling (dsw-alias
tokens, CSS Modules, focus-visible, reduced-motion).
2026-08-14 05:33:34 +00:00
BB-fat a7bc8bacf6 feat(dsh-plugin-browserskill): host-side ObservationService for the PiP overlay (Phase 1-A)
Per-session live observation for owned sessions only, feeding the
upcoming client overlay:

- State model: SessionObservation (sessionId/url/action/since/
  thumbnailAttachmentId/lastError), one entry per owned session;
  added on start, removed on stop, cleared on dispose.
- Instrumentation: the shared runBsk wrapper begins/ends an action
  (tool name mapped to a verb) around every model-facing call and tags
  the child with its session id; action end triggers an immediate
  thumbnail refresh.
- Thumbnail loop: 1.5s cadence while active, 8s idle downclock, x3
  failure backoff, silent frame retention on errors; captures run
  OUTSIDE the instrumentation (no action events, no current-pointer
  movement) and land in the attachment store by reference.
- Interrupt: runner.killFor(tag) kills exactly the tagged in-flight bsk
  children — chat-Stop-equivalent semantics for the current or a named
  owned session; foreign ids and the no-in-flight case return false.
- Remote seam: dsh 0.1's Typert Remote pipeline and forwarded-event
  allowlist are closed to out-of-tree packages, so state/events/
  interrupt are served over the documented webServer route seam:
  GET /bsk-observation/state, GET /bsk-observation/events (SSE),
  POST /bsk-observation/interrupt; routes mount only when a webServer
  service exists (headless compositions skip them).
- Config: observationEnabled (true), thumbnailIntervalMs (1500),
  idleIntervalMs (8000).

Tests (+13, 72 total): state machine + event stream, cadence fast/idle/
backoff, headless no-store silence, observation-traffic isolation,
interrupt routing (default/specified/none/foreign), HTTP route
handlers, disabled-webServer no-op.
2026-08-14 04:59:26 +00:00
BB-fat 3d050e0f26 feat(dsh-plugin-browserskill): strict ownership boundary — foreign sessions are invisible
Tighten the shared-daemon boundary from 'cleanup only touches owned
sessions' to 'the plugin only ever sees and operates on sessions it
created':

- browser_session_list no longer queries the daemon at all; it returns
  the plugin's own session table (with the current marker), so foreign
  sessions cannot even be enumerated through the plugin.
- Every tool's optional session argument must name a plugin-created
  session; foreign/unknown ids are rejected with a clear error before
  any command reaches the daemon (the reference-adoption channel is
  gone).
- Unchanged: dispose stops exactly the owned set, stop requires
  ownership, the start reservation protocol, and handle-precise child
  kills.

Tests updated (59): explicit-foreign rejection without daemon contact,
registry-only listing, reservation race, stale-handle-tolerant dispose.
e2e: a manually started foreign session was rejected on reference,
absent from session_list, refused on stop, and survived dispose while
both plugin sessions were cleaned up.
2026-08-14 04:12:38 +00:00
BB-fat afdc398730 fix(dsh-plugin-browserskill): strict session ownership on a shared daemon
Review hardening for daemon-sharing deployments (other agents,
terminals, or dsh instances on the same bsk daemon):

- Ownership split: only sessions created by browser_session_start are
  'owned'. Explicitly referenced foreign sessions are still tracked for
  the current-session pointer, but browser_session_stop refuses them
  and unload cleanup stops exactly the owned set — no path can stop a
  session this plugin did not create.
- Start race: the capacity check is now a synchronous
  reserveStart/completeStart/abandonStart protocol, so two concurrent
  starts can never both pass the cap (check-and-reserve is atomic on
  the event loop); a rejected or failed start never leaks a session.
- Stale handles: dispose already swallowed per-stop errors; the owned
  set is computed at dispose time so an externally stopped session is a
  no-op, not a failure.

Tests (+7): reservation race (two concurrent starts, cap 1 -> exactly
one spawn), foreign-stop refusal (no stop command reaches the daemon),
reference adoption never becoming owned, resolveForStop pointer
stability, and an apply-level dispose test proving only owned sessions
are stopped while stale stops are tolerated. e2e: a manually started
foreign session survived the plugin's full lifecycle while both plugin
sessions were cleaned up.
2026-08-14 03:45:52 +00:00
BB-fat 6b96fcca32 feat(dsh-plugin-browserskill): add the web client half with a browser_screenshot toolview
Make the package dual-face: a dsh.client declaration (platform 'web')
plus an exports["./client"] bundle built in dsh's closure-factory shape
(window.__ModuleLoader__.load handoff, platform modules external,
everything else inlined, CSS Modules via lightningcss).

The client registers a keyed 'tool.call.toolview' view for
browser_screenshot that keeps the terminal block (command + output) and,
when the settled result carries an image block, resolves the durable
attachment through the client session's authorized readAttachment RPC
and renders it with the shared MessageImage thumbnail/lightbox atoms.
Path-only results render unchanged; every other browser_* tool keeps
the stock terminal card.

Tests: view-model derivation (running/ok/error, image vs path-only),
component rendering through @testing-library/react + happy-dom, and the
registration key. biome.json now excludes the package's lib/ build
output (same treatment as dist/).
2026-08-14 02:54:33 +00:00
BB-fat 0771292336 fix(dsh-plugin-browserskill): keep the terminal result card for multi-block results
browser_screenshot on an image-capable route returns text + image
blocks; the shared result presenter required exactly one text block and
bailed to the generic card (tool name + raw args). Project the first
text block instead so the terminal card shows in both modes.
2026-08-13 18:06:57 +00:00
BB-fat 54869cabd0 fix(dsh-plugin-browserskill): include the bsk error hint in the thrown message
The daemon's JSON error envelope carries an actionable hint (e.g.
'choose an input, textarea, or contenteditable element from the latest
snapshot'), but the thrown BskError only embedded the message field, so
the model-facing error text never showed the hint. Append it.
2026-08-13 17:56:16 +00:00
BB-fat 2de7c5c6ff fix(dsh-plugin-browserskill): enforce the session cap before spawning
The concurrency cap was checked in registry.add() AFTER
`bsk session start` had already created the session, so a rejected
start leaked a live session outside the plugin's tracking (found by the
real-browser e2e: the rejected third session stayed in
`bsk session list` after dispose). Check capacity before spawning;
registry.add() keeps the same check as a backstop.
2026-08-13 15:27:29 +00:00
BB-fat 703fc64399 feat(packages): add dsh-plugin-browserskill — DeepSeek Harness tools for bsk
Add a new monorepo package that registers BrowserSkill (bsk) browser
automation as model-visible tools in DeepSeek Harness (dsh):

- 11 tools mapping to bsk CLI --json commands: session start/stop/list,
  navigate, snapshot, observe, click, fill, press, screenshot, emulate
- multi-session support: optional session arg with a current-session
  pointer, session id echoed in every result, configurable concurrency
  cap (default 5), and dispose-time cleanup of plugin-started sessions
- cancellation: exec.signal aborts kill the underlying bsk child process
- terminal-style UI cards (command line as the call card, output as the
  result card); screenshots inline the PNG through the host attachment
  store when the model route accepts image input, else return the path
- config: bskPath / defaultTimeoutMs / maxSessions via Schemastery schema
- install guidance when the bsk binary is missing; bsk JSON error
  envelopes (code/message/hint) surfaced to the model
- unit tests mock the bsk runner (no real browser required); the root
  lint script now also runs the package's typecheck + tests so the
  existing CI frontend job covers the new package (a dedicated CI job
  diff is provided in the PR; the pushing token lacks workflow scope)
2026-08-13 15:04:52 +00:00
NianJiu 4549ed0892 fix(extension): make tool cancellation cooperative (#89)
Tool cancellation is now cooperative end-to-end: the queue stays busy until compensation completes, cancellation is checked cooperatively across the pipeline, the fallback window is compensated too, and cleanup_failed is surfaced explicitly. Includes a gated delayed-compensation E2E test.
2026-08-13 17:31:23 +08:00
BB-fat 37fbaacc94 feat(extension): add popup toggle to hide Agent control hints (#81)
Add a per-browser "Control hints" switch to the popup that hides the in-page control overlay (status pill + orange glow, along with its input blocker) while the Agent controls a tab. Preference lives in chrome.storage.local (bsk_control_hints_hidden, default: shown) and is applied live in content scripts via storage.onChanged.

Both popup settings rows share a single Switch component; the hint copy lives in an info-icon tooltip.

Refs #62
2026-08-13 10:24:17 +08:00
BB-fat f81a1e7796 fix(screenshot): fall back to CDP capture when captureVisibleTab fails (#83)
chrome.tabs.captureVisibleTab reads back the window surface, which fails
outright on some Windows/Chrome combinations (Chromium
FAILURE_REASON_READBACK_FAILED: "Failed to capture tab: image readback
failed"), leaving full-tab screenshots without any working path.

Capture the full tab via CDP Page.captureScreenshot (fromSurface) when
the visible-tab capture rejects; the CDP path goes through the
renderer BeginFrame pipeline instead of the surface readback. When both
paths fail, return cdp_failed with data.reason=screenshot_capture_failed
carrying both underlying messages, and render a CLI hint that points at
the browser-side readback cause.

Refs #71
2026-08-12 22:10:09 +08:00
BB-fat 78c2719718 fix(daemon): use hash-only Windows named-pipe name (#86)
The per-user pipe name embedded %USERNAME% verbatim
(\\.\pipe\bsk-daemon-{user}-{hash}). When the username contains
apostrophes (e.g. z'z'f'l'g'y), NPFS behaviour diverges between create
and open: the daemon's CreateNamedPipeW reports success and logs that it
is listening, yet every client CreateFileW on the same name fails with
ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND, so the CLI can never reach
the daemon (issue #75).

The name is now hash-only (\\.\pipe\bsk-daemon-{hash}); the hash
already covers user + home, so uniqueness and per-user/BSK_HOME
isolation are unchanged, and no raw username characters can reach NPFS.
The pipe name is exchanged exclusively through daemon.json (the CLI
never computes it), so old/new CLI/daemon combinations stay compatible.

Unit tests pin the safe character set for apostrophe/space/Unicode
usernames and the per-(user, home) uniqueness.

Fixes #75
2026-08-12 22:10:05 +08:00
BB-fatandalectimison-maker f443cfae74 feat(session): support unfocused agent windows (--no-focus) (#87)
`bsk session start --no-focus` opens the Agent Window in the background
without stealing focus. The flag threads focused=false through
CLI -> daemon IPC -> tool.session_start -> chrome.windows.create; an
omitted `focused` keeps the extension default (true) so older clients
are unaffected.

Reworked onto current main so it composes with #52 window sizing:
AgentWindowApi.create / SessionManager.start (extension), start_session
(CLI) and the daemon's session start now take options objects/structs
({ size, focused }) instead of colliding positional params.

Co-authored-by: alectimison-maker <alec.timison@gmail.com>
2026-08-12 19:58:53 +08:00
BB-fatandNianJiuZst 0ce497f017 fix(update): apply staged Windows replacement (#85)
Co-authored-by: NianJiuZst <3235467914@qq.com>
2026-08-12 19:45:39 +08:00
BB-fat 08212497f1 docs(skill): list bsk console and bsk network in SKILL.md (#84) 2026-08-12 19:38:49 +08:00
BB-fatandNianJiuZst f98c0606a1 fix(extension): harden websocket reconnect lifecycle (#82)
Co-authored-by: NianJiuZst <3235467914@qq.com>
2026-08-12 19:38:03 +08:00
NianJiu 1371c92721 fix(runtime): clean up sessions on disconnect (#17)
* fix(runtime): clean up sessions on disconnect

* chore(ci): match Biome 2.4 formatting

* ci: retry stalled Rust job

* test: bind daemon ports without probe races
2026-08-12 18:44:22 +08:00
Qiaochu HuandClaude c75f97119d fix(install): verify release archive checksum before install (#38)
The first-run installers (install.sh, install.ps1) downloaded and
extracted the bsk release archive with no integrity check, even though
the in-app `bsk update` path treats a sha256 checksum as mandatory
(update.rs bails with "does not include a sha256 checksum; cannot
safely auto-update"). The release version.json already carries a
per-asset sha256 (render-version-json.mjs renders with --dist), so the
installers can verify the same way.

Both installers now fetch version.json once (pinned and latest), read
`assets[<platform>].sha256`, and verify the downloaded archive's sha256
before extracting:
- install.sh: POSIX sed to parse the field (no jq dependency), and
  sha256sum/shasum to compute the digest.
- install.ps1: Invoke-RestMethod + Get-FileHash.

A checksum mismatch is fatal (fail-closed, matching `bsk update`). A
missing manifest/checksum, or the absence of a sha256 tool, only skips
verification with a warning, so the first-run bootstrap still works on
minimal/offline machines.

Validation: `sh -n` / `bash -n`; functional test of the sed extraction
against a realistic version.json and of compute_sha256 against
sha256sum. The installers are not exercised by CI (no shell harness).

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 18:44:17 +08:00
BB-fat ac806ae8f4 feat(update): auto-upgrade bsk when daemon finds a newer version (#77)
The daemon's periodic update check now installs a newer release instead
of only refreshing the hint cache: it reuses the update.rs install path
(download, sha256 verify, extract, replace), skips the round while any
agent session is live, and otherwise restarts itself into the new binary
(spawns a detached replacement that waits for the old pid, then exits).
On by default; BSK_AUTO_UPDATE=off keeps the old cache/hint-only
behaviour. Windows can only stage the replacement, so it just logs that
a restart is needed. The CLI hint wording reflects the switch.
2026-08-12 14:53:36 +08:00
BB-fat 89200975f3 fix(update): run periodic update check in daemon so hints actually fire (#76)
* fix(update): run periodic update check in daemon so hints actually fire

The CLI spawned a detached thread to refresh ~/.bsk/update-check.json,
but the process exits as soon as the command finishes, killing the
thread before the fetch could complete — so the cache was never
written and the new-version hint never showed.

Move the periodic check into the daemon: a tokio task ticks every
30 minutes (down from 24h), refreshes the cache when missing/stale,
and logs failures at warn/info into the daemon log. The CLI path is
now read-only: it prints the hint only from a fresh cache and never
spawns threads or touches the network.

* fix(update): decouple daemon refresh window so checks truly run every 30min

The refresh decision reused the 30min tick interval as its freshness
window, and with is_fresh()'s inclusive bound a cache written just
after tick N still counted as fresh at tick N+1 — so in steady state
the daemon only refetched every other tick (~60min).

Judge staleness against DAEMON_REFRESH_WINDOW (25min = 5/6 of the
tick) instead: every 30min tick now finds the cache stale and really
refreshes, while a daemon restarted with a younger-than-25min cache
still skips its first tick. CLI hint freshness semantics
(UPDATE_CHECK_INTERVAL = 30min) are unchanged.
2026-08-12 14:45:34 +08:00
Zhang GH 238a98476e Merge pull request #34 from hobostay/fix/navigate-commit-frame-loader-id
LGTM  Thanks.
2026-08-11 14:49:27 +08:00
Zhang GH 1da2d54ddc Merge pull request #39 from hobostay/fix/console-capture-retry-on-failure
fix(extension): retry console capture when a domain enable fails
LGTM
2026-08-11 14:43:43 +08:00
Zhang GH 6e35e62199 Merge pull request #33 from hobostay/fix/cdp-attach-rollback-page-enable-failure
fix(extension): roll back CDP attach when a domain enable fails
2026-08-11 14:38:12 +08:00
Zhang GH 610782698b Merge pull request #68 from Tencent/feat/more-browser-actions
feat(extension): hover end to end capability support
2026-08-10 19:26:50 +08:00
BB-fat 9504072914 chore(release): extension 0.1.6 (#74) 2026-08-09 00:34:47 +08:00
BB-fat b4450fbb8c chore(release): bsk 0.1.10 (#73) 2026-08-08 23:22:03 +08:00
BB-fat 1268761611 fix(screenshot): hide overlays during capture so shots only contain page content (#64) 2026-08-08 22:48:50 +08:00
Zhang GH 37d3e2b22f Merge pull request #72 from Tencent/issue-69-cws-publishing
CI: add automate Chrome Web Store publishing with service account
2026-08-07 19:24:30 +08:00
Ljy-0827 566b6c17fd feat(extension): hover chain node fix 2026-08-07 17:37:28 +08:00
Ljy-0827 2ccc0aafb9 feat(extension): hover chain node 2026-08-07 15:45:38 +08:00
drakezhang ce8bc6c870 add automate Chrome Web Store publishing with service account 2026-08-07 14:30:59 +08:00
Ljy-0827 eeab97bcdd feat(extension): hover candidate pool 2026-08-07 12:02:28 +08:00
Ljy-0827 6c610faed9 fix(extension): bugfix 2026-08-06 18:46:06 +08:00
Ljy-0827 d685c75d25 feat(extension): emit pending hover before actions 2026-08-06 17:47:19 +08:00
Ljy-0827 73724617f9 feat(extension): recording hover with custom div 2026-08-06 17:41:33 +08:00
Ljy-0827 4ba6fb45e1 feat(extension): recording hover 2026-08-06 17:33:46 +08:00
Zhang GH 93df62a356 Merge pull request #42 from shnpd/feat/popup-function-icon
feat(extension): use function launcher icon
2026-08-06 16:47:52 +08:00
Zhang GH 20002e1ff2 Merge pull request #63 from Tencent/feat/record-overlay-timer
feat(extension): compact recording overlay with live timer
2026-08-06 16:47:11 +08:00
“haonan” 674c53339f fix(extension): keep record overlay timer across navigations 2026-08-06 16:28:21 +08:00
Ljy-0827 9d4ac90592 fix(extension): ci and bug fix 2026-08-06 16:16:41 +08:00
Ljy-0827 cde6f22ecf fix(extension): ci and bug fix 2026-08-06 16:14:18 +08:00
Ljy-0827 d24bb6aa93 feat(extension): dispatcher fix 2026-08-06 15:48:11 +08:00
Ljy-0827 dbc4845c0d feat(extension): vom hover description bug fix 2026-08-06 15:30:11 +08:00
BB-fat d0bee1a09b feat(install-skill): add Kimi Code harness support (#65)
* feat(install-skill): add Kimi Code harness support

Add kimi-code to the install-skill harness list: installs the bundled SKILL.md into ~/.kimi/skills (Kimi Code CLI's brand-group user skills directory, highest priority per https://github.com/MoonshotAI/kimi-cli/blob/main/docs/en/customization/skills.md). Detection signals: ~/.kimi directory and kimi/kimi-cli on PATH. Aliases: kimi, kimi-code, kimi-cli, kimicode, kimi_cli.

* fix(install-skill): point kimi-code harness at ~/.kimi-code and honor KIMI_CODE_HOME

Kimi Code CLI's canonical repo is MoonshotAI/kimi-code: user-level skills live in $KIMI_CODE_HOME/skills (default ~/.kimi-code/skills), not kimi-cli's ~/.kimi/skills. Detection checks the kimi-code home dir (env-aware label) and the kimi binary on PATH. Serialize env-mutating kimi tests with a shared mutex to avoid a parallel-test race on KIMI_CODE_HOME.
2026-08-06 13:25:18 +08:00
Ljy-0827 91225e5fdb feat(extension): add bsk hover and adjust vom to support 2026-08-06 12:19:45 +08:00
“haonan” 66684c4ce3 feat(extension): compact recording overlay with live timer
Show the recording message for 10s, then switch to a fixed-width MM:SS
timer with the icon-only finish button.
2026-08-05 20:26:43 +08:00
BB-fat 0f264c5df1 feat(emulate): add mobile device emulation via CDP Emulation domain (#60)
* feat(emulate): add mobile device emulation via CDP Emulation domain

Add `bsk emulate` so agents can debug mobile page behaviour by
emulating a device environment on a session tab:

- Viewport metrics (Emulation.setDeviceMetricsOverride), User-Agent
  (Emulation.setUserAgentOverride, optional accept-language and UA
  client-hints metadata), and touch (Emulation.setTouchEmulationEnabled
  with maxTouchPoints).
- Seven CLI-side device presets (iphone-14, iphone-14-pro-max,
  iphone-se, pixel-7, galaxy-s23, ipad-mini, galaxy-tab-s8) via
  --device; manual flags (--width/--height/--dpr/--mobile/--ua/
  --accept-language/--touch/--max-touch-points) work standalone or
  override individual preset fields.
- `bsk emulate --off` clears every override (clearDeviceMetricsOverride
  + touch disabled + empty UA override) and restores the real
  environment.
- Overrides are per-tab (CDP per-target) and not inherited by new tabs;
  the result JSON carries a note reminding agents to re-apply.

Wire: new tool.emulate method end-to-end (bsk-protocol types + JSON
schemas + Method::ToolEmulate classified BrowserMutation, daemon
forwarding, CLI subcommand, extension tool handler + ChromiumCdp
Emulation wrappers, dispatcher registration), docs in both SKILL.md
copies, and unit tests on every layer.

* refine(emulate): merge per-tab state and align preset override semantics

- extension: remember the applied emulation state per tab and merge each new request onto it field by field before applying, so a later --width/--height no longer resets an earlier preset's dpr/mobile; --off clears the stored state along with the CDP overrides
- extension: cdp_failed errors now note that already-applied overrides were not rolled back and can be reset with --off
- cli: validate width/height pairing and metric requirements on the merged preset+flags result, so --device iphone-14 --width 500 keeps the preset height
- cli: add --no-mobile/--no-touch (conflicting with --mobile/--touch) to turn preset mobile/touch fields back off
- docs: describe the merge behaviour, --no-mobile/--no-touch and --off state clearing in both SKILL.md copies
2026-08-05 18:10:39 +08:00
BB-fat 49378d70de feat: support Agent Window sizing (session start --width/--height, window resize) (#52)
- `bsk session start --width <px> --height <px>` opens the Agent Window
  with the given outer size (chrome.windows.create)
- new `bsk window resize --session <id> --width <px> --height <px>`
  resizes the Agent Window at runtime (chrome.windows.update)
- both validated on CLI, daemon and extension sides (100..=7680 CSS px)
- protocol: new tool.window_resize method (BrowserMutation) and
  optional width/height on tool.session_start params; schemas regenerated
2026-08-05 11:19:10 +08:00
BB-fat d7f0f1e78c fix(install-skill): restrict --all to detected harnesses (#59)
`bsk install-skill --all` installed the bundled skill into every supported harness directory (detected or not), creating junk paths like ~/.claude/skills on machines without those harnesses. --all now targets detected harnesses only and errors with guidance when none are found; --harness remains the explicit override. The --yes non-interactive branch shares the same detected-only logic.
2026-08-04 21:35:01 +08:00
BB-fat 71255ae142 feat(request-help): add BSK_REQUEST_HELP=off to disable blocking help requests (#53)
* feat(request-help): add BSK_REQUEST_HELP=off to disable blocking help requests

* style(extension): format HelpOutcome union per biome

* docs(skill): condense BSK_REQUEST_HELP section
2026-07-31 23:54:30 +08:00
Ljy-0827 3f091318d0 chore(release): bsk 0.1.9 + extension 0.1.5 2026-07-29 15:01:59 +08:00
Zhang GH c6a2b3ec1e Merge pull request #45 from Tencent/vom
This PR brings the new VOM feature and greatly improves BrowserSkill. Some strategies may need user feedback to catch edge cases, so let's merge it now as a new version and continue polishing it iteratively.
2026-07-29 13:42:00 +08:00
Ljy-0827 d67c1a71b0 fix(vom): CI fix 2026-07-28 18:19:47 +08:00
Ljy-0827 03d2adc035 fix(vom): clean up pr 2026-07-28 17:36:41 +08:00
Ljy-0827 8f5df59b79 fix(vom): overlay 2026-07-28 17:31:33 +08:00
Ljy-0827 000fbf5540 fix(vom): bug fix 2026-07-28 16:45:40 +08:00
Ljy-0827 a52c2d6bf2 feat(vom): split function fix 2026-07-28 11:19:19 +08:00
Ljy-0827 c002c394e6 feat(vom): vom fix 2026-07-27 14:53:10 +08:00
Ljy-0827 4fb2e331a6 feat(vom): update vom algorithm, fix overlay problem with background state machine 2026-07-27 13:46:33 +08:00
Ljy-0827 e14ae9cea0 feat(vom): vom method bug fix 2026-07-24 17:25:57 +08:00
Ljy-0827 f319672721 feat(vom): method update, solve context over exposed problem 2026-07-24 17:25:57 +08:00
Ljy-0827 5e0d26ccb2 feat(vom): method update 2026-07-24 17:25:57 +08:00
Ljy-0827 9dbc553e33 feat(vom): vom base algorithm and infrastructure 2026-07-24 17:25:57 +08:00
Zhang GH b657ab574b Merge pull request #43 from Tencent/fix/early-control-return
fix(extension): fix early control return and preserve request-help handoff across tabs
LGTM
2026-07-24 15:27:41 +08:00
Ljy-0827 959bfac1ae fix(extension): human-loop fixed 2026-07-24 14:07:33 +08:00
Ljy-0827 c095128218 fix(extension): CI problem fixed, tab logic fixed 2026-07-24 14:07:33 +08:00
Ljy-0827 f73a0e9fb3 fix(runtime): incorrectly takeover the browser when user open up different tab within the same window 2026-07-24 14:07:33 +08:00
Ljy-0827 4e44fb2823 fix(runtime): fix early control return and update lifecycle 2026-07-24 14:07:33 +08:00
Zhang GH c8963e3a68 Merge pull request #37 from Tencent/fix-browser-keepalive-reconnect
Fix browser keepalive reconnect
2026-07-23 21:45:17 +08:00
“haonan”andCursor aab3b7a8e5 feat(extension): use function launcher icon
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:44:11 +08:00
Qiaochu HuandClaude 2dad162a87 fix(extension): retry console capture when a domain enable fails
`enableConsoleDomains` marked the tab in `consoleDomainsAttemptedTabs`
before issuing `Runtime.enable`/`Log.enable` and swallowed any errors,
so a transient failure (e.g. a restricted page during attach)
permanently disabled console capture for that tab: every later
`ensureConsoleCapture` was a no-op and the agent silently got an empty
console buffer with no indication that capture was broken.

This was inconsistent with `enableNetworkDomain`, which records the tab
only after `Network.enable` succeeds (and is explicitly tested as
"retries and surfaces failures"). Bring `enableConsoleDomains` in line:
mark the tab only after both domains enable successfully, so a later
`ensureConsoleCapture` can recover from a transient failure. Renamed
the flag `consoleDomainsAttemptedTabs` -> `consoleDomainsEnabledTabs`
to match `networkDomainsEnabledTabs` and reflect the new semantics.

Attach remains best-effort (it still succeeds when a domain can't be
enabled). Adds a regression test asserting `ensureConsoleCapture`
retries after a failed enable; it fails (no retry) on the previous code.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 20:12:51 +08:00
drakezhang aa63465050 Merge remote-tracking branch 'origin/main' into fix-browser-keepalive-reconnect
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	apps/extension/wxt.config.ts
#	crates/bsk-cli/src/cli/session.rs
2026-07-22 15:59:12 +08:00
Qiaochu HuandClaude 063b0bbc91 fix(extension): pass frame loaderId when waiting for navigation commit
handleNavigate's `Page.frameNavigated` handler called
`acceptLifecycleEvent("commit", frameId)` without the frame's loaderId.
Once `Page.navigate` resolves, the loader guard always carries the new
loaderId, so `eventLoaderIsRelevant(undefined, guard)` rejected the
commit event outright.

The navigation still completed in the common case only because the
readyState probe short-circuited first. On cross-origin navigations the
probe returns null (the old execution context has been destroyed), so
the wait fell back to `frameNavigated` — which was then rejected — and
`wait_until="commit"` timed out for the full `timeout_ms` instead of
resolving on commit.

Pass `p.frame?.loaderId` through, matching the sibling `lifecycleEvent`
branches. Adds a regression test that makes the readyState probe fail
and asserts commit is reached from the `frameNavigated` event; it fails
(reached "timeout") on the previous code.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 15:56:35 +08:00
Qiaochu HuandClaude 0de67c537a fix(extension): roll back CDP attach when a domain enable fails
If `chrome.debugger.attach` succeeds but a subsequent CDP domain enable
rejects — most notably `Page.enable`, when the tab navigates to a
chrome:// URL or the Web Store in the gap between the two calls — the
driver left the tab in an unrecoverable state. `attachedTabs` never
recorded the tabId, so the next `ensureAttached` re-attached and failed
permanently with "Another debugger is already attached" until the
extension was reloaded.

Detach the debuggee directly in the failure path (the public `detach()`
is a no-op here because the id is not cached yet) so the next attempt
starts from a clean slate. Adds a regression test that fails on the
previous code, where the rollback never ran and `detach` was never
called.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 15:43:29 +08:00
“haonan” ff74db48c8 chore(release): bsk 0.1.8 + extension 0.1.4 2026-07-22 15:29:02 +08:00
polarday bc8d6fa190 Merge pull request #28 from shnpd/feat/semantic-record-v3
feat: add semantic browser recording via bsk record
2026-07-22 15:17:56 +08:00
“haonan” 19981526c7 fix: record prompt copy 2026-07-22 15:05:54 +08:00
“haonan” d1de850b42 fix(record): use example.com as the default record start URL 2026-07-22 14:15:53 +08:00
“haonan” 585308df45 feat(record): add popup quick-actions launcher and make record --url optional
Add a popup launcher + action-recording panel that builds a copyable
`bsk record start` prompt, add `--browser` targeting, and default `--url`
to https://example.com/ when omitted. Drop the popup browser-alias field.
2026-07-22 13:27:30 +08:00
drakezhangandCursor 29ff239726 Make liveness thresholds configurable and cover the reaper end-to-end
Move the reaper's silence timeout and scan cadence into DaemonConfig
(defaulting to the existing 60s/15s constants) with a
`with_browser_liveness` builder, so tests can drive the task in
sub-second windows.

Add an integration test that runs a real daemon and asserts a silent
heartbeat-capable browser — and its sessions — get reaped, while a
legacy browser that never heartbeated is left untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 13:06:17 +08:00
drakezhangandCursor 31d2a1b3dc Gate liveness reaping on heartbeat capability
Only reap browsers that have sent at least one `system.heartbeat`.
A pre-heartbeat extension never opts in, so it is never dropped on
silence and keeps relying on socket-close detection exactly as before.
This removes a backward-compat regression where a new daemon paired
with an old extension would wrongly reap a live-but-idle connection
and then report `no_browser_connected` on the next command.

- Add a `heartbeat_seen` flag to `BrowserClient`, set when a heartbeat
  event arrives, and exclude never-heartbeated clients from
  `stale_browsers`.
- Cover both the opt-in and the legacy-survivor cases in unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 13:06:17 +08:00
drakezhangandCursor ed92e54dff Bump extension to 0.1.4
The manifest now declares a minimum Chrome version and requests the
`idle` permission, and the runtime gains the WebSocket heartbeat and
wake-driven reconnect. Publishing that update requires a new version.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 12:30:46 +08:00
drakezhangandCursor cb11cecdf6 Lock system.heartbeat wire name with a contract test
The extension emits the literal event string "system.heartbeat";
assert the daemon-side serde name serialises to the same value and
round-trips from the exact frame shape the extension sends, so a
future rename cannot silently break keepalive and liveness.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 12:30:46 +08:00
drakezhangandCursor ea03f36f3a Widen session.start browser-connect wait for cold reconnects
After a long idle the service worker is only revived on its next
keepalive-alarm tick (up to 30s away), so the previous 5s wait made
the first command after idle fail with `no_browser_connected` even
though the extension was about to reconnect.

- Raise the daemon's `EXTENSION_CONNECT_WAIT` (used by `session.start`)
  to 35s so it comfortably covers one alarm period plus handshake slack.
- Bump the CLI `session start` IPC timeout to that window plus slack so
  it never gives up before the daemon answers, and print a one-line
  "waiting for browser extension to connect…" hint when it actually
  waits.
- Keep `bsk status` / `browsers` on their own short 5s wait so quick
  informational commands are not slowed down.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 12:17:15 +08:00
drakezhangandCursor 234e9101ed Keep service worker alive with WS heartbeat and wake reconnect
Harden the extension's connection so the daemon link survives normal
idle periods and OS sleep instead of silently dropping.

- Send a `system.heartbeat` event every 20s while the post-handshake
  link is live. Since Chrome 116 WebSocket activity resets the MV3
  service-worker idle timer, so this keeps the worker (and the socket)
  alive during use rather than relying on the 30s keepalive alarm that
  sits right on the eviction boundary. Declare `minimum_chrome_version:
  "116"` accordingly.
- Reconnect on `runtime.onStartup` and `idle.onStateChanged` ("active"),
  which revive the worker after a browser restart or machine wake and
  let it reconnect immediately instead of waiting for the next alarm
  tick. Add the `idle` permission and document it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 12:17:06 +08:00
drakezhangandCursor 5e91178e03 Add heartbeat event and daemon browser-liveness reaper
Introduce a `system.heartbeat` protocol event and use it as a
liveness signal on the daemon side.

- Track `last_seen` per connected browser, bumped on every inbound
  WS frame (tool response, event, ping/pong, or heartbeat).
- Add a reaper task that drops any browser silent past a 60s window
  and purges its sessions, mirroring the WS disconnect cleanup. This
  releases half-open connections left behind when the OS resumes from
  sleep and kills the service worker without delivering a close frame,
  so the daemon no longer pins a phantom "connected" browser and can
  idle-exit normally.
- Wire the reaper into both the foreground daemon and the test/embed
  entry point, aborting it on shutdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 12:16:56 +08:00
“haonan” ac028a7a18 feat: add bsk record for semantic user-action traces
Capture user actions in the Agent Window and export a record-only
trace (pages[] + navigate/click/fill/select/press steps with target
descriptors). Adds the record overlay UI aligned with the control
overlay, and wires record start/stop/await through CLI, daemon,
protocol, and extension.
2026-07-22 11:27:30 +08:00
BB-fat ccf3c32140 Merge pull request #8 from hjxccc/feat/console-network-commands
feat: add bsk network read-only command
2026-07-18 22:53:20 +08:00
BB-fatandCursor 9799b98937 fix: address network command review feedback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:52:53 +08:00
hjxccc 259b4826b7 feat: add bsk network read-only command
Add `bsk network` as a sibling of the `console` command from #7,
reading the buffered network responses / failures for a tab. Fills the
remaining half of the request in #2 (console landed in #7; network did
not).

Mirrors the console command's conventions exactly:

  bsk network --session <id> [--tab-id N] [--since C]
                             [--limit N] [--max-text-chars N]

The extension enables `Network` on attach (best-effort) and buffers
`responseReceived` / `loadingFailed` events per tab with a monotonic
sequence, correlating them to `requestWillBeSent` for method/URL. Reads
are cursor-paginated (`since` -> `next_since`) and bounded (`limit`,
`max_text_chars`) for agent-context safety, same as console. Entries
carry the real HTTP status code (response) or the CDP failure reason
(failure) — things `performance.getEntriesByType('resource')` can't
give.

Classified read-only (pass-through under the pending-interrupt gate);
defaults to the Agent Window's active tab.

Wires bsk-protocol (Method + params/result/entry structs + schema),
bsk-cli (network subcommand + daemon dispatch allow-list) and the
extension (handler + dispatcher route), with unit / parse / IPC tests
paralleling the console command.

Refs #2.
2026-07-18 22:37:57 +08:00
BB-fat 3d6c95c22a Merge pull request #16 from NianJiuZst/codex/fail-closed-tab-borrow
fix(extension): fail closed on borrow confirmation
2026-07-18 22:34:31 +08:00
BB-fatandCursor 11ec51dfb8 fix(extension): dismiss overlay on borrow timeout
Send borrow-cancel when the confirmation timeout denies, and add a
timer fallback so auto-deny does not depend only on CSS transitionend.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:31:27 +08:00
BB-fat e16b37aaa0 Merge pull request #22 from NianJiuZst/codex/enforce-session-idle-timeout
fix(daemon): enforce session idle timeout
2026-07-17 16:53:21 +08:00
BB-fat d27ef1f5e0 Merge pull request #23 from NianJiuZst/codex/doctor-fails-nonzero
fix(doctor): exit nonzero on failed checks
2026-07-17 16:50:19 +08:00
BB-fat e55ce8ad99 Merge pull request #29 from klren0312/fixbug/errortips
Correct the incorrect command examples in the error message that does not indicate the mistake.
2026-07-17 16:13:49 +08:00
klkl 7bee906ffb fix(bsk-cli): Correct the incorrect command examples in the error message that does not indicate the mistake. 2026-07-17 15:42:52 +08:00
NianJiuZst bf6c384b2e test: avoid probe race in idle daemon helper 2026-07-10 23:07:43 +08:00
NianJiuZst ec2cde4467 test: bind daemon ports without probe races 2026-07-10 23:06:51 +08:00
NianJiuZst c5e83eac4c test: bind daemon ports without probe races 2026-07-10 23:06:46 +08:00
NianJiuZst 9f82d89788 test: bind daemon ports without probe races 2026-07-10 23:06:02 +08:00
NianJiuZst 2964f6219d ci: retry stalled Rust job 2026-07-10 22:59:01 +08:00
NianJiuZst f8907e5f5a ci: retry stalled Rust job 2026-07-10 22:58:57 +08:00
NianJiuZst d422f655d4 ci: retry stalled Rust job 2026-07-10 22:50:15 +08:00
NianJiuZst d4d4c3beeb ci: retry stalled Rust job 2026-07-10 22:50:01 +08:00
NianJiuZst 3c7a22e7c7 chore(ci): match Biome 2.4 formatting 2026-07-10 22:40:27 +08:00
NianJiuZst dca2b58cbd fix(doctor): exit nonzero on failed checks 2026-07-10 22:39:41 +08:00
NianJiuZst 13279a7f56 fix(daemon): enforce session idle timeout 2026-07-10 22:36:13 +08:00
NianJiuZst ed6d14eab3 fix(extension): fail closed on borrow confirmation 2026-07-10 22:01:46 +08:00
BB-fat 913a3f80a4 Merge pull request #13 from zjxzjw/fix2
config(extension): 配置WebSocket连接URL为构建时变量
2026-07-10 00:30:40 +08:00
BB-fat f60d1e65a7 Merge pull request #12 from NianJiuZst/codex/issue-10-pr-ci
Add PR CI workflow
2026-07-10 00:23:36 +08:00
BB-fat d4a89e89f1 Merge pull request #14 from Tencent/chore/fix-pnpm-lint
Fix pnpm lint formatting
2026-07-10 00:21:36 +08:00
BB-fatandCursor 3c936982a3 Fix Biome and Stylelint formatting so pnpm lint passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 00:21:19 +08:00
engineer 37056b3c6e config(extension): 配置WebSocket连接URL为构建时变量
- 移除DEFAULT_DAEMON_PORT常量定义
- 使用__BSK_DAEMON_WS_URL__构建时变量替代硬编码端口
- 在wxt.config.ts和vitest.config.ts中添加环境变量配置
- 为__BSK_DAEMON_WS_URL__添加类型声明和文档注释
- 默认值保持为ws://127.0.0.1:52800,可通过BSK_DAEMON_WS_URL环境变量覆盖
2026-07-09 13:44:48 +08:00
NianJiuZst fa3133a094 Add PR CI workflow 2026-07-09 12:42:42 +08:00
BB-fat d395faa0ab Merge pull request #9 from Tencent/feat/bsk-cli-auto-update
Add bsk CLI auto update support
2026-07-08 14:20:31 +08:00
BB-fatandCursor a7c48275f9 feat: add bsk CLI auto update
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:14:00 +08:00
BB-fatandCursor 647ae659b7 feat: add placeholder for bsk CLI auto update
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:20:59 +08:00
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
439 changed files with 103483 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
+84
View File
@@ -0,0 +1,84 @@
name: CI
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
rust:
name: Rust fmt, clippy, tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Check Rust formatting
run: cargo fmt --all -- --check
- name: Run clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Run Rust tests
run: cargo test --workspace --locked
frontend:
name: Frontend lint, typecheck, tests, build
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: Prepare extension types
run: pnpm --filter @browser-skill/extension exec wxt prepare
- name: Run lint checks
run: pnpm lint
- name: Compile extension TypeScript
run: pnpm --filter @browser-skill/extension compile
- name: Run extension tests
run: pnpm ext:test
- name: Build extension
run: pnpm ext:build
node-scripts:
name: Node script tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Run script tests
run: node --test scripts/*.test.mjs
+155
View File
@@ -0,0 +1,155 @@
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 }}" \
--dist dist \
--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
+105
View File
@@ -0,0 +1,105 @@
name: Release dsh plugin
on:
push:
tags:
- dsh-plugin-v*
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-dsh-plugin-${{ github.ref }}
cancel-in-progress: false
env:
PACKAGE_NAME: "@wxg-prc-cpg/browser-skill-dsh-plugin"
PACKAGE_DIR: packages/dsh-plugin-browserskill
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- uses: actions/checkout@v6
- name: Resolve plugin version
id: version
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').version")"
else
VERSION="${GITHUB_REF_NAME#dsh-plugin-v}"
fi
VERSION="${VERSION#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=dsh-plugin-v${VERSION}" >> "$GITHUB_OUTPUT"
guard:
needs: resolve
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Verify tag matches package.json name and version
shell: bash
run: |
set -euo pipefail
PKG_NAME="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').name")"
PKG_VERSION="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').version")"
RELEASE_VERSION="${{ needs.resolve.outputs.version }}"
if [ "$PKG_NAME" != "$PACKAGE_NAME" ]; then
echo "package.json name (${PKG_NAME}) does not match expected ${PACKAGE_NAME}"
exit 1
fi
if [ "$PKG_VERSION" != "$RELEASE_VERSION" ]; then
echo "${PACKAGE_DIR}/package.json version (${PKG_VERSION}) does not match release version (${RELEASE_VERSION})"
exit 1
fi
echo "Version guard passed: ${PKG_NAME}@${RELEASE_VERSION}"
publish:
needs: [resolve, guard]
runs-on: ubuntu-latest
# Secret NPM_TOKEN is stored on the "npm-publish" GitHub Environment.
environment: npm-publish
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org
scope: "@wxg-prc-cpg"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck plugin
run: pnpm --filter "${{ env.PACKAGE_NAME }}" typecheck
- name: Test plugin
run: pnpm --filter "${{ env.PACKAGE_NAME }}" test
- name: Publish to npm
working-directory: ${{ env.PACKAGE_DIR }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "::error::Environment secret NPM_TOKEN is not configured"
exit 1
fi
pnpm publish --access public --no-git-checks
echo "Published ${PACKAGE_NAME}@${{ needs.resolve.outputs.version }}"
+327
View File
@@ -0,0 +1,327 @@
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
publish-cws:
needs: [resolve, build-extension]
runs-on: ubuntu-latest
environment: chrome-web-store
permissions:
contents: read
steps:
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: extension-zip
path: dist
- name: Validate Chrome Web Store configuration
shell: bash
env:
PUBLISHER_ID: ${{ vars.CHROME_WEBSTORE_PUBLISHER_ID }}
EXTENSION_ID: ${{ vars.CHROME_WEBSTORE_EXTENSION_ID }}
run: |
set -euo pipefail
if [ -z "$PUBLISHER_ID" ]; then
echo "::error::Repository variable CHROME_WEBSTORE_PUBLISHER_ID is not configured"
exit 1
fi
if [ -z "$EXTENSION_ID" ]; then
echo "::error::Repository variable CHROME_WEBSTORE_EXTENSION_ID is not configured"
exit 1
fi
ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)"
if [ -z "$ARCHIVE" ]; then
echo "::error::No extension zip found in the downloaded artifact"
exit 1
fi
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
- name: Authenticate with service account
id: cws-auth
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import { sign } from "node:crypto";
import { appendFileSync } from "node:fs";
const rawCredentials = process.env.SERVICE_ACCOUNT_JSON;
if (!rawCredentials) {
throw new Error(
"Repository secret CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not configured",
);
}
let credentials;
try {
credentials = JSON.parse(rawCredentials);
} catch {
throw new Error(
"Repository secret CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not valid JSON",
);
}
if (!credentials.client_email || !credentials.private_key) {
throw new Error(
"Service account JSON must contain client_email and private_key",
);
}
const encode = (value) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const issuedAt = Math.floor(Date.now() / 1000);
const header = {
alg: "RS256",
typ: "JWT",
kid: credentials.private_key_id,
};
const claim = {
iss: credentials.client_email,
scope: "https://www.googleapis.com/auth/chromewebstore",
aud: "https://oauth2.googleapis.com/token",
iat: issuedAt,
exp: issuedAt + 3600,
};
const unsignedJwt = `${encode(header)}.${encode(claim)}`;
const signature = sign(
"RSA-SHA256",
Buffer.from(unsignedJwt),
credentials.private_key,
).toString("base64url");
const assertion = `${unsignedJwt}.${signature}`;
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion,
}),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
`Service account token exchange failed (${response.status}): ${responseText}`,
);
}
const tokenResponse = JSON.parse(responseText);
if (!tokenResponse.access_token) {
throw new Error("Service account token response has no access_token");
}
console.log(`::add-mask::${tokenResponse.access_token}`);
appendFileSync(
process.env.GITHUB_OUTPUT,
`access_token=${tokenResponse.access_token}\n`,
);
NODE
- name: Upload and publish Chrome Web Store extension
shell: bash
env:
ACCESS_TOKEN: ${{ steps.cws-auth.outputs.access_token }}
PUBLISHER_ID: ${{ vars.CHROME_WEBSTORE_PUBLISHER_ID }}
EXTENSION_ID: ${{ vars.CHROME_WEBSTORE_EXTENSION_ID }}
run: |
set -euo pipefail
ITEM_NAME="publishers/${PUBLISHER_ID}/items/${EXTENSION_ID}"
API_ROOT="https://chromewebstore.googleapis.com"
UPLOAD_RESPONSE="$RUNNER_TEMP/cws-upload-response.json"
STATUS_RESPONSE="$RUNNER_TEMP/cws-status-response.json"
PUBLISH_RESPONSE="$RUNNER_TEMP/cws-publish-response.json"
HTTP_STATUS="$(curl --silent --show-error \
--output "$UPLOAD_RESPONSE" \
--write-out '%{http_code}' \
--request POST \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/zip" \
--data-binary "@$ARCHIVE" \
"$API_ROOT/upload/v2/$ITEM_NAME:upload")"
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
echo "::error::Chrome Web Store upload failed with HTTP $HTTP_STATUS"
jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE"
exit 1
fi
UPLOAD_STATE="$(jq -r '.uploadState // empty' "$UPLOAD_RESPONSE")"
echo "Chrome Web Store upload state: $UPLOAD_STATE"
if [ "$UPLOAD_STATE" = "IN_PROGRESS" ] || [ "$UPLOAD_STATE" = "UPLOAD_IN_PROGRESS" ]; then
for attempt in $(seq 1 30); do
sleep 5
HTTP_STATUS="$(curl --silent --show-error \
--output "$STATUS_RESPONSE" \
--write-out '%{http_code}' \
--header "Authorization: Bearer $ACCESS_TOKEN" \
"$API_ROOT/v2/$ITEM_NAME:fetchStatus")"
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
echo "::error::Chrome Web Store status check failed with HTTP $HTTP_STATUS"
jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE"
exit 1
fi
UPLOAD_STATE="$(jq -r '.lastAsyncUploadState // empty' "$STATUS_RESPONSE")"
echo "Upload status check $attempt/30: $UPLOAD_STATE"
if [ "$UPLOAD_STATE" = "SUCCEEDED" ]; then
break
fi
if [ "$UPLOAD_STATE" != "IN_PROGRESS" ] && [ "$UPLOAD_STATE" != "UPLOAD_IN_PROGRESS" ]; then
echo "::error::Chrome Web Store upload did not succeed: $UPLOAD_STATE"
jq . "$STATUS_RESPONSE"
exit 1
fi
done
fi
if [ "$UPLOAD_STATE" != "SUCCEEDED" ]; then
echo "::error::Chrome Web Store upload did not complete: $UPLOAD_STATE"
jq . "$UPLOAD_RESPONSE"
exit 1
fi
HTTP_STATUS="$(curl --silent --show-error \
--output "$PUBLISH_RESPONSE" \
--write-out '%{http_code}' \
--request POST \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{"publishType":"DEFAULT_PUBLISH"}' \
"$API_ROOT/v2/$ITEM_NAME:publish")"
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
echo "::error::Chrome Web Store publish failed with HTTP $HTTP_STATUS"
jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE"
exit 1
fi
echo "Chrome Web Store submission created successfully:"
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
+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
+8
View File
@@ -0,0 +1,8 @@
**/dist/**
**/.wxt/**
**/.output/**
**/target/**
**/node_modules/**
# Generated client CSS (tailwind compile output); built by
# packages/dsh-plugin-browserskill/scripts/build-client-css.mjs.
**/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css
+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
}
}
+50
View File
@@ -0,0 +1,50 @@
# 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 install page
matching the user's browser —
[Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
for Chrome and other Chromium browsers,
[Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg)
for Microsoft Edge — then tell the user:
> Install **BrowserSkill** from the
> [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
> (or [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg)
> on Edge), 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
+2882
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
[workspace]
resolver = "2"
members = [
"crates/bsk-cli",
"crates/bsk-protocol",
]
default-members = ["crates/bsk-cli"]
[workspace.package]
version = "0.1.10"
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"
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] }
flate2 = "1.1.9"
tar = "0.4.46"
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
sha2 = "0.11.0"
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.9", 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.
+214
View File
@@ -0,0 +1,214 @@
# 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, DeepSeek Harness, and other 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)
or [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg).
#### 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 your browser's store:
| Browser | Store listing |
| --- | --- |
| Chrome | [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) |
| Microsoft Edge | [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg) |
On other Chromium-based browsers, install the Chrome Web Store build.
#### 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. DeepSeek Harness uses a
dedicated plugin instead — see [DeepSeek Harness plugin](#deepseek-harness-plugin).
</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.
```
## DeepSeek Harness plugin
Using [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)?
BrowserSkill ships a first-class dsh plugin on npm as
[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin).
It injects native `browser_*` tools (no shelling out to `bsk`) and a live Web UI
overlay of each Agent Window.
Add it to a dsh profile, then start that profile:
```sh
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
dsh --profile web
```
The plugin carries its own copy of the skill, so `bsk install-skill` is not needed
for dsh — but the `bsk` CLI and the browser extension are still prerequisites. See
the [plugin README](packages/dsh-plugin-browserskill/README.md) for the tool list,
configuration, and the observation overlay.
## 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. DeepSeek Harness takes the same path
through the [plugin](#deepseek-harness-plugin): the agent calls injected
`browser_*` tools, and the plugin invokes `bsk` on its behalf.
## 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
- `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`)
## License
MIT
+184
View File
@@ -0,0 +1,184 @@
# 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、DeepSeek Harness 等 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)
或 [Edge 加载项商店](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg) 安装浏览器扩展。
#### 1. 安装 `bsk` CLI
**macOS / Linux**(推荐,安装到 `~/.local/bin`):
```bash
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
```
**Windows**(PowerShell,安装到 `~/.local/bin`):
```powershell
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
```
验证二进制:
```bash
bsk --version
```
#### 2. 安装浏览器扩展
在对应浏览器的商店安装 BrowserSkill:
| 浏览器 | 商店页面 |
| --- | --- |
| Chrome | [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) |
| Microsoft Edge | [Edge 加载项商店](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg) |
其他基于 Chromium 的浏览器,安装 Chrome Web Store 版本即可。
#### 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`。DeepSeek Harness 走独立插件,见 [DeepSeek Harness 插件](#deepseek-harness-插件)。
</details>
启动一个新的 Agent 会话,写一条需要使用浏览器的 prompt,例如:
```text
/browser-skill open example.com and summarize what is on the page.
```
## DeepSeek Harness 插件
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它会注入原生 `browser_*` 工具(无需再通过 Shell 调用 `bsk`),并在 Web UI 中实时观察每个 Agent Window。
把它装进某个 dsh profile,然后启动该 profile:
```sh
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
dsh --profile web
```
插件自带 skill,所以在 dsh 下无需执行 `bsk install-skill`;但 `bsk` CLI 和浏览器扩展仍是前置条件。工具清单、配置项与观察浮层见[插件 README](packages/dsh-plugin-browserskill/README.md)。
## 工作原理
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 中执行。DeepSeek Harness 走同一条链路,只是经由 [插件](#deepseek-harness-插件):Agent 调用注入的 `browser_*` 工具,由插件代为执行 `bsk`。
## 面向开发者
本仓库是 Cargo + pnpm workspace:
- `crates/bsk-cli` — `bsk` CLI 与本地 daemon
- `crates/bsk-protocol` — 共享协议类型与 JSON Schema
- `apps/extension` — 浏览器扩展
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`)
## 许可证
MIT
View File
+92
View File
@@ -0,0 +1,92 @@
# 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.
- **`idle`** — Detect when the device returns from idle/locked so the Extension can promptly re-establish the local WebSocket connection after the machine wakes. No idle data is stored or transmitted.
- **`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。
+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.30589 17.9712C6.21652 18.4603 6.63298 18.8722 7.12749 18.7838C7.88158 18.6491 8.86635 18.4075 9.25454 18.0236C9.49544 17.7853 9.61164 17.4432 9.57757 17.0726C9.5435 16.702 9.36195 16.3331 9.07287 16.0472C8.78378 15.7613 8.41084 15.5818 8.03608 15.5481C7.66133 15.5144 7.31545 15.6293 7.07455 15.8676C6.6865 16.2513 6.4422 17.2253 6.30589 17.9712Z" fill="#E94500"/>
<path d="M7.77425 8.64281C10.7374 4.12636 15.4876 3.7066 17.9364 4.13499C18.3335 4.20461 18.6474 4.48261 18.7821 4.84105C20.6715 9.87191 18.4167 13.7056 16.5008 15.5334C16.1262 15.8909 15.9395 16.3745 16.0282 16.8215L16.12 17.2834C16.24 17.887 15.871 18.4819 15.2723 18.6496L12.7811 19.3469C12.1057 19.536 11.4124 19.1112 11.285 18.4289L11.036 17.0959C10.9844 16.82 10.8353 16.5712 10.6151 16.3948L8.12777 14.4035C7.90665 14.2266 7.62818 14.1345 7.34359 14.1428L5.72835 14.1897C4.9877 14.2112 4.40832 13.5657 4.51644 12.8401L4.89632 10.2912C4.98194 9.71734 5.47627 9.2895 6.06234 9.28148L6.69124 9.27171C7.11716 9.26594 7.52243 9.02649 7.77425 8.64281ZM14.4959 7.91429C13.8284 7.91436 13.2869 8.45568 13.2869 9.12327C13.2871 9.79078 13.8284 10.3322 14.4959 10.3323C15.1635 10.3323 15.7048 9.79082 15.7049 9.12327C15.7049 8.45564 15.1636 7.91429 14.4959 7.91429Z" fill="#E94500"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+38
View File
@@ -0,0 +1,38 @@
{
"name": "@browser-skill/extension",
"version": "0.1.6",
"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:*",
"@browser-skill/vom": "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,674 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpDebuggee, 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<[CdpDebuggee, 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("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
const { api, onEvent } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (
target: chrome.debugger.Debuggee & { sessionId?: string },
method: string,
params?: { frameId?: string },
) => {
if (method === "DOM.getFrameOwner") {
return { backendNodeId: params?.frameId === "nested" ? 300 : 200 };
}
if (method !== "Page.getFrameTree") return {};
if (target.sessionId === "right-session") {
return {
frameTree: {
frame: { id: "right", url: "https://right.test" },
childFrames: [
{ frame: { id: "nested", parentId: "right", url: "https://nested.test" } },
],
},
};
}
if (target.sessionId === "nested-session") {
return { frameTree: { frame: { id: "nested", url: "https://nested.test" } } };
}
return {
frameTree: {
frame: { id: "main", url: "https://app.test" },
childFrames: [
{ frame: { id: "left", parentId: "main", url: "https://left.test" } },
{
frame: { id: "right", parentId: "main", url: "https://right.test" },
childFrames: [
{ frame: { id: "nested", parentId: "right", url: "https://nested.test" } },
],
},
],
},
};
},
);
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(4);
onEvent.fire({ tabId: 4 }, "Target.attachedToTarget", { sessionId: "right-session" });
onEvent.fire({ tabId: 4 } as chrome.debugger.Debuggee, "Target.attachedToTarget", {
sessionId: "nested-session",
});
const graph = await cdp.getFrameGraph(4);
expect(graph.frames).toHaveLength(4);
expect(graph.frames.find((frame) => frame.frameId === "left")?.target).toEqual({ tabId: 4 });
expect(graph.frames.find((frame) => frame.frameId === "right")?.target.sessionId).toBe(
"right-session",
);
expect(graph.frames.find((frame) => frame.frameId === "nested")?.target.sessionId).toBe(
"nested-session",
);
expect(graph.frames.find((frame) => frame.frameId === "right")?.ownerBackendNodeId).toBe(200);
expect(graph.frames.find((frame) => frame.frameId === "nested")?.ownerBackendNodeId).toBe(300);
expect(api.sendCommand).toHaveBeenCalledWith(
{ tabId: 4, sessionId: "right-session" },
"DOM.getFrameOwner",
{ frameId: "nested" },
);
expect(api.sendCommand).toHaveBeenCalledWith(
{ tabId: 4, sessionId: "nested-session" },
"Target.setAutoAttach",
expect.objectContaining({ flatten: true }),
);
});
it("waits for recursively attached iframe targets without a depth limit", async () => {
const { api, onEvent } = fakeApi();
const depth = 6;
const scheduled = new Set<string>();
const nestedTree = (index: number): Record<string, unknown> => ({
frame: {
id: index === 0 ? "main" : `frame-${index}`,
...(index > 0 ? { parentId: index === 1 ? "main" : `frame-${index - 1}` } : {}),
},
...(index < depth ? { childFrames: [nestedTree(index + 1)] } : {}),
});
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (target: CdpDebuggee, method: string, params?: { frameId?: string }) => {
if (method === "Target.setAutoAttach") {
const parentIndex = target.sessionId
? Number(target.sessionId.replace("session-", ""))
: 0;
const nextIndex = parentIndex + 1;
const key = `${target.sessionId ?? "root"}:${nextIndex}`;
if (nextIndex <= depth && !scheduled.has(key)) {
scheduled.add(key);
setTimeout(() => {
onEvent.fire(
{ tabId: 4, ...(target.sessionId ? { sessionId: target.sessionId } : {}) },
"Target.attachedToTarget",
{
sessionId: `session-${nextIndex}`,
targetInfo: { type: "iframe" },
},
);
}, 5);
}
return {};
}
if (method === "Page.getFrameTree") {
if (!target.sessionId) return { frameTree: nestedTree(0) };
const index = Number(target.sessionId.replace("session-", ""));
return { frameTree: nestedTree(index) };
}
if (method === "DOM.getFrameOwner") {
return { backendNodeId: Number(params?.frameId?.replace("frame-", "")) + 100 };
}
return {};
},
);
const cdp = new ChromiumCdp(api);
const graph = await cdp.getFrameGraph(4);
expect(graph.frames).toHaveLength(depth + 1);
for (let index = 1; index <= depth; index += 1) {
expect(
graph.frames.find((frame) => frame.frameId === `frame-${index}`)?.target.sessionId,
).toBe(`session-${index}`);
}
});
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("rolls back the raw attach when a domain enable fails so the tab is not left stuck", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Page.enable") throw new Error("Page.enable rejected");
return {};
},
);
const cdp = new ChromiumCdp(api);
// Raw attach succeeds, but Page.enable fails. Without the rollback
// the debugger would stay attached while `attachedTabs` omits the id,
// leaving the tab stuck on "Another debugger is already attached" for
// every later call until the extension is reloaded.
await expect(cdp.ensureAttached(42)).rejects.toThrow(/Page\.enable rejected/);
expect(cdp.isAttached(42)).toBe(false);
expect(api.detach).toHaveBeenCalledWith({ tabId: 42 });
// The rollback detached, so a fresh attach can succeed afterwards.
(api.sendCommand as ReturnType<typeof vi.fn>).mockResolvedValue({});
await expect(cdp.ensureAttached(42)).resolves.toBeUndefined();
expect(cdp.isAttached(42)).toBe(true);
});
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", {});
});
it("retries console capture after a domain enable fails 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);
// Attach is best-effort: Log.enable fails but attach still succeeds,
// leaving the tab unmarked so capture can be retried.
await cdp.ensureAttached(12);
const afterAttach = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
([, method]) => method === "Runtime.enable" || method === "Log.enable",
);
expect(afterAttach).toHaveLength(2);
// Before the fix the "attempted" flag was set before success, so this
// was a no-op and the tab silently returned no console output forever.
// Now it retries both domains.
await cdp.ensureConsoleCapture(12);
const afterRetry = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
([, method]) => method === "Runtime.enable" || method === "Log.enable",
);
expect(afterRetry).toHaveLength(4);
});
it("retries and surfaces Network.enable failures for explicit capture", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Network.enable") throw new Error("network restricted");
return {};
},
);
const cdp = new ChromiumCdp(api);
await expect(cdp.ensureAttached(13)).resolves.toBeUndefined();
await expect(cdp.ensureNetworkCapture(13)).rejects.toThrow("network restricted");
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
([, method]) => method === "Network.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" }],
});
expect(cdp.consoleEntriesSince(23, undefined, 1, 1000, false)).toMatchObject({
next_since: 3,
truncated: true,
entries: [{ sequence: 3, text: "message 3" }],
});
});
it("reports dropped buffered entries only when they affect the cursor", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(25);
for (let i = 1; i <= 201; i += 1) {
onEvent.fire({ tabId: 25 }, "Runtime.consoleAPICalled", {
type: "log",
args: [{ value: `message ${i}` }],
});
}
expect(cdp.consoleEntriesSince(25, 0, 200, 1000, false).truncated).toBe(true);
expect(cdp.consoleEntriesSince(25, 200, 200, 1000, false)).toMatchObject({
next_since: 201,
truncated: false,
entries: [{ sequence: 201, text: "message 201" }],
});
});
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("isolates network request metadata by tab and consumes it", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(31);
await cdp.ensureAttached(32);
onEvent.fire({ tabId: 31 }, "Network.requestWillBeSent", {
requestId: "shared-id",
request: { url: "https://one.test/api", method: "GET" },
type: "Fetch",
});
onEvent.fire({ tabId: 32 }, "Network.requestWillBeSent", {
requestId: "shared-id",
request: { url: "https://two.test/script.js", method: "POST" },
type: "Script",
});
onEvent.fire({ tabId: 31 }, "Network.responseReceived", {
requestId: "shared-id",
response: { status: 204 },
type: "Fetch",
});
onEvent.fire({ tabId: 32 }, "Network.loadingFailed", {
requestId: "shared-id",
errorText: "net::ERR_FAILED",
type: "Script",
});
onEvent.fire({ tabId: 31 }, "Network.loadingFailed", {
requestId: "shared-id",
errorText: "net::ERR_ABORTED",
});
expect(cdp.networkEntriesSince(31, 0, 50, 1000).entries).toMatchObject([
{
kind: "response",
method: "GET",
url: "https://one.test/api",
status: 204,
},
{
kind: "failure",
url: undefined,
error_text: "net::ERR_ABORTED",
},
]);
expect(cdp.networkEntriesSince(32, 0, 50, 1000).entries).toMatchObject([
{
kind: "failure",
method: "POST",
url: "https://two.test/script.js",
error_text: "net::ERR_FAILED",
},
]);
});
it("bounds request metadata and clears it on detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(33);
const longUrl = `https://example.test/${"x".repeat(5000)}`;
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "long",
request: { url: longUrl, method: "GET" },
});
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
requestId: "long",
errorText: "net::ERR_FAILED",
});
const bounded = cdp.networkEntriesSince(33, 0, 50, 10_000).entries[0];
expect(bounded.url).toHaveLength(4096);
expect(bounded.truncated).toBe(true);
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "long-method",
request: { url: "https://example.test/ok", method: "M".repeat(5000) },
});
onEvent.fire({ tabId: 33 }, "Network.responseReceived", {
requestId: "long-method",
response: { url: "https://example.test/ok", status: 200 },
});
const boundedResponse = cdp.networkEntriesSince(33, 1, 50, 10_000).entries[0];
expect(boundedResponse.method).toHaveLength(4096);
expect(boundedResponse.truncated).toBe(true);
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "stale",
request: { url: "https://example.test/stale", method: "GET" },
});
await cdp.detach(33);
await cdp.ensureAttached(33);
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
requestId: "stale",
errorText: "net::ERR_ABORTED",
});
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
});
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,136 @@
import { describe, expect, it } from "vitest";
import { buildFrameGraph, type CdpFrameTreeNode, type CdpFrameTreeSource } from "../frame-graph";
describe("buildFrameGraph", () => {
it("keeps sibling frames distinct and routes nested OOPIFs to their child sessions", () => {
const graph = buildFrameGraph([
{
target: { tabId: 4 },
tree: {
frame: { id: "main", url: "https://app.test" },
childFrames: [
{ frame: { id: "left", parentId: "main", url: "https://left.test" } },
{
frame: { id: "right", parentId: "main", url: "https://right.test" },
childFrames: [
{ frame: { id: "nested", parentId: "right", url: "https://nested.test" } },
],
},
],
},
},
{
target: { tabId: 4, sessionId: "right-session" },
tree: {
frame: { id: "right", url: "https://right.test" },
childFrames: [{ frame: { id: "nested", parentId: "right", url: "https://nested.test" } }],
},
},
{
target: { tabId: 4, sessionId: "nested-session" },
tree: { frame: { id: "nested", url: "https://nested.test" } },
},
]);
expect(graph?.frames).toHaveLength(4);
expect(graph?.frames.find((frame) => frame.frameId === "left")?.target).toEqual({ tabId: 4 });
expect(graph?.frames.find((frame) => frame.frameId === "right")).toMatchObject({
parentFrameId: "main",
target: { tabId: 4, sessionId: "right-session" },
});
expect(graph?.frames.find((frame) => frame.frameId === "nested")).toMatchObject({
parentFrameId: "right",
target: { tabId: 4, sessionId: "nested-session" },
});
});
it("propagates each target boundary to its same-process descendants", () => {
const sources: CdpFrameTreeSource[] = [
{
target: { tabId: 4 },
tree: {
frame: { id: "main" },
childFrames: [
{
frame: { id: "oopif-a", parentId: "main" },
childFrames: [
{
frame: { id: "same-process-b", parentId: "oopif-a" },
childFrames: [
{
frame: { id: "oopif-c", parentId: "same-process-b" },
childFrames: [{ frame: { id: "same-process-d", parentId: "oopif-c" } }],
},
],
},
],
},
],
},
},
{
target: { tabId: 4, sessionId: "session-a" },
tree: {
frame: { id: "oopif-a" },
childFrames: [
{
frame: { id: "same-process-b", parentId: "oopif-a" },
childFrames: [{ frame: { id: "oopif-c", parentId: "same-process-b" } }],
},
],
},
},
{
target: { tabId: 4, sessionId: "session-c" },
tree: {
frame: { id: "oopif-c" },
childFrames: [{ frame: { id: "same-process-d", parentId: "oopif-c" } }],
},
},
];
const graph = buildFrameGraph(sources);
expect(graph?.frames.map((frame) => [frame.frameId, frame.target.sessionId])).toEqual([
["main", undefined],
["oopif-a", "session-a"],
["same-process-b", "session-a"],
["oopif-c", "session-c"],
["same-process-d", "session-c"],
]);
expect(graph?.frames.find((frame) => frame.frameId === "oopif-a")?.parentFrameId).toBe("main");
expect(graph?.frames.find((frame) => frame.frameId === "oopif-c")?.parentFrameId).toBe(
"same-process-b",
);
const reordered = buildFrameGraph([sources[2], sources[1], sources[0]]);
expect(reordered).toEqual(graph);
});
it("walks deeply nested frame trees without consuming the JavaScript call stack", () => {
const depth = 5_000;
const root: CdpFrameTreeNode = { frame: { id: "frame-0" } };
let parent = root;
for (let index = 1; index <= depth; index += 1) {
const child: CdpFrameTreeNode = {
frame: { id: `frame-${index}`, parentId: `frame-${index - 1}` },
};
parent.childFrames = [child];
parent = child;
}
const graph = buildFrameGraph([{ target: { tabId: 4 }, tree: root }]);
expect(graph?.frames).toHaveLength(depth + 1);
expect(graph?.frames.at(-1)?.frameId).toBe(`frame-${depth}`);
});
it("does not loop when an in-memory frame tree contains an object cycle", () => {
const root: CdpFrameTreeNode = { frame: { id: "main" } };
root.childFrames = [root];
expect(buildFrameGraph([{ target: { tabId: 4 }, tree: root }])?.frames).toEqual([
{ frameId: "main", target: { tabId: 4 } },
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,107 @@
export interface CdpTarget {
tabId: number;
sessionId?: string;
}
export interface CdpFrame {
frameId: string;
parentFrameId?: string;
ownerBackendNodeId?: number;
url?: string;
target: CdpTarget;
}
export interface CdpFrameGraph {
rootFrameId: string;
frames: CdpFrame[];
}
export interface CdpFrameTreeNode {
frame: {
id: string;
parentId?: string;
url?: string;
};
childFrames?: CdpFrameTreeNode[];
}
export interface CdpFrameTreeSource {
target: CdpTarget;
tree: CdpFrameTreeNode;
}
export function cdpTargetKey(target: CdpTarget): string {
return `${target.tabId}:${target.sessionId ?? "root"}`;
}
function mergeFrame(
frames: Map<string, CdpFrame>,
order: string[],
node: CdpFrameTreeNode,
target: CdpTarget,
): void {
const existing = frames.get(node.frame.id);
if (!existing) order.push(node.frame.id);
const parentFrameId = node.frame.parentId || existing?.parentFrameId;
const url = node.frame.url || existing?.url;
frames.set(node.frame.id, {
frameId: node.frame.id,
...(parentFrameId ? { parentFrameId } : {}),
...(url ? { url } : {}),
target,
});
}
function walkFrameTree(
source: CdpFrameTreeSource,
targetByRootFrameId: ReadonlyMap<string, CdpTarget>,
frames: Map<string, CdpFrame>,
order: string[],
): void {
const stack: Array<{ node: CdpFrameTreeNode; inheritedTarget: CdpTarget }> = [
{ node: source.tree, inheritedTarget: source.target },
];
const expanded = new WeakSet<CdpFrameTreeNode>();
while (stack.length > 0) {
const current = stack.pop();
if (!current || expanded.has(current.node)) continue;
expanded.add(current.node);
const target = targetByRootFrameId.get(current.node.frame.id) ?? current.inheritedTarget;
mergeFrame(frames, order, current.node, target);
const children = current.node.childFrames ?? [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push({ node: children[index], inheritedTarget: target });
}
}
}
export function buildFrameGraph(sources: CdpFrameTreeSource[]): CdpFrameGraph | null {
const rootSource = sources.find((source) => source.target.sessionId === undefined) ?? sources[0];
if (!rootSource) return null;
const frames = new Map<string, CdpFrame>();
const order: string[] = [];
const targetByRootFrameId = new Map(
sources.map((source) => [source.tree.frame.id, source.target] as const),
);
// The top-level source is authoritative if malformed input reports the same
// frame as both a root target and a child target.
targetByRootFrameId.set(rootSource.tree.frame.id, rootSource.target);
walkFrameTree(rootSource, targetByRootFrameId, frames, order);
for (const source of sources) {
if (source === rootSource) continue;
walkFrameTree(source, targetByRootFrameId, frames, order);
}
return {
rootFrameId: rootSource.tree.frame.id,
frames: order.flatMap((frameId) => {
const frame = frames.get(frameId);
return frame ? [frame] : [];
}),
};
}
@@ -0,0 +1,293 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiCloseLine } from "@remixicon/react";
import { type TransitionEvent, useEffect, useRef, useState } 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>
);
}
/** Matches BorrowProgressBar / CountdownRing transition duration (duration-1000). */
const PROGRESS_TRANSITION_MS = 1000;
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 [awaitingAutoDeny, setAwaitingAutoDeny] = useState(false);
const settledRef = useRef(false);
const onAllowRef = useRef(request.onAllow);
const onDenyRef = useRef(request.onDeny);
onAllowRef.current = request.onAllow;
onDenyRef.current = request.onDeny;
function triggerAllow() {
if (settledRef.current) return;
settledRef.current = true;
setAwaitingAutoDeny(false);
setExiting(true);
setTimeout(() => onAllowRef.current(), EXIT_ANIMATION_MS);
}
function handleAllow() {
triggerAllow();
}
function handleDeny() {
if (settledRef.current) return;
settledRef.current = true;
setAwaitingAutoDeny(false);
setExiting(true);
setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS);
}
useEffect(() => {
if (secondsLeft <= 0) {
if (!settledRef.current) {
setAwaitingAutoDeny(true);
}
return;
}
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [secondsLeft]);
// Prefer transitionend for visual sync, but fall back to a timer so
// background tabs / suppressed CSS transitions still auto-deny.
useEffect(() => {
if (!awaitingAutoDeny || settledRef.current) return;
const id = setTimeout(() => {
handleDeny();
}, PROGRESS_TRANSITION_MS);
return () => clearTimeout(id);
}, [awaitingAutoDeny]);
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 (!awaitingAutoDeny || secondsLeft !== 0) return;
if (propertyName !== "stroke-dashoffset") return;
handleDeny();
}}
/>
<span className="text-[13px] text-gray-500">
{t("borrowConfirmation.autoDeny", { 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 (!awaitingAutoDeny || secondsLeft !== 0) return;
if (event.propertyName !== "width") return;
handleDeny();
}}
/>
<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,183 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiStopCircleLine } from "@remixicon/react";
import { useEffect, useRef, 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);
const blockerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (visible) {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}
setShow(false);
}, [visible]);
useEffect(() => {
const blocker = blockerRef.current;
if (!blocker) return;
const stopScroll = (event: WheelEvent | TouchEvent) => {
if (automationBypass) return;
event.preventDefault();
event.stopPropagation();
};
blocker.addEventListener("wheel", stopScroll, { passive: false });
blocker.addEventListener("touchmove", stopScroll, { passive: false });
return () => {
blocker.removeEventListener("wheel", stopScroll);
blocker.removeEventListener("touchmove", stopScroll);
};
}, [automationBypass]);
useEffect(() => {
if (!visible || automationBypass) return;
const stopScroll = (event: WheelEvent | TouchEvent) => {
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("wheel", stopScroll, { capture: true, passive: false });
window.addEventListener("touchmove", stopScroll, { capture: true, passive: false });
return () => {
window.removeEventListener("wheel", stopScroll, { capture: true });
window.removeEventListener("touchmove", stopScroll, { capture: true });
};
}, [visible, automationBypass]);
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
ref={blockerRef}
data-slot="control-overlay-blocker"
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
data-slot="control-overlay-pill"
style={{
position: "fixed",
bottom: 32,
left: "50%",
transform: "translateX(-50%)",
zIndex: 2147483647,
pointerEvents: "auto",
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: "auto",
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,814 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiArrowDownSLine } from "@remixicon/react";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { HelpHighlightRect } from "@/lib/help-bridge";
import logoUrl from "../../assets/logo.png";
export interface HelpRequestData {
id: string;
prompt: string;
/** Custom overlay title; falls back to i18n when omitted. */
title?: string;
/** Full task UI on the subject tab; compact status UI on related tabs. */
displayMode?: "full" | "compact";
/** CSS selectors to scroll to + flash-highlight. */
selectors: string[];
/** Pre-resolved top-viewport rectangles, used for cross-frame targets. */
rects?: HelpHighlightRect[];
/** Re-resolve cross-frame rectangles after scroll, resize, or layout changes. */
refreshRects?: () => Promise<HelpHighlightRect[] | undefined>;
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;
const EMPTY_SELECTORS: string[] = [];
const EMPTY_RECTS: HelpHighlightRect[] = [];
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 isCompact = request?.displayMode === "compact";
const effectiveSelectors = isCompact ? EMPTY_SELECTORS : (request?.selectors ?? EMPTY_SELECTORS);
const [liveRects, setLiveRects] = useState<HelpHighlightRect[] | null>(null);
const effectiveRects = isCompact ? EMPTY_RECTS : (liveRects ?? request?.rects ?? EMPTY_RECTS);
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("");
setLiveRects(null);
setCollapsed(false);
placementRef.current = null;
setDragPos(null);
setDragging(false);
userMovedRef.current = false;
dragStartRef.current = null;
}, [request?.id]);
useEffect(() => {
if (!request || isCompact || !request.refreshRects) return;
let disposed = false;
let resolving = false;
const refresh = async () => {
if (resolving) return;
resolving = true;
try {
const rects = await request.refreshRects?.();
if (!disposed && rects) setLiveRects(rects);
} finally {
resolving = false;
}
};
const onViewportChange = () => void refresh();
window.addEventListener("scroll", onViewportChange, true);
window.addEventListener("resize", onViewportChange);
const interval = window.setInterval(onViewportChange, 500);
return () => {
disposed = true;
window.removeEventListener("scroll", onViewportChange, true);
window.removeEventListener("resize", onViewportChange);
window.clearInterval(interval);
};
}, [request, isCompact]);
// Scroll the first matched target into view once per distinct request id.
useLayoutEffect(() => {
if (!request) return;
if (isCompact) return;
if (lastScrolledIdRef.current === request.id) return;
for (const sel of effectiveSelectors) {
const el = querySelectorSafe(sel);
if (el) {
lastScrolledIdRef.current = request.id;
el.scrollIntoView({ block: "center", behavior: "smooth" });
break;
}
}
}, [request, isCompact, effectiveSelectors]);
// Keep highlight boxes aligned with the page as it scrolls / resizes.
useEffect(() => {
if (!request) {
setBoxes((prev) => (prev.length === 0 ? prev : []));
return;
}
const update = () => {
const nextBoxes = [...measure(effectiveSelectors), ...effectiveRects];
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, effectiveSelectors, effectiveRects]);
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 (isCompact) {
setPanelPos(null);
return;
}
if (userMovedRef.current) return;
const measured = boxes.length > 0 ? boxes : [...measure(effectiveSelectors), ...effectiveRects];
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, isCompact, effectiveSelectors, effectiveRects]);
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;
justify-content: flex-start;
min-height: 0;
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: 12px;
}
.bsk-help-banner[data-display-mode="compact"] {
width: min(360px, calc(100vw - ${VIEWPORT_MARGIN * 2}px));
padding: 12px 14px;
gap: 0;
border-radius: 12px;
}
.bsk-help-drag-strip {
position: absolute;
top: 6px;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
height: 12px;
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 {
top: 4px;
}
.bsk-help-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header {
gap: 8px;
}
.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-compact-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
color: #374151;
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-title {
white-space: nowrap;
}
.bsk-help-header-actions {
flex-shrink: 0;
display: none;
align-items: center;
gap: 10px;
margin-left: auto;
overflow: hidden;
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-header-actions {
display: flex;
max-width: 280px;
opacity: 1;
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: block;
flex-shrink: 0;
min-width: 0;
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-body {
position: absolute;
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;
flex-wrap: wrap;
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;
min-width: 0;
white-space: normal;
}
.bsk-help-btn-continue {
cursor: pointer;
border: none;
background: #f97316;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #fff;
min-width: 0;
white-space: normal;
}
.bsk-help-header-actions .bsk-help-btn-cancel,
.bsk-help-header-actions .bsk-help-btn-continue {
padding: 6px 12px;
white-space: nowrap;
}
.bsk-help-header-actions .bsk-help-btn-continue {
padding: 6px 14px;
max-width: 180px;
}
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions {
display: flex;
max-width: none;
}
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions .bsk-help-btn-cancel,
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions .bsk-help-btn-continue {
padding: 6px 10px;
font-size: 12px;
}
.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>
{!isCompact &&
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-display-mode={isCompact ? "compact" : "full"}
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
}
>
{!isCompact && (
<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 }}
/>
{isCompact ? (
<span className="bsk-help-compact-title">{t("helpRequest.compactStatus")}</span>
) : (
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
)}
{!isCompact && (
<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>
)}
{!isCompact && (
<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>
{!isCompact && (
<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,287 @@
import { useTranslation } from "@browser-skill/i18n/react";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import recordIconUrl from "../../assets/record-icon.png";
export interface RecordRequestData {
id: string;
/**
* Epoch ms when the recording began. Supplied by the background so the timer
* keeps counting the whole session across navigations; falls back to mount
* time when the background could not report it.
*/
startedAtMs?: number;
onFinish: () => void;
}
type Props = {
request: RecordRequestData | null;
};
const VIEWPORT_MARGIN = 16;
const DRAG_THRESHOLD_PX = 8;
const INTRO_DURATION_MS = 10_000;
const TIMER_REFRESH_MS = 250;
function formatElapsed(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`;
}
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)),
};
}
export function RecordOverlay({ request }: Props) {
const { t } = useTranslation("extension");
const [show, setShow] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [dragPos, setDragPos] = useState<{ top: number; left: number } | null>(null);
const [dragging, setDragging] = useState(false);
const pillRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef<{
pointerX: number;
pointerY: number;
originLeft: number;
originTop: number;
panelW: number;
panelH: number;
armed: boolean;
} | null>(null);
useEffect(() => {
if (request) {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}
setShow(false);
}, [request]);
const requestStartedAtMs = request?.startedAtMs;
useEffect(() => {
setDragPos(null);
setDragging(false);
dragStartRef.current = null;
if (!request) {
setElapsedSeconds(0);
return;
}
const startedAt = requestStartedAtMs ?? Date.now();
const updateElapsed = () => {
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1_000)));
};
updateElapsed();
const timer = window.setInterval(updateElapsed, TIMER_REFRESH_MS);
return () => window.clearInterval(timer);
}, [request?.id, requestStartedAtMs]);
const onPillPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (e.target instanceof Element && e.target.closest("button, a, input, textarea, select")) {
return;
}
const pill = pillRef.current;
if (!pill) return;
const rect = pill.getBoundingClientRect();
dragStartRef.current = {
pointerX: e.clientX,
pointerY: e.clientY,
originLeft: rect.left,
originTop: rect.top,
panelW: pill.offsetWidth || rect.width,
panelH: pill.offsetHeight || rect.height,
armed: false,
};
}, []);
useEffect(() => {
const onMove = (e: PointerEvent) => {
const start = dragStartRef.current;
if (!start) return;
const dx = e.clientX - start.pointerX;
const dy = e.clientY - start.pointerY;
if (!start.armed) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
start.armed = true;
setDragging(true);
setDragPos({ top: start.originTop, left: start.originLeft });
}
setDragPos(
clampDragPos(start.originTop + dy, start.originLeft + dx, start.panelW, start.panelH),
);
};
const onUp = () => {
const start = dragStartRef.current;
if (!start) return;
if (start.armed) {
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);
};
}, []);
if (!request) return null;
const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS;
const positioned = dragPos !== null;
const defaultTransform = show
? "translateX(-50%) translateY(0)"
: "translateX(-50%) translateY(8px)";
return (
<>
<style>{`
@keyframes bsk-rec-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.82); }
}
`}</style>
<div
ref={pillRef}
data-slot="record-overlay-pill"
data-phase={introVisible ? "intro" : "timer"}
data-dragging={dragging ? "true" : "false"}
onPointerDown={onPillPointerDown}
style={{
position: "fixed",
...(positioned
? { top: dragPos.top, left: dragPos.left, bottom: "auto" }
: { bottom: 32, left: "50%" }),
zIndex: 2147483647,
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 5,
backgroundColor: "#fff",
borderRadius: 9999,
height: 50,
boxSizing: "border-box",
padding: "6px 8px 6px 16px",
boxShadow: dragging
? "0 12px 40px rgba(15,23,42,0.22), 0 2px 8px rgba(0,0,0,0.12)"
: "0 8px 32px rgba(15,23,42,0.16), 0 2px 8px rgba(0,0,0,0.1)",
opacity: show ? 1 : 0,
transform: positioned ? "none" : defaultTransform,
transition: dragging
? "box-shadow 150ms ease-out"
: "opacity 300ms ease-out, transform 300ms ease-out, box-shadow 150ms ease-out",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
maxWidth: "min(420px, calc(100vw - 32px))",
cursor: dragging ? "grabbing" : "grab",
userSelect: "none",
touchAction: "none",
}}
>
<div
data-slot="record-overlay-status"
style={{
display: "flex",
alignItems: "center",
gap: 8,
minWidth: 0,
flex: 1,
borderRadius: 9999,
outline: "none",
minHeight: 32,
paddingRight: 4,
}}
>
<span
data-slot="record-overlay-indicator"
style={{
width: 9,
height: 9,
borderRadius: "50%",
backgroundColor: "#ef4444",
flexShrink: 0,
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
}}
/>
<span
data-slot="record-overlay-label"
style={{
display: "block",
flex: "0 1 auto",
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
overflow: "hidden",
textOverflow: "ellipsis",
width: introVisible ? "auto" : 48,
maxWidth: introVisible ? 280 : 48,
fontVariantNumeric: introVisible ? undefined : "tabular-nums",
textAlign: introVisible ? "left" : "center",
margin: 0,
padding: 0,
minWidth: 0,
transition: "max-width 180ms ease-out",
}}
>
{introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)}
</span>
</div>
<button
type="button"
data-slot="record-overlay-finish"
aria-label={t("recordOverlay.finish")}
onClick={request.onFinish}
style={{
pointerEvents: "auto",
display: "block",
width: 32,
height: 32,
border: "none",
borderRadius: 9999,
padding: 0,
backgroundColor: "transparent",
cursor: "pointer",
flexShrink: 0,
}}
>
<img
src={recordIconUrl}
alt=""
width={24}
height={24}
draggable={false}
style={{ display: "block", margin: "auto" }}
/>
</button>
</div>
</>
);
}
@@ -0,0 +1,120 @@
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-denies 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();
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(onDeny).toHaveBeenCalledTimes(1);
expect(onAllow).not.toHaveBeenCalled();
});
it("auto-denies via timer fallback when progress transitionend never fires", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();
render(
<BorrowConfirmationOverlay
requests={[
{
id: "req-fallback",
isActiveTab: true,
tabTitle: "Example",
timeoutMs: 5000,
onAllow,
onDeny,
},
]}
/>,
);
for (let i = 0; i < 5; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
}
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
// No transitionend — the PROGRESS_TRANSITION_MS fallback must still deny.
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(150);
});
expect(onDeny).toHaveBeenCalledTimes(1);
expect(onAllow).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,64 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ControlOverlay } from "../ControlOverlay";
describe("ControlOverlay", () => {
afterEach(() => {
cleanup();
});
it("keeps page blocker none under automationBypass but Interrupt stays clickable", () => {
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={true}
onInterrupt={() => {}}
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("none");
const pill = container.querySelector("[data-slot='control-overlay-pill']");
expect(pill).toBeTruthy();
expect((pill as HTMLElement).style.pointerEvents).toBe("auto");
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
expect(stopBtn).toBeTruthy();
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("auto");
});
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-blocker']");
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("auto");
});
it("calls onInterrupt from the stop button", () => {
const onInterrupt = vi.fn();
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={false}
onInterrupt={onInterrupt}
/>,
);
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
(stopBtn as HTMLButtonElement).click();
expect(onInterrupt).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,494 @@
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay";
function renderOverlay(req: HelpRequestData | null) {
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 explicit viewport rectangles for cross-frame targets", () => {
const { container } = renderOverlay(
baseRequest({ rects: [{ top: 30, left: 40, width: 120, height: 50 }] }),
);
const highlight = container.querySelector<HTMLElement>("[data-slot='help-highlight']");
expect(highlight?.style.top).toBe("30px");
expect(highlight?.style.left).toBe("40px");
expect(highlight?.style.width).toBe("120px");
expect(highlight?.style.height).toBe("50px");
});
it("re-resolves cross-frame rectangles after the viewport changes", async () => {
const refreshRects = vi.fn(async () => [{ top: 80, left: 90, width: 140, height: 60 }]);
const { container } = renderOverlay(
baseRequest({
rects: [{ top: 30, left: 40, width: 120, height: 50 }],
refreshRects,
}),
);
window.dispatchEvent(new Event("scroll"));
await waitFor(() => {
const highlight = container.querySelector<HTMLElement>("[data-slot='help-highlight']");
expect(highlight?.style.top).toBe("80px");
expect(highlight?.style.left).toBe("90px");
});
expect(refreshRects).toHaveBeenCalledTimes(1);
});
it("keeps the inactive render stable", () => {
const { container, rerender } = renderOverlay(null);
rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, { request: null }),
),
);
expect(container.querySelector("[data-slot='help-request-banner']")).toBeNull();
});
it("renders a compact status without full request controls", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 10, left: 10, width: 100, height: 40, right: 110, bottom: 50 }) as DOMRect;
document.body.append(el);
const { container } = renderOverlay(
baseRequest({
displayMode: "compact",
selectors: ["#login"],
}),
);
expect(screen.getByText(i18n.t("helpRequest.compactStatus", { ns: "extension" }))).toBeTruthy();
expect(screen.queryByText("Please complete the captcha")).toBeNull();
expect(screen.queryByRole("textbox")).toBeNull();
expect(screen.queryByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" }))).toBeNull();
expect(container.querySelector("[data-slot='help-continue-button']")).toBeNull();
expect(container.querySelector("[data-slot='help-cancel-button']")).toBeNull();
expect(document.querySelectorAll("[data-slot='help-highlight']").length).toBe(0);
el.remove();
});
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("uses natural body height in the expanded layout", () => {
const { container } = renderOverlay(baseRequest());
const styles = overlayStyles(container);
expect(styles).toMatch(/\.bsk-help-body\s*\{[^}]*display:\s*block;/s);
expect(styles).not.toMatch(/\.bsk-help-body\s*\{[^}]*grid-template-rows/s);
expect(styles).toMatch(/\.bsk-help-banner\s*\{[^}]*justify-content:\s*flex-start;/s);
});
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,181 @@
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RecordOverlay } from "../RecordOverlay";
function renderOverlay(request: { id: string; startedAtMs?: number; onFinish: () => void } | null) {
return render(
createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request })),
);
}
function advanceRecording(ms: number) {
act(() => {
vi.advanceTimersByTime(ms);
});
}
describe("RecordOverlay", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders the bottom pill with the icon-only finish button", () => {
const onFinish = vi.fn();
const { container } = renderOverlay({ id: "rec-1", onFinish });
const pill = container.querySelector("[data-slot='record-overlay-pill']");
expect(pill).toBeTruthy();
expect((pill as HTMLElement).style.borderRadius).toBe("9999px");
expect((pill as HTMLElement).style.backgroundColor).toBe("#fff");
expect((pill as HTMLElement).style.gap).toBe("5px");
expect((pill as HTMLElement).style.padding).toBe("6px 8px 6px 16px");
expect((pill as HTMLElement).style.height).toBe("50px");
expect((pill as HTMLElement).style.boxSizing).toBe("border-box");
expect(container.querySelector("[data-slot='record-overlay-collapse']")).toBeNull();
const status = container.querySelector("[data-slot='record-overlay-status']") as HTMLElement;
expect(status.style.minHeight).toBe("32px");
const finish = screen.getByRole("button", {
name: i18n.t("recordOverlay.finish", { ns: "extension" }),
});
expect(finish).toBeTruthy();
expect(finish.textContent).toBe("");
const icon = finish.querySelector("img");
expect(icon).toBeTruthy();
expect(icon?.getAttribute("src")).toContain("record-icon.png");
expect((finish as HTMLElement).style.width).toBe("32px");
expect((finish as HTMLElement).style.height).toBe("32px");
expect(icon?.getAttribute("width")).toBe("24");
expect(icon?.getAttribute("height")).toBe("24");
expect((finish as HTMLElement).style.borderRadius).toBe("9999px");
fireEvent.click(finish);
expect(onFinish).toHaveBeenCalledTimes(1);
});
it("shows a pulsing recording indicator and no full-screen glow layer", () => {
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
expect(container.querySelector("[data-slot='record-overlay']")).toBeNull();
const indicator = container.querySelector("[data-slot='record-overlay-indicator']");
expect(indicator).toBeTruthy();
expect((indicator as HTMLElement).style.animation).toContain("bsk-rec-pulse");
});
it("shows the recording message for ten seconds, then displays elapsed time", () => {
vi.useFakeTimers();
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
expect(pill.dataset.phase).toBe("intro");
expect(label.textContent).toBe(i18n.t("recordOverlay.recording", { ns: "extension" }));
advanceRecording(9999);
expect(pill.dataset.phase).toBe("intro");
advanceRecording(1);
expect(pill.dataset.phase).toBe("timer");
expect(label.textContent).toBe("00:10");
});
it("formats elapsed time as zero-padded minutes and seconds", () => {
vi.useFakeTimers();
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
advanceRecording(125_000);
expect(label.textContent).toBe("02:05");
});
it("keeps a fixed timer label width with tabular numerals", () => {
vi.useFakeTimers();
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
advanceRecording(10_000);
expect(label.style.width).toBe("48px");
expect(label.style.maxWidth).toBe("48px");
expect(label.style.fontVariantNumeric).toBe("tabular-nums");
expect(label.style.textAlign).toBe("center");
advanceRecording(1_000);
expect(label.textContent).toBe("00:11");
expect(label.style.width).toBe("48px");
});
it("resets the intro and elapsed time for a new request", () => {
vi.useFakeTimers();
const first = { id: "rec-1", onFinish: vi.fn() };
const second = { id: "rec-2", onFinish: vi.fn() };
const { container, rerender } = renderOverlay(first);
advanceRecording(65_000);
rerender(
createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request: second })),
);
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
expect(pill.dataset.phase).toBe("intro");
expect(label.textContent).toBe(i18n.t("recordOverlay.recording", { ns: "extension" }));
advanceRecording(10_000);
expect(pill.dataset.phase).toBe("timer");
expect(label.textContent).toBe("00:10");
});
it("continues the session timer when the overlay remounts on a new page", () => {
vi.useFakeTimers();
const startedAtMs = Date.now() - 65_000;
const { container } = renderOverlay({ id: "rec-1", startedAtMs, onFinish: vi.fn() });
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
expect(pill.dataset.phase).toBe("timer");
expect(label.textContent).toBe("01:05");
advanceRecording(5_000);
expect(label.textContent).toBe("01:10");
});
it("does not start drag when pointer down on finish", () => {
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
const finish = container.querySelector("[data-slot='record-overlay-finish']") as HTMLElement;
fireEvent.pointerDown(finish, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
expect(pill.getAttribute("data-dragging")).toBe("false");
});
it("moves the pill when dragging the non-button area without toggling collapse", () => {
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
pill.getBoundingClientRect = () =>
({
top: 600,
left: 200,
width: 320,
height: 48,
right: 520,
bottom: 648,
}) as DOMRect;
Object.defineProperty(pill, "offsetWidth", { value: 320, configurable: true });
Object.defineProperty(pill, "offsetHeight", { value: 48, configurable: true });
fireEvent.pointerDown(pill, { button: 0, clientX: 300, clientY: 620, pointerId: 1 });
fireEvent(window, new PointerEvent("pointermove", { clientX: 360, clientY: 580 }));
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
expect(pill.getAttribute("data-phase")).toBe("intro");
expect(pill.style.left).toBe("260px");
expect(pill.style.top).toBe("560px");
expect(pill.style.bottom).toBe("auto");
expect(pill.style.transform).toBe("none");
});
});
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CAPTURE_SUPPRESS, type CaptureSuppressAck } from "@/lib/capture-suppress-bridge";
import { CAPTURE_HIDDEN_ATTR, createCaptureSuppressController } from "../capture-suppress";
/**
* rAF is stubbed with a manual queue so tests can drive the compositor
* frame-by-frame and assert the ack only lands after the second frame.
*/
describe("capture-suppress controller", () => {
let rafQueue: FrameRequestCallback[];
let host: HTMLElement;
beforeEach(() => {
rafQueue = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
rafQueue.push(cb);
return rafQueue.length;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
document.body.innerHTML = "";
host = document.createElement("div");
document.body.append(host);
});
/** Run every queued rAF callback, draining microtasks between rounds. */
async function flushFrames(rounds = 4): Promise<void> {
for (let round = 0; round < rounds && rafQueue.length > 0; round += 1) {
const callbacks = rafQueue.splice(0);
for (const cb of callbacks) cb(0);
await Promise.resolve();
await Promise.resolve();
}
}
it("begin hides the host immediately but only acks after two frames", async () => {
const controller = createCaptureSuppressController(() => host);
const sendResponse = vi.fn();
const needsAsync = controller.handleMessage(
{ type: CAPTURE_SUPPRESS, phase: "begin" },
sendResponse,
);
expect(needsAsync).toBe(true);
expect(controller.pendingCount).toBe(1);
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
expect(sendResponse).not.toHaveBeenCalled();
// First frame commits the style change; the ack must still be pending.
await flushFrames(1);
expect(sendResponse).not.toHaveBeenCalled();
// Second frame: the compositor has produced an overlay-free frame.
await flushFrames();
const expected: CaptureSuppressAck = { type: CAPTURE_SUPPRESS, ok: true };
expect(sendResponse).toHaveBeenCalledWith(expected);
});
it("end removes the attribute once the count returns to zero", async () => {
const controller = createCaptureSuppressController(() => host);
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
await flushFrames();
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
const sendResponse = vi.fn();
const needsAsync = controller.handleMessage(
{ type: CAPTURE_SUPPRESS, phase: "end" },
sendResponse,
);
expect(needsAsync).toBe(false);
expect(controller.pendingCount).toBe(0);
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
expect(sendResponse).toHaveBeenCalledWith({ type: CAPTURE_SUPPRESS, ok: true });
});
it("reference-counts concurrent begins so one end does not unhide early", async () => {
const controller = createCaptureSuppressController(() => host);
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
await flushFrames();
expect(controller.pendingCount).toBe(2);
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, vi.fn());
expect(controller.pendingCount).toBe(1);
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, vi.fn());
expect(controller.pendingCount).toBe(0);
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
});
it("treats a stray end as a no-op", () => {
const controller = createCaptureSuppressController(() => host);
const sendResponse = vi.fn();
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, sendResponse);
expect(controller.pendingCount).toBe(0);
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
expect(sendResponse).toHaveBeenCalledWith({ type: CAPTURE_SUPPRESS, ok: true });
});
it("re-applies the attribute when the host is remounted mid-capture", () => {
let currentHost: HTMLElement | null = host;
const controller = createCaptureSuppressController(() => currentHost);
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
// Host lost and rebuilt before `end` arrives.
currentHost = null;
const rebuilt = document.createElement("div");
controller.onHostMounted(rebuilt);
expect(rebuilt.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
});
it("leaves a remounted host visible once suppression has ended", () => {
const controller = createCaptureSuppressController(() => host);
const rebuilt = document.createElement("div");
controller.onHostMounted(rebuilt);
expect(rebuilt.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { createHelpRequestData } from "../help-request";
describe("createHelpRequestData", () => {
it("gives live delivery and recovery the same rectangle refresh mechanism", async () => {
const finish = vi.fn();
const query = vi.fn(async () => ({
active: true,
request: {
requestId: "help-1",
prompt: "complete the challenge",
selectors: [],
rects: [{ top: 80, left: 90, width: 140, height: 60 }],
timeoutMs: 1_000,
},
}));
const request = createHelpRequestData(
{
requestId: "help-1",
prompt: "complete the challenge",
selectors: [],
rects: [{ top: 10, left: 20, width: 30, height: 40 }],
timeoutMs: 1_000,
},
{ finish, query },
);
await expect(request.refreshRects?.()).resolves.toEqual([
{ top: 80, left: 90, width: 140, height: 60 },
]);
request.onContinue(" done ");
request.onCancel();
expect(query).toHaveBeenCalledOnce();
expect(finish).toHaveBeenNthCalledWith(1, "help-1", "continued", " done ");
expect(finish).toHaveBeenNthCalledWith(2, "help-1", "cancelled");
});
it("ignores rectangle replies for a replaced request", async () => {
const request = createHelpRequestData(
{
requestId: "help-1",
prompt: "complete the challenge",
selectors: [],
timeoutMs: 1_000,
},
{
finish: vi.fn(),
query: vi.fn(async () => ({
active: true,
request: {
requestId: "help-2",
prompt: "new challenge",
selectors: [],
rects: [{ top: 1, left: 2, width: 3, height: 4 }],
timeoutMs: 1_000,
},
})),
},
);
await expect(request.refreshRects?.()).resolves.toBeUndefined();
});
});
@@ -0,0 +1,160 @@
import { describe, expect, it, vi } from "vitest";
import { OverlayController, shouldShowAgentControlOverlay } 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.activeRecord).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");
});
it("tracks active record request state", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
controller.setAgentRecordRequest({
id: "rec-1",
onFinish: vi.fn(),
});
expect(controller.snapshot().activeRecord?.id).toBe("rec-1");
});
it("hides agent control overlay while recording is active", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
controller.setAgentRecordRequest({
id: "rec-1",
onFinish: vi.fn(),
});
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
expect(controller.snapshot().controlVisible).toBe(true);
});
it("keeps control hidden after record ends until session overlays reset", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
controller.setAgentRecordRequest({
id: "rec-1",
onFinish: vi.fn(),
});
controller.setAutomationBypass(true);
controller.setAutomationBypass(true);
controller.clearAgentRecordRequest("rec-1");
expect(controller.snapshot().activeRecord).toBeNull();
expect(controller.snapshot().suppressControlAfterRecord).toBe(true);
expect(controller.snapshot().automationBypassCount).toBe(0);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
controller.resetAgentOverlays("sess-1");
expect(controller.snapshot().suppressControlAfterRecord).toBe(false);
expect(controller.snapshot().controlVisible).toBe(false);
});
it("hides control while paused and shows it again only after authoritative control mode", () => {
const controller = new OverlayController();
controller.applyAgentControlMode("sess-1", "paused");
expect(controller.snapshot().activeSessionId).toBe("sess-1");
expect(controller.snapshot().controlVisible).toBe(false);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
controller.applyAgentControlMode("sess-1", "control");
expect(controller.snapshot().activeSessionId).toBe("sess-1");
expect(controller.snapshot().controlVisible).toBe(true);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
});
it("hides the control overlay when the user hides control hints", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
controller.setControlHintsHidden(true);
expect(controller.snapshot().controlHintsHidden).toBe(true);
// The session still owns the tab — only the chrome is hidden.
expect(controller.snapshot().controlVisible).toBe(true);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
controller.setControlHintsHidden(false);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
});
it("keeps the control-hints preference across session overlay resets", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
controller.setControlHintsHidden(true);
controller.resetAgentOverlays("sess-1");
expect(controller.snapshot().controlHintsHidden).toBe(true);
controller.activateAgentSession("sess-2");
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import { isHoverSurfaceCandidateElement, isLikelyHoverSurfaceOwner } from "../record-hover-surface";
function mockRect(
el: Element,
rect: { left: number; top: number; width: number; height: number },
): void {
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
x: rect.left,
y: rect.top,
top: rect.top,
left: rect.left,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
width: rect.width,
height: rect.height,
toJSON: () => ({}),
});
}
describe("record-hover-surface", () => {
it("recognizes popover containers without treating topbar wrappers or menu items as surfaces", () => {
document.body.innerHTML = `
<ul class="header-menu tw-flex navbar-app__authentication-menu">
<li class="tg-popover">
<div class="tg-avatar tg-avatar--img"></div>
</li>
</ul>
<div class="tg-popover__popper">
<div class="tg-popover__content">
<div class="tg-popover__content-body">
<li class="header-menu__item">
<a class="header-dropdown-menu__link" href="/u/me">My profile</a>
</li>
<li class="header-menu__item">
<a class="header-dropdown-menu__link" href="/dashboard/groups">My groups</a>
</li>
</div>
</div>
</div>
`;
expect(isHoverSurfaceCandidateElement(document.querySelector(".header-menu")!)).toBe(false);
expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover")!)).toBe(false);
expect(isHoverSurfaceCandidateElement(document.querySelector(".header-menu__item")!)).toBe(
false,
);
expect(
isHoverSurfaceCandidateElement(document.querySelector(".header-dropdown-menu__link")!),
).toBe(false);
expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__popper")!)).toBe(
true,
);
expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__content")!)).toBe(
true,
);
expect(
isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__content-body")!),
).toBe(true);
});
it("does not treat elements covered by an open surface as the surface owner", () => {
document.body.innerHTML = `
<button class="avatar">image</button>
<a class="fork" href="/fork">Fork</a>
<button class="clone">Clone & Download</button>
<div class="tg-popover__content-body"></div>
`;
const avatar = document.querySelector(".avatar")!;
const fork = document.querySelector(".fork")!;
const clone = document.querySelector(".clone")!;
const surface = document.querySelector(".tg-popover__content-body")!;
mockRect(avatar, { left: 1340, top: 11, width: 26, height: 26 });
mockRect(fork, { left: 1275, top: 56, width: 69, height: 28 });
mockRect(clone, { left: 1159, top: 111, width: 180, height: 30 });
mockRect(surface, { left: 1111, top: 44, width: 240, height: 496 });
expect(isLikelyHoverSurfaceOwner(avatar, surface)).toBe(true);
expect(isLikelyHoverSurfaceOwner(fork, surface)).toBe(false);
expect(isLikelyHoverSurfaceOwner(clone, surface)).toBe(false);
});
it("does not treat fixed navigation containers as plain floating surfaces", () => {
document.body.innerHTML = `
<header class="navbar-app">
<a href="/fork">Fork</a>
<button>image</button>
</header>
<div class="plain-floating"></div>
`;
const navbar = document.querySelector(".navbar-app")!;
const floating = document.querySelector(".plain-floating")!;
mockRect(navbar, { left: 0, top: 0, width: 1386, height: 51 });
mockRect(floating, { left: 1111, top: 44, width: 240, height: 496 });
vi.spyOn(window, "getComputedStyle").mockImplementation((el) => {
const style = {
cursor: "",
display: "block",
pointerEvents: "auto",
position: el === navbar ? "fixed" : "absolute",
visibility: "visible",
} as CSSStyleDeclaration;
return style;
});
expect(isHoverSurfaceCandidateElement(navbar)).toBe(false);
expect(isHoverSurfaceCandidateElement(floating)).toBe(true);
});
});
@@ -0,0 +1,99 @@
/**
* Content-script side of the capture-suppress bridge: hides the overlay
* shadow host while the background captures a screenshot.
*
* `begin` sets `data-bsk-capture-hidden` on the host (overlay.css maps it
* to `display: none !important`, which removes the whole shadow subtree
* from compositing) and only acks after two animation frames — the first
* frame commits the style change, the second guarantees the compositor
* has produced a frame without the overlay, so the capture API cannot
* pick up a stale frame that still contains it. `end` is
* reference-counted so overlapping captures do not unhide the host early.
*/
import {
CAPTURE_SUPPRESS,
type CaptureSuppressAck,
type CaptureSuppressMessage,
} from "@/lib/capture-suppress-bridge";
export const CAPTURE_HIDDEN_ATTR = "data-bsk-capture-hidden";
/**
* Safety net for hidden/background tabs where rAF callbacks never fire:
* the attribute is already set synchronously, so any frame the compositor
* produces from now on excludes the overlay. Waiting longer would just
* deadlock the capture.
*/
const FRAME_FALLBACK_MS = 500;
function nextFrame(): Promise<void> {
return new Promise((resolve) => {
const raf = requestAnimationFrame(() => {
clearTimeout(timer);
resolve();
});
const timer = setTimeout(() => {
cancelAnimationFrame(raf);
resolve();
}, FRAME_FALLBACK_MS);
});
}
export interface CaptureSuppressController {
/**
* Handle one bridge message. Returns true when the ack is sent
* asynchronously and the caller must keep the message channel open.
*/
handleMessage(
message: CaptureSuppressMessage,
sendResponse: (ack: CaptureSuppressAck) => void,
): boolean;
/** Re-apply the hidden attribute when the overlay host is (re)mounted. */
onHostMounted(host: HTMLElement): void;
/** Number of in-flight `begin` phases (exposed for tests). */
readonly pendingCount: number;
}
export function createCaptureSuppressController(
getHost: () => HTMLElement | null,
): CaptureSuppressController {
let pending = 0;
const ack: CaptureSuppressAck = { type: CAPTURE_SUPPRESS, ok: true };
function setHidden(host: HTMLElement | null, hidden: boolean): void {
if (!host) return;
if (hidden) host.setAttribute(CAPTURE_HIDDEN_ATTR, "");
else host.removeAttribute(CAPTURE_HIDDEN_ATTR);
}
return {
get pendingCount() {
return pending;
},
handleMessage(message, sendResponse) {
if (message.phase === "begin") {
pending += 1;
setHidden(getHost(), true);
void (async () => {
await nextFrame();
await nextFrame();
sendResponse(ack);
})();
return true;
}
// `end` without a matching `begin` (e.g. a retried message) must not
// drive the counter negative or clear a concurrent capture's flag.
pending = Math.max(0, pending - 1);
if (pending === 0) setHidden(getHost(), false);
sendResponse(ack);
return false;
},
onHostMounted(host) {
// Host lost + rebuilt mid-capture: the fresh host must stay hidden
// until the last `end` arrives.
setHidden(host, pending > 0);
},
};
}
@@ -0,0 +1,31 @@
import type { HelpQueryResponse, HelpRequestMessage } from "@/lib/help-bridge";
import type { HelpRequestData } from "./HelpRequestOverlay";
export interface HelpRequestDataDeps {
finish(requestId: string, outcome: "continued" | "cancelled", note?: string): void;
query(): Promise<HelpQueryResponse | undefined>;
}
/** Build the content-side representation shared by live delivery and recovery. */
export function createHelpRequestData(
message: Omit<HelpRequestMessage, "type">,
deps: HelpRequestDataDeps,
): HelpRequestData {
return {
id: message.requestId,
prompt: message.prompt,
...(message.title ? { title: message.title } : {}),
...(message.displayMode ? { displayMode: message.displayMode } : {}),
selectors: message.selectors,
rects: message.rects,
refreshRects: async () => {
const response = await deps.query();
return response?.active && response.request?.requestId === message.requestId
? response.request.rects
: undefined;
},
onContinue: (note: string) =>
deps.finish(message.requestId, "continued", note.trim() ? note : undefined),
onCancel: () => deps.finish(message.requestId, "cancelled"),
};
}
@@ -0,0 +1,171 @@
import type { OverlayMode } from "@/lib/overlay-bridge";
import type { BorrowRequestData } from "./BorrowConfirmationOverlay";
import type { HelpRequestData } from "./HelpRequestOverlay";
import type { RecordRequestData } from "./RecordOverlay";
export interface OverlayState {
borrowRequests: BorrowRequestData[];
activeHelp: HelpRequestData | null;
activeRecord: RecordRequestData | null;
controlVisible: boolean;
interrupting: boolean;
activeSessionId: string | null;
controlMode: OverlayMode;
automationBypassCount: number;
/**
* After record Finish/Stop clears `activeRecord`, the session is still
* alive until CLI `session.stop`. Suppress the control mask for that gap
* so 「Agent 正在控制」does not flash between RecordOverlay and teardown.
*/
suppressControlAfterRecord: boolean;
/**
* User preference (chrome.storage, toggled from the popup): hide the
* control hints — status pill, orange glow, and the input blocker that
* comes with them. Not session state; survives overlay resets.
*/
controlHintsHidden: boolean;
}
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,
activeRecord: null,
interrupting: false,
activeSessionId: null,
controlMode: "hidden",
automationBypassCount: 0,
suppressControlAfterRecord: false,
controlHintsHidden: false,
};
snapshot(): OverlayState {
return {
...this.state,
controlVisible: this.isControlVisible(),
borrowRequests: [...this.state.borrowRequests],
};
}
isControlVisible(): boolean {
return this.state.activeSessionId !== null && this.state.controlMode === "control";
}
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;
this.state.controlMode = "control";
}
applyAgentControlMode(sessionId: string | null, mode: OverlayMode): void {
if (mode === "control" && sessionId) {
this.state.activeSessionId = sessionId;
this.state.interrupting = false;
this.state.controlMode = "control";
return;
}
if ((mode === "interrupting" || mode === "paused") && sessionId) {
this.state.activeSessionId = sessionId;
this.state.interrupting = mode === "interrupting";
this.state.controlMode = mode;
return;
}
this.state.activeSessionId = null;
this.state.interrupting = false;
this.state.controlMode = "hidden";
}
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;
}
setAgentRecordRequest(request: RecordRequestData): RecordRequestData | null {
const previous = this.state.activeRecord;
this.state.activeRecord = request;
this.state.suppressControlAfterRecord = false;
return previous;
}
clearAgentRecordRequest(requestId?: string): void {
if (!this.state.activeRecord) return;
if (requestId && this.state.activeRecord.id !== requestId) return;
this.state.activeRecord = null;
// Hide control until session teardown — see OverlayState.suppressControlAfterRecord.
this.state.suppressControlAfterRecord = true;
// Record start/rearm may have stacked automation-bypass refs; drop them so a
// stray ControlOverlay cannot sit with pointer-events:none (page usable,
// Interrupt unclickable).
this.state.automationBypassCount = 0;
}
setAutomationBypass(enabled: boolean): void {
if (enabled) {
this.state.automationBypassCount += 1;
} else {
this.state.automationBypassCount = Math.max(0, this.state.automationBypassCount - 1);
}
}
setControlHintsHidden(hidden: boolean): void {
this.state.controlHintsHidden = hidden;
}
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,
activeRecord: null,
interrupting: false,
activeSessionId: null,
controlMode: "hidden",
automationBypassCount: 0,
suppressControlAfterRecord: false,
};
return previousHelp;
}
}
/** Control mask ("Agent 正在控制") must hide while help/record overlays own the chrome. */
export function shouldShowAgentControlOverlay(state: OverlayState): boolean {
return (
state.controlVisible &&
!state.controlHintsHidden &&
!state.suppressControlAfterRecord &&
state.activeHelp === null &&
state.activeRecord === null
);
}
+23
View File
@@ -0,0 +1,23 @@
@import "tailwindcss";
@source "../content";
:host([data-bsk-overlay-surface]) {
position: fixed !important;
inset: 0 !important;
display: block !important;
width: 100vw !important;
height: 100vh !important;
z-index: 2147483647 !important;
pointer-events: none !important;
}
:host([data-bsk-overlay-surface][data-bsk-overlay-blocking]) {
pointer-events: auto !important;
}
/* Screenshot suppression: the host lives in the main DOM, so hiding it
removes the whole shadow subtree from compositing and captured frames
only contain page content. */
:host([data-bsk-capture-hidden]) {
display: none !important;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
export interface HoverSurfaceDecision {
related: boolean;
reason?: string;
surface?: Element;
}
export interface HoverSurfaceContext {
triggerElement: Element;
}
export interface HoverSurfaceState {
element: Element;
signature: string;
}
const HOVER_SURFACE_SELECTOR = [
'[role="menu"]',
'[role="menubar"]',
'[role="listbox"]',
'[role="dialog"]',
'[role="tooltip"]',
'[aria-modal="true"]',
"[data-popper-placement]",
"[data-floating-ui-placement]",
"[data-headlessui-state]",
"[data-radix-popper-content-wrapper]",
].join(",");
const HOVER_SURFACE_WORD_RE =
/(^|[^a-z0-9])(dropdown-menu|drop-down-menu|menu-list|popover__(popper|content|content-body)|popover-content|popper|popup|flyout|submenu|submenu-wrapper|context-menu|account-menu|user-menu|avatar-menu)(?=[^a-z0-9]|$)/i;
const HOVER_SURFACE_ITEM_RE =
/(^|[^a-z0-9])((dropdown|menu|submenu|context-menu)(-|__?)?(item|link)|item-(text|label|content)|item__?(text|label|content))(?=[^a-z0-9]|$)/i;
const HOVER_SURFACE_MAX_AXIS_GAP = 96;
const HOVER_SURFACE_MAX_DIAGONAL_GAP = 16;
const HOVER_SURFACE_MAX_VIEWPORT_AREA_RATIO = 0.5;
function elementTokenText(el: Element): string {
return [
el.id,
typeof el.className === "string" ? el.className : "",
el.getAttribute("data-testid"),
el.getAttribute("data-test"),
el.getAttribute("data-cy"),
el.getAttribute("aria-label"),
el.getAttribute("role"),
]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join(" ");
}
export function isRecognizedHoverSurfaceElement(el: Element): boolean {
if (el.matches(HOVER_SURFACE_SELECTOR)) return true;
const tokenText = elementTokenText(el);
if (HOVER_SURFACE_WORD_RE.test(tokenText) && !HOVER_SURFACE_ITEM_RE.test(tokenText)) {
return true;
}
return false;
}
function isVisibleSurfaceElement(el: Element): boolean {
if (!(el instanceof HTMLElement)) return false;
const style = getComputedStyle(el);
if (style.pointerEvents === "none" || style.visibility === "hidden" || style.display === "none") {
return false;
}
return rectFor(el) !== null;
}
export function closestRecognizedHoverSurface(element: Element): Element | null {
let node: Element | null = element;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
if (isRecognizedHoverSurfaceElement(node)) return node;
node = node.parentElement;
depth += 1;
}
return null;
}
export function isPositionedFloatingElement(el: Element): boolean {
if (!(el instanceof HTMLElement)) return false;
const style = getComputedStyle(el);
if (style.position !== "absolute") return false;
if (style.pointerEvents === "none" || style.visibility === "hidden" || style.display === "none") {
return false;
}
const rect = rectFor(el);
if (!rect) return false;
const viewportArea = window.innerWidth * window.innerHeight;
const area = rect.width * rect.height;
return (
area > 0 && (!viewportArea || area <= viewportArea * HOVER_SURFACE_MAX_VIEWPORT_AREA_RATIO)
);
}
export function isHoverSurfaceCandidateElement(el: Element): boolean {
return isRecognizedHoverSurfaceElement(el) || isPositionedFloatingElement(el);
}
export function closestHoverSurfaceCandidate(element: Element): Element | null {
let node: Element | null = element;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
if (isHoverSurfaceCandidateElement(node)) return node;
node = node.parentElement;
depth += 1;
}
return null;
}
function closestFloatingSurface(clickElement: Element): Element | null {
let node: Element | null = clickElement;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
if (isPositionedFloatingElement(node)) return node;
node = node.parentElement;
depth += 1;
}
return null;
}
function hoverSurfaceSignature(el: Element): string {
const rect = rectFor(el);
return [
rect
? `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)},${Math.round(rect.height)}`
: "",
(el.textContent ?? "").trim().slice(0, 512),
].join("|");
}
export function collectHoverSurfaceStates(): HoverSurfaceState[] {
const states: HoverSurfaceState[] = [];
for (const el of document.querySelectorAll("*")) {
if (isHoverSurfaceCandidateElement(el) && isVisibleSurfaceElement(el)) {
states.push({ element: el, signature: hoverSurfaceSignature(el) });
}
}
return states;
}
function rectFor(el: Element): DOMRect | null {
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
return rect;
}
function surfaceRelationMetrics(surface: Element, trigger: Element) {
const surfaceRect = rectFor(surface);
const triggerRect = rectFor(trigger);
if (!surfaceRect || !triggerRect) return null;
const horizontalOverlap =
Math.min(triggerRect.right, surfaceRect.right) - Math.max(triggerRect.left, surfaceRect.left);
const verticalOverlap =
Math.min(triggerRect.bottom, surfaceRect.bottom) - Math.max(triggerRect.top, surfaceRect.top);
const horizontalGap = Math.max(
0,
triggerRect.left - surfaceRect.right,
surfaceRect.left - triggerRect.right,
);
const verticalGap = Math.max(
0,
triggerRect.top - surfaceRect.bottom,
surfaceRect.top - triggerRect.bottom,
);
return {
surfaceRect,
triggerRect,
horizontalOverlap,
verticalOverlap,
horizontalGap,
verticalGap,
};
}
function isNearTrigger(surface: Element, trigger: Element): boolean {
const metrics = surfaceRelationMetrics(surface, trigger);
if (!metrics) return true;
const { horizontalOverlap, verticalOverlap, horizontalGap, verticalGap } = metrics;
if (horizontalOverlap > 0 && verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true;
if (verticalOverlap > 0 && horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true;
return (
horizontalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP && verticalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP
);
}
export function isLikelyHoverSurfaceOwner(trigger: Element, surface: Element): boolean {
const metrics = surfaceRelationMetrics(surface, trigger);
if (!metrics) return true;
const {
surfaceRect,
triggerRect,
horizontalOverlap,
verticalOverlap,
horizontalGap,
verticalGap,
} = metrics;
const opensVertically =
horizontalOverlap > 0 &&
verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP &&
(triggerRect.bottom <= surfaceRect.top + 8 || surfaceRect.bottom <= triggerRect.top + 8);
const opensSideways =
verticalOverlap > 0 &&
horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP &&
(triggerRect.right <= surfaceRect.left + 8 || surfaceRect.right <= triggerRect.left + 8);
const opensFromCorner =
horizontalGap > 0 &&
verticalGap > 0 &&
horizontalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP &&
verticalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP;
return opensVertically || opensSideways || opensFromCorner;
}
export function decideHoverSurfaceRelation(
context: HoverSurfaceContext,
clickElement: Element,
): HoverSurfaceDecision {
const clickSurface = closestRecognizedHoverSurface(clickElement);
if (clickSurface && isNearTrigger(clickSurface, context.triggerElement)) {
return { related: true, reason: "click-inside-hover-surface", surface: clickSurface };
}
const floatingSurface = closestFloatingSurface(clickElement);
if (floatingSurface && isNearTrigger(floatingSurface, context.triggerElement)) {
return {
related: true,
reason: "click-inside-near-floating-surface",
surface: floatingSurface,
};
}
if (context.triggerElement.contains(clickElement) && clickElement !== context.triggerElement) {
return { related: true, reason: "click-inside-trigger-contained-surface" };
}
return { related: false, reason: "click-outside-hover-surface" };
}
@@ -0,0 +1,35 @@
import { describe, expect, it, 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,409 @@
import { i18n } from "@browser-skill/i18n";
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { ConnectionController } from "@/lib/connection-controller";
import { startHeartbeat } from "@/lib/heartbeat";
import {
getConnectionEnabled,
setConnectionEnabled as persistConnectionEnabled,
setLabel,
} from "@/lib/instance-id";
import { startKeepalive } from "@/lib/keepalive";
import {
OVERLAY_AGENT_STATE,
OVERLAY_AUTOMATION_BYPASS,
OVERLAY_MSG_INTERRUPT,
OVERLAY_MSG_READY,
OVERLAY_MSG_WHO_AM_I,
type OverlayAgentStateMessage,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
type OverlayMessage,
type OverlayMode,
} from "@/lib/overlay-bridge";
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
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 {
attachRecordFinishListener,
attachRecordQueryListener,
attachRecordStepListener,
} from "@/tools/record";
import { detectBrowserMeta } from "@/transport/handshake";
import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport";
export default defineBackground(() => {
const controller = new ConnectionController();
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
const sessions = new SessionManager();
const cdp = new ChromiumCdp();
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
let overlayGeneration = 0;
const controlModes = new Map<string, OverlayMode>();
function setControlMode(sessionId: string, mode: OverlayMode): void {
if (controlModes.get(sessionId) === mode) return;
controlModes.set(sessionId, mode);
overlayGeneration += 1;
const ctx = sessions.get(sessionId);
if (ctx) void pushOverlayStateForWindow(ctx.agentWindowId);
}
function overlayStateForWindow(windowId?: number): OverlayAgentStateMessage {
const ctx = typeof windowId === "number" ? sessions.findByWindowId(windowId) : null;
if (!ctx) {
return {
type: OVERLAY_AGENT_STATE,
sessionId: null,
mode: "hidden",
generation: overlayGeneration,
};
}
return {
type: OVERLAY_AGENT_STATE,
sessionId: ctx.sessionId,
mode: controlModes.get(ctx.sessionId) ?? "control",
generation: overlayGeneration,
};
}
async function pushOverlayStateToTab(
tabId: number,
state: OverlayAgentStateMessage,
): Promise<void> {
try {
await chrome.tabs.sendMessage(tabId, state);
} catch {
// Restricted pages and not-yet-loaded content scripts cannot receive messages.
}
}
async function pushOverlayStateForWindow(windowId: number): Promise<void> {
const state = overlayStateForWindow(windowId);
const tabs = await chrome.tabs.query({ windowId });
await Promise.all(
tabs.map((tab) =>
typeof tab.id === "number" ? pushOverlayStateToTab(tab.id, state) : Promise.resolve(),
),
);
}
function pushAllAgentOverlayStates(): void {
const windowIds = new Set(sessions.list().map((ctx) => ctx.agentWindowId));
for (const windowId of windowIds) {
void pushOverlayStateForWindow(windowId);
}
}
function onOverlaySessionStateChanged(): void {
void sessionsLive.syncFromManager();
const liveSessionIds = new Set(sessions.list().map((ctx) => ctx.sessionId));
for (const sessionId of controlModes.keys()) {
if (!liveSessionIds.has(sessionId)) controlModes.delete(sessionId);
}
overlayGeneration += 1;
pushAllAgentOverlayStates();
}
function onBrowserControlResumed(sessionId: string): void {
const ctx = sessions.get(sessionId);
if (!ctx) return;
setControlMode(sessionId, "control");
}
function pushOverlayStateForAgentWindow(windowId: number): void {
if (!sessions.findByWindowId(windowId)) return;
void pushOverlayStateForWindow(windowId);
}
chrome.tabs.onActivated.addListener((activeInfo) => {
pushOverlayStateForAgentWindow(activeInfo.windowId);
});
chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
if (changeInfo.status !== "complete") return;
if (typeof tab.windowId !== "number") return;
pushOverlayStateForAgentWindow(tab.windowId);
});
// 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 cleanupAfterDisconnect = createDisconnectCleanup({
manager: sessions,
sessionStopDeps: { cdp },
onSessionsChanged: () => {
void sessionsLive.syncFromManager();
},
});
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp,
onSessionsChanged: onOverlaySessionStateChanged,
onBrowserControlResumed,
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 they cannot surface an authorization decision.
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();
const recordDeps = {
tabsApi: chrome.tabs,
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
chrome.tabs.sendMessage(tabId, msg),
bypassOverlay: async (tabId: number, enabled: boolean) => {
try {
await chrome.tabs.sendMessage(tabId, {
type: OVERLAY_AUTOMATION_BYPASS,
enabled,
});
} catch {
// Content script may be unavailable on restricted pages.
}
},
};
// Message listeners stay up (cheap; fire only for record message types).
// Tab / webNavigation observation attaches lazily while a recording is
// active — see ensureBrowserObservationListeners in tools/record.ts.
attachRecordStepListener();
attachRecordFinishListener(recordDeps);
attachRecordQueryListener(recordDeps);
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 the
// request would only resolve via the fail-closed background timeout.
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: onOverlaySessionStateChanged,
});
// 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,
});
// Application-level heartbeat (Chrome 116+): while the post-handshake
// link is live, beat every 20s so WebSocket activity keeps the service
// worker — and thus the daemon connection — alive during use, rather
// than depending on the boundary-hugging 30s keepalive alarm. The
// daemon also uses these beats to reap a silently-dead browser.
startHeartbeat({
send: (frame) => transport.send(frame),
onActiveChange: (cb) =>
controller.subscribe((snap) =>
cb(snap.state === "connected" || snap.state === "version_skew"),
),
});
// Wake-driven reconnect (best effort). An MV3 service worker is killed
// across OS sleep regardless of any keepalive, and the setTimeout-based
// transport backoff dies with it. These Chrome lifecycle events revive
// the worker and let us reconnect immediately instead of waiting for
// the next 30s alarm tick. They only help when the daemon is actually
// running; a cold daemon is (re)spawned by the next `bsk` command.
const reconnectIfNeeded = () => {
if (!controller.isConnectionEnabled) return;
if (transport.state === "connected") return;
void transport.connect().catch((err) => {
console.debug("[browser-skill] wake reconnect attempt failed", err);
});
};
if (typeof chrome.runtime?.onStartup?.addListener === "function") {
chrome.runtime.onStartup.addListener(reconnectIfNeeded);
}
if (typeof chrome.idle?.onStateChanged?.addListener === "function") {
chrome.idle.onStateChanged.addListener((state) => {
// "active" fires when the user returns from idle/locked — the most
// reliable "machine just woke" signal we get.
if (state === "active") reconnectIfNeeded();
});
// Treat >60s of no input as idle so the active transition is timely.
chrome.idle.setDetectionInterval?.(60);
}
void (async () => {
const connectionEnabled = await getConnectionEnabled();
await controller.attach(transport, detectBrowserMeta(), connectionEnabled, {
beforeDisconnect: async () => {
const report = await cleanupAfterDisconnect();
if (report.failures.length > 0) {
console.warn("[browser-skill] session cleanup before disconnect was incomplete", report);
}
},
onDisconnected: async () => {
const report = await cleanupAfterDisconnect();
if (report.failures.length > 0) {
console.warn("[browser-skill] session cleanup after disconnect was incomplete", report);
}
},
});
})().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_READY) {
sendResponse(overlayStateForWindow(sender.tab?.windowId));
return false;
}
if (msg.kind === OVERLAY_MSG_INTERRUPT) {
const req = msg as OverlayInterruptRequest;
const ctx = sessions.get(req.sessionId);
if (ctx) setControlMode(req.sessionId, "interrupting");
void handleOverlayInterrupt(transport, req.sessionId).then((reply) => {
if (reply.ok && sessions.get(req.sessionId)) {
setControlMode(req.sessionId, "paused");
}
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" }),
};
}
+535
View File
@@ -0,0 +1,535 @@
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import React from "react";
import { flushSync } from "react-dom";
import ReactDOM from "react-dom/client";
import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
import { ControlOverlay } from "@/content/ControlOverlay";
import { createCaptureSuppressController } from "@/content/capture-suppress";
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
import { createHelpRequestData } from "@/content/help-request";
import overlayCss from "@/content/overlay.css?inline";
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
import { RecordOverlay } from "@/content/RecordOverlay";
import {
handleRecordContentMessage,
isRecordContentMessage,
type RecordCaptureController,
} from "@/content/record-capture";
import {
type CaptureSuppressAck,
type CaptureSuppressMessage,
isCaptureSuppressMessage,
} from "@/lib/capture-suppress-bridge";
import {
HELP_ACK,
HELP_FINISH,
HELP_QUERY,
type HelpAckMessage,
type HelpCancelMessage,
type HelpFinishMessage,
type HelpQueryResponse,
type HelpRequestMessage,
isHelpCancelMessage,
isHelpRequestMessage,
} from "@/lib/help-bridge";
import { getControlHintsHidden, STORAGE_KEYS } from "@/lib/instance-id";
import {
isOverlayAgentOverlayResetMessage,
isOverlayAgentStateMessage,
OVERLAY_AUTOMATION_BYPASS,
OVERLAY_MSG_READY,
type OverlayAgentOverlayResetMessage,
type OverlayAgentStateMessage,
type OverlayAutomationBypassMessage,
} from "@/lib/overlay-bridge";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
import {
RECORD_FINISH,
RECORD_QUERY,
type RecordQueryResponse,
type RecordStartAck,
type RecordStopAck,
} from "@/lib/record-bridge";
import type {
BorrowCancelMessage,
BorrowRequestMessage,
BorrowResponseMessage,
} from "@/tools/borrow-confirmation";
// 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 recordCapture: RecordCaptureController | null = null;
let activeRecordRequestId: string | null = null;
let reactRoot: ReactDOM.Root | null = null;
let overlayHost: HTMLElement | null = null;
let overlayContainer: HTMLElement | null = null;
let activeAgentState: OverlayAgentStateMessage | null = null;
let hostLossReported = false;
let remountInProgress = false;
// Load the user's control-hints preference up front so an already-active
// Agent session does not flash the overlay before the stored value lands.
try {
overlays.setControlHintsHidden(await getControlHintsHidden());
} catch (err) {
console.debug("[bsk overlay] control-hints preference read failed", err);
}
const captureSuppress = createCaptureSuppressController(() => overlayHost);
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;
overlayContainer = container;
hostLossReported = false;
// A host rebuilt mid-capture must stay hidden until `end` arrives.
captureSuppress.onHostMounted(shadowHost);
const app = document.createElement("div");
app.className = "bsk-overlay-root";
container.append(app);
reactRoot = ReactDOM.createRoot(app);
renderAll();
void requestOverlayState();
return reactRoot;
},
onRemove(root) {
overlayHost = null;
overlayContainer = null;
root?.unmount();
reactRoot = null;
},
});
function setOverlaySurfaceState(active: boolean, blocking: boolean): void {
const host = overlayHost;
const container = overlayContainer;
if (!host || !container) return;
if (active) {
host.setAttribute("data-bsk-overlay-surface", "");
if (blocking) {
host.setAttribute("data-bsk-overlay-blocking", "");
} else {
host.removeAttribute("data-bsk-overlay-blocking");
}
Object.assign(container.style, {
position: "fixed",
inset: "0",
pointerEvents: "none",
});
return;
}
host.removeAttribute("data-bsk-overlay-surface");
host.removeAttribute("data-bsk-overlay-blocking");
container.style.removeProperty("position");
container.style.removeProperty("inset");
container.style.removeProperty("pointer-events");
}
function setOverlayHostHiddenFromAccessibility(hidden: boolean): void {
const host = overlayHost;
if (!host) return;
if (!hidden) {
host.removeAttribute("aria-hidden");
return;
}
const focusedInShadow = host.shadowRoot?.activeElement;
if (focusedInShadow instanceof HTMLElement) {
focusedInShadow.blur();
}
if (document.activeElement === host) {
host.blur();
}
host.setAttribute("aria-hidden", "true");
}
function renderReactOverlays(): void {
const overlayState = overlays.snapshot();
const controlOverlayVisible = shouldShowAgentControlOverlay(overlayState);
const interactiveOverlayVisible =
overlayState.borrowRequests.length > 0 ||
overlayState.activeHelp !== null ||
overlayState.activeRecord !== null;
setOverlayHostHiddenFromAccessibility(!interactiveOverlayVisible);
setOverlaySurfaceState(
controlOverlayVisible,
controlOverlayVisible && overlayState.automationBypassCount === 0,
);
const root = reactRoot;
if (!root) return;
flushSync(() => {
root.render(
React.createElement(
I18nextProvider,
{ i18n },
React.createElement(
React.Fragment,
null,
React.createElement(BorrowConfirmationOverlay, {
requests: overlayState.borrowRequests,
}),
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
React.createElement(RecordOverlay, { request: overlayState.activeRecord }),
React.createElement(ControlOverlay, {
visible: controlOverlayVisible,
interrupting: overlayState.interrupting,
automationBypass: overlayState.automationBypassCount > 0,
onInterrupt: handleInterrupt,
}),
),
),
);
});
}
function renderAll(): void {
renderReactOverlays();
}
async function waitForRenderedOverlayUpdate(): Promise<void> {
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
}
function clearCurrentAgentSession(): void {
const sessionId = overlays.snapshot().activeSessionId;
if (!sessionId) return;
resetAgentOverlayState(sessionId);
}
function applyOverlayState(state: OverlayAgentStateMessage): void {
activeAgentState = state;
overlays.applyAgentControlMode(state.sessionId, state.mode);
renderAll();
}
function resetAgentOverlayState(sessionId: string) {
const previousHelp = overlays.resetAgentOverlays(sessionId);
if (previousHelp) {
void sendHelpFinish(previousHelp.id, "cancelled");
}
recordCapture?.dispose();
recordCapture = null;
activeRecordRequestId = null;
renderAll();
}
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);
renderAll();
void sendInterrupt((msg) => chrome.runtime.sendMessage(msg), sessionId).then((reply) => {
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
| CaptureSuppressMessage
| OverlayAgentOverlayResetMessage
| OverlayAgentStateMessage
| OverlayAutomationBypassMessage,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: BorrowResponseMessage | HelpAckMessage | CaptureSuppressAck) => void,
) => {
if (isCaptureSuppressMessage(message)) {
return captureSuppress.handleMessage(message, sendResponse);
}
if (isRecordContentMessage(message)) {
const needsAsync = handleRecordContentMessage(
message,
{
activeRequestId: activeRecordRequestId,
capture: recordCapture,
setActiveRequestId: (id) => {
activeRecordRequestId = id;
},
setCapture: (capture) => {
recordCapture = capture;
},
onStart: (requestId, startedAtMs) => {
overlays.setAgentRecordRequest({
id: requestId,
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
onFinish: () => {
void chrome.runtime.sendMessage({
type: RECORD_FINISH,
requestId,
});
},
});
renderAll();
},
onStop: () => {
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
renderAll();
},
},
sendResponse as unknown as
| ((response: RecordStartAck | RecordStopAck) => void)
| undefined,
);
return needsAsync;
}
if (
message &&
typeof message === "object" &&
"type" in message &&
message.type === OVERLAY_AUTOMATION_BYPASS
) {
const bypassMsg = message as OverlayAutomationBypassMessage;
overlays.setAutomationBypass(bypassMsg.enabled);
renderAll();
return false;
}
if (isOverlayAgentStateMessage(message)) {
applyOverlayState(message);
return false;
}
if (isOverlayAgentOverlayResetMessage(message)) {
resetAgentOverlayState(message.sessionId);
return false;
}
if (message.type === "borrow-cancel") {
overlays.removeBorrowRequest(message.requestId);
renderAll();
return false;
}
if (isHelpCancelMessage(message)) {
const state = overlays.snapshot();
if (state.activeHelp && state.activeHelp.id === message.requestId) {
overlays.clearAgentHelpRequest(message.requestId);
renderAll();
}
return false;
}
if (isHelpRequestMessage(message)) {
const helpMsg = message as HelpRequestMessage;
const previousHelp = mountHelpRequest(helpMsg);
if (previousHelp && previousHelp.id !== helpMsg.requestId) {
void sendHelpFinish(previousHelp.id, "cancelled");
}
renderAll();
sendResponse({ type: HELP_ACK, ok: true });
return false;
}
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);
renderAll();
};
overlays.addBorrowRequest({
id: message.requestId,
isActiveTab: message.isActiveTab,
tabTitle: message.tabTitle,
timeoutMs: message.timeoutMs,
onAllow: () => respond(true),
onDeny: () => respond(false),
});
renderAll();
return true;
}
return false;
};
async function sendHelpFinish(
requestId: string,
outcome: "continued" | "cancelled",
note?: string,
): Promise<void> {
const msg: HelpFinishMessage = {
type: HELP_FINISH,
requestId,
outcome,
...(note ? { note } : {}),
};
overlays.clearAgentHelpRequest(requestId);
renderAll();
await waitForRenderedOverlayUpdate();
await chrome.runtime.sendMessage(msg).catch((err) => {
console.debug("[bsk overlay] help finish failed", err);
});
}
function mountHelpRequest(helpMsg: Omit<HelpRequestMessage, "type">) {
return overlays.setAgentHelpRequest(
createHelpRequestData(helpMsg, {
finish: (requestId, outcome, note) => {
void sendHelpFinish(requestId, outcome, note);
},
query: () =>
chrome.runtime.sendMessage({ type: HELP_QUERY }) as Promise<
HelpQueryResponse | undefined
>,
}),
);
}
async function queryActiveHelpWithRetry(): Promise<void> {
for (let attempt = 0; attempt < 6; attempt += 1) {
try {
const helpQuery = (await chrome.runtime.sendMessage({
type: HELP_QUERY,
})) as HelpQueryResponse | undefined;
if (helpQuery?.active && helpQuery.request) {
mountHelpRequest(helpQuery.request);
renderAll();
return;
}
} catch (err) {
console.debug("[bsk overlay] help query failed", err);
}
await new Promise((resolve) => window.setTimeout(resolve, 150));
}
}
async function queryActiveRecord(): Promise<void> {
try {
const recordQuery = (await chrome.runtime.sendMessage({
type: RECORD_QUERY,
})) as RecordQueryResponse | undefined;
if (
recordQuery?.active &&
typeof recordQuery.requestId === "string" &&
overlays.snapshot().activeRecord === null
) {
const requestId = recordQuery.requestId;
const startedAtMs = recordQuery.startedAtMs;
overlays.setAgentRecordRequest({
id: requestId,
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
onFinish: () => {
void chrome.runtime.sendMessage({
type: RECORD_FINISH,
requestId,
});
},
});
renderAll();
}
} catch (err) {
console.debug("[bsk overlay] record query failed", err);
}
}
async function refreshAuxiliaryOverlayState(): Promise<void> {
await Promise.all([queryActiveHelpWithRetry(), queryActiveRecord()]);
}
async function requestOverlayState(): Promise<void> {
try {
const state = (await chrome.runtime.sendMessage({
kind: OVERLAY_MSG_READY,
})) as OverlayAgentStateMessage | undefined;
if (state && isOverlayAgentStateMessage(state)) {
applyOverlayState(state);
}
void refreshAuxiliaryOverlayState();
} catch (err) {
console.debug("[bsk overlay] overlay.ready failed", err);
}
}
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) void requestOverlayState();
};
// Live-apply popup toggles of the control-hints preference.
const onStorageChange = (
changes: Record<string, chrome.storage.StorageChange>,
areaName: string,
) => {
if (areaName !== "local") return;
const change = changes[STORAGE_KEYS.CONTROL_HINTS_HIDDEN];
if (!change) return;
overlays.setControlHintsHidden(change.newValue === true);
renderAll();
};
chrome.storage.onChanged.addListener(onStorageChange);
ui.mount();
chrome.runtime.onMessage.addListener(onMessage);
void requestOverlayState();
window.addEventListener("pageshow", onPageShow);
const hostObserver = new MutationObserver(() => {
const connected = overlayHost?.isConnected ?? false;
if (overlays.isControlVisible() && !connected && !hostLossReported) {
hostLossReported = true;
if (!remountInProgress) {
remountInProgress = true;
try {
ui.mount();
if (activeAgentState) applyOverlayState(activeAgentState);
void requestOverlayState();
} finally {
remountInProgress = false;
}
}
}
if (connected) {
hostLossReported = false;
}
});
hostObserver.observe(document.documentElement, { childList: true, subtree: false });
ctx.onInvalidated(() => {
hostObserver.disconnect();
chrome.runtime.onMessage.removeListener(onMessage);
chrome.storage.onChanged.removeListener(onStorageChange);
window.removeEventListener("pageshow", onPageShow);
// Restore history hooks / remove capture listeners before the CS unloads.
recordCapture?.dispose();
recordCapture = null;
activeRecordRequestId = null;
});
},
});
@@ -0,0 +1,338 @@
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 { STORAGE_KEYS } from "@/lib/instance-id";
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,
};
function openRecordView() {
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
}
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("does not render record UI on the main view", () => {
render(<App />);
expect(screen.queryByRole("button", { name: "复制录制指令" })).toBeNull();
expect(screen.queryByRole("button", { name: "操作录制" })).toBeNull();
});
it("opens the feature list from the launcher and navigates to record", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
expect(screen.getByText("操作录制")).toBeTruthy();
expect(screen.getByText("录制你的操作,供 Agent 参考")).toBeTruthy();
expect(screen.queryByText("未连接")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
expect(screen.getByRole("button", { name: "复制录制指令" })).toBeTruthy();
});
it("returns from the feature list to the main view", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
fireEvent.click(screen.getByRole("button", { name: "返回" }));
expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.queryByText("录制你的操作,供 Agent 参考")).toBeNull();
});
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");
});
it("copies the record prompt with instance id and --browser when connected", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
state: "connected",
instanceId: "03c3e47f",
handshake: {
server: "bh",
version: mockDaemonVersion,
protocol_version: "1.0",
},
},
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
openRecordView();
fireEvent.change(screen.getByPlaceholderText("例如:发布一篇文章"), {
target: { value: "发布 wiki 文档" },
});
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
expect(copyButton.getAttribute("disabled")).toBeNull();
fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
expect(copied).toContain("03c3e47f");
expect(copied).toContain("--browser 03c3e47f");
expect(copied).toContain('--purpose "发布 wiki 文档"');
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
// Button label stays static; a transient toast confirms the copy.
expect(copyButton.textContent).toContain("复制录制指令");
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
});
it("includes --url in record prompt when a start URL is provided", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
state: "connected",
instanceId: "03c3e47f",
handshake: {
server: "bh",
version: mockDaemonVersion,
protocol_version: "1.0",
},
},
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
openRecordView();
fireEvent.change(screen.getByPlaceholderText("https://…"), {
target: { value: "https://example.com/" },
});
fireEvent.click(screen.getByRole("button", { name: "复制录制指令" }));
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
expect(copied).toContain("--url https://example.com/");
expect(copied).not.toContain("--purpose");
});
it("disables record copy when disconnected", () => {
render(<App />);
openRecordView();
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
expect(copyButton.getAttribute("disabled")).not.toBeNull();
expect(screen.getByText("连接后可用")).toBeTruthy();
});
});
describe("control hints toggle", () => {
function stubChromeStorage(initial: Record<string, unknown> = {}) {
const store = { ...initial };
vi.stubGlobal("chrome", {
runtime: { lastError: undefined },
storage: {
local: {
get: (keys: string | string[], cb: (items: Record<string, unknown>) => void) => {
const items: Record<string, unknown> = {};
for (const k of Array.isArray(keys) ? keys : [keys]) {
if (k in store) items[k] = store[k];
}
cb(items);
},
set: (items: Record<string, unknown>, cb?: () => void) => {
Object.assign(store, items);
cb?.();
},
},
onChanged: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
});
return store;
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the control hints toggle on when no preference is stored", async () => {
stubChromeStorage();
render(<App />);
const toggle = await screen.findByRole("switch", { name: "控制提示" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("reflects the stored hidden preference", async () => {
stubChromeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: true });
render(<App />);
const toggle = await screen.findByRole("switch", { name: "控制提示" });
await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false"));
});
it("persists the hidden preference when the toggle is turned off", async () => {
const store = stubChromeStorage();
render(<App />);
const toggle = await screen.findByRole("switch", { name: "控制提示" });
fireEvent.click(toggle);
expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true);
expect(toggle.getAttribute("aria-checked")).toBe("false");
});
it("keeps the hint copy in an accessible info tooltip", async () => {
stubChromeStorage();
render(<App />);
const info = await screen.findByRole("button", { name: "控制提示说明" });
expect(info).toBeTruthy();
const tooltip = screen.getByRole("tooltip");
expect(tooltip.textContent).toBe("Agent 控制页面时显示提示条和橙色闪光。");
// Hidden until the info button is hovered or focused.
expect(tooltip.className).toContain("opacity-0");
});
it("uses the same switch component and size for both settings rows", async () => {
stubChromeStorage();
render(<App />);
const hintsToggle = await screen.findByRole("switch", { name: "控制提示" });
const connectionToggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
// One shared Switch component, one size — hierarchy comes from copy and
// the info icon, not control size. Both rows default to checked, so the
// class strings must be identical.
expect(hintsToggle.className).toContain("h-5 w-9");
expect(hintsToggle.className).toBe(connectionToggle.className);
});
});
@@ -0,0 +1,417 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { Badge, Button, Input, Label } from "@browser-skill/ui";
import {
RiArrowLeftLine,
RiArrowRightSLine,
RiCheckLine,
RiFileCopyLine,
RiInformationLine,
} from "@remixicon/react";
import { type ChangeEvent, useEffect, useState } from "react";
import { PROTOCOL_VERSION } from "@/transport/handshake";
import functionIconUrl from "../../../assets/function.svg";
import { ConnectionStatusIndicator } from "./connection-status-indicator";
import { POPUP_FEATURES, type PopupView } from "./features";
import { Switch } from "./switch";
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
import { useControlHintsHidden } from "./use-control-hints-hidden";
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, setConnectionEnabled } = useConnectionState();
const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden();
const [view, setView] = useState<PopupView>("main");
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
const [purposeDraft, setPurposeDraft] = useState("");
const [startUrlDraft, setStartUrlDraft] = useState("");
// Bumped on every successful copy so the "copied" toast re-shows (and its
// auto-hide timer restarts) even when the copied content is unchanged.
const [copiedTick, setCopiedTick] = useState(0);
const showCopiedToast = copiedTick > 0;
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(() => {
setCopiedInstanceId(false);
setCopiedTick(0);
}, [snapshot.instanceId]);
// Hide the copied toast when the command changes (topic / start URL edits).
useEffect(() => {
setCopiedTick(0);
}, [purposeDraft, startUrlDraft]);
// Auto-hide the copied toast shortly after it appears.
useEffect(() => {
if (copiedTick === 0) return;
const timer = window.setTimeout(() => setCopiedTick(0), 1500);
return () => window.clearTimeout(timer);
}, [copiedTick]);
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);
};
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
const recordPurpose = purposeDraft.trim();
const recordStartUrl = startUrlDraft.trim();
const recordCommand = snapshot.instanceId
? [
`bsk record start --browser ${snapshot.instanceId}`,
recordStartUrl ? `--url ${recordStartUrl}` : "",
recordPurpose ? `--purpose "${recordPurpose}"` : "",
]
.filter(Boolean)
.join(" ")
: "";
const recordPrompt = snapshot.instanceId
? t("popup.record.promptTemplate", {
command: recordCommand,
})
: "";
const copyRecordPrompt = async () => {
if (!recordReady || !navigator.clipboard?.writeText) return;
await navigator.clipboard.writeText(recordPrompt);
setCopiedTick((tick) => tick + 1);
};
const headerTitle =
view === "features"
? t("popup.launcher.title")
: view === "record"
? t("popup.record.sectionTitle")
: t("popup.brandName");
return (
<main
className="min-w-[320px] max-w-[340px] space-y-3 bg-background p-3 text-foreground"
data-slot="popup-root"
data-view={view}
data-version-skew={isSkewed ? "true" : undefined}
>
<header
className="flex items-center gap-2"
data-slot={view === "main" ? "popup-brand-header" : "popup-subview-header"}
>
{view !== "main" ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md"
aria-label={t("popup.back")}
onClick={() => setView(view === "record" ? "features" : "main")}
data-slot="popup-back"
>
<RiArrowLeftLine className="size-4" aria-hidden />
</Button>
) : (
<img
src={getLogoSrc()}
alt=""
className="size-7 rounded-lg"
data-slot="popup-brand-logo"
/>
)}
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold tracking-tight">
{headerTitle}
</h1>
{view === "main" && (
<Button
type="button"
variant="ghost"
size="icon"
className="group size-8 shrink-0 rounded-md"
aria-label={t("popup.launcher.title")}
onClick={() => setView("features")}
data-slot="popup-launcher"
>
<img
src={functionIconUrl}
alt=""
className="size-6 opacity-35 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
data-slot="popup-launcher-icon"
/>
</Button>
)}
</header>
{view === "main" && (
<>
<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>
<Switch
checked={snapshot.connectionEnabled}
onCheckedChange={setConnectionEnabled}
aria-label={t("popup.connectionToggleTitle")}
data-slot="popup-connection-toggle"
/>
</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>
<section
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
data-slot="popup-control-hints-card"
>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium">
{t("popup.controlHintsToggleTitle")}
</span>
<span className="group relative inline-flex shrink-0">
<button
type="button"
aria-label={t("popup.controlHintsInfoLabel")}
data-slot="popup-control-hints-info"
className="flex size-4 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<RiInformationLine className="size-3.5" aria-hidden />
</button>
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-0 z-10 mb-1.5 w-56 whitespace-normal rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium leading-snug text-background opacity-0 shadow-md backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
{t("popup.controlHintsToggleHint")}
</span>
</span>
</span>
<Switch
checked={!controlHintsHidden}
onCheckedChange={(shown) => setControlHintsHidden(!shown)}
aria-label={t("popup.controlHintsToggleTitle")}
data-slot="popup-control-hints-toggle"
/>
</div>
</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>
)}
<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>
</>
)}
{view === "features" && (
<section className="space-y-2" data-slot="popup-feature-list">
{POPUP_FEATURES.map((feature) => {
const Icon = feature.icon;
return (
<button
key={feature.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl border border-border/80 bg-card/60 px-3 py-2.5 text-left transition-colors hover:bg-card/80"
onClick={() => setView(feature.id)}
data-slot={`popup-feature-${feature.id}`}
>
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-foreground">
{t(feature.titleKey)}
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">
{t(feature.descKey)}
</span>
</span>
<RiArrowRightSLine className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
);
})}
</section>
)}
{view === "record" && (
<section className="space-y-2.5" data-slot="popup-record-body">
<p
className="text-[11px] leading-snug text-muted-foreground"
data-slot="popup-record-hint-desc"
>
{t("popup.record.sectionHint")}
</p>
<div className="flex flex-col gap-1.5" data-slot="popup-record-purpose-field">
<Label htmlFor="bh-record-purpose" className="block text-xs text-muted-foreground">
{t("popup.record.topicLabel")}
</Label>
<Input
id="bh-record-purpose"
type="text"
value={purposeDraft}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setPurposeDraft(event.target.value)
}
placeholder={t("popup.record.topicPlaceholder")}
className="mt-0 h-8 text-sm"
data-slot="popup-record-purpose-input"
/>
</div>
<div className="flex flex-col gap-1.5" data-slot="popup-record-start-url-field">
<Label htmlFor="bh-record-start-url" className="block text-xs text-muted-foreground">
{t("popup.record.startUrlLabel")}
</Label>
<Input
id="bh-record-start-url"
type="url"
value={startUrlDraft}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setStartUrlDraft(event.target.value)
}
placeholder={t("popup.record.startUrlPlaceholder")}
className="mt-0 h-8 text-sm"
data-slot="popup-record-start-url-input"
/>
</div>
<textarea
readOnly
value={recordPrompt}
rows={5}
className="w-full resize-none rounded-md border border-input bg-muted/40 px-2.5 py-2 font-mono text-[10px] leading-snug text-foreground/90 focus-visible:outline-none"
data-slot="popup-record-prompt"
/>
<div className="flex items-center justify-between gap-2">
{!recordReady && (
<p className="text-[10px] text-muted-foreground" data-slot="popup-record-hint">
{t("popup.record.hintDisconnected")}
</p>
)}
<div className="relative ml-auto shrink-0">
{showCopiedToast && (
<div
role="status"
className="absolute bottom-full right-0 mb-1.5 flex items-center gap-1 whitespace-nowrap rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium text-background shadow-md backdrop-blur-sm"
data-slot="popup-record-copied-toast"
>
<RiCheckLine className="size-3" aria-hidden />
{t("popup.record.copied")}
</div>
)}
<Button
type="button"
variant="secondary"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={!recordReady}
onClick={() => {
void copyRecordPrompt();
}}
data-slot="popup-record-copy"
>
<RiFileCopyLine className="size-3.5" aria-hidden />
{t("popup.record.copyButton")}
</Button>
</div>
</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,22 @@
import type { RemixiconComponentType } from "@remixicon/react";
import { RiRecordCircleLine } from "@remixicon/react";
export type PopupFeatureId = "record";
export type PopupView = "main" | "features" | PopupFeatureId;
export type PopupFeature = {
id: PopupFeatureId;
icon: RemixiconComponentType;
titleKey: "popup.record.sectionTitle";
descKey: "popup.record.cardDesc";
};
export const POPUP_FEATURES: PopupFeature[] = [
{
id: "record",
icon: RiRecordCircleLine,
titleKey: "popup.record.sectionTitle",
descKey: "popup.record.cardDesc",
},
];
@@ -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,13 @@
@import "@browser-skill/ui/styles";
@source "../../";
@source "../../../../../packages/ui/src";
/* The shared base sets html/body/#root to height:100%, which is meant for
full-page surfaces. In a popup that pins the window to the tallest content
it ever showed, so collapsing the record section leaves blank space. Size
to content instead so Chrome can grow and shrink the popup to fit. */
html,
body,
#root {
height: auto;
}
@@ -0,0 +1,29 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Switch } from "./switch";
describe("Switch", () => {
afterEach(() => {
cleanup();
});
it("renders switch semantics with the checked state", () => {
render(<Switch checked={true} onCheckedChange={vi.fn()} aria-label="开关" />);
const toggle = screen.getByRole("switch", { name: "开关" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(toggle.className).toContain("h-5 w-9");
expect(toggle.className).toContain("bg-primary");
});
it("calls onCheckedChange with the negated state when clicked", () => {
const onCheckedChange = vi.fn();
render(<Switch checked={false} onCheckedChange={onCheckedChange} aria-label="开关" />);
const toggle = screen.getByRole("switch", { name: "开关" });
expect(toggle.className).toContain("bg-muted");
fireEvent.click(toggle);
expect(onCheckedChange).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,43 @@
import { cn } from "@browser-skill/ui";
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
"aria-label": string;
"data-slot"?: string;
}
/**
* The popup's single toggle switch — every settings row uses this same
* component and size; hierarchy between primary and secondary settings is
* conveyed by copy/iconography, not by control size.
*/
export function Switch({
checked,
onCheckedChange,
"aria-label": ariaLabel,
"data-slot": dataSlot,
}: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
data-slot={dataSlot}
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",
checked ? "bg-primary" : "bg-muted",
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
);
}
@@ -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,44 @@
import { useEffect, useState } from "react";
import { getControlHintsHidden, STORAGE_KEYS, setControlHintsHidden } from "@/lib/instance-id";
/**
* Popup-side view of the "hide control hints" preference in
* `chrome.storage.local`. Defaults to shown when storage is unavailable
* (e.g. unit tests) so the popup still renders; writes go straight to
* storage and content scripts pick them up via `storage.onChanged`.
*/
export function useControlHintsHidden(): [boolean, (hidden: boolean) => void] {
const [hidden, setHidden] = useState(false);
useEffect(() => {
if (typeof chrome === "undefined" || !chrome.storage?.local) return undefined;
let cancelled = false;
getControlHintsHidden()
.then((value) => {
if (!cancelled) setHidden(value);
})
.catch((err) => {
console.debug("[browser-skill] control-hints preference read failed", err);
});
const onChanged = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
if (areaName !== "local") return;
const change = changes[STORAGE_KEYS.CONTROL_HINTS_HIDDEN];
if (change) setHidden(change.newValue === true);
};
chrome.storage.onChanged.addListener(onChanged);
return () => {
cancelled = true;
chrome.storage.onChanged.removeListener(onChanged);
};
}, []);
const update = (value: boolean) => {
setHidden(value);
if (typeof chrome === "undefined" || !chrome.storage?.local) return;
setControlHintsHidden(value).catch((err) => {
console.debug("[browser-skill] control-hints preference write failed", err);
});
};
return [hidden, update];
}
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import {
CAPTURE_SUPPRESS,
type CaptureSuppressMessage,
withOverlaysHiddenForCapture,
} from "../capture-suppress-bridge";
function recordingSendToTab(events: string[]) {
return vi.fn(async (_tabId: number, message: CaptureSuppressMessage) => {
events.push(message.phase);
return { type: CAPTURE_SUPPRESS, ok: true };
});
}
describe("withOverlaysHiddenForCapture", () => {
it("sends begin → fn → end and returns fn's result", async () => {
const events: string[] = [];
const sendToTab = recordingSendToTab(events);
const result = await withOverlaysHiddenForCapture(
7,
async () => {
events.push("capture");
return "shot";
},
sendToTab,
);
expect(result).toBe("shot");
expect(events).toEqual(["begin", "capture", "end"]);
expect(sendToTab).toHaveBeenCalledWith(7, { type: CAPTURE_SUPPRESS, phase: "begin" });
expect(sendToTab).toHaveBeenCalledWith(7, { type: CAPTURE_SUPPRESS, phase: "end" });
});
it("still sends end when fn throws, then rethrows", async () => {
const events: string[] = [];
const sendToTab = recordingSendToTab(events);
await expect(
withOverlaysHiddenForCapture(
7,
async () => {
events.push("capture");
throw new Error("capture exploded");
},
sendToTab,
),
).rejects.toThrow("capture exploded");
expect(events).toEqual(["begin", "capture", "end"]);
});
it("captures directly when the tab has no content script", async () => {
const sendToTab = vi.fn(async () => {
throw new Error("Could not establish connection. Receiving end does not exist.");
});
const fn = vi.fn(async () => "shot");
const result = await withOverlaysHiddenForCapture(7, fn, sendToTab);
expect(result).toBe("shot");
expect(fn).toHaveBeenCalledTimes(1);
// begin failed → no matching end may be sent.
expect(sendToTab).toHaveBeenCalledTimes(1);
});
it("swallows end failures so the capture result stands", async () => {
const sendToTab = vi.fn(async (_tabId: number, message: CaptureSuppressMessage) => {
if (message.phase === "end") throw new Error("tab navigated away");
return { type: CAPTURE_SUPPRESS, ok: true };
});
const result = await withOverlaysHiddenForCapture(7, async () => "shot", sendToTab);
expect(result).toBe("shot");
expect(sendToTab).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,252 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
import type { ConnectionStateHandler, FrameHandler, Transport } from "../../transport/transport";
import type { ConnectionState, HandshakeResult, ProtocolFrame } from "../../transport/types";
import { __testing__, ConnectionController } from "../connection-controller";
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 messageHandlers = new Set<FrameHandler>();
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((handler: FrameHandler) => {
messageHandlers.add(handler);
return { dispose: () => messageHandlers.delete(handler) };
}),
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);
},
emitMessage(frame: ProtocolFrame) {
for (const handler of messageHandlers) handler(frame);
},
};
return transport as typeof transport & Transport;
}
describe("ConnectionController connectionEnabled", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
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("runs safe session cleanup before an intentional disconnect", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
const order: string[] = [];
transport.disconnect.mockImplementation(async () => {
order.push("disconnect");
});
await controller.attach(transport, { name: "Chrome", version: "120" }, true, {
beforeDisconnect: async () => {
order.push("cleanup");
},
});
await controller.setConnectionEnabled(false);
expect(order).toEqual(["cleanup", "disconnect"]);
});
it("runs session cleanup when the transport disconnects unexpectedly", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
const onDisconnected = vi.fn(async () => {});
await controller.attach(transport, { name: "Chrome", version: "120" }, true, {
onDisconnected,
});
transport.emitState("disconnected");
await vi.waitFor(() => expect(onDisconnected).toHaveBeenCalledTimes(1));
});
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");
});
it("disconnects and retries when handshake fails while the socket is still open", async () => {
vi.useFakeTimers();
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, true);
const request = transport.send.mock.calls[0]?.[0] as { id: string };
transport.emitMessage({
id: request.id,
error: { code: "protocol_error", message: "bad handshake" },
});
await vi.waitFor(() => expect(transport.disconnect).toHaveBeenCalledTimes(1));
expect(controller.snapshot().state).toBe("disconnected");
await vi.advanceTimersByTimeAsync(1_000);
expect(transport.connect).toHaveBeenCalledTimes(2);
});
it("binds each handshake to the connection that initiated it", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, true);
const first = transport.send.mock.calls[0]?.[0] as { id: string };
transport.emitState("disconnected");
transport.emitState("connected");
const second = transport.send.mock.calls[1]?.[0] as { id: string };
expect(second.id).not.toBe(first.id);
transport.emitMessage({ id: first.id, result: handshake("1.0", "1.0") });
await Promise.resolve();
expect(controller.snapshot().state).not.toBe("connected");
transport.emitMessage({ id: second.id, result: handshake("1.0", "1.0") });
await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected"));
});
});
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it } from "vitest";
import { describeEventTarget, describeTarget, isMeaningfulClickTarget } from "../describe-target";
describe("describeTarget", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("prefers aria-label and button role", () => {
document.body.innerHTML = `<button aria-label="发布">go</button>`;
const el = document.querySelector("button")!;
expect(describeTarget(el)).toEqual({
tag: "button",
role: "button",
name: "发布",
});
});
it("uses label text and name_attr for inputs", () => {
document.body.innerHTML = `
<label for="svc">服务名称</label>
<input id="svc" name="serviceName" />
`;
const el = document.querySelector("input")!;
expect(describeTarget(el)).toEqual({
tag: "input",
role: "textbox",
name: "服务名称",
name_attr: "serviceName",
});
});
it("never invents CSS selectors or @eN", () => {
document.body.innerHTML = `<a href="/x">详情</a>`;
const desc = describeTarget(document.querySelector("a")!);
expect(JSON.stringify(desc)).not.toMatch(/@e\d+|nth-of-type|#/);
expect(desc).toEqual({ tag: "a", role: "link", name: "详情" });
});
it("prefers heading text inside SERP-style links", () => {
document.body.innerHTML = `
<a href="https://www.tencent.com">
<h3>Tencent 腾讯</h3>
<cite>https://www.tencent.com</cite>
</a>
`;
expect(describeTarget(document.querySelector("a")!).name).toBe("Tencent 腾讯");
});
});
describe("LLM textbook click targets", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("rejects tag-only targets the LLM cannot find", () => {
expect(isMeaningfulClickTarget({ tag: "div" })).toBe(false);
});
it("treats a short visible name as the teaching signal", () => {
expect(isMeaningfulClickTarget({ tag: "div", name: "发布" })).toBe(true);
});
it("ignores anonymous layout chrome and lifts child clicks to the named button", () => {
document.body.innerHTML = `
<div id="noise">background</div>
<button type="button"><span id="icon">发布</span></button>
`;
expect(describeEventTarget(document.querySelector("#noise"))).toBeNull();
expect(describeEventTarget(document.querySelector("#icon"))).toEqual({
tag: "button",
role: "button",
name: "发布",
});
});
it("does not attach layout sibling text as nearby_label on buttons", () => {
document.body.innerHTML = `
<div>background</div>
<button type="button" id="pub">发布</button>
`;
expect(describeTarget(document.querySelector("#pub")!)).toEqual({
tag: "button",
role: "button",
name: "发布",
});
});
it("keeps nearby_label for form fields from a preceding label-like sibling", () => {
document.body.innerHTML = `
<div>服务名称</div>
<input id="svc" />
`;
expect(describeTarget(document.querySelector("#svc")!)).toEqual(
expect.objectContaining({
tag: "input",
nearby_label: "服务名称",
}),
);
});
it("does not treat plain text blocks as clicks", () => {
document.body.innerHTML = `<p id="copy">说明文字</p>`;
expect(describeEventTarget(document.querySelector("#copy"))).toBeNull();
});
it("records custom button-like controls by their visible label", () => {
document.body.innerHTML = `<div class="btn" id="pub" style="cursor: pointer">发布</div>`;
expect(describeEventTarget(document.querySelector("#pub"))).toEqual(
expect.objectContaining({ tag: "div", name: "发布" }),
);
});
});
@@ -0,0 +1,130 @@
import { describe, expect, it, vi } from "vitest";
import type { ProtocolFrame } from "@/transport/types";
import { HEARTBEAT_EVENT, HEARTBEAT_INTERVAL_MS, startHeartbeat } from "../heartbeat";
function makeTimers() {
let nextId = 1;
const intervals = new Map<number, { cb: () => void; ms: number }>();
return {
intervals,
api: {
setInterval: (cb: () => void, ms: number) => {
const id = nextId++;
intervals.set(id, { cb, ms });
return id;
},
clearInterval: (id: number) => {
intervals.delete(id);
},
},
tick: (id: number) => intervals.get(id)?.cb(),
};
}
/** Drives `onActiveChange` subscribers so a test can flip the link state. */
function makeActiveSource() {
const listeners = new Set<(active: boolean) => void>();
return {
subscribe: (cb: (active: boolean) => void) => {
// Mirror the controller contract: fire immediately with the current
// value (starts inactive), then on every change.
cb(false);
listeners.add(cb);
return () => listeners.delete(cb);
},
set: (active: boolean) => {
for (const cb of listeners) cb(active);
},
listenerCount: () => listeners.size,
};
}
describe("startHeartbeat", () => {
it("does not beat while the link is inactive", () => {
const timers = makeTimers();
const active = makeActiveSource();
const send = vi.fn();
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
expect(timers.intervals.size).toBe(0);
expect(send).not.toHaveBeenCalled();
});
it("starts a 20s interval on activation and sends heartbeat events", () => {
const timers = makeTimers();
const active = makeActiveSource();
const send = vi.fn();
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
active.set(true);
expect(timers.intervals.size).toBe(1);
const [id, info] = [...timers.intervals.entries()][0];
expect(info.ms).toBe(HEARTBEAT_INTERVAL_MS);
timers.tick(id);
expect(send).toHaveBeenCalledWith({ event: HEARTBEAT_EVENT, payload: {} });
});
it("stops beating when the link goes inactive", () => {
const timers = makeTimers();
const active = makeActiveSource();
const send = vi.fn();
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
active.set(true);
expect(timers.intervals.size).toBe(1);
active.set(false);
expect(timers.intervals.size).toBe(0);
});
it("does not stack intervals on repeated activation", () => {
const timers = makeTimers();
const active = makeActiveSource();
startHeartbeat({ send: vi.fn(), onActiveChange: active.subscribe, timers: timers.api });
active.set(true);
active.set(true);
expect(timers.intervals.size).toBe(1);
});
it("swallows send errors so the interval keeps running", () => {
const timers = makeTimers();
const active = makeActiveSource();
const send = vi.fn(() => {
throw new Error("socket closed");
});
const handle = startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
expect(() => handle.beatForTest()).not.toThrow();
});
it("dispose clears the interval and unsubscribes", () => {
const timers = makeTimers();
const active = makeActiveSource();
const handle = startHeartbeat({
send: vi.fn(),
onActiveChange: active.subscribe,
timers: timers.api,
});
active.set(true);
expect(timers.intervals.size).toBe(1);
handle.dispose();
expect(timers.intervals.size).toBe(0);
expect(active.listenerCount()).toBe(0);
});
it("sends a well-formed protocol frame", () => {
const timers = makeTimers();
const active = makeActiveSource();
let captured: ProtocolFrame | null = null;
const handle = startHeartbeat({
send: (f) => {
captured = f;
},
onActiveChange: active.subscribe,
timers: timers.api,
});
handle.beatForTest();
expect(captured).toEqual({ event: HEARTBEAT_EVENT, payload: {} });
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
HELP_ACK,
HELP_CANCEL,
HELP_FINISH,
HELP_QUERY,
HELP_REQUEST,
HELP_RESPONSE,
isHelpAckMessage,
isHelpCancelMessage,
isHelpFinishMessage,
isHelpQueryMessage,
isHelpRequestMessage,
isHelpResponseMessage,
} 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");
expect(HELP_ACK).toBe("bsk-help-ack");
expect(HELP_QUERY).toBe("bsk-help-query");
expect(HELP_FINISH).toBe("bsk-help-finish");
});
it("type-guards a help-request message", () => {
expect(
isHelpRequestMessage({
type: HELP_REQUEST,
requestId: "r1",
prompt: "log in",
displayMode: "compact",
selectors: ["#login"],
rects: [{ top: 10, left: 20, width: 100, height: 40 }],
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);
});
it("type-guards lifecycle messages", () => {
expect(isHelpAckMessage({ type: HELP_ACK, ok: true })).toBe(true);
expect(isHelpQueryMessage({ type: HELP_QUERY })).toBe(true);
expect(isHelpFinishMessage({ type: HELP_FINISH, requestId: "r1", outcome: "continued" })).toBe(
true,
);
expect(isHelpResponseMessage({ type: HELP_RESPONSE, outcome: "cancelled", note: "no" })).toBe(
true,
);
expect(isHelpFinishMessage({ type: HELP_FINISH, requestId: "r1", outcome: "weird" })).toBe(
false,
);
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { evaluateHoverTrigger } from "../hover-trigger-policy";
describe("evaluateHoverTrigger", () => {
it("accepts explicit popup triggers", () => {
const decision = evaluateHoverTrigger({
tag: "button",
role: "button",
label: "Open user navigation menu",
attrs: { "aria-haspopup": "menu", "aria-expanded": "false" },
rect: { x: 900, y: 8, w: 32, h: 32 },
cursor: "pointer",
pointerEvents: "auto",
});
expect(decision.eligible).toBe(true);
expect(decision.reasons).toEqual(
expect.arrayContaining(["aria-haspopup", "collapsed", "popup-signal"]),
);
});
it("accepts compact topbar image buttons without popup attributes", () => {
const decision = evaluateHoverTrigger({
tag: "button",
role: "button",
label: "image",
attrs: {},
rect: { x: 900, y: 8, w: 32, h: 32 },
pointerEvents: "auto",
hasGraphicDescendant: true,
});
expect(decision.eligible).toBe(true);
expect(decision.reasons).toEqual(expect.arrayContaining(["icon-only", "topbar", "compact"]));
});
it("accepts compact topbar image links without popup attributes", () => {
const decision = evaluateHoverTrigger({
tag: "a",
role: "link",
label: "image",
attrs: {},
rect: { x: 900, y: 8, w: 32, h: 32 },
pointerEvents: "auto",
hasGraphicDescendant: true,
});
expect(decision.eligible).toBe(true);
expect(decision.reasons).toEqual(expect.arrayContaining(["icon-only", "topbar", "compact"]));
});
it("rejects topbar menu links without surface trigger evidence", () => {
const decision = evaluateHoverTrigger({
tag: "a",
role: "link",
label: "My profile",
attrs: {},
rect: { x: 820, y: 72, w: 96, h: 32 },
cursor: "pointer",
pointerEvents: "auto",
});
expect(decision.eligible).toBe(false);
});
it("rejects text navigation items even when their attributes contain popup words", () => {
const decision = evaluateHoverTrigger({
tag: "a",
role: "link",
label: "My profile",
attrs: { class: "dropdown-item profile-link" },
rect: { x: 820, y: 72, w: 96, h: 32 },
cursor: "pointer",
pointerEvents: "auto",
});
expect(decision.eligible).toBe(false);
});
it("rejects labelled dropdown list items as hover triggers", () => {
const decision = evaluateHoverTrigger({
tag: "li",
label: "文档C+D",
attrs: { class: "dropdown-item", tabindex: "0" },
rect: { x: 820, y: 48, w: 160, h: 32 },
cursor: "pointer",
pointerEvents: "auto",
});
expect(decision.eligible).toBe(false);
});
it("rejects ordinary controls without hover popup signals", () => {
const decision = evaluateHoverTrigger({
tag: "button",
role: "button",
label: "Search",
attrs: {},
rect: { x: 100, y: 240, w: 120, h: 36 },
pointerEvents: "auto",
});
expect(decision.eligible).toBe(false);
});
it("rejects unsafe or non-visible triggers", () => {
expect(
evaluateHoverTrigger({
tag: "input",
attrs: {},
rect: { x: 10, y: 10, w: 80, h: 24 },
pointerEvents: "auto",
}).eligible,
).toBe(false);
expect(
evaluateHoverTrigger({
tag: "button",
role: "button",
attrs: { hidden: "" },
rect: { x: 10, y: 10, w: 80, h: 24 },
pointerEvents: "auto",
}).eligible,
).toBe(false);
});
});
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from "vitest";
import {
getConnectionEnabled,
getControlHintsHidden,
getLabel,
getOrCreateInstanceId,
STORAGE_KEYS,
setConnectionEnabled,
setControlHintsHidden,
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);
});
it("getControlHintsHidden returns false when storage is empty", async () => {
const { backend } = fakeStorage();
expect(await getControlHintsHidden(backend)).toBe(false);
});
it("getControlHintsHidden returns persisted boolean values", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: true });
expect(await getControlHintsHidden(backend)).toBe(true);
});
it("getControlHintsHidden treats non-boolean stored values as shown", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: "true" });
expect(await getControlHintsHidden(backend)).toBe(false);
});
it("setControlHintsHidden persists the value retrievable by getControlHintsHidden", async () => {
const { backend, store } = fakeStorage();
await setControlHintsHidden(true, backend);
expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true);
expect(await getControlHintsHidden(backend)).toBe(true);
});
});
@@ -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, expect, it } from "vitest";
import {
isOverlayAgentOverlayResetMessage,
OVERLAY_AGENT_OVERLAY_RESET,
OVERLAY_MSG_INTERRUPT,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
} 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, expect, it, 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,80 @@
import { describe, expect, it } from "vitest";
import { appendRecordedPayload, observeRecordedNavigation } from "../recording-step-buffer";
describe("recording-step-buffer", () => {
it("stores semantic click without summary", () => {
const buffer = { steps: [], pendingNavigation: false };
appendRecordedPayload(buffer, {
op: "click",
target: { tag: "button", role: "button", name: "发布" },
expects_navigation: true,
});
expect(buffer.steps).toEqual([
{
op: "click",
target: { tag: "button", role: "button", name: "发布" },
},
]);
expect(buffer.pendingNavigation).toBe(true);
});
it("annotates navigated_to on action-caused navigation instead of wait_for_navigation", () => {
const buffer = {
steps: [
{
op: "click" as const,
target: { tag: "button", role: "button", name: "发布" },
},
],
currentUrl: "https://example.com/a",
pendingNavigation: true,
pendingNavigationDeadline: Date.now() + 5_000,
};
observeRecordedNavigation(buffer, "https://example.com/b", true);
expect(buffer.steps).toEqual([
{
op: "click",
target: { tag: "button", role: "button", name: "发布" },
navigated_to: "https://example.com/b",
},
]);
expect(JSON.stringify(buffer.steps)).not.toContain("wait_for_navigation");
});
it("annotates navigated_to onto a select that auto-submits a navigation", () => {
const buffer = {
steps: [
{
op: "select" as const,
target: { tag: "select", role: "combobox", name: "分类" },
values: ["tech"],
},
],
currentUrl: "https://example.com/list",
pendingNavigation: true,
pendingNavigationDeadline: Date.now() + 5_000,
};
observeRecordedNavigation(buffer, "https://example.com/list?cat=tech", true);
expect(buffer.steps).toEqual([
{
op: "select",
target: { tag: "select", role: "combobox", name: "分类" },
values: ["tech"],
navigated_to: "https://example.com/list?cat=tech",
},
]);
});
it("emits navigate for uncaused URL changes", () => {
const buffer = {
steps: [],
currentUrl: "https://example.com/a",
pendingNavigation: false,
};
observeRecordedNavigation(buffer, "https://example.com/b", false);
expect(buffer.steps[0]).toMatchObject({
op: "navigate",
url: "https://example.com/b",
});
});
});
@@ -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,186 @@
import { describe, expect, it } from "vitest";
import type { DraftTraceStep } from "@/transport/types";
import { reduceTraceSteps, resolveTraceStartUrl, shouldRecordPress } from "../trace-reducer";
describe("shouldRecordPress", () => {
it("keeps Enter and Escape", () => {
expect(shouldRecordPress("Enter")).toBe(true);
expect(shouldRecordPress("Escape")).toBe(true);
});
it("drops modifiers, clipboard shortcuts, and bare typing", () => {
expect(shouldRecordPress("Meta")).toBe(false);
expect(shouldRecordPress("c", ["meta"])).toBe(false);
expect(shouldRecordPress("a", ["ctrl"])).toBe(false);
expect(shouldRecordPress("x")).toBe(false);
expect(shouldRecordPress("中")).toBe(false);
});
});
describe("reduceTraceSteps", () => {
it("builds steps with pages dictionary and page id references", () => {
const drafts: DraftTraceStep[] = [
{
op: "navigate",
url: "https://example.com/search?q=hello&utm_source=x",
page_url: "https://example.com/search?q=hello&utm_source=x",
},
{
op: "fill",
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
value: "browser skill",
page_url: "https://example.com/search?q=hello&utm_source=x",
},
{
op: "press",
key: "Enter",
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
navigated_to: "https://example.com/results/42",
page_url: "https://example.com/search?q=hello&utm_source=x",
},
{
op: "click",
target: { tag: "button", role: "button", name: "发布" },
navigated_to: "https://example.com/p/99",
page_url: "https://example.com/results/42",
},
{
op: "press",
key: "a",
page_url: "https://example.com/p/99",
},
];
const { pages, steps } = reduceTraceSteps(
drafts,
"https://example.com/search?q=hello&utm_source=x",
);
expect(JSON.stringify(steps)).not.toContain("parameters");
expect(JSON.stringify(steps)).not.toContain("intent");
expect(JSON.stringify(steps)).not.toContain("summary");
expect(steps.map((s) => s.op)).toEqual(["navigate", "fill", "press", "click"]);
expect(pages.map((p) => p.url)).toEqual([
"https://example.com/search?q=hello&utm_source=x",
"https://example.com/results/42",
"https://example.com/p/99",
]);
expect(steps[0]).toMatchObject({
id: 1,
op: "navigate",
page: "p1",
to: "https://example.com/search?q=hello&utm_source=x",
});
expect(steps[1]).toMatchObject({
op: "fill",
page: "p1",
value: "browser skill",
});
expect(steps[2]).toMatchObject({
op: "press",
key: "Enter",
page: "p1",
effect: { navigated_to: "p2" },
});
expect(steps[3]).toMatchObject({
op: "click",
page: "p2",
effect: { navigated_to: "p3" },
});
});
it("collapses consecutive navigations", () => {
const { steps } = reduceTraceSteps([
{ op: "navigate", url: "https://a.example/redirect1" },
{ op: "navigate", url: "https://a.example/final" },
]);
expect(steps).toHaveLength(1);
expect(steps[0]).toMatchObject({
op: "navigate",
to: "https://a.example/final",
});
});
it("keeps committed empty fill steps", () => {
const { steps } = reduceTraceSteps(
[
{
op: "fill",
target: { tag: "input", role: "textbox", name: "Search query" },
value: "",
page_url: "https://example.com/search",
},
],
"https://example.com/search",
);
expect(steps).toEqual([
expect.objectContaining({
op: "fill",
value: "",
}),
]);
});
it("maps select navigated_to onto effect.navigated_to (page id)", () => {
const { pages, steps } = reduceTraceSteps(
[
{
op: "select",
target: { tag: "select", role: "combobox", name: "分类" },
values: ["tech"],
labels: ["技术"],
navigated_to: "https://example.com/list?cat=tech",
page_url: "https://example.com/list",
},
],
"https://example.com/list",
);
expect(pages.map((p) => p.url)).toEqual([
"https://example.com/list",
"https://example.com/list?cat=tech",
]);
expect(steps[0]).toMatchObject({
op: "select",
page: "p1",
selection: [{ value: "tech", label: "技术" }],
effect: { navigated_to: "p2" },
});
});
it("keeps hover steps before menu clicks", () => {
const { steps } = reduceTraceSteps(
[
{
op: "hover",
target: { tag: "span", role: "button", name: "Account" },
page_url: "https://example.com/app",
},
{
op: "click",
target: { tag: "a", role: "link", name: "Profile" },
page_url: "https://example.com/app",
},
],
"https://example.com/app",
);
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
expect(steps[0]).toMatchObject({
op: "hover",
page: "p1",
target: { name: "Account" },
});
});
it("resolveTraceStartUrl prefers explicit start URL", () => {
expect(
resolveTraceStartUrl(
[{ op: "navigate", url: "https://example.com/other" }],
"https://example.com/start",
),
).toBe("https://example.com/start");
});
});
@@ -0,0 +1,71 @@
/**
* Wire protocol and background-side helper for hiding the extension's
* in-page overlay while a screenshot is captured, so captured frames only
* contain the page itself.
*
* The background wraps every capture in `withOverlaysHiddenForCapture`,
* which sends `begin` (the content script hides the overlay host and only
* acks once the compositor has produced an overlay-free frame) and always
* follows up with `end`, even when the capture throws. Tabs without the
* content script (chrome://, the Web Store, ...) host no overlay, so a
* failed `begin` falls through to capturing directly.
*/
export const CAPTURE_SUPPRESS = "bsk/capture-suppress";
export type CaptureSuppressPhase = "begin" | "end";
export interface CaptureSuppressMessage {
type: typeof CAPTURE_SUPPRESS;
phase: CaptureSuppressPhase;
}
export interface CaptureSuppressAck {
type: typeof CAPTURE_SUPPRESS;
ok: true;
}
export function isCaptureSuppressMessage(msg: unknown): msg is CaptureSuppressMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === CAPTURE_SUPPRESS && (m.phase === "begin" || m.phase === "end");
}
/** Minimal `chrome.tabs.sendMessage` surface so tests can inject a fake. */
export type CaptureSuppressSendToTab = (
tabId: number,
message: CaptureSuppressMessage,
) => Promise<unknown>;
const defaultSendToTab: CaptureSuppressSendToTab = (tabId, message) =>
chrome.tabs.sendMessage(tabId, message);
/**
* Run `fn` with the target tab's overlay hidden. `end` is sent from a
* `finally` so the overlay always reappears, and a missing content script
* (or any other `begin` failure) simply skips suppression — there is no
* overlay to hide in that tab.
*/
export async function withOverlaysHiddenForCapture<T>(
tabId: number,
fn: () => Promise<T>,
sendToTab: CaptureSuppressSendToTab = defaultSendToTab,
): Promise<T> {
let began = false;
try {
await sendToTab(tabId, { type: CAPTURE_SUPPRESS, phase: "begin" });
began = true;
} catch {
// No content script in the target tab → no overlay can leak into the
// capture; proceed without suppression.
}
try {
return await fn();
} finally {
if (began) {
await sendToTab(tabId, { type: CAPTURE_SUPPRESS, phase: "end" }).catch((err) => {
console.debug("[bsk capture] overlay restore failed", err);
});
}
}
}
@@ -0,0 +1,378 @@
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";
const HANDSHAKE_RETRY_DELAY_MS = 1_000;
export interface SnapshotInfo {
state: ConnectionState;
instanceId: string;
label: string;
extensionVersion: string;
handshake: HandshakeResult | null;
lastError: string | null;
connectionEnabled: boolean;
}
type Listener = (s: SnapshotInfo) => void;
export interface ConnectionLifecycleHooks {
/** Safe local teardown that must finish before an intentional disconnect. */
beforeDisconnect?: () => void | Promise<void>;
/** Best-effort teardown after an unexpected transport loss. */
onDisconnected?: () => void | Promise<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 lifecycleHooks: ConnectionLifecycleHooks = {};
private disconnectRecovery: Promise<void> | null = null;
private connectionGeneration = 0;
private handshakeAbort: AbortController | null = null;
private handshakeRetryTimer: ReturnType<typeof setTimeout> | null = null;
private suppressDisconnectRecovery = 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,
lifecycleHooks: ConnectionLifecycleHooks = {},
): Promise<void> {
this.transport = transport;
this.connectionEnabled = connectionEnabled;
this.lifecycleHooks = lifecycleHooks;
this.instanceId = await getOrCreateInstanceId();
this.label = await getLabel();
transport.onConnectionStateChange((s) => {
if (s === "disconnected") {
// An in-flight handshake belongs to the dead connection — cancel it so
// a late resolution can never clobber the next attempt.
this.cancelHandshake();
this.handshake = null;
if (this.connectionEnabled) {
this.setState("disconnected");
if (this.suppressDisconnectRecovery) {
// Deliberate disconnect from runHandshake (rejected peer or failed
// handshake bounce); the caller owns the reconnect policy there.
this.suppressDisconnectRecovery = false;
} else {
void this.recoverFromDisconnect();
}
}
return;
}
if (!this.connectionEnabled) return;
if (s === "connected") {
this.startHandshake(browser);
return;
}
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 startHandshake(browser: { name: string; version: string }): void {
this.cancelHandshake();
const generation = ++this.connectionGeneration;
const abort = new AbortController();
this.handshakeAbort = abort;
void this.runHandshake(browser, generation, abort.signal);
}
private async runHandshake(
browser: { name: string; version: string },
generation: number,
signal: AbortSignal,
): Promise<void> {
if (!this.transport) return;
if (!this.connectionEnabled) return;
this.setState("connecting");
try {
const outcome = await performHandshake(
this.transport,
{
instanceId: this.instanceId,
browser,
label: this.label,
},
{ signal },
);
if (generation !== this.connectionGeneration || signal.aborted) return;
this.handshake = outcome.result;
const verdict = computeConnectedState(outcome.result);
if (verdict.kind === "rejected") {
this.lastError = `version_too_old: ${verdict.reason}`;
this.handshake = null;
// Rejected peers must not be auto-retried: mark this disconnect as
// deliberate so the listener skips unexpected-loss recovery.
this.suppressDisconnectRecovery = true;
await this.transport.disconnect().catch(() => {});
this.setState("disconnected");
return;
}
this.clearHandshakeRetry();
this.lastError = null;
this.setState(verdict.kind);
} catch (err) {
if (generation !== this.connectionGeneration || signal.aborted || isAbortError(err)) return;
this.handshake = null;
this.lastError = err instanceof Error ? err.message : String(err);
// Bounce the half-open socket deliberately; the retry timer below owns
// the reconnect, so skip unexpected-loss recovery for this disconnect.
this.suppressDisconnectRecovery = true;
await this.transport.disconnect().catch(() => {});
this.setState("disconnected");
this.scheduleHandshakeRetry(browser);
} finally {
if (generation === this.connectionGeneration) this.handshakeAbort = null;
}
}
private async applyDisabledState(): Promise<void> {
this.cancelHandshake();
this.clearHandshakeRetry();
this.handshake = null;
this.lastError = null;
await this.lifecycleHooks.beforeDisconnect?.();
if (this.transport) {
await this.transport.disconnect().catch(() => {});
}
const was = this.currentState;
this.setState("disconnected");
if (was === "disconnected") this.fire();
}
private recoverFromDisconnect(): Promise<void> {
if (this.disconnectRecovery) return this.disconnectRecovery;
const recovery = (async () => {
// WSTransport schedules its reconnect timer immediately after notifying
// state listeners. Yield once, then disconnect explicitly so that timer
// is cancelled before local session teardown begins.
await Promise.resolve();
if (!this.connectionEnabled || !this.transport) return;
// Another path already reconnected while we yielded — nothing to do.
if (this.currentState !== "disconnected") return;
await this.transport.disconnect().catch(() => {});
try {
await this.lifecycleHooks.onDisconnected?.();
} catch (err) {
console.warn("[browser-skill] session cleanup after disconnect failed", err);
}
if (!this.connectionEnabled) return;
try {
await this.transport.connect();
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected");
}
})();
this.disconnectRecovery = recovery.finally(() => {
this.disconnectRecovery = null;
});
return this.disconnectRecovery;
}
private cancelHandshake(): void {
this.connectionGeneration += 1;
this.handshakeAbort?.abort();
this.handshakeAbort = null;
}
private scheduleHandshakeRetry(browser: { name: string; version: string }): void {
if (this.handshakeRetryTimer || !this.connectionEnabled) return;
this.handshakeRetryTimer = setTimeout(() => {
this.handshakeRetryTimer = null;
if (!this.connectionEnabled || !this.transport) return;
void this.transport.connect().catch((err) => {
this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected");
});
}, HANDSHAKE_RETRY_DELAY_MS);
}
private clearHandshakeRetry(): void {
if (!this.handshakeRetryTimer) return;
clearTimeout(this.handshakeRetryTimer);
this.handshakeRetryTimer = null;
}
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);
}
}
}
}
function isAbortError(err: unknown): boolean {
return (
typeof err === "object" && err !== null && (err as { name?: string }).name === "AbortError"
);
}
/**
* 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 };
+359
View File
@@ -0,0 +1,359 @@
/**
* Build a semantic TargetDescriptor for an interacted element.
*
* Trace steps are an LLM *textbook*: each click must say what to look for
* on screen (usually a short visible name). Tag-only noise like
* `{ "tag": "div" }` / “点击div” is useless and must not be recorded.
*/
export interface TargetDescriptor {
role?: string;
name?: string;
tag: string;
name_attr?: string;
placeholder?: string;
nearby_label?: string;
}
/** Max length for a label that is still a useful “find this on screen” hint. */
const ACTIONABLE_LABEL_MAX = 48;
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function truncate(value: string, max = 80): string {
const trimmed = normalizeWhitespace(value);
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max - 1)}…`;
}
/** Short, human label an LLM can search for in a snapshot. */
export function isActionableLabel(name: string): boolean {
const trimmed = normalizeWhitespace(name);
if (!trimmed) return false;
if (trimmed.length > ACTIONABLE_LABEL_MAX) return false;
if (/^https?:\/\//i.test(trimmed)) return false;
return true;
}
function labelledByText(el: Element): string | null {
const ids = el.getAttribute("aria-labelledby");
if (!ids) return null;
const parts = ids
.split(/\s+/)
.map((id) => document.getElementById(id)?.textContent ?? "")
.map(normalizeWhitespace)
.filter(Boolean);
return parts.length ? truncate(parts.join(" ")) : null;
}
function associatedLabelText(el: Element): string | null {
if (!(el instanceof HTMLElement) || !el.id) return null;
const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
if (!label) return null;
const text = normalizeWhitespace(label.textContent ?? "");
return text ? truncate(text) : null;
}
function wrappingLabelText(el: Element): string | null {
const label = el.closest("label");
if (!label) return null;
const clone = label.cloneNode(true) as HTMLElement;
for (const control of clone.querySelectorAll("input,textarea,select,button")) {
control.remove();
}
const text = normalizeWhitespace(clone.textContent ?? "");
return text ? truncate(text) : null;
}
function placeholderOrTitle(el: Element): string | null {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
const ph = normalizeWhitespace(el.placeholder);
if (ph) return truncate(ph);
}
const title = normalizeWhitespace(el.getAttribute("title") ?? "");
return title ? truncate(title) : null;
}
function ownText(el: Element): string | null {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
return null;
}
const text = normalizeWhitespace(el.textContent ?? "");
return text ? truncate(text) : null;
}
/** Prefer a compact title inside links (e.g. SERP result `<h3>`), not the whole blob. */
function compactLinkName(el: Element): string | null {
const heading = el.querySelector("h1, h2, h3, h4");
if (heading) {
const text = normalizeWhitespace(heading.textContent ?? "");
if (text) return truncate(text);
}
const img = el.querySelector("img[alt]");
if (img instanceof HTMLImageElement) {
const alt = normalizeWhitespace(img.alt);
if (alt) return truncate(alt);
}
const clone = el.cloneNode(true) as HTMLElement;
for (const junk of clone.querySelectorAll("cite, script, style, svg, noscript")) {
junk.remove();
}
const text = normalizeWhitespace(clone.textContent ?? "");
if (!text) return null;
const withoutUrl = text.replace(/https?:\/\/\S+/g, "").trim();
return truncate(withoutUrl || text);
}
/** Visible accessible name preference order for LLM textbooks. */
export function accessibleName(el: Element): string | undefined {
const ariaLabel = normalizeWhitespace(el.getAttribute("aria-label") ?? "");
if (ariaLabel) return truncate(ariaLabel);
const labelledBy = labelledByText(el);
if (labelledBy) return labelledBy;
const forLabel = associatedLabelText(el);
if (forLabel) return forLabel;
const wrapLabel = wrappingLabelText(el);
if (wrapLabel) return wrapLabel;
const alt =
el instanceof HTMLImageElement
? normalizeWhitespace(el.alt)
: normalizeWhitespace(el.getAttribute("alt") ?? "");
if (alt) return truncate(alt);
const ph = placeholderOrTitle(el);
if (ph) return ph;
const tag = el.tagName.toLowerCase();
if (tag === "a" || el.getAttribute("role") === "link") {
const linkName = compactLinkName(el);
if (linkName) return linkName;
}
const text = ownText(el);
if (text) return text;
if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement) {
const value = normalizeWhitespace(el.value);
if (
value &&
(el instanceof HTMLButtonElement || el.type === "submit" || el.type === "button")
) {
return truncate(value);
}
}
return undefined;
}
const INTERACTIVE_SELECTOR = [
"a[href]",
"button",
"summary",
"select",
'input:not([type="hidden"])',
"textarea",
'[role="button"]',
'[role="link"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[role="option"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="treeitem"]',
'[role="combobox"]',
'[contenteditable]:not([contenteditable="false"])',
].join(",");
const INTERACTIVE_ROLES = new Set([
"button",
"link",
"tab",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"checkbox",
"radio",
"switch",
"treeitem",
"combobox",
"textbox",
"searchbox",
"slider",
]);
function looksClickable(el: Element): boolean {
if (!(el instanceof HTMLElement)) return false;
if (el.matches(INTERACTIVE_SELECTOR)) return true;
const role = normalizeWhitespace(el.getAttribute("role") ?? "").toLowerCase();
if (role && INTERACTIVE_ROLES.has(role)) return true;
if (el.tabIndex >= 0) return true;
try {
if (getComputedStyle(el).cursor === "pointer") return true;
} catch {
// happy-dom / detached nodes
}
const cls = typeof el.className === "string" ? el.className : "";
if (/\b(btn|button|link|clickable|action)\b/i.test(cls)) return true;
return false;
}
/**
* Resolve the element a click should describe for the LLM textbook.
* Never falls back to an anonymous layout `div`.
*/
export function resolveClickableElement(target: Element): Element | null {
const interactive = target.closest(INTERACTIVE_SELECTOR);
if (interactive instanceof Element) return interactive;
let node: Element | null = target;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth < 8) {
const name = accessibleName(node);
if (name && isActionableLabel(name) && looksClickable(node)) {
return node;
}
node = node.parentElement;
depth += 1;
}
return null;
}
export function resolveHoverElement(target: Element): Element | null {
const clickable = resolveClickableElement(target);
if (clickable) return clickable;
let node: Element | null = target;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth < 8) {
if (looksClickable(node)) return node;
node = node.parentElement;
depth += 1;
}
return null;
}
/**
* Teachable clicks: the LLM must get a label it can later find via snapshot
* (visible name), or at least a form `name_attr` for checkbox/radio.
* Recording “点击div” with no name fails this bar.
*/
export function isMeaningfulClickTarget(target: TargetDescriptor): boolean {
const name = target.name?.trim();
if (name && isActionableLabel(name)) return true;
if (
(target.role === "checkbox" || target.role === "radio" || target.tag === "input") &&
target.name_attr
) {
return true;
}
return false;
}
export function inferRole(el: Element): string | undefined {
const explicit = normalizeWhitespace(el.getAttribute("role") ?? "").toLowerCase();
if (explicit) return explicit;
const tag = el.tagName.toLowerCase();
if (tag === "button") return "button";
if (tag === "a" && el.hasAttribute("href")) return "link";
if (tag === "select") return "combobox";
if (tag === "textarea") return "textbox";
if (tag === "img") return "img";
if (tag === "summary") return "button";
if (el instanceof HTMLElement && (el.isContentEditable || el.contentEditable === "true")) {
return "textbox";
}
if (el instanceof HTMLInputElement) {
const type = el.type.toLowerCase();
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
if (type === "submit" || type === "button" || type === "image" || type === "reset") {
return "button";
}
if (type === "range") return "slider";
return "textbox";
}
return undefined;
}
/**
* Nearby labels help the LLM name form fields. Only apply to fillable
* controls — never to buttons/links, where a previous layout sibling
* (e.g. a chrome `<div>`) would pollute the textbook target.
*/
function nearbyLabelText(el: Element): string | undefined {
if (
!(
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
)
) {
return undefined;
}
if (el.id) {
const byFor = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
const text = byFor ? normalizeWhitespace(byFor.textContent ?? "") : "";
if (text) return truncate(text, ACTIONABLE_LABEL_MAX);
}
const wrapped = el.closest("label");
if (wrapped) {
const clone = wrapped.cloneNode(true) as HTMLLabelElement;
for (const control of clone.querySelectorAll("input, textarea, select, button")) {
control.remove();
}
const text = normalizeWhitespace(clone.textContent ?? "");
if (text) return truncate(text, ACTIONABLE_LABEL_MAX);
}
const prev = el.previousElementSibling;
if (prev && /^(LABEL|SPAN|DIV|P|DT)$/i.test(prev.tagName)) {
const text = normalizeWhitespace(prev.textContent ?? "");
if (text && text.length <= ACTIONABLE_LABEL_MAX) return text;
}
return undefined;
}
export function describeTarget(el: Element): TargetDescriptor {
const tag = el.tagName.toLowerCase();
const role = inferRole(el);
const name = accessibleName(el);
const nameAttr =
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement ||
el instanceof HTMLButtonElement
? normalizeWhitespace(el.name) || undefined
: normalizeWhitespace(el.getAttribute("name") ?? "") || undefined;
const placeholder =
el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
? normalizeWhitespace(el.placeholder) || undefined
: normalizeWhitespace(el.getAttribute("placeholder") ?? "") || undefined;
const nearby = nearbyLabelText(el);
return {
tag,
...(role ? { role } : {}),
...(name ? { name } : {}),
...(nameAttr ? { name_attr: nameAttr } : {}),
...(placeholder ? { placeholder } : {}),
...(nearby && nearby !== name ? { nearby_label: nearby } : {}),
};
}
export function describeEventTarget(target: EventTarget | null): TargetDescriptor | null {
if (!(target instanceof Element)) return null;
const clickable = resolveClickableElement(target);
if (!clickable) return null;
const desc = describeTarget(clickable);
return isMeaningfulClickTarget(desc) ? desc : null;
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Application-level WebSocket heartbeat.
*
* Since Chrome 116, sending or receiving a message over a WebSocket
* resets the MV3 service-worker idle timer (see
* https://developer.chrome.com/docs/extensions/how-to/web-platform/websockets).
* By emitting a tiny `system.heartbeat` event every 20s — comfortably
* inside the 30s idle window — we keep the worker (and therefore the
* daemon link) alive for as long as the connection is up, instead of
* relying solely on the 30s keepalive alarm that sits right on the
* eviction boundary.
*
* The daemon also treats the heartbeat as a liveness signal, so a
* silently-dead browser can be reaped on its side.
*
* The heartbeat only runs while the *post-handshake* link is live: the
* daemon rejects any first frame that is not `system.handshake`, so
* beating before the handshake completes would get the connection
* kicked. Callers wire {@link HeartbeatOptions.onActiveChange} to the
* connection controller, which only reports `connected` / `version_skew`
* after the handshake has landed.
*/
import type { ProtocolFrame } from "@/transport/types";
export const HEARTBEAT_EVENT = "system.heartbeat";
/** 20s: the interval Chrome's own WebSocket keepalive sample recommends. */
export const HEARTBEAT_INTERVAL_MS = 20_000;
/** Subset of the timer API we use; lets tests inject fakes. */
export interface HeartbeatTimers {
setInterval(cb: () => void, ms: number): number;
clearInterval(handle: number): void;
}
export interface HeartbeatOptions {
/** Send a frame over the live transport. */
send: (frame: ProtocolFrame) => void;
/**
* Subscribe to "is the post-handshake link live" changes. Must invoke
* the callback with the current value immediately and again on every
* transition, and return an unsubscribe function.
*/
onActiveChange: (cb: (active: boolean) => void) => () => void;
/** Override the beat interval (tests). */
intervalMs?: number;
/** Inject fake timers (tests). */
timers?: HeartbeatTimers;
}
export interface HeartbeatHandle {
/** Stop beating and detach the active-state listener. */
dispose(): void;
/** Test hook: emit one heartbeat frame right now. */
beatForTest(): void;
}
function defaultTimers(): HeartbeatTimers {
return {
setInterval: (cb, ms) => setInterval(cb, ms) as unknown as number,
clearInterval: (handle) => clearInterval(handle),
};
}
/**
* Start a heartbeat that beats every `intervalMs` while the link is
* active and pauses entirely while it is not.
*/
export function startHeartbeat(options: HeartbeatOptions): HeartbeatHandle {
const intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
const timers = options.timers ?? defaultTimers();
let timer: number | null = null;
const beat = () => {
try {
options.send({ event: HEARTBEAT_EVENT, payload: {} });
} catch (err) {
// The socket raced a close between the state change and this
// tick. The transport's own reconnect path recovers; swallow so
// the interval keeps running (it is cleared on the disconnect
// notification anyway).
console.debug("[bsk heartbeat] send failed", err);
}
};
const stop = () => {
if (timer !== null) {
timers.clearInterval(timer);
timer = null;
}
};
const start = () => {
if (timer !== null) return;
timer = timers.setInterval(beat, intervalMs);
};
const unsubscribe = options.onActiveChange((active) => {
if (active) start();
else stop();
});
return {
dispose: () => {
unsubscribe();
stop();
},
beatForTest: beat,
};
}
+139
View File
@@ -0,0 +1,139 @@
/**
* 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 acks display, then later reports Done / Cancel.
*/
export const HELP_REQUEST = "bsk-help-request";
export const HELP_RESPONSE = "bsk-help-response";
export const HELP_CANCEL = "bsk-help-cancel";
export const HELP_ACK = "bsk-help-ack";
export const HELP_QUERY = "bsk-help-query";
export const HELP_FINISH = "bsk-help-finish";
export interface HelpHighlightRect {
top: number;
left: number;
width: number;
height: number;
}
export interface HelpRequestMessage {
type: typeof HELP_REQUEST;
requestId: string;
prompt: string;
/** Custom overlay title; omitted when the extension should use its default. */
title?: string;
/** Full task UI on the subject tab; compact status UI on related tabs. */
displayMode?: "full" | "compact";
/** CSS selectors to scroll to + flash-highlight (may be empty). */
selectors: string[];
/** Top-viewport rectangles for targets that cannot be represented by a root selector. */
rects?: HelpHighlightRect[];
timeoutMs: number;
}
export interface HelpAckMessage {
type: typeof HELP_ACK;
ok: true;
}
export interface HelpResponseMessage {
type: typeof HELP_RESPONSE;
outcome: "continued" | "cancelled";
note?: string;
}
export interface HelpCancelMessage {
type: typeof HELP_CANCEL;
requestId: string;
}
export interface HelpQueryMessage {
type: typeof HELP_QUERY;
}
export interface HelpQueryResponse {
active: boolean;
request?: Omit<HelpRequestMessage, "type">;
}
export interface HelpFinishMessage {
type: typeof HELP_FINISH;
requestId: string;
outcome: "continued" | "cancelled";
note?: 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") &&
(m.displayMode === undefined || m.displayMode === "full" || m.displayMode === "compact") &&
Array.isArray(m.selectors) &&
m.selectors.every((selector) => typeof selector === "string") &&
(m.rects === undefined ||
(Array.isArray(m.rects) &&
m.rects.every(
(rect) =>
typeof rect === "object" &&
rect !== null &&
["top", "left", "width", "height"].every((key) =>
Number.isFinite((rect as Record<string, unknown>)[key]),
),
))) &&
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";
}
export function isHelpResponseMessage(msg: unknown): msg is HelpResponseMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return (
m.type === HELP_RESPONSE &&
(m.outcome === "continued" || m.outcome === "cancelled") &&
(m.note === undefined || typeof m.note === "string")
);
}
export function isHelpAckMessage(msg: unknown): msg is HelpAckMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === HELP_ACK && m.ok === true;
}
export function isHelpQueryMessage(msg: unknown): msg is HelpQueryMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === HELP_QUERY;
}
export function isHelpFinishMessage(msg: unknown): msg is HelpFinishMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return (
m.type === HELP_FINISH &&
typeof m.requestId === "string" &&
(m.outcome === "continued" || m.outcome === "cancelled") &&
(m.note === undefined || typeof m.note === "string")
);
}
@@ -0,0 +1,178 @@
export interface HoverTriggerRect {
x: number;
y: number;
w: number;
h: number;
}
export interface HoverTriggerSignals {
tag: string;
role?: string;
label?: string;
attrs?: Record<string, string | undefined>;
rect?: HoverTriggerRect | null;
cursor?: string;
pointerEvents?: string;
hasGraphicDescendant?: boolean;
cssHoverMatch?: boolean;
}
export interface HoverTriggerDecision {
eligible: boolean;
score: number;
reasons: string[];
}
const HOVER_TRIGGER_MIN_SCORE = 45;
const HOVER_POPUP_SIGNAL_RE =
/(menu|dropdown|popover|popup|avatar|profile|account|user|more|ellipsis|caret)/;
const HOVER_TEXT_ITEM_RE = /(^|[\s_-])(dropdown-item|menu-item|context-menu-item|option)($|[\s_-])/;
function attr(signals: HoverTriggerSignals, name: string): string | undefined {
return signals.attrs?.[name.toLowerCase()];
}
export function isUnsafeHoverTrigger(signals: HoverTriggerSignals): boolean {
const tag = signals.tag.toLowerCase();
if (["input", "textarea", "select", "option"].includes(tag)) return true;
if ((attr(signals, "contenteditable") ?? "").toLowerCase() === "true") return true;
if (attr(signals, "disabled") !== undefined || attr(signals, "inert") !== undefined) return true;
return (attr(signals, "aria-disabled") ?? "").toLowerCase() === "true";
}
export function hasHoverPopupSignal(signals: HoverTriggerSignals): boolean {
const attrs = signals.attrs ?? {};
const haystack = [
attrs.id,
attrs.class,
attrs["data-testid"],
attrs["data-test"],
attrs["data-cy"],
attrs["aria-label"],
attrs["aria-haspopup"],
attrs["aria-controls"],
attrs.title,
signals.role === "button" ? signals.label : undefined,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return HOVER_POPUP_SIGNAL_RE.test(haystack);
}
export function hasStrongHoverExpansionSignal(signals: HoverTriggerSignals): boolean {
return (
signals.cssHoverMatch === true ||
attr(signals, "aria-haspopup") !== undefined ||
(attr(signals, "aria-expanded") ?? "").toLowerCase() === "false"
);
}
export function hasDirectHoverInteractiveSignal(signals: HoverTriggerSignals): boolean {
const tag = signals.tag.toLowerCase();
const role = (signals.role ?? "").toLowerCase();
return (
["button", "a", "summary"].includes(tag) ||
["button", "link", "menuitem", "tab", "combobox"].includes(role) ||
attr(signals, "tabindex") !== undefined ||
signals.cursor === "pointer" ||
attr(signals, "onclick") !== undefined ||
attr(signals, "onmouseenter") !== undefined ||
attr(signals, "onmouseover") !== undefined ||
attr(signals, "aria-haspopup") !== undefined ||
attr(signals, "aria-expanded") === "false"
);
}
export function evaluateHoverTrigger(signals: HoverTriggerSignals): HoverTriggerDecision {
const rect = signals.rect;
if (
!rect ||
rect.w <= 0 ||
rect.h <= 0 ||
signals.pointerEvents === "none" ||
attr(signals, "hidden") !== undefined ||
attr(signals, "inert") !== undefined ||
(attr(signals, "aria-hidden") ?? "").toLowerCase() === "true" ||
isUnsafeHoverTrigger(signals)
) {
return { eligible: false, score: 0, reasons: [] };
}
const area = rect.w * rect.h;
if (area <= 0 || area > 160_000) {
return { eligible: false, score: 0, reasons: [] };
}
const reasons: string[] = [];
let score = 0;
const tag = signals.tag.toLowerCase();
const role = (signals.role ?? "").toLowerCase();
const label = signals.label?.trim();
const graphic = signals.hasGraphicDescendant === true;
const directInteractive = hasDirectHoverInteractiveSignal(signals);
const popupSignal = hasHoverPopupSignal(signals);
const isTextNavigationItem =
(tag === "a" ||
tag === "li" ||
role === "link" ||
role === "option" ||
role.startsWith("menuitem") ||
HOVER_TEXT_ITEM_RE.test(attr(signals, "class") ?? "")) &&
!graphic &&
!!label &&
label.toLowerCase() !== "image";
const compactTopbarIcon =
(role === "button" || role === "link" || tag === "button" || tag === "a") &&
graphic &&
rect.y < 120 &&
rect.w <= 96 &&
rect.h <= 96 &&
(!label || label.toLowerCase() === "image");
const surfaceTriggerEvidence =
(popupSignal && !isTextNavigationItem) ||
compactTopbarIcon ||
hasStrongHoverExpansionSignal(signals);
if (!directInteractive || !surfaceTriggerEvidence) {
return { eligible: false, score: 0, reasons: [] };
}
if (attr(signals, "aria-haspopup") !== undefined) {
score += 80;
reasons.push("aria-haspopup");
}
if ((attr(signals, "aria-expanded") ?? "").toLowerCase() === "false") {
score += 55;
reasons.push("collapsed");
}
if (popupSignal) {
score += 45;
reasons.push("popup-signal");
}
if (tag === "button" || role === "button") {
score += 30;
reasons.push("button");
}
if (signals.cursor === "pointer") {
score += 25;
reasons.push("pointer");
}
if (graphic && (!label || label.toLowerCase() === "image")) {
score += 40;
reasons.push("icon-only");
}
if (rect.y < 120) {
score += 25;
reasons.push("topbar");
}
if (rect.w <= 80 && rect.h <= 80) {
score += 20;
reasons.push("compact");
}
if (signals.cssHoverMatch) {
score += 40;
reasons.push("css-hover");
}
return { eligible: score >= HOVER_TRIGGER_MIN_SCORE, score, reasons };
}
+135
View File
@@ -0,0 +1,135 @@
const STORAGE_KEY = "bsk_instance_id";
const LABEL_STORAGE_KEY = "bh_label";
const CONNECTION_ENABLED_KEY = "bh_connection_enabled";
const CONTROL_HINTS_HIDDEN_KEY = "bsk_control_hints_hidden";
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 });
}
/**
* User preference for the in-page control hints (status pill + orange glow
* shown while the Agent controls a tab). Defaults to shown when unset or
* non-boolean; when hidden the whole control overlay — including its input
* blocker — is skipped so the page looks and behaves normally.
*/
export async function getControlHintsHidden(
storage: StorageBackend = defaultStorage(),
): Promise<boolean> {
const items = await storage.get(CONTROL_HINTS_HIDDEN_KEY);
const raw = items[CONTROL_HINTS_HIDDEN_KEY];
return typeof raw === "boolean" ? raw : false;
}
export async function setControlHintsHidden(
hidden: boolean,
storage: StorageBackend = defaultStorage(),
): Promise<void> {
await storage.set({ [CONTROL_HINTS_HIDDEN_KEY]: hidden });
}
export const STORAGE_KEYS = {
INSTANCE_ID: STORAGE_KEY,
LABEL: LABEL_STORAGE_KEY,
CONNECTION_ENABLED: CONNECTION_ENABLED_KEY,
CONTROL_HINTS_HIDDEN: CONTROL_HINTS_HIDDEN_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,
};
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Wire protocol for `chrome.runtime.sendMessage`-driven communication
* between the content-script control overlay and the background SW.
*
* Content script → background:
* - `{ kind: "overlay.ready" }` → background replies with the authoritative
* overlay state for the sender's window.
* - `{ 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_READY = "overlay.ready";
export const OVERLAY_MSG_INTERRUPT = "overlay.interrupt";
/**
* WXT shadow-host element name (`createShadowRootUi({ name })`) and the marker
* attribute set on the host for the agent's own overlay UI. The VOM capture
* adapter uses these to skip the overlay host and its shadow subtree.
*/
export const OVERLAY_HOST_NAME = "browser-skill-overlay";
export const OVERLAY_HOST_MARKER_ATTR = "data-bsk-overlay";
/** CSS selector for the WXT shadow host (tag + marker attribute). */
export const OVERLAY_HOST_SELECTOR = `${OVERLAY_HOST_NAME}, [${OVERLAY_HOST_MARKER_ATTR}]`;
export function isOverlayHostElementName(tagName: string): boolean {
return tagName.toLowerCase() === OVERLAY_HOST_NAME;
}
export function isOverlayHostMarkerAttribute(attrName: string): boolean {
return attrName.toLowerCase() === OVERLAY_HOST_MARKER_ATTR;
}
export function isOverlayHostNode(tagName: string, attributeNames?: Iterable<string>): boolean {
if (isOverlayHostElementName(tagName)) return true;
if (!attributeNames) return false;
for (const name of attributeNames) {
if (isOverlayHostMarkerAttribute(name)) return true;
}
return false;
}
/** Page-world `document.querySelector(...)` for the overlay shadow host. */
export const OVERLAY_HOST_LOOKUP_EXPR = `document.querySelector(${JSON.stringify(OVERLAY_HOST_SELECTOR)})`;
export interface OverlayWhoAmIRequest {
kind: typeof OVERLAY_MSG_WHO_AM_I;
tabId?: number;
windowId?: number;
}
export interface OverlayWhoAmIResponse {
sessionId: string | null;
}
export interface OverlayReadyRequest {
kind: typeof OVERLAY_MSG_READY;
}
export type OverlayMode = "control" | "interrupting" | "paused" | "hidden";
/** Background → content: complete, authoritative control-overlay state. */
export const OVERLAY_AGENT_STATE = "bh-agent-overlay-state";
export interface OverlayAgentStateMessage {
type: typeof OVERLAY_AGENT_STATE;
sessionId: string | null;
mode: OverlayMode;
generation: number;
}
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 function isOverlayAgentStateMessage(message: unknown): message is OverlayAgentStateMessage {
if (!message || typeof message !== "object") return false;
const candidate = message as {
type?: unknown;
sessionId?: unknown;
mode?: unknown;
generation?: unknown;
};
return (
candidate.type === OVERLAY_AGENT_STATE &&
(typeof candidate.sessionId === "string" || candidate.sessionId === null) &&
(candidate.mode === "control" ||
candidate.mode === "interrupting" ||
candidate.mode === "paused" ||
candidate.mode === "hidden") &&
typeof candidate.generation === "number"
);
}
export type OverlayMessage = OverlayWhoAmIRequest | OverlayReadyRequest | 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 };
+119
View File
@@ -0,0 +1,119 @@
/**
* Wire protocol for user-action recording, sent between the background
* service worker and a tab's content script.
*/
import type { TargetDescriptor } from "./describe-target";
export const RECORD_START = "bsk-record-start";
export const RECORD_STEP = "bsk-record-step";
export const RECORD_STOP = "bsk-record-stop";
export const RECORD_CANCEL = "bsk-record-cancel";
export const RECORD_FINISH = "bsk-record-finish";
export const RECORD_QUERY = "bsk-record-query";
export interface RecordStartAck {
ok: true;
}
export type RecordStopAck = { ok: true } | { ok: false; error: string };
export interface RecordQueryMessage {
type: typeof RECORD_QUERY;
}
export interface RecordQueryResponse {
active: boolean;
requestId?: string;
/** Epoch ms when the recording began; see `RecordStartMessage.startedAtMs`. */
startedAtMs?: number;
}
export interface RecordStartMessage {
type: typeof RECORD_START;
requestId: string;
/**
* Epoch ms when the whole recording began, not when this tab was armed.
* The overlay timer must span the session, so it survives navigations and
* content-script remounts instead of restarting per page.
*/
startedAtMs?: number;
}
export interface RecordStepPayload {
op: "click" | "hover" | "fill" | "press" | "select" | "navigate";
target?: TargetDescriptor;
value?: string;
key?: string;
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
values?: string[];
labels?: string[];
url?: string;
redacted?: boolean;
/** Page URL when the step was captured. */
page_url?: string;
/** Capture-only hint; never persisted unless converted to navigated_to. */
expects_navigation?: boolean;
/** Whether an observed URL change was synchronously caused by the action. */
navigation_caused_by_action?: boolean;
}
export interface RecordStepMessage {
type: typeof RECORD_STEP;
requestId: string;
step: RecordStepPayload;
}
export interface RecordStopMessage {
type: typeof RECORD_STOP;
requestId: string;
}
export interface RecordCancelMessage {
type: typeof RECORD_CANCEL;
requestId: string;
}
export interface RecordFinishMessage {
type: typeof RECORD_FINISH;
requestId: string;
}
export function isRecordStartMessage(msg: unknown): msg is RecordStartMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_START && typeof m.requestId === "string";
}
export function isRecordStopMessage(msg: unknown): msg is RecordStopMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_STOP && typeof m.requestId === "string";
}
export function isRecordCancelMessage(msg: unknown): msg is RecordCancelMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_CANCEL && typeof m.requestId === "string";
}
export function isRecordFinishMessage(msg: unknown): msg is RecordFinishMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_FINISH && typeof m.requestId === "string";
}
export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_QUERY;
}
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
if (m.type !== RECORD_STEP || typeof m.requestId !== "string") return false;
const step = m.step;
if (typeof step !== "object" || step === null) return false;
return typeof (step as RecordStepPayload).op === "string";
}
@@ -0,0 +1,136 @@
import type { DraftTraceStep } from "@/transport/types";
import type { RecordStepPayload } from "./record-bridge";
export interface RecordingStepBuffer {
steps: DraftTraceStep[];
currentUrl?: string;
pendingNavigation: boolean;
pendingNavigationDeadline?: number;
}
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
const pageUrl = payload.page_url;
switch (payload.op) {
case "click":
return payload.target
? {
op: "click",
target: payload.target,
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "hover":
return payload.target
? {
op: "hover",
target: payload.target,
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "fill":
return payload.target
? {
op: "fill",
target: payload.target,
value: payload.value ?? "",
...(payload.redacted ? { redacted: true } : {}),
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "press":
return payload.key
? {
op: "press",
key: payload.key,
...(payload.target ? { target: payload.target } : {}),
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "select":
return payload.target && payload.values
? {
op: "select",
target: payload.target,
values: payload.values,
...(payload.labels?.length ? { labels: payload.labels } : {}),
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "navigate":
return null;
}
}
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): boolean {
for (let i = buffer.steps.length - 1; i >= 0; i -= 1) {
const step = buffer.steps[i];
if (!step) continue;
if (step.op === "click" || step.op === "press" || step.op === "select") {
buffer.steps[i] = { ...step, navigated_to: url };
return true;
}
break;
}
return false;
}
export function observeRecordedNavigation(
buffer: RecordingStepBuffer,
url: string,
causedByAction?: boolean,
): void {
if (!url || url === buffer.currentUrl) return;
buffer.currentUrl = url;
const pendingIsCurrent =
buffer.pendingNavigation &&
(buffer.pendingNavigationDeadline === undefined ||
buffer.pendingNavigationDeadline >= Date.now());
if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) {
buffer.pendingNavigation = false;
buffer.pendingNavigationDeadline = undefined;
if (!annotateLastStepNavigation(buffer, url)) {
buffer.steps.push({
op: "navigate",
url,
page_url: url,
});
}
return;
}
buffer.pendingNavigation = false;
buffer.pendingNavigationDeadline = undefined;
buffer.steps.push({
op: "navigate",
url,
page_url: url,
});
}
export function appendRecordedPayload(
buffer: RecordingStepBuffer,
payload: RecordStepPayload,
): void {
if (payload.op === "navigate") {
if (payload.url) {
observeRecordedNavigation(buffer, payload.url, payload.navigation_caused_by_action);
}
return;
}
const step = toDraftStep({
...payload,
page_url: payload.page_url ?? buffer.currentUrl,
});
if (!step) return;
buffer.steps.push(step);
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
buffer.pendingNavigation = payload.expects_navigation === true;
buffer.pendingNavigationDeadline = buffer.pendingNavigation
? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS
: undefined;
}
}
+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,
};
}
+224
View File
@@ -0,0 +1,224 @@
import type { DraftTraceStep, PageRef, SelectedOption, Step } from "@/transport/types";
const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "A", "C", "V", "X"]);
const MODIFIER_ONLY_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "Hyper", "Super"]);
export function shouldRecordPress(
key: string,
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">,
): boolean {
if (MODIFIER_ONLY_KEYS.has(key)) return false;
const mods = modifiers ?? [];
const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta");
if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false;
if (key === "Enter" || key === "Escape") return true;
// Drop bare character typing — FillSession already records the value.
if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false;
return false;
}
function shouldIncludeDraft(step: DraftTraceStep): boolean {
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
return true;
}
/** Collapse consecutive navigations to the last hop. */
function collapseNavigations(steps: DraftTraceStep[]): DraftTraceStep[] {
const out: DraftTraceStep[] = [];
for (const step of steps) {
const prev = out[out.length - 1];
if (step.op === "navigate" && prev?.op === "navigate") {
out[out.length - 1] = step;
continue;
}
out.push(step);
}
return out;
}
function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
const urls: string[] = [];
if (startUrl) urls.push(startUrl);
for (const step of steps) {
if (step.op === "navigate") {
urls.push(step.url);
continue;
}
if ("page_url" in step && step.page_url) urls.push(step.page_url);
if ("navigated_to" in step && step.navigated_to) urls.push(step.navigated_to);
}
const seen = new Set<string>();
const unique: string[] = [];
for (const url of urls) {
if (!url || seen.has(url)) continue;
seen.add(url);
unique.push(url);
}
return unique;
}
function buildPageRegistry(
steps: DraftTraceStep[],
startUrl?: string,
): { pages: PageRef[]; urlToId: Map<string, string> } {
const urls = collectUrls(steps, startUrl);
const urlToId = new Map<string, string>();
const pages = urls.map((url, index) => {
const id = `p${index + 1}`;
urlToId.set(url, id);
return { id, url };
});
return { pages, urlToId };
}
function pageIdFor(
url: string | undefined,
urlToId: Map<string, string>,
fallbackUrl?: string,
): string {
if (url && urlToId.has(url)) return urlToId.get(url)!;
if (fallbackUrl && urlToId.has(fallbackUrl)) return urlToId.get(fallbackUrl)!;
return urlToId.values().next().value ?? "p1";
}
function pageUrlForDraft(step: DraftTraceStep, fallbackUrl?: string): string | undefined {
if (step.op === "navigate") return step.page_url ?? step.url;
if ("page_url" in step && step.page_url) return step.page_url;
return fallbackUrl;
}
function effectForNavigation(
navigatedTo: string | undefined,
urlToId: Map<string, string>,
): Step["effect"] {
if (!navigatedTo) return undefined;
const pageId = urlToId.get(navigatedTo);
if (!pageId) return undefined;
return { navigated_to: pageId };
}
function withEffect(step: Step, effect: Step["effect"]): Step {
if (!effect) return step;
return { ...step, effect };
}
function toSelection(values: string[], labels?: string[]): SelectedOption[] {
return values.map((value, index) => ({
value,
...(labels?.[index] ? { label: labels[index] } : {}),
}));
}
function toV2Step(
step: DraftTraceStep,
id: number,
urlToId: Map<string, string>,
fallbackUrl?: string,
): Step | null {
if (!shouldIncludeDraft(step)) return null;
const pageUrl = pageUrlForDraft(step, fallbackUrl);
const page = pageIdFor(pageUrl, urlToId, fallbackUrl);
switch (step.op) {
case "navigate":
return {
op: "navigate",
id,
page: pageIdFor(step.url, urlToId, fallbackUrl),
to: step.url,
};
case "click":
return withEffect(
{
op: "click",
id,
page,
target: step.target,
},
effectForNavigation(step.navigated_to, urlToId),
);
case "hover":
return {
op: "hover",
id,
page,
target: step.target,
};
case "fill":
return {
op: "fill",
id,
page,
target: step.target,
value: step.value,
...(step.redacted ? { redacted: true } : {}),
};
case "press":
return withEffect(
{
op: "press",
id,
page,
key: step.key,
...(step.target ? { target: step.target } : {}),
...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
},
effectForNavigation(step.navigated_to, urlToId),
);
case "select":
return withEffect(
{
op: "select",
id,
page,
target: step.target,
selection: toSelection(step.values, step.labels),
},
effectForNavigation(step.navigated_to, urlToId),
);
}
}
export interface ReducedTrace {
pages: PageRef[];
steps: Step[];
}
/**
* Compile capture drafts into record-only trace v2 steps.
* Variable inputs are NOT classified here — executing agents infer that at run time.
*/
export function reduceTraceSteps(steps: DraftTraceStep[], startUrl?: string): ReducedTrace {
const collapsed = collapseNavigations(steps);
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
const out: Step[] = [];
let id = 1;
let lastUrl = startUrl;
for (const draft of collapsed) {
if (draft.op === "navigate") lastUrl = draft.url;
else if ("navigated_to" in draft && draft.navigated_to) lastUrl = draft.navigated_to;
else if ("page_url" in draft && draft.page_url) lastUrl = draft.page_url;
const step = toV2Step(draft, id, urlToId, lastUrl);
if (!step) continue;
out.push(step);
id += 1;
}
return { pages, steps: out };
}
export function resolveTraceStartUrl(
drafts: DraftTraceStep[],
startUrl?: string,
pages?: PageRef[],
): string {
if (startUrl) return startUrl;
const navigate = drafts.find((step): step is Extract<DraftTraceStep, { op: "navigate" }> => {
return step.op === "navigate";
});
if (navigate) return navigate.url;
for (const draft of drafts) {
if ("page_url" in draft && draft.page_url) return draft.page_url;
}
return pages?.[0]?.url ?? "about:blank";
}
@@ -0,0 +1,96 @@
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();
});
});
describe("chromeAgentWindowApi.create", () => {
const create = vi.fn();
beforeEach(() => {
vi.stubGlobal("chrome", {
windows: { create },
});
create.mockReset();
create.mockResolvedValue({ id: 100 });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("focuses Agent Windows by default", async () => {
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME);
expect(create).toHaveBeenCalledWith({
type: "normal",
focused: true,
url: AGENT_WINDOW_HOME,
});
});
it("can create an Agent Window without stealing focus", async () => {
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME, { focused: false });
expect(create).toHaveBeenCalledWith({
type: "normal",
focused: false,
url: AGENT_WINDOW_HOME,
});
});
it("passes an optional window size through the options object", async () => {
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME, {
size: { width: 1280, height: 800 },
});
expect(create).toHaveBeenCalledWith({
type: "normal",
focused: true,
url: AGENT_WINDOW_HOME,
width: 1280,
height: 800,
});
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { createDisconnectCleanup } from "../disconnect-cleanup";
import { SessionManager } from "../manager";
describe("disconnect session cleanup", () => {
it("stops every local session through the safe session-stop path", async () => {
const remove = vi.fn(async () => {});
let nextWindowId = 100;
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => nextWindowId++),
ensureActiveTab: vi.fn(async () => {}),
remove,
},
});
await manager.start("aa11");
await manager.start("bb22");
const detachSession = vi.fn(async () => {});
const onSessionsChanged = vi.fn();
const cleanup = createDisconnectCleanup({
manager,
sessionStopDeps: { cdp: { detachSession } },
onSessionsChanged,
});
const report = await cleanup();
expect(report).toEqual({ stoppedSessionIds: ["aa11", "bb22"], failures: [] });
expect(manager.list()).toEqual([]);
expect(remove).toHaveBeenCalledTimes(2);
expect(detachSession).toHaveBeenCalledWith("aa11");
expect(detachSession).toHaveBeenCalledWith("bb22");
expect(onSessionsChanged).toHaveBeenCalledTimes(1);
});
it("coalesces overlapping disconnect notifications", async () => {
let releaseRemove: () => void = () => {};
const removeGate = new Promise<void>((resolve) => {
releaseRemove = resolve;
});
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
ensureActiveTab: vi.fn(async () => {}),
remove: vi.fn(() => removeGate),
},
});
await manager.start("aa11");
const cleanup = createDisconnectCleanup({ manager });
const first = cleanup();
const second = cleanup();
expect(second).toBe(first);
releaseRemove();
await expect(first).resolves.toEqual({ stoppedSessionIds: ["aa11"], failures: [] });
});
});
@@ -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,179 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentWindowApi, AgentWindowCreateOptions } 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, _opts?: AgentWindowCreateOptions) => {
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("forwards an optional window size when starting a session", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11", { size: { width: 1280, height: 800 } });
expect(aw.createMock).toHaveBeenCalledWith("about:blank", {
size: { width: 1280, height: 800 },
});
expect(ctx.agentWindowId).toBe(100);
});
it("forwards an explicit unfocused start to the Agent Window", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
await sm.start("aa11", { focused: false });
expect(aw.createMock).toHaveBeenCalledWith("about:blank", { focused: false });
});
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("removes a newly created Agent Window when startup is aborted", async () => {
const aw = fakeAgentWindow();
let resolveCreate: (windowId: number) => void = () => {};
aw.createMock.mockImplementationOnce(
() =>
new Promise<number>((resolve) => {
resolveCreate = resolve;
}),
);
const sm = new SessionManager({ agentWindow: aw });
const controller = new AbortController();
const pending = sm.start("aa11", { signal: controller.signal });
controller.abort();
resolveCreate(777);
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(aw.removeMock).toHaveBeenCalledWith(777);
expect(sm.has("aa11")).toBe(false);
});
it("removes an incomplete Agent Window when active-tab setup fails", async () => {
const aw = fakeAgentWindow();
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
const sm = new SessionManager({ agentWindow: aw });
await expect(sm.start("aa11")).rejects.toThrow("tab setup failed");
expect(aw.removeMock).toHaveBeenCalledWith(100);
expect(sm.has("aa11")).toBe(false);
});
it("surfaces the orphan Agent Window id when startup cleanup fails", async () => {
const aw = fakeAgentWindow();
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
aw.removeMock.mockRejectedValueOnce(new Error("window removal denied"));
const sm = new SessionManager({ agentWindow: aw });
await expect(sm.start("aa11")).rejects.toMatchObject({
name: "SessionStartCleanupError",
windowId: 100,
message: expect.stringMatching(/cleanup of Agent Window 100 failed.*window removal denied/),
});
expect(sm.has("aa11")).toBe(false);
});
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,51 @@
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);
});
it("preserves the child CDP session as part of ref identity", () => {
const s = new RefStore();
s.set("e1", 42, {
tabId: 7,
frameId: "child-frame",
cdpSessionId: "child-session",
});
expect(s.resolveEntry("e1")).toMatchObject({
backendNodeId: 42,
tabId: 7,
frameId: "child-frame",
cdpSessionId: "child-session",
});
});
});
@@ -0,0 +1,63 @@
/**
* 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, opts?: AgentWindowCreateOptions): 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>;
}
/** Creation hints for a new Agent Window. */
export interface AgentWindowCreateOptions {
/** Optional outer size in CSS pixels. */
size?: { width: number; height: number };
/** Defaults to true so existing sessions keep visible Agent Windows. */
focused?: boolean;
}
/** 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, opts: AgentWindowCreateOptions = {}): Promise<number> {
const win = await chrome.windows.create({
type: "normal",
focused: opts.focused ?? true,
url,
...(opts.size ? { width: opts.size.width, height: opts.size.height } : {}),
});
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> {
// Callers decide whether a missing/failed removal is benign. In
// particular, transactional session-start cleanup must be able to
// surface a window it could not remove instead of reporting a false
// cancellation success while the Agent Window remains open.
await chrome.windows.remove(windowId);
},
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,75 @@
import { handleSessionStop, type SessionStopDeps } from "@/tools/session";
import type { SessionManager } from "./manager";
export interface DisconnectCleanupFailure {
sessionId: string;
message: string;
}
export interface DisconnectCleanupReport {
stoppedSessionIds: string[];
failures: DisconnectCleanupFailure[];
}
export interface DisconnectCleanupOptions {
manager: SessionManager;
sessionStopDeps?: SessionStopDeps;
onSessionsChanged?: () => void;
}
/**
* Build a coalesced cleanup operation for transport loss.
*
* The daemon purges its registry when the extension socket disappears. To
* keep the extension-side mirror consistent, every local session must follow
* the normal safe stop path as well: return borrowed tabs, clear refs, detach
* CDP, and only then close the Agent Window.
*/
export function createDisconnectCleanup(options: DisconnectCleanupOptions) {
let inFlight: Promise<DisconnectCleanupReport> | null = null;
return (): Promise<DisconnectCleanupReport> => {
if (inFlight) return inFlight;
inFlight = cleanupSessions(options).finally(() => {
inFlight = null;
});
return inFlight;
};
}
async function cleanupSessions(
options: DisconnectCleanupOptions,
): Promise<DisconnectCleanupReport> {
const stoppedSessionIds: string[] = [];
const failures: DisconnectCleanupFailure[] = [];
for (const ctx of options.manager.list()) {
try {
const result = await handleSessionStop(
options.manager,
{ session_id: ctx.sessionId },
options.sessionStopDeps,
);
if ("code" in result) {
failures.push({ sessionId: ctx.sessionId, message: result.message });
continue;
}
if (result.return_failures?.length) {
failures.push({
sessionId: ctx.sessionId,
message: result.return_failures.map((failure) => failure.message).join("; "),
});
continue;
}
stoppedSessionIds.push(ctx.sessionId);
} catch (err) {
failures.push({
sessionId: ctx.sessionId,
message: err instanceof Error ? err.message : String(err),
});
}
}
options.onSessionsChanged?.();
return { stoppedSessionIds, failures };
}
@@ -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,239 @@
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;
}
/** Options for starting a session's Agent Window. */
export interface SessionStartOptions {
/** Optional Agent Window outer size in CSS pixels. */
size?: { width: number; height: number };
/** Defaults to true so existing clients keep visible Agent Windows. */
focused?: boolean;
/** Cancellation for the transactional Agent Window startup sequence. */
signal?: AbortSignal;
}
export class SessionStartCleanupError extends Error {
readonly windowId: number;
readonly startupError: unknown;
readonly cleanupError: unknown;
constructor(windowId: number, startupError: unknown, cleanupError: unknown) {
const startupMessage =
startupError instanceof Error ? startupError.message : String(startupError);
const cleanupMessage =
cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
super(
`session_start failed (${startupMessage}) and cleanup of Agent Window ${windowId} failed: ${cleanupMessage}`,
);
this.name = "SessionStartCleanupError";
this.windowId = windowId;
this.startupError = startupError;
this.cleanupError = cleanupError;
}
}
function sessionStartAbortError(): Error {
const error = new Error("session_start aborted");
error.name = "AbortError";
return error;
}
function throwIfSessionStartAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw sessionStartAbortError();
}
/**
* 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, opts: SessionStartOptions = {}): Promise<SessionContext> {
if (this.sessions.has(sessionId)) {
throw new Error(`[bh] session ${sessionId} already exists`);
}
throwIfSessionStartAborted(opts.signal);
let windowId: number | null = null;
try {
const { signal: _signal, ...createOptions } = opts;
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
throwIfSessionStartAborted(opts.signal);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
throwIfSessionStartAborted(opts.signal);
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;
} catch (startupError) {
if (windowId !== null) {
try {
await this.agentWindow.remove(windowId);
} catch (cleanupError) {
throw new SessionStartCleanupError(windowId, startupError, cleanupError);
}
}
throw startupError;
}
}
/**
* 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,113 @@
/**
* Per-session map from `@e<N>` snapshot refs to a CDP node address.
*
* Each fresh `tool.snapshot` resets the store: M6 will call
* `replace(...)` with the new ref → node address pairs. A node address
* includes the owning flat CDP session/frame when the element lives in
* an OOPIF; tools resolve live geometry from that identity at call time.
*
* 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;
frameId?: string;
cdpSessionId?: string;
generation: number;
}
export type RefInput =
| BackendNodeId
| {
backendNodeId: BackendNodeId;
tabId: number;
frameId?: string;
cdpSessionId?: string;
};
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 → CDP node identity 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;
frameId?: string;
cdpSessionId?: string;
} = {},
): void {
this.map.set(normaliseRef(ref), {
backendNodeId: id,
tabId: opts.tabId ?? null,
...(opts.frameId ? { frameId: opts.frameId } : {}),
...(opts.cdpSessionId ? { cdpSessionId: opts.cdpSessionId } : {}),
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,
...(input.frameId ? { frameId: input.frameId } : {}),
...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}),
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,625 @@
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("denies malformed or missing confirmation responses", async () => {
tabs.get.mockResolvedValue({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValue(
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
);
for (const response of [undefined, {}, { allowed: true }, { type: "borrow-response" }]) {
tabs.sendMessage.mockResolvedValueOnce(response);
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("waits for a notification decision and denies when every UI path times out", 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 safe timeout.
expect(resolved).toBe(false);
// The background timeout must resolve the request without authorizing it.
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
expect(await pending).toBe(false);
expect(resolvedValue).toBe(false);
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("denies 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(false);
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(options.requireInteraction).toBe(true);
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.
// A second call may arrive if the background timeout dismisses the overlay.
let resolveSend: (v: { type: string; allowed: boolean }) => void = () => undefined;
tabs.sendMessage.mockImplementationOnce(
() => new Promise((res) => (resolveSend = res as never)),
);
tabs.sendMessage.mockResolvedValue(undefined);
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 Promise.resolve();
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("denies 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));
tabs.sendMessage.mockResolvedValueOnce(undefined);
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
expect(await pending).toBe(false);
// Timeout must dismiss any in-flight overlay so Allow cannot linger.
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
type: string;
requestId: string;
};
expect(cancelMessage.type).toBe("borrow-cancel");
});
it("dismisses the pending overlay and denies 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(false);
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);
});
it("rejects the Edge Add-ons store", () => {
expect(
isInjectableContentScriptUrl(
"https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg",
),
).toBe(false);
// Non-store paths on the same host stay injectable.
expect(isInjectableContentScriptUrl("https://microsoftedge.microsoft.com/")).toBe(true);
});
});
@@ -0,0 +1,112 @@
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 cursor 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: 75,
max_text_chars: 1500,
include_stack: true,
},
deps,
);
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9);
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500, true);
});
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();
});
});

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