Compare commits

..
Author SHA1 Message Date
Ljy-0827 ec62b8f0f2 fix(bsk): fix ci 2026-08-21 16:34:06 +08:00
Ljy-0827 9393f0e58b feat(bsk): harden browser file transfer transactions 2026-08-21 16:31:47 +08:00
Ljy-0827 f9d395ee41 fix(bsk): fix ci 2026-08-21 14:32:29 +08:00
Ljy-0827 8f81c15656 fix(bsk): upload function safety 2026-08-21 14:11:13 +08:00
Ljy-0827 3cfa9eceef feat(bsk): fix upload function that cannot bypass layout 2026-08-21 11:14:51 +08:00
Ljy-0827 34f47d7387 feat(bsk): fix different type of upload 2026-08-20 18:59:22 +08:00
Ljy-0827 0a184e33d1 feat(bsk): support upload and download 2026-08-20 16:51:50 +08:00
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
195 changed files with 21469 additions and 8028 deletions
+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 }}"
-4
View File
@@ -17,7 +17,3 @@ dist/
# Environment # Environment
.env.local .env.local
.env.*.local .env.*.local
.pnpm-store/
# Local recording output
/trace/
+3
View File
@@ -3,3 +3,6 @@
**/.output/** **/.output/**
**/target/** **/target/**
**/node_modules/** **/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
+9 -5
View File
@@ -34,13 +34,17 @@ where only `extension connected` fails is expected; go to Step 3.
## 3. Open the extension install page ## 3. Open the extension install page
If `extension connected` is `FAIL` (`0 browsers connected`), the user likely If `extension connected` is `FAIL` (`0 browsers connected`), the user likely
has not installed or enabled the browser extension yet. Open the has not installed or enabled the browser extension yet. Open the install page
[Chrome Web Store install page](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) matching the user's browser —
in the user's browser, then tell the user: [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 > Install **BrowserSkill** from the
> [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi), > [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
> open the popup, and wait until it turns green. Reply when done. > (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` 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. once more. All `ok`/`na` → tell the user it's ready.
Generated
+1
View File
@@ -137,6 +137,7 @@ name = "bsk"
version = "0.1.10" version = "0.1.10"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"bsk-protocol", "bsk-protocol",
"clap", "clap",
"console", "console",
+1
View File
@@ -36,6 +36,7 @@ flate2 = "1.1.9"
tar = "0.4.46" tar = "0.4.46"
zip = { version = "7.2.0", default-features = false, features = ["deflate"] } zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
sha2 = "0.11.0" sha2 = "0.11.0"
base64 = "0.22"
tempfile = "3" tempfile = "3"
nix = { version = "0.29", features = ["signal", "process"] } nix = { version = "0.29", features = ["signal", "process"] }
rand = "0.8" rand = "0.8"
+37 -5
View File
@@ -13,7 +13,7 @@
</p> </p>
**BrowserSkill** connects Cursor, Claude Code, Codex, OpenClaw, CodeBuddy, **BrowserSkill** connects Cursor, Claude Code, Codex, OpenClaw, CodeBuddy,
WorkBuddy, Pi, Hermes Agent, and other shell-capable AI agents to your already logged-in WorkBuddy, Pi, Hermes Agent, DeepSeek Harness, and other AI agents to your already logged-in
browser. browser.
Need the agent to touch a tab you already have open? It must borrow that tab Need the agent to touch a tab you already have open? It must borrow that tab
@@ -67,7 +67,8 @@ Set up browser-skill on this machine by following https://raw.githubusercontent.
<br> <br>
Install the CLI, then install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi). 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 #### 1. Install the `bsk` CLI
@@ -91,7 +92,14 @@ bsk --version
#### 2. Install the browser extension #### 2. Install the browser extension
Install BrowserSkill from the [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi). 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 #### 3. Install the skill
@@ -123,7 +131,8 @@ internal variants and install paths.
Other shell-capable agent harnesses are supported too. Copy Other shell-capable agent harnesses are supported too. Copy
[`skill/SKILL.md`](skill/SKILL.md) into your harness's skills directory as [`skill/SKILL.md`](skill/SKILL.md) into your harness's skills directory as
`browser-skill/SKILL.md` to install the skill manually. `browser-skill/SKILL.md` to install the skill manually. DeepSeek Harness uses a
dedicated plugin instead — see [DeepSeek Harness plugin](#deepseek-harness-plugin).
</details> </details>
@@ -133,6 +142,26 @@ Start a new Agent session and write a prompt that needs the browser, for example
/browser-skill open example.com and summarize what is on the page. /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 ## How It Works
BrowserSkill is a local bridge between your agent harness and your browser. BrowserSkill is a local bridge between your agent harness and your browser.
@@ -166,7 +195,9 @@ flowchart TB
The agent never talks to the browser directly. It asks the `bsk` CLI to perform a 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 browser task; the local daemon routes that request to the extension; the
extension runs it in an Agent Window. 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 ## For Developers
@@ -176,6 +207,7 @@ The repository is a Cargo + pnpm workspace:
- `crates/bsk-protocol` — shared wire types and JSON schemas - `crates/bsk-protocol` — shared wire types and JSON schemas
- `apps/extension` — browser extension - `apps/extension` — browser extension
- `packages/ui` and `packages/i18n` — shared extension UI support - `packages/ui` and `packages/i18n` — shared extension UI support
- `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`)
## License ## License
+32 -7
View File
@@ -12,7 +12,7 @@
<a href="README.md">English</a> · 中文 <a href="README.md">English</a> · 中文
</p> </p>
**BrowserSkill** 把 Cursor、Claude Code、Codex、OpenClaw、CodeBuddy、WorkBuddy、Pi、Hermes Agent 等支持 Shell 的 AI Agent 连接到你已登录的浏览器。 **BrowserSkill** 把 Cursor、Claude Code、Codex、OpenClaw、CodeBuddy、WorkBuddy、Pi、Hermes Agent、DeepSeek Harness 等 AI Agent 连接到你已登录的浏览器。
需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。 需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。
@@ -54,7 +54,8 @@ BrowserSkill 由两个本地运行组件组成:`bsk` CLI/daemon 和浏览器
<br> <br>
先安装 CLI,再从 [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) 安装浏览器扩展。 先安装 CLI,再从 [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
或 [Edge 加载项商店](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg) 安装浏览器扩展。
#### 1. 安装 `bsk` CLI #### 1. 安装 `bsk` CLI
@@ -64,8 +65,11 @@ BrowserSkill 由两个本地运行组件组成:`bsk` CLI/daemon 和浏览器
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
``` ```
**Windows**:从 [最新 CLI release](https://github.com/Tencent/BrowserSkill/releases/latest) **Windows**(PowerShell,安装到 `~/.local/bin`):
下载 `bsk-v<version>-x86_64-pc-windows-msvc.zip`,解压后将 `bsk.exe` 加入 `PATH`。
```powershell
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
```
验证二进制: 验证二进制:
@@ -75,7 +79,14 @@ bsk --version
#### 2. 安装浏览器扩展 #### 2. 安装浏览器扩展
从 [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) 安装 BrowserSkill。 在对应浏览器的商店安装 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 #### 3. 安装 skill
@@ -102,7 +113,7 @@ bsk install-skill
用 <kbd>Space</kbd> 选择需要安装的 Agent harness,然后按 <kbd>Enter</kbd> 安装 skill。运行 `bsk install-skill --list` 可查看 internal 变体及安装路径。 用 <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`。 其他支持 Shell 的 Agent harness 也可使用 BrowserSkill,但需手动将 [`skill/SKILL.md`](skill/SKILL.md) 复制到对应 skills 目录下的 `browser-skill/SKILL.md`。DeepSeek Harness 走独立插件,见 [DeepSeek Harness 插件](#deepseek-harness-插件)。
</details> </details>
@@ -112,6 +123,19 @@ bsk install-skill
/browser-skill open example.com and summarize what is on the page. /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 运行时与浏览器之间的本地桥接层。 BrowserSkill 是 Agent 运行时与浏览器之间的本地桥接层。
@@ -143,7 +167,7 @@ flowchart TB
style UserWindows fill:#f8fafc,stroke:#cbd5e1,color:#334155 style UserWindows fill:#f8fafc,stroke:#cbd5e1,color:#334155
``` ```
Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;本地 daemon 把请求路由到扩展;扩展在 Agent Window 中执行。 Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;本地 daemon 把请求路由到扩展;扩展在 Agent Window 中执行。DeepSeek Harness 走同一条链路,只是经由 [插件](#deepseek-harness-插件):Agent 调用注入的 `browser_*` 工具,由插件代为执行 `bsk`。
## 面向开发者 ## 面向开发者
@@ -153,6 +177,7 @@ Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;
- `crates/bsk-protocol` — 共享协议类型与 JSON Schema - `crates/bsk-protocol` — 共享协议类型与 JSON Schema
- `apps/extension` — 浏览器扩展 - `apps/extension` — 浏览器扩展
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持 - `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`)
## 许可证 ## 许可证
+6 -3
View File
@@ -1,6 +1,6 @@
# BrowserSkill — Privacy Policy # BrowserSkill — Privacy Policy
**Last updated:** May 25, 2026 **Last updated:** August 20, 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. 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.
@@ -14,7 +14,7 @@ BrowserSkill is a local automation bridge that lets AI coding agents (such as Cu
## 2. Single Purpose ## 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. The Extension's single purpose is to expose browser automation primitives (navigation, DOM observation, screenshots, clicks, form filling, task-scoped file transfer, and 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 ## 3. Data the Extension Accesses
@@ -26,6 +26,7 @@ Depending on the commands the user (via their AI agent) sends to the local daemo
| **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. | | **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. | | **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. | | **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. |
| **File transfers requested by the agent** | Local files explicitly supplied to `bsk upload`, and the file created by a single `bsk download` action. | Required to attach a task file to a web page or return a browser-generated download to the invoking local agent. |
| **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. | | **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 ## 4. Data the Extension Does **Not** Collect
@@ -34,7 +35,7 @@ BrowserSkill does **not**:
- Send any data to remote servers, the Extension's authors, or any third party. - 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. - 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. - Read or transmit cookies, browsing history, bookmarks, saved passwords, or autofill data. It observes only the download initiated by an active `bsk download` call, not download history generally.
- Use webcam, microphone, geolocation, or any device sensor. - Use webcam, microphone, geolocation, or any device sensor.
- Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code. - Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code.
- Track users across websites or across sessions. - Track users across websites or across sessions.
@@ -50,6 +51,7 @@ The Extension requests the following Chrome permissions. Each is used solely for
- **`alarms`** — Periodically wake the service worker to keep the local WebSocket connection alive. - **`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. - **`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. - **`notifications`** — Show a system notification to obtain user approval before the agent borrows a user-owned tab.
- **`downloads`** — Correlate and route the one browser download initiated by an active `bsk download` command. If that claimed transaction fails, BrowserSkill cancels an in-progress file or removes its completed temporary browser file. It is not used to enumerate download history or alter unclaimed downloads.
- **`storage`** — Persist a random instance ID and optional label in `chrome.storage.local`. - **`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. - **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.
@@ -61,6 +63,7 @@ All Extension activity stays on the user's local device. The only network traffi
- The instance ID and optional label persist in `chrome.storage.local` until the user uninstalls the Extension or clears extension storage. - 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. - 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.
- Upload and download bytes are staged by the local daemon in a private, session-scoped directory. Download staging is removed after it is copied to the requested destination. Upload staging is retained until the session ends so a later form submission can still read the attached file. Remaining staging is removed when the session ends or disconnects, or when the daemon next starts after a crash.
## 8. User Control ## 8. User Control
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { type CdpDebuggerApi, ChromiumCdp } from "../chromium-cdp"; import { type CdpDebuggee, type CdpDebuggerApi, ChromiumCdp } from "../chromium-cdp";
function fakeChromeEvent<TArgs extends unknown[]>() { function fakeChromeEvent<TArgs extends unknown[]>() {
const listeners = new Set<(...args: TArgs) => void>(); const listeners = new Set<(...args: TArgs) => void>();
@@ -14,7 +14,7 @@ function fakeChromeEvent<TArgs extends unknown[]>() {
} }
function fakeApi() { function fakeApi() {
const onEvent = fakeChromeEvent<[chrome.debugger.Debuggee, string, unknown]>(); const onEvent = fakeChromeEvent<[CdpDebuggee, string, unknown]>();
const onDetach = fakeChromeEvent<[chrome.debugger.Debuggee, string]>(); const onDetach = fakeChromeEvent<[chrome.debugger.Debuggee, string]>();
const api: CdpDebuggerApi = { const api: CdpDebuggerApi = {
attach: vi.fn(async () => {}), attach: vi.fn(async () => {}),
@@ -29,6 +29,135 @@ function fakeApi() {
} }
describe("ChromiumCdp", () => { 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 () => { it("coalesces concurrent attach calls for the same tab", async () => {
const { api } = fakeApi(); const { api } = fakeApi();
let releaseAttach!: () => void; let releaseAttach!: () => void;
@@ -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 } },
]);
});
});
+211 -12
View File
@@ -31,6 +31,15 @@ import type {
NetworkEntryKind, NetworkEntryKind,
NetworkResult, NetworkResult,
} from "@/transport/types"; } from "@/transport/types";
import {
buildFrameGraph,
type CdpFrameGraph,
type CdpFrameTreeNode,
type CdpFrameTreeSource,
type CdpTarget,
} from "./frame-graph";
export type CdpDebuggee = chrome.debugger.Debuggee & { sessionId?: string };
/** /**
* Minimal slice of `chrome.debugger` the rest of the extension * Minimal slice of `chrome.debugger` the rest of the extension
@@ -38,21 +47,15 @@ import type {
* fake without monkey-patching the real `chrome` global. * fake without monkey-patching the real `chrome` global.
*/ */
export interface CdpDebuggerApi { export interface CdpDebuggerApi {
attach(target: chrome.debugger.Debuggee, requiredVersion: string): Promise<void>; attach(target: CdpDebuggee, requiredVersion: string): Promise<void>;
detach(target: chrome.debugger.Debuggee): Promise<void>; detach(target: CdpDebuggee): Promise<void>;
sendCommand( sendCommand(target: CdpDebuggee, method: string, commandParams?: object): Promise<unknown>;
target: chrome.debugger.Debuggee,
method: string,
commandParams?: object,
): Promise<unknown>;
/** /**
* Fires for every CDP event (`Page.lifecycleEvent`, `DOM.documentUpdated`, * Fires for every CDP event (`Page.lifecycleEvent`, `DOM.documentUpdated`,
* …). The first callback argument is the source debuggee; the second * …). The first callback argument is the source debuggee; the second
* is the CDP method name; the third is the payload. * is the CDP method name; the third is the payload.
*/ */
onEvent: chrome.events.Event< onEvent: chrome.events.Event<(source: CdpDebuggee, method: string, params: unknown) => void>;
(source: chrome.debugger.Debuggee, method: string, params: unknown) => void
>;
/** /**
* Fires when Chrome unilaterally detaches us — most commonly because * Fires when Chrome unilaterally detaches us — most commonly because
* the tab navigated to a chrome:// URL or the user clicked * the tab navigated to a chrome:// URL or the user clicked
@@ -110,6 +113,8 @@ const MAX_CONSOLE_STACK_FRAMES = 20;
const MAX_NETWORK_BUFFER = 200; const MAX_NETWORK_BUFFER = 200;
const MAX_NETWORK_FIELD_LENGTH = 4096; const MAX_NETWORK_FIELD_LENGTH = 4096;
const MAX_NETWORK_REQUEST_META = 1024; const MAX_NETWORK_REQUEST_META = 1024;
const FRAME_DISCOVERY_TIMEOUT_MS = 1000;
const FRAME_DISCOVERY_QUIET_MS = 20;
interface ParsedDialogOpening { interface ParsedDialogOpening {
type: JavaScriptDialogType; type: JavaScriptDialogType;
@@ -131,6 +136,27 @@ interface NetworkRequestMeta {
truncated: boolean; truncated: boolean;
} }
interface FrameDiscoveryState {
sessions: Set<string>;
pending: Set<Promise<void>>;
generation: number;
}
async function settleBeforeDeadline(promises: Promise<void>[], deadline: number): Promise<boolean> {
if (promises.length === 0) return true;
const remaining = deadline - Date.now();
if (remaining <= 0) return false;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
Promise.allSettled(promises).then(() => false),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), remaining);
}),
]);
if (timer) clearTimeout(timer);
return !timedOut;
}
/** /**
* Wrapper around `chrome.debugger` that owns the "attach once per * Wrapper around `chrome.debugger` that owns the "attach once per
* tabId" cache and exposes typed `send<T>()`. * tabId" cache and exposes typed `send<T>()`.
@@ -149,10 +175,12 @@ export class ChromiumCdp {
private readonly networkSequences = new Map<number, number>(); private readonly networkSequences = new Map<number, number>();
private readonly networkDomainsEnabledTabs = new Set<number>(); private readonly networkDomainsEnabledTabs = new Set<number>();
private readonly networkRequestMeta = new Map<number, Map<string, NetworkRequestMeta>>(); private readonly networkRequestMeta = new Map<number, Map<string, NetworkRequestMeta>>();
private readonly frameDiscovery = new Map<number, FrameDiscoveryState>();
private detachSubscription: { dispose(): void } | null = null; private detachSubscription: { dispose(): void } | null = null;
private dialogSubscription: { dispose(): void } | null = null; private dialogSubscription: { dispose(): void } | null = null;
private consoleSubscription: { dispose(): void } | null = null; private consoleSubscription: { dispose(): void } | null = null;
private networkSubscription: { dispose(): void } | null = null; private networkSubscription: { dispose(): void } | null = null;
private frameTargetSubscription: { dispose(): void } | null = null;
constructor(api: CdpDebuggerApi = chromeDebuggerApi) { constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
this.api = api; this.api = api;
@@ -160,6 +188,7 @@ export class ChromiumCdp {
this.bindDialogHandler(); this.bindDialogHandler();
this.bindConsoleHandler(); this.bindConsoleHandler();
this.bindNetworkHandler(); this.bindNetworkHandler();
this.bindFrameTargetHandler();
} }
/** Attach to `tabId` if we haven't already in this driver. */ /** Attach to `tabId` if we haven't already in this driver. */
@@ -177,6 +206,9 @@ export class ChromiumCdp {
await this.enableConsoleDomains(tabId); await this.enableConsoleDomains(tabId);
await this.enableNetworkDomainBestEffort(tabId); await this.enableNetworkDomainBestEffort(tabId);
this.attachedTabs.add(tabId); this.attachedTabs.add(tabId);
await this.enableFrameDiscovery({ tabId }).catch((err) => {
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
});
} catch (err) { } catch (err) {
// A CDP domain enable failed after the raw attach succeeded // A CDP domain enable failed after the raw attach succeeded
// (e.g. `Page.enable` rejects because the tab just navigated to // (e.g. `Page.enable` rejects because the tab just navigated to
@@ -222,6 +254,73 @@ export class ChromiumCdp {
} }
} }
async sendToTarget<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T> {
if (!this.attachedTabs.has(target.tabId)) {
await this.ensureAttached(target.tabId);
}
try {
return (await this.api.sendCommand(target, method, params ?? {})) as T;
} catch (err) {
throw normalizeError(err);
}
}
async getFrameGraph(tabId: number): Promise<CdpFrameGraph> {
await this.ensureAttached(tabId);
await this.enableFrameDiscovery({ tabId }).catch(() => {});
await this.drainFrameAttachTasks(tabId);
const sources: CdpFrameTreeSource[] = [];
const root = await this.sendToTarget<{ frameTree?: CdpFrameTreeNode }>(
{ tabId },
"Page.getFrameTree",
{},
);
if (root.frameTree) sources.push({ target: { tabId }, tree: root.frameTree });
const sessions = [...(this.frameDiscovery.get(tabId)?.sessions ?? [])];
const childTrees = await Promise.all(
sessions.map(async (sessionId): Promise<CdpFrameTreeSource | null> => {
const target = { tabId, sessionId };
try {
const reply = await this.sendToTarget<{ frameTree?: CdpFrameTreeNode }>(
target,
"Page.getFrameTree",
{},
);
return reply.frameTree ? { target, tree: reply.frameTree } : null;
} catch {
return null;
}
}),
);
for (const source of childTrees) {
if (source) sources.push(source);
}
const graph = buildFrameGraph(sources);
if (!graph) throw new Error("Page.getFrameTree returned no root frame");
const frameById = new Map(graph.frames.map((frame) => [frame.frameId, frame]));
await Promise.all(
graph.frames.map(async (frame) => {
if (!frame.parentFrameId) return;
const parent = frameById.get(frame.parentFrameId);
if (!parent) return;
try {
const owner = await this.sendToTarget<{ backendNodeId?: number }>(
parent.target,
"DOM.getFrameOwner",
{ frameId: frame.frameId },
);
if (owner.backendNodeId !== undefined) frame.ownerBackendNodeId = owner.backendNodeId;
} catch {
// The frame may have navigated between tree capture and owner lookup.
}
}),
);
return graph;
}
/** Return a cursor marking the current dialog sequence for `tabId`. */ /** Return a cursor marking the current dialog sequence for `tabId`. */
dialogCursor(tabId: number): DialogCursor { dialogCursor(tabId: number): DialogCursor {
return this.dialogSequences.get(tabId) ?? 0; return this.dialogSequences.get(tabId) ?? 0;
@@ -332,6 +431,7 @@ export class ChromiumCdp {
this.clearDialogState(tabId); this.clearDialogState(tabId);
this.clearConsoleState(tabId); this.clearConsoleState(tabId);
this.clearNetworkState(tabId); this.clearNetworkState(tabId);
this.clearFrameState(tabId);
try { try {
await this.api.detach({ tabId }); await this.api.detach({ tabId });
} catch (err) { } catch (err) {
@@ -354,7 +454,7 @@ export class ChromiumCdp {
} }
/** Subscribe to all CDP events. Returned disposable removes the listener. */ /** Subscribe to all CDP events. Returned disposable removes the listener. */
onEvent(handler: (source: chrome.debugger.Debuggee, method: string, params: unknown) => void): { onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
dispose(): void; dispose(): void;
} { } {
this.api.onEvent.addListener(handler); this.api.onEvent.addListener(handler);
@@ -378,6 +478,7 @@ export class ChromiumCdp {
this.networkSequences.clear(); this.networkSequences.clear();
this.networkDomainsEnabledTabs.clear(); this.networkDomainsEnabledTabs.clear();
this.networkRequestMeta.clear(); this.networkRequestMeta.clear();
this.frameDiscovery.clear();
await Promise.all( await Promise.all(
tabs.map(async (tabId) => { tabs.map(async (tabId) => {
try { try {
@@ -428,9 +529,104 @@ export class ChromiumCdp {
} }
} }
private async enableFrameDiscovery(target: CdpTarget): Promise<void> {
await this.api.sendCommand(target, "Target.setAutoAttach", {
autoAttach: true,
waitForDebuggerOnStart: false,
flatten: true,
filter: [{ type: "iframe", exclude: false }],
});
}
private bindFrameTargetHandler(): void {
if (this.frameTargetSubscription) return;
const listener = (source: CdpDebuggee, method: string, params: unknown) => {
const tabId = source.tabId;
if (typeof tabId !== "number") return;
const raw = (params ?? {}) as Record<string, unknown>;
const sessionId = typeof raw.sessionId === "string" ? raw.sessionId : undefined;
if (!sessionId) return;
if (method === "Target.detachedFromTarget") {
const state = this.frameDiscovery.get(tabId);
if (state?.sessions.delete(sessionId)) state.generation += 1;
return;
}
if (method !== "Target.attachedToTarget") return;
const targetInfo = raw.targetInfo as { type?: string } | undefined;
if (targetInfo?.type && targetInfo.type !== "iframe") return;
const state = this.frameDiscoveryState(tabId);
if (state.sessions.has(sessionId)) return;
state.sessions.add(sessionId);
state.generation += 1;
const task = this.initializeFrameTarget({ tabId, sessionId });
state.pending.add(task);
void task.finally(() => {
state.pending.delete(task);
});
};
this.api.onEvent.addListener(listener);
this.frameTargetSubscription = {
dispose: () => this.api.onEvent.removeListener(listener),
};
}
private async initializeFrameTarget(target: CdpTarget): Promise<void> {
try {
await this.enableFrameDiscovery(target);
} catch (err) {
const state = this.frameDiscovery.get(target.tabId);
if (state?.sessions.delete(target.sessionId as string)) state.generation += 1;
console.debug("[bsk cdp] child frame target initialization failed", { target, err });
}
}
private async drainFrameAttachTasks(tabId: number): Promise<void> {
const deadline = Date.now() + FRAME_DISCOVERY_TIMEOUT_MS;
while (Date.now() < deadline) {
const state = this.frameDiscovery.get(tabId);
const generation = state?.generation ?? 0;
if (state?.pending.size && !(await settleBeforeDeadline([...state.pending], deadline))) break;
// Target.setAutoAttach may enqueue the next attachedToTarget event after
// its command promise settles. Wait for a short quiet window, then finish
// only if neither the state object nor its generation changed.
const quietTime = Math.min(FRAME_DISCOVERY_QUIET_MS, deadline - Date.now());
if (quietTime <= 0) break;
await new Promise<void>((resolve) => setTimeout(resolve, quietTime));
const current = this.frameDiscovery.get(tabId);
if (
current === state &&
(current?.generation ?? 0) === generation &&
!current?.pending.size
) {
return;
}
}
console.debug("[bsk cdp] frame discovery did not reach quiescence before timeout", { tabId });
}
private frameDiscoveryState(tabId: number): FrameDiscoveryState {
const existing = this.frameDiscovery.get(tabId);
if (existing) return existing;
const created: FrameDiscoveryState = {
sessions: new Set(),
pending: new Set(),
generation: 0,
};
this.frameDiscovery.set(tabId, created);
return created;
}
private clearFrameState(tabId: number): void {
this.frameDiscovery.delete(tabId);
}
private bindDialogHandler(): void { private bindDialogHandler(): void {
if (this.dialogSubscription) return; if (this.dialogSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => { const listener = (source: CdpDebuggee, method: string, params: unknown) => {
if (method !== "Page.javascriptDialogOpening") return; if (method !== "Page.javascriptDialogOpening") return;
const tabId = source.tabId; const tabId = source.tabId;
if (typeof tabId !== "number") return; if (typeof tabId !== "number") return;
@@ -614,6 +810,7 @@ export class ChromiumCdp {
this.clearDialogState(source.tabId); this.clearDialogState(source.tabId);
this.clearConsoleState(source.tabId); this.clearConsoleState(source.tabId);
this.clearNetworkState(source.tabId); this.clearNetworkState(source.tabId);
this.clearFrameState(source.tabId);
} }
}; };
this.api.onDetach.addListener(listener); this.api.onDetach.addListener(listener);
@@ -632,6 +829,8 @@ export class ChromiumCdp {
this.consoleSubscription = null; this.consoleSubscription = null;
this.networkSubscription?.dispose(); this.networkSubscription?.dispose();
this.networkSubscription = null; this.networkSubscription = null;
this.frameTargetSubscription?.dispose();
this.frameTargetSubscription = null;
} }
/** Detach tabs only when no other live session has claimed them. */ /** Detach tabs only when no other live session has claimed them. */
@@ -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] : [];
}),
};
}
@@ -8,6 +8,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type { HelpHighlightRect } from "@/lib/help-bridge";
import logoUrl from "../../assets/logo.png"; import logoUrl from "../../assets/logo.png";
export interface HelpRequestData { export interface HelpRequestData {
@@ -19,6 +20,10 @@ export interface HelpRequestData {
displayMode?: "full" | "compact"; displayMode?: "full" | "compact";
/** CSS selectors to scroll to + flash-highlight. */ /** CSS selectors to scroll to + flash-highlight. */
selectors: string[]; 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; onContinue: (note: string) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -49,6 +54,7 @@ const PANEL_WIDTH = 420;
const FALLBACK_PANEL_H = 180; const FALLBACK_PANEL_H = 180;
const FALLBACK_PANEL_H_COLLAPSED = 44; const FALLBACK_PANEL_H_COLLAPSED = 44;
const EMPTY_SELECTORS: string[] = []; const EMPTY_SELECTORS: string[] = [];
const EMPTY_RECTS: HelpHighlightRect[] = [];
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
@@ -179,6 +185,8 @@ export function HelpRequestOverlay({ request }: Props) {
const { t } = useTranslation("extension"); const { t } = useTranslation("extension");
const isCompact = request?.displayMode === "compact"; const isCompact = request?.displayMode === "compact";
const effectiveSelectors = isCompact ? EMPTY_SELECTORS : (request?.selectors ?? EMPTY_SELECTORS); 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 [note, setNote] = useState("");
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [boxes, setBoxes] = useState<Box[]>([]); const [boxes, setBoxes] = useState<Box[]>([]);
@@ -201,6 +209,7 @@ export function HelpRequestOverlay({ request }: Props) {
// Reset the note and collapse state whenever a new request appears. // Reset the note and collapse state whenever a new request appears.
useEffect(() => { useEffect(() => {
setNote(""); setNote("");
setLiveRects(null);
setCollapsed(false); setCollapsed(false);
placementRef.current = null; placementRef.current = null;
setDragPos(null); setDragPos(null);
@@ -209,6 +218,32 @@ export function HelpRequestOverlay({ request }: Props) {
dragStartRef.current = null; dragStartRef.current = null;
}, [request?.id]); }, [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. // Scroll the first matched target into view once per distinct request id.
useLayoutEffect(() => { useLayoutEffect(() => {
if (!request) return; if (!request) return;
@@ -231,7 +266,7 @@ export function HelpRequestOverlay({ request }: Props) {
return; return;
} }
const update = () => { const update = () => {
const nextBoxes = measure(effectiveSelectors); const nextBoxes = [...measure(effectiveSelectors), ...effectiveRects];
setBoxes((prev) => (boxesEqual(prev, nextBoxes) ? prev : nextBoxes)); setBoxes((prev) => (boxesEqual(prev, nextBoxes) ? prev : nextBoxes));
}; };
update(); update();
@@ -243,7 +278,7 @@ export function HelpRequestOverlay({ request }: Props) {
window.removeEventListener("resize", update); window.removeEventListener("resize", update);
window.clearInterval(interval); window.clearInterval(interval);
}; };
}, [request, effectiveSelectors]); }, [request, effectiveSelectors, effectiveRects]);
const onHeaderPointerDown = useCallback( const onHeaderPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => { (e: ReactPointerEvent<HTMLDivElement>) => {
@@ -305,7 +340,7 @@ export function HelpRequestOverlay({ request }: Props) {
return; return;
} }
if (userMovedRef.current) return; if (userMovedRef.current) return;
const measured = boxes.length > 0 ? boxes : measure(effectiveSelectors); const measured = boxes.length > 0 ? boxes : [...measure(effectiveSelectors), ...effectiveRects];
const anchor = unionBox(measured); const anchor = unionBox(measured);
const banner = bannerRef.current; const banner = bannerRef.current;
if (!anchor || !banner) { if (!anchor || !banner) {
@@ -329,7 +364,7 @@ export function HelpRequestOverlay({ request }: Props) {
} }
const nextPos = placePanel(anchor, panelW, panelH, placementRef.current.placement); const nextPos = placePanel(anchor, panelW, panelH, placementRef.current.placement);
setPanelPos((prev) => (panelPosEqual(prev, nextPos) ? prev : nextPos)); setPanelPos((prev) => (panelPosEqual(prev, nextPos) ? prev : nextPos));
}, [request, boxes, collapsed, isCompact, effectiveSelectors]); }, [request, boxes, collapsed, isCompact, effectiveSelectors, effectiveRects]);
if (!request) return null; if (!request) return null;
+3 -28
View File
@@ -1,5 +1,4 @@
import { useTranslation } from "@browser-skill/i18n/react"; import { useTranslation } from "@browser-skill/i18n/react";
import type { FrameCaptureFailure, FrameCaptureStatus } from "@/transport/types";
import { import {
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
useCallback, useCallback,
@@ -22,8 +21,6 @@ export interface RecordRequestData {
type Props = { type Props = {
request: RecordRequestData | null; request: RecordRequestData | null;
captureStatus?: FrameCaptureStatus;
captureFailures?: FrameCaptureFailure[];
}; };
const VIEWPORT_MARGIN = 16; const VIEWPORT_MARGIN = 16;
@@ -55,11 +52,7 @@ function clampDragPos(
}; };
} }
export function RecordOverlay({ export function RecordOverlay({ request }: Props) {
request,
captureStatus = "complete",
captureFailures = [],
}: Props) {
const { t } = useTranslation("extension"); const { t } = useTranslation("extension");
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0); const [elapsedSeconds, setElapsedSeconds] = useState(0);
@@ -161,7 +154,6 @@ export function RecordOverlay({
const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS; const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS;
const positioned = dragPos !== null; const positioned = dragPos !== null;
const partialCapture = captureStatus === "partial" || captureFailures.length > 0;
const defaultTransform = show const defaultTransform = show
? "translateX(-50%) translateY(0)" ? "translateX(-50%) translateY(0)"
: "translateX(-50%) translateY(8px)"; : "translateX(-50%) translateY(8px)";
@@ -216,9 +208,8 @@ export function RecordOverlay({
data-slot="record-overlay-status" data-slot="record-overlay-status"
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", alignItems: "center",
alignItems: "flex-start", gap: 8,
gap: 2,
minWidth: 0, minWidth: 0,
flex: 1, flex: 1,
borderRadius: 9999, borderRadius: 9999,
@@ -227,7 +218,6 @@ export function RecordOverlay({
paddingRight: 4, paddingRight: 4,
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0, width: "100%" }}>
<span <span
data-slot="record-overlay-indicator" data-slot="record-overlay-indicator"
style={{ style={{
@@ -264,21 +254,6 @@ export function RecordOverlay({
{introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)} {introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)}
</span> </span>
</div> </div>
{partialCapture ? (
<span
data-slot="record-overlay-partial-warning"
style={{
fontSize: 12,
lineHeight: "16px",
color: "#b45309",
whiteSpace: "normal",
maxWidth: 280,
}}
>
{t("recordOverlay.partialCapture")}
</span>
) : null}
</div>
<button <button
type="button" type="button"
data-slot="record-overlay-finish" data-slot="record-overlay-finish"
@@ -1,6 +1,6 @@
import { i18n } from "@browser-skill/i18n"; import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react"; import { I18nextProvider } from "@browser-skill/i18n/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createElement } from "react"; import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay"; import { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay";
@@ -38,6 +38,36 @@ describe("HelpRequestOverlay", () => {
expect(screen.getByText("Please complete the captcha")).toBeTruthy(); 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", () => { it("keeps the inactive render stable", () => {
const { container, rerender } = renderOverlay(null); const { container, rerender } = renderOverlay(null);
@@ -178,30 +178,4 @@ describe("RecordOverlay", () => {
expect(pill.style.bottom).toBe("auto"); expect(pill.style.bottom).toBe("auto");
expect(pill.style.transform).toBe("none"); expect(pill.style.transform).toBe("none");
}); });
it("shows a persistent partial-capture warning when frame capture is incomplete", () => {
const { container } = render(
createElement(
I18nextProvider,
{ i18n },
createElement(RecordOverlay, {
request: { id: "rec-1", onFinish: vi.fn() },
captureStatus: "partial",
captureFailures: [
{
reason: "not_injectable",
frame_id: 2,
url: "chrome://settings",
},
],
}),
),
);
const warning = container.querySelector("[data-slot='record-overlay-partial-warning']");
expect(warning).toBeTruthy();
expect(warning?.textContent).toBe(
i18n.t("recordOverlay.partialCapture", { ns: "extension" }),
);
});
}); });
@@ -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();
});
});
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { RECORD_START, RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge"; import { RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge";
import { handleRecordContentMessage, startRecordCapture } from "../record-capture"; import { handleRecordContentMessage, startRecordCapture } from "../record-capture";
vi.stubGlobal("chrome", { vi.stubGlobal("chrome", {
@@ -96,65 +96,6 @@ describe("handleRecordContentMessage stop/cancel", () => {
expect(dispose).not.toHaveBeenCalled(); expect(dispose).not.toHaveBeenCalled();
expect(onStop).not.toHaveBeenCalled(); expect(onStop).not.toHaveBeenCalled();
}); });
it("keeps a failed STOP retryable and redelivers its recorded step", async () => {
document.body.innerHTML = `<label for="retry">Draft</label><input id="retry" />`;
const sendMessage = vi.mocked(chrome.runtime.sendMessage);
sendMessage.mockReset();
sendMessage.mockRejectedValueOnce(new Error("service worker unavailable"));
sendMessage.mockResolvedValueOnce(undefined);
let activeRequestId: string | null = null;
let capture: ReturnType<typeof startRecordCapture> | null = null;
const onStop = vi.fn();
const dispatch = (
message:
| { type: typeof RECORD_START; requestId: string; startedAtMs?: number }
| { type: typeof RECORD_STOP; requestId: string },
sendResponse?: (response: unknown) => void,
) =>
handleRecordContentMessage(
message,
{
activeRequestId,
capture,
setActiveRequestId: (id) => {
activeRequestId = id;
},
setCapture: (next) => {
capture = next;
},
onStart: vi.fn(),
onStop,
},
sendResponse,
);
dispatch({ type: RECORD_START, requestId: "rec-retry" });
const input = document.querySelector("input")!;
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.value = "dirty final value";
input.dispatchEvent(new Event("input", { bubbles: true }));
const firstResponse = vi.fn();
expect(dispatch({ type: RECORD_STOP, requestId: "rec-retry" }, firstResponse)).toBe(true);
await vi.waitFor(() =>
expect(firstResponse).toHaveBeenCalledWith({
ok: false,
error: "failed to deliver one or more recorded steps",
}),
);
expect(activeRequestId).toBe("rec-retry");
expect(onStop).not.toHaveBeenCalled();
const retryResponse = vi.fn();
expect(dispatch({ type: RECORD_STOP, requestId: "rec-retry" }, retryResponse)).toBe(true);
await vi.waitFor(() => expect(retryResponse).toHaveBeenCalledWith({ ok: true }));
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(activeRequestId).toBeNull();
expect(onStop).toHaveBeenCalledTimes(1);
});
}); });
describe("record-capture semantic", () => { describe("record-capture semantic", () => {
@@ -287,7 +228,6 @@ describe("record-capture semantic", () => {
expect(steps[0]).toMatchObject({ expect(steps[0]).toMatchObject({
op: "hover", op: "hover",
target: { role: "button", name: "Open user navigation menu" }, target: { role: "button", name: "Open user navigation menu" },
geometry: { tag: "button", rect: { x: 900, y: 8, w: 32, h: 32 } },
}); });
expect(steps[1]).toMatchObject({ expect(steps[1]).toMatchObject({
op: "click", op: "click",
@@ -1614,79 +1554,3 @@ describe("record-capture semantic", () => {
expect(steps.map((s) => s.op)).toEqual(["click"]); expect(steps.map((s) => s.op)).toEqual(["click"]);
}); });
}); });
describe("record-capture child frame", () => {
let steps: RecordStepPayload[];
beforeEach(() => {
steps = [];
document.body.innerHTML = `
<label for="child-q">Child query</label>
<input id="child-q" name="child-q" />
<button type="button" aria-label="Child search">Search</button>
<select aria-label="Child status">
<option value="on">On</option>
<option value="off">Off</option>
</select>
`;
});
it("stamps a document token on start and clears it on dispose", () => {
const capture = startRecordCapture("rec-child-token", (step) => steps.push(step), {
frameMode: "child",
});
const token = document.documentElement.getAttribute("data-bsk-record-document");
expect(token).toBeTruthy();
click(document.querySelector("button")!);
expect(steps[0]?.documentToken).toBe(token);
expect(steps[0]?.frame_url).toBeTruthy();
capture.dispose();
expect(document.documentElement.hasAttribute("data-bsk-record-document")).toBe(false);
});
it("records click, fill, select, and press in child frame mode", () => {
const capture = startRecordCapture("rec-child-ops", (step) => steps.push(step), {
frameMode: "child",
});
const input = document.querySelector("input")!;
const select = document.querySelector("select")!;
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.value = "child value";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
select.value = "off";
select.dispatchEvent(new Event("change", { bubbles: true }));
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
click(document.querySelector("button")!);
capture.dispose();
expect(steps.map((s) => s.op)).toEqual(
expect.arrayContaining(["fill", "select", "press", "click"]),
);
expect(steps.every((s) => s.documentToken)).toBe(true);
});
it("commits the last fill when dispose runs in child frame mode", () => {
const capture = startRecordCapture("rec-child-fill-stop", (step) => steps.push(step), {
frameMode: "child",
});
const input = document.querySelector("input")!;
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.value = "pending child fill";
input.dispatchEvent(new Event("input", { bubbles: true }));
capture.dispose();
expect(steps.some((s) => s.op === "fill" && s.value === "pending child fill")).toBe(true);
});
it("does not emit navigate steps for child frame navigations", () => {
const capture = startRecordCapture("rec-child-nav", (step) => steps.push(step), {
frameMode: "child",
});
history.pushState({}, "", "/child-frame-path");
capture.dispose();
expect(steps.filter((s) => s.op === "navigate")).toHaveLength(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"),
};
}
+30 -136
View File
@@ -1,9 +1,9 @@
import { import {
type CaptureTargetDescriptor,
describeEventTarget, describeEventTarget,
describeTarget, describeTarget,
resolveClickableElement, resolveClickableElement,
resolveHoverElement, resolveHoverElement,
type TargetDescriptor,
} from "@/lib/describe-target"; } from "@/lib/describe-target";
import { import {
evaluateHoverTrigger, evaluateHoverTrigger,
@@ -21,18 +21,12 @@ import {
RECORD_STEP, RECORD_STEP,
RECORD_STOP, RECORD_STOP,
type RecordCancelMessage, type RecordCancelMessage,
type RecordFrameMode,
type RecordStartAck, type RecordStartAck,
type RecordStartMessage, type RecordStartMessage,
type RecordStepPayload, type RecordStepPayload,
type RecordStopAck, type RecordStopAck,
type RecordStopMessage, type RecordStopMessage,
} from "@/lib/record-bridge"; } from "@/lib/record-bridge";
import {
clearRecordDocumentToken,
createRecordDocumentToken,
stampRecordDocumentToken,
} from "@/lib/record-document-token";
import { shouldRecordPress } from "@/lib/trace-reducer"; import { shouldRecordPress } from "@/lib/trace-reducer";
import { import {
closestHoverSurfaceCandidate, closestHoverSurfaceCandidate,
@@ -45,26 +39,17 @@ import {
const pendingStepSends = new Map<string, Set<Promise<boolean>>>(); const pendingStepSends = new Map<string, Set<Promise<boolean>>>();
const failedStepDeliveries = new Set<string>(); const failedStepDeliveries = new Set<string>();
const failedStepPayloads = new Map<string, RecordStepPayload[]>();
const pendingStopFlushes = new Map<string, Promise<RecordStopAck>>();
const knownRecordRequests = new Set<string>(); const knownRecordRequests = new Set<string>();
function deliverRecordStep(requestId: string, step: RecordStepPayload): Promise<boolean> { function sendRecordStep(requestId: string, step: RecordStepPayload): void {
const payload = { type: RECORD_STEP, requestId, step }; const payload = { type: RECORD_STEP, requestId, step };
return Promise.resolve(chrome.runtime.sendMessage(payload)).then( const pending = Promise.resolve(chrome.runtime.sendMessage(payload)).then(
() => true, () => true,
() => { () => {
failedStepDeliveries.add(requestId); failedStepDeliveries.add(requestId);
const failed = failedStepPayloads.get(requestId) ?? [];
failed.push(step);
failedStepPayloads.set(requestId, failed);
return false; return false;
}, },
); );
}
function sendRecordStep(requestId: string, step: RecordStepPayload): void {
const pending = deliverRecordStep(requestId, step);
const sends = pendingStepSends.get(requestId) ?? new Set<Promise<boolean>>(); const sends = pendingStepSends.get(requestId) ?? new Set<Promise<boolean>>();
sends.add(pending); sends.add(pending);
pendingStepSends.set(requestId, sends); pendingStepSends.set(requestId, sends);
@@ -80,15 +65,14 @@ export interface RecordCaptureController {
interface FillSession { interface FillSession {
element: FillableElement; element: FillableElement;
target: CaptureTargetDescriptor; target: TargetDescriptor;
baselineValue: string; baselineValue: string;
lastValue: string; lastValue: string;
pendingCommit?: "enter" | "suggestion" | "blur";
} }
interface HoverCandidate { interface HoverCandidate {
element: Element; element: Element;
target: CaptureTargetDescriptor; target: TargetDescriptor;
recordedAt: number; recordedAt: number;
score: number; score: number;
eligible: boolean; eligible: boolean;
@@ -173,28 +157,6 @@ function nearbyFillableFromSearchChrome(target: Element): FillableElement | null
return null; return null;
} }
function captureGeometry(el: Element): RecordStepPayload["geometry"] {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return {
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
scrollX: window.scrollX,
scrollY: window.scrollY,
position: style.position,
tag: el.tagName.toLowerCase(),
};
}
function geometryForEventTarget(
target: EventTarget | null,
): RecordStepPayload["geometry"] | undefined {
if (!(target instanceof Element)) return undefined;
const clickable = target.closest(
'a,button,input,select,textarea,[role="button"],[role="link"],[role="menuitem"],[contenteditable="true"]',
);
return captureGeometry(clickable ?? target);
}
function fillableValue(el: FillableElement): string { function fillableValue(el: FillableElement): string {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
return el.value; return el.value;
@@ -270,7 +232,7 @@ function collectHoverTriggerLabelText(root: Element): string {
return normalizeLabelText(text); return normalizeLabelText(text);
} }
function compactHoverTargetName(el: Element, desc: CaptureTargetDescriptor): CaptureTargetDescriptor { function compactHoverTargetName(el: Element, desc: TargetDescriptor): TargetDescriptor {
if (!desc.name) return desc; if (!desc.name) return desc;
const fullText = normalizeLabelText(el.textContent ?? ""); const fullText = normalizeLabelText(el.textContent ?? "");
const compactName = collectHoverTriggerLabelText(el); const compactName = collectHoverTriggerLabelText(el);
@@ -288,7 +250,7 @@ function compactHoverTargetName(el: Element, desc: CaptureTargetDescriptor): Cap
return desc; return desc;
} }
function isWeakHoverTarget(target: CaptureTargetDescriptor): boolean { function isWeakHoverTarget(target: TargetDescriptor): boolean {
return !target.role && !target.name && target.tag === "div"; return !target.role && !target.name && target.tag === "div";
} }
@@ -299,9 +261,9 @@ function looksLikeAvatarElement(el: Element): boolean {
function normalizeHoverTarget( function normalizeHoverTarget(
el: Element, el: Element,
desc: CaptureTargetDescriptor, desc: TargetDescriptor,
decision: HoverTriggerDecision, decision: HoverTriggerDecision,
): CaptureTargetDescriptor { ): TargetDescriptor {
if (desc.role === "img" && !desc.name && looksLikeAvatarElement(el)) { if (desc.role === "img" && !desc.name && looksLikeAvatarElement(el)) {
return { ...desc, name: "image" }; return { ...desc, name: "image" };
} }
@@ -315,7 +277,7 @@ function normalizeHoverTarget(
return desc; return desc;
} }
function hoverTriggerSignals(el: Element, desc: CaptureTargetDescriptor) { function hoverTriggerSignals(el: Element, desc: TargetDescriptor) {
if (!(el instanceof HTMLElement)) return null; if (!(el instanceof HTMLElement)) return null;
const style = hoverTriggerStyle(el); const style = hoverTriggerStyle(el);
return { return {
@@ -408,37 +370,12 @@ function scheduleInputCompletionCommit(
setTimeout(syncAndCommit, 0); setTimeout(syncAndCommit, 0);
} }
export interface RecordCaptureOptions {
frameMode?: RecordFrameMode;
}
export function startRecordCapture( export function startRecordCapture(
_requestId: string, _requestId: string,
sendStep: (step: RecordStepPayload) => void, sendStep: (step: RecordStepPayload) => void,
options: RecordCaptureOptions = {},
): RecordCaptureController { ): RecordCaptureController {
const frameMode = options.frameMode ?? "top";
const emitNavigate = frameMode === "top";
const documentToken = createRecordDocumentToken();
stampRecordDocumentToken(documentToken);
const capturedAtMs = () => Date.now();
const resolveTopPageUrl = (): string => {
try {
return window.top?.location.href ?? location.href;
} catch {
return location.href;
}
};
const emitStep = (step: RecordStepPayload) => { const emitStep = (step: RecordStepPayload) => {
sendStep({ sendStep({ page_url: location.href, ...step });
page_url: resolveTopPageUrl(),
frame_url: location.href,
documentToken,
capturedAtMs: capturedAtMs(),
...step,
});
}; };
const hoverSurfaceStateMap = (states: HoverSurfaceState[]): Map<Element, string> => const hoverSurfaceStateMap = (states: HoverSurfaceState[]): Map<Element, string> =>
new Map(states.map((state) => [state.element, state.signature])); new Map(states.map((state) => [state.element, state.signature]));
@@ -474,17 +411,14 @@ export function startRecordCapture(
emitStep({ emitStep({
op: "fill", op: "fill",
target: session.target, target: session.target,
geometry: captureGeometry(session.element),
value, value,
commit: session.pendingCommit ?? "blur",
...(isPassword ? { redacted: true } : {}), ...(isPassword ? { redacted: true } : {}),
}); });
}; };
const commitFillSession = (commit: "enter" | "suggestion" | "blur" = "blur") => { const commitFillSession = () => {
if (!fillSession || composing) return; if (!fillSession || composing) return;
const session = fillSession; const session = fillSession;
session.pendingCommit = commit;
fillSession = null; fillSession = null;
emitFill(session); emitFill(session);
committedValues.set(session.element, session.lastValue); committedValues.set(session.element, session.lastValue);
@@ -509,7 +443,6 @@ export function startRecordCapture(
}; };
const emitNavigateIfChanged = (causedByAction?: boolean) => { const emitNavigateIfChanged = (causedByAction?: boolean) => {
if (!emitNavigate) return;
if (location.href === lastUrl) return; if (location.href === lastUrl) return;
commitFillSession(); commitFillSession();
lastUrl = location.href; lastUrl = location.href;
@@ -691,7 +624,6 @@ export function startRecordCapture(
emitStep({ emitStep({
op: "hover", op: "hover",
target: hover.target, target: hover.target,
geometry: captureGeometry(hover.element),
}); });
emittedHoverElements.add(hover.element); emittedHoverElements.add(hover.element);
}; };
@@ -746,7 +678,6 @@ export function startRecordCapture(
emitStep({ emitStep({
op: "click", op: "click",
target, target,
geometry: geometryForEventTarget(eventTarget(event)),
expects_navigation: true, expects_navigation: true,
}); });
}; };
@@ -848,7 +779,7 @@ export function startRecordCapture(
scheduleInputCompletionCommit( scheduleInputCompletionCommit(
sessionElement, sessionElement,
syncFillSessionValue, syncFillSessionValue,
() => commitFillSession("suggestion"), commitFillSession,
(el) => fillSession?.element === el, (el) => fillSession?.element === el,
); );
return; return;
@@ -908,7 +839,6 @@ export function startRecordCapture(
emitStep({ emitStep({
op: "select", op: "select",
target: desc, target: desc,
geometry: captureGeometry(target),
values, values,
labels, labels,
expects_navigation: true, expects_navigation: true,
@@ -949,7 +879,7 @@ export function startRecordCapture(
}; };
} }
if (fillable) { if (fillable) {
commitFillSession(event.key === "Enter" ? "enter" : "blur"); commitFillSession();
} }
const desc = describeEventTarget(target); const desc = describeEventTarget(target);
if (!desc && !event.key) return; if (!desc && !event.key) return;
@@ -957,7 +887,7 @@ export function startRecordCapture(
emitStep({ emitStep({
op: "press", op: "press",
key: event.key, key: event.key,
...(desc ? { target: desc, geometry: geometryForEventTarget(target) } : {}), ...(desc ? { target: desc } : {}),
...(modifiers.length ? { modifiers } : {}), ...(modifiers.length ? { modifiers } : {}),
expects_navigation: event.key === "Enter", expects_navigation: event.key === "Enter",
}); });
@@ -976,12 +906,9 @@ export function startRecordCapture(
const urlObserver = new MutationObserver(() => emitNavigateIfChanged()); const urlObserver = new MutationObserver(() => emitNavigateIfChanged());
urlObserver.observe(document, { subtree: true, childList: true }); urlObserver.observe(document, { subtree: true, childList: true });
const onUrlEvent = () => emitNavigateIfChanged(); const onUrlEvent = () => emitNavigateIfChanged();
// Wrap: passing commitFillSession directly would forward the DOM event as
// the `commit` argument and stamp it onto the recorded fill step.
const onPageHide = () => commitFillSession();
window.addEventListener("hashchange", onUrlEvent); window.addEventListener("hashchange", onUrlEvent);
window.addEventListener("popstate", onUrlEvent); window.addEventListener("popstate", onUrlEvent);
window.addEventListener("pagehide", onPageHide); window.addEventListener("pagehide", commitFillSession);
const originalPushState = history.pushState; const originalPushState = history.pushState;
const originalReplaceState = history.replaceState; const originalReplaceState = history.replaceState;
@@ -997,7 +924,6 @@ export function startRecordCapture(
return { return {
dispose() { dispose() {
commitFillSession(); commitFillSession();
clearRecordDocumentToken();
document.removeEventListener("click", onClick, true); document.removeEventListener("click", onClick, true);
document.removeEventListener("mouseover", onMouseOver, true); document.removeEventListener("mouseover", onMouseOver, true);
document.removeEventListener("focusin", onFocusIn, true); document.removeEventListener("focusin", onFocusIn, true);
@@ -1010,7 +936,7 @@ export function startRecordCapture(
urlObserver.disconnect(); urlObserver.disconnect();
window.removeEventListener("hashchange", onUrlEvent); window.removeEventListener("hashchange", onUrlEvent);
window.removeEventListener("popstate", onUrlEvent); window.removeEventListener("popstate", onUrlEvent);
window.removeEventListener("pagehide", onPageHide); window.removeEventListener("pagehide", commitFillSession);
history.pushState = originalPushState; history.pushState = originalPushState;
history.replaceState = originalReplaceState; history.replaceState = originalReplaceState;
}, },
@@ -1030,8 +956,8 @@ export function handleRecordContentMessage(
capture: RecordCaptureController | null; capture: RecordCaptureController | null;
setActiveRequestId(id: string | null): void; setActiveRequestId(id: string | null): void;
setCapture(capture: RecordCaptureController | null): void; setCapture(capture: RecordCaptureController | null): void;
onStart?(requestId: string, startedAtMs?: number): void; onStart(requestId: string, startedAtMs?: number): void;
onStop?(): void; onStop(): void;
}, },
sendResponse?: (response: RecordStartAck | RecordStopAck) => void, sendResponse?: (response: RecordStartAck | RecordStopAck) => void,
): boolean { ): boolean {
@@ -1039,23 +965,15 @@ export function handleRecordContentMessage(
if (!knownRecordRequests.has(message.requestId)) { if (!knownRecordRequests.has(message.requestId)) {
knownRecordRequests.add(message.requestId); knownRecordRequests.add(message.requestId);
failedStepDeliveries.delete(message.requestId); failedStepDeliveries.delete(message.requestId);
failedStepPayloads.delete(message.requestId);
} }
state.capture?.dispose(); state.capture?.dispose();
const frameMode = message.frameMode ?? "top";
state.setCapture( state.setCapture(
startRecordCapture( startRecordCapture(message.requestId, (step) => {
message.requestId,
(step) => {
sendRecordStep(message.requestId, step); sendRecordStep(message.requestId, step);
}, }),
{ frameMode },
),
); );
state.setActiveRequestId(message.requestId); state.setActiveRequestId(message.requestId);
if (message.showOverlay !== false && frameMode === "top") { state.onStart(message.requestId, message.startedAtMs);
state.onStart?.(message.requestId, message.startedAtMs);
}
sendResponse?.({ ok: true }); sendResponse?.({ ok: true });
return sendResponse !== undefined; return sendResponse !== undefined;
} }
@@ -1070,55 +988,31 @@ export function handleRecordContentMessage(
state.capture?.dispose(); state.capture?.dispose();
state.setCapture(null); state.setCapture(null);
const finishStop = () => { const finishStop = () => {
state.onStop?.(); state.onStop();
state.setActiveRequestId(null); state.setActiveRequestId(null);
}; };
if (isRecordStopMessage(message) && sendResponse) { if (isRecordStopMessage(message) && sendResponse) {
const existingFlush = pendingStopFlushes.get(message.requestId);
if (existingFlush) {
void existingFlush.then(sendResponse);
return true;
}
const failedBeforeStop = [...(failedStepPayloads.get(message.requestId) ?? [])];
if (failedBeforeStop.length > 0) {
failedStepPayloads.delete(message.requestId);
failedStepDeliveries.delete(message.requestId);
}
const pending = [...(pendingStepSends.get(message.requestId) ?? [])]; const pending = [...(pendingStepSends.get(message.requestId) ?? [])];
const flush = Promise.all(pending).then(async (delivered): Promise<RecordStopAck> => { void Promise.all(pending).then((delivered) => {
const retried = await Promise.all( finishStop();
failedBeforeStop.map((step) => deliverRecordStep(message.requestId, step)), const succeeded = delivered.every(Boolean) && !failedStepDeliveries.has(message.requestId);
);
const succeeded =
delivered.every(Boolean) &&
retried.every(Boolean) &&
!failedStepDeliveries.has(message.requestId);
if (succeeded) { if (succeeded) {
failedStepDeliveries.delete(message.requestId); failedStepDeliveries.delete(message.requestId);
failedStepPayloads.delete(message.requestId);
knownRecordRequests.delete(message.requestId); knownRecordRequests.delete(message.requestId);
finishStop();
} }
return succeeded sendResponse(
succeeded
? { ok: true } ? { ok: true }
: { : {
ok: false, ok: false,
error: "failed to deliver one or more recorded steps", error: "failed to deliver one or more recorded steps",
}; },
}); );
pendingStopFlushes.set(message.requestId, flush);
void flush.then(sendResponse).finally(() => {
if (pendingStopFlushes.get(message.requestId) === flush) {
pendingStopFlushes.delete(message.requestId);
}
}); });
return true; return true;
} }
if (isRecordCancelMessage(message)) { if (isRecordCancelMessage(message)) {
failedStepDeliveries.delete(message.requestId); failedStepDeliveries.delete(message.requestId);
failedStepPayloads.delete(message.requestId);
pendingStopFlushes.delete(message.requestId);
knownRecordRequests.delete(message.requestId); knownRecordRequests.delete(message.requestId);
} }
finishStop(); finishStop();
+1 -23
View File
@@ -33,9 +33,6 @@ import {
defaultBorrowChromeWindows, defaultBorrowChromeWindows,
requestBorrowConfirmation, requestBorrowConfirmation,
} from "@/tools/borrow-confirmation"; } from "@/tools/borrow-confirmation";
import {
type RecordCaptureStatusMessage,
} from "@/lib/record-bridge";
import { ToolDispatcher } from "@/tools/dispatcher"; import { ToolDispatcher } from "@/tools/dispatcher";
import { import {
attachRecordFinishListener, attachRecordFinishListener,
@@ -175,27 +172,8 @@ export default defineBackground(() => {
dispatcher.start(); dispatcher.start();
const recordDeps = { const recordDeps = {
tabsApi: chrome.tabs, tabsApi: chrome.tabs,
cdp,
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) => sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
chrome.tabs.sendMessage(tabId, msg), chrome.tabs.sendMessage(tabId, msg),
sendToDocument: (
tabId: number,
documentId: string,
msg: Parameters<typeof chrome.tabs.sendMessage>[1],
) => chrome.tabs.sendMessage(tabId, msg, { documentId }),
getAllFrames: async (tabId: number) => {
if (!chrome.webNavigation?.getAllFrames) return [];
const frames = await chrome.webNavigation.getAllFrames({ tabId });
return (frames ?? []).map((frame) => ({
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
parentFrameId: frame.parentFrameId,
}));
},
broadcastCaptureStatus: async (tabId: number, msg: RecordCaptureStatusMessage) => {
await chrome.tabs.sendMessage(tabId, msg, { frameId: 0 });
},
bypassOverlay: async (tabId: number, enabled: boolean) => { bypassOverlay: async (tabId: number, enabled: boolean) => {
try { try {
await chrome.tabs.sendMessage(tabId, { await chrome.tabs.sendMessage(tabId, {
@@ -210,7 +188,7 @@ export default defineBackground(() => {
// Message listeners stay up (cheap; fire only for record message types). // Message listeners stay up (cheap; fire only for record message types).
// Tab / webNavigation observation attaches lazily while a recording is // Tab / webNavigation observation attaches lazily while a recording is
// active — see ensureBrowserObservationListeners in tools/record.ts. // active — see ensureBrowserObservationListeners in tools/record.ts.
attachRecordStepListener(recordDeps); attachRecordStepListener();
attachRecordFinishListener(recordDeps); attachRecordFinishListener(recordDeps);
attachRecordQueryListener(recordDeps); attachRecordQueryListener(recordDeps);
if (typeof chrome.notifications?.onClicked?.addListener === "function") { if (typeof chrome.notifications?.onClicked?.addListener === "function") {
+16 -44
View File
@@ -7,6 +7,7 @@ import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
import { ControlOverlay } from "@/content/ControlOverlay"; import { ControlOverlay } from "@/content/ControlOverlay";
import { createCaptureSuppressController } from "@/content/capture-suppress"; import { createCaptureSuppressController } from "@/content/capture-suppress";
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay"; import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
import { createHelpRequestData } from "@/content/help-request";
import overlayCss from "@/content/overlay.css?inline"; import overlayCss from "@/content/overlay.css?inline";
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller"; import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
import { RecordOverlay } from "@/content/RecordOverlay"; import { RecordOverlay } from "@/content/RecordOverlay";
@@ -44,15 +45,12 @@ import {
} from "@/lib/overlay-bridge"; } from "@/lib/overlay-bridge";
import { sendInterrupt } from "@/lib/overlay-interrupt-client"; import { sendInterrupt } from "@/lib/overlay-interrupt-client";
import { import {
isRecordCaptureStatusMessage,
RECORD_FINISH, RECORD_FINISH,
RECORD_QUERY, RECORD_QUERY,
type RecordCaptureStatusMessage,
type RecordQueryResponse, type RecordQueryResponse,
type RecordStartAck, type RecordStartAck,
type RecordStopAck, type RecordStopAck,
} from "@/lib/record-bridge"; } from "@/lib/record-bridge";
import type { FrameCaptureFailure, FrameCaptureStatus } from "@/transport/types";
import type { import type {
BorrowCancelMessage, BorrowCancelMessage,
BorrowRequestMessage, BorrowRequestMessage,
@@ -73,8 +71,6 @@ export default defineContentScript({
const overlays = new OverlayController(); const overlays = new OverlayController();
let recordCapture: RecordCaptureController | null = null; let recordCapture: RecordCaptureController | null = null;
let activeRecordRequestId: string | null = null; let activeRecordRequestId: string | null = null;
let recordCaptureStatus: FrameCaptureStatus = "complete";
let recordCaptureFailures: FrameCaptureFailure[] = [];
let reactRoot: ReactDOM.Root | null = null; let reactRoot: ReactDOM.Root | null = null;
let overlayHost: HTMLElement | null = null; let overlayHost: HTMLElement | null = null;
let overlayContainer: HTMLElement | null = null; let overlayContainer: HTMLElement | null = null;
@@ -193,11 +189,7 @@ export default defineContentScript({
requests: overlayState.borrowRequests, requests: overlayState.borrowRequests,
}), }),
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }), React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
React.createElement(RecordOverlay, { React.createElement(RecordOverlay, { request: overlayState.activeRecord }),
request: overlayState.activeRecord,
captureStatus: recordCaptureStatus,
captureFailures: recordCaptureFailures,
}),
React.createElement(ControlOverlay, { React.createElement(ControlOverlay, {
visible: controlOverlayVisible, visible: controlOverlayVisible,
interrupting: overlayState.interrupting, interrupting: overlayState.interrupting,
@@ -269,8 +261,7 @@ export default defineContentScript({
| CaptureSuppressMessage | CaptureSuppressMessage
| OverlayAgentOverlayResetMessage | OverlayAgentOverlayResetMessage
| OverlayAgentStateMessage | OverlayAgentStateMessage
| OverlayAutomationBypassMessage | OverlayAutomationBypassMessage,
| RecordCaptureStatusMessage,
_sender: chrome.runtime.MessageSender, _sender: chrome.runtime.MessageSender,
sendResponse: (response: BorrowResponseMessage | HelpAckMessage | CaptureSuppressAck) => void, sendResponse: (response: BorrowResponseMessage | HelpAckMessage | CaptureSuppressAck) => void,
) => { ) => {
@@ -278,13 +269,6 @@ export default defineContentScript({
return captureSuppress.handleMessage(message, sendResponse); return captureSuppress.handleMessage(message, sendResponse);
} }
if (isRecordCaptureStatusMessage(message)) {
recordCaptureStatus = message.status;
recordCaptureFailures = message.failures ?? [];
renderAll();
return false;
}
if (isRecordContentMessage(message)) { if (isRecordContentMessage(message)) {
const needsAsync = handleRecordContentMessage( const needsAsync = handleRecordContentMessage(
message, message,
@@ -312,8 +296,6 @@ export default defineContentScript({
}, },
onStop: () => { onStop: () => {
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined); overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
recordCaptureStatus = "complete";
recordCaptureFailures = [];
renderAll(); renderAll();
}, },
}, },
@@ -363,16 +345,7 @@ export default defineContentScript({
if (isHelpRequestMessage(message)) { if (isHelpRequestMessage(message)) {
const helpMsg = message as HelpRequestMessage; const helpMsg = message as HelpRequestMessage;
const previousHelp = overlays.setAgentHelpRequest({ const previousHelp = mountHelpRequest(helpMsg);
id: helpMsg.requestId,
prompt: helpMsg.prompt,
...(helpMsg.title ? { title: helpMsg.title } : {}),
...(helpMsg.displayMode ? { displayMode: helpMsg.displayMode } : {}),
selectors: helpMsg.selectors,
onContinue: (note: string) =>
void sendHelpFinish(helpMsg.requestId, "continued", note.trim() ? note : undefined),
onCancel: () => void sendHelpFinish(helpMsg.requestId, "cancelled"),
});
if (previousHelp && previousHelp.id !== helpMsg.requestId) { if (previousHelp && previousHelp.id !== helpMsg.requestId) {
void sendHelpFinish(previousHelp.id, "cancelled"); void sendHelpFinish(previousHelp.id, "cancelled");
} }
@@ -425,17 +398,18 @@ export default defineContentScript({
}); });
} }
function mountHelpRequest(helpMsg: Omit<HelpRequestMessage, "type">): void { function mountHelpRequest(helpMsg: Omit<HelpRequestMessage, "type">) {
overlays.setAgentHelpRequest({ return overlays.setAgentHelpRequest(
id: helpMsg.requestId, createHelpRequestData(helpMsg, {
prompt: helpMsg.prompt, finish: (requestId, outcome, note) => {
...(helpMsg.title ? { title: helpMsg.title } : {}), void sendHelpFinish(requestId, outcome, note);
...(helpMsg.displayMode ? { displayMode: helpMsg.displayMode } : {}), },
selectors: helpMsg.selectors, query: () =>
onContinue: (note: string) => chrome.runtime.sendMessage({ type: HELP_QUERY }) as Promise<
void sendHelpFinish(helpMsg.requestId, "continued", note.trim() ? note : undefined), HelpQueryResponse | undefined
onCancel: () => void sendHelpFinish(helpMsg.requestId, "cancelled"), >,
}); }),
);
} }
async function queryActiveHelpWithRetry(): Promise<void> { async function queryActiveHelpWithRetry(): Promise<void> {
@@ -468,8 +442,6 @@ export default defineContentScript({
) { ) {
const requestId = recordQuery.requestId; const requestId = recordQuery.requestId;
const startedAtMs = recordQuery.startedAtMs; const startedAtMs = recordQuery.startedAtMs;
recordCaptureStatus = recordQuery.captureStatus ?? "complete";
recordCaptureFailures = recordQuery.captureFailures ?? [];
overlays.setAgentRecordRequest({ overlays.setAgentRecordRequest({
id: requestId, id: requestId,
...(typeof startedAtMs === "number" ? { startedAtMs } : {}), ...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
@@ -100,24 +100,6 @@ describe("App", () => {
expect(screen.queryByText("录制你的操作,供 Agent 参考")).toBeNull(); expect(screen.queryByText("录制你的操作,供 Agent 参考")).toBeNull();
}); });
it("renders localized copy for a version failure instead of protocol details", () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
lastError:
"[handshake] daemon rejected handshake: version_too_old — peer protocol 1.0 is below local min_compatible_protocol 1.1",
},
statusState: "disconnected",
setLabel,
setConnectionEnabled,
});
render(<App />);
const error = screen.getByText(/bsk 与 BrowserSkill 扩展版本不兼容/);
expect(error.textContent).not.toContain("min_compatible_protocol");
});
it("shows single-line compact metadata and copies the instance id", async () => { it("shows single-line compact metadata and copies the instance id", async () => {
mockUseConnectionState.mockReturnValue({ mockUseConnectionState.mockReturnValue({
snapshot: { snapshot: {
@@ -218,13 +200,6 @@ describe("App", () => {
expect(copied).toContain("--browser 03c3e47f"); expect(copied).toContain("--browser 03c3e47f");
expect(copied).toContain('--purpose "发布 wiki 文档"'); expect(copied).toContain('--purpose "发布 wiki 文档"');
expect(copied).not.toMatch(/bsk record start[^\n]*--url/); expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
expect(copied).toContain("./trace");
expect(copied).toContain("trace.json");
expect(copied).toContain("pages/");
expect(copied).toContain("总结我做了什么");
expect(copied).not.toContain("不要重跑");
expect(copied).not.toContain("pages + steps");
expect(copied).not.toContain("@eN");
// Button label stays static; a transient toast confirms the copy. // Button label stays static; a transient toast confirms the copy.
expect(copyButton.textContent).toContain("复制录制指令"); expect(copyButton.textContent).toContain("复制录制指令");
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制")); await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
+2 -5
View File
@@ -83,9 +83,6 @@ export function App() {
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—"; const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
const extensionVersion = snapshot.extensionVersion || "—"; const extensionVersion = snapshot.extensionVersion || "—";
const instanceId = snapshot.instanceId || "—"; const instanceId = snapshot.instanceId || "—";
const errorMessage = snapshot.lastError?.includes("version_too_old")
? t("popup.versionIncompatibleError")
: snapshot.lastError;
const copyInstanceId = async () => { const copyInstanceId = async () => {
if (!snapshot.instanceId || !navigator.clipboard?.writeText) return; if (!snapshot.instanceId || !navigator.clipboard?.writeText) return;
@@ -255,12 +252,12 @@ export function App() {
</div> </div>
</section> </section>
{errorMessage && ( {snapshot.lastError && (
<div <div
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive" className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
data-slot="popup-error" data-slot="popup-error"
> >
{errorMessage} {snapshot.lastError}
</div> </div>
)} )}
@@ -1,77 +0,0 @@
import {
handleRecordContentMessage,
isRecordContentMessage,
type RecordCaptureController,
} from "@/content/record-capture";
import {
RECORD_QUERY,
type RecordQueryResponse,
} from "@/lib/record-bridge";
/** Capture-only content script for child frames (no React overlay). */
export default defineContentScript({
matches: ["<all_urls>"],
runAt: "document_end",
allFrames: true,
matchAboutBlank: true,
matchOriginAsFallback: true,
main(ctx) {
if (window.top === window) return;
let recordCapture: RecordCaptureController | null = null;
let activeRecordRequestId: string | null = null;
const onMessage = (
message: unknown,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void,
) => {
if (isRecordContentMessage(message)) {
const needsAsync = handleRecordContentMessage(
message,
{
activeRequestId: activeRecordRequestId,
capture: recordCapture,
setActiveRequestId: (id) => {
activeRecordRequestId = id;
},
setCapture: (capture) => {
recordCapture = capture;
},
},
sendResponse as never,
);
return needsAsync;
}
return false;
};
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" &&
activeRecordRequestId === null
) {
// Background rearm sends RECORD_START; nothing to mount locally.
}
} catch (err) {
console.debug("[bsk record-frame] record query failed", err);
}
}
chrome.runtime.onMessage.addListener(onMessage);
void queryActiveRecord();
ctx.onInvalidated(() => {
chrome.runtime.onMessage.removeListener(onMessage);
recordCapture?.dispose();
recordCapture = null;
activeRecordRequestId = null;
});
},
});
@@ -27,19 +27,19 @@ function handshake(
describe("computeConnectedState (protocol-based compat)", () => { describe("computeConnectedState (protocol-based compat)", () => {
it("returns connected when protocol strings match", () => { it("returns connected when protocol strings match", () => {
expect(computeConnectedState(handshake("1.1", "1.1"), MIN_COMPATIBLE_PROTOCOL)).toEqual({ expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
kind: "connected", kind: "connected",
}); });
}); });
it("returns version_skew when daemon protocol minor is newer", () => { it("returns version_skew when daemon protocol minor is newer", () => {
expect(computeConnectedState(handshake("1.2", "1.1"))).toEqual({ expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({
kind: "version_skew", kind: "version_skew",
}); });
}); });
it("returns version_skew when daemon protocol string differs but floor is satisfied", () => { it("returns version_skew when daemon protocol string differs but floor is satisfied", () => {
expect(computeConnectedState(handshake("1.1.0", "1.1"))).toEqual({ expect(computeConnectedState(handshake("1", "1.0"))).toEqual({
kind: "version_skew", kind: "version_skew",
}); });
}); });
@@ -48,19 +48,16 @@ describe("computeConnectedState (protocol-based compat)", () => {
const result = computeConnectedState(handshake("2.0", "1.0")); const result = computeConnectedState(handshake("2.0", "1.0"));
expect(result.kind).toBe("rejected"); expect(result.kind).toBe("rejected");
if (result.kind === "rejected") { if (result.kind === "rejected") {
expect(result.reason).toContain("bsk CLI"); expect(result.reason).toContain("protocol-major mismatch");
expect(result.reason).toContain("BrowserSkill extension");
expect(result.reason).not.toContain("protocol");
} }
}); });
it("rejects when extension is below daemon min_compatible_protocol", () => { it("rejects when extension is below daemon min_compatible_protocol", () => {
const result = computeConnectedState(handshake("1.1", "1.5")); const result = computeConnectedState(handshake("1.0", "1.5"));
expect(result.kind).toBe("rejected"); expect(result.kind).toBe("rejected");
if (result.kind === "rejected") { if (result.kind === "rejected") {
expect(result.reason).toContain("BrowserSkill extension is out of date"); expect(result.reason).toContain("min_compatible_protocol");
expect(result.reason).toContain("Update and reload the extension"); expect(result.reason).toContain("1.5");
expect(result.reason).not.toContain("min_compatible_protocol");
} }
}); });
@@ -68,7 +65,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
const result = computeConnectedState({ const result = computeConnectedState({
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.1", protocol_version: "1.0",
min_compatible_peer: "0.1.0", min_compatible_peer: "0.1.0",
}); });
expect(result).toEqual({ kind: "connected" }); expect(result).toEqual({ kind: "connected" });
@@ -78,23 +75,16 @@ describe("computeConnectedState (protocol-based compat)", () => {
const result = computeConnectedState(handshake("1.0", "1.0"), "1.1"); const result = computeConnectedState(handshake("1.0", "1.0"), "1.1");
expect(result.kind).toBe("rejected"); expect(result.kind).toBe("rejected");
if (result.kind === "rejected") { if (result.kind === "rejected") {
expect(result.reason).toContain("bsk CLI is out of date"); expect(result.reason).toContain("below extension min_compatible_protocol");
expect(result.reason).toContain("bsk daemon restart");
expect(result.reason).not.toContain("min_compatible_protocol");
} }
}); });
it("rejects a pre-Trace-v3 daemon using the configured extension floor", () => {
const result = computeConnectedState(handshake("1.0", "1.0"));
expect(result.kind).toBe("rejected");
});
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => { it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
const result = computeConnectedState(handshake("1.1", "not-a-protocol")); const result = computeConnectedState(handshake("1.0", "not-a-protocol"));
expect(result.kind).toBe("rejected"); expect(result.kind).toBe("rejected");
if (result.kind === "rejected") { if (result.kind === "rejected") {
expect(result.reason).toContain("could not verify version compatibility"); expect(result.reason).toContain("daemon min_compatible_protocol");
expect(result.reason).not.toContain("min_compatible_protocol"); expect(result.reason).toContain("not-a-protocol");
} }
}); });
@@ -252,11 +242,11 @@ describe("ConnectionController connectionEnabled", () => {
const second = transport.send.mock.calls[1]?.[0] as { id: string }; const second = transport.send.mock.calls[1]?.[0] as { id: string };
expect(second.id).not.toBe(first.id); expect(second.id).not.toBe(first.id);
transport.emitMessage({ id: first.id, result: handshake("1.1", "1.1") }); transport.emitMessage({ id: first.id, result: handshake("1.0", "1.0") });
await Promise.resolve(); await Promise.resolve();
expect(controller.snapshot().state).not.toBe("connected"); expect(controller.snapshot().state).not.toBe("connected");
transport.emitMessage({ id: second.id, result: handshake("1.1", "1.1") }); transport.emitMessage({ id: second.id, result: handshake("1.0", "1.0") });
await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected")); await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected"));
}); });
}); });
@@ -32,6 +32,7 @@ describe("help-bridge", () => {
prompt: "log in", prompt: "log in",
displayMode: "compact", displayMode: "compact",
selectors: ["#login"], selectors: ["#login"],
rects: [{ top: 10, left: 20, width: 100, height: 40 }],
timeoutMs: 1000, timeoutMs: 1000,
}), }),
).toBe(true); ).toBe(true);
@@ -1,172 +0,0 @@
import type { RenderedRef } from "@browser-skill/vom";
import { describe, expect, it } from "vitest";
import type { CapturedNode } from "@/tools/vom/capture";
import { matchTarget } from "../match-target";
function node(backendNodeId: number, overrides: Partial<CapturedNode> = {}): CapturedNode {
return {
backendNodeId,
parentBackendNodeId: null,
tag: "button",
attrs: {},
rect: { x: 10, y: 20, w: 100, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
...overrides,
};
}
describe("matchTarget", () => {
const refs: RenderedRef[] = [
{ ref: "e1", backendNodeId: 42, role: "button", name: "发布", line: 3 },
];
it("matches a unique node by viewport coordinates", () => {
const target = matchTarget({
geometry: {
rect: { x: 10, y: 20, w: 100, h: 30 },
scrollX: 0,
scrollY: 0,
position: "static",
tag: "button",
},
captured: [node(42)],
refs,
fallback: { tag: "button", name: "发布" },
});
expect(target).toEqual({ ref: "e1", role: "button", name: "发布" });
});
it("matches a non-fixed node after the top frame scrolls", () => {
const target = matchTarget({
geometry: {
rect: { x: 10, y: 20, w: 100, h: 30 },
scrollX: 40,
scrollY: 300,
position: "static",
tag: "button",
},
captured: [
node(42, {
documentRect: { x: 50, y: 320, w: 100, h: 30 },
localRect: { x: 10, y: 20, w: 100, h: 30 },
rect: { x: 10, y: 20, w: 100, h: 30 },
}),
],
refs,
fallback: { tag: "button", name: "发布" },
});
expect(target).toEqual({ ref: "e1", role: "button", name: "发布" });
});
it("matches a static node by document coordinates when scroll changed after observation", () => {
const target = matchTarget({
geometry: {
rect: { x: 10, y: 50, w: 100, h: 30 },
scrollX: 0,
scrollY: 250,
position: "static",
tag: "button",
},
captured: [
node(42, {
documentRect: { x: 10, y: 300, w: 100, h: 30 },
localRect: { x: 10, y: 200, w: 100, h: 30 },
rect: { x: 10, y: 200, w: 100, h: 30 },
}),
],
refs,
fallback: { tag: "button", name: "发布" },
});
expect(target).toEqual({ ref: "e1", role: "button", name: "发布" });
});
it("returns unmatched when multiple candidates match", () => {
const target = matchTarget({
geometry: {
rect: { x: 10, y: 20, w: 100, h: 30 },
scrollX: 0,
scrollY: 0,
position: "static",
tag: "button",
},
captured: [node(42), node(43)],
refs,
});
expect(target.unmatched).toBe(true);
});
it("uses viewport coordinates for fixed elements", () => {
const target = matchTarget({
geometry: {
rect: { x: 5, y: 5, w: 80, h: 24 },
scrollX: 100,
scrollY: 200,
position: "fixed",
tag: "button",
},
captured: [node(42, { position: "fixed", rect: { x: 5, y: 5, w: 80, h: 24 } })],
refs,
});
expect(target.ref).toBe("e1");
});
it("isolates geometry matching to the owner iframe backend node", () => {
const sharedRect = { x: 10, y: 20, w: 100, h: 30 };
const iframeRefs: RenderedRef[] = [
{ ref: "e1", backendNodeId: 42, role: "button", name: "Top", line: 1 },
{ ref: "e2", backendNodeId: 99, role: "button", name: "Iframe", line: 2 },
];
const target = matchTarget({
geometry: {
rect: sharedRect,
scrollX: 0,
scrollY: 0,
position: "static",
tag: "button",
ownerFrameBackendNodeId: 13,
},
captured: [
node(42, { rect: sharedRect, documentRect: sharedRect }),
node(99, {
rect: sharedRect,
documentRect: sharedRect,
ownerFrameBackendNodeId: 13,
}),
],
refs: iframeRefs,
fallback: { tag: "button", name: "Iframe" },
});
expect(target).toEqual({ ref: "e2", role: "button", name: "Iframe" });
});
it("matches iframe static elements using frame scroll and document coordinates", () => {
const iframeRefs: RenderedRef[] = [
{ ref: "e2", backendNodeId: 99, role: "button", name: "Iframe", line: 2 },
];
const target = matchTarget({
geometry: {
rect: { x: 10, y: 50, w: 100, h: 30 },
scrollX: 0,
scrollY: 120,
position: "static",
tag: "button",
ownerFrameBackendNodeId: 13,
},
captured: [
node(99, {
ownerFrameBackendNodeId: 13,
documentRect: { x: 10, y: 170, w: 100, h: 30 },
localRect: { x: 10, y: 50, w: 100, h: 30 },
rect: { x: 10, y: 50, w: 100, h: 30 },
}),
],
refs: iframeRefs,
fallback: { tag: "button", name: "Iframe" },
});
expect(target).toEqual({ ref: "e2", role: "button", name: "Iframe" });
});
});
@@ -1,116 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { CapturedNode } from "@/tools/vom/capture";
import type { DraftTraceStep } from "@/transport/types";
import { resetStateIdCounterForTests } from "../record-constants";
import {
applyTargetMatching,
buildTraceV3,
createObservationState,
rememberStepOnPage,
} from "../record-observation";
import { registerObservation } from "../trace-reducer";
const URL = "https://example.com/login";
function capturedInput(): CapturedNode {
return {
backendNodeId: 42,
parentBackendNodeId: null,
tag: "input",
attrs: {},
rect: { x: 20, y: 40, w: 200, h: 30 },
localRect: { x: 20, y: 40, w: 200, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
};
}
function finalizedFillBody(value: string, redactValues: boolean): string {
const obs = createObservationState({ redactValues });
const rawVomText = '@vom 1\ntextbox "Password" value="••••••" [ref=e1]';
const stateId = registerObservation(obs.stateRegistry, { url: URL, rawVomText });
obs.lastSettled = {
stateId,
captured: [capturedInput()],
matchNodes: [capturedInput()],
documentTokenOwners: new Map(),
refs: [{ ref: "e1", backendNodeId: 42, role: "textbox", name: "Password", line: 1 }],
url: URL,
vomText: rawVomText,
};
const draft: DraftTraceStep = {
op: "fill",
target: { unmatched: true },
value,
geometry: {
rect: { x: 20, y: 40, w: 200, h: 30 },
scrollX: 0,
scrollY: 0,
position: "static",
tag: "input",
},
};
applyTargetMatching(obs, draft, 1);
draft.postStateId = stateId;
rememberStepOnPage(obs, draft, 1);
return (
buildTraceV3({
obs,
steps: [draft],
startedAt: "2026-08-12T00:00:00.000Z",
stoppedBy: "user_finish",
bskVersion: "test",
}).states[0]?.body ?? ""
);
}
describe("record observation annotations", () => {
beforeEach(() => {
resetStateIdCounterForTests();
});
it("omits a fill literal from finalized state bodies when values are redacted", () => {
const secret = "hunter2-private";
const body = finalizedFillBody(secret, true);
expect(body).toContain("step 1: fill");
expect(body).not.toContain(secret);
});
it("keeps fill details in ordinary recording annotations", () => {
const value = "ordinary text";
const body = finalizedFillBody(value, false);
expect(body).toContain(`step 1: fill: ${JSON.stringify(value)}`);
});
});
describe("applyTargetMatching without a settled observation", () => {
it("keeps the capture role/name instead of a bare unmatched target", () => {
const obs = createObservationState();
const draft: DraftTraceStep = {
op: "hover",
target: { unmatched: true },
captureTarget: { tag: "button", role: "button", name: "新建" },
geometry: {
rect: { x: 900, y: 8, w: 60, h: 32 },
scrollX: 0,
scrollY: 0,
position: "static",
tag: "button",
},
};
applyTargetMatching(obs, draft, 1);
expect(draft.preStateId).toBeUndefined();
expect(draft.target).toEqual({
role: "button",
name: "新建",
unmatched: true,
});
});
});
@@ -12,42 +12,12 @@ describe("recording-step-buffer", () => {
expect(buffer.steps).toEqual([ expect(buffer.steps).toEqual([
{ {
op: "click", op: "click",
target: { unmatched: true }, target: { tag: "button", role: "button", name: "发布" },
captureTarget: { tag: "button", role: "button", name: "发布" },
}, },
]); ]);
expect(buffer.pendingNavigation).toBe(true); expect(buffer.pendingNavigation).toBe(true);
}); });
it("keeps the hovered element description as a capture fallback", () => {
const buffer = { steps: [], pendingNavigation: false };
appendRecordedPayload(buffer, {
op: "hover",
target: { tag: "button", role: "button", name: "新建" },
geometry: {
rect: { x: 900, y: 8, w: 60, h: 32 },
scrollX: 0,
scrollY: 0,
position: "static",
tag: "button",
},
});
expect(buffer.steps).toEqual([
{
op: "hover",
target: { unmatched: true },
captureTarget: { tag: "button", role: "button", name: "新建" },
geometry: {
rect: { x: 900, y: 8, w: 60, h: 32 },
scrollX: 0,
scrollY: 0,
position: "static",
tag: "button",
},
},
]);
});
it("annotates navigated_to on action-caused navigation instead of wait_for_navigation", () => { it("annotates navigated_to on action-caused navigation instead of wait_for_navigation", () => {
const buffer = { const buffer = {
steps: [ steps: [
@@ -101,41 +71,10 @@ describe("recording-step-buffer", () => {
currentUrl: "https://example.com/a", currentUrl: "https://example.com/a",
pendingNavigation: false, pendingNavigation: false,
}; };
const result = observeRecordedNavigation(buffer, "https://example.com/b", false); observeRecordedNavigation(buffer, "https://example.com/b", false);
expect(result).toEqual({ kind: "appended", index: 0 });
expect(buffer.steps[0]).toMatchObject({ expect(buffer.steps[0]).toMatchObject({
op: "navigate", op: "navigate",
url: "https://example.com/b", url: "https://example.com/b",
}); });
}); });
it("asks the recorder to coalesce redirect hops instead of emitting each one", () => {
const buffer = {
steps: [],
currentUrl: "https://passport.example/login",
pendingNavigation: false,
};
const hop1 = observeRecordedNavigation(
buffer,
"https://passport.example/callback",
false,
"link",
["server_redirect"],
);
expect(hop1).toEqual({
kind: "coalesce_redirect",
url: "https://passport.example/callback",
});
expect(buffer.steps).toEqual([]);
const hop2 = observeRecordedNavigation(buffer, "https://app.example/dashboard", false, "link", [
"client_redirect",
]);
expect(hop2).toEqual({
kind: "coalesce_redirect",
url: "https://app.example/dashboard",
});
expect(buffer.steps).toEqual([]);
expect(buffer.currentUrl).toBe("https://app.example/dashboard");
});
}); });
@@ -1,11 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resetStateIdCounterForTests } from "@/lib/record-constants"; import type { DraftTraceStep } from "@/transport/types";
import { import { reduceTraceSteps, resolveTraceStartUrl, shouldRecordPress } from "../trace-reducer";
reduceTraceSteps,
registerObservation,
resolveTraceStartUrl,
shouldRecordPress,
} from "../trace-reducer";
describe("shouldRecordPress", () => { describe("shouldRecordPress", () => {
it("keeps Enter and Escape", () => { it("keeps Enter and Escape", () => {
@@ -22,85 +17,86 @@ describe("shouldRecordPress", () => {
}); });
}); });
describe("reduceTraceSteps v3", () => { describe("reduceTraceSteps", () => {
it("builds steps with state/result references and deduped states", () => { it("builds steps with pages dictionary and page id references", () => {
resetStateIdCounterForTests(); const drafts: DraftTraceStep[] = [
const registry = new Map();
const body = "@vom 1\nL1 page";
const s1 = registerObservation(registry, {
url: "https://example.com/search",
rawVomText: body,
});
const s2 = registerObservation(registry, {
url: "https://example.com/results/42",
rawVomText: "@vom 1\nL1 results",
});
const { states, steps } = reduceTraceSteps(
[
{ {
op: "navigate", op: "navigate",
url: "https://example.com/search", url: "https://example.com/search?q=hello&utm_source=x",
preStateId: s1, page_url: "https://example.com/search?q=hello&utm_source=x",
postStateId: s1,
}, },
{ {
op: "fill", op: "fill",
target: { ref: "e1", role: "textbox", name: "搜索" }, target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
value: "browser skill", value: "browser skill",
commit: "enter", page_url: "https://example.com/search?q=hello&utm_source=x",
preStateId: s1,
postStateId: s1,
}, },
{ {
op: "press", op: "press",
key: "Enter", key: "Enter",
target: { ref: "e1", role: "textbox", name: "搜索" }, target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
preStateId: s1,
postStateId: s2,
navigated_to: "https://example.com/results/42", navigated_to: "https://example.com/results/42",
page_url: "https://example.com/search?q=hello&utm_source=x",
}, },
{ {
op: "click", op: "click",
target: { ref: "e2", role: "button", name: "发布" }, target: { tag: "button", role: "button", name: "发布" },
preStateId: s2, navigated_to: "https://example.com/p/99",
postStateId: s2, page_url: "https://example.com/results/42",
}, },
], {
registry, op: "press",
); key: "a",
page_url: "https://example.com/p/99",
},
];
expect(states).toHaveLength(2); 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(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({ expect(steps[1]).toMatchObject({
op: "fill", op: "fill",
state: s1, page: "p1",
result: { state: s1 }, value: "browser skill",
commit: "enter",
}); });
expect(steps[2]).toMatchObject({ expect(steps[2]).toMatchObject({
op: "press", op: "press",
state: s1, key: "Enter",
result: { state: s2 }, page: "p1",
effect: { navigated_to: "p2" },
});
expect(steps[3]).toMatchObject({
op: "click",
page: "p2",
effect: { navigated_to: "p3" },
}); });
expect(JSON.stringify(steps)).not.toContain("page");
expect(JSON.stringify(steps)).not.toContain("effect");
}); });
it("collapses consecutive navigations", () => { it("collapses consecutive navigations", () => {
resetStateIdCounterForTests(); const { steps } = reduceTraceSteps([
const registry = new Map(); { op: "navigate", url: "https://a.example/redirect1" },
const s1 = registerObservation(registry, { { op: "navigate", url: "https://a.example/final" },
url: "https://a.example/redirect1", ]);
rawVomText: "a",
});
const { steps } = reduceTraceSteps(
[
{ op: "navigate", url: "https://a.example/redirect1", preStateId: s1, postStateId: s1 },
{ op: "navigate", url: "https://a.example/final", preStateId: s1, postStateId: s1 },
],
registry,
);
expect(steps).toHaveLength(1); expect(steps).toHaveLength(1);
expect(steps[0]).toMatchObject({ expect(steps[0]).toMatchObject({
op: "navigate", op: "navigate",
@@ -108,124 +104,73 @@ describe("reduceTraceSteps v3", () => {
}); });
}); });
it("keeps the origin state and cause of the hop that started a redirect chain", () => { it("keeps committed empty fill steps", () => {
resetStateIdCounterForTests();
const registry = new Map();
const start = registerObservation(registry, {
url: "https://example.com/",
rawVomText: "start",
});
const hop = registerObservation(registry, {
url: "https://iwiki.woa.com/",
rawVomText: "hop",
});
const landing = registerObservation(registry, {
url: "https://iwiki.woa.com/dashboard",
rawVomText: "landing",
});
const { steps, stepIdByDraftId } = reduceTraceSteps(
[
{
op: "navigate",
url: "https://iwiki.woa.com/",
preStateId: start,
postStateId: hop,
transitionType: "typed",
},
{
op: "navigate",
url: "https://iwiki.woa.com/dashboard",
preStateId: hop,
postStateId: landing,
transitionType: "link",
},
],
registry,
);
expect(steps).toHaveLength(1);
expect(steps[0]).toMatchObject({
op: "navigate",
id: 1,
state: start,
result: { state: landing },
to: "https://iwiki.woa.com/dashboard",
cause: "user_typed",
});
// Both drafts fold into step 1, so anything tagged with either draft id
// must resolve to it.
expect(stepIdByDraftId.get(1)).toBe(1);
expect(stepIdByDraftId.get(2)).toBe(1);
});
it("maps draft ids to published step ids across dropped drafts", () => {
resetStateIdCounterForTests();
const registry = new Map();
const s1 = registerObservation(registry, { url: "https://a.example/", rawVomText: "a" });
const { steps, stepIdByDraftId } = reduceTraceSteps(
[
// Dropped: bare typing press.
{ op: "press", key: "a", preStateId: s1, postStateId: s1 },
{ op: "click", target: { ref: "e2" }, preStateId: s1, postStateId: s1 },
],
registry,
);
expect(steps.map((step) => step.op)).toEqual(["click"]);
expect(stepIdByDraftId.get(1)).toBeUndefined();
expect(stepIdByDraftId.get(2)).toBe(1);
});
it("keeps committed empty fills", () => {
resetStateIdCounterForTests();
const registry = new Map();
const s1 = registerObservation(registry, { url: "https://a.example/", rawVomText: "a" });
const { steps } = reduceTraceSteps( const { steps } = reduceTraceSteps(
[ [
{ op: "fill", target: { ref: "e1", role: "textbox", name: "Search" }, value: "", preStateId: s1, postStateId: s1 }, {
{ op: "click", target: { ref: "e2", role: "button", name: "Apply" }, preStateId: s1, postStateId: s1 }, op: "fill",
target: { tag: "input", role: "textbox", name: "Search query" },
value: "",
page_url: "https://example.com/search",
},
], ],
registry, "https://example.com/search",
); );
expect(steps.map((step) => step.op)).toEqual(["fill", "click"]); expect(steps).toEqual([
expect(steps[0]).toMatchObject({ op: "fill", value: "" }); 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", () => { it("keeps hover steps before menu clicks", () => {
resetStateIdCounterForTests();
const registry = new Map();
const state = registerObservation(registry, {
url: "https://example.com/app",
rawVomText: '@e1 button "Account"\n@e2 link "Profile"',
});
const { steps } = reduceTraceSteps( const { steps } = reduceTraceSteps(
[ [
{ {
op: "hover", op: "hover",
target: { ref: "e1", role: "button", name: "Account" }, target: { tag: "span", role: "button", name: "Account" },
page_url: "https://example.com/app", page_url: "https://example.com/app",
preStateId: state,
postStateId: state,
}, },
{ {
op: "click", op: "click",
target: { ref: "e2", role: "link", name: "Profile" }, target: { tag: "a", role: "link", name: "Profile" },
page_url: "https://example.com/app", page_url: "https://example.com/app",
preStateId: state,
postStateId: state,
}, },
], ],
registry, "https://example.com/app",
); );
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
expect(steps[0]).toMatchObject({ expect(steps[0]).toMatchObject({
op: "hover", op: "hover",
state, page: "p1",
result: { state },
target: { name: "Account" }, target: { name: "Account" },
}); });
}); });
@@ -233,7 +178,7 @@ describe("reduceTraceSteps v3", () => {
it("resolveTraceStartUrl prefers explicit start URL", () => { it("resolveTraceStartUrl prefers explicit start URL", () => {
expect( expect(
resolveTraceStartUrl( resolveTraceStartUrl(
[{ op: "navigate", url: "https://example.com/other", preStateId: "s1", postStateId: "s1" }], [{ op: "navigate", url: "https://example.com/other" }],
"https://example.com/start", "https://example.com/start",
), ),
).toBe("https://example.com/start"); ).toBe("https://example.com/start");
@@ -313,15 +313,6 @@ export type HandshakeVerdict =
| { kind: "version_skew" } | { kind: "version_skew" }
| { kind: "rejected"; reason: string }; | { kind: "rejected"; reason: string };
const UPDATE_BOTH_VERSION_MESSAGE =
"The installed bsk CLI and BrowserSkill extension are incompatible. Update both, reload the extension, then run `bsk daemon restart`.";
const UPDATE_BSK_VERSION_MESSAGE =
"The installed bsk CLI is out of date for this BrowserSkill extension. Update bsk, then run `bsk daemon restart`.";
const UPDATE_EXTENSION_VERSION_MESSAGE =
"This BrowserSkill extension is out of date for the installed bsk CLI. Update and reload the extension, then try again.";
const INVALID_VERSION_MESSAGE =
"BrowserSkill could not verify version compatibility. Update bsk and the BrowserSkill extension, reload the extension, then run `bsk daemon restart`.";
/** /**
* Symmetric extension-side **protocol** compat check. * Symmetric extension-side **protocol** compat check.
* *
@@ -348,19 +339,19 @@ export function computeConnectedState(
if (daemonMajor === null || ourMajor === null || daemonMajor !== ourMajor) { if (daemonMajor === null || ourMajor === null || daemonMajor !== ourMajor) {
return { return {
kind: "rejected", kind: "rejected",
reason: UPDATE_BOTH_VERSION_MESSAGE, reason: `protocol-major mismatch (daemon protocol v${handshake.protocol_version}, extension protocol v${PROTOCOL_VERSION})`,
}; };
} }
if (compareProtocol(handshake.protocol_version, localMinCompatibleProtocol) === null) { if (compareProtocol(handshake.protocol_version, localMinCompatibleProtocol) === null) {
return { return {
kind: "rejected", kind: "rejected",
reason: INVALID_VERSION_MESSAGE, reason: `daemon protocol v${handshake.protocol_version} is unparseable`,
}; };
} }
if (compareProtocol(handshake.protocol_version, localMinCompatibleProtocol)! < 0) { if (compareProtocol(handshake.protocol_version, localMinCompatibleProtocol)! < 0) {
return { return {
kind: "rejected", kind: "rejected",
reason: UPDATE_BSK_VERSION_MESSAGE, reason: `daemon protocol v${handshake.protocol_version} below extension min_compatible_protocol ${localMinCompatibleProtocol}`,
}; };
} }
const peerFloor = handshake.min_compatible_protocol; const peerFloor = handshake.min_compatible_protocol;
@@ -368,13 +359,13 @@ export function computeConnectedState(
if (compareProtocol(PROTOCOL_VERSION, peerFloor) === null) { if (compareProtocol(PROTOCOL_VERSION, peerFloor) === null) {
return { return {
kind: "rejected", kind: "rejected",
reason: INVALID_VERSION_MESSAGE, reason: `daemon min_compatible_protocol ${peerFloor} is unparseable`,
}; };
} }
if (compareProtocol(PROTOCOL_VERSION, peerFloor)! < 0) { if (compareProtocol(PROTOCOL_VERSION, peerFloor)! < 0) {
return { return {
kind: "rejected", kind: "rejected",
reason: UPDATE_EXTENSION_VERSION_MESSAGE, reason: `extension protocol v${PROTOCOL_VERSION} below daemon min_compatible_protocol ${peerFloor}`,
}; };
} }
} }
+4 -5
View File
@@ -6,8 +6,7 @@
* `{ "tag": "div" }` / “点击div” is useless and must not be recorded. * `{ "tag": "div" }` / “点击div” is useless and must not be recorded.
*/ */
/** Content-script capture descriptor before VOM geometric matching. */ export interface TargetDescriptor {
export interface CaptureTargetDescriptor {
role?: string; role?: string;
name?: string; name?: string;
tag: string; tag: string;
@@ -248,7 +247,7 @@ export function resolveHoverElement(target: Element): Element | null {
* (visible name), or at least a form `name_attr` for checkbox/radio. * (visible name), or at least a form `name_attr` for checkbox/radio.
* Recording “点击div” with no name fails this bar. * Recording “点击div” with no name fails this bar.
*/ */
export function isMeaningfulClickTarget(target: CaptureTargetDescriptor): boolean { export function isMeaningfulClickTarget(target: TargetDescriptor): boolean {
const name = target.name?.trim(); const name = target.name?.trim();
if (name && isActionableLabel(name)) return true; if (name && isActionableLabel(name)) return true;
if ( if (
@@ -324,7 +323,7 @@ function nearbyLabelText(el: Element): string | undefined {
return undefined; return undefined;
} }
export function describeTarget(el: Element): CaptureTargetDescriptor { export function describeTarget(el: Element): TargetDescriptor {
const tag = el.tagName.toLowerCase(); const tag = el.tagName.toLowerCase();
const role = inferRole(el); const role = inferRole(el);
const name = accessibleName(el); const name = accessibleName(el);
@@ -351,7 +350,7 @@ export function describeTarget(el: Element): CaptureTargetDescriptor {
}; };
} }
export function describeEventTarget(target: EventTarget | null): CaptureTargetDescriptor | null { export function describeEventTarget(target: EventTarget | null): TargetDescriptor | null {
if (!(target instanceof Element)) return null; if (!(target instanceof Element)) return null;
const clickable = resolveClickableElement(target); const clickable = resolveClickableElement(target);
if (!clickable) return null; if (!clickable) return null;
@@ -1,62 +0,0 @@
import type { Step } from "@/transport/types";
import { OBSERVATION_FILE_VERSION } from "./record-constants";
export interface ObservationAnnotation {
stepId: number;
op: Step["op"];
line: number;
stateId: string;
detail?: string;
}
function formatAnnotation({ stepId, op, detail }: ObservationAnnotation): string {
const suffix = detail ? `: ${detail}` : "";
return ` ⟵ step ${stepId}: ${op}${suffix}`;
}
/** Serialize a page observation file (front matter + VOM body + step annotations). */
export function formatObservationFile(input: {
stateId: string;
url: string;
title?: string;
stepsHere: number[];
body: string;
annotations?: ObservationAnnotation[];
}): string {
const lines: string[] = [
`# bsk-observation ${OBSERVATION_FILE_VERSION}`,
`state: ${input.stateId}`,
`url: ${input.url}`,
];
if (input.title) lines.push(`title: ${input.title}`);
if (input.stepsHere.length > 0) {
lines.push(`steps_here: [${input.stepsHere.join(", ")}]`);
}
lines.push("---");
const bodyLines = input.body.split("\n");
const annotationMap = new Map<number, ObservationAnnotation[]>();
for (const ann of input.annotations ?? []) {
const bucket = annotationMap.get(ann.line) ?? [];
bucket.push(ann);
annotationMap.set(ann.line, bucket);
}
for (let i = 0; i < bodyLines.length; i += 1) {
let line = bodyLines[i] ?? "";
const anns = annotationMap.get(i);
if (anns) {
for (const ann of anns) {
line += formatAnnotation(ann);
}
}
lines.push(line);
}
return `${lines.join("\n")}\n`;
}
/** Hash input: VOM body **before** annotations are inserted. */
export function observationBodyForHash(vomText: string): string {
return vomText;
}
+19
View File
@@ -16,6 +16,13 @@ export const HELP_ACK = "bsk-help-ack";
export const HELP_QUERY = "bsk-help-query"; export const HELP_QUERY = "bsk-help-query";
export const HELP_FINISH = "bsk-help-finish"; export const HELP_FINISH = "bsk-help-finish";
export interface HelpHighlightRect {
top: number;
left: number;
width: number;
height: number;
}
export interface HelpRequestMessage { export interface HelpRequestMessage {
type: typeof HELP_REQUEST; type: typeof HELP_REQUEST;
requestId: string; requestId: string;
@@ -26,6 +33,8 @@ export interface HelpRequestMessage {
displayMode?: "full" | "compact"; displayMode?: "full" | "compact";
/** CSS selectors to scroll to + flash-highlight (may be empty). */ /** CSS selectors to scroll to + flash-highlight (may be empty). */
selectors: string[]; selectors: string[];
/** Top-viewport rectangles for targets that cannot be represented by a root selector. */
rects?: HelpHighlightRect[];
timeoutMs: number; timeoutMs: number;
} }
@@ -74,6 +83,16 @@ export function isHelpRequestMessage(msg: unknown): msg is HelpRequestMessage {
(m.displayMode === undefined || m.displayMode === "full" || m.displayMode === "compact") && (m.displayMode === undefined || m.displayMode === "full" || m.displayMode === "compact") &&
Array.isArray(m.selectors) && Array.isArray(m.selectors) &&
m.selectors.every((selector) => typeof selector === "string") && 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" typeof m.timeoutMs === "number"
); );
} }
-102
View File
@@ -1,102 +0,0 @@
import type { RenderedRef } from "@browser-skill/vom";
import type { CapturedNode } from "@/tools/vom/capture";
import type { CaptureGeometry, TargetDescriptor } from "@/transport/types";
import type { CaptureTargetDescriptor } from "./describe-target";
import { GEOM_MATCH_TOLERANCE_PX } from "./record-constants";
export interface MatchTargetInput {
geometry: CaptureGeometry;
captured: CapturedNode[];
refs: RenderedRef[];
fallback?: CaptureTargetDescriptor;
}
function withinTolerance(a: number, b: number): boolean {
return Math.abs(a - b) <= GEOM_MATCH_TOLERANCE_PX;
}
function rectsMatch(
a: { x: number; y: number; w: number; h: number },
b: { x: number; y: number; w: number; h: number },
): boolean {
return (
withinTolerance(a.x, b.x) &&
withinTolerance(a.y, b.y) &&
withinTolerance(a.w, b.w) &&
withinTolerance(a.h, b.h)
);
}
function nodeViewportRect(
node: CapturedNode,
): { x: number; y: number; w: number; h: number } | null {
return node.localRect ?? node.rect;
}
function matchingRects(
geometry: CaptureGeometry,
node: CapturedNode,
): {
target: { x: number; y: number; w: number; h: number };
node: { x: number; y: number; w: number; h: number } | null;
} {
const viewportPosition = geometry.position === "fixed" || geometry.position === "sticky";
if (viewportPosition || !node.documentRect) {
return { target: geometry.rect, node: nodeViewportRect(node) };
}
return {
target: {
x: geometry.rect.x + geometry.scrollX,
y: geometry.rect.y + geometry.scrollY,
w: geometry.rect.w,
h: geometry.rect.h,
},
node: node.documentRect,
};
}
function tagMatches(geometryTag: string, nodeTag: string): boolean {
return geometryTag.toLowerCase() === nodeTag.toLowerCase();
}
/** Best description available when the element cannot be located in the VOM. */
export function fallbackDescriptor(fallback?: CaptureTargetDescriptor): TargetDescriptor {
if (!fallback) {
return { unmatched: true };
}
return {
...(fallback.role ? { role: fallback.role } : {}),
...(fallback.name ? { name: fallback.name } : {}),
unmatched: true,
};
}
/** Locate the interacted element in the last settled observation by geometry. */
export function matchTarget(input: MatchTargetInput): TargetDescriptor {
const ownerFrame = input.geometry.ownerFrameBackendNodeId ?? null;
const candidates = input.captured.filter((node) => {
if (ownerFrame !== (node.ownerFrameBackendNodeId ?? null)) return false;
if (!tagMatches(input.geometry.tag, node.tag)) return false;
const rects = matchingRects(input.geometry, node);
if (!rects.node) return false;
return rectsMatch(rects.target, rects.node);
});
if (candidates.length !== 1) {
return fallbackDescriptor(input.fallback);
}
const backendNodeId = candidates[0]!.backendNodeId;
const refEntry = input.refs.find((r) => r.backendNodeId === backendNodeId);
if (!refEntry) {
return fallbackDescriptor(input.fallback);
}
return {
ref: refEntry.ref,
...(refEntry.role ? { role: refEntry.role } : {}),
...(refEntry.name ? { name: refEntry.name } : {}),
...(refEntry.ctx ? { ctx: refEntry.ctx } : {}),
};
}
-92
View File
@@ -1,92 +0,0 @@
// Deciding *when* a page is worth observing.
//
// A fixed delay cannot serve both a modal that toggles in one frame and a
// route change that renders for a second. Instead of guessing, ask the page:
// a document that has stopped mutating and is no longer loading is done
// reacting to whatever the user just did.
import type { CdpRunner } from "@/tools/shared";
import { SETTLE_MAX_MS, SETTLE_MIN_MS, SETTLE_POLL_MS, SETTLE_QUIET_MS } from "./record-constants";
/**
* Installed once per document. Records only the timestamp of the last DOM
* change so each poll stays O(1) no matter how large the page is.
*/
const QUIET_PROBE = `(() => {
const scope = window;
let probe = scope.__bskRecordQuiet;
if (!probe) {
probe = { changedAt: Date.now() };
const observer = new MutationObserver(() => {
probe.changedAt = Date.now();
});
observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
scope.__bskRecordQuiet = probe;
}
return { idleMs: Date.now() - probe.changedAt, readyState: document.readyState };
})()`;
export type SettleOutcome =
/** The page stopped changing on its own. */
| "quiet"
/** Still changing when the budget ran out; observe it as it is. */
| "timeout"
/** A newer action took over; this observation is no longer wanted. */
| "cancelled";
interface QuietProbe {
idleMs: number;
readyState: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function readQuietProbe(cdp: CdpRunner, tabId: number): Promise<QuietProbe | null> {
try {
const reply = await cdp.send<{ result?: { value?: unknown } }>(tabId, "Runtime.evaluate", {
expression: QUIET_PROBE,
returnByValue: true,
});
const value = reply.result?.value;
if (!value || typeof value !== "object") return null;
const { idleMs, readyState } = value as { idleMs?: unknown; readyState?: unknown };
if (typeof idleMs !== "number" || typeof readyState !== "string") return null;
return { idleMs, readyState };
} catch {
// An unreadable page is a page mid-swap; that is a reason to keep waiting,
// not a reason to give up.
return null;
}
}
/** Wait until the page has finished reacting, or until the budget runs out. */
export async function waitForPageSettled(
cdp: CdpRunner,
tabId: number,
options: { cancelled?: () => boolean } = {},
): Promise<SettleOutcome> {
const startedAt = Date.now();
const floor = startedAt + SETTLE_MIN_MS;
const deadline = startedAt + SETTLE_MAX_MS;
for (;;) {
if (options.cancelled?.()) return "cancelled";
await sleep(SETTLE_POLL_MS);
if (options.cancelled?.()) return "cancelled";
const probe = await readQuietProbe(cdp, tabId);
const now = Date.now();
if (now < floor) continue;
if (now >= deadline) return "timeout";
if (!probe) continue;
if (probe.readyState === "loading") continue;
if (probe.idleMs >= SETTLE_QUIET_MS) return "quiet";
}
}
+4 -49
View File
@@ -3,8 +3,7 @@
* service worker and a tab's content script. * service worker and a tab's content script.
*/ */
import type { CaptureTargetDescriptor } from "./describe-target"; import type { TargetDescriptor } from "./describe-target";
import type { FrameCaptureFailure, FrameCaptureStatus } from "@/transport/types";
export const RECORD_START = "bsk-record-start"; export const RECORD_START = "bsk-record-start";
export const RECORD_STEP = "bsk-record-step"; export const RECORD_STEP = "bsk-record-step";
@@ -12,7 +11,6 @@ export const RECORD_STOP = "bsk-record-stop";
export const RECORD_CANCEL = "bsk-record-cancel"; export const RECORD_CANCEL = "bsk-record-cancel";
export const RECORD_FINISH = "bsk-record-finish"; export const RECORD_FINISH = "bsk-record-finish";
export const RECORD_QUERY = "bsk-record-query"; export const RECORD_QUERY = "bsk-record-query";
export const RECORD_CAPTURE_STATUS = "bsk-record-capture-status";
export interface RecordStartAck { export interface RecordStartAck {
ok: true; ok: true;
@@ -29,12 +27,8 @@ export interface RecordQueryResponse {
requestId?: string; requestId?: string;
/** Epoch ms when the recording began; see `RecordStartMessage.startedAtMs`. */ /** Epoch ms when the recording began; see `RecordStartMessage.startedAtMs`. */
startedAtMs?: number; startedAtMs?: number;
captureStatus?: FrameCaptureStatus;
captureFailures?: FrameCaptureFailure[];
} }
export type RecordFrameMode = "top" | "child";
export interface RecordStartMessage { export interface RecordStartMessage {
type: typeof RECORD_START; type: typeof RECORD_START;
requestId: string; requestId: string;
@@ -44,15 +38,11 @@ export interface RecordStartMessage {
* content-script remounts instead of restarting per page. * content-script remounts instead of restarting per page.
*/ */
startedAtMs?: number; startedAtMs?: number;
/** When true, the top-frame overlay should mount. Child frames omit this. */
showOverlay?: boolean;
/** Whether this document runs top-frame or child-frame capture semantics. */
frameMode?: RecordFrameMode;
} }
export interface RecordStepPayload { export interface RecordStepPayload {
op: "click" | "hover" | "fill" | "press" | "select" | "navigate" | "scroll"; op: "click" | "hover" | "fill" | "press" | "select" | "navigate";
target?: CaptureTargetDescriptor; target?: TargetDescriptor;
value?: string; value?: string;
key?: string; key?: string;
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">; modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
@@ -60,31 +50,12 @@ export interface RecordStepPayload {
labels?: string[]; labels?: string[];
url?: string; url?: string;
redacted?: boolean; redacted?: boolean;
commit?: "enter" | "suggestion" | "blur"; /** Page URL when the step was captured. */
/** Top-level tab URL when the step was captured. */
page_url?: string; page_url?: string;
/** URL of the frame document where the interaction occurred. */
frame_url?: string;
/** Ephemeral token stamped on the active document root; not persisted in trace steps. */
documentToken?: string;
/** Epoch ms when the step was captured in the content script. */
capturedAtMs?: number;
/** Document geometry at capture time for background geometric matching. */
geometry?: {
rect: { x: number; y: number; w: number; h: number };
scrollX: number;
scrollY: number;
position: string;
tag: string;
ownerFrameBackendNodeId?: number | null;
};
/** Capture-only hint; never persisted unless converted to navigated_to. */ /** Capture-only hint; never persisted unless converted to navigated_to. */
expects_navigation?: boolean; expects_navigation?: boolean;
/** Whether an observed URL change was synchronously caused by the action. */ /** Whether an observed URL change was synchronously caused by the action. */
navigation_caused_by_action?: boolean; navigation_caused_by_action?: boolean;
/** Raw webNavigation transition metadata for cause mapping. */
transitionType?: string;
transitionQualifiers?: string[];
} }
export interface RecordStepMessage { export interface RecordStepMessage {
@@ -108,12 +79,6 @@ export interface RecordFinishMessage {
requestId: string; requestId: string;
} }
export interface RecordCaptureStatusMessage {
type: typeof RECORD_CAPTURE_STATUS;
status: FrameCaptureStatus;
failures?: FrameCaptureFailure[];
}
export function isRecordStartMessage(msg: unknown): msg is RecordStartMessage { export function isRecordStartMessage(msg: unknown): msg is RecordStartMessage {
if (typeof msg !== "object" || msg === null) return false; if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>; const m = msg as Record<string, unknown>;
@@ -144,16 +109,6 @@ export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
return m.type === RECORD_QUERY; return m.type === RECORD_QUERY;
} }
export function isRecordCaptureStatusMessage(msg: unknown): msg is RecordCaptureStatusMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return (
m.type === RECORD_CAPTURE_STATUS &&
(m.status === "complete" || m.status === "partial") &&
(m.failures === undefined || Array.isArray(m.failures))
);
}
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage { export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
if (typeof msg !== "object" || msg === null) return false; if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>; const m = msg as Record<string, unknown>;
@@ -1,97 +0,0 @@
import type { FillCommit, NavigationCause } from "@/transport/types";
/**
* Floor before a post-action observation may be taken. A page usually has not
* started reacting in the first frames after an action, and capturing then
* would record the page as it was, not as the action left it.
*/
export const SETTLE_MIN_MS = 150;
/** How long the DOM must stop changing before a page counts as settled. */
export const SETTLE_QUIET_MS = 250;
/** Upper bound on waiting for a page to settle; animations never stop. */
export const SETTLE_MAX_MS = 2_000;
/** How often to ask the page whether it has gone quiet. */
export const SETTLE_POLL_MS = 60;
/** Minimum interval between consecutive observations on the same tab. */
export const OBSERVATION_MIN_INTERVAL_MS = 200;
/** Delay before retrying a capture that lost its execution context. */
export const CAPTURE_RETRY_DELAY_MS = 250;
/** Document-coordinate matching tolerance in CSS pixels. */
export const GEOM_MATCH_TOLERANCE_PX = 2;
/** Default max tokens per page observation when CLI omits `--max-page-tokens`. */
export const DEFAULT_MAX_PAGE_TOKENS = 3000;
/** VOM observation file format version (front matter header). */
export const OBSERVATION_FILE_VERSION = 1;
/** FNV-1a 64-bit offset basis. */
const FNV_OFFSET = 0xcbf29ce484222325n;
/** FNV-1a 64-bit prime. */
const FNV_PRIME = 0x100000001b3n;
/** Content-hash for state deduplication (sync, non-cryptographic). */
export function fnv1a64(text: string): string {
let hash = FNV_OFFSET;
for (let i = 0; i < text.length; i += 1) {
hash ^= BigInt(text.charCodeAt(i));
hash = (hash * FNV_PRIME) & 0xffffffffffffffffn;
}
return hash.toString(16).padStart(16, "0");
}
let nextStateSerial = 0;
/** Monotonic state id generator (`s1`, `s2`, …). */
export function nextStateId(): string {
nextStateSerial += 1;
return `s${nextStateSerial}`;
}
/** Test seam: reset the state id counter. */
export function resetStateIdCounterForTests(): void {
nextStateSerial = 0;
}
const REDIRECT_QUALIFIERS = new Set(["client_redirect", "server_redirect"]);
const TRANSITION_TO_CAUSE: Record<string, NavigationCause> = {
typed: "user_typed",
generated: "user_typed",
keyword: "user_typed",
keyword_generated: "user_typed",
link: "link",
form_submit: "form_submit",
reload: "reload",
auto_bookmark: "browser",
start_page: "browser",
};
export interface NavigationTransitionMeta {
transitionType?: string;
transitionQualifiers?: string[];
navigationActionPending?: boolean;
}
/** Map webNavigation metadata to protocol `NavigationCause`. Returns null for redirects. */
export function mapNavigationCause(meta: NavigationTransitionMeta): NavigationCause | null {
const qualifiers = meta.transitionQualifiers ?? [];
if (qualifiers.includes("forward_back")) return "history";
if (qualifiers.some((q) => REDIRECT_QUALIFIERS.has(q))) return null;
if (qualifiers.includes("from_address_bar")) return "user_typed";
const type = meta.transitionType ?? "";
const mapped = TRANSITION_TO_CAUSE[type];
if (mapped) return mapped;
if (!type && meta.navigationActionPending === false) return "script";
return "browser";
}
export const DEFAULT_FILL_COMMIT: FillCommit = "blur";
@@ -1,24 +0,0 @@
/** Attribute written on `<html>` while a document is actively recording. */
export const RECORD_DOCUMENT_TOKEN_ATTR = "data-bsk-record-document";
export function createRecordDocumentToken(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `doc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function stampRecordDocumentToken(token: string): void {
document.documentElement.setAttribute(RECORD_DOCUMENT_TOKEN_ATTR, token);
}
export function clearRecordDocumentToken(): void {
document.documentElement.removeAttribute(RECORD_DOCUMENT_TOKEN_ATTR);
}
export function readRecordDocumentTokenFromAttrs(
attrs: Record<string, string> | undefined,
): string | undefined {
const token = attrs?.[RECORD_DOCUMENT_TOKEN_ATTR];
return token?.trim() ? token : undefined;
}
@@ -1,250 +0,0 @@
import {
RECORD_CANCEL,
RECORD_START,
RECORD_STOP,
type RecordCancelMessage,
type RecordStartAck,
type RecordStartMessage,
type RecordStopAck,
type RecordStopMessage,
} from "@/lib/record-bridge";
import type { FrameCaptureFailure, FrameCaptureFailureReason } from "@/transport/types";
export interface WebNavigationFrameInfo {
frameId: number;
documentId?: string;
url?: string;
parentFrameId: number;
}
export interface ArmedDocument {
tabId: number;
frameId: number;
documentId: string;
url?: string;
parentFrameId: number;
}
export interface RecordFrameSendDeps {
sendToDocument(
tabId: number,
documentId: string,
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
): Promise<unknown>;
getAllFrames(tabId: number): Promise<WebNavigationFrameInfo[]>;
}
const RECORD_START_RETRIES = 3;
const RECORD_START_RETRY_DELAY_MS = 500;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function isFrameUrlRestricted(url: string | undefined): boolean {
if (!url) return false;
const lower = url.toLowerCase();
return (
lower.startsWith("chrome://") ||
lower.startsWith("chrome-extension://") ||
lower.startsWith("edge://") ||
lower.startsWith("devtools://") ||
lower.startsWith("devtools:") ||
lower.startsWith("https://chrome.google.com/webstore")
);
}
function isRecordStartAck(response: unknown): response is RecordStartAck {
return (
typeof response === "object" &&
response !== null &&
"ok" in response &&
(response as RecordStartAck).ok === true
);
}
function isRecordStopAckOk(response: unknown): boolean {
return (
typeof response === "object" &&
response !== null &&
"ok" in response &&
(response as RecordStopAck).ok === true
);
}
export function makeFrameCaptureFailure(
reason: FrameCaptureFailureReason,
input: { frameId: number; documentId?: string; url?: string; detail?: string },
): FrameCaptureFailure {
return {
reason,
frame_id: input.frameId,
...(input.documentId ? { document_id: input.documentId } : {}),
...(input.url ? { url: input.url } : {}),
...(input.detail ? { detail: input.detail } : {}),
};
}
export async function listInjectableFrames(
tabId: number,
deps: RecordFrameSendDeps,
): Promise<{ injectable: WebNavigationFrameInfo[]; failures: FrameCaptureFailure[] }> {
const failures: FrameCaptureFailure[] = [];
let frames: WebNavigationFrameInfo[] = [];
try {
frames = await deps.getAllFrames(tabId);
} catch (err) {
failures.push(
makeFrameCaptureFailure("arm_failed", {
frameId: 0,
detail: err instanceof Error ? err.message : "webNavigation.getAllFrames failed",
}),
);
return { injectable: [], failures };
}
const injectable = frames.filter((frame) => {
if (!frame.documentId) {
failures.push(
makeFrameCaptureFailure("not_injectable", {
frameId: frame.frameId,
url: frame.url,
detail: "missing documentId",
}),
);
return false;
}
if (isFrameUrlRestricted(frame.url)) {
failures.push(
makeFrameCaptureFailure("not_injectable", {
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
}),
);
return false;
}
return true;
});
return { injectable, failures };
}
export async function sendRecordStartWithAckToDocument(
tabId: number,
documentId: string,
msg: RecordStartMessage,
deps: RecordFrameSendDeps,
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) {
try {
const response = await deps.sendToDocument(tabId, documentId, msg);
if (isRecordStartAck(response)) return;
lastError = new Error("content script did not ack RECORD_START");
} catch (err) {
lastError = err;
}
if (attempt + 1 < RECORD_START_RETRIES) {
await sleep(RECORD_START_RETRY_DELAY_MS);
}
}
throw lastError ?? new Error("failed to start recording in content script");
}
export async function armDocumentCapture(
tabId: number,
frame: WebNavigationFrameInfo,
startMsg: RecordStartMessage,
deps: RecordFrameSendDeps,
): Promise<{ armed?: ArmedDocument; failure?: FrameCaptureFailure }> {
if (!frame.documentId) {
return {
failure: makeFrameCaptureFailure("not_injectable", {
frameId: frame.frameId,
url: frame.url,
detail: "missing documentId",
}),
};
}
if (isFrameUrlRestricted(frame.url)) {
return {
failure: makeFrameCaptureFailure("not_injectable", {
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
}),
};
}
try {
await sendRecordStartWithAckToDocument(tabId, frame.documentId, startMsg, deps);
return {
armed: {
tabId,
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
parentFrameId: frame.parentFrameId,
},
};
} catch (err) {
return {
failure: makeFrameCaptureFailure("arm_failed", {
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
detail: err instanceof Error ? err.message : "RECORD_START failed",
}),
};
}
}
export async function armAllDocumentsForTab(
tabId: number,
startMsg: RecordStartMessage,
deps: RecordFrameSendDeps,
): Promise<{ armed: ArmedDocument[]; failures: FrameCaptureFailure[] }> {
const { injectable, failures } = await listInjectableFrames(tabId, deps);
const armed: ArmedDocument[] = [];
for (const frame of injectable) {
const result = await armDocumentCapture(tabId, frame, startMsg, deps);
if (result.armed) armed.push(result.armed);
if (result.failure) failures.push(result.failure);
}
return { armed, failures };
}
export async function stopDocumentCapture(
armed: ArmedDocument,
stopMsg: RecordStopMessage,
deps: RecordFrameSendDeps,
): Promise<{ ok: true } | FrameCaptureFailure> {
try {
const response = await deps.sendToDocument(armed.tabId, armed.documentId, stopMsg);
if (isRecordStopAckOk(response)) return { ok: true };
return makeFrameCaptureFailure("flush_failed", {
frameId: armed.frameId,
documentId: armed.documentId,
url: armed.url,
detail: "content script did not confirm recorded steps",
});
} catch (err) {
return makeFrameCaptureFailure("flush_failed", {
frameId: armed.frameId,
documentId: armed.documentId,
url: armed.url,
detail: err instanceof Error ? err.message : "RECORD_STOP failed",
});
}
}
export async function cancelDocumentCapture(
armed: ArmedDocument,
cancelMsg: RecordCancelMessage,
deps: RecordFrameSendDeps,
): Promise<void> {
try {
await deps.sendToDocument(armed.tabId, armed.documentId, cancelMsg);
} catch {
// Best-effort cleanup.
}
}
@@ -1,673 +0,0 @@
import type { RenderedRef } from "@browser-skill/vom";
import { formatObservationFile, type ObservationAnnotation } from "@/lib/format-observation-file";
import { fallbackDescriptor, matchTarget } from "@/lib/match-target";
import { waitForPageSettled } from "@/lib/page-settled";
import {
CAPTURE_RETRY_DELAY_MS,
DEFAULT_MAX_PAGE_TOKENS,
OBSERVATION_MIN_INTERVAL_MS,
} from "@/lib/record-constants";
import { registerObservation, type StateRegistryEntry } from "@/lib/trace-reducer";
import { captureVomObservation } from "@/tools/capture-vom-observation";
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
import type { CapturedNode } from "@/tools/vom/capture";
import type { DraftTraceStep, Step, StopReason, Trace, TraceState } from "@/transport/types";
import { reduceTraceSteps, resolveTraceStartUrl } from "./trace-reducer";
export interface LastSettledObservation {
stateId: string;
captured: CapturedNode[];
matchNodes: CapturedNode[];
documentTokenOwners: Map<string, number | null>;
refs: RenderedRef[];
url: string;
title?: string;
vomText: string;
}
/** A post-action observation that has been queued but not yet written down. */
interface PendingSettle {
draftIndex: number;
/** Set when a newer action has already decided where this step landed. */
cancelled: boolean;
}
/** Latest URL in an in-flight redirect chain awaiting coalesce + settle. */
export interface PendingRedirectLanding {
url: string;
/** Bumped on every hop so an in-flight `waitForPageSettled` can cancel. */
generation: number;
}
export interface RecordingObservationState {
stateRegistry: Map<string, StateRegistryEntry>;
lastSettled: LastSettledObservation | null;
/** Latest token→owner map from the most recent observation capture. */
documentTokenOwners: Map<string, number | null>;
maxPageTokens: number;
redactValues: boolean;
lastCaptureAtMs: number;
/**
* Tail of the settle chain. Observations run one at a time so that a slow
* capture can never land after a faster one and report the pages out of the
* order the user visited them.
*/
settleQueue: Promise<void>;
/** Queued and in-flight settles, by draft index, so newer actions can supersede them. */
settles: Map<number, PendingSettle>;
stepAnnotations: Map<number, ObservationAnnotation[]>;
/**
* OAuth / server redirects are coalesced here: intermediate hops only update
* `url`+`generation`, and one `navigate` is emitted after the page settles.
*/
pendingRedirect: PendingRedirectLanding | null;
redirectFlushQueue: Promise<void>;
}
export function createObservationState(options?: {
maxPageTokens?: number;
redactValues?: boolean;
}): RecordingObservationState {
return {
stateRegistry: new Map(),
lastSettled: null,
documentTokenOwners: new Map(),
maxPageTokens: options?.maxPageTokens ?? DEFAULT_MAX_PAGE_TOKENS,
redactValues: options?.redactValues ?? false,
lastCaptureAtMs: 0,
settleQueue: Promise.resolve(),
settles: new Map(),
stepAnnotations: new Map(),
pendingRedirect: null,
redirectFlushQueue: Promise.resolve(),
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function readTabMeta(
tabsApi: ChromeTabsApi,
tabId: number,
): Promise<{ url: string; title?: string }> {
try {
const tab = await tabsApi.get(tabId);
return { url: tab.url ?? "about:blank", title: tab.title };
} catch {
return { url: "about:blank" };
}
}
export async function captureAndRegisterObservation(
obs: RecordingObservationState,
cdp: CdpRunner,
tabId: number,
tabsApi: ChromeTabsApi,
urlOverride?: string,
): Promise<LastSettledObservation> {
const now = Date.now();
const waitMs = Math.max(0, OBSERVATION_MIN_INTERVAL_MS - (now - obs.lastCaptureAtMs));
if (waitMs > 0) await sleep(waitMs);
const { url, title } = urlOverride
? { url: urlOverride, title: undefined }
: await readTabMeta(tabsApi, tabId);
const meta = urlOverride ? await readTabMeta(tabsApi, tabId) : { url, title };
const resolvedTitle = title ?? meta.title;
const rendered = await captureVomObservation(cdp, tabId, url, {
maxTokens: obs.maxPageTokens,
redactValues: obs.redactValues,
conditionalSurfaceProbe: false,
});
const stateId = registerObservation(obs.stateRegistry, {
url,
title: resolvedTitle,
rawVomText: rendered.text,
truncated: rendered.truncated,
});
obs.lastCaptureAtMs = Date.now();
obs.documentTokenOwners = rendered.documentTokenOwners;
const settled: LastSettledObservation = {
stateId,
captured: rendered.captured,
matchNodes: rendered.matchNodes,
documentTokenOwners: rendered.documentTokenOwners,
refs: rendered.refs,
url,
title: resolvedTitle,
vomText: rendered.text,
};
obs.lastSettled = settled;
return settled;
}
/**
* Bind a draft to the page it was performed on: origin state, geometric target
* match, and the inline annotation. All three read the *current* observation,
* which is only the page the user acted on while the draft is still fresh —
* once the action settles, `lastSettled` is the destination page instead.
*/
export function applyTargetMatching(
obs: RecordingObservationState,
draft: DraftTraceStep,
stepId: number,
): void {
if (!obs.lastSettled) {
// Initial observation can fail or race the first action. Keep whatever
// the content script captured so the exported step is still teachable.
if ("target" in draft && "captureTarget" in draft) {
draft.target = fallbackDescriptor(draft.captureTarget);
}
rememberStepAnnotation(obs, stepId, draft);
return;
}
draft.preStateId = obs.lastSettled.stateId;
if ("target" in draft) {
draft.target =
"geometry" in draft && draft.geometry
? matchTarget({
geometry: draft.geometry,
captured: obs.lastSettled.matchNodes,
refs: obs.lastSettled.refs,
fallback: draft.captureTarget,
})
: // No geometry to match on, but the capture still knew what the user
// touched — keep that instead of an anonymous unmatched target.
fallbackDescriptor(draft.captureTarget);
}
rememberStepAnnotation(obs, stepId, draft);
}
function fillDetailForDraft(
obs: RecordingObservationState,
draft: DraftTraceStep,
): string | undefined {
if (obs.redactValues) return undefined;
if (draft.op === "fill") return JSON.stringify(draft.value);
return undefined;
}
function refLineForDraft(
obs: RecordingObservationState,
draft: DraftTraceStep,
): number | undefined {
if (!("target" in draft) || !obs.lastSettled) return undefined;
const targetRef = draft.target?.ref;
if (!targetRef) return undefined;
return obs.lastSettled.refs.find((r) => r.ref === targetRef)?.line;
}
function rememberStepAnnotation(
obs: RecordingObservationState,
stepId: number,
draft: DraftTraceStep,
): void {
const line = refLineForDraft(obs, draft);
if (line === undefined || draft.op === "navigate" || draft.op === "scroll") return;
if (!draft.preStateId) return;
const ann: ObservationAnnotation = {
stepId,
op: draft.op,
line,
stateId: draft.preStateId,
detail: fillDetailForDraft(obs, draft),
};
const bucket = obs.stepAnnotations.get(line) ?? [];
bucket.push(ann);
obs.stepAnnotations.set(line, bucket);
}
/** Record the step under the page it was performed on, not the one it led to. */
export function rememberStepOnPage(
obs: RecordingObservationState,
draft: DraftTraceStep,
stepId: number,
): void {
const stateId = draft.preStateId;
if (!stateId) return;
const entry = obs.stateRegistry.get(stateId);
if (entry && !entry.stepsHere.includes(stepId)) {
entry.stepsHere.push(stepId);
}
}
/** Drop an in-flight redirect coalesce (e.g. a real navigate superseded it). */
export function clearPendingRedirectLanding(obs: RecordingObservationState): void {
obs.pendingRedirect = null;
}
function enqueueRedirectFlush(obs: RecordingObservationState, task: () => Promise<void>): void {
obs.redirectFlushQueue = obs.redirectFlushQueue.then(task, task).catch(() => {});
}
/**
* Remember a redirect hop and schedule a settle-then-emit of a single navigate
* to the final URL. Later hops only bump `generation` / replace `url`.
*/
export function scheduleRedirectLandingFlush(
obs: RecordingObservationState,
steps: DraftTraceStep[],
cdp: CdpRunner,
tabId: number,
tabsApi: ChromeTabsApi,
url: string,
options: { cancelled?: () => boolean } = {},
): void {
const generation = (obs.pendingRedirect?.generation ?? 0) + 1;
obs.pendingRedirect = { url, generation };
enqueueRedirectFlush(obs, () =>
coalesceRedirectLanding(obs, steps, cdp, tabId, tabsApi, options),
);
}
/**
* Drain any coalesced redirect so the next action matches against the real
* landing page. Safe to call when nothing is pending.
*/
export async function flushPendingRedirectLanding(
obs: RecordingObservationState,
steps: DraftTraceStep[],
cdp: CdpRunner | undefined,
tabId: number,
tabsApi: ChromeTabsApi,
options: { cancelled?: () => boolean } = {},
): Promise<void> {
if (obs.pendingRedirect && cdp) {
enqueueRedirectFlush(obs, () =>
coalesceRedirectLanding(obs, steps, cdp, tabId, tabsApi, options),
);
}
await obs.redirectFlushQueue;
}
/**
* Wait until the redirect chain stops changing the document, then emit one
* `navigate` (cause `browser`) to the tab's final URL and settle its observation.
*/
async function coalesceRedirectLanding(
obs: RecordingObservationState,
steps: DraftTraceStep[],
cdp: CdpRunner,
tabId: number,
tabsApi: ChromeTabsApi,
options: { cancelled?: () => boolean },
): Promise<void> {
while (obs.pendingRedirect) {
if (options.cancelled?.()) {
obs.pendingRedirect = null;
return;
}
const snap = obs.pendingRedirect;
const outcome = await waitForPageSettled(cdp, tabId, {
cancelled: () =>
!!options.cancelled?.() ||
obs.pendingRedirect === null ||
obs.pendingRedirect.generation !== snap.generation,
});
if (options.cancelled?.()) {
obs.pendingRedirect = null;
return;
}
// A newer hop cancelled this wait — loop and settle against the latest URL.
if (outcome === "cancelled") continue;
if (!obs.pendingRedirect || obs.pendingRedirect.generation !== snap.generation) {
continue;
}
const finalUrl = (await readTabMeta(tabsApi, tabId)).url || snap.url;
obs.pendingRedirect = null;
if (!finalUrl || finalUrl === "about:blank") return;
if (obs.lastSettled?.url === finalUrl) return;
const last = steps[steps.length - 1];
if (last?.op === "navigate" && last.url === finalUrl) {
if (!last.postStateId) {
const draftIndex = steps.length - 1;
scheduleDraftSettle(obs, draftIndex, draftIndex + 1, cdp, tabId, tabsApi, steps);
await obs.settleQueue;
}
return;
}
if (outcome === "timeout") {
console.debug(
`[bsk record] redirect landing still changing after settle budget; ` +
`recording navigate to ${finalUrl}`,
);
}
const draft: DraftTraceStep = {
op: "navigate",
url: finalUrl,
page_url: finalUrl,
cause: "browser",
preStateId: obs.lastSettled?.stateId,
};
steps.push(draft);
const draftIndex = steps.length - 1;
const stepId = draftIndex + 1;
rememberStepOnPage(obs, draft, stepId);
scheduleDraftSettle(obs, draftIndex, stepId, cdp, tabId, tabsApi, steps);
// Callers that flush before the next action need `lastSettled` to already
// be the landing page so target matching does not use the pre-redirect view.
await obs.settleQueue;
return;
}
}
/**
* A capture that lands mid-navigation fails on a destroyed execution context.
* That is transient, so give it one more chance before the step has to fall
* back to a stale state.
*/
async function captureWithRetry(
obs: RecordingObservationState,
cdp: CdpRunner,
tabId: number,
tabsApi: ChromeTabsApi,
urlOverride?: string,
): Promise<LastSettledObservation> {
try {
return await captureAndRegisterObservation(obs, cdp, tabId, tabsApi, urlOverride);
} catch (err) {
console.debug("[bsk record] observation failed, retrying once", err);
await sleep(CAPTURE_RETRY_DELAY_MS);
return captureAndRegisterObservation(obs, cdp, tabId, tabsApi, urlOverride);
}
}
export async function settleDraftObservation(
obs: RecordingObservationState,
cdp: CdpRunner,
tabId: number,
tabsApi: ChromeTabsApi,
steps: DraftTraceStep[],
draftIndex: number,
pending?: PendingSettle,
): Promise<void> {
const cancelled = () => pending?.cancelled === true;
const outcome = await waitForPageSettled(cdp, tabId, { cancelled });
if (outcome === "cancelled") return;
if (outcome === "timeout") {
console.debug(
`[bsk record] step ${draftIndex + 1} was still changing the page after the settle ` +
"budget; observing it as it stands",
);
}
const started = steps[draftIndex];
if (!started) return;
// The URL attached to the draft may only be an intermediate redirect hop.
// Once the page is settled, register the capture against live tab metadata.
const settled = await captureWithRetry(obs, cdp, tabId, tabsApi);
// The observation still counts as the latest view of the page even when this
// step no longer wants it — the next action will start from it.
if (cancelled()) return;
// Re-read the slot: a navigation observed while the capture ran may have
// rewritten this draft, and the observation belongs to whatever occupies the
// slot now — writing to the object we started with would strand it.
const draft = steps[draftIndex] ?? started;
draft.postStateId = settled.stateId;
}
/**
* Recover landing states from the shape of the recording itself: wherever the
* next action was performed is, by definition, where the previous one landed.
* Only the immediately following draft counts — a later one would claim a page
* that several unobserved actions away, which is worse than admitting we saw
* no change.
*/
export function inferMissingPostStates(steps: DraftTraceStep[]): void {
for (let i = 0; i < steps.length - 1; i += 1) {
const draft = steps[i];
const next = steps[i + 1];
if (!draft || !next || draft.postStateId || !next.preStateId) continue;
draft.postStateId = next.preStateId;
console.debug(
`[bsk record] step ${i + 1} (${draft.op}) had no post-action observation; ` +
`using where step ${i + 2} started (${next.preStateId})`,
);
}
}
/**
* Last chance for the closing actions of a recording. Stopping right after the
* final action is normal, and so is that action navigating away, so take one
* final look at the page. Only trailing drafts may claim it: an earlier step
* did not land on whatever the user happens to be looking at when they stop.
*/
export async function settleUnsettledDrafts(
obs: RecordingObservationState,
cdp: CdpRunner,
tabId: number,
tabsApi: ChromeTabsApi,
steps: DraftTraceStep[],
): Promise<void> {
const trailing: DraftTraceStep[] = [];
for (let i = steps.length - 1; i >= 0; i -= 1) {
const draft = steps[i];
if (!draft || draft.postStateId) break;
trailing.push(draft);
}
if (trailing.length === 0) return;
let settled: LastSettledObservation;
try {
settled = await captureWithRetry(obs, cdp, tabId, tabsApi);
} catch (err) {
console.warn("[bsk record] final observation at stop failed", err);
return;
}
for (const draft of trailing) draft.postStateId = settled.stateId;
console.debug(
`[bsk record] settled ${trailing.length} trailing step(s) against the page at stop ` +
`(${settled.stateId} ${settled.url})`,
);
}
/**
* Performing a new action ends the previous one: the page the user reached for
* is, by definition, where the earlier step left them. Taking the landing from
* the newer step also keeps the trace monotonic — a capture that finished late
* would otherwise credit an earlier step with a page that only exists because
* of the newer action.
*/
function supersedeEarlierSettles(
obs: RecordingObservationState,
draftIndex: number,
steps: DraftTraceStep[],
): void {
const landing = steps[draftIndex]?.preStateId;
for (const [index, pending] of obs.settles) {
if (index >= draftIndex) continue;
pending.cancelled = true;
obs.settles.delete(index);
const draft = steps[index];
if (!draft || draft.postStateId) continue;
if (!landing) continue;
draft.postStateId = landing;
console.debug(
`[bsk record] step ${index + 1} (${draft.op}) was still settling when step ` +
`${draftIndex + 1} started; landing it on ${landing}`,
);
}
}
export function scheduleDraftSettle(
obs: RecordingObservationState,
draftIndex: number,
stepId: number,
cdp: CdpRunner,
tabId: number,
tabsApi: ChromeTabsApi,
steps: DraftTraceStep[],
): void {
supersedeEarlierSettles(obs, draftIndex, steps);
// Rescheduling the same step (a navigation showed up after the action)
// replaces the pending observation rather than racing it.
const superseded = obs.settles.get(draftIndex);
if (superseded) superseded.cancelled = true;
const pending: PendingSettle = { draftIndex, cancelled: false };
obs.settles.set(draftIndex, pending);
enqueueSettle(obs, async () => {
if (pending.cancelled) return;
try {
await settleDraftObservation(obs, cdp, tabId, tabsApi, steps, draftIndex, pending);
} catch (err) {
// Recoverable: stop-time repair still gives the step a landing page.
const op = steps[draftIndex]?.op ?? "?";
console.warn(`[bsk record] post-action observation failed for step ${stepId} (${op})`, err);
} finally {
if (obs.settles.get(draftIndex) === pending) obs.settles.delete(draftIndex);
}
});
}
function enqueueSettle(obs: RecordingObservationState, task: () => Promise<void>): void {
obs.settleQueue = obs.settleQueue.then(task, task).catch(() => {});
}
export function cancelPendingSettles(obs: RecordingObservationState): void {
for (const pending of obs.settles.values()) pending.cancelled = true;
obs.settles.clear();
}
/** Bound the drain loop so a self-rescheduling settle cannot block stop. */
const MAX_SETTLE_FLUSH_ROUNDS = 10;
/**
* Drain settle work before exporting, including work queued while draining —
* a navigation observed during the last capture schedules another one, and
* the trace would drop it if stop did not wait.
*/
export async function flushPendingSettles(obs: RecordingObservationState): Promise<void> {
for (let round = 0; round < MAX_SETTLE_FLUSH_ROUNDS; round += 1) {
const drained = obs.settleQueue;
await drained;
if (obs.settleQueue === drained) return;
}
console.warn("[bsk record] settle queue kept growing at stop; exporting what has been observed");
}
function finalizeStateBodies(
entries: StateRegistryEntry[],
annotationsByState: Map<string, ObservationAnnotation[]>,
stepIdByDraftId: Map<number, number>,
idByOldId: Map<string, string>,
): TraceState[] {
return entries.map((entry) => {
const id = idByOldId.get(entry.id) ?? entry.id;
const body = formatObservationFile({
stateId: id,
url: entry.url,
title: entry.title,
stepsHere: remapStepIds(entry.stepsHere, stepIdByDraftId),
body: entry.rawVomText,
annotations: annotationsByState.get(entry.id) ?? [],
});
return {
id,
url: entry.url,
...(entry.title ? { title: entry.title } : {}),
body,
...(entry.truncated ? { truncated: true } : {}),
};
});
}
/** Draft ids only become step ids after collapsing and filtering. */
function remapStepIds(draftIds: number[], stepIdByDraftId: Map<number, number>): number[] {
const mapped = new Set<number>();
for (const draftId of draftIds) {
const stepId = stepIdByDraftId.get(draftId);
if (stepId !== undefined) mapped.add(stepId);
}
return [...mapped].sort((a, b) => a - b);
}
function collectAnnotationsByState(
obs: RecordingObservationState,
stepIdByDraftId: Map<number, number>,
): Map<string, ObservationAnnotation[]> {
const byState = new Map<string, ObservationAnnotation[]>();
for (const anns of obs.stepAnnotations.values()) {
for (const ann of anns) {
const stepId = stepIdByDraftId.get(ann.stepId);
if (stepId === undefined) continue;
const bucket = byState.get(ann.stateId) ?? [];
bucket.push({ ...ann, stepId });
byState.set(ann.stateId, bucket);
}
}
return byState;
}
/**
* Keep only the pages the published steps point at. Redirect hops and
* mid-load captures land in the registry too, and shipping them would invite
* a reader to treat a page the flow merely passed through as a real stop.
* With no steps at all the first observation is the whole artifact, so it
* stays.
*/
function selectPublishedStates(
registry: Map<string, StateRegistryEntry>,
steps: Step[],
): StateRegistryEntry[] {
const entries = [...registry.values()];
if (steps.length === 0) return entries.slice(0, 1);
const referenced = new Set<string>();
for (const step of steps) {
referenced.add(step.state);
referenced.add(step.result.state);
}
return entries.filter((entry) => referenced.has(entry.id));
}
export function buildTraceV3(input: {
obs: RecordingObservationState;
steps: DraftTraceStep[];
startedAt: string;
purpose?: string;
startUrl?: string;
stoppedBy: StopReason;
bskVersion: string;
frameCapture?: Trace["frame_capture"];
}): Trace {
const { steps, stepIdByDraftId } = reduceTraceSteps(input.steps, input.obs.stateRegistry);
const published = selectPublishedStates(input.obs.stateRegistry, steps);
// Renumber so the shipped dictionary reads s1..sN without holes where the
// dropped captures used to be.
const idByOldId = new Map(published.map((entry, index) => [entry.id, `s${index + 1}`]));
for (const step of steps) {
step.state = idByOldId.get(step.state) ?? step.state;
step.result.state = idByOldId.get(step.result.state) ?? step.result.state;
}
const annotationsByState = collectAnnotationsByState(input.obs, stepIdByDraftId);
const states = finalizeStateBodies(published, annotationsByState, stepIdByDraftId, idByOldId);
const startUrl = resolveTraceStartUrl(input.steps, input.startUrl, states);
return {
version: 3,
...(input.purpose ? { purpose: input.purpose } : {}),
recorded_at: new Date().toISOString(),
started_at: input.startedAt,
stopped_by: input.stoppedBy,
entry: { start_url: startUrl },
recorder: { bsk: input.bskVersion, vom: 1 },
...(input.frameCapture ? { frame_capture: input.frameCapture } : {}),
states,
steps,
};
}
+22 -98
View File
@@ -1,29 +1,23 @@
import type { DraftTraceStep, NavigationCause } from "@/transport/types"; import type { DraftTraceStep } from "@/transport/types";
import type { RecordStepPayload } from "./record-bridge"; import type { RecordStepPayload } from "./record-bridge";
import { mapNavigationCause } from "./record-constants";
export interface RecordingStepBuffer { export interface RecordingStepBuffer {
steps: DraftTraceStep[]; steps: DraftTraceStep[];
currentUrl?: string; currentUrl?: string;
pendingNavigation: boolean; pendingNavigation: boolean;
pendingNavigationDeadline?: number; pendingNavigationDeadline?: number;
lastTransitionType?: string;
lastTransitionQualifiers?: string[];
} }
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000; const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null { function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
const pageUrl = payload.page_url; const pageUrl = payload.page_url;
const geometry = payload.geometry;
switch (payload.op) { switch (payload.op) {
case "click": case "click":
return payload.target return payload.target
? { ? {
op: "click", op: "click",
target: { unmatched: true }, target: payload.target,
captureTarget: payload.target,
...(geometry ? { geometry } : {}),
...(pageUrl ? { page_url: pageUrl } : {}), ...(pageUrl ? { page_url: pageUrl } : {}),
} }
: null; : null;
@@ -31,9 +25,7 @@ function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
return payload.target return payload.target
? { ? {
op: "hover", op: "hover",
target: { unmatched: true }, target: payload.target,
captureTarget: payload.target,
...(geometry ? { geometry } : {}),
...(pageUrl ? { page_url: pageUrl } : {}), ...(pageUrl ? { page_url: pageUrl } : {}),
} }
: null; : null;
@@ -41,11 +33,8 @@ function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
return payload.target return payload.target
? { ? {
op: "fill", op: "fill",
target: { unmatched: true }, target: payload.target,
captureTarget: payload.target,
...(geometry ? { geometry } : {}),
value: payload.value ?? "", value: payload.value ?? "",
...(payload.commit ? { commit: payload.commit } : {}),
...(payload.redacted ? { redacted: true } : {}), ...(payload.redacted ? { redacted: true } : {}),
...(pageUrl ? { page_url: pageUrl } : {}), ...(pageUrl ? { page_url: pageUrl } : {}),
} }
@@ -55,10 +44,7 @@ function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
? { ? {
op: "press", op: "press",
key: payload.key, key: payload.key,
...(payload.target ...(payload.target ? { target: payload.target } : {}),
? { target: { unmatched: true }, captureTarget: payload.target }
: {}),
...(geometry ? { geometry } : {}),
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}), ...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
...(pageUrl ? { page_url: pageUrl } : {}), ...(pageUrl ? { page_url: pageUrl } : {}),
} }
@@ -67,69 +53,37 @@ function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
return payload.target && payload.values return payload.target && payload.values
? { ? {
op: "select", op: "select",
target: { unmatched: true }, target: payload.target,
captureTarget: payload.target,
...(geometry ? { geometry } : {}),
values: payload.values, values: payload.values,
...(payload.labels?.length ? { labels: payload.labels } : {}), ...(payload.labels?.length ? { labels: payload.labels } : {}),
...(pageUrl ? { page_url: pageUrl } : {}), ...(pageUrl ? { page_url: pageUrl } : {}),
} }
: null; : null;
case "scroll":
return { op: "scroll", ...(pageUrl ? { page_url: pageUrl } : {}) };
case "navigate": case "navigate":
return null; return null;
} }
} }
/** Annotate the latest action with `navigated_to`. Returns its index, or -1. */ function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): boolean {
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): number {
for (let i = buffer.steps.length - 1; i >= 0; i -= 1) { for (let i = buffer.steps.length - 1; i >= 0; i -= 1) {
const step = buffer.steps[i]; const step = buffer.steps[i];
if (!step) continue; if (!step) continue;
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") { if (step.op === "click" || step.op === "press" || step.op === "select") {
// Mutate in place: an in-flight settle holds this object, and replacing buffer.steps[i] = { ...step, navigated_to: url };
// it would strand that settle's observation on the discarded copy. return true;
step.navigated_to = url;
return i;
} }
break; break;
} }
return -1; return false;
} }
function causeFromBuffer(buffer: RecordingStepBuffer): NavigationCause | null {
return mapNavigationCause({
transitionType: buffer.lastTransitionType,
transitionQualifiers: buffer.lastTransitionQualifiers,
});
}
/** Outcome of folding a main-frame URL change into the draft buffer. */
export type NavigationObserveResult =
| { kind: "noop" }
| { kind: "annotated"; index: number }
| { kind: "appended"; index: number }
/**
* Redirect hop: do not emit a step yet. The recorder coalesces the chain and
* emits one `navigate` after `waitForPageSettled` on the final URL.
*/
| { kind: "coalesce_redirect"; url: string };
export function observeRecordedNavigation( export function observeRecordedNavigation(
buffer: RecordingStepBuffer, buffer: RecordingStepBuffer,
url: string, url: string,
causedByAction?: boolean, causedByAction?: boolean,
transitionType?: string, ): void {
transitionQualifiers?: string[], if (!url || url === buffer.currentUrl) return;
): NavigationObserveResult {
if (!url || url === buffer.currentUrl) return { kind: "noop" };
buffer.currentUrl = url; buffer.currentUrl = url;
if (transitionType !== undefined) buffer.lastTransitionType = transitionType;
if (transitionQualifiers !== undefined) {
buffer.lastTransitionQualifiers = transitionQualifiers;
}
const pendingIsCurrent = const pendingIsCurrent =
buffer.pendingNavigation && buffer.pendingNavigation &&
(buffer.pendingNavigationDeadline === undefined || (buffer.pendingNavigationDeadline === undefined ||
@@ -138,69 +92,40 @@ export function observeRecordedNavigation(
if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) { if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) {
buffer.pendingNavigation = false; buffer.pendingNavigation = false;
buffer.pendingNavigationDeadline = undefined; buffer.pendingNavigationDeadline = undefined;
const annotatedIndex = annotateLastStepNavigation(buffer, url); if (!annotateLastStepNavigation(buffer, url)) {
if (annotatedIndex >= 0) {
return { kind: "annotated", index: annotatedIndex };
}
const cause = causeFromBuffer(buffer);
if (cause === null) {
return { kind: "coalesce_redirect", url };
}
buffer.steps.push({ buffer.steps.push({
op: "navigate", op: "navigate",
url, url,
page_url: url, page_url: url,
cause,
transitionType: buffer.lastTransitionType,
transitionQualifiers: buffer.lastTransitionQualifiers,
}); });
return { kind: "appended", index: buffer.steps.length - 1 }; }
return;
} }
buffer.pendingNavigation = false; buffer.pendingNavigation = false;
buffer.pendingNavigationDeadline = undefined; buffer.pendingNavigationDeadline = undefined;
const cause = causeFromBuffer(buffer);
if (cause === null) {
return { kind: "coalesce_redirect", url };
}
buffer.steps.push({ buffer.steps.push({
op: "navigate", op: "navigate",
url, url,
page_url: url, page_url: url,
cause,
transitionType: buffer.lastTransitionType,
transitionQualifiers: buffer.lastTransitionQualifiers,
}); });
return { kind: "appended", index: buffer.steps.length - 1 };
} }
/**
* Buffer one captured payload. Returns the index of the draft it appended, or
* `null` when the payload produced none (an unnamed click target, a navigation
* that only annotated the previous draft, …). Callers must not fall back to
* "the last draft" on `null`: that would re-match an already recorded step
* against a newer observation and overwrite its origin state.
*/
export function appendRecordedPayload( export function appendRecordedPayload(
buffer: RecordingStepBuffer, buffer: RecordingStepBuffer,
payload: RecordStepPayload, payload: RecordStepPayload,
): number | null { ): void {
if (payload.op === "navigate") { if (payload.op === "navigate") {
if (!payload.url) return null; if (payload.url) {
const result = observeRecordedNavigation( observeRecordedNavigation(buffer, payload.url, payload.navigation_caused_by_action);
buffer, }
payload.url, return;
payload.navigation_caused_by_action,
payload.transitionType,
payload.transitionQualifiers,
);
return result.kind === "appended" ? result.index : null;
} }
const step = toDraftStep({ const step = toDraftStep({
...payload, ...payload,
page_url: payload.page_url ?? buffer.currentUrl, page_url: payload.page_url ?? buffer.currentUrl,
}); });
if (!step) return null; if (!step) return;
buffer.steps.push(step); buffer.steps.push(step);
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") { if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
buffer.pendingNavigation = payload.expects_navigation === true; buffer.pendingNavigation = payload.expects_navigation === true;
@@ -208,5 +133,4 @@ export function appendRecordedPayload(
? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS ? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS
: undefined; : undefined;
} }
return buffer.steps.length - 1;
} }
+141 -153
View File
@@ -1,17 +1,4 @@
import type { import type { DraftTraceStep, PageRef, SelectedOption, Step } from "@/transport/types";
DraftTraceStep,
FillCommit,
NavigationCause,
Step,
TraceState,
} from "@/transport/types";
import {
DEFAULT_FILL_COMMIT,
fnv1a64,
mapNavigationCause,
nextStateId,
resetStateIdCounterForTests,
} from "./record-constants";
const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "A", "C", "V", "X"]); 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"]); const MODIFIER_ONLY_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "Hyper", "Super"]);
@@ -25,6 +12,7 @@ export function shouldRecordPress(
const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta"); const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta");
if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false; if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false;
if (key === "Enter" || key === "Escape") return true; 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; if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false;
return false; return false;
} }
@@ -34,191 +22,195 @@ function shouldIncludeDraft(step: DraftTraceStep): boolean {
return true; return true;
} }
/** A reduced draft plus the capture-order ids of every draft folded into it. */ /** Collapse consecutive navigations to the last hop. */
interface CollapsedDraft { function collapseNavigations(steps: DraftTraceStep[]): DraftTraceStep[] {
draft: DraftTraceStep; const out: DraftTraceStep[] = [];
draftIds: number[]; for (const step of steps) {
}
/**
* Collapse a redirect chain into a single hop.
*
* The surviving step keeps the *first* hop's origin state and cause — that is
* where the user was and why they left — and the *last* hop's destination, so
* the intermediate URL the browser only passed through never becomes the
* step's `state` or `to`.
*/
function collapseNavigations(steps: DraftTraceStep[]): CollapsedDraft[] {
const out: CollapsedDraft[] = [];
steps.forEach((step, index) => {
const draftId = index + 1;
const prev = out[out.length - 1]; const prev = out[out.length - 1];
if (step.op === "navigate" && prev?.draft.op === "navigate") { if (step.op === "navigate" && prev?.op === "navigate") {
prev.draft = { out[out.length - 1] = step;
...prev.draft, continue;
url: step.url, }
postStateId: step.postStateId ?? prev.draft.postStateId, out.push(step);
};
prev.draftIds.push(draftId);
return;
} }
out.push({ draft: step, draftIds: [draftId] });
});
return out; return out;
} }
export interface StateRegistryEntry extends TraceState { function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
contentHash: string; const urls: string[] = [];
rawVomText: string; if (startUrl) urls.push(startUrl);
stepsHere: number[]; for (const step of steps) {
} if (step.op === "navigate") {
urls.push(step.url);
export function stateDedupKey(body: string, url: string): string { continue;
return `${fnv1a64(body)}:${url}`;
}
/** Register or reuse a page observation in the states dictionary. */
export function registerObservation(
registry: Map<string, StateRegistryEntry>,
input: {
url: string;
title?: string;
rawVomText: string;
truncated?: boolean;
},
): string {
const key = stateDedupKey(input.rawVomText, input.url);
for (const entry of registry.values()) {
if (entry.contentHash === key) return entry.id;
} }
const id = nextStateId(); if ("page_url" in step && step.page_url) urls.push(step.page_url);
registry.set(id, { if ("navigated_to" in step && step.navigated_to) urls.push(step.navigated_to);
id, }
url: input.url, const seen = new Set<string>();
...(input.title ? { title: input.title } : {}), const unique: string[] = [];
truncated: input.truncated ?? false, for (const url of urls) {
contentHash: key, if (!url || seen.has(url)) continue;
rawVomText: input.rawVomText, seen.add(url);
stepsHere: [], unique.push(url);
}); }
return id; return unique;
} }
function toSelection( function buildPageRegistry(
values: string[], steps: DraftTraceStep[],
labels?: string[], startUrl?: string,
): Array<{ value: string; label?: 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) => ({ return values.map((value, index) => ({
value, value,
...(labels?.[index] ? { label: labels[index] } : {}), ...(labels?.[index] ? { label: labels[index] } : {}),
})); }));
} }
function navigateCauseForDraft( function toV2Step(
draft: Extract<DraftTraceStep, { op: "navigate" }>, step: DraftTraceStep,
): NavigationCause { id: number,
if (draft.cause) return draft.cause; urlToId: Map<string, string>,
const mapped = mapNavigationCause({ fallbackUrl?: string,
transitionType: draft.transitionType, ): Step | null {
transitionQualifiers: draft.transitionQualifiers, if (!shouldIncludeDraft(step)) return null;
});
return mapped ?? "browser";
}
function toV3Step(draft: DraftTraceStep, id: number): Step | null { const pageUrl = pageUrlForDraft(step, fallbackUrl);
if (!shouldIncludeDraft(draft)) return null; const page = pageIdFor(pageUrl, urlToId, fallbackUrl);
// An action the user really performed must not disappear because one of its
// observations is missing — a capture racing a document swap is routine, and
// the last step of a recording is the most exposed to it. Fall back to the
// endpoint we do know; only a step with no observation at all has nothing to
// point at.
const preState = draft.preStateId ?? draft.postStateId;
const postState = draft.postStateId ?? draft.preStateId;
if (!preState || !postState) return null;
const common = { id, state: preState, result: { state: postState } }; switch (step.op) {
switch (draft.op) {
case "navigate": case "navigate":
return { return {
op: "navigate", op: "navigate",
...common, id,
to: draft.url, page: pageIdFor(step.url, urlToId, fallbackUrl),
cause: navigateCauseForDraft(draft), to: step.url,
}; };
case "click": case "click":
return { op: "click", ...common, target: draft.target }; return withEffect(
{
op: "click",
id,
page,
target: step.target,
},
effectForNavigation(step.navigated_to, urlToId),
);
case "hover": case "hover":
return { op: "hover", ...common, target: draft.target }; return {
op: "hover",
id,
page,
target: step.target,
};
case "fill": case "fill":
return { return {
op: "fill", op: "fill",
...common, id,
target: draft.target, page,
value: draft.value, target: step.target,
commit: draft.commit ?? DEFAULT_FILL_COMMIT, value: step.value,
...(draft.redacted ? { redacted: true } : {}), ...(step.redacted ? { redacted: true } : {}),
}; };
case "press": case "press":
return { return withEffect(
{
op: "press", op: "press",
...common, id,
key: draft.key, page,
...(draft.target ? { target: draft.target } : {}), key: step.key,
...(draft.modifiers?.length ? { modifiers: draft.modifiers } : {}), ...(step.target ? { target: step.target } : {}),
}; ...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
},
effectForNavigation(step.navigated_to, urlToId),
);
case "select": case "select":
return { return withEffect(
{
op: "select", op: "select",
...common, id,
target: draft.target, page,
selection: toSelection(draft.values, draft.labels), target: step.target,
}; selection: toSelection(step.values, step.labels),
case "scroll": },
return { op: "scroll", ...common }; effectForNavigation(step.navigated_to, urlToId),
);
} }
} }
export interface ReducedTrace { export interface ReducedTrace {
states: TraceState[]; pages: PageRef[];
steps: Step[]; steps: Step[];
/**
* Capture-order draft id (`draftIndex + 1`) → published step id. Collapsed
* and dropped drafts make the two diverge, so anything the recorder tagged
* with a draft id (`steps_here`, inline annotations) must be remapped.
*/
stepIdByDraftId: Map<number, number>;
} }
/** /**
* Compile capture drafts into record-only trace v3 steps. * Compile capture drafts into record-only trace v2 steps.
* Drafts must already carry `preStateId` / `postStateId` from the recorder. * Variable inputs are NOT classified here — executing agents infer that at run time.
*/ */
export function reduceTraceSteps( export function reduceTraceSteps(steps: DraftTraceStep[], startUrl?: string): ReducedTrace {
steps: DraftTraceStep[],
registry: Map<string, StateRegistryEntry>,
): ReducedTrace {
const collapsed = collapseNavigations(steps); const collapsed = collapseNavigations(steps);
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
const out: Step[] = []; const out: Step[] = [];
const stepIdByDraftId = new Map<number, number>();
let id = 1; let id = 1;
for (const { draft, draftIds } of collapsed) { let lastUrl = startUrl;
const step = toV3Step(draft, id); 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; if (!step) continue;
out.push(step); out.push(step);
for (const draftId of draftIds) stepIdByDraftId.set(draftId, id);
id += 1; id += 1;
} }
const states = [...registry.values()].map( return { pages, steps: out };
({ contentHash: _hash, rawVomText: _body, stepsHere: _steps, ...state }) => state,
);
return { states, steps: out, stepIdByDraftId };
} }
export function resolveTraceStartUrl( export function resolveTraceStartUrl(
drafts: DraftTraceStep[], drafts: DraftTraceStep[],
startUrl?: string, startUrl?: string,
states?: TraceState[], pages?: PageRef[],
): string { ): string {
if (startUrl) return startUrl; if (startUrl) return startUrl;
const navigate = drafts.find((step): step is Extract<DraftTraceStep, { op: "navigate" }> => { const navigate = drafts.find((step): step is Extract<DraftTraceStep, { op: "navigate" }> => {
@@ -228,9 +220,5 @@ export function resolveTraceStartUrl(
for (const draft of drafts) { for (const draft of drafts) {
if ("page_url" in draft && draft.page_url) return draft.page_url; if ("page_url" in draft && draft.page_url) return draft.page_url;
} }
return states?.[0]?.url ?? "about:blank"; return pages?.[0]?.url ?? "about:blank";
} }
export type { FillCommit, NavigationCause };
/** Test seam */
export { resetStateIdCounterForTests };
@@ -75,6 +75,52 @@ describe("SessionManager", () => {
await expect(sm.start("aa11")).rejects.toThrow(/already exists/); 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 () => { it("stop() closes the Agent Window and forgets the session", async () => {
const aw = fakeAgentWindow(); const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw }); const sm = new SessionManager({ agentWindow: aw });
@@ -33,4 +33,19 @@ describe("RefStore", () => {
expect(s.resolve("@e11")).toBe(11); expect(s.resolve("@e11")).toBe(11);
expect(s.size()).toBe(2); 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",
});
});
}); });
@@ -43,12 +43,11 @@ export const chromeAgentWindowApi: AgentWindowApi = {
return win.id; return win.id;
}, },
async remove(windowId: number): Promise<void> { async remove(windowId: number): Promise<void> {
try { // 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); await chrome.windows.remove(windowId);
} catch (err) {
// Window may have been closed by the user already; ignore.
console.debug("[bh] chrome.windows.remove failed", err);
}
}, },
async ensureActiveTab(windowId: number, url: string): Promise<void> { async ensureActiveTab(windowId: number, url: string): Promise<void> {
const tabs = await chrome.tabs.query({ windowId }); const tabs = await chrome.tabs.query({ windowId });
+51 -1
View File
@@ -31,6 +31,38 @@ export interface SessionStartOptions {
size?: { width: number; height: number }; size?: { width: number; height: number };
/** Defaults to true so existing clients keep visible Agent Windows. */ /** Defaults to true so existing clients keep visible Agent Windows. */
focused?: boolean; 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();
} }
/** /**
@@ -140,8 +172,16 @@ export class SessionManager {
if (this.sessions.has(sessionId)) { if (this.sessions.has(sessionId)) {
throw new Error(`[bh] session ${sessionId} already exists`); throw new Error(`[bh] session ${sessionId} already exists`);
} }
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, opts); 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); await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
throwIfSessionStartAborted(opts.signal);
const ctx: SessionContext = { const ctx: SessionContext = {
sessionId, sessionId,
agentWindowId: windowId, agentWindowId: windowId,
@@ -152,6 +192,16 @@ export class SessionManager {
this.sessions.set(sessionId, ctx); this.sessions.set(sessionId, ctx);
this.windowIndex.set(windowId, sessionId); this.windowIndex.set(windowId, sessionId);
return ctx; return ctx;
} catch (startupError) {
if (windowId !== null) {
try {
await this.agentWindow.remove(windowId);
} catch (cleanupError) {
throw new SessionStartCleanupError(windowId, startupError, cleanupError);
}
}
throw startupError;
}
} }
/** /**
@@ -1,9 +1,10 @@
/** /**
* Per-session map from `@e<N>` snapshot refs to CDP `backendNodeId`. * Per-session map from `@e<N>` snapshot refs to a CDP node address.
* *
* Each fresh `tool.snapshot` resets the store: M6 will call * Each fresh `tool.snapshot` resets the store: M6 will call
* `replace(...)` with the new ref → backendNodeId pairs. Tools like * `replace(...)` with the new ref → node address pairs. A node address
* `tool.click` consume the store via `resolve("@e1")`. * 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 * Refs are session-scoped (§7): looking up a ref in the wrong session
* returns `null`, never silently leaks. Storing values in different * returns `null`, never silently leaks. Storing values in different
@@ -15,10 +16,19 @@ export type BackendNodeId = number;
export interface RefEntry { export interface RefEntry {
backendNodeId: BackendNodeId; backendNodeId: BackendNodeId;
tabId: number | null; tabId: number | null;
frameId?: string;
cdpSessionId?: string;
generation: number; generation: number;
} }
type RefInput = BackendNodeId | { backendNodeId: BackendNodeId; tabId: number }; export type RefInput =
| BackendNodeId
| {
backendNodeId: BackendNodeId;
tabId: number;
frameId?: string;
cdpSessionId?: string;
};
export class RefStore { export class RefStore {
private readonly map = new Map<string, RefEntry>(); private readonly map = new Map<string, RefEntry>();
@@ -44,7 +54,7 @@ export class RefStore {
} }
/** /**
* Replace the entire store with a new ref → backendNodeId mapping. * Replace the entire store with a new ref → CDP node identity mapping.
* Used after every fresh `tool.snapshot`. * Used after every fresh `tool.snapshot`.
*/ */
replace(entries: Iterable<readonly [string, RefInput]>): void { replace(entries: Iterable<readonly [string, RefInput]>): void {
@@ -53,10 +63,20 @@ export class RefStore {
for (const [ref, input] of entries) this.map.set(normaliseRef(ref), this.entry(input)); for (const [ref, input] of entries) this.map.set(normaliseRef(ref), this.entry(input));
} }
set(ref: string, id: BackendNodeId, opts: { tabId?: number } = {}): void { set(
ref: string,
id: BackendNodeId,
opts: {
tabId?: number;
frameId?: string;
cdpSessionId?: string;
} = {},
): void {
this.map.set(normaliseRef(ref), { this.map.set(normaliseRef(ref), {
backendNodeId: id, backendNodeId: id,
tabId: opts.tabId ?? null, tabId: opts.tabId ?? null,
...(opts.frameId ? { frameId: opts.frameId } : {}),
...(opts.cdpSessionId ? { cdpSessionId: opts.cdpSessionId } : {}),
generation: this.generation, generation: this.generation,
}); });
} }
@@ -80,6 +100,8 @@ export class RefStore {
return { return {
backendNodeId: input.backendNodeId, backendNodeId: input.backendNodeId,
tabId: input.tabId, tabId: input.tabId,
...(input.frameId ? { frameId: input.frameId } : {}),
...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}),
generation: this.generation, generation: this.generation,
}; };
} }
@@ -612,4 +612,14 @@ describe("isInjectableContentScriptUrl", () => {
).toBe(false); ).toBe(false);
expect(isInjectableContentScriptUrl("https://chromewebstore.google.com/")).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);
});
}); });
@@ -1,86 +0,0 @@
import { describe, expect, it } from "vitest";
import { RECORD_DOCUMENT_TOKEN_ATTR } from "@/lib/record-document-token";
import type { CapturedNode } from "@/tools/vom/capture";
import { collectDocumentTokenOwners } from "../capture-vom-observation";
function htmlNode(
backendNodeId: number,
token: string,
ownerFrameBackendNodeId: number | null = null,
): CapturedNode {
return {
backendNodeId,
parentBackendNodeId: null,
tag: "html",
attrs: { [RECORD_DOCUMENT_TOKEN_ATTR]: token },
rect: { x: 0, y: 0, w: 1280, h: 720 },
paintOrder: 0,
position: "static",
pointerEvents: "auto",
...(ownerFrameBackendNodeId === null ? {} : { ownerFrameBackendNodeId }),
};
}
describe("collectDocumentTokenOwners", () => {
it("maps document tokens to their immediate iframe owner backend node", () => {
const topToken = "top-doc-token";
const childToken = "child-doc-token";
const owners = collectDocumentTokenOwners({
viewport: { width: 1280, height: 720 },
nodes: [htmlNode(1, topToken, null)],
iframeNodes: new Map([
[
13,
[
htmlNode(101, childToken, 13),
{
backendNodeId: 102,
parentBackendNodeId: 101,
tag: "input",
attrs: {},
rect: { x: 10, y: 20, w: 200, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
ownerFrameBackendNodeId: 13,
},
],
],
]),
excludedBackendNodeIds: new Set(),
});
expect(owners.get(topToken)).toBeNull();
expect(owners.get(childToken)).toBe(13);
});
it("maps nested iframe documents to their direct owner, not the outer iframe", () => {
const nestedToken = "nested-doc-token";
const owners = collectDocumentTokenOwners({
viewport: { width: 1280, height: 720 },
nodes: [htmlNode(1, "top-token", null)],
iframeNodes: new Map([
[
13,
[
{
backendNodeId: 23,
parentBackendNodeId: 101,
tag: "iframe",
attrs: {},
rect: { x: 0, y: 0, w: 400, h: 300 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
ownerFrameBackendNodeId: 13,
},
],
],
[23, [htmlNode(201, nestedToken, 23)]],
]),
excludedBackendNodeIds: new Set(),
});
expect(owners.get(nestedToken)).toBe(23);
});
});
@@ -168,6 +168,107 @@ describe("ToolDispatcher", () => {
}); });
}); });
it("bypasses and restores the control overlay for an upload trigger click", async () => {
const sendMessage = vi.fn(async () => undefined);
vi.stubGlobal("chrome", {
tabs: {
get: vi.fn(async () => ({ id: 7, windowId: 4242, active: true })),
query: vi.fn(async () => [{ id: 7, windowId: 4242, active: true }]),
sendMessage,
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
const ctx = await sessions.start("aa11");
ctx.refStore.set("e1", 123, { tabId: 7 });
const send = vi.fn(async <T>(_tabId: number, method: string, params?: object) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } } as T;
}
if (method === "DOM.getContentQuads") {
return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] } as T;
}
if (method === "DOM.resolveNode") {
return { object: { objectId: "trigger-object" } } as T;
}
if (method === "DOM.describeNode") return { node: { backendNodeId: 456 } } as T;
if (method === "Runtime.callFunctionOn") {
const declaration = (params as { functionDeclaration?: string }).functionDeclaration ?? "";
if (declaration.includes("count: state.inputs.length")) {
return { result: { value: { count: 1, multiple: false } } } as T;
}
if (declaration.includes("inputs[0]")) {
return { result: { objectId: "input-object" } } as T;
}
return { result: { value: true } } as T;
}
if (method === "Runtime.evaluate") {
const expression = (params as { expression?: string }).expression ?? "";
if (expression.includes("overlayDetails")) {
return { result: { value: { hitIndex: 0 } } } as T;
}
if (expression.includes("overlayHostPresent")) {
return {
result: {
value: { overlayHostPresent: true, overlayHostConnected: true },
},
} as T;
}
if (expression.includes("count:")) {
return { result: { value: { count: 1, multiple: false } } } as T;
}
if (expression.includes("?.inputs[0]")) {
return { result: { objectId: "input-object" } } as T;
}
return { result: { value: true } } as T;
}
return {} as T;
});
const cdp = {
send,
detachSession: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
setDeviceMetricsOverride: vi.fn(async () => {}),
clearDeviceMetricsOverride: vi.fn(async () => {}),
setUserAgentOverride: vi.fn(async () => {}),
setTouchEmulationEnabled: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
dispatcher.start();
deliver(
makeRequest("tool.upload", {
session_id: "aa11",
ref: "@e1",
files: [{ transfer_id: "tr_1", name: "test.png", staged_path: "/stage/test.png" }],
}),
);
await flushMicrotasks();
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({ result: { tab_id: 7, file_names: ["test.png"] } });
expect(sendMessage).toHaveBeenNthCalledWith(1, 7, {
type: "bh-automation-bypass",
enabled: true,
});
expect(sendMessage).toHaveBeenNthCalledWith(2, 7, {
type: "bh-automation-bypass",
enabled: false,
});
});
it("detaches CDP state before stopping a session", async () => { it("detaches CDP state before stopping a session", async () => {
const { transport, sent, deliver } = fakeTransport(); const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({ const sessions = new SessionManager({
@@ -677,23 +778,31 @@ describe("ToolDispatcher", () => {
await flushMicrotasks(); await flushMicrotasks();
expect(ac?.signal.aborted).toBe(true); expect(ac?.signal.aborted).toBe(true);
// Cancel ack arrived synchronously; the slow tool replies with // Cancel ack arrives synchronously, but the original RPC must not reply
// `cancelled` once the dispatcher's race observes the abort. // until the in-progress window creation has completed and been rolled back.
const ack = sent.find( const ack = sent.find(
(m) => (m) =>
typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "cancel-1", typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "cancel-1",
); );
expect(ack).toEqual({ id: "cancel-1", result: { cancelled: true } }); expect(ack).toEqual({ id: "cancel-1", result: { cancelled: true } });
expect(
sent.find(
(m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1",
),
).toBeUndefined();
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(true);
resolveCreate(9999);
await flushMicrotasks();
const slow = sent.find( const slow = sent.find(
(m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1", (m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1",
); );
expect(slow).toMatchObject({ id: "r-1", error: { code: "cancelled" } }); expect(slow).toMatchObject({ id: "r-1", error: { code: "cancelled" } });
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(false); expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(false);
expect(sessions.has("aa44")).toBe(false);
// Drain the dangling create promise so vitest does not warn. expect(sessions.list()).toEqual([]);
resolveCreate(9999);
await flushMicrotasks();
}); });
it("cancel for an unknown rpc_id replies with cancelled=false", async () => { it("cancel for an unknown rpc_id replies with cancelled=false", async () => {
@@ -1,24 +1,54 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { nodeBoundingRect, quadBoundingRect } from "../element-geometry"; import { nodeContentRegion } from "../element-geometry";
import type { CdpRunner } from "../shared"; import type { CdpRunner } from "../shared";
describe("quadBoundingRect", () => { describe("nodeContentRegion", () => {
it("computes axis-aligned bounds from an 8-double quad", () => { it("rejects invalid content quads before using the box model fallback", async () => {
expect(quadBoundingRect([10, 20, 110, 20, 110, 60, 10, 60])).toEqual({ const send = async (_tabId: number, method: string) => {
x: 10, if (method === "DOM.getContentQuads") return { quads: [[0, 0, 0, 0, 0, 0, 0, 0]] };
y: 20, if (method === "DOM.getBoxModel") {
width: 100, return { model: { content: [5, 10, 25, 10, 25, 30, 5, 30] } };
height: 40, }
}); throw new Error(`unexpected CDP call ${method}`);
};
await expect(nodeContentRegion({ send: send as CdpRunner["send"] }, 7, 555)).resolves.toEqual([
[
{ x: 5, y: 10 },
{ x: 25, y: 10 },
{ x: 25, y: 30 },
{ x: 5, y: 30 },
],
]);
}); });
it("returns null for degenerate quads", () => { it("preserves every visible content fragment", async () => {
expect(quadBoundingRect([0, 0, 0, 0, 0, 0, 0, 0])).toBeNull(); const send = async () => ({
expect(quadBoundingRect([1, 2, 3])).toBeNull(); quads: [
[10, 20, 60, 20, 60, 40, 10, 40],
[15, 50, 90, 50, 90, 80, 15, 80],
],
});
const cdp: CdpRunner = {
send: send as CdpRunner["send"],
};
await expect(nodeContentRegion(cdp, 7, 555)).resolves.toEqual([
[
{ x: 10, y: 20 },
{ x: 60, y: 20 },
{ x: 60, y: 40 },
{ x: 10, y: 40 },
],
[
{ x: 15, y: 50 },
{ x: 90, y: 50 },
{ x: 90, y: 80 },
{ x: 15, y: 80 },
],
]);
}); });
});
describe("nodeBoundingRect", () => {
it("falls back to visible descendant bounds for zero-size containers", async () => { it("falls back to visible descendant bounds for zero-size containers", async () => {
const calls: Array<{ method: string; params?: object }> = []; const calls: Array<{ method: string; params?: object }> = [];
const send = async (_tabId: number, method: string, params?: object) => { const send = async (_tabId: number, method: string, params?: object) => {
@@ -38,12 +68,14 @@ describe("nodeBoundingRect", () => {
}; };
const cdp = { send: send as CdpRunner["send"] }; const cdp = { send: send as CdpRunner["send"] };
await expect(nodeBoundingRect(cdp, 7, 555)).resolves.toEqual({ await expect(nodeContentRegion(cdp, 7, 555)).resolves.toEqual([
x: 242, [
y: 468, { x: 242, y: 468 },
width: 769, { x: 1011, y: 468 },
height: 180, { x: 1011, y: 648 },
}); { x: 242, y: 648 },
],
]);
expect(calls.map((c) => c.method)).toEqual([ expect(calls.map((c) => c.method)).toEqual([
"DOM.getContentQuads", "DOM.getContentQuads",
"DOM.getBoxModel", "DOM.getBoxModel",
@@ -0,0 +1,559 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { type DownloadsApi, handleDownload } from "../download";
import { captureBrowserDownload } from "../download-capture";
import { uploadThroughActivatedFileInput } from "../file-input-transaction";
import type { ResolvedActionTarget } from "../interaction";
import type { CdpRunner } from "../shared";
import { handleUpload } from "../upload";
function sessions() {
return new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
}
function tabsApi() {
return {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
};
}
function fakeEvent<T extends (...args: never[]) => unknown>() {
const listeners = new Set<T>();
return {
addListener: (listener: T) => listeners.add(listener),
removeListener: (listener: T) => listeners.delete(listener),
emit: (...args: Parameters<T>) => {
for (const listener of listeners) listener(...args);
},
};
}
function actionTarget(frameId?: string): ResolvedActionTarget {
return {
tab: { tabId: 4, windowId: 100, active: true },
backendNodeId: 123,
cdpTarget: { tabId: 4 },
...(frameId ? { frameId } : {}),
usedRef: "e3",
};
}
function uploadCdp(
options: {
inputCount?: number;
multiple?: boolean;
chooser?: { frameId?: string; backendNodeId?: number; mode?: string };
pendingResolve?: boolean;
} = {},
) {
const calls: Array<{ method: string; params?: object }> = [];
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
calls.push({ method, params });
if (method === "Page.setInterceptFileChooserDialog") return {};
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
if (method === "DOM.resolveNode") {
if (options.pendingResolve) return new Promise<never>(() => {});
return { object: { objectId: "trigger-object" } };
}
if (method === "Runtime.callFunctionOn") {
const declaration = (params as { functionDeclaration?: string }).functionDeclaration ?? "";
if (declaration.includes("Object.defineProperty")) return { result: { value: true } };
if (declaration.includes("count: state.inputs.length")) {
return {
result: {
value: {
count: options.inputCount ?? 1,
multiple: options.multiple ?? true,
},
},
};
}
if (declaration.includes("inputs[0]")) {
return { result: { objectId: "input-object" } };
}
return { result: { value: true } };
}
if (method === "DOM.describeNode") return { node: { backendNodeId: 456 } };
if (
method === "Input.dispatchMouseEvent" &&
(params as { type?: string }).type === "mousePressed" &&
options.chooser
) {
cdpEvent?.({ tabId: 4 }, "Page.fileChooserOpened", options.chooser);
}
return {};
});
const cdp: CdpRunner = {
send: send as unknown as CdpRunner["send"],
onEvent: (handler) => {
cdpEvent = handler;
return { dispose: vi.fn() };
},
};
return {
calls,
emitChooser: (event: { frameId?: string; backendNodeId?: number; mode?: string }) =>
cdpEvent?.({ tabId: 4 }, "Page.fileChooserOpened", event),
cdp,
};
}
describe("file transfer tools", () => {
it("captures the file input activated by the requested click and injects only staged paths", async () => {
const manager = sessions();
const ctx = await manager.start("s1");
ctx.refStore.set("e3", 123, { tabId: 4 });
const { cdp, calls } = uploadCdp();
const result = await handleUpload(
manager,
{
session_id: "s1",
ref: "@e3",
files: [
{ transfer_id: "tr_1", name: "one.png", staged_path: "/private/stage/one" },
{ transfer_id: "tr_2", name: "two.png", staged_path: "/private/stage/two" },
],
},
{ cdp, tabsApi: tabsApi() },
);
expect(result).toMatchObject({ tab_id: 4, file_names: ["one.png", "two.png"] });
expect(calls[0]).toEqual({
method: "Page.setInterceptFileChooserDialog",
params: { enabled: true },
});
expect(calls).toContainEqual({
method: "Page.setInterceptFileChooserDialog",
params: { enabled: false, cancel: true },
});
expect(calls).toContainEqual({
method: "DOM.setFileInputFiles",
params: { files: ["/private/stage/one", "/private/stage/two"], backendNodeId: 456 },
});
});
it("fails immediately when the trigger does not activate a file input", async () => {
const { cdp } = uploadCdp({ inputCount: 0 });
const result = await uploadThroughActivatedFileInput({
cdp,
actionTarget: actionTarget(),
files: ["/private/stage/one"],
timeoutMs: 100,
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
});
expect(result).toMatchObject({
code: "unsupported",
data: { reason: "file_input_not_activated", phase: "resolve_input" },
});
});
it("fails before clicking when chooser interception is unavailable", async () => {
const trigger = vi.fn(async () => ({ tab_id: 4, x: 10, y: 10 }));
const cdp: CdpRunner = {
send: vi.fn(async (_tabId: number, method: string) => {
if (method === "Page.setInterceptFileChooserDialog") {
throw new Error("method unavailable");
}
return {};
}) as CdpRunner["send"],
};
const result = await uploadThroughActivatedFileInput({
cdp,
actionTarget: actionTarget(),
files: ["/private/stage/one"],
timeoutMs: 100,
trigger,
});
expect(result).toMatchObject({
code: "cdp_failed",
data: { effect_state: "none", phase: "arm_interception" },
});
expect(trigger).not.toHaveBeenCalled();
});
it("uses an exact chooser event as an independent input-location signal", async () => {
const { cdp, calls, emitChooser } = uploadCdp({ inputCount: 0 });
const result = await uploadThroughActivatedFileInput({
cdp,
actionTarget: actionTarget("f1"),
files: ["/private/stage/one"],
timeoutMs: 100,
trigger: async () => {
emitChooser({ frameId: "f1", backendNodeId: 789, mode: "selectSingle" });
return { tab_id: 4, x: 10, y: 10 };
},
});
expect(result).toMatchObject({ multiple: false });
expect(calls).toContainEqual({
method: "DOM.setFileInputFiles",
params: { files: ["/private/stage/one"], backendNodeId: 789 },
});
});
it("reports a File System Access picker without waiting for a timeout", async () => {
const { cdp, emitChooser } = uploadCdp({
inputCount: 0,
});
const result = await uploadThroughActivatedFileInput({
cdp,
actionTarget: actionTarget("f1"),
files: ["/private/stage/one"],
timeoutMs: 100,
trigger: async () => {
emitChooser({ frameId: "f1", mode: "selectSingle" });
return { tab_id: 4, x: 10, y: 10 };
},
});
expect(result).toMatchObject({
code: "unsupported",
message: "upload trigger invoked a non-input file picker",
data: { reason: "file_input_not_activated", phase: "resolve_input" },
});
});
it("bounds a stuck file-input probe", async () => {
const { cdp } = uploadCdp({ pendingResolve: true });
const result = await uploadThroughActivatedFileInput({
cdp,
actionTarget: actionTarget(),
files: ["/private/stage/one"],
timeoutMs: 5,
trigger: vi.fn(),
});
expect(result).toMatchObject({
code: "timeout",
data: { reason: "file_input_probe_failed", phase: "arm_input_probe" },
});
});
it("marks a timed-out file assignment unknown and detaches browser state", async () => {
const fixture = uploadCdp();
const originalSend = fixture.cdp.send;
const detach = vi.fn(async () => {});
fixture.cdp.detach = detach;
fixture.cdp.send = vi.fn((tabId: number, method: string, params?: object) => {
if (method === "DOM.setFileInputFiles") return new Promise<never>(() => {});
return originalSend(tabId, method, params);
}) as CdpRunner["send"];
const result = await uploadThroughActivatedFileInput({
cdp: fixture.cdp,
actionTarget: actionTarget(),
files: ["/private/stage/one"],
timeoutMs: 10,
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
});
expect(result).toMatchObject({
code: "timeout",
data: { effect_state: "unknown", phase: "set_files" },
});
expect(detach).toHaveBeenCalledWith(4);
});
it("routes one exact-target download through a browser-relative capability", async () => {
const manager = sessions();
const ctx = await manager.start("s1");
ctx.refStore.set("e3", 123, { tabId: 4 });
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
const onDeterminingFilename =
fakeEvent<
(
item: chrome.downloads.DownloadItem,
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
) => void | true
>();
const initial = {
id: 9,
url: "https://example.test/result.zip",
finalUrl: "https://example.test/result.zip",
filename: "result.zip",
state: "in_progress",
fileSize: -1,
totalBytes: 12,
mime: "application/zip",
danger: "safe",
} as chrome.downloads.DownloadItem;
const completed = {
...initial,
filename: "/profile/Downloads/BrowserSkill/tr_1/result.zip",
state: "complete",
fileSize: 12,
} as chrome.downloads.DownloadItem;
const downloads: DownloadsApi = {
onCreated,
onChanged,
onDeterminingFilename,
search: vi.fn(async () => [completed]),
cancel: vi.fn(async () => {}),
removeFile: vi.fn(async () => {}),
};
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
let suggested: chrome.downloads.DownloadFilenameSuggestion | undefined;
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
if (
method === "Input.dispatchMouseEvent" &&
(params as { type?: string }).type === "mousePressed"
) {
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
url: initial.url,
suggestedFilename: "result.zip",
});
await new Promise<void>((resolve) => {
onDeterminingFilename.emit(initial, (value) => {
suggested = value;
resolve();
});
});
onCreated.emit(completed);
}
return {};
});
const cdp: CdpRunner = {
send: send as unknown as CdpRunner["send"],
onEvent: (handler) => {
cdpEvent = handler;
return { dispose: vi.fn() };
},
};
const result = await handleDownload(
manager,
{ session_id: "s1", ref: "@e3", browser_relative_dir: "BrowserSkill/tr_1" },
{ cdp, tabsApi: tabsApi(), downloads },
);
expect(suggested).toEqual({
filename: "BrowserSkill/tr_1/result.zip",
conflictAction: "overwrite",
});
expect(result).toMatchObject({
tab_id: 4,
suggested_filename: "result.zip",
byte_size: 12,
browser_path: completed.filename,
});
});
it("does not claim a download without an intent from the exact target", async () => {
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
const onDeterminingFilename =
fakeEvent<
(
item: chrome.downloads.DownloadItem,
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
) => void | true
>();
const downloads: DownloadsApi = {
onCreated,
onChanged,
onDeterminingFilename,
search: vi.fn(async () => []),
cancel: vi.fn(async () => {}),
removeFile: vi.fn(async () => {}),
};
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const cdp: CdpRunner = {
send: vi.fn(async () => ({})) as unknown as CdpRunner["send"],
onEvent: (handler) => {
cdpEvent = handler;
return { dispose: vi.fn() };
},
};
const unrelated = {
id: 17,
url: "https://example.test/unrelated.zip",
finalUrl: "https://example.test/unrelated.zip",
filename: "unrelated.zip",
state: "in_progress",
} as chrome.downloads.DownloadItem;
let defaultSuggestionCalled = false;
const result = await captureBrowserDownload({
cdp,
target: { tabId: 4, sessionId: "expected-child" },
downloads,
browserRelativeDir: "BrowserSkill/tr_1",
timeoutMs: 5,
trigger: async () => {
cdpEvent?.({ tabId: 4, sessionId: "other-child" }, "Page.downloadWillBegin", {
url: unrelated.url,
suggestedFilename: unrelated.filename,
});
onDeterminingFilename.emit(unrelated, (suggestion) => {
defaultSuggestionCalled = suggestion === undefined;
});
return { tab_id: 4, x: 10, y: 10 };
},
});
expect(defaultSuggestionCalled).toBe(true);
expect(downloads.cancel).not.toHaveBeenCalled();
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "download_capture_failed" },
});
});
it("correlates a filename candidate that arrives before the CDP intent", async () => {
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
const onDeterminingFilename =
fakeEvent<
(
item: chrome.downloads.DownloadItem,
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
) => void | true
>();
const initial = {
id: 21,
url: "https://example.test/candidate-first.bin",
finalUrl: "https://example.test/candidate-first.bin",
filename: "candidate-first.bin",
state: "in_progress",
fileSize: -1,
totalBytes: 4,
bytesReceived: 0,
} as chrome.downloads.DownloadItem;
const complete = {
...initial,
state: "complete",
fileSize: 4,
} as chrome.downloads.DownloadItem;
const downloads: DownloadsApi = {
onCreated,
onChanged,
onDeterminingFilename,
search: vi.fn(async () => [complete]),
cancel: vi.fn(async () => {}),
removeFile: vi.fn(async () => {}),
};
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const cdp: CdpRunner = {
send: vi.fn(async () => ({})) as CdpRunner["send"],
onEvent: (handler) => {
cdpEvent = handler;
return { dispose: vi.fn() };
},
};
let suggestion: chrome.downloads.DownloadFilenameSuggestion | undefined;
const result = await captureBrowserDownload({
cdp,
target: { tabId: 4 },
downloads,
browserRelativeDir: "BrowserSkill/tr_21",
timeoutMs: 1_000,
trigger: async () => {
const suggested = new Promise<void>((resolve) => {
onDeterminingFilename.emit(initial, (value) => {
suggestion = value;
resolve();
});
});
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
url: initial.url,
suggestedFilename: initial.filename,
});
await suggested;
onCreated.emit(complete);
return { tab_id: 4, x: 10, y: 10 };
},
});
expect(suggestion).toEqual({
filename: "BrowserSkill/tr_21/candidate-first.bin",
conflictAction: "overwrite",
});
expect(result).toMatchObject({ item: { id: 21, state: "complete" } });
});
it("rejects ambiguous attribution without cancelling either unclaimed download", async () => {
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
const onDeterminingFilename =
fakeEvent<
(
item: chrome.downloads.DownloadItem,
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
) => void | true
>();
const downloads: DownloadsApi = {
onCreated,
onChanged,
onDeterminingFilename,
search: vi.fn(async () => []),
cancel: vi.fn(async () => {}),
removeFile: vi.fn(async () => {}),
};
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const cdp: CdpRunner = {
send: vi.fn(async () => ({})) as CdpRunner["send"],
onEvent: (handler) => {
cdpEvent = handler;
return { dispose: vi.fn() };
},
};
const defaults: number[] = [];
const candidate = (id: number) =>
({
id,
url: "https://example.test/same.bin",
finalUrl: "https://example.test/same.bin",
filename: "same.bin",
state: "in_progress",
}) as chrome.downloads.DownloadItem;
const result = await captureBrowserDownload({
cdp,
target: { tabId: 4 },
downloads,
browserRelativeDir: "BrowserSkill/tr_ambiguous",
timeoutMs: 100,
trigger: async () => {
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
url: "https://example.test/same.bin",
suggestedFilename: "same.bin",
});
for (const id of [31, 32]) {
onDeterminingFilename.emit(candidate(id), (value) => {
if (value === undefined) defaults.push(id);
});
}
return { tab_id: 4, x: 10, y: 10 };
},
});
expect(defaults.sort()).toEqual([31, 32]);
expect(downloads.cancel).not.toHaveBeenCalled();
expect(result).toMatchObject({
code: "cdp_failed",
data: { effect_state: "unknown", phase: "attribution" },
});
});
});
@@ -0,0 +1,286 @@
import { describe, expect, it, vi } from "vitest";
import { resolveFrameProjection, resolveNodeGeometry } from "../frame-geometry";
import {
clipPolygon,
polygonArea,
polygonBounds,
projectAndClipRegion,
projectUnitPoint,
type Quad,
rectPolygon,
regionBounds,
} from "../geometry";
import type { CdpRunner } from "../shared";
describe("frame geometry projection", () => {
it("keeps region bounds separate from polygon area", () => {
const region = [
rectPolygon({ x: 0, y: 0, w: 10, h: 10 }),
rectPolygon({ x: 20, y: 5, w: 5, h: 5 }),
];
expect(regionBounds(region)).toEqual({ x: 0, y: 0, width: 25, height: 10 });
expect(polygonArea(region[0])).toBe(100);
});
it("maps through the iframe content box instead of its border box", () => {
const contentQuad: Quad = [
{ x: 204, y: 306 },
{ x: 604, y: 306 },
{ x: 604, y: 506 },
{ x: 204, y: 506 },
];
const projected = projectAndClipRegion(
[rectPolygon({ x: 10, y: 20, w: 100, h: 40 })],
[{ sourceViewport: { width: 200, height: 100 }, destinationQuad: contentQuad }],
{ width: 1000, height: 800 },
);
expect(polygonBounds(projected.flat())).toEqual({ x: 224, y: 346, width: 200, height: 80 });
});
it("uses a projective mapping for perspective-transformed iframe quads", () => {
const quad: Quad = [
{ x: 0, y: 0 },
{ x: 200, y: 20 },
{ x: 180, y: 140 },
{ x: 20, y: 100 },
];
expect(projectUnitPoint({ x: 0, y: 0 }, quad)).toEqual(quad[0]);
expect(projectUnitPoint({ x: 1, y: 0 }, quad)).toEqual(quad[1]);
expect(projectUnitPoint({ x: 1, y: 1 }, quad)).toEqual(quad[2]);
expect(projectUnitPoint({ x: 0, y: 1 }, quad)).toEqual(quad[3]);
});
it("clips against the iframe polygon rather than its bounding box", () => {
const frameQuad: Quad = [
{ x: 50, y: 0 },
{ x: 100, y: 50 },
{ x: 50, y: 100 },
{ x: 0, y: 50 },
];
expect(clipPolygon(rectPolygon({ x: 0, y: 0, w: 10, h: 10 }), frameQuad)).toEqual([]);
});
it("does not reapply same-target frame transforms around an OOPIF", async () => {
const send = vi.fn(async (_tabId, method, params) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
}
if (method === "DOM.getBoxModel") {
const backendNodeId = (params as { backendNodeId?: number }).backendNodeId;
return backendNodeId === 20
? { model: { content: [110, 70, 310, 70, 310, 170, 110, 170] } }
: { model: { content: [100, 50, 400, 50, 400, 250, 100, 250] } };
}
throw new Error(`unexpected root command ${method}`);
});
const cdp: CdpRunner = {
send: send as CdpRunner["send"],
sendToTarget: vi.fn(async (_target, method) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
}
throw new Error(`unexpected child command ${method}`);
}) as CdpRunner["sendToTarget"],
};
const projection = await resolveFrameProjection(
cdp,
{
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "same-process-parent",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4 },
},
{
frameId: "oopif",
parentFrameId: "same-process-parent",
ownerBackendNodeId: 20,
target: { tabId: 4, sessionId: "oopif-session" },
},
],
},
"oopif",
);
expect(projection?.edges).toEqual([
{
sourceViewport: { width: 200, height: 100 },
destinationQuad: [
{ x: 110, y: 70 },
{ x: 310, y: 70 },
{ x: 310, y: 170 },
{ x: 110, y: 170 },
],
destinationClips: [
[
{ x: 100, y: 50 },
{ x: 400, y: 50 },
{ x: 400, y: 250 },
{ x: 100, y: 250 },
],
],
},
]);
});
it("clips live OOPIF geometry at every frame boundary", async () => {
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
}
if (method === "DOM.getBoxModel") {
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } };
}
throw new Error(`unexpected root command ${method}`);
}) as CdpRunner["send"],
sendToTarget: vi.fn(async (_target, method) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
}
if (method === "DOM.getContentQuads") {
return { quads: [[-50, -20, 250, -20, 250, 120, -50, 120]] };
}
throw new Error(`unexpected child command ${method}`);
}) as CdpRunner["sendToTarget"],
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4, sessionId: "child-session" },
},
],
})),
};
const geometry = await resolveNodeGeometry(cdp, 4, {
target: { tabId: 4, sessionId: "child-session" },
frameId: "child",
backendNodeId: 101,
});
expect(geometry).toMatchObject({
topBounds: { x: 100, y: 100, width: 200, height: 100 },
actionPoint: { x: 200, y: 150 },
});
});
it("reuses one frame graph while scrolling and projecting live geometry", async () => {
const getFrameGraph = vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4, sessionId: "child-session" },
},
],
}));
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method) => {
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
}
if (method === "DOM.getBoxModel") {
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } };
}
throw new Error(`unexpected root command ${method}`);
}) as CdpRunner["send"],
sendToTarget: vi.fn(async (_target, method) => {
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
}
if (method === "DOM.getContentQuads") {
return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
}
throw new Error(`unexpected child command ${method}`);
}) as CdpRunner["sendToTarget"],
getFrameGraph,
};
await expect(
resolveNodeGeometry(
cdp,
4,
{
target: { tabId: 4, sessionId: "child-session" },
frameId: "child",
backendNodeId: 101,
},
{ scrollIntoView: true },
),
).resolves.not.toMatchObject({ code: expect.any(String) });
expect(getFrameGraph).toHaveBeenCalledOnce();
});
it("clips live geometry to same-target iframe ancestors", async () => {
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
}
if (method === "DOM.getBoxModel") {
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } };
}
if (method === "DOM.getContentQuads") {
return { quads: [[50, 50, 350, 50, 350, 250, 50, 250]] };
}
throw new Error(`unexpected ${method}`);
}) as CdpRunner["send"],
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4 },
},
],
})),
};
const geometry = await resolveNodeGeometry(cdp, 4, {
target: { tabId: 4 },
frameId: "child",
backendNodeId: 101,
});
expect(geometry).toMatchObject({
topBounds: { x: 100, y: 100, width: 200, height: 100 },
actionPoint: { x: 200, y: 150 },
});
});
it("fails closed when an OOPIF frame graph is unavailable", async () => {
const cdp: CdpRunner = {
send: vi.fn(),
sendToTarget: vi.fn(async (_target, method) => {
if (method === "DOM.getContentQuads") {
return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
}
throw new Error(`unexpected ${method}`);
}) as CdpRunner["sendToTarget"],
};
await expect(
resolveNodeGeometry(cdp, 4, {
target: { tabId: 4, sessionId: "child-session" },
frameId: "child",
backendNodeId: 101,
}),
).resolves.toMatchObject({ code: "cdp_failed" });
});
});
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager"; import { SessionManager } from "@/session-manager/manager";
import { RefStore } from "@/session-manager/ref-store";
import type { RequestHelpParams } from "@/transport/types"; import type { RequestHelpParams } from "@/transport/types";
import { handleRequestHelp, type RequestHelpDeps, resetHelpLifecycleForTests } from "../human-loop"; import { handleRequestHelp, type RequestHelpDeps, resetHelpLifecycleForTests } from "../human-loop";
@@ -50,11 +51,10 @@ function installHelpLifecycleChrome() {
} }
function fakeManager(sessionId: string, agentWindowId: number, tabId: number) { function fakeManager(sessionId: string, agentWindowId: number, tabId: number) {
const refStore = new RefStore();
const mgr = { const mgr = {
get: (id: string) => get: (id: string) =>
id === sessionId id === sessionId ? { sessionId, agentWindowId, refStore, borrowedTabs: new Map() } : null,
? { sessionId, agentWindowId, refStore: { resolve: () => null }, borrowedTabs: new Map() }
: null,
findByWindowId: (wid: number) => (wid === agentWindowId ? { sessionId } : null), findByWindowId: (wid: number) => (wid === agentWindowId ? { sessionId } : null),
} as unknown as SessionManager; } as unknown as SessionManager;
return mgr; return mgr;
@@ -339,26 +339,23 @@ describe("handleRequestHelp", () => {
ac.abort(); ac.abort();
await expect(pending).resolves.toMatchObject({ code: "cancelled" }); await expect(pending).resolves.toMatchObject({ code: "cancelled" });
expect(cdpSend).toHaveBeenCalledWith( expect(cdpSend).not.toHaveBeenCalledWith(
5, expect.anything(),
"DOM.querySelectorAll", "DOM.removeAttribute",
expect.objectContaining({ selector: "[data-bsk-help]" }), expect.anything(),
);
expect(cdpSend).toHaveBeenCalledWith(
6,
"DOM.querySelectorAll",
expect.objectContaining({ selector: "[data-bsk-help]" }),
); );
}); });
it("tags ref targets via CDP and reports them matched", async () => { it("resolves ref targets to explicit viewport rectangles without mutating the page", async () => {
const refStore = new RefStore();
refStore.set("e1", 42, { tabId: 5 });
const mgr = { const mgr = {
get: (id: string) => get: (id: string) =>
id === "abcd" id === "abcd"
? { ? {
sessionId: "abcd", sessionId: "abcd",
agentWindowId: 99, agentWindowId: 99,
refStore: { resolve: () => 42 }, refStore,
borrowedTabs: new Map(), borrowedTabs: new Map(),
} }
: null, : null,
@@ -366,7 +363,17 @@ describe("handleRequestHelp", () => {
} as unknown as SessionManager; } as unknown as SessionManager;
const deps = baseDeps({ const deps = baseDeps({
cdp: { cdp: {
send: vi.fn(async () => ({ object: { objectId: "obj-1" } })), send: vi.fn(async (_tabId, method) => {
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "DOM.getContentQuads") {
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
}
throw new Error(`unexpected ${method}`);
}),
} as unknown as RequestHelpDeps["cdp"], } as unknown as RequestHelpDeps["cdp"],
}); });
const res = await handleRequestHelp( const res = await handleRequestHelp(
@@ -375,14 +382,97 @@ describe("handleRequestHelp", () => {
deps, deps,
); );
const sentMsg = (deps.sendToTab as ReturnType<typeof vi.fn>).mock.calls[0][1]; const sentMsg = (deps.sendToTab as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(sentMsg.selectors).toContain('[data-bsk-help="0"]'); expect(sentMsg.selectors).toEqual([]);
expect(sentMsg.rects).toEqual([{ top: 20, left: 10, width: 100, height: 40 }]);
expect(res).toMatchObject({ expect(res).toMatchObject({
outcome: "continued", outcome: "continued",
tab_id: 5, tab_id: 5,
resolved_targets: [{ matched: true, ref: "@e1" }], resolved_targets: [
{
matched: true,
ref: "@e1",
},
],
}); });
}); });
it("projects an OOPIF ref through its live content quad for the help overlay", async () => {
const mgr = {
get: (id: string) =>
id === "abcd"
? {
sessionId: "abcd",
agentWindowId: 99,
refStore: {
resolveEntry: () => ({
backendNodeId: 42,
tabId: 5,
frameId: "child",
cdpSessionId: "child-session",
generation: 1,
}),
},
borrowedTabs: new Map(),
}
: null,
findByWindowId: (wid: number) => (wid === 99 ? { sessionId: "abcd" } : null),
} as unknown as SessionManager;
const send = vi.fn(async (_tabId, method) => {
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "DOM.getBoxModel") {
return { model: { content: [204, 306, 604, 306, 604, 506, 204, 506] } };
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
}
throw new Error(`unexpected root ${method}`);
});
const sendToTarget = vi.fn(async (_target, method) => {
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "DOM.getContentQuads") {
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
}
throw new Error(`unexpected child ${method}`);
});
const deps = baseDeps({
cdp: {
send,
sendToTarget,
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 5 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 99,
target: { tabId: 5, sessionId: "child-session" },
},
],
})),
} as unknown as NonNullable<RequestHelpDeps["cdp"]>,
});
const res = await handleRequestHelp(
mgr,
baseParams({ tab_id: 5, targets: [{ ref: "@e1" }] }),
deps,
);
const sentMsg = (deps.sendToTab as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(sentMsg.rects).toEqual([{ top: 346, left: 224, width: 200, height: 80 }]);
expect(res).toMatchObject({ resolved_targets: [{ matched: true, ref: "@e1" }] });
expect(sendToTarget).toHaveBeenCalledWith(
{ tabId: 5, sessionId: "child-session" },
"DOM.getContentQuads",
{ backendNodeId: 42 },
);
});
it("reports ref target unmatched when ref does not resolve", async () => { it("reports ref target unmatched when ref does not resolve", async () => {
const deps = baseDeps(); const deps = baseDeps();
const res = await handleRequestHelp( const res = await handleRequestHelp(
@@ -398,16 +488,15 @@ describe("handleRequestHelp", () => {
}); });
it("reports ref target unmatched when ref is for another tab", async () => { it("reports ref target unmatched when ref is for another tab", async () => {
const refStore = new RefStore();
refStore.set("e1", 42, { tabId: 4 });
const mgr = { const mgr = {
get: (id: string) => get: (id: string) =>
id === "abcd" id === "abcd"
? { ? {
sessionId: "abcd", sessionId: "abcd",
agentWindowId: 99, agentWindowId: 99,
refStore: { refStore,
resolve: (ref: string, opts: { tabId?: number }) =>
ref === "e1" && opts.tabId === 4 ? 42 : null,
},
borrowedTabs: new Map(), borrowedTabs: new Map(),
} }
: null, : null,
@@ -30,6 +30,9 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
const sendImpl = async (tabId: number, method: string, params?: object) => { const sendImpl = async (tabId: number, method: string, params?: object) => {
sent.push({ tabId, method, params }); sent.push({ tabId, method, params });
const h = handlers[method]; const h = handlers[method];
if (!h && method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
}
if (!h) throw new Error(`unexpected CDP call ${method}`); if (!h) throw new Error(`unexpected CDP call ${method}`);
return h(params); return h(params);
}; };
@@ -164,6 +167,63 @@ describe("handleClick", () => {
}); });
}); });
it("resolves frame refs in their CDP session and dispatches input in top coordinates", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, {
tabId: 4,
frameId: "child-frame",
cdpSessionId: "child-session",
});
const fake = makeFakeCdp({
"Input.dispatchMouseEvent": () => ({}),
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.getBoxModel": () => ({
model: { content: [204, 306, 604, 306, 604, 506, 204, 506] },
}),
});
fake.cdp.getFrameGraph = vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child-frame",
parentFrameId: "main",
ownerBackendNodeId: 99,
target: { tabId: 4, sessionId: "child-session" },
},
],
}));
const targetCalls: Array<{ sessionId?: string; method: string }> = [];
fake.cdp.sendToTarget = vi.fn(async (target, method) => {
targetCalls.push({ sessionId: target.sessionId, method });
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "DOM.getContentQuads") {
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
}
throw new Error(`unexpected child CDP call ${method}`);
}) as CdpRunner["sendToTarget"];
const res = await handleClick(
sm,
{ session_id: "aa11", ref: "@e3" },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
// The iframe content box starts at (204, 306) after its border and is scaled 2x.
expect(res).toMatchObject({ x: 324, y: 386 });
expect(targetCalls).toEqual([
{ sessionId: "child-session", method: "DOM.scrollIntoViewIfNeeded" },
{ sessionId: "child-session", method: "DOM.getContentQuads" },
{ sessionId: "child-session", method: "Page.getLayoutMetrics" },
]);
expect(fake.sent.filter((call) => call.method === "Input.dispatchMouseEvent")).toHaveLength(3);
});
it("enables overlay bypass before mouse events when overlay blocks the click point", async () => { it("enables overlay bypass before mouse events when overlay blocks the click point", async () => {
const order: string[] = []; const order: string[] = [];
const bypassOverlay = vi.fn(async (_tabId: number, enabled: boolean) => { const bypassOverlay = vi.fn(async (_tabId: number, enabled: boolean) => {
@@ -5,6 +5,7 @@ import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "@/lib/overlay-bridg
import { SessionManager } from "@/session-manager/manager"; import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared"; import type { CdpRunner } from "@/tools/shared";
import { import {
buildFrameVomScene,
buildVomScene, buildVomScene,
type CdpAxNode, type CdpAxNode,
handleGetHtml, handleGetHtml,
@@ -62,6 +63,9 @@ function makeFakeCdp(handlers: Record<string, (params?: object) => unknown>) {
const send = vi.fn(async (_tabId: number, method: string, params?: object) => { const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
sent.push({ method, params }); sent.push({ method, params });
const handler = handlers[method]; const handler = handlers[method];
if (!handler && method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
}
if (!handler) throw new Error(`unexpected CDP call ${method}`); if (!handler) throw new Error(`unexpected CDP call ${method}`);
return handler(params); return handler(params);
}); });
@@ -508,6 +512,292 @@ describe("handleScreenshot overlay suppression", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("buildVomScene", () => { describe("buildVomScene", () => {
it("merges multiple sibling and nested frames without mixing their targets", () => {
const node = (
backendNodeId: number,
parentBackendNodeId: number | null,
frameId: string,
tag: string,
attrs: Record<string, string> = {},
): CapturedNode => ({
backendNodeId,
parentBackendNodeId,
frameId,
tag,
attrs,
rect: { x: 0, y: 0, w: 100, h: 30 },
localRect: { x: 0, y: 0, w: 100, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
});
const mainNodes = [
node(1, null, "main", "body"),
node(10, 1, "main", "iframe", { title: "First" }),
node(20, 1, "main", "iframe", { title: "Second" }),
];
const firstNodes = [
node(100, null, "first", "body"),
node(101, 100, "first", "button", { "aria-label": "First action" }),
];
const secondNodes = [
node(200, null, "second", "body"),
node(201, 200, "second", "h2"),
node(210, 200, "second", "iframe", { title: "Nested" }),
];
const nestedNodes = [
node(300, null, "nested", "body"),
node(301, 300, "nested", "input", { placeholder: "Nested field" }),
];
const captured: CapturedViewModel = {
viewport: { width: 1200, height: 800 },
nodes: mainNodes,
iframeNodes: new Map([
[10, firstNodes],
[20, secondNodes],
[210, nestedNodes],
]),
frameNodes: new Map([
["main", mainNodes],
["first", firstNodes],
["second", secondNodes],
["nested", nestedNodes],
]),
frameOwnerBackendNodeIds: new Map([
["first", 10],
["second", 20],
["nested", 210],
]),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
const axNode = (
nodeId: string,
backendDOMNodeId: number,
role: string,
parentId?: string,
name?: string,
): CdpAxNode => ({
nodeId,
backendDOMNodeId,
role: { type: "role", value: role },
...(parentId ? { parentId } : {}),
...(name ? { name: { type: "computedString", value: name } } : {}),
});
const result = buildFrameVomScene(
[
{
frameId: "main",
contextScopeId: "main",
target: { tabId: 7 },
domNodes: mainNodes,
axNodes: [
axNode("root", 1, "RootWebArea"),
axNode("frame-1", 10, "Iframe", "root", "First"),
axNode("frame-2", 20, "Iframe", "root", "Second"),
],
},
{
frameId: "first",
contextScopeId: "first",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 7, sessionId: "session-first" },
domNodes: firstNodes,
axNodes: [
axNode("root", 100, "RootWebArea"),
axNode("button", 101, "button", "root", "First action"),
],
},
{
frameId: "second",
contextScopeId: "second",
parentFrameId: "main",
ownerBackendNodeId: 20,
target: { tabId: 7, sessionId: "session-second" },
domNodes: secondNodes,
axNodes: [
axNode("root", 200, "RootWebArea"),
axNode("heading", 201, "heading", "root", "Second heading"),
axNode("nested-frame", 210, "Iframe", "root", "Nested"),
],
},
{
frameId: "nested",
contextScopeId: "nested",
parentFrameId: "second",
ownerBackendNodeId: 210,
target: { tabId: 7, sessionId: "session-nested" },
domNodes: nestedNodes,
axNodes: [
axNode("root", 300, "RootWebArea"),
axNode("input", 301, "textbox", "root", "Nested field"),
],
},
],
captured,
);
const rendered = renderVom(result);
expect(rendered.text).toContain('Iframe "First"');
expect(rendered.text).toContain('@e1 button "First action"');
expect(rendered.text).toContain('heading "Second heading"');
expect(rendered.text).toContain('Iframe "Nested"');
expect(rendered.text).toContain('textbox "Nested field"');
expect(rendered.refs.find((ref) => ref.backendNodeId === 101)?.frameId).toBe("first");
expect(rendered.refs.find((ref) => ref.backendNodeId === 301)?.frameId).toBe("nested");
const firstAction = result.nodes.find((item) => item.backendNodeId === 101);
const nestedInput = result.nodes.find((item) => item.backendNodeId === 301);
expect(firstAction).toMatchObject({ frameId: "first", contextScopeId: "first" });
expect(nestedInput).toMatchObject({ frameId: "nested", contextScopeId: "nested" });
expect(nestedInput?.parentId).toBe(result.nodes.find((item) => item.backendNodeId === 210)?.id);
});
it("does not place a child document at the page root when its frame boundary is unresolved", () => {
const childNode: CapturedNode = {
backendNodeId: 101,
parentBackendNodeId: null,
frameId: "child",
tag: "button",
attrs: {},
rect: { x: 0, y: 0, w: 100, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
};
const captured: CapturedViewModel = {
viewport: { width: 800, height: 600 },
nodes: [],
iframeNodes: new Map(),
frameNodes: new Map([["child", [childNode]]]),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
const result = buildFrameVomScene(
[
{
frameId: "main",
contextScopeId: "main",
target: { tabId: 7 },
domNodes: [],
axNodes: [],
},
{
frameId: "child",
contextScopeId: "child",
parentFrameId: "main",
target: { tabId: 7 },
domNodes: [childNode],
axNodes: [
{
nodeId: "button",
frameId: "child",
backendDOMNodeId: 101,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "Child action" },
},
],
},
],
captured,
);
expect(result.nodes).toEqual([]);
});
it("keeps AX-only frame content attached to its iframe boundary", () => {
const mainNodes: CapturedNode[] = [
{
backendNodeId: 1,
parentBackendNodeId: null,
frameId: "main",
tag: "body",
attrs: {},
rect: { x: 0, y: 0, w: 800, h: 600 },
paintOrder: 0,
position: "static",
pointerEvents: "auto",
},
{
backendNodeId: 10,
parentBackendNodeId: 1,
frameId: "main",
tag: "iframe",
attrs: { title: "Remote" },
rect: { x: 100, y: 100, w: 400, h: 300 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
},
];
const captured: CapturedViewModel = {
viewport: { width: 800, height: 600 },
nodes: mainNodes,
iframeNodes: new Map(),
frameNodes: new Map([
["main", mainNodes],
["child", []],
]),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
const result = buildFrameVomScene(
[
{
frameId: "main",
contextScopeId: "main",
target: { tabId: 7 },
domNodes: mainNodes,
axNodes: [
{
nodeId: "main-root",
backendDOMNodeId: 1,
role: { type: "role", value: "RootWebArea" },
},
{
nodeId: "frame-owner",
parentId: "main-root",
backendDOMNodeId: 10,
role: { type: "role", value: "Iframe" },
name: { type: "computedString", value: "Remote" },
},
],
},
{
frameId: "child",
contextScopeId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 7, sessionId: "child-session" },
domNodes: [],
axNodes: [
{
nodeId: "child-root",
backendDOMNodeId: 100,
role: { type: "role", value: "RootWebArea" },
},
{
nodeId: "child-button",
parentId: "child-root",
backendDOMNodeId: 101,
role: { type: "role", value: "button" },
name: { type: "computedString", value: "AX fallback action" },
},
],
},
],
captured,
);
const childButton = result.nodes.find((node) => node.backendNodeId === 101);
expect(childButton).toMatchObject({ frameId: "child", contextScopeId: "child" });
expect(childButton?.parentId).toBe(result.nodes.find((node) => node.backendNodeId === 10)?.id);
expect(renderVom(result).text).toContain('@e1 button "AX fallback action"');
});
it("joins AX semantics with captured geometry by backendDOMNodeId", () => { it("joins AX semantics with captured geometry by backendDOMNodeId", () => {
const axNodes: CdpAxNode[] = [ const axNodes: CdpAxNode[] = [
{ {
@@ -816,6 +1106,7 @@ describe("buildVomScene", () => {
parentId: "2", parentId: "2",
role: { type: "role", value: "textbox" }, role: { type: "role", value: "textbox" },
name: { type: "x", value: "输入密码" }, name: { type: "x", value: "输入密码" },
value: { value: "iframe-secret" },
backendDOMNodeId: 202, backendDOMNodeId: 202,
properties: [{ name: "inputType", value: { value: "password" } }], properties: [{ name: "inputType", value: { value: "password" } }],
}, },
@@ -870,6 +1161,10 @@ describe("buildVomScene", () => {
expect(passwordNodes).toHaveLength(1); expect(passwordNodes).toHaveLength(1);
expect(passwordNodes[0]).toEqual(expect.objectContaining({ id: 202, sensitive: true })); expect(passwordNodes[0]).toEqual(expect.objectContaining({ id: 202, sensitive: true }));
expect(passwordNodes[0].value).toBeUndefined();
const rendered = renderVom(buildVomScene(axNodes, captured)).text;
expect(rendered).toContain('textbox "输入密码" [filled] ="•••"');
expect(rendered).not.toContain("iframe-secret");
}); });
it("keeps unnamed iframe controls but skips unnamed iframe links", () => { it("keeps unnamed iframe controls but skips unnamed iframe links", () => {
@@ -1034,7 +1329,7 @@ describe("buildVomScene", () => {
expect(rendered).toContain(' @e2 textbox "验证码"'); expect(rendered).toContain(' @e2 textbox "验证码"');
}); });
it("marks current-password autocomplete as sensitive even for text inputs", () => { it("does not mark non-password inputs sensitive from password-like labels", () => {
const axNodes: CdpAxNode[] = [ const axNodes: CdpAxNode[] = [
{ {
nodeId: "1", nodeId: "1",
@@ -1069,7 +1364,7 @@ describe("buildVomScene", () => {
expect(buildVomScene(axNodes, captured).nodes[0]).toEqual( expect(buildVomScene(axNodes, captured).nodes[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
id: 10, id: 10,
sensitive: true, sensitive: false,
}), }),
); );
}); });
@@ -1364,9 +1659,7 @@ describe("buildVomScene", () => {
); );
const rendered = renderVom(scene); const rendered = renderVom(scene);
expect(rendered.text).toContain('@e1 button "close"'); expect(rendered.text).toContain('@e1 button "close"');
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([ expect(rendered.refs).toEqual([{ ref: "e1", backendNodeId: 20 }]);
{ ref: "e1", backendNodeId: 20 },
]);
}); });
it("does not promote a clickable container that wraps a real interactive control", () => { it("does not promote a clickable container that wraps a real interactive control", () => {
@@ -1510,9 +1803,7 @@ describe("buildVomScene", () => {
expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic"); expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic");
const rendered = renderVom(scene); const rendered = renderVom(scene);
expect(rendered.text).toContain('@e1 button "收藏"'); expect(rendered.text).toContain('@e1 button "收藏"');
expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([ expect(rendered.refs).toEqual([{ ref: "e1", backendNodeId: 20 }]);
{ ref: "e1", backendNodeId: 20 },
]);
}); });
it("builds active scope blocks from active aria-controls relationships", () => { it("builds active scope blocks from active aria-controls relationships", () => {
@@ -2461,6 +2752,64 @@ describe("handleSnapshot", () => {
expect(ctx.refStore.resolve("e1")).toBeNull(); expect(ctx.refStore.resolve("e1")).toBeNull();
}); });
it("keeps the previous RefStore when cancellation lands during DOM capture", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 999, { tabId: 4 });
const controller = new AbortController();
let resolveCapture: (value: unknown) => void = () => {};
const send = vi.fn(async (_tabId: number, method: string) => {
if (method === "Accessibility.enable") return {};
if (method === "Accessibility.getFullAXTree") {
return {
nodes: [
{
nodeId: "new",
role: { type: "role", value: "button" },
name: { type: "computedString", value: "New" },
backendDOMNodeId: 123,
},
],
};
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
}
if (method === "DOMSnapshot.enable") return {};
if (method === "DOMSnapshot.captureSnapshot") {
return new Promise((resolve) => {
resolveCapture = resolve;
});
}
throw new Error(`unexpected CDP method ${method}`);
});
const deps = {
cdp: {
send: send as unknown as <T = unknown>(
tabId: number,
method: string,
params?: object,
) => Promise<T>,
trackSessionTab: vi.fn(),
},
tabsApi: {
get: vi.fn(),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
},
};
const pending = handleSnapshot(sm, { session_id: "aa11" }, deps, controller.signal);
await vi.waitFor(() =>
expect(send).toHaveBeenCalledWith(4, "DOMSnapshot.captureSnapshot", expect.any(Object)),
);
controller.abort();
resolveCapture({ strings: [], documents: [] });
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
expect(ctx.refStore.resolve("e1", { tabId: 4 })).toBe(999);
expect(ctx.refStore.resolve("e2")).toBeNull();
});
it("surfaces CDP failures as cdp_failed", async () => { it("surfaces CDP failures as cdp_failed", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11"); await sm.start("aa11");
@@ -3069,6 +3418,32 @@ describe("handleGetHtml", () => {
expect(deps.send).toHaveBeenCalledTimes(1); expect(deps.send).toHaveBeenCalledTimes(1);
}); });
it("reads a frame ref through its child CDP session", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e7", 4242, {
tabId: 4,
frameId: "child-frame",
cdpSessionId: "child-session",
});
const deps = makeDeps({});
const sendToTarget = vi.fn(async (target, method, params) => {
expect(target).toEqual({ tabId: 4, sessionId: "child-session" });
expect(method).toBe("DOM.getOuterHTML");
expect(params).toEqual({ backendNodeId: 4242 });
return { outerHTML: "<button>frame</button>" };
});
(deps.cdp as CdpRunner).sendToTarget = sendToTarget as unknown as NonNullable<
CdpRunner["sendToTarget"]
>;
const res = await handleGetHtml(sm, { session_id: "aa11", ref: "@e7" }, deps);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.html).toBe("<button>frame</button>");
expect(deps.send).not.toHaveBeenCalled();
});
it("returns not_found when a ref belongs to another tab", async () => { it("returns not_found when a ref belongs to another tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11"); const ctx = await sm.start("aa11");
File diff suppressed because it is too large Load Diff
@@ -60,6 +60,7 @@ function makeApis(
}), }),
getLastFocused: vi.fn(async () => ({ id: 500 }) as chrome.windows.Window), getLastFocused: vi.fn(async () => ({ id: 500 }) as chrome.windows.Window),
create: vi.fn(async () => ({ id: 999 }) as chrome.windows.Window), create: vi.fn(async () => ({ id: 999 }) as chrome.windows.Window),
remove: vi.fn(async () => {}),
}; };
return { tabs, windows }; return { tabs, windows };
} }
@@ -46,6 +46,25 @@ describe("lookupSnapshotRef", () => {
}); });
describe("resolveSnapshotRef", () => { describe("resolveSnapshotRef", () => {
it("preserves the frame and child session needed to route iframe refs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e3", 1234, {
tabId: 4,
frameId: "child-frame",
cdpSessionId: "child-session",
});
const expected = {
backendNodeId: 1234,
refKey: "e3",
frameId: "child-frame",
cdpSessionId: "child-session",
};
expect(lookupSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
expect(resolveSnapshotRef(ctx, "@e3", 4)).toEqual(expected);
});
it("returns not_found for unknown ref", async () => { it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11"); const ctx = await sm.start("aa11");
@@ -179,6 +179,7 @@ function makeWindowsApi(
get: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn>;
lastFocused: ReturnType<typeof vi.fn>; lastFocused: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
}; };
} { } {
const get = vi.fn(async (windowId: number) => { const get = vi.fn(async (windowId: number) => {
@@ -197,7 +198,13 @@ function makeWindowsApi(
const id = opts?.createWindowId ?? 999; const id = opts?.createWindowId ?? 999;
return { id } as chrome.windows.Window; return { id } as chrome.windows.Window;
}); });
return { api: { get, getLastFocused: lastFocused, create }, spies: { get, lastFocused, create } }; const remove = vi.fn(async (windowId: number) => {
state.windowsClosed.add(windowId);
});
return {
api: { get, getLastFocused: lastFocused, create, remove },
spies: { get, lastFocused, create, remove },
};
} }
describe("handleTabCreate", () => { describe("handleTabCreate", () => {
@@ -601,6 +608,86 @@ describe("handleTabReturn", () => {
expect(winSpies.create).toHaveBeenCalledOnce(); expect(winSpies.create).toHaveBeenCalledOnce();
}); });
it("closes a newly created fallback window when cancellation wins before the move", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([200]),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
lastFocused: 100,
createWindowId: 777,
});
let resolveCreate: (window: chrome.windows.Window) => void = () => {};
winSpies.create.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
const controller = new AbortController();
const pending = handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi, signal: controller.signal },
);
await vi.waitFor(() => expect(winSpies.create).toHaveBeenCalledOnce());
controller.abort();
resolveCreate({ id: 777 } as chrome.windows.Window);
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
expect(winSpies.remove).toHaveBeenCalledWith(777);
expect(spies.move).not.toHaveBeenCalled();
expect(ctx.borrowedTabs.has(7)).toBe(true);
});
it("surfaces the fallback window id when cancellation cleanup fails", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([200]),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
lastFocused: 100,
createWindowId: 777,
});
let resolveCreate: (window: chrome.windows.Window) => void = () => {};
winSpies.create.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
}),
);
winSpies.remove.mockRejectedValueOnce(new Error("window removal denied"));
const controller = new AbortController();
const pending = handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi, signal: controller.signal },
);
await vi.waitFor(() => expect(winSpies.create).toHaveBeenCalledOnce());
controller.abort();
resolveCreate({ id: 777 } as chrome.windows.Window);
await expect(pending).resolves.toMatchObject({
code: "protocol_error",
data: { reason: "cleanup_failed", resource_type: "window", resource_id: 777 },
message: expect.stringMatching(/fallback window 777.*window removal denied/),
});
expect(spies.move).not.toHaveBeenCalled();
expect(ctx.borrowedTabs.has(7)).toBe(true);
});
it("falls back to a new window when getLastFocused only returns the Agent Window", async () => { it("falls back to a new window when getLastFocused only returns the Agent Window", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11"); const ctx = await sm.start("aa11");
@@ -250,9 +250,10 @@ const DEFAULT_NOTIFICATION_COPY: BorrowNotificationCopy = {
// URL injection-eligibility test // URL injection-eligibility test
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const CHROME_WEB_STORE_RES = [ const EXTENSION_STORE_RES = [
/^https:\/\/chrome\.google\.com\/webstore/i, /^https:\/\/chrome\.google\.com\/webstore/i,
/^https:\/\/chromewebstore\.google\.com/i, /^https:\/\/chromewebstore\.google\.com/i,
/^https:\/\/microsoftedge\.microsoft\.com\/addons/i,
]; ];
/** /**
@@ -264,14 +265,15 @@ const CHROME_WEB_STORE_RES = [
* - `ftp://` no longer hosts content scripts reliably in modern Chrome. * - `ftp://` no longer hosts content scripts reliably in modern Chrome.
* - `about:` / `chrome:` / `chrome-extension:` / `edge:` / `devtools:` / * - `about:` / `chrome:` / `chrome-extension:` / `edge:` / `devtools:` /
* `view-source:` / `data:` / `blob:` are blocked by the platform. * `view-source:` / `data:` / `blob:` are blocked by the platform.
* - The Chrome Web Store (both legacy and new domains) is also blocked. * - Extension storefronts are blocked by their own browser: the Chrome Web
* Store (legacy and new domains) under Chrome, and Edge Add-ons under Edge.
* *
* Mirrors the implicit scheme list documented in * Mirrors the implicit scheme list documented in
* https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns. * https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns.
*/ */
export function isInjectableContentScriptUrl(url: string | undefined): boolean { export function isInjectableContentScriptUrl(url: string | undefined): boolean {
if (!url) return false; if (!url) return false;
for (const re of CHROME_WEB_STORE_RES) { for (const re of EXTENSION_STORE_RES) {
if (re.test(url)) return false; if (re.test(url)) return false;
} }
return /^https?:\/\//i.test(url); return /^https?:\/\//i.test(url);
@@ -1,124 +0,0 @@
import { type RenderedRef, renderVom, type VomOptions } from "@browser-skill/vom";
import { readRecordDocumentTokenFromAttrs } from "@/lib/record-document-token";
import type { CdpAxNode } from "./observation";
import { buildVomScene } from "./observation";
import type { CdpRunner } from "./shared";
import {
type CapturedNode,
type CapturedSurfaceProbe,
type CapturedViewModel,
captureViewModel,
collectOverlayExcludedBackendIds,
} from "./vom/capture";
export interface CaptureVomObservationOptions extends VomOptions {
conditionalSurfaceProbe?: boolean;
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
}
export interface CaptureVomObservationResult {
text: string;
refs: RenderedRef[];
truncated: boolean;
captured: CapturedNode[];
/** All nodes including iframe sub-documents, for geometric target matching. */
matchNodes: CapturedNode[];
/** Maps ephemeral record document tokens to immediate iframe owner backend ids. */
documentTokenOwners: Map<string, number | null>;
surfaceProbes?: CapturedSurfaceProbe[];
}
export function flattenCapturedNodes(vm: CapturedViewModel): CapturedNode[] {
return [...vm.nodes, ...[...vm.iframeNodes.values()].flat()];
}
export function collectDocumentTokenOwners(vm: CapturedViewModel): Map<string, number | null> {
const owners = new Map<string, number | null>();
for (const node of flattenCapturedNodes(vm)) {
if (node.tag !== "html") continue;
const token = readRecordDocumentTokenFromAttrs(node.attrs);
if (!token) continue;
owners.set(token, node.ownerFrameBackendNodeId ?? null);
}
return owners;
}
function emptyCapturedViewModel(viewport = { width: 0, height: 0 }): CapturedViewModel {
return { viewport, nodes: [], iframeNodes: new Map(), excludedBackendNodeIds: new Set() };
}
interface LayoutMetricsViewportReply {
cssLayoutViewport?: { clientWidth?: number; clientHeight?: number };
layoutViewport?: { clientWidth?: number; clientHeight?: number };
}
async function fallbackCapturedViewModel(
cdp: CdpRunner,
tabId: number,
): Promise<CapturedViewModel> {
let viewport = { width: 0, height: 0 };
try {
const metrics = await cdp.send<LayoutMetricsViewportReply>(tabId, "Page.getLayoutMetrics", {});
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
viewport = {
width: vpSrc.clientWidth ?? 0,
height: vpSrc.clientHeight ?? 0,
};
} catch {
// viewport stays zero-sized
}
const excludedBackendNodeIds = await collectOverlayExcludedBackendIds(cdp, tabId);
return { ...emptyCapturedViewModel(viewport), excludedBackendNodeIds };
}
async function captureForVom(
cdp: CdpRunner,
tabId: number,
conditionalSurfaceProbe: boolean,
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>,
): Promise<CapturedViewModel> {
try {
return await captureViewModel(cdp, tabId, {
conditionalSurfaceProbe,
hoverProbeBypassOverlay,
});
} catch {
return fallbackCapturedViewModel(cdp, tabId);
}
}
/** Pure VOM capture pipeline shared by snapshot, observe, and recording. */
export async function captureVomObservation(
cdp: CdpRunner,
tabId: number,
url: string | undefined,
opts: CaptureVomObservationOptions = {},
): Promise<CaptureVomObservationResult> {
await cdp.ensureAttachedToUrl?.(tabId, url);
await cdp.send<unknown>(tabId, "Accessibility.enable", {});
const result = await cdp.send<{ nodes: CdpAxNode[] }>(tabId, "Accessibility.getFullAXTree", {});
const axNodes = result.nodes ?? [];
const captured = await captureForVom(
cdp,
tabId,
opts.conditionalSurfaceProbe ?? false,
opts.hoverProbeBypassOverlay,
);
const scene = buildVomScene(axNodes, captured, { pageUrl: url });
const rendered = renderVom(scene, {
maxDepth: opts.maxDepth,
maxTokens: opts.maxTokens,
redactValues: opts.redactValues,
activeRegionPolicy: opts.activeRegionPolicy ?? true,
});
const matchNodes = flattenCapturedNodes(captured);
return {
text: rendered.text,
refs: rendered.refs,
truncated: rendered.truncated,
captured: captured.nodes,
matchNodes,
documentTokenOwners: collectDocumentTokenOwners(captured),
surfaceProbes: captured.surfaceProbes,
};
}
+4
View File
@@ -27,19 +27,23 @@ export async function handleConsole(
manager: SessionManager, manager: SessionManager,
params: ConsoleParams, params: ConsoleParams,
deps: ConsoleDeps = defaultConsoleDeps(), deps: ConsoleDeps = defaultConsoleDeps(),
signal?: AbortSignal,
): Promise<ConsoleResult | RpcError> { ): Promise<ConsoleResult | RpcError> {
if (signal?.aborted) return { code: "cancelled", message: "console aborted" };
const ctxOrErr = lookupSession(manager, params, "console"); const ctxOrErr = lookupSession(manager, params, "console");
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseBufferedReadBounds(params); const bounds = parseBufferedReadBounds(params);
if (isRpcError(bounds)) return bounds; if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi); const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
if (signal?.aborted) return { code: "cancelled", message: "console aborted" };
if (!deps.cdp.ensureConsoleCapture || !deps.cdp.consoleEntriesSince) { if (!deps.cdp.ensureConsoleCapture || !deps.cdp.consoleEntriesSince) {
return { code: "cdp_failed", message: "console capture requires CDP console support" }; return { code: "cdp_failed", message: "console capture requires CDP console support" };
} }
try { try {
await deps.cdp.ensureConsoleCapture(target.tabId); await deps.cdp.ensureConsoleCapture(target.tabId);
if (signal?.aborted) return { code: "cancelled", message: "console aborted" };
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
return deps.cdp.consoleEntriesSince( return deps.cdp.consoleEntriesSince(
target.tabId, target.tabId,
+120 -56
View File
@@ -4,6 +4,7 @@ import type { Transport } from "@/transport/transport";
import type { import type {
ClickParams, ClickParams,
ConsoleParams, ConsoleParams,
DownloadParams,
EmulateParams, EmulateParams,
EvaluateParams, EvaluateParams,
FillParams, FillParams,
@@ -28,10 +29,12 @@ import type {
ScreenshotParams, ScreenshotParams,
SelectParams, SelectParams,
SnapshotParams, SnapshotParams,
UploadParams,
WaitForNavigationParams, WaitForNavigationParams,
} from "@/transport/types"; } from "@/transport/types";
import { isRequestFrame } from "@/transport/types"; import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console"; import { handleConsole } from "./console";
import { handleDownload } from "./download";
import { type EmulateCdpRunner, handleEmulate } from "./emulate"; import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { handleEvaluate } from "./evaluate"; import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop"; import { handleRequestHelp } from "./human-loop";
@@ -74,6 +77,7 @@ import {
type TabReturnParams, type TabReturnParams,
type TabSelectParams, type TabSelectParams,
} from "./tabs"; } from "./tabs";
import { handleUpload } from "./upload";
import { handleWaitForNavigation } from "./waits"; import { handleWaitForNavigation } from "./waits";
import { handleWindowResize, type WindowResizeParams } from "./window"; import { handleWindowResize, type WindowResizeParams } from "./window";
@@ -125,11 +129,10 @@ export interface DispatcherDeps {
* `AbortController` keyed by its wire `id` in * `AbortController` keyed by its wire `id` in
* [`inflightAbortControllers`]. When the daemon pushes a `cancel` * [`inflightAbortControllers`]. When the daemon pushes a `cancel`
* request the dispatcher trips the matching controller; tool * request the dispatcher trips the matching controller; tool
* handlers that already accept a `signal` (waits, navigation, * handlers observe that signal between awaited operations. The
* interaction, evaluate, tabs) react in line, and the dispatcher * original RPC remains pending until its handler has stopped or
* additionally races the in-flight invocation against the abort * completed compensation; only the separate cancel acknowledgement
* promise so handlers without explicit signal plumbing still respond * takes the fast path.
* promptly with `cancelled`.
*/ */
export class ToolDispatcher { export class ToolDispatcher {
private readonly transport: Transport; private readonly transport: Transport;
@@ -221,7 +224,7 @@ export class ToolDispatcher {
try { try {
const sessionId = sessionIdForBrowserControlMethod(req); const sessionId = sessionIdForBrowserControlMethod(req);
if (sessionId) this.onBrowserControlResumed?.(sessionId); if (sessionId) this.onBrowserControlResumed?.(sessionId);
const result = await Promise.race([this.invoke(req, ac.signal), abortPromise(ac.signal)]); const result = await this.invoke(req, ac.signal);
if (isRpcError(result)) { if (isRpcError(result)) {
body = { id: req.id, error: result }; body = { id: req.id, error: result };
} else { } else {
@@ -286,7 +289,7 @@ export class ToolDispatcher {
private async invoke(req: RequestFrame, signal: AbortSignal): Promise<unknown | RpcError> { private async invoke(req: RequestFrame, signal: AbortSignal): Promise<unknown | RpcError> {
switch (req.method) { switch (req.method) {
case "tool.session_start": case "tool.session_start":
return handleSessionStart(this.sessions, req.params as SessionStartParams); return handleSessionStart(this.sessions, req.params as SessionStartParams, { signal });
case "tool.session_stop": { case "tool.session_stop": {
await this.releaseHoverLatch((req.params as SessionStopParams).session_id); await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
return handleSessionStop(this.sessions, req.params as SessionStopParams, { return handleSessionStop(this.sessions, req.params as SessionStopParams, {
@@ -294,29 +297,36 @@ export class ToolDispatcher {
}); });
} }
case "tool.tab_list": case "tool.tab_list":
return handleTabList(this.sessions, req.params as TabListParams); return handleTabList(this.sessions, req.params as TabListParams, chromeTabsApi, signal);
case "tool.tab_create": case "tool.tab_create":
return handleTabCreate(this.sessions, req.params as TabCreateParams); return handleTabCreate(this.sessions, req.params as TabCreateParams, { signal });
case "tool.tab_close": case "tool.tab_close":
return this.withHoverReleaseForRequest(req.params as TabCloseParams, () => return this.withHoverReleaseForRequest(
handleTabClose(this.sessions, req.params as TabCloseParams), req.params as TabCloseParams,
() => handleTabClose(this.sessions, req.params as TabCloseParams, { signal }),
signal,
); );
case "tool.tab_select": case "tool.tab_select":
return handleTabSelect(this.sessions, req.params as TabSelectParams); return handleTabSelect(this.sessions, req.params as TabSelectParams, { signal });
case "tool.tab_borrow": case "tool.tab_borrow":
return handleTabBorrow(this.sessions, req.params as TabBorrowParams, { return handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
signal, signal,
approveBorrow: this.approveBorrow, approveBorrow: this.approveBorrow,
}); });
case "tool.tab_return": case "tool.tab_return":
return handleTabReturn(this.sessions, req.params as TabReturnParams); return handleTabReturn(this.sessions, req.params as TabReturnParams, { signal });
case "tool.window_resize": case "tool.window_resize":
return handleWindowResize(this.sessions, req.params as WindowResizeParams); return handleWindowResize(
this.sessions,
req.params as WindowResizeParams,
undefined,
signal,
);
case "tool.emulate": case "tool.emulate":
return handleEmulate( return handleEmulate(
this.sessions, this.sessions,
req.params as EmulateParams, req.params as EmulateParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
); );
case "tool.screenshot": case "tool.screenshot":
return handleScreenshot( return handleScreenshot(
@@ -325,31 +335,42 @@ export class ToolDispatcher {
this.cdp this.cdp
? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi } ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi }
: undefined, : undefined,
signal,
); );
case "tool.console": case "tool.console":
return handleConsole( return handleConsole(
this.sessions, this.sessions,
req.params as ConsoleParams, req.params as ConsoleParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
signal,
); );
case "tool.network": case "tool.network":
return handleNetwork( return handleNetwork(
this.sessions, this.sessions,
req.params as NetworkParams, req.params as NetworkParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
signal,
); );
case "tool.snapshot": case "tool.snapshot":
return this.withHoverReassert(req.params as SnapshotParams, () => return this.withHoverReassert(
req.params as SnapshotParams,
() =>
handleSnapshot( handleSnapshot(
this.sessions, this.sessions,
req.params as SnapshotParams, req.params as SnapshotParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
signal,
), ),
{},
signal,
); );
case "tool.observe": { case "tool.observe": {
const params = req.params as ObserveParams; const params = req.params as ObserveParams;
const hoverScope = await this.resolveHoverLatchScope(params); const hoverScope = await this.resolveHoverLatchScope(params);
return this.withHoverReassert(params, () => throwIfDispatchAborted(signal);
return this.withHoverReassert(
params,
() =>
handleObserve( handleObserve(
this.sessions, this.sessions,
params, params,
@@ -361,7 +382,10 @@ export class ToolDispatcher {
hoverProbeBypassOverlay: bypassOverlay, hoverProbeBypassOverlay: bypassOverlay,
} }
: undefined, : undefined,
signal,
), ),
{},
signal,
); );
} }
case "tool.get_html": case "tool.get_html":
@@ -369,38 +393,51 @@ export class ToolDispatcher {
this.sessions, this.sessions,
req.params as GetHtmlParams, req.params as GetHtmlParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
signal,
); );
case "tool.navigate": case "tool.navigate":
return this.withHoverReleaseForRequest(req.params as NavigateParams, () => return this.withHoverReleaseForRequest(
req.params as NavigateParams,
() =>
handleNavigate( handleNavigate(
this.sessions, this.sessions,
req.params as NavigateParams, req.params as NavigateParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
), ),
signal,
); );
case "tool.navigate_back": case "tool.navigate_back":
return this.withHoverReleaseForRequest(req.params as NavigateBackParams, () => return this.withHoverReleaseForRequest(
req.params as NavigateBackParams,
() =>
handleNavigateBack( handleNavigateBack(
this.sessions, this.sessions,
req.params as NavigateBackParams, req.params as NavigateBackParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
), ),
signal,
); );
case "tool.navigate_forward": case "tool.navigate_forward":
return this.withHoverReleaseForRequest(req.params as NavigateForwardParams, () => return this.withHoverReleaseForRequest(
req.params as NavigateForwardParams,
() =>
handleNavigateForward( handleNavigateForward(
this.sessions, this.sessions,
req.params as NavigateForwardParams, req.params as NavigateForwardParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
), ),
signal,
); );
case "tool.reload": case "tool.reload":
return this.withHoverReleaseForRequest(req.params as ReloadParams, () => return this.withHoverReleaseForRequest(
req.params as ReloadParams,
() =>
handleReload( handleReload(
this.sessions, this.sessions,
req.params as ReloadParams, req.params as ReloadParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
), ),
signal,
); );
case "tool.click": case "tool.click":
return this.withHoverReassert( return this.withHoverReassert(
@@ -419,6 +456,7 @@ export class ToolDispatcher {
: undefined, : undefined,
), ),
{ releaseAfter: true }, { releaseAfter: true },
signal,
); );
case "tool.hover": { case "tool.hover": {
const result = await handleHover( const result = await handleHover(
@@ -438,28 +476,71 @@ export class ToolDispatcher {
return this.rememberHover((req.params as HoverParams).session_id, result); return this.rememberHover((req.params as HoverParams).session_id, result);
} }
case "tool.fill": case "tool.fill":
return this.withHoverReleaseForRequest(req.params as FillParams, () => return this.withHoverReleaseForRequest(
req.params as FillParams,
() =>
handleFill( handleFill(
this.sessions, this.sessions,
req.params as FillParams, req.params as FillParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
), ),
signal,
); );
case "tool.press": case "tool.press":
return this.withHoverReleaseForRequest(req.params as PressParams, () => return this.withHoverReleaseForRequest(
req.params as PressParams,
() =>
handlePress( handlePress(
this.sessions, this.sessions,
req.params as PressParams, req.params as PressParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
), ),
signal,
); );
case "tool.select": case "tool.select":
return this.withHoverReleaseForRequest(req.params as SelectParams, () => return this.withHoverReleaseForRequest(
req.params as SelectParams,
() =>
handleSelect( handleSelect(
this.sessions, this.sessions,
req.params as SelectParams, req.params as SelectParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
), ),
signal,
);
case "tool.upload":
return this.withHoverReleaseForRequest(
req.params as UploadParams,
() =>
this.cdp
? handleUpload(this.sessions, req.params as UploadParams, {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
bypassOverlay,
})
: Promise.resolve({
code: "unsupported",
message: "upload requires CDP",
} satisfies RpcError),
signal,
);
case "tool.download":
return this.withHoverReleaseForRequest(
req.params as DownloadParams,
() =>
this.cdp
? handleDownload(this.sessions, req.params as DownloadParams, {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
bypassOverlay,
})
: Promise.resolve({
code: "unsupported",
message: "download requires CDP",
} satisfies RpcError),
signal,
); );
case "tool.evaluate": case "tool.evaluate":
return handleEvaluate( return handleEvaluate(
@@ -566,9 +647,13 @@ export class ToolDispatcher {
params: { session_id: string; tab_id?: number }, params: { session_id: string; tab_id?: number },
work: () => Promise<T>, work: () => Promise<T>,
options: { releaseAfter?: boolean } = {}, options: { releaseAfter?: boolean } = {},
signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
throwIfDispatchAborted(signal);
const scope = await this.resolveHoverLatchScope(params); const scope = await this.resolveHoverLatchScope(params);
throwIfDispatchAborted(signal);
await this.reassertHover(scope); await this.reassertHover(scope);
throwIfDispatchAborted(signal);
try { try {
return await work(); return await work();
} finally { } finally {
@@ -581,9 +666,13 @@ export class ToolDispatcher {
private async withHoverReleaseForRequest<T>( private async withHoverReleaseForRequest<T>(
params: { session_id: string; tab_id?: number }, params: { session_id: string; tab_id?: number },
work: () => Promise<T>, work: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
throwIfDispatchAborted(signal);
const scope = await this.resolveHoverLatchScope(params); const scope = await this.resolveHoverLatchScope(params);
throwIfDispatchAborted(signal);
await this.releaseHoverLatch(scope.session_id, scope.tab_id); await this.releaseHoverLatch(scope.session_id, scope.tab_id);
throwIfDispatchAborted(signal);
return work(); return work();
} }
@@ -670,6 +759,8 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
case "tool.fill": case "tool.fill":
case "tool.press": case "tool.press":
case "tool.select": case "tool.select":
case "tool.upload":
case "tool.download":
case "tool.evaluate": case "tool.evaluate":
case "tool.observe": case "tool.observe":
case "tool.request_help": case "tool.request_help":
@@ -693,41 +784,14 @@ async function bypassOverlay(tabId: number, enabled: boolean): Promise<void> {
} }
} }
/** function throwIfDispatchAborted(signal: AbortSignal | undefined): void {
* Resolves never; rejects with `AbortLikeError` as soon as the signal if (!signal?.aborted) return;
* fires (or immediately if it is already aborted). Used by the const error = new Error("rpc aborted by daemon cancel");
* dispatcher to race the tool invocation so handlers without explicit error.name = "AbortError";
* signal plumbing still surface a `cancelled` reply promptly. throw error;
*/
function abortPromise(signal: AbortSignal): Promise<never> {
return new Promise<never>((_, reject) => {
if (signal.aborted) {
reject(new AbortLikeError());
return;
}
signal.addEventListener(
"abort",
() => {
reject(new AbortLikeError());
},
{ once: true },
);
});
}
/**
* Sentinel error class so [`isAbortLikeError`] can recognise our own
* race-rejection without confusing it with a real CDP failure.
*/
class AbortLikeError extends Error {
constructor() {
super("rpc aborted by daemon cancel");
this.name = "BhAbortError";
}
} }
function isAbortLikeError(err: unknown): boolean { function isAbortLikeError(err: unknown): boolean {
if (err instanceof AbortLikeError) return true;
if (err instanceof DOMException && err.name === "AbortError") return true; if (err instanceof DOMException && err.name === "AbortError") return true;
if (typeof err === "object" && err !== null && (err as { name?: string }).name === "AbortError") { if (typeof err === "object" && err !== null && (err as { name?: string }).name === "AbortError") {
return true; return true;
@@ -0,0 +1,349 @@
// Order-independent coordinator for one browser download. CDP supplies the
// exact target/frame intent while chrome.downloads supplies the download id
// and filename routing hook; neither event is assumed to arrive first.
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { ClickResult, RpcError, TransferEffectState } from "@/transport/types";
import { transferError } from "./errors";
import { type CdpRunner, isRpcError } from "./shared";
const CORRELATION_GRACE_MS = 750;
const UNIQUE_SETTLE_MS = 50;
const SIZE_POLL_MS = 250;
type DeterminingFilenameListener = (
item: chrome.downloads.DownloadItem,
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
) => void | true;
interface ListenerEvent<T> {
addListener(listener: T): void;
removeListener(listener: T): void;
}
export interface DownloadsApi {
onCreated: ListenerEvent<(item: chrome.downloads.DownloadItem) => void>;
onChanged: ListenerEvent<(delta: chrome.downloads.DownloadDelta) => void>;
onDeterminingFilename: ListenerEvent<DeterminingFilenameListener>;
search(query: chrome.downloads.DownloadQuery): Promise<chrome.downloads.DownloadItem[]>;
cancel(downloadId: number): Promise<void>;
removeFile(downloadId: number): Promise<void>;
}
export const chromeDownloadsApi: DownloadsApi = {
get onCreated() {
return chrome.downloads.onCreated;
},
get onChanged() {
return chrome.downloads.onChanged;
},
get onDeterminingFilename() {
return chrome.downloads.onDeterminingFilename;
},
search: (query) => chrome.downloads.search(query),
cancel: (id) => chrome.downloads.cancel(id),
removeFile: (id) => chrome.downloads.removeFile(id),
};
export interface DownloadCaptureOptions {
cdp: CdpRunner;
target: CdpTarget;
expectedFrameId?: string;
downloads: DownloadsApi;
browserRelativeDir: string;
maxByteSize?: number;
timeoutMs: number;
signal?: AbortSignal;
trigger(): Promise<ClickResult | RpcError>;
}
export interface DownloadCaptureResult {
click: ClickResult;
item: chrome.downloads.DownloadItem;
}
interface DownloadIntent {
url: string;
suggestedFilename: string;
frameId?: string;
}
interface DownloadCandidate {
item: chrome.downloads.DownloadItem;
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void;
suggested: boolean;
graceTimer: ReturnType<typeof setTimeout>;
}
function safeBasename(filename: string): string {
const basename = filename.split(/[\\/]/).pop()?.trim();
return basename && basename !== "." && basename !== ".." ? basename : "download";
}
function sameTarget(source: { tabId?: number; sessionId?: string }, target: CdpTarget): boolean {
return source.tabId === target.tabId && source.sessionId === target.sessionId;
}
function matchesIntent(item: chrome.downloads.DownloadItem, intent: DownloadIntent): boolean {
const urlMatches = item.url === intent.url || item.finalUrl === intent.url;
return urlMatches && safeBasename(item.filename) === safeBasename(intent.suggestedFilename);
}
function knownSize(item: chrome.downloads.DownloadItem): number | undefined {
if (item.fileSize >= 0) return item.fileSize;
if (item.totalBytes >= 0) return item.totalBytes;
return undefined;
}
function captureError(
message: string,
effectState: TransferEffectState,
phase: string,
cleanupFailed = false,
): RpcError {
return transferError("cdp_failed", "download_capture_failed", message, {
effectState,
phase,
...(cleanupFailed ? { cleanupState: "failed" } : {}),
});
}
export async function captureBrowserDownload(
options: DownloadCaptureOptions,
): Promise<DownloadCaptureResult | RpcError> {
let click: ClickResult | undefined;
let intent: DownloadIntent | undefined;
let capturedId: number | undefined;
let capturedItem: chrome.downloads.DownloadItem | undefined;
let settled = false;
let succeeded = false;
let failureResult: RpcError | undefined;
let uniquenessTimer: ReturnType<typeof setTimeout> | undefined;
let operationTimer: ReturnType<typeof setTimeout> | undefined;
let sizePoll: ReturnType<typeof setInterval> | undefined;
const candidates = new Map<number, DownloadCandidate>();
const createdItems = new Map<number, chrome.downloads.DownloadItem>();
let resolveCompletion!: (item: chrome.downloads.DownloadItem) => void;
let rejectCompletion!: (error: Error) => void;
const completion = new Promise<chrome.downloads.DownloadItem>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
const fail = (error: Error) => {
if (settled) return;
settled = true;
rejectCompletion(error);
};
const complete = (item: chrome.downloads.DownloadItem) => {
if (settled) return;
const size = knownSize(item);
if (size !== undefined && options.maxByteSize !== undefined && size > options.maxByteSize) {
fail(new Error(`download exceeds transfer limit ${options.maxByteSize}`));
return;
}
settled = true;
capturedItem = item;
resolveCompletion(item);
};
const suggestDefault = (candidate: DownloadCandidate) => {
if (candidate.suggested) return;
candidate.suggested = true;
candidate.suggest();
};
const matchingCandidates = (): DownloadCandidate[] => {
const currentIntent = intent;
return currentIntent
? [...candidates.values()].filter(
(candidate) => !candidate.suggested && matchesIntent(candidate.item, currentIntent),
)
: [];
};
const claimUnique = () => {
uniquenessTimer = undefined;
if (settled || capturedId !== undefined || !intent) return;
const matches = matchingCandidates();
if (matches.length !== 1) {
if (matches.length > 1) {
for (const candidate of matches) suggestDefault(candidate);
fail(new Error("download attribution is ambiguous"));
}
return;
}
const candidate = matches[0];
candidate.suggested = true;
clearTimeout(candidate.graceTimer);
capturedId = candidate.item.id;
capturedItem = candidate.item;
candidate.suggest({
filename: `${options.browserRelativeDir}/${safeBasename(intent.suggestedFilename)}`,
conflictAction: "overwrite",
});
const size = knownSize(candidate.item);
if (size !== undefined && options.maxByteSize !== undefined && size > options.maxByteSize) {
fail(new Error(`download exceeds transfer limit ${options.maxByteSize}`));
return;
}
const created = createdItems.get(candidate.item.id);
if (created?.state === "interrupted") {
fail(new Error(created.error ?? "download interrupted"));
} else if (created?.state === "complete") {
complete(created);
}
};
const reconcile = () => {
if (settled || capturedId !== undefined || !intent) return;
const matches = matchingCandidates();
if (matches.length > 1) {
for (const candidate of matches) suggestDefault(candidate);
fail(new Error("download attribution is ambiguous"));
return;
}
if (matches.length === 1 && !uniquenessTimer) {
uniquenessTimer = setTimeout(claimUnique, UNIQUE_SETTLE_MS);
}
};
const determiningListener: DeterminingFilenameListener = (item, suggest) => {
const candidate: DownloadCandidate = {
item,
suggest,
suggested: false,
graceTimer: setTimeout(() => {
suggestDefault(candidate);
candidates.delete(item.id);
if (intent && matchesIntent(item, intent) && capturedId === undefined) {
fail(new Error("download correlation grace elapsed before unique attribution"));
}
}, CORRELATION_GRACE_MS),
};
candidates.set(item.id, candidate);
reconcile();
return true;
};
const createdListener = (item: chrome.downloads.DownloadItem) => {
createdItems.set(item.id, item);
if (capturedId !== item.id) return;
capturedItem = item;
if (item.state === "interrupted") {
fail(new Error(item.error ?? "download interrupted"));
} else if (item.state === "complete") {
complete(item);
}
};
const changedListener = async (delta: chrome.downloads.DownloadDelta) => {
if (capturedId === undefined || delta.id !== capturedId || settled) return;
if (delta.state?.current === "interrupted" || delta.error?.current) {
fail(new Error(delta.error?.current ?? "download interrupted"));
return;
}
if (delta.state?.current === "complete") {
try {
const [item] = await options.downloads.search({ id: delta.id });
if (item) complete(item);
else fail(new Error("completed download disappeared"));
} catch (err) {
fail(err instanceof Error ? err : new Error(String(err)));
}
}
};
const onAbort = () => fail(new DOMException("aborted", "AbortError"));
const cdpSubscription = options.cdp.onEvent?.((source, method, raw) => {
if (method !== "Page.downloadWillBegin" || !sameTarget(source, options.target)) return;
const event = raw as { url?: unknown; suggestedFilename?: unknown; frameId?: unknown };
if (typeof event.url !== "string" || typeof event.suggestedFilename !== "string") return;
if (options.expectedFrameId && event.frameId !== options.expectedFrameId) {
fail(new Error("download originated from a different frame"));
return;
}
if (intent) {
fail(new Error("download trigger produced more than one browser download intent"));
return;
}
intent = {
url: event.url,
suggestedFilename: event.suggestedFilename,
...(typeof event.frameId === "string" ? { frameId: event.frameId } : {}),
};
reconcile();
});
if (!cdpSubscription) {
return captureError("CDP download intent subscription unavailable", "none", "arm");
}
options.downloads.onDeterminingFilename.addListener(determiningListener);
options.downloads.onCreated.addListener(createdListener);
options.downloads.onChanged.addListener(changedListener);
options.signal?.addEventListener("abort", onAbort, { once: true });
operationTimer = setTimeout(
() => fail(new Error("download did not complete before timeout")),
options.timeoutMs,
);
sizePoll = setInterval(() => {
if (capturedId === undefined || settled || options.maxByteSize === undefined) return;
void options.downloads
.search({ id: capturedId })
.then(([item]) => {
if (!item || settled) return;
capturedItem = item;
if (item.bytesReceived > (options.maxByteSize as number)) {
fail(new Error(`download exceeds transfer limit ${options.maxByteSize}`));
}
})
.catch((err) => fail(err instanceof Error ? err : new Error(String(err))));
}, SIZE_POLL_MS);
try {
const triggered = await options.trigger();
if (isRpcError(triggered)) {
void completion.catch(() => undefined);
const effect: TransferEffectState =
capturedId !== undefined ? "committed" : intent ? "unknown" : "none";
failureResult = {
...triggered,
data: { ...triggered.data, effect_state: effect, phase: "trigger" },
};
return failureResult;
}
click = triggered;
const item = await completion;
succeeded = true;
return { click, item };
} catch (err) {
const effect: TransferEffectState =
capturedId !== undefined ? "committed" : click ? "unknown" : "none";
failureResult = captureError(
err instanceof Error ? err.message : String(err),
effect,
capturedId !== undefined ? "download" : "attribution",
);
return failureResult;
} finally {
settled = true;
if (operationTimer) clearTimeout(operationTimer);
if (uniquenessTimer) clearTimeout(uniquenessTimer);
if (sizePoll) clearInterval(sizePoll);
options.signal?.removeEventListener("abort", onAbort);
options.downloads.onDeterminingFilename.removeListener(determiningListener);
options.downloads.onCreated.removeListener(createdListener);
options.downloads.onChanged.removeListener(changedListener);
cdpSubscription.dispose();
for (const candidate of candidates.values()) {
clearTimeout(candidate.graceTimer);
if (candidate.item.id !== capturedId) suggestDefault(candidate);
}
if (!succeeded && capturedId !== undefined) {
const item = capturedItem ?? createdItems.get(capturedId);
try {
if (item?.state === "complete") {
await options.downloads.removeFile(capturedId);
} else {
await options.downloads.cancel(capturedId);
}
} catch {
if (failureResult?.data) failureResult.data.cleanup_state = "failed";
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
// Download orchestration: validate session/tab ownership, then delegate one
// browser-global chrome.downloads transaction to download-capture.ts.
import type { SessionManager } from "@/session-manager/manager";
import type { DownloadParams, DownloadResult, RpcError } from "@/transport/types";
import { captureBrowserDownload, chromeDownloadsApi, type DownloadsApi } from "./download-capture";
import { clickResolvedTarget, type InteractionDeps, resolveActionTarget } from "./interaction";
import { enforceAgentWindow, isRpcError, lookupSession, resolveTargetTab } from "./shared";
let downloadActive = false;
export type { DownloadsApi } from "./download-capture";
export interface DownloadDeps extends InteractionDeps {
downloads?: DownloadsApi;
}
export async function handleDownload(
manager: SessionManager,
params: DownloadParams,
deps: DownloadDeps,
): Promise<DownloadResult | RpcError> {
if (downloadActive) return { code: "invalid_params", message: "another bsk download is active" };
downloadActive = true;
try {
const ctx = lookupSession(manager, params, "download");
if (isRpcError(ctx)) return ctx;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "download");
if (denied) return denied;
if (!params.browser_relative_dir) {
return { code: "invalid_params", message: "download requires a daemon capability directory" };
}
const address = await resolveActionTarget(deps.cdp, ctx, target, params, "download");
if (isRpcError(address)) return address;
const capture = await captureBrowserDownload({
cdp: deps.cdp,
target: address.cdpTarget,
downloads: deps.downloads ?? chromeDownloadsApi,
browserRelativeDir: params.browser_relative_dir,
maxByteSize: params.max_byte_size,
timeoutMs: params.timeout_ms ?? 120_000,
signal: deps.signal,
expectedFrameId: address.frameId,
trigger: () => clickResolvedTarget(ctx, address, {}, deps),
});
if (isRpcError(capture)) return capture;
const { click, item } = capture;
return {
tab_id: target.tabId,
used_ref: click.used_ref,
used_selector: click.used_selector,
suggested_filename: item.filename.split(/[\\/]/).pop() ?? "download",
byte_size: item.fileSize >= 0 ? item.fileSize : item.totalBytes,
mime: item.mime || undefined,
danger: item.danger,
browser_path: item.filename,
};
} finally {
downloadActive = false;
}
}
+41 -96
View File
@@ -1,9 +1,15 @@
// Shared CDP helpers for scrolling nodes into view and computing // Resolve and scroll nodes inside one CDP target. Cross-frame projection
// viewport-space bounding rectangles. Used by interaction tools (click // belongs to frame-geometry.ts.
// centre) and observation tools (element screenshot clip).
import type { RpcError } from "@/transport/types"; import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
import {
parseCdpQuad,
polygonBounds,
type Region,
rectPolygon,
type ViewportRect,
} from "./geometry";
import { type CdpRunner, isRpcError } from "./shared"; import { type CdpRunner, isRpcError } from "./shared";
const ELEMENT_NOT_VISIBLE_MESSAGE = const ELEMENT_NOT_VISIBLE_MESSAGE =
@@ -13,45 +19,6 @@ function elementNotVisibleError(): RpcError {
return rpcError("permission_denied", "element_not_visible", ELEMENT_NOT_VISIBLE_MESSAGE); return rpcError("permission_denied", "element_not_visible", ELEMENT_NOT_VISIBLE_MESSAGE);
} }
/** Viewport-space axis-aligned bounding box for CDP `Page.captureScreenshot` clip. */
export interface ViewportRect {
x: number;
y: number;
width: number;
height: number;
}
/**
* Compute the axis-aligned bounding box of an 8-double CDP content
* quad / box-model polygon. Exported for unit tests.
*/
export function quadBoundingRect(quad: number[]): ViewportRect | null {
if (quad.length !== 8) return null;
const xs = [quad[0], quad[2], quad[4], quad[6]];
const ys = [quad[1], quad[3], quad[5], quad[7]];
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const width = maxX - minX;
const height = maxY - minY;
if (width <= 0 || height <= 0) return null;
return { x: minX, y: minY, width, height };
}
function rectToQuad(rect: ViewportRect): number[] {
return [
rect.x,
rect.y,
rect.x + rect.width,
rect.y,
rect.x + rect.width,
rect.y + rect.height,
rect.x,
rect.y + rect.height,
];
}
/** /**
* Resolve `backendNodeId` → CDP `objectId` so we can invoke * Resolve `backendNodeId` → CDP `objectId` so we can invoke
* `Runtime.callFunctionOn` against the live JS object. * `Runtime.callFunctionOn` against the live JS object.
@@ -112,48 +79,24 @@ export async function scrollNodeIntoView(
} }
} }
/** function validCdpRegion(quads: number[][] | undefined): Region {
* Viewport-space bounding box for `backendNodeId`. Prefers return (quads ?? []).flatMap((raw) => {
* `DOM.getContentQuads`, then `DOM.getBoxModel`, then the union of const polygon = parseCdpQuad(raw);
* visible descendant client rects for zero-size overflow containers. return polygon && polygonBounds(polygon) ? [polygon] : [];
*/ });
export async function nodeBoundingRect( }
async function resolveVisibleContentRegion(
cdp: CdpRunner, cdp: CdpRunner,
tabId: number, tabId: number,
backendNodeId: number, backendNodeId: number,
): Promise<ViewportRect | RpcError> { ): Promise<Region | RpcError> {
const polygon = await visibleContentPolygon(cdp, tabId, backendNodeId);
if (isRpcError(polygon)) return polygon;
// visibleContentPolygon only returns quads with positive area.
return quadBoundingRect(polygon)!;
}
/**
* Compute the centroid of a CDP content quad. Quads are reported as
* 8 doubles in clockwise order: (x1, y1, x2, y2, x3, y3, x4, y4).
*/
export function quadCentre(quad: number[]): { x: number; y: number } {
const x = (quad[0] + quad[4]) / 2;
const y = (quad[1] + quad[5]) / 2;
return { x, y };
}
/** Centre of a `DOM.getBoxModel` `content` polygon (also 8 doubles). */
export function boxCentre(box: number[]): { x: number; y: number } {
return quadCentre(box);
}
async function visibleContentPolygon(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<number[] | RpcError> {
try { try {
const quads = await cdp.send<{ quads?: number[][] }>(tabId, "DOM.getContentQuads", { const quads = await cdp.send<{ quads?: number[][] }>(tabId, "DOM.getContentQuads", {
backendNodeId, backendNodeId,
}); });
const quad = quads.quads?.find((q) => q.length === 8 && quadBoundingRect(q) !== null); const visible = validCdpRegion(quads.quads);
if (quad) return quad; if (visible.length > 0) return visible;
} catch (err) { } catch (err) {
console.debug("[bsk element-geometry] getContentQuads failed", err); console.debug("[bsk element-geometry] getContentQuads failed", err);
} }
@@ -162,15 +105,34 @@ async function visibleContentPolygon(
backendNodeId, backendNodeId,
}); });
const content = box.model?.content; const content = box.model?.content;
if (content && content.length === 8 && quadBoundingRect(content)) return content; const fallback = validCdpRegion(content ? [content] : undefined);
if (fallback.length > 0) return fallback;
} catch (err) { } catch (err) {
console.debug("[bsk element-geometry] getBoxModel failed", err); console.debug("[bsk element-geometry] getBoxModel failed", err);
} }
const descendantRect = await descendantBoundingRect(cdp, tabId, backendNodeId); const descendantRect = await descendantBoundingRect(cdp, tabId, backendNodeId);
if (descendantRect) return rectToQuad(descendantRect); if (descendantRect) {
return [
rectPolygon({
x: descendantRect.x,
y: descendantRect.y,
w: descendantRect.width,
h: descendantRect.height,
}),
];
}
return elementNotVisibleError(); return elementNotVisibleError();
} }
/** Resolve every visible content region in the node's target-local viewport. */
export async function nodeContentRegion(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<Region | RpcError> {
return resolveVisibleContentRegion(cdp, tabId, backendNodeId);
}
async function descendantBoundingRect( async function descendantBoundingRect(
cdp: CdpRunner, cdp: CdpRunner,
tabId: number, tabId: number,
@@ -241,20 +203,3 @@ function parseViewportRect(value: unknown): ViewportRect | null {
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null; if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
return { x, y, width, height }; return { x, y, width, height };
} }
/**
* Get the click point for `backendNodeId`. Prefers
* `DOM.getContentQuads` (richer for rotated / transformed elements);
* falls back to `DOM.getBoxModel`, then visible descendant bounds.
* Returns `permission_denied` with "element not visible" when every
* geometry path comes back empty / fails.
*/
export async function nodeCentre(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<{ x: number; y: number } | RpcError> {
const polygon = await visibleContentPolygon(cdp, tabId, backendNodeId);
if (isRpcError(polygon)) return polygon;
return quadCentre(polygon);
}
+17
View File
@@ -44,6 +44,7 @@ export interface EmulateCdpRunner {
export interface EmulateDeps { export interface EmulateDeps {
cdp: EmulateCdpRunner; cdp: EmulateCdpRunner;
tabsApi: ChromeTabsApi; tabsApi: ChromeTabsApi;
signal?: AbortSignal;
} }
function defaultEmulateDeps(): EmulateDeps { function defaultEmulateDeps(): EmulateDeps {
@@ -210,11 +211,13 @@ export async function handleEmulate(
params: EmulateParams, params: EmulateParams,
deps: EmulateDeps = defaultEmulateDeps(), deps: EmulateDeps = defaultEmulateDeps(),
): Promise<EmulateResult | RpcError> { ): Promise<EmulateResult | RpcError> {
if (deps.signal?.aborted) return { code: "cancelled", message: "emulate aborted" };
const ctxOrErr = lookupSession(manager, params, "emulate"); const ctxOrErr = lookupSession(manager, params, "emulate");
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr; const ctx = ctxOrErr;
const target = await resolveTargetTab(manager, ctx, params?.tab_id, deps.tabsApi); const target = await resolveTargetTab(manager, ctx, params?.tab_id, deps.tabsApi);
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
if (deps.signal?.aborted) return { code: "cancelled", message: "emulate aborted" };
const denied = enforceAgentWindow(ctx, target, "emulate"); const denied = enforceAgentWindow(ctx, target, "emulate");
if (denied) return denied; if (denied) return denied;
@@ -223,9 +226,16 @@ export async function handleEmulate(
return invalidParams("emulate off cannot be combined with overrides"); return invalidParams("emulate off cannot be combined with overrides");
} }
try { try {
if (deps.signal?.aborted) return { code: "cancelled", message: "emulate aborted" };
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await deps.cdp.clearDeviceMetricsOverride(target.tabId); await deps.cdp.clearDeviceMetricsOverride(target.tabId);
if (deps.signal?.aborted) {
return { code: "cancelled", message: "emulate aborted after clearing device metrics" };
}
await deps.cdp.setTouchEmulationEnabled(target.tabId, false); await deps.cdp.setTouchEmulationEnabled(target.tabId, false);
if (deps.signal?.aborted) {
return { code: "cancelled", message: "emulate aborted after clearing touch emulation" };
}
// An empty userAgent string clears the override (CDP convention). // An empty userAgent string clears the override (CDP convention).
await deps.cdp.setUserAgentOverride(target.tabId, { userAgent: "" }); await deps.cdp.setUserAgentOverride(target.tabId, { userAgent: "" });
} catch (err) { } catch (err) {
@@ -246,6 +256,7 @@ export async function handleEmulate(
// values; the merged state is what gets applied (and echoed back). // values; the merged state is what gets applied (and echoed back).
const merged = mergeEmulateOverrides(tabEmulationStates.get(target.tabId), overrides); const merged = mergeEmulateOverrides(tabEmulationStates.get(target.tabId), overrides);
try { try {
if (deps.signal?.aborted) return { code: "cancelled", message: "emulate aborted" };
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
if (merged.width !== undefined && merged.height !== undefined) { if (merged.width !== undefined && merged.height !== undefined) {
await deps.cdp.setDeviceMetricsOverride(target.tabId, { await deps.cdp.setDeviceMetricsOverride(target.tabId, {
@@ -254,6 +265,9 @@ export async function handleEmulate(
deviceScaleFactor: merged.device_scale_factor ?? 0, deviceScaleFactor: merged.device_scale_factor ?? 0,
mobile: merged.mobile ?? false, mobile: merged.mobile ?? false,
}); });
if (deps.signal?.aborted) {
return { code: "cancelled", message: "emulate aborted after applying device metrics" };
}
} }
if (merged.user_agent !== undefined) { if (merged.user_agent !== undefined) {
await deps.cdp.setUserAgentOverride(target.tabId, { await deps.cdp.setUserAgentOverride(target.tabId, {
@@ -263,6 +277,9 @@ export async function handleEmulate(
? { userAgentMetadata: toCdpUserAgentMetadata(merged.user_agent_metadata) } ? { userAgentMetadata: toCdpUserAgentMetadata(merged.user_agent_metadata) }
: {}), : {}),
}); });
if (deps.signal?.aborted) {
return { code: "cancelled", message: "emulate aborted after applying user agent" };
}
} }
if (merged.touch !== undefined || merged.max_touch_points !== undefined) { if (merged.touch !== undefined || merged.max_touch_points !== undefined) {
await deps.cdp.setTouchEmulationEnabled( await deps.cdp.setTouchEmulationEnabled(
+27 -1
View File
@@ -2,10 +2,23 @@
// rendering. Extension handlers attach reasons here; human-facing copy // rendering. Extension handlers attach reasons here; human-facing copy
// lives in bsk-cli `render_error.rs`. // lives in bsk-cli `render_error.rs`.
import type { ErrorCode, RpcError, RpcErrorData, RpcErrorReason } from "@/transport/types"; import type {
ErrorCode,
RpcError,
RpcErrorData,
RpcErrorReason,
TransferCleanupState,
TransferEffectState,
} from "@/transport/types";
export type { RpcErrorData, RpcErrorReason }; export type { RpcErrorData, RpcErrorReason };
export interface TransferErrorOptions {
effectState: TransferEffectState;
phase: string;
cleanupState?: TransferCleanupState;
}
export function rpcError( export function rpcError(
code: ErrorCode, code: ErrorCode,
reason: RpcErrorReason, reason: RpcErrorReason,
@@ -15,3 +28,16 @@ export function rpcError(
const data: RpcErrorData = { reason, ...extra }; const data: RpcErrorData = { reason, ...extra };
return { code, message, data }; return { code, message, data };
} }
export function transferError(
code: ErrorCode,
reason: RpcErrorReason,
message: string,
options: TransferErrorOptions,
): RpcError {
return rpcError(code, reason, message, {
effect_state: options.effectState,
phase: options.phase,
...(options.cleanupState ? { cleanup_state: options.cleanupState } : {}),
});
}
@@ -0,0 +1,484 @@
// One upload transaction with an explicit browser-side commit boundary.
// Chrome interception prevents a native chooser from escaping automation;
// chooser events and a frame-scoped DOM probe are independent input-location
// signals, so event delivery is not required for standard file inputs.
import type { ClickResult, RpcError, TransferEffectState } from "@/transport/types";
import { transferError } from "./errors";
import type { ResolvedActionTarget } from "./interaction";
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
const CLEANUP_TIMEOUT_MS = 1_000;
const ACTIVATION_GRACE_MS = 1_000;
const PROBE_INTERVAL_MS = 20;
type UploadPhase =
| "arm_interception"
| "arm_input_probe"
| "trigger"
| "resolve_input"
| "set_files"
| "cleanup";
interface RuntimeReply {
result?: { value?: unknown; objectId?: string };
exceptionDetails?: { text?: string; exception?: { description?: string } };
}
interface ChooserEvent {
frameId?: string;
backendNodeId?: number;
mode?: "selectSingle" | "selectMultiple";
}
export interface FileInputTransactionOptions {
cdp: CdpRunner;
actionTarget: ResolvedActionTarget;
files: string[];
timeoutMs: number;
signal?: AbortSignal;
trigger(): Promise<ClickResult | RpcError>;
}
export interface FileInputTransactionResult {
click: ClickResult;
multiple: boolean;
}
class BoundedWaitError extends Error {
constructor(
readonly kind: "timeout" | "aborted",
message: string,
) {
super(message);
}
}
function remainingMs(deadline: number): number {
return Math.max(0, deadline - Date.now());
}
async function waitBounded<T>(
promise: Promise<T>,
deadline: number,
signal: AbortSignal | undefined,
timeoutMessage: string,
): Promise<T> {
const remaining = remainingMs(deadline);
if (signal?.aborted) throw new BoundedWaitError("aborted", "upload transaction aborted");
if (remaining === 0) throw new BoundedWaitError("timeout", timeoutMessage);
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const boundary = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new BoundedWaitError("timeout", timeoutMessage)), remaining);
if (signal) {
onAbort = () => reject(new BoundedWaitError("aborted", "upload transaction aborted"));
signal.addEventListener("abort", onAbort, { once: true });
}
});
try {
return await Promise.race([promise, boundary]);
} finally {
if (timer) clearTimeout(timer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
}
}
function runtimeError(reply: RuntimeReply, fallback: string): Error | null {
if (!reply.exceptionDetails) return null;
return new Error(
reply.exceptionDetails.exception?.description ?? reply.exceptionDetails.text ?? fallback,
);
}
function transferFailure(
code: RpcError["code"],
reason: "file_input_probe_failed" | "file_input_not_activated" | "set_file_input_failed",
message: string,
effectState: TransferEffectState,
phase: UploadPhase,
): RpcError {
return transferError(code, reason, message, { effectState, phase });
}
function enrichFailure(
error: RpcError,
effectState: TransferEffectState,
phase: UploadPhase,
): RpcError {
return {
...error,
data: { ...error.data, effect_state: effectState, phase },
};
}
function sameTarget(
source: { tabId?: number; sessionId?: string },
target: { tabId: number; sessionId?: string },
): boolean {
return source.tabId === target.tabId && source.sessionId === target.sessionId;
}
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
await waitBounded(
new Promise((resolve) => setTimeout(resolve, ms)),
Date.now() + ms + 1,
signal,
"upload activation probe timed out",
);
}
async function callOnTrigger<T = RuntimeReply>(
options: FileInputTransactionOptions,
objectId: string,
functionDeclaration: string,
args: unknown[],
returnByValue: boolean,
): Promise<T> {
return sendToCdpTarget<T>(options.cdp, options.actionTarget.cdpTarget, "Runtime.callFunctionOn", {
objectId,
functionDeclaration,
arguments: args.map((value) => ({ value })),
returnByValue,
awaitPromise: false,
});
}
export async function uploadThroughActivatedFileInput(
options: FileInputTransactionOptions,
): Promise<FileInputTransactionResult | RpcError> {
const deadline = Date.now() + options.timeoutMs;
const target = options.actionTarget.cdpTarget;
const objectGroup = `bsk-upload-${crypto.randomUUID()}`;
const stateKey = `__bskUpload_${crypto.randomUUID().replaceAll("-", "")}`;
const chooserEvents: ChooserEvent[] = [];
let interceptionArmed = false;
let probeArmed = false;
let triggerObjectId: string | undefined;
let outcome: FileInputTransactionResult | RpcError = transferFailure(
"protocol_error",
"file_input_probe_failed",
"upload transaction ended without an outcome",
"none",
"cleanup",
);
const chooserSubscription = options.cdp.onEvent?.((source, method, raw) => {
if (method !== "Page.fileChooserOpened" || !sameTarget(source, target)) return;
const event = raw as ChooserEvent;
chooserEvents.push(event);
});
try {
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
enabled: true,
}),
deadline,
options.signal,
"arming native file chooser interception timed out",
);
interceptionArmed = true;
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
"file_input_probe_failed",
err instanceof Error ? err.message : String(err),
"none",
"arm_interception",
);
return outcome;
}
try {
const resolved = await waitBounded(
sendToCdpTarget<{ object?: { objectId?: string } }>(
options.cdp,
target,
"DOM.resolveNode",
{
backendNodeId: options.actionTarget.backendNodeId,
objectGroup,
},
),
deadline,
options.signal,
"resolving upload trigger timed out",
);
triggerObjectId = resolved.object?.objectId;
if (!triggerObjectId) throw new Error("DOM.resolveNode returned no trigger objectId");
const armed = await waitBounded(
callOnTrigger<RuntimeReply>(
options,
triggerObjectId,
`function(key) {
const doc = this.ownerDocument;
const owner = doc.defaultView;
if (!owner) return false;
const state = { inputs: [], listener: null };
Object.defineProperty(owner, key, { value: state, configurable: true });
state.listener = event => {
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
const candidate = path[0] || event.target;
if (candidate && candidate.nodeType === 1 &&
candidate.localName === "input" && candidate.type === "file") {
if (!state.inputs.includes(candidate)) state.inputs.push(candidate);
event.preventDefault();
}
};
doc.addEventListener("click", state.listener, true);
return true;
}`,
[stateKey],
true,
),
deadline,
options.signal,
"arming frame-scoped file input probe timed out",
);
const armError = runtimeError(armed, "failed to arm file input probe");
if (armError || armed.result?.value !== true) {
throw armError ?? new Error("file input probe did not arm");
}
probeArmed = true;
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
"file_input_probe_failed",
err instanceof Error ? err.message : String(err),
"none",
"arm_input_probe",
);
return outcome;
}
let click: ClickResult | RpcError;
try {
click = await waitBounded(
options.trigger(),
deadline,
options.signal,
"upload trigger timed out",
);
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
"file_input_probe_failed",
err instanceof Error ? err.message : String(err),
"none",
"trigger",
);
return outcome;
}
if (isRpcError(click)) {
outcome = enrichFailure(click, "none", "trigger");
return outcome;
}
try {
const activationDeadline = Math.min(deadline, Date.now() + ACTIVATION_GRACE_MS);
let summary: { count: number; multiple: boolean } = { count: 0, multiple: false };
while (Date.now() < activationDeadline) {
if (chooserEvents.length > 0) break;
const reply = await callOnTrigger<RuntimeReply>(
options,
triggerObjectId,
`function(key) {
const state = this.ownerDocument.defaultView?.[key];
return state
? { count: state.inputs.length, multiple: state.inputs[0]?.multiple === true }
: { count: 0, multiple: false };
}`,
[stateKey],
true,
);
const summaryError = runtimeError(reply, "failed to inspect activated file input");
if (summaryError) throw summaryError;
const value = reply.result?.value as { count?: unknown; multiple?: unknown } | undefined;
summary = {
count: typeof value?.count === "number" ? value.count : 0,
multiple: value?.multiple === true,
};
if (summary.count > 0) break;
await delay(Math.min(PROBE_INTERVAL_MS, remainingMs(activationDeadline)), options.signal);
}
if (chooserEvents.length > 1) {
outcome = transferFailure(
"unsupported",
"file_input_not_activated",
"upload trigger activated more than one file chooser",
"none",
"resolve_input",
);
return outcome;
}
const chooser = chooserEvents[0];
let backendNodeId: number | undefined;
let multiple = summary.multiple;
if (chooser) {
if (options.actionTarget.frameId && chooser.frameId !== options.actionTarget.frameId) {
outcome = transferFailure(
"unsupported",
"file_input_not_activated",
"upload trigger activated a file chooser in a different frame",
"none",
"resolve_input",
);
return outcome;
}
if (typeof chooser.backendNodeId !== "number") {
outcome = transferFailure(
"unsupported",
"file_input_not_activated",
"upload trigger invoked a non-input file picker",
"none",
"resolve_input",
);
return outcome;
}
backendNodeId = chooser.backendNodeId;
multiple = chooser.mode === "selectMultiple";
} else {
if (summary.count !== 1) {
outcome = transferFailure(
"unsupported",
"file_input_not_activated",
summary.count === 0
? "upload trigger did not activate an input[type=file]"
: "upload trigger activated more than one input[type=file]",
"none",
"resolve_input",
);
return outcome;
}
const input = await callOnTrigger<RuntimeReply>(
options,
triggerObjectId,
`function(key) { return this.ownerDocument.defaultView?.[key]?.inputs[0]; }`,
[stateKey],
false,
);
const inputError = runtimeError(input, "failed to resolve activated file input object");
if (inputError) throw inputError;
if (!input.result?.objectId) throw new Error("activated file input returned no objectId");
const described = await sendToCdpTarget<{ node?: { backendNodeId?: number } }>(
options.cdp,
target,
"DOM.describeNode",
{ objectId: input.result.objectId },
);
backendNodeId = described.node?.backendNodeId;
if (typeof backendNodeId !== "number") {
throw new Error("DOM.describeNode returned no file input backendNodeId");
}
}
if (!multiple && options.files.length !== 1) {
outcome = enrichFailure(
{ code: "invalid_params", message: "file input accepts exactly one file" },
"none",
"resolve_input",
);
return outcome;
}
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "DOM.setFileInputFiles", {
files: options.files,
backendNodeId,
}),
deadline,
options.signal,
"setting file input files timed out",
);
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
"set_file_input_failed",
err instanceof Error ? err.message : String(err),
"unknown",
"set_files",
);
return outcome;
}
outcome = { click, multiple };
return outcome;
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
"file_input_probe_failed",
err instanceof Error ? err.message : String(err),
"none",
"resolve_input",
);
return outcome;
}
} finally {
chooserSubscription?.dispose();
let cleanupFailed = false;
if (probeArmed && triggerObjectId) {
try {
await waitBounded(
callOnTrigger(
options,
triggerObjectId,
`function(key) {
const owner = this.ownerDocument.defaultView;
const state = owner?.[key];
if (state?.listener) this.ownerDocument.removeEventListener("click", state.listener, true);
if (owner) delete owner[key];
}`,
[stateKey],
true,
),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"cleaning file input probe timed out",
);
} catch {
cleanupFailed = true;
}
}
if (interceptionArmed) {
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
enabled: false,
cancel: true,
}),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"disabling file chooser interception timed out",
);
} catch {
cleanupFailed = true;
}
}
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "Runtime.releaseObjectGroup", { objectGroup }),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"releasing upload object group timed out",
);
} catch {
cleanupFailed = true;
}
const effect = isRpcError(outcome)
? (outcome.data?.effect_state as TransferEffectState | undefined)
: "committed";
if (effect === "unknown" || cleanupFailed) {
await options.cdp.detach?.(target.tabId);
}
if (cleanupFailed && isRpcError(outcome)) {
outcome.data = { ...outcome.data, cleanup_state: "failed" };
}
}
}
+290
View File
@@ -0,0 +1,290 @@
import { type CdpFrame, type CdpFrameGraph, type CdpTarget } from "@/browser-driver/frame-graph";
import type { RpcError } from "@/transport/types";
import { nodeContentRegion, scrollNodeIntoView } from "./element-geometry";
import {
clipPolygon,
type GeometryProjection,
type Point,
type Polygon,
type ProjectiveEdge,
parseCdpQuad,
polygonArea,
polygonCentroid,
projectRegionToViewport,
type Quad,
type Region,
rectPolygon,
regionBounds,
type Size,
type ViewportRect,
} from "./geometry";
import { type CdpRunner, cdpRunnerForTarget, isRpcError, sendToCdpTarget } from "./shared";
export interface NodeAddress {
target: CdpTarget;
backendNodeId: number;
frameId?: string;
}
export interface ResolvedNodeGeometry {
topVisibleRegions: Region;
topBounds: ViewportRect;
actionPoint: Point;
}
function geometryError(message: string): RpcError {
return { code: "cdp_failed", message };
}
function frameMap(graph: CdpFrameGraph): Map<string, CdpFrame> {
return new Map(graph.frames.map((frame) => [frame.frameId, frame]));
}
function targetRootFrame(byId: Map<string, CdpFrame>, frame: CdpFrame): CdpFrame | null {
const seen = new Set<string>();
let current = frame;
while (current.parentFrameId) {
if (seen.has(current.frameId)) return null;
seen.add(current.frameId);
const parent = byId.get(current.parentFrameId);
if (!parent || parent.target.sessionId !== current.target.sessionId) break;
current = parent;
}
return current;
}
function frameAncestry(graph: CdpFrameGraph, frameId: string): CdpFrame[] | null {
const byId = frameMap(graph);
const path: CdpFrame[] = [];
const seen = new Set<string>();
let current = byId.get(frameId);
if (!current) return null;
while (current.parentFrameId) {
if (seen.has(current.frameId)) return null;
seen.add(current.frameId);
path.push(current);
const parent = byId.get(current.parentFrameId);
if (!parent) return null;
current = parent;
}
return path;
}
async function targetViewport(cdp: CdpRunner, target: CdpTarget): Promise<Size | null> {
const metrics = await sendToCdpTarget<{
cssLayoutViewport?: { clientWidth?: number; clientHeight?: number };
layoutViewport?: { clientWidth?: number; clientHeight?: number };
}>(cdp, target, "Page.getLayoutMetrics", {});
const viewport = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
const width = viewport.clientWidth ?? 0;
const height = viewport.clientHeight ?? 0;
return width > 0 && height > 0 ? { width, height } : null;
}
async function ownerContentQuad(
cdp: CdpRunner,
parent: CdpFrame,
ownerBackendNodeId: number,
): Promise<Quad | null> {
const result = await sendToCdpTarget<{ model?: { content?: number[] } }>(
cdp,
parent.target,
"DOM.getBoxModel",
{ backendNodeId: ownerBackendNodeId },
);
return parseCdpQuad(result.model?.content);
}
async function sameTargetFrameClips(
cdp: CdpRunner,
byId: Map<string, CdpFrame>,
frame: CdpFrame,
targetRoot: CdpFrame,
): Promise<Polygon[] | null> {
const clips: Polygon[] = [];
let current = frame;
const seen = new Set<string>();
while (current.frameId !== targetRoot.frameId) {
if (seen.has(current.frameId) || !current.parentFrameId) return null;
seen.add(current.frameId);
const parent = byId.get(current.parentFrameId);
if (!parent || current.ownerBackendNodeId === undefined) return null;
const clip = await ownerContentQuad(cdp, parent, current.ownerBackendNodeId);
if (!clip) return null;
clips.push(clip);
current = parent;
}
return clips;
}
export async function resolveFrameProjection(
cdp: CdpRunner,
graph: CdpFrameGraph,
frameId: string,
): Promise<GeometryProjection | null> {
const byId = frameMap(graph);
const frame = byId.get(frameId);
if (!frame) return null;
let targetRoot = targetRootFrame(byId, frame);
if (!targetRoot) return null;
const sourceViewport = await targetViewport(cdp, targetRoot.target);
if (!sourceViewport) return null;
const sourceClips = await sameTargetFrameClips(cdp, byId, frame, targetRoot);
if (!sourceClips) return null;
const edges: ProjectiveEdge[] = [];
while (targetRoot.parentFrameId) {
const parent = byId.get(targetRoot.parentFrameId);
if (!parent || targetRoot.ownerBackendNodeId === undefined) return null;
const destinationQuad = await ownerContentQuad(cdp, parent, targetRoot.ownerBackendNodeId);
if (!destinationQuad) return null;
const source =
edges.length === 0 ? sourceViewport : await targetViewport(cdp, targetRoot.target);
if (!source) return null;
const parentTargetRoot = targetRootFrame(byId, parent);
if (!parentTargetRoot) return null;
const destinationClips = await sameTargetFrameClips(cdp, byId, parent, parentTargetRoot);
if (!destinationClips) return null;
edges.push({ sourceViewport: source, destinationQuad, destinationClips });
targetRoot = parentTargetRoot;
}
const topViewport =
edges.length === 0 ? sourceViewport : await targetViewport(cdp, targetRoot.target);
return topViewport ? { sourceClips, edges, topViewport } : null;
}
async function loadFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> {
if (!cdp.getFrameGraph) return null;
try {
return await cdp.getFrameGraph(tabId);
} catch (error) {
console.debug("[bsk frame-geometry] frame graph resolution failed", error);
return null;
}
}
async function scrollFrameOwners(
cdp: CdpRunner,
tabId: number,
graph: CdpFrameGraph,
frameId: string,
): Promise<RpcError | null> {
const byId = frameMap(graph);
const ancestry = frameAncestry(graph, frameId);
if (!ancestry) return geometryError(`could not resolve frame ancestry for ${frameId}`);
for (const child of [...ancestry].reverse()) {
const parent = child.parentFrameId ? byId.get(child.parentFrameId) : undefined;
if (!parent || child.ownerBackendNodeId === undefined) {
return geometryError(`could not resolve frame owner for ${child.frameId}`);
}
const error = await scrollNodeIntoView(
cdpRunnerForTarget(cdp, parent.target),
tabId,
child.ownerBackendNodeId,
);
if (error) return error;
}
return null;
}
async function scrollElementWithFrameGraph(
cdp: CdpRunner,
tabId: number,
target: CdpTarget,
backendNodeId: number,
frameId: string | undefined,
graph: CdpFrameGraph | null,
): Promise<RpcError | null> {
if (frameId) {
if (!graph) return geometryError(`could not resolve frame graph for ${frameId}`);
const error = await scrollFrameOwners(cdp, tabId, graph, frameId);
if (error) return error;
} else if (target.sessionId) {
return geometryError("an OOPIF node address requires frameId");
}
return scrollNodeIntoView(cdpRunnerForTarget(cdp, target), tabId, backendNodeId);
}
export async function scrollElementAndFramesIntoView(
cdp: CdpRunner,
tabId: number,
target: CdpTarget,
backendNodeId: number,
frameId?: string,
): Promise<RpcError | null> {
const graph = frameId ? await loadFrameGraph(cdp, tabId) : null;
return scrollElementWithFrameGraph(cdp, tabId, target, backendNodeId, frameId, graph);
}
function largestRegion(regions: Region): Polygon | null {
let largest: { polygon: Polygon; area: number } | null = null;
for (const polygon of regions) {
const area = polygonArea(polygon);
if (area <= 0) continue;
if (!largest || area > largest.area) largest = { polygon, area };
}
return largest?.polygon ?? null;
}
export async function resolveNodeGeometry(
cdp: CdpRunner,
tabId: number,
address: NodeAddress,
options: { scrollIntoView?: boolean } = {},
): Promise<ResolvedNodeGeometry | RpcError> {
try {
if (address.target.sessionId && !address.frameId) {
return geometryError("an OOPIF node address requires frameId");
}
const graph = address.frameId ? await loadFrameGraph(cdp, tabId) : null;
if (address.frameId && !graph) {
return geometryError(`could not resolve frame graph for ${address.frameId}`);
}
if (options.scrollIntoView) {
const scrollError = await scrollElementWithFrameGraph(
cdp,
tabId,
address.target,
address.backendNodeId,
address.frameId,
graph,
);
if (scrollError) return scrollError;
}
const localRegion = await nodeContentRegion(
cdpRunnerForTarget(cdp, address.target),
tabId,
address.backendNodeId,
);
if (isRpcError(localRegion)) return localRegion;
let topVisibleRegions: Region;
if (address.frameId && graph) {
const projection = await resolveFrameProjection(cdp, graph, address.frameId);
if (!projection)
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
topVisibleRegions = projectRegionToViewport(localRegion, projection);
} else {
const viewport = await targetViewport(cdp, address.target);
if (!viewport) return geometryError("could not resolve top viewport geometry");
topVisibleRegions = localRegion
.map((polygon) =>
clipPolygon(polygon, rectPolygon({ x: 0, y: 0, w: viewport.width, h: viewport.height })),
)
.filter((polygon) => polygon.length >= 3);
}
const topBounds = regionBounds(topVisibleRegions);
const actionRegion = largestRegion(topVisibleRegions);
const actionPoint = actionRegion ? polygonCentroid(actionRegion) : null;
if (!topBounds || !actionPoint) {
return { code: "permission_denied", message: "element not visible" };
}
return { topVisibleRegions, topBounds, actionPoint };
} catch (error) {
return geometryError(error instanceof Error ? error.message : String(error));
}
}
+266
View File
@@ -0,0 +1,266 @@
export interface Point {
x: number;
y: number;
}
export type Polygon = Point[];
export type Region = Polygon[];
export type Quad = [Point, Point, Point, Point];
export interface Size {
width: number;
height: number;
}
export interface ViewportRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ProjectiveEdge {
sourceViewport: Size;
destinationQuad: Quad;
destinationClips?: Polygon[];
}
export interface GeometryProjection {
sourceClips: Polygon[];
edges: ProjectiveEdge[];
topViewport: Size;
}
export function parseCdpQuad(raw: number[] | undefined): Quad | null {
if (!raw || raw.length !== 8 || !raw.every(Number.isFinite)) return null;
return [
{ x: raw[0], y: raw[1] },
{ x: raw[2], y: raw[3] },
{ x: raw[4], y: raw[5] },
{ x: raw[6], y: raw[7] },
];
}
export function rectPolygon(rect: { x: number; y: number; w: number; h: number }): Quad {
return [
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.w, y: rect.y },
{ x: rect.x + rect.w, y: rect.y + rect.h },
{ x: rect.x, y: rect.y + rect.h },
];
}
export function viewportPolygon(viewport: Size): Quad {
return rectPolygon({ x: 0, y: 0, w: viewport.width, h: viewport.height });
}
/** Project a normalized unit-square point into an arbitrary content quad. */
export function projectUnitPoint(point: Point, quad: Quad): Point {
const [p0, p1, p2, p3] = quad;
const dx1 = p1.x - p2.x;
const dx2 = p3.x - p2.x;
const dx3 = p0.x - p1.x + p2.x - p3.x;
const dy1 = p1.y - p2.y;
const dy2 = p3.y - p2.y;
const dy3 = p0.y - p1.y + p2.y - p3.y;
let a: number;
let b: number;
let c: number;
let d: number;
let e: number;
let f: number;
let g = 0;
let h = 0;
if (Math.abs(dx3) < 1e-9 && Math.abs(dy3) < 1e-9) {
a = p1.x - p0.x;
b = p3.x - p0.x;
c = p0.x;
d = p1.y - p0.y;
e = p3.y - p0.y;
f = p0.y;
} else {
const denominator = dx1 * dy2 - dx2 * dy1;
if (Math.abs(denominator) < 1e-9) {
return {
x: p0.x + (p1.x - p0.x) * point.x + (p3.x - p0.x) * point.y,
y: p0.y + (p1.y - p0.y) * point.x + (p3.y - p0.y) * point.y,
};
}
g = (dx3 * dy2 - dx2 * dy3) / denominator;
h = (dx1 * dy3 - dx3 * dy1) / denominator;
a = p1.x - p0.x + g * p1.x;
b = p3.x - p0.x + h * p3.x;
c = p0.x;
d = p1.y - p0.y + g * p1.y;
e = p3.y - p0.y + h * p3.y;
f = p0.y;
}
const scale = g * point.x + h * point.y + 1;
return {
x: (a * point.x + b * point.y + c) / scale,
y: (d * point.x + e * point.y + f) / scale,
};
}
export function projectPolygon(polygon: Polygon, edge: ProjectiveEdge): Polygon {
const { width, height } = edge.sourceViewport;
if (width <= 0 || height <= 0) return [];
return polygon.map((point) =>
projectUnitPoint({ x: point.x / width, y: point.y / height }, edge.destinationQuad),
);
}
function polygonSignedArea(points: Polygon): number {
let area = 0;
for (let index = 0; index < points.length; index += 1) {
const current = points[index];
const next = points[(index + 1) % points.length];
area += current.x * next.y - next.x * current.y;
}
return area / 2;
}
function edgeIntersection(start: Point, end: Point, clipStart: Point, clipEnd: Point): Point {
const subjectX = end.x - start.x;
const subjectY = end.y - start.y;
const clipX = clipEnd.x - clipStart.x;
const clipY = clipEnd.y - clipStart.y;
const denominator = subjectX * clipY - subjectY * clipX;
if (Math.abs(denominator) < 1e-9) return end;
const offsetX = clipStart.x - start.x;
const offsetY = clipStart.y - start.y;
const scale = (offsetX * clipY - offsetY * clipX) / denominator;
return { x: start.x + scale * subjectX, y: start.y + scale * subjectY };
}
/** Clip a polygon to a convex polygon, preserving every resulting vertex. */
export function clipPolygon(subject: Polygon, clip: Polygon): Polygon {
if (subject.length < 3 || clip.length < 3) return [];
const orientation = polygonSignedArea(clip) >= 0 ? 1 : -1;
let output = [...subject];
for (let edge = 0; edge < clip.length && output.length > 0; edge += 1) {
const clipStart = clip[edge];
const clipEnd = clip[(edge + 1) % clip.length];
const input = output;
output = [];
const inside = (point: Point) =>
orientation *
((clipEnd.x - clipStart.x) * (point.y - clipStart.y) -
(clipEnd.y - clipStart.y) * (point.x - clipStart.x)) >=
-1e-9;
let start = input[input.length - 1];
for (const end of input) {
const startInside = inside(start);
const endInside = inside(end);
if (endInside) {
if (!startInside) output.push(edgeIntersection(start, end, clipStart, clipEnd));
output.push(end);
} else if (startInside) {
output.push(edgeIntersection(start, end, clipStart, clipEnd));
}
start = end;
}
}
return output;
}
export function polygonBounds(points: Polygon): ViewportRect | null {
if (points.length < 3) return null;
const xs = points.map((point) => point.x);
const ys = points.map((point) => point.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const right = Math.max(...xs);
const bottom = Math.max(...ys);
if (right <= x || bottom <= y) return null;
return { x, y, width: right - x, height: bottom - y };
}
export function regionBounds(region: Region): ViewportRect | null {
return polygonBounds(region.flat());
}
export function polygonArea(points: Polygon): number {
return Math.abs(polygonSignedArea(points));
}
export function polygonCentroid(points: Polygon): Point | null {
if (points.length < 3) return null;
let twiceArea = 0;
let x = 0;
let y = 0;
for (let index = 0; index < points.length; index += 1) {
const current = points[index];
const next = points[(index + 1) % points.length];
const cross = current.x * next.y - next.x * current.y;
twiceArea += cross;
x += (current.x + next.x) * cross;
y += (current.y + next.y) * cross;
}
if (Math.abs(twiceArea) < 1e-9) return null;
return { x: x / (3 * twiceArea), y: y / (3 * twiceArea) };
}
export function projectAndClipRegion(
region: Region,
edges: ProjectiveEdge[],
topViewport: Size,
): Region {
let projected = region;
for (const edge of edges) {
const sourceClip = viewportPolygon(edge.sourceViewport);
projected = projected
.map((polygon) => clipPolygon(polygon, sourceClip))
.filter((polygon) => polygon.length >= 3)
.map((polygon) =>
[edge.destinationQuad, ...(edge.destinationClips ?? [])].reduce(
(current, clip) => clipPolygon(current, clip),
projectPolygon(polygon, edge),
),
)
.filter((polygon) => polygon.length >= 3);
}
const topClip = viewportPolygon(topViewport);
return projected
.map((polygon) => clipPolygon(polygon, topClip))
.filter((polygon) => polygon.length >= 3);
}
export function projectRegionToViewport(region: Region, projection: GeometryProjection): Region {
const clipped = region
.map((polygon) =>
projection.sourceClips.reduce((current, clip) => clipPolygon(current, clip), polygon),
)
.filter((polygon) => polygon.length >= 3);
return projectAndClipRegion(clipped, projection.edges, projection.topViewport);
}
export function projectRectToViewport(
rect: { x: number; y: number; w: number; h: number } | null,
projection: GeometryProjection,
): ViewportRect | null {
if (!rect) return null;
return regionBounds(projectRegionToViewport([rectPolygon(rect)], projection));
}
export function childFrameProjection(
parent: GeometryProjection,
ownerRectInParent: { x: number; y: number; w: number; h: number },
): GeometryProjection {
const destinationQuad = rectPolygon(ownerRectInParent);
return {
sourceClips: [],
edges: [
{
sourceViewport: { width: ownerRectInParent.w, height: ownerRectInParent.h },
destinationQuad,
destinationClips: parent.sourceClips,
},
...parent.edges,
],
topViewport: parent.topViewport,
};
}
+62 -68
View File
@@ -8,6 +8,7 @@ import {
HELP_REQUEST, HELP_REQUEST,
type HelpCancelMessage, type HelpCancelMessage,
type HelpFinishMessage, type HelpFinishMessage,
type HelpHighlightRect,
type HelpQueryResponse, type HelpQueryResponse,
type HelpRequestMessage, type HelpRequestMessage,
isHelpAckMessage, isHelpAckMessage,
@@ -25,6 +26,7 @@ import type {
ResolvedTarget, ResolvedTarget,
RpcError, RpcError,
} from "@/transport/types"; } from "@/transport/types";
import { resolveNodeGeometry } from "./frame-geometry";
import { import {
type CdpRunner, type CdpRunner,
type ChromeTabsApi, type ChromeTabsApi,
@@ -37,7 +39,6 @@ import {
import { lookupSnapshotRef } from "./snapshot-ref"; import { lookupSnapshotRef } from "./snapshot-ref";
const DEFAULT_HELP_TIMEOUT_MS = 300_000; const DEFAULT_HELP_TIMEOUT_MS = 300_000;
const HELP_ATTR = "data-bsk-help";
const HELP_SEND_RETRIES = 3; const HELP_SEND_RETRIES = 3;
const HELP_SEND_RETRY_DELAY_MS = 350; const HELP_SEND_RETRY_DELAY_MS = 350;
const HELP_REARM_DEBOUNCE_MS = 150; const HELP_REARM_DEBOUNCE_MS = 150;
@@ -74,6 +75,7 @@ interface ActiveHelpRequest {
title?: string; title?: string;
targets: HelpTarget[]; targets: HelpTarget[];
selectors: string[]; selectors: string[];
rects: HelpHighlightRect[];
timeoutMs: number; timeoutMs: number;
notificationId: string; notificationId: string;
resolvedTargets?: ResolvedTarget[]; resolvedTargets?: ResolvedTarget[];
@@ -134,6 +136,7 @@ function helpRequestMessage(help: ActiveHelpRequest, tabId: number): HelpRequest
...(help.title ? { title: help.title } : {}), ...(help.title ? { title: help.title } : {}),
displayMode: isPrimaryTab ? "full" : "compact", displayMode: isPrimaryTab ? "full" : "compact",
selectors: isPrimaryTab ? help.selectors : [], selectors: isPrimaryTab ? help.selectors : [],
rects: isPrimaryTab ? help.rects : [],
timeoutMs: help.timeoutMs, timeoutMs: help.timeoutMs,
}; };
} }
@@ -165,33 +168,6 @@ async function sendHelpRequestWithAck(
throw lastError ?? new Error("failed to show help overlay"); throw lastError ?? new Error("failed to show help overlay");
} }
async function tagRefTarget(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
index: number,
): Promise<string | null> {
let objectId: string | undefined;
try {
const resolved = await cdp.send<{ object?: { objectId?: string } }>(tabId, "DOM.resolveNode", {
backendNodeId,
});
objectId = resolved.object?.objectId;
if (!objectId) return null;
await cdp.send(tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function(){ this.setAttribute("${HELP_ATTR}", "${index}"); }`,
});
return `[${HELP_ATTR}="${index}"]`;
} catch {
return null;
} finally {
if (objectId) {
await cdp.send(tabId, "Runtime.releaseObject", { objectId }).catch(() => {});
}
}
}
async function selectorExists( async function selectorExists(
cdp: CdpRunner, cdp: CdpRunner,
tabId: number, tabId: number,
@@ -209,36 +185,22 @@ async function selectorExists(
} }
} }
async function clearRefTags(cdp: CdpRunner | undefined, tabId: number): Promise<void> {
if (!cdp) return;
try {
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", {
depth: 0,
});
const rootId = doc.root?.nodeId;
if (rootId === undefined) return;
const { nodeIds } = await cdp.send<{ nodeIds: number[] }>(tabId, "DOM.querySelectorAll", {
nodeId: rootId,
selector: `[${HELP_ATTR}]`,
});
for (const nodeId of nodeIds ?? []) {
await cdp.send(tabId, "DOM.removeAttribute", { nodeId, name: HELP_ATTR }).catch(() => {});
}
} catch {
// Best-effort cleanup.
}
}
async function resolveHelpTargets( async function resolveHelpTargets(
ctx: ReturnType<SessionManager["get"]>, ctx: ReturnType<SessionManager["get"]>,
tabId: number, tabId: number,
targets: HelpTarget[], targets: HelpTarget[],
deps: RequestHelpDeps, deps: RequestHelpDeps,
): Promise<{ selectors: string[]; resolvedTargets?: ResolvedTarget[] }> { options: { scrollIntoView: boolean },
): Promise<{
selectors: string[];
rects: HelpHighlightRect[];
resolvedTargets?: ResolvedTarget[];
}> {
const selectors: string[] = []; const selectors: string[] = [];
const rects: HelpHighlightRect[] = [];
const resolved: ResolvedTarget[] = []; const resolved: ResolvedTarget[] = [];
let rootNodeId: number | undefined; let rootNodeId: number | undefined;
if (deps.cdp && targets.length > 0) { if (deps.cdp && targets.some((target) => Boolean(target.selector))) {
try { try {
const doc = await deps.cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", { const doc = await deps.cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", {
depth: 0, depth: 0,
@@ -262,28 +224,55 @@ async function resolveHelpTargets(
resolved.push({ matched, selector: tgt.selector }); resolved.push({ matched, selector: tgt.selector });
} else if (tgt.ref) { } else if (tgt.ref) {
const looked = ctx ? lookupSnapshotRef(ctx, tgt.ref, tabId) : null; const looked = ctx ? lookupSnapshotRef(ctx, tgt.ref, tabId) : null;
const backendNodeId = looked?.backendNodeId ?? null; let rect: HelpHighlightRect | null = null;
let sel: string | null = null; if (looked && deps.cdp) {
if (backendNodeId !== null && deps.cdp) { const target = {
sel = await tagRefTarget(deps.cdp, tabId, backendNodeId, i); tabId,
...(looked.cdpSessionId ? { sessionId: looked.cdpSessionId } : {}),
};
const geometry = await resolveNodeGeometry(
deps.cdp,
tabId,
{
target,
backendNodeId: looked.backendNodeId,
...(looked.frameId ? { frameId: looked.frameId } : {}),
},
{ scrollIntoView: options.scrollIntoView },
);
if (!isRpcError(geometry)) {
rect = {
top: geometry.topBounds.y,
left: geometry.topBounds.x,
width: geometry.topBounds.width,
height: geometry.topBounds.height,
};
} }
if (sel) selectors.push(sel); }
resolved.push({ matched: sel !== null, ref: tgt.ref }); if (rect) rects.push(rect);
resolved.push({
matched: rect !== null,
ref: tgt.ref,
});
} }
} }
return { selectors, resolvedTargets: resolved.length > 0 ? resolved : undefined }; return { selectors, rects, resolvedTargets: resolved.length > 0 ? resolved : undefined };
} }
async function refreshHelpTargets(help: ActiveHelpRequest): Promise<void> { async function refreshHelpTargets(
await clearRefTags(help.deps.cdp, help.primaryTabId); help: ActiveHelpRequest,
const { selectors, resolvedTargets } = await resolveHelpTargets( options: { scrollIntoView: boolean } = { scrollIntoView: true },
): Promise<void> {
const { selectors, rects, resolvedTargets } = await resolveHelpTargets(
help.ctx, help.ctx,
help.primaryTabId, help.primaryTabId,
help.targets, help.targets,
help.deps, help.deps,
options,
); );
help.selectors = selectors; help.selectors = selectors;
help.rects = rects;
help.resolvedTargets = resolvedTargets; help.resolvedTargets = resolvedTargets;
} }
@@ -294,12 +283,7 @@ async function cleanupHelp(help: ActiveHelpRequest): Promise<void> {
const tabsToCancel = new Set([help.primaryTabId, ...help.overlayTabIds]); const tabsToCancel = new Set([help.primaryTabId, ...help.overlayTabIds]);
await Promise.all( await Promise.all(
[...tabsToCancel].map((tabId) => [...tabsToCancel].map((tabId) =>
Promise.all([ help.deps.sendToTab(tabId, { type: HELP_CANCEL, requestId: help.requestId }).catch(() => {}),
clearRefTags(help.deps.cdp, tabId),
help.deps
.sendToTab(tabId, { type: HELP_CANCEL, requestId: help.requestId })
.catch(() => {}),
]),
), ),
); );
} }
@@ -476,7 +460,9 @@ function attachHelpRuntimeListener(deps: RequestHelpDeps): () => void {
sendResponse({ active: false }); sendResponse({ active: false });
return; return;
} }
if (tabId === help.primaryTabId) await refreshHelpTargets(help); if (tabId === help.primaryTabId) {
await refreshHelpTargets(help, { scrollIntoView: false });
}
help.overlayTabIds.add(tabId); help.overlayTabIds.add(tabId);
sendResponse({ sendResponse({
active: true, active: true,
@@ -486,6 +472,7 @@ function attachHelpRuntimeListener(deps: RequestHelpDeps): () => void {
...(help.title ? { title: help.title } : {}), ...(help.title ? { title: help.title } : {}),
displayMode: tabId === help.primaryTabId ? "full" : "compact", displayMode: tabId === help.primaryTabId ? "full" : "compact",
selectors: tabId === help.primaryTabId ? help.selectors : [], selectors: tabId === help.primaryTabId ? help.selectors : [],
rects: tabId === help.primaryTabId ? help.rects : [],
timeoutMs: help.timeoutMs, timeoutMs: help.timeoutMs,
}, },
}); });
@@ -686,7 +673,13 @@ export async function handleRequestHelp(
if (deps.autoAttachLifecycle !== false) ensureHelpLifecycleListeners(deps); if (deps.autoAttachLifecycle !== false) ensureHelpLifecycleListeners(deps);
const tabId = target.tabId; const tabId = target.tabId;
const initialTargets = params.targets ?? []; const initialTargets = params.targets ?? [];
const { selectors, resolvedTargets } = await resolveHelpTargets(ctx, tabId, initialTargets, deps); const { selectors, rects, resolvedTargets } = await resolveHelpTargets(
ctx,
tabId,
initialTargets,
deps,
{ scrollIntoView: true },
);
await deps.windows.update(target.windowId, { focused: true }).catch(() => {}); await deps.windows.update(target.windowId, { focused: true }).catch(() => {});
await deps.activateTab(tabId).catch(() => {}); await deps.activateTab(tabId).catch(() => {});
@@ -729,6 +722,7 @@ export async function handleRequestHelp(
...(params.title ? { title: params.title } : {}), ...(params.title ? { title: params.title } : {}),
targets: initialTargets, targets: initialTargets,
selectors, selectors,
rects,
timeoutMs, timeoutMs,
notificationId, notificationId,
resolvedTargets, resolvedTargets,
+122 -60
View File
@@ -3,14 +3,15 @@
// //
// All interaction tools: // All interaction tools:
// 1. Resolve target tab (sandbox: must be inside Agent Window). // 1. Resolve target tab (sandbox: must be inside Agent Window).
// 2. Resolve target element by `ref` (RefStore.resolve with tabId // 2. Resolve target element by `ref` (compound frame/session identity)
// binding) or `selector` (DOM.querySelector + describeNode). // or `selector` (DOM.querySelector + describeNode).
// 3. Scroll the node into view, then dispatch the appropriate // 3. Scroll the node and its frame owners into view, project its live
// `Input.*` CDP events. // content quad into the top viewport, then dispatch `Input.*` events.
// 4. Honour `AbortSignal` so canceled calls don't issue follow-up CDP // 4. Honour `AbortSignal` so canceled calls don't issue follow-up CDP
// commands. // commands.
import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { SessionContext, SessionManager } from "@/session-manager/manager"; import type { SessionContext, SessionManager } from "@/session-manager/manager";
import type { import type {
ClickParams, ClickParams,
@@ -28,21 +29,18 @@ import type {
SelectResult, SelectResult,
} from "@/transport/types"; } from "@/transport/types";
import { attachDialogs, markDialogCursor } from "./dialogs"; import { attachDialogs, markDialogCursor } from "./dialogs";
import { import { backendNodeToObject } from "./element-geometry";
backendNodeToObject,
boxCentre,
nodeCentre,
quadCentre,
scrollNodeIntoView,
} from "./element-geometry";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
import { resolveNodeGeometry, scrollElementAndFramesIntoView } from "./frame-geometry";
import { import {
type CdpRunner, type CdpRunner,
type ChromeTabsApi, type ChromeTabsApi,
cdpRunnerForTarget,
chromeTabsApi, chromeTabsApi,
enforceAgentWindow, enforceAgentWindow,
isRpcError, isRpcError,
lookupSession, lookupSession,
type ResolvedTargetTab,
resolveTargetTab, resolveTargetTab,
} from "./shared"; } from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref"; import { resolveSnapshotRef } from "./snapshot-ref";
@@ -59,6 +57,15 @@ export interface InteractionDeps {
keepOverlayBypassAfterHover?: boolean; keepOverlayBypassAfterHover?: boolean;
} }
export interface ResolvedActionTarget {
tab: ResolvedTargetTab;
backendNodeId: number;
cdpTarget: CdpTarget;
frameId?: string;
usedRef?: string;
usedSelector?: string;
}
const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_HOVER_SETTLE_MS = 200; const DEFAULT_HOVER_SETTLE_MS = 200;
@@ -131,13 +138,22 @@ async function wait(ms: number, signal?: AbortSignal): Promise<void> {
* `RpcError` if the caller supplied neither (or both), or if neither * `RpcError` if the caller supplied neither (or both), or if neither
* lookup matched. * lookup matched.
*/ */
async function resolveBackendNode( export async function resolveBackendNode(
cdp: CdpRunner, cdp: CdpRunner,
ctx: SessionContext, ctx: SessionContext,
target: { tabId: number }, target: { tabId: number },
params: { ref?: string; selector?: string }, params: { ref?: string; selector?: string },
toolName: string, toolName: string,
): Promise<{ backendNodeId: number; usedRef?: string; usedSelector?: string } | RpcError> { ): Promise<
| {
backendNodeId: number;
cdpTarget: CdpTarget;
frameId?: string;
usedRef?: string;
usedSelector?: string;
}
| RpcError
> {
const hasRef = typeof params.ref === "string" && params.ref.length > 0; const hasRef = typeof params.ref === "string" && params.ref.length > 0;
const hasSelector = typeof params.selector === "string" && params.selector.length > 0; const hasSelector = typeof params.selector === "string" && params.selector.length > 0;
if (hasRef && hasSelector) { if (hasRef && hasSelector) {
@@ -155,7 +171,15 @@ async function resolveBackendNode(
if (hasRef) { if (hasRef) {
const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId); const resolved = resolveSnapshotRef(ctx, params.ref as string, target.tabId);
if (isRpcError(resolved)) return resolved; if (isRpcError(resolved)) return resolved;
return { backendNodeId: resolved.backendNodeId, usedRef: resolved.refKey }; return {
backendNodeId: resolved.backendNodeId,
cdpTarget: {
tabId: target.tabId,
...(resolved.cdpSessionId ? { sessionId: resolved.cdpSessionId } : {}),
},
...(resolved.frameId ? { frameId: resolved.frameId } : {}),
usedRef: resolved.refKey,
};
} }
// selector path // selector path
try { try {
@@ -192,7 +216,7 @@ async function resolveBackendNode(
message: "DOM.describeNode returned no backendNodeId", message: "DOM.describeNode returned no backendNodeId",
}; };
} }
return { backendNodeId, usedSelector: params.selector }; return { backendNodeId, cdpTarget: { tabId: target.tabId }, usedSelector: params.selector };
} catch (err) { } catch (err) {
return { return {
code: "cdp_failed", code: "cdp_failed",
@@ -201,6 +225,17 @@ async function resolveBackendNode(
} }
} }
export async function resolveActionTarget(
cdp: CdpRunner,
ctx: SessionContext,
target: ResolvedTargetTab,
params: { ref?: string; selector?: string },
toolName: string,
): Promise<ResolvedActionTarget | RpcError> {
const node = await resolveBackendNode(cdp, ctx, target, params, toolName);
return isRpcError(node) ? node : { tab: target, ...node };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// tool.click // tool.click
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -219,28 +254,37 @@ export async function handleClick(
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "click"); const denied = enforceAgentWindow(ctx, target, "click");
if (denied) return denied; if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId); const resolved = await resolveActionTarget(deps.cdp, ctx, target, params, "click");
if (isRpcError(resolved)) return resolved;
return clickResolvedTarget(ctx, resolved, params, deps);
}
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "click"); export async function clickResolvedTarget(
if (isRpcError(node)) return node; ctx: SessionContext,
resolved: ResolvedActionTarget,
params: Pick<ClickParams, "button" | "click_count" | "modifiers">,
deps: InteractionDeps,
): Promise<ClickResult | RpcError> {
const { tab: target } = resolved;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "click aborted" }; return { code: "cancelled", message: "click aborted" };
} }
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId); const geometry = await resolveNodeGeometry(
if (scrollErr) return scrollErr; deps.cdp,
} catch (err) { target.tabId,
return { {
code: "cdp_failed", target: resolved.cdpTarget,
message: err instanceof Error ? err.message : String(err), backendNodeId: resolved.backendNodeId,
}; ...(resolved.frameId ? { frameId: resolved.frameId } : {}),
} },
{ scrollIntoView: true },
const centre = await nodeCentre(deps.cdp, target.tabId, node.backendNodeId); );
if (isRpcError(centre)) return centre; if (isRpcError(geometry)) return geometry;
const centre = geometry.actionPoint;
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "click aborted" }; return { code: "cancelled", message: "click aborted" };
@@ -323,8 +367,8 @@ export async function handleClick(
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId, tab_id: target.tabId,
used_ref: node.usedRef, used_ref: resolved.usedRef,
used_selector: node.usedSelector, used_selector: resolved.usedSelector,
x: centre.x, x: centre.x,
y: centre.y, y: centre.y,
}); });
@@ -353,19 +397,19 @@ export async function handleHover(
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "hover"); const node = await resolveBackendNode(deps.cdp, ctx, target, params, "hover");
if (isRpcError(node)) return node; if (isRpcError(node)) return node;
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId); const geometry = await resolveNodeGeometry(
if (scrollErr) return scrollErr; deps.cdp,
} catch (err) { target.tabId,
return { {
code: "cdp_failed", target: node.cdpTarget,
message: err instanceof Error ? err.message : String(err), backendNodeId: node.backendNodeId,
}; ...(node.frameId ? { frameId: node.frameId } : {}),
} },
{ scrollIntoView: true },
const centre = await nodeCentre(deps.cdp, target.tabId, node.backendNodeId); );
if (isRpcError(centre)) return centre; if (isRpcError(geometry)) return geometry;
const centre = geometry.actionPoint;
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "hover aborted" }; return { code: "cancelled", message: "hover aborted" };
@@ -585,10 +629,11 @@ export async function handleFill(
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "fill"); const node = await resolveBackendNode(deps.cdp, ctx, target, params, "fill");
if (isRpcError(node)) return node; if (isRpcError(node)) return node;
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
try { try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const described = await deps.cdp.send<{ node?: DescribedNode }>( const described = await nodeCdp.send<{ node?: DescribedNode }>(
target.tabId, target.tabId,
"DOM.describeNode", "DOM.describeNode",
{ {
@@ -602,12 +647,18 @@ export async function handleFill(
`element ${described.node?.nodeName ?? "?"} not fillable (need input/textarea/contenteditable)`, `element ${described.node?.nodeName ?? "?"} not fillable (need input/textarea/contenteditable)`,
); );
} }
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId); const scrollErr = await scrollElementAndFramesIntoView(
deps.cdp,
target.tabId,
node.cdpTarget,
node.backendNodeId,
node.frameId,
);
if (scrollErr) return scrollErr; if (scrollErr) return scrollErr;
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" }; return { code: "cancelled", message: "fill aborted" };
} }
await deps.cdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId }); await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) { } catch (err) {
return { return {
code: "cdp_failed", code: "cdp_failed",
@@ -619,7 +670,7 @@ export async function handleFill(
return { code: "cancelled", message: "fill aborted" }; return { code: "cancelled", message: "fill aborted" };
} }
const objectIdOrErr = await backendNodeToObject(deps.cdp, target.tabId, node.backendNodeId); const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, node.backendNodeId);
if (isRpcError(objectIdOrErr)) return objectIdOrErr; if (isRpcError(objectIdOrErr)) return objectIdOrErr;
const objectId = objectIdOrErr; const objectId = objectIdOrErr;
const clearBefore = params.clear_before ?? true; const clearBefore = params.clear_before ?? true;
@@ -628,7 +679,7 @@ export async function handleFill(
if (clearBefore) { if (clearBefore) {
// Clear input/textarea value or wipe contenteditable innerText, // Clear input/textarea value or wipe contenteditable innerText,
// then fire `input` so frameworks observe the empty state. // then fire `input` so frameworks observe the empty state.
await deps.cdp.send(target.tabId, "Runtime.callFunctionOn", { await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
objectId, objectId,
functionDeclaration: `function() { functionDeclaration: `function() {
if (this.isContentEditable) { this.textContent = ''; } if (this.isContentEditable) { this.textContent = ''; }
@@ -655,7 +706,7 @@ export async function handleFill(
return { code: "cancelled", message: "fill aborted" }; return { code: "cancelled", message: "fill aborted" };
} }
// Fire `input` + `change` so React / Vue controlled inputs commit. // Fire `input` + `change` so React / Vue controlled inputs commit.
await deps.cdp.send(target.tabId, "Runtime.callFunctionOn", { await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
objectId, objectId,
functionDeclaration: `function() { functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true })); this.dispatchEvent(new Event('input', { bubbles: true }));
@@ -853,14 +904,21 @@ export async function handlePress(
if (params.ref || params.selector) { if (params.ref || params.selector) {
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "press"); const node = await resolveBackendNode(deps.cdp, ctx, target, params, "press");
if (isRpcError(node)) return node; if (isRpcError(node)) return node;
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
try { try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId); const scrollErr = await scrollElementAndFramesIntoView(
deps.cdp,
target.tabId,
node.cdpTarget,
node.backendNodeId,
node.frameId,
);
if (scrollErr) return scrollErr; if (scrollErr) return scrollErr;
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "press aborted" }; return { code: "cancelled", message: "press aborted" };
} }
await deps.cdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId }); await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) { } catch (err) {
return { return {
code: "cdp_failed", code: "cdp_failed",
@@ -965,10 +1023,11 @@ export async function handleSelect(
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "select"); const node = await resolveBackendNode(deps.cdp, ctx, target, params, "select");
if (isRpcError(node)) return node; if (isRpcError(node)) return node;
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
try { try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const described = await deps.cdp.send<{ node?: DescribedNode }>( const described = await nodeCdp.send<{ node?: DescribedNode }>(
target.tabId, target.tabId,
"DOM.describeNode", "DOM.describeNode",
{ backendNodeId: node.backendNodeId }, { backendNodeId: node.backendNodeId },
@@ -991,12 +1050,18 @@ export async function handleSelect(
"single-select <select> requires exactly one value", "single-select <select> requires exactly one value",
); );
} }
const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId); const scrollErr = await scrollElementAndFramesIntoView(
deps.cdp,
target.tabId,
node.cdpTarget,
node.backendNodeId,
node.frameId,
);
if (scrollErr) return scrollErr; if (scrollErr) return scrollErr;
if (throwIfAborted(deps.signal)) { if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "select aborted" }; return { code: "cancelled", message: "select aborted" };
} }
await deps.cdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId }); await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) { } catch (err) {
return { return {
code: "cdp_failed", code: "cdp_failed",
@@ -1008,12 +1073,12 @@ export async function handleSelect(
return { code: "cancelled", message: "select aborted" }; return { code: "cancelled", message: "select aborted" };
} }
const objectIdOrErr = await backendNodeToObject(deps.cdp, target.tabId, node.backendNodeId); const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, node.backendNodeId);
if (isRpcError(objectIdOrErr)) return objectIdOrErr; if (isRpcError(objectIdOrErr)) return objectIdOrErr;
const objectId = objectIdOrErr; const objectId = objectIdOrErr;
try { try {
const evaluated = await deps.cdp.send<{ const evaluated = await nodeCdp.send<{
result?: { value?: SelectMutationResult | null }; result?: { value?: SelectMutationResult | null };
}>(target.tabId, "Runtime.callFunctionOn", { }>(target.tabId, "Runtime.callFunctionOn", {
objectId, objectId,
@@ -1072,9 +1137,6 @@ export async function handleSelect(
export const __testing__ = { export const __testing__ = {
DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS,
quadCentre,
boxCentre,
resolveBackendNode, resolveBackendNode,
nodeCentre,
isFillable, isFillable,
}; };
+5
View File
@@ -37,17 +37,22 @@ export async function handleNetwork(
manager: SessionManager, manager: SessionManager,
params: NetworkParams, params: NetworkParams,
deps: NetworkDeps = defaultNetworkDeps(), deps: NetworkDeps = defaultNetworkDeps(),
signal?: AbortSignal,
): Promise<NetworkResult | RpcError> { ): Promise<NetworkResult | RpcError> {
if (signal?.aborted) return { code: "cancelled", message: "network aborted" };
const ctxOrErr = lookupSession(manager, params, "network"); const ctxOrErr = lookupSession(manager, params, "network");
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseBufferedReadBounds(params); const bounds = parseBufferedReadBounds(params);
if (isRpcError(bounds)) return bounds; if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi); const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
if (signal?.aborted) return { code: "cancelled", message: "network aborted" };
try { try {
if (signal?.aborted) return { code: "cancelled", message: "network aborted" };
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
await deps.cdp.ensureNetworkCapture(target.tabId); await deps.cdp.ensureNetworkCapture(target.tabId);
if (signal?.aborted) return { code: "cancelled", message: "network aborted" };
return deps.cdp.networkEntriesSince( return deps.cdp.networkEntriesSince(
target.tabId, target.tabId,
bounds.since, bounds.since,
+337 -54
View File
@@ -8,10 +8,12 @@ import {
applyVomInteractionRecovery, applyVomInteractionRecovery,
type CondSurface, type CondSurface,
isVomReferenceNode, isVomReferenceNode,
renderVom,
type VomNode, type VomNode,
type VomScene, type VomScene,
} from "@browser-skill/vom"; } from "@browser-skill/vom";
import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { CdpTarget } from "@/browser-driver/frame-graph";
import { import {
type CaptureSuppressSendToTab, type CaptureSuppressSendToTab,
withOverlaysHiddenForCapture, withOverlaysHiddenForCapture,
@@ -28,10 +30,9 @@ import type {
SnapshotParams, SnapshotParams,
SnapshotResult, SnapshotResult,
} from "@/transport/types"; } from "@/transport/types";
import { captureVomObservation } from "./capture-vom-observation";
import { attachDialogs, markDialogCursor } from "./dialogs"; import { attachDialogs, markDialogCursor } from "./dialogs";
import { nodeBoundingRect, scrollNodeIntoView } from "./element-geometry";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
import { resolveNodeGeometry } from "./frame-geometry";
import { import {
type ChromeTabsApi, type ChromeTabsApi,
enforceToolTargetScope, enforceToolTargetScope,
@@ -40,11 +41,18 @@ import {
type ResolvedTargetTab, type ResolvedTargetTab,
resolveCdpAccessibleTargetTab, resolveCdpAccessibleTargetTab,
type CdpRunner as SharedCdpRunner, type CdpRunner as SharedCdpRunner,
sendToCdpTarget,
normaliseRef as sharedNormaliseRef, normaliseRef as sharedNormaliseRef,
type ToolEffect, type ToolEffect,
} from "./shared"; } from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref"; import { resolveSnapshotRef } from "./snapshot-ref";
import { type CapturedNode, type CapturedViewModel } from "./vom/capture"; import {
type CapturedNode,
type CapturedViewModel,
captureViewModel,
collectOverlayExcludedBackendIds,
} from "./vom/capture";
import { type CapturedFrameDocument, captureFrameData } from "./vom/frame-capture";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared helpers (legacy aliases — observation.ts kept exporting these // Shared helpers (legacy aliases — observation.ts kept exporting these
@@ -132,35 +140,66 @@ function defaultScreenshotDeps(): ScreenshotDeps {
}; };
} }
function cancelled(tool: string): RpcError {
return { code: "cancelled", message: `${tool} aborted` };
}
function abortError(tool: string): Error {
const error = new Error(`${tool} aborted`);
error.name = "AbortError";
return error;
}
function isAbortError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
(error as { name?: string }).name === "AbortError"
);
}
function throwIfAborted(signal: AbortSignal | undefined, tool: string): void {
if (signal?.aborted) throw abortError(tool);
}
async function captureElementScreenshot( async function captureElementScreenshot(
cdp: SharedCdpRunner, cdp: SharedCdpRunner,
tabId: number, tabId: number,
target: CdpTarget,
backendNodeId: number, backendNodeId: number,
frameId?: string,
signal?: AbortSignal,
): Promise<{ image_base64: string; width: number; height: number } | RpcError> { ): Promise<{ image_base64: string; width: number; height: number } | RpcError> {
const scrollErr = await scrollNodeIntoView(cdp, tabId, backendNodeId); if (signal?.aborted) return cancelled("screenshot");
if (scrollErr) return scrollErr; const geometry = await resolveNodeGeometry(
cdp,
const rectOrErr = await nodeBoundingRect(cdp, tabId, backendNodeId); tabId,
if (isRpcError(rectOrErr)) return rectOrErr; { target, backendNodeId, ...(frameId ? { frameId } : {}) },
{ scrollIntoView: true },
);
if (isRpcError(geometry)) return geometry;
if (signal?.aborted) return cancelled("screenshot");
const rect = geometry.topBounds;
try { try {
const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", { const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", {
format: "png", format: "png",
clip: { clip: {
x: rectOrErr.x, x: rect.x,
y: rectOrErr.y, y: rect.y,
width: rectOrErr.width, width: rect.width,
height: rectOrErr.height, height: rect.height,
scale: 1, scale: 1,
}, },
}); });
if (signal?.aborted) return cancelled("screenshot");
const image_base64 = shot.data ?? ""; const image_base64 = shot.data ?? "";
if (!image_base64) { if (!image_base64) {
return { code: "cdp_failed", message: "Page.captureScreenshot returned no data" }; return { code: "cdp_failed", message: "Page.captureScreenshot returned no data" };
} }
const dims = parsePngDimensions(image_base64) ?? { const dims = parsePngDimensions(image_base64) ?? {
width: Math.round(rectOrErr.width), width: Math.round(rect.width),
height: Math.round(rectOrErr.height), height: Math.round(rect.height),
}; };
return { image_base64, width: dims.width, height: dims.height }; return { image_base64, width: dims.width, height: dims.height };
} catch (err) { } catch (err) {
@@ -188,11 +227,15 @@ async function captureFullTabPng(
deps: ScreenshotDeps, deps: ScreenshotDeps,
ctx: SessionContext, ctx: SessionContext,
target: ResolvedTargetTab, target: ResolvedTargetTab,
signal?: AbortSignal,
): Promise<string | RpcError> { ): Promise<string | RpcError> {
if (signal?.aborted) return cancelled("screenshot");
try { try {
const dataUrl = await deps.captureApi.captureVisibleTab(target.windowId, { format: "png" }); const dataUrl = await deps.captureApi.captureVisibleTab(target.windowId, { format: "png" });
if (signal?.aborted) return cancelled("screenshot");
return stripDataUrlPrefix(dataUrl); return stripDataUrlPrefix(dataUrl);
} catch (primaryErr) { } catch (primaryErr) {
if (signal?.aborted) return cancelled("screenshot");
const cdp = deps.cdp; const cdp = deps.cdp;
if (!cdp) { if (!cdp) {
return { return {
@@ -202,12 +245,15 @@ async function captureFullTabPng(
} }
let fallbackMsg: string; let fallbackMsg: string;
try { try {
if (signal?.aborted) return cancelled("screenshot");
cdp.trackSessionTab?.(ctx.sessionId, target.tabId); cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await cdp.ensureAttachedToUrl?.(target.tabId, target.url); await cdp.ensureAttachedToUrl?.(target.tabId, target.url);
if (signal?.aborted) return cancelled("screenshot");
const shot = await cdp.send<{ data?: string }>(target.tabId, "Page.captureScreenshot", { const shot = await cdp.send<{ data?: string }>(target.tabId, "Page.captureScreenshot", {
format: "png", format: "png",
fromSurface: true, fromSurface: true,
}); });
if (signal?.aborted) return cancelled("screenshot");
if (shot.data) return shot.data; if (shot.data) return shot.data;
fallbackMsg = "Page.captureScreenshot returned no data"; fallbackMsg = "Page.captureScreenshot returned no data";
} catch (fallbackErr) { } catch (fallbackErr) {
@@ -226,7 +272,9 @@ export async function handleScreenshot(
manager: SessionManager, manager: SessionManager,
params: ScreenshotParams, params: ScreenshotParams,
deps: ScreenshotDeps = defaultScreenshotDeps(), deps: ScreenshotDeps = defaultScreenshotDeps(),
signal?: AbortSignal,
): Promise<ScreenshotResult | RpcError> { ): Promise<ScreenshotResult | RpcError> {
if (signal?.aborted) return cancelled("screenshot");
const ctxOrErr = lookupSession(manager, params, "screenshot"); const ctxOrErr = lookupSession(manager, params, "screenshot");
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr; const ctx = ctxOrErr;
@@ -238,6 +286,7 @@ export async function handleScreenshot(
"screenshot", "screenshot",
); );
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
if (signal?.aborted) return cancelled("screenshot");
const dialogCursor = deps.cdp ? markDialogCursor(deps.cdp, target.tabId) : 0; const dialogCursor = deps.cdp ? markDialogCursor(deps.cdp, target.tabId) : 0;
const withShotDialogs = <T extends object>(result: T) => const withShotDialogs = <T extends object>(result: T) =>
deps.cdp ? attachDialogs(deps.cdp, target.tabId, dialogCursor, result) : result; deps.cdp ? attachDialogs(deps.cdp, target.tabId, dialogCursor, result) : result;
@@ -249,15 +298,30 @@ export async function handleScreenshot(
} }
const node = resolveSnapshotRef(ctx, ref, target.tabId); const node = resolveSnapshotRef(ctx, ref, target.tabId);
if (isRpcError(node)) return node; if (isRpcError(node)) return node;
if (signal?.aborted) return cancelled("screenshot");
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url); await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
if (signal?.aborted) return cancelled("screenshot");
const cdp = deps.cdp; const cdp = deps.cdp;
const nodeTarget = {
tabId: target.tabId,
...(node.cdpSessionId ? { sessionId: node.cdpSessionId } : {}),
};
const captured = await withOverlaysHiddenForCapture( const captured = await withOverlaysHiddenForCapture(
target.tabId, target.tabId,
() => captureElementScreenshot(cdp, target.tabId, node.backendNodeId), () =>
captureElementScreenshot(
cdp,
target.tabId,
nodeTarget,
node.backendNodeId,
node.frameId,
signal,
),
deps.sendToTab, deps.sendToTab,
); );
if (isRpcError(captured)) return captured; if (isRpcError(captured)) return captured;
if (signal?.aborted) return cancelled("screenshot");
return withShotDialogs({ return withShotDialogs({
image_base64: captured.image_base64, image_base64: captured.image_base64,
width: captured.width, width: captured.width,
@@ -277,10 +341,11 @@ export async function handleScreenshot(
const captured = await withOverlaysHiddenForCapture( const captured = await withOverlaysHiddenForCapture(
target.tabId, target.tabId,
() => captureFullTabPng(deps, ctx, target), () => captureFullTabPng(deps, ctx, target, signal),
deps.sendToTab, deps.sendToTab,
); );
if (isRpcError(captured)) return captured; if (isRpcError(captured)) return captured;
if (signal?.aborted) return cancelled("screenshot");
const image_base64 = captured; const image_base64 = captured;
const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 }; const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 };
return withShotDialogs({ return withShotDialogs({
@@ -306,6 +371,7 @@ export type CdpRunner = SharedCdpRunner;
/** Subset of CDP `AXNode` we care about — see `Accessibility.AXNode`. */ /** Subset of CDP `AXNode` we care about — see `Accessibility.AXNode`. */
export interface CdpAxNode { export interface CdpAxNode {
nodeId: string; nodeId: string;
frameId?: string;
parentId?: string; parentId?: string;
backendDOMNodeId?: number; backendDOMNodeId?: number;
ignored?: boolean; ignored?: boolean;
@@ -345,18 +411,7 @@ function isModalSignal(
function isSensitive(capturedNode: CapturedNode | undefined): boolean { function isSensitive(capturedNode: CapturedNode | undefined): boolean {
const attrs = capturedNode?.attrs ?? {}; const attrs = capturedNode?.attrs ?? {};
const type = (attrs.type ?? "").toLowerCase(); return (attrs.type ?? "").toLowerCase() === "password";
if (type === "password") return true;
const autocomplete = (attrs.autocomplete ?? "").toLowerCase();
if (autocomplete.startsWith("cc-")) return true;
if (
autocomplete === "one-time-code" ||
autocomplete === "current-password" ||
autocomplete === "new-password"
) {
return true;
}
return false;
} }
interface AxNodeSignals { interface AxNodeSignals {
@@ -406,13 +461,7 @@ const AX_TEXT_STOP_ROLES = new Set([
"columnheader", "columnheader",
"rowheader", "rowheader",
]); ]);
const SENSITIVE_AX_INPUT_TYPES = new Set([ const SENSITIVE_AX_INPUT_TYPES = new Set(["password", "credit-card"]);
"password",
"credit-card",
"one-time-code",
"current-password",
"new-password",
]);
function axPropertyString(axNode: CdpAxNode, name: string): string | undefined { function axPropertyString(axNode: CdpAxNode, name: string): string | undefined {
const prop = axNode.properties?.find((item) => item.name === name); const prop = axNode.properties?.find((item) => item.name === name);
@@ -555,7 +604,7 @@ function iframeNameFor(node: CapturedNode): string | undefined {
function isRenderableIframeControl(node: CapturedNode): boolean { function isRenderableIframeControl(node: CapturedNode): boolean {
const tag = normalizeTag(node.tag); const tag = normalizeTag(node.tag);
if (!IFRAME_RENDERABLE_TAGS.has(tag)) return false; if (!IFRAME_RENDERABLE_TAGS.has(tag)) return false;
if (!node.rect) return false; if (!node.localRect && !node.rect) return false;
if (node.pointerEvents === "none") return false; if (node.pointerEvents === "none") return false;
if ((node.attrs.type ?? "").toLowerCase() === "hidden") return false; if ((node.attrs.type ?? "").toLowerCase() === "hidden") return false;
@@ -727,7 +776,12 @@ function applyCapturedSignals(
signals: VomNodeDomSignals, signals: VomNodeDomSignals,
): VomNode { ): VomNode {
if (!capturedNode) return node; if (!capturedNode) return node;
const attrs = capturedNode.attrs; const attrs =
node.sensitive && Object.prototype.hasOwnProperty.call(capturedNode.attrs, "value")
? Object.fromEntries(
Object.entries(capturedNode.attrs).filter(([name]) => name.toLowerCase() !== "value"),
)
: capturedNode.attrs;
const text = cleanAttr(capturedNode.textContent); const text = cleanAttr(capturedNode.textContent);
const nearbyText = nearbyTextFor(capturedNode, signals.childrenByParentId); const nearbyText = nearbyTextFor(capturedNode, signals.childrenByParentId);
const placeholder = cleanAttr(capturedNode.formPlaceholder) ?? cleanAttr(attrs.placeholder); const placeholder = cleanAttr(capturedNode.formPlaceholder) ?? cleanAttr(attrs.placeholder);
@@ -794,8 +848,7 @@ function vomNodeFromCaptured(
signals: VomNodeDomSignals, signals: VomNodeDomSignals,
): VomNode { ): VomNode {
const sensitive = isSensitive(capturedNode); const sensitive = isSensitive(capturedNode);
const value = const value = sensitive ? undefined : capturedNode.formValue;
capturedNode.formValue ?? (sensitive ? (capturedNode.attrs.value ?? "") : undefined);
const tag = normalizeTag(capturedNode.tag); const tag = normalizeTag(capturedNode.tag);
const node = applyCapturedSignals( const node = applyCapturedSignals(
{ {
@@ -831,8 +884,17 @@ function axVomNode(
): VomNode { ): VomNode {
const role = axString(axNode.role); const role = axString(axNode.role);
const capturedValue = capturedNode?.formValue; const capturedValue = capturedNode?.formValue;
const value = capturedValue ?? axValue(axNode.value); const sourceValue = capturedValue ?? axValue(axNode.value);
const signalsForNode = axSignals.get(axNode.nodeId); const signalsForNode = axSignals.get(axNode.nodeId);
const sensitive = isSensitive(capturedNode) || signalsForNode?.sensitive === true;
const value = sensitive ? undefined : sourceValue;
const inputState =
inputStateFor(capturedNode, sourceValue) ??
(sensitive && ["textbox", "searchbox"].includes(role?.toLowerCase() ?? "")
? sourceValue === undefined || sourceValue === ""
? "empty"
: "filled"
: undefined);
let name = FORM_CONTROL_TAGS.has(normalizeTag(capturedNode?.tag)) let name = FORM_CONTROL_TAGS.has(normalizeTag(capturedNode?.tag))
? formControlName(capturedNode, axString(axNode.name), axSiblingLabel(axNode, axById), signals) ? formControlName(capturedNode, axString(axNode.name), axSiblingLabel(axNode, axById), signals)
: (axString(axNode.name) ?? signalsForNode?.aggregatedText); : (axString(axNode.name) ?? signalsForNode?.aggregatedText);
@@ -848,8 +910,8 @@ function axVomNode(
position: capturedNode?.position || "static", position: capturedNode?.position || "static",
pointerEvents: capturedNode?.pointerEvents || "auto", pointerEvents: capturedNode?.pointerEvents || "auto",
modal: isModalSignal(axNode, capturedNode), modal: isModalSignal(axNode, capturedNode),
sensitive: isSensitive(capturedNode) || signalsForNode?.sensitive === true, sensitive,
inputState: inputStateFor(capturedNode, value), inputState,
}; };
if (role) node.role = role; if (role) node.role = role;
if (name) node.name = name; if (name) node.name = name;
@@ -1112,6 +1174,8 @@ export interface BuildVomSceneOptions {
pageUrl?: string; pageUrl?: string;
} }
export type VomFrameDocument = CapturedFrameDocument<CdpAxNode>;
function externalHrefHost( function externalHrefHost(
href: string | undefined, href: string | undefined,
pageUrl: string | undefined, pageUrl: string | undefined,
@@ -1205,6 +1269,119 @@ export function buildVomScene(
}; };
} }
export function buildFrameVomScene(
documents: VomFrameDocument[],
captured: CapturedViewModel,
options: BuildVomSceneOptions = {},
): VomScene {
const documentByFrameId = new Map(documents.map((document) => [document.frameId, document]));
const rootFrameId =
captured.rootFrameId ?? documents.find((document) => !document.parentFrameId)?.frameId;
const orderedFrameIds = [
...(rootFrameId ? [rootFrameId] : []),
...documents.map((document) => document.frameId),
].filter((frameId, index, all) => all.indexOf(frameId) === index);
const localScenes = new Map<string, VomScene>();
for (const frameId of orderedFrameIds) {
const document = documentByFrameId.get(frameId);
if (!document) continue;
localScenes.set(
frameId,
buildVomScene(
document.axNodes,
{
viewport: captured.viewport,
nodes: document.domNodes,
iframeNodes: new Map(),
surfaceProbes: frameId === rootFrameId ? captured.surfaceProbes : [],
excludedBackendNodeIds:
frameId === rootFrameId ? captured.excludedBackendNodeIds : new Set(),
},
{ pageUrl: document.url ?? options.pageUrl },
),
);
}
const sceneIdByFrameAndBackend = new Map<string, number>();
let nextSceneId = 1;
for (const [frameId, scene] of localScenes) {
for (const node of scene.nodes) {
sceneIdByFrameAndBackend.set(`${frameId}:${node.id}`, nextSceneId);
nextSceneId += 1;
}
}
const nodes: VomNode[] = [];
const surfaces: CondSurface[] = [];
const activeScopeBlocks: ActiveScopeBlock[] = [];
for (const [frameId, scene] of localScenes) {
const document = documentByFrameId.get(frameId);
const ownerBackendNodeId = document?.ownerBackendNodeId;
const parentFrameId = document?.parentFrameId;
const ownerSceneId =
ownerBackendNodeId !== undefined && parentFrameId
? sceneIdByFrameAndBackend.get(`${parentFrameId}:${ownerBackendNodeId}`)
: undefined;
if (frameId !== rootFrameId && ownerSceneId === undefined) continue;
const suppressedRoots = new Set(
frameId === rootFrameId
? []
: scene.nodes
.filter(
(node) =>
node.parentId === null &&
["rootwebarea", "webarea"].includes(node.role?.toLowerCase() ?? ""),
)
.map((node) => node.id),
);
for (const node of scene.nodes) {
if (suppressedRoots.has(node.id)) continue;
const sceneNodeId = sceneIdByFrameAndBackend.get(`${frameId}:${node.id}`) as number;
let parentId =
node.parentId !== null
? sceneIdByFrameAndBackend.get(`${frameId}:${node.parentId}`)
: ownerSceneId;
if (node.parentId !== null && suppressedRoots.has(node.parentId)) parentId = ownerSceneId;
const remapId = (id: number) => sceneIdByFrameAndBackend.get(`${frameId}:${id}`);
nodes.push({
...node,
id: sceneNodeId,
backendNodeId: node.id,
frameId,
contextScopeId: document?.contextScopeId ?? frameId,
parentId: parentId ?? null,
...(node.domParentId !== undefined
? { domParentId: node.domParentId === null ? null : (remapId(node.domParentId) ?? null) }
: {}),
...(node.domAncestorIds
? { domAncestorIds: node.domAncestorIds.flatMap((id) => remapId(id) ?? []) }
: {}),
});
}
for (const surface of scene.surfaces ?? []) {
const triggerId = sceneIdByFrameAndBackend.get(`${frameId}:${surface.triggerId}`);
if (triggerId !== undefined) surfaces.push({ ...surface, triggerId });
}
for (const block of scene.activeScopeBlocks ?? []) {
const triggerId = sceneIdByFrameAndBackend.get(`${frameId}:${block.triggerId}`);
if (triggerId !== undefined) activeScopeBlocks.push({ ...block, triggerId });
}
}
return {
viewport: captured.viewport,
nodes,
...(rootFrameId ? { rootFrameId } : {}),
...(surfaces.length > 0 ? { surfaces } : {}),
...(activeScopeBlocks.length > 0 ? { activeScopeBlocks } : {}),
};
}
export interface SnapshotDeps { export interface SnapshotDeps {
cdp: CdpRunner; cdp: CdpRunner;
tabsApi: { tabsApi: {
@@ -1264,7 +1441,9 @@ export async function handleGetHtml(
manager: SessionManager, manager: SessionManager,
params: GetHtmlParams, params: GetHtmlParams,
deps: SnapshotDeps = getDefaultDeps(), deps: SnapshotDeps = getDefaultDeps(),
signal?: AbortSignal,
): Promise<GetHtmlResult | RpcError> { ): Promise<GetHtmlResult | RpcError> {
if (signal?.aborted) return cancelled("get_html");
const ctxOrErr = lookupSession(manager, params, "get_html"); const ctxOrErr = lookupSession(manager, params, "get_html");
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr; const ctx = ctxOrErr;
@@ -1276,21 +1455,31 @@ export async function handleGetHtml(
"get_html", "get_html",
); );
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
if (signal?.aborted) return cancelled("get_html");
const dialogCursor = markDialogCursor(deps.cdp, target.tabId); const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
const maxBytes = const maxBytes =
params.max_bytes && params.max_bytes > 0 ? params.max_bytes : DEFAULT_GET_HTML_MAX_BYTES; params.max_bytes && params.max_bytes > 0 ? params.max_bytes : DEFAULT_GET_HTML_MAX_BYTES;
try { try {
throwIfAborted(signal, "get_html");
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url); await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
throwIfAborted(signal, "get_html");
let html: string; let html: string;
if (params.ref) { if (params.ref) {
const resolved = resolveSnapshotRef(ctx, params.ref, target.tabId); const resolved = resolveSnapshotRef(ctx, params.ref, target.tabId);
if (isRpcError(resolved)) return resolved; if (isRpcError(resolved)) return resolved;
const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", { const resp = await sendToCdpTarget<{ outerHTML?: string }>(
backendNodeId: resolved.backendNodeId, deps.cdp,
}); {
tabId: target.tabId,
...(resolved.cdpSessionId ? { sessionId: resolved.cdpSessionId } : {}),
},
"DOM.getOuterHTML",
{ backendNodeId: resolved.backendNodeId },
);
throwIfAborted(signal, "get_html");
html = resp.outerHTML ?? ""; html = resp.outerHTML ?? "";
} else { } else {
const doc = await deps.cdp.send<{ root?: { nodeId?: number } }>( const doc = await deps.cdp.send<{ root?: { nodeId?: number } }>(
@@ -1298,6 +1487,7 @@ export async function handleGetHtml(
"DOM.getDocument", "DOM.getDocument",
{ depth: 0 }, { depth: 0 },
); );
throwIfAborted(signal, "get_html");
const nodeId = doc.root?.nodeId; const nodeId = doc.root?.nodeId;
if (typeof nodeId !== "number") { if (typeof nodeId !== "number") {
return { return {
@@ -1308,6 +1498,7 @@ export async function handleGetHtml(
const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", { const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", {
nodeId, nodeId,
}); });
throwIfAborted(signal, "get_html");
html = resp.outerHTML ?? ""; html = resp.outerHTML ?? "";
} }
const originalBytes = utf8ByteLength(html); const originalBytes = utf8ByteLength(html);
@@ -1319,6 +1510,7 @@ export async function handleGetHtml(
tab_id: target.tabId, tab_id: target.tabId,
}); });
} catch (err) { } catch (err) {
if (isAbortError(err)) return cancelled("get_html");
return { return {
code: "cdp_failed", code: "cdp_failed",
message: err instanceof Error ? err.message : String(err), message: err instanceof Error ? err.message : String(err),
@@ -1326,6 +1518,58 @@ export async function handleGetHtml(
} }
} }
function emptyCapturedViewModel(viewport = { width: 0, height: 0 }): CapturedViewModel {
return { viewport, nodes: [], iframeNodes: new Map(), excludedBackendNodeIds: new Set() };
}
interface LayoutMetricsViewportReply {
cssLayoutViewport?: { clientWidth?: number; clientHeight?: number };
layoutViewport?: { clientWidth?: number; clientHeight?: number };
}
async function fallbackCapturedViewModel(
cdp: CdpRunner,
tabId: number,
signal?: AbortSignal,
): Promise<CapturedViewModel> {
throwIfAborted(signal, "observation");
let viewport = { width: 0, height: 0 };
try {
const metrics = await cdp.send<LayoutMetricsViewportReply>(tabId, "Page.getLayoutMetrics", {});
throwIfAborted(signal, "observation");
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
viewport = {
width: vpSrc.clientWidth ?? 0,
height: vpSrc.clientHeight ?? 0,
};
} catch (err) {
if (isAbortError(err)) throw err;
// viewport stays zero-sized
}
const excludedBackendNodeIds = await collectOverlayExcludedBackendIds(cdp, tabId, signal);
throwIfAborted(signal, "observation");
return { ...emptyCapturedViewModel(viewport), excludedBackendNodeIds };
}
async function captureForVom(
cdp: CdpRunner,
tabId: number,
conditionalSurfaceProbe: boolean,
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>,
signal?: AbortSignal,
): Promise<CapturedViewModel> {
try {
return await captureViewModel(cdp, tabId, {
conditionalSurfaceProbe,
hoverProbeBypassOverlay,
signal,
});
} catch (err) {
if (isAbortError(err)) throw err;
return fallbackCapturedViewModel(cdp, tabId, signal);
}
}
async function handleVomObservation( async function handleVomObservation(
manager: SessionManager, manager: SessionManager,
params: SnapshotParams | ObserveParams, params: SnapshotParams | ObserveParams,
@@ -1333,7 +1577,9 @@ async function handleVomObservation(
effect: ToolEffect, effect: ToolEffect,
conditionalSurfaceProbe: boolean, conditionalSurfaceProbe: boolean,
deps: SnapshotDeps = getDefaultDeps(), deps: SnapshotDeps = getDefaultDeps(),
signal?: AbortSignal,
): Promise<SnapshotResult | ObserveResult | RpcError> { ): Promise<SnapshotResult | ObserveResult | RpcError> {
if (signal?.aborted) return cancelled(toolName);
const ctxOrErr = lookupSession(manager, params, toolName); const ctxOrErr = lookupSession(manager, params, toolName);
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr; const ctx = ctxOrErr;
@@ -1345,25 +1591,59 @@ async function handleVomObservation(
toolName, toolName,
); );
if (isRpcError(target)) return target; if (isRpcError(target)) return target;
if (signal?.aborted) return cancelled(toolName);
const denied = enforceToolTargetScope(ctx, target, effect, toolName); const denied = enforceToolTargetScope(ctx, target, effect, toolName);
if (denied) return denied; if (denied) return denied;
const dialogCursor = markDialogCursor(deps.cdp, target.tabId); const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
try { try {
throwIfAborted(signal, toolName);
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
throwIfAborted(signal, toolName);
const effectiveConditionalSurfaceProbe = const effectiveConditionalSurfaceProbe =
deps.conditionalSurfaceProbe ?? conditionalSurfaceProbe; deps.conditionalSurfaceProbe ?? conditionalSurfaceProbe;
const rendered = await captureVomObservation(deps.cdp, target.tabId, target.url, { const captured = await captureForVom(
deps.cdp,
target.tabId,
effectiveConditionalSurfaceProbe,
deps.hoverProbeBypassOverlay,
signal,
);
throwIfAborted(signal, toolName);
const frameDocuments = await captureFrameData<CdpAxNode>(
deps.cdp,
target.tabId,
captured,
signal,
);
throwIfAborted(signal, toolName);
const scene =
captured.rootFrameId && captured.frameNodes?.size
? buildFrameVomScene(frameDocuments, captured, { pageUrl: target.url })
: buildVomScene(frameDocuments[0]?.axNodes ?? [], captured, { pageUrl: target.url });
const rendered = renderVom(scene, {
maxDepth: params.max_depth, maxDepth: params.max_depth,
maxTokens: params.max_tokens, maxTokens: params.max_tokens,
activeRegionPolicy: true, activeRegionPolicy: true,
conditionalSurfaceProbe: effectiveConditionalSurfaceProbe,
hoverProbeBypassOverlay: deps.hoverProbeBypassOverlay,
}); });
throwIfAborted(signal, toolName);
const targetByFrameId = new Map(
frameDocuments.map((document) => [document.frameId, document.target]),
);
ctx.refStore.replace( ctx.refStore.replace(
rendered.refs.map( rendered.refs.map((ref) => {
(r) => [r.ref, { backendNodeId: r.backendNodeId, tabId: target.tabId }] as const, const refTarget = ref.frameId ? targetByFrameId.get(ref.frameId) : undefined;
), return [
ref.ref,
{
backendNodeId: ref.backendNodeId,
tabId: target.tabId,
...(ref.frameId ? { frameId: ref.frameId } : {}),
...(refTarget?.sessionId ? { cdpSessionId: refTarget.sessionId } : {}),
},
] as const;
}),
); );
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
text: rendered.text, text: rendered.text,
@@ -1373,7 +1653,7 @@ async function handleVomObservation(
...(toolName === "observe" && (params as ObserveParams).debug_surfaces ...(toolName === "observe" && (params as ObserveParams).debug_surfaces
? { ? {
debug: { debug: {
surface_probes: (rendered.surfaceProbes ?? []).map((probe) => ({ surface_probes: (captured.surfaceProbes ?? []).map((probe) => ({
trigger_backend_node_id: probe.triggerBackendNodeId, trigger_backend_node_id: probe.triggerBackendNodeId,
...(probe.triggerPoint ? { trigger_point: probe.triggerPoint } : {}), ...(probe.triggerPoint ? { trigger_point: probe.triggerPoint } : {}),
trigger_action: probe.triggerAction, trigger_action: probe.triggerAction,
@@ -1385,6 +1665,7 @@ async function handleVomObservation(
: {}), : {}),
}); });
} catch (err) { } catch (err) {
if (isAbortError(err)) return cancelled(toolName);
return { return {
code: "cdp_failed", code: "cdp_failed",
message: err instanceof Error ? err.message : String(err), message: err instanceof Error ? err.message : String(err),
@@ -1396,14 +1677,16 @@ export async function handleSnapshot(
manager: SessionManager, manager: SessionManager,
params: SnapshotParams, params: SnapshotParams,
deps: SnapshotDeps = getDefaultDeps(), deps: SnapshotDeps = getDefaultDeps(),
signal?: AbortSignal,
): Promise<SnapshotResult | RpcError> { ): Promise<SnapshotResult | RpcError> {
return handleVomObservation(manager, params, "snapshot", "passive_read", false, deps); return handleVomObservation(manager, params, "snapshot", "passive_read", false, deps, signal);
} }
export async function handleObserve( export async function handleObserve(
manager: SessionManager, manager: SessionManager,
params: ObserveParams, params: ObserveParams,
deps: SnapshotDeps = getDefaultDeps(), deps: SnapshotDeps = getDefaultDeps(),
signal?: AbortSignal,
): Promise<ObserveResult | RpcError> { ): Promise<ObserveResult | RpcError> {
return handleVomObservation(manager, params, "observe", "transient_input", true, deps); return handleVomObservation(manager, params, "observe", "transient_input", true, deps, signal);
} }
+79 -549
View File
@@ -7,53 +7,21 @@ import {
isRecordQueryMessage, isRecordQueryMessage,
isRecordStepMessage, isRecordStepMessage,
RECORD_CANCEL, RECORD_CANCEL,
RECORD_CAPTURE_STATUS,
RECORD_START, RECORD_START,
RECORD_STEP, RECORD_STEP,
RECORD_STOP, RECORD_STOP,
type RecordCancelMessage, type RecordCancelMessage,
type RecordCaptureStatusMessage,
type RecordFinishMessage, type RecordFinishMessage,
type RecordQueryResponse, type RecordQueryResponse,
type RecordStartAck, type RecordStartAck,
type RecordStartMessage, type RecordStartMessage,
type RecordStepPayload,
type RecordStopMessage, type RecordStopMessage,
} from "@/lib/record-bridge"; } from "@/lib/record-bridge";
import {
armAllDocumentsForTab,
armDocumentCapture,
cancelDocumentCapture,
type ArmedDocument,
listInjectableFrames,
type RecordFrameSendDeps,
stopDocumentCapture,
} from "@/lib/record-frame-arming";
import { DEFAULT_MAX_PAGE_TOKENS } from "@/lib/record-constants";
import {
applyTargetMatching,
buildTraceV3,
cancelPendingSettles,
captureAndRegisterObservation,
clearPendingRedirectLanding,
createObservationState,
flushPendingRedirectLanding,
flushPendingSettles,
inferMissingPostStates,
type RecordingObservationState,
rememberStepOnPage,
scheduleDraftSettle,
scheduleRedirectLandingFlush,
settleUnsettledDrafts,
} from "@/lib/record-observation";
import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording-step-buffer"; import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording-step-buffer";
import { reduceTraceSteps, resolveTraceStartUrl } from "@/lib/trace-reducer";
import type { SessionManager } from "@/session-manager/manager"; import type { SessionManager } from "@/session-manager/manager";
import { EXTENSION_VERSION } from "@/transport/handshake";
import type { import type {
DraftTraceStep, DraftTraceStep,
FrameCaptureFailure,
FrameCaptureInfo,
FrameCaptureStatus,
RecordAwaitParams, RecordAwaitParams,
RecordAwaitResult, RecordAwaitResult,
RecordStartParams, RecordStartParams,
@@ -61,7 +29,6 @@ import type {
RecordStopParams, RecordStopParams,
RecordStopResult, RecordStopResult,
RpcError, RpcError,
StopReason,
Trace, Trace,
} from "@/transport/types"; } from "@/transport/types";
import { handleNavigate } from "./navigation"; import { handleNavigate } from "./navigation";
@@ -91,37 +58,6 @@ interface ActiveRecording {
currentUrl?: string; currentUrl?: string;
pendingNavigation: boolean; pendingNavigation: boolean;
pendingNavigationDeadline?: number; pendingNavigationDeadline?: number;
observation: RecordingObservationState;
stoppedBy: StopReason;
maxPageTokens: number;
redactValues: boolean;
/** Documents whose capture acknowledged START and still need STOP. */
armedDocuments: Map<string, ArmedDocument>;
frameCaptureFailures: FrameCaptureFailure[];
frameCaptureStatus: FrameCaptureStatus;
/** When false, ignore subframe rearm requests during finish. */
acceptingNewFrames: boolean;
/** Rearms already sending START when finish begins. */
rearmCallbacks: Set<Promise<void>>;
/** Navigation callbacks tracked from event receipt through action enqueue. */
navigationCallbacks: Set<Promise<void>>;
/** Synchronous intake gate closed only after finish drains to stability. */
acceptingNavigation: boolean;
/**
* Serializes step appends with navigation observation so a click is always
* in `steps` before a same-turn `webNavigation` tries to annotate it.
*/
actionQueue: Promise<void>;
/**
* CDP runner captured at `record_start`. The message listeners are attached
* once at service-worker startup, where no runner exists yet, so observation
* capture must go through the recording instead of the listener deps.
*/
cdp?: CdpRunner;
}
function enqueueRecordingAction(recording: ActiveRecording, task: () => Promise<void>): void {
recording.actionQueue = recording.actionQueue.then(task, task).catch(() => {});
} }
const recordings = new Map<string, ActiveRecording>(); const recordings = new Map<string, ActiveRecording>();
@@ -142,9 +78,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
/** Recording producer version mirrored into trace.recorder.bsk. */
export const BSK_TRACE_VERSION = EXTENSION_VERSION;
/** Injectable http(s) landing page when `tool.record_start` omits `url`. */ /** Injectable http(s) landing page when `tool.record_start` omits `url`. */
export const RECORD_DEFAULT_START_URL = "https://example.com/"; export const RECORD_DEFAULT_START_URL = "https://example.com/";
@@ -222,95 +155,16 @@ async function sendRecordStartWithAck(
} }
function buildTrace(recording: ActiveRecording): Trace { function buildTrace(recording: ActiveRecording): Trace {
const frameCapture: FrameCaptureInfo | undefined = const { pages, steps } = reduceTraceSteps(recording.steps, recording.startUrl);
recording.frameCaptureFailures.length > 0 || recording.frameCaptureStatus === "partial" const startUrl = resolveTraceStartUrl(recording.steps, recording.startUrl, pages);
? { return {
status: recording.frameCaptureStatus,
...(recording.frameCaptureFailures.length > 0
? { failures: recording.frameCaptureFailures }
: {}),
}
: undefined;
return buildTraceV3({
obs: recording.observation,
steps: recording.steps,
startedAt: recording.startedAt,
...(recording.purpose ? { purpose: recording.purpose } : {}), ...(recording.purpose ? { purpose: recording.purpose } : {}),
startUrl: recording.startUrl, recorded_at: new Date().toISOString(),
stoppedBy: recording.stoppedBy, started_at: recording.startedAt,
bskVersion: BSK_TRACE_VERSION, entry: { start_url: startUrl },
...(frameCapture ? { frameCapture } : {}), pages,
}); steps,
}
function recordFrameFailure(recording: ActiveRecording, failure: FrameCaptureFailure): void {
recording.frameCaptureFailures.push(failure);
recording.frameCaptureStatus = "partial";
}
function enrichRecordedStep(
recording: ActiveRecording,
step: RecordStepPayload,
): RecordStepPayload {
const enriched: RecordStepPayload = { ...step };
if (enriched.geometry && enriched.documentToken) {
const owner = recording.observation.documentTokenOwners.get(enriched.documentToken);
if (owner !== undefined) {
enriched.geometry = { ...enriched.geometry, ownerFrameBackendNodeId: owner };
}
}
if (!enriched.page_url && recording.currentUrl) {
enriched.page_url = recording.currentUrl;
}
return enriched;
}
async function publishCaptureStatus(
recording: ActiveRecording,
deps: RecordDeps,
): Promise<void> {
if (!deps.broadcastCaptureStatus) return;
const msg: RecordCaptureStatusMessage = {
type: RECORD_CAPTURE_STATUS,
status: recording.frameCaptureStatus,
...(recording.frameCaptureFailures.length > 0
? { failures: recording.frameCaptureFailures }
: {}),
}; };
try {
await deps.broadcastCaptureStatus(recording.tabId, msg);
} catch {
// Overlay is best-effort.
}
}
async function processRecordedStep(
recording: ActiveRecording,
deps: RecordDeps,
draftIndex: number,
): Promise<void> {
const cdp = recording.cdp ?? deps.cdp;
if (!cdp) return;
const draft = recording.steps[draftIndex];
if (!draft) return;
const stepId = draftIndex + 1;
if (draft.op === "navigate") {
draft.preStateId = recording.observation.lastSettled?.stateId;
} else {
applyTargetMatching(recording.observation, draft, stepId);
}
rememberStepOnPage(recording.observation, draft, stepId);
scheduleDraftSettle(
recording.observation,
draftIndex,
stepId,
cdp,
recording.tabId,
deps.tabsApi,
recording.steps,
);
} }
export interface RecordDeps { export interface RecordDeps {
@@ -319,67 +173,17 @@ export interface RecordDeps {
tabId: number, tabId: number,
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage, msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
): Promise<unknown>; ): Promise<unknown>;
sendToDocument?(
tabId: number,
documentId: string,
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
): Promise<unknown>;
getAllFrames?(tabId: number): Promise<
Array<{
frameId: number;
documentId?: string;
url?: string;
parentFrameId: number;
}>
>;
broadcastCaptureStatus?(
tabId: number,
msg: RecordCaptureStatusMessage,
): Promise<void>;
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>; bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
cdp?: CdpRunner; cdp?: CdpRunner;
signal?: AbortSignal; signal?: AbortSignal;
} }
function frameSendDeps(deps: RecordDeps): RecordFrameSendDeps {
return {
sendToDocument: (tabId, documentId, msg) => {
if (deps.sendToDocument) return deps.sendToDocument(tabId, documentId, msg);
return deps.sendToTab(tabId, msg);
},
getAllFrames: async (tabId) => {
if (deps.getAllFrames) return deps.getAllFrames(tabId);
if (!chrome.webNavigation?.getAllFrames) return [];
const frames = await chrome.webNavigation.getAllFrames({ tabId });
return (frames ?? []).map((frame) => ({
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
parentFrameId: frame.parentFrameId,
}));
},
};
}
let defaultDeps: RecordDeps | null = null; let defaultDeps: RecordDeps | null = null;
function getDefaultDeps(): RecordDeps { function getDefaultDeps(): RecordDeps {
if (!defaultDeps) { if (!defaultDeps) {
defaultDeps = { defaultDeps = {
tabsApi: chromeTabsApi, tabsApi: chromeTabsApi,
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg), sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
sendToDocument: (tabId, documentId, msg) =>
chrome.tabs.sendMessage(tabId, msg, { documentId }),
getAllFrames: async (tabId) => {
if (!chrome.webNavigation?.getAllFrames) return [];
const frames = await chrome.webNavigation.getAllFrames({ tabId });
return (frames ?? []).map((frame) => ({
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
parentFrameId: frame.parentFrameId,
}));
},
broadcastCaptureStatus: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg, { frameId: 0 }),
}; };
} }
return defaultDeps; return defaultDeps;
@@ -437,29 +241,16 @@ export function releaseBrowserObservationListenersIfIdle(): void {
detachBrowserObservation = null; detachBrowserObservation = null;
} }
export function attachRecordStepListener(deps: RecordDeps = getDefaultDeps()): () => void { export function attachRecordStepListener(): () => void {
const listener = ( const listener = (
message: unknown, message: unknown,
sender: chrome.runtime.MessageSender, _sender: chrome.runtime.MessageSender,
_sendResponse: () => void, _sendResponse: () => void,
) => { ) => {
if (!isRecordStepMessage(message)) return false; if (!isRecordStepMessage(message)) return false;
for (const recording of recordings.values()) { for (const recording of recordings.values()) {
if (recording.requestId !== message.requestId) continue; if (recording.requestId !== message.requestId) continue;
enqueueRecordingAction(recording, async () => { appendRecordedPayload(recording, message.step);
const cdp = recording.cdp ?? deps.cdp;
await flushPendingRedirectLanding(
recording.observation,
recording.steps,
cdp,
recording.tabId,
deps.tabsApi,
{ cancelled: () => recording.settled },
);
const enriched = enrichRecordedStep(recording, message.step);
const draftIndex = appendRecordedPayload(recording, enriched);
if (draftIndex !== null) await processRecordedStep(recording, deps, draftIndex);
});
return false; return false;
} }
return false; return false;
@@ -486,11 +277,7 @@ export function attachRecordFinishListener(deps: RecordDeps = getDefaultDeps()):
function findRecordingByTabId(tabId: number): ActiveRecording | null { function findRecordingByTabId(tabId: number): ActiveRecording | null {
for (const recording of recordings.values()) { for (const recording of recordings.values()) {
if (recording.settled) continue; if (recording.tabId === tabId && !recording.settled) return recording;
if (recording.tabId === tabId) return recording;
for (const armed of recording.armedDocuments.values()) {
if (armed.tabId === tabId) return recording;
}
} }
return null; return null;
} }
@@ -538,135 +325,71 @@ async function clearRearmTimersForRecording(
} }
} }
async function stopRecordingOnAllDocuments( async function stopRecordingOnAllAgentTabs(
recording: ActiveRecording, recording: ActiveRecording,
deps: RecordDeps, deps: RecordDeps,
): Promise<void> { ): Promise<void> {
recording.acceptingNewFrames = false;
const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId }; const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId };
const frameDeps = frameSendDeps(deps); let tabIds = [recording.tabId];
const armed = [...recording.armedDocuments.values()]; try {
for (const doc of armed) { const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
const result = await stopDocumentCapture(doc, stopMsg, frameDeps); tabIds = [
if ("ok" in result) { ...new Set([
recording.armedDocuments.delete(doc.documentId); recording.tabId,
continue; ...tabs.flatMap((tab) => (typeof tab.id === "number" ? [tab.id] : [])),
]),
];
} catch {
// Fall back to the current recording tab.
} }
recordFrameFailure(recording, result);
for (const tabId of tabIds) {
try {
const response = await deps.sendToTab(tabId, stopMsg);
if (tabId === recording.tabId && !isRecordStartAck(response)) {
throw new Error("content script did not confirm recorded steps");
} }
if (recording.armedDocuments.size > 0) { } catch {
for (const doc of [...recording.armedDocuments.values()]) { if (tabId === recording.tabId) {
recordFrameFailure( throw new Error("failed to flush recorded steps");
recording,
{
reason: "flush_failed",
frame_id: doc.frameId,
document_id: doc.documentId,
url: doc.url,
detail: "document still armed after STOP",
},
);
} }
recording.armedDocuments.clear();
} }
await publishCaptureStatus(recording, deps);
if (deps.bypassOverlay) { if (deps.bypassOverlay) {
try { try {
await deps.bypassOverlay(recording.tabId, false); await deps.bypassOverlay(tabId, false);
} catch { } catch {
// Best-effort cleanup. // Best-effort cleanup.
} }
} }
}
async function cancelRecordingOnAllDocuments(
recording: ActiveRecording,
deps: RecordDeps,
): Promise<void> {
const cancelMsg: RecordCancelMessage = { type: RECORD_CANCEL, requestId: recording.requestId };
const frameDeps = frameSendDeps(deps);
for (const doc of [...recording.armedDocuments.values()]) {
await cancelDocumentCapture(doc, cancelMsg, frameDeps);
} }
recording.armedDocuments.clear();
} }
async function rearmRecordingDocuments( async function rearmRecording(
recording: ActiveRecording, recording: ActiveRecording,
targetTabId: number, targetTabId: number,
deps: RecordDeps, deps: RecordDeps,
targetDocumentId?: string,
): Promise<boolean> { ): Promise<boolean> {
let resolveTracked!: () => void; // Do NOT toggle automation-bypass here: each retry used to increment the
const tracked = new Promise<void>((resolve) => { // content-script counter, and a single stop decrement left the ControlOverlay
resolveTracked = resolve; // stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
}); // already hides the control chrome while activeRecord is set.
recording.rearmCallbacks.add(tracked); for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
const frameDeps = frameSendDeps(deps);
try {
const attemptLimit = targetDocumentId ? 1 : RECORD_REARM_MAX_ATTEMPTS;
for (let attempt = 0; attempt < attemptLimit; attempt += 1) {
if (recording.settled || recording.finishing || !recording.acceptingNewFrames) return false;
const { injectable, failures } = await listInjectableFrames(targetTabId, frameDeps);
for (const failure of failures) recordFrameFailure(recording, failure);
const targets = targetDocumentId
? injectable.filter((frame) => frame.documentId === targetDocumentId)
: injectable.filter((frame) => !recording.armedDocuments.has(frame.documentId ?? ""));
if (targets.length === 0) {
if (targetDocumentId) {
recordFrameFailure(recording, {
reason: "rearm_failed",
frame_id: 0,
document_id: targetDocumentId,
detail: "document not injectable",
});
await publishCaptureStatus(recording, deps);
return false;
}
recording.tabId = targetTabId;
return injectable.some((frame) => recording.armedDocuments.has(frame.documentId ?? ""));
}
let armedAny = false;
for (const frame of targets) {
const startMsg: RecordStartMessage = { const startMsg: RecordStartMessage = {
type: RECORD_START, type: RECORD_START,
requestId: recording.requestId, requestId: recording.requestId,
startedAtMs: recording.startedAtMs, startedAtMs: recording.startedAtMs,
showOverlay: frame.frameId === 0,
frameMode: frame.frameId === 0 ? "top" : "child",
}; };
const result = await armDocumentCapture(targetTabId, frame, startMsg, frameDeps); try {
if (result.armed) { await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
recording.armedDocuments.set(result.armed.documentId, result.armed);
armedAny = true;
}
if (result.failure) recordFrameFailure(recording, result.failure);
}
if (armedAny) {
recording.tabId = targetTabId; recording.tabId = targetTabId;
await publishCaptureStatus(recording, deps);
if (deps.cdp && recording.acceptingNewFrames) {
void captureAndRegisterObservation(
recording.observation,
deps.cdp,
targetTabId,
deps.tabsApi,
).catch(() => {});
}
return true; return true;
} } catch {
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) { if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
await sleep(RECORD_REARM_RETRY_DELAY_MS); await sleep(RECORD_REARM_RETRY_DELAY_MS);
} }
} }
await publishCaptureStatus(recording, deps);
return false;
} finally {
recording.rearmCallbacks.delete(tracked);
resolveTracked();
} }
return false;
} }
function scheduleRearmForTab(tabId: number, deps: RecordDeps): void { function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
@@ -678,7 +401,7 @@ function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
rearmTimers.delete(tabId); rearmTimers.delete(tabId);
void (async () => { void (async () => {
const current = await findRecordingForTab(tabId, deps); const current = await findRecordingForTab(tabId, deps);
if (current) await rearmRecordingDocuments(current, tabId, deps); if (current) await rearmRecording(current, tabId, deps);
})(); })();
}, RECORD_REARM_DEBOUNCE_MS), }, RECORD_REARM_DEBOUNCE_MS),
); );
@@ -709,86 +432,11 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): ()
} }
export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps()): () => void { export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps()): () => void {
const observeMainFrame = ( const observeMainFrame = (tabId: number, url?: string, causedByAction?: boolean) => {
tabId: number,
url?: string,
causedByAction?: boolean,
transitionType?: string,
transitionQualifiers?: string[],
) => {
if (!url) return;
const direct = findRecordingByTabId(tabId);
const candidates = direct
? direct.acceptingNavigation
? [direct]
: []
: [...recordings.values()].filter(
(recording) => !recording.settled && recording.acceptingNavigation,
);
if (candidates.length === 0) return;
let resolveTracked!: () => void;
const tracked = new Promise<void>((resolve) => {
resolveTracked = resolve;
});
for (const candidate of candidates) candidate.navigationCallbacks.add(tracked);
void (async () => { void (async () => {
try {
const recording = await findRecordingForTab(tabId, deps); const recording = await findRecordingForTab(tabId, deps);
if (!recording || !recording.acceptingNavigation || !candidates.includes(recording)) { if (recording && url) {
return; observeRecordedNavigation(recording, url, causedByAction);
}
enqueueRecordingAction(recording, async () => {
const result = observeRecordedNavigation(
recording,
url,
causedByAction,
transitionType,
transitionQualifiers,
);
const cdp = recording.cdp ?? deps.cdp;
if (!cdp) return;
if (result.kind === "coalesce_redirect") {
scheduleRedirectLandingFlush(
recording.observation,
recording.steps,
cdp,
tabId,
deps.tabsApi,
result.url,
{ cancelled: () => recording.settled },
);
return;
}
if (result.kind === "noop") return;
// A concrete navigation supersedes any in-flight redirect coalesce.
clearPendingRedirectLanding(recording.observation);
const draftIndex = result.index;
const lastDraft = recording.steps[draftIndex];
if (!lastDraft) return;
if (result.kind === "appended") {
lastDraft.preStateId = recording.observation.lastSettled?.stateId;
rememberStepOnPage(recording.observation, lastDraft, draftIndex + 1);
}
scheduleDraftSettle(
recording.observation,
draftIndex,
draftIndex + 1,
cdp,
tabId,
deps.tabsApi,
recording.steps,
);
});
} finally {
for (const candidate of candidates) candidate.navigationCallbacks.delete(tracked);
resolveTracked();
} }
})(); })();
}; };
@@ -799,36 +447,29 @@ export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps
})(); })();
}; };
const onSubFrameComplete = (tabId: number, documentId?: string) => {
if (!documentId) return;
void (async () => {
const recording = await findRecordingForTab(tabId, deps);
if (!recording || !recording.acceptingNewFrames) return;
await rearmRecordingDocuments(recording, tabId, deps, documentId);
})();
};
if (chrome.webNavigation?.onCompleted) { if (chrome.webNavigation?.onCompleted) {
const completedListener = ( const completedListener = (
details: chrome.webNavigation.WebNavigationFramedCallbackDetails, details: chrome.webNavigation.WebNavigationFramedCallbackDetails,
) => { ) => {
if (details.frameId === 0) { if (details.frameId !== 0) return;
onMainFrameComplete(details.tabId, details.url); onMainFrameComplete(details.tabId, details.url);
return;
}
onSubFrameComplete(details.tabId, details.documentId);
}; };
const committedListener = ( const committedListener = (
details: chrome.webNavigation.WebNavigationTransitionCallbackDetails, details: chrome.webNavigation.WebNavigationTransitionCallbackDetails,
) => { ) => {
if (details.frameId !== 0) return; if (details.frameId !== 0) return;
observeMainFrame( const causedByAction =
details.tabId, details.transitionType === "link" || details.transitionType === "form_submit"
details.url, ? true
undefined, : details.transitionType === "typed" ||
details.transitionType, details.transitionType === "auto_bookmark" ||
details.transitionQualifiers, details.transitionType === "generated" ||
); details.transitionType === "keyword" ||
details.transitionType === "keyword_generated" ||
details.transitionType === "reload"
? false
: undefined;
observeMainFrame(details.tabId, details.url, causedByAction);
}; };
chrome.webNavigation.onCompleted.addListener(completedListener); chrome.webNavigation.onCompleted.addListener(completedListener);
chrome.webNavigation.onCommitted?.addListener(committedListener); chrome.webNavigation.onCommitted?.addListener(committedListener);
@@ -846,46 +487,6 @@ export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps
return () => chrome.tabs.onUpdated.removeListener(listener); return () => chrome.tabs.onUpdated.removeListener(listener);
} }
const MAX_FINISH_DRAIN_ROUNDS = 10;
async function drainRecordingToStability(
recording: ActiveRecording,
deps: RecordDeps,
): Promise<boolean> {
const cdp = recording.cdp ?? deps.cdp;
for (let round = 0; round < MAX_FINISH_DRAIN_ROUNDS; round += 1) {
await Promise.all([...recording.navigationCallbacks]);
const actionTail = recording.actionQueue;
await actionTail;
await flushPendingRedirectLanding(
recording.observation,
recording.steps,
cdp,
recording.tabId,
deps.tabsApi,
{ cancelled: () => recording.settled },
);
await flushPendingSettles(recording.observation);
const redirectTail = recording.observation.redirectFlushQueue;
const settleTail = recording.observation.settleQueue;
await Promise.all([redirectTail, settleTail]);
if (
recording.navigationCallbacks.size === 0 &&
recording.actionQueue === actionTail &&
recording.observation.redirectFlushQueue === redirectTail &&
recording.observation.settleQueue === settleTail
) {
// No event or promise continuation can interleave with this synchronous
// check-and-close, so work accepted before the cutoff is fully drained.
recording.acceptingNavigation = false;
return true;
}
}
console.warn("[bsk record] navigation/action queues did not stabilize at stop");
return false;
}
export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()): () => void { export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()): () => void {
const listener = ( const listener = (
message: unknown, message: unknown,
@@ -904,15 +505,11 @@ export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()):
sendResponse({ active: false }); sendResponse({ active: false });
return; return;
} }
await rearmRecordingDocuments(recording, tabId, deps, sender.documentId); await rearmRecording(recording, tabId, deps);
sendResponse({ sendResponse({
active: true, active: true,
requestId: recording.requestId, requestId: recording.requestId,
startedAtMs: recording.startedAtMs, startedAtMs: recording.startedAtMs,
captureStatus: recording.frameCaptureStatus,
...(recording.frameCaptureFailures.length > 0
? { captureFailures: recording.frameCaptureFailures }
: {}),
}); });
})(); })();
return true; return true;
@@ -930,46 +527,23 @@ async function finishRecordingByRequest(
if (recording.requestId !== requestId || recording.settled) continue; if (recording.requestId !== requestId || recording.settled) continue;
const match = await findRecordingForTab(tabId, deps); const match = await findRecordingForTab(tabId, deps);
if (match !== recording) continue; if (match !== recording) continue;
await finishRecording(sessionId, deps, "user_finish"); await finishRecording(sessionId, deps);
return; return;
} }
} }
async function finishRecording( async function finishRecording(sessionId: string, deps: RecordDeps): Promise<Trace | null> {
sessionId: string,
deps: RecordDeps,
stoppedBy: StopReason,
): Promise<Trace | null> {
const recording = recordings.get(sessionId); const recording = recordings.get(sessionId);
if (!recording || recording.settled) return null; if (!recording || recording.settled || recording.finishing) return null;
if (recording.finishing) return recording.finishPromise;
recording.finishing = true; recording.finishing = true;
recording.stoppedBy = stoppedBy;
await clearRearmTimersForRecording(recording, deps); await clearRearmTimersForRecording(recording, deps);
await Promise.all([...recording.rearmCallbacks]);
try { try {
await stopRecordingOnAllDocuments(recording, deps); await stopRecordingOnAllAgentTabs(recording, deps);
} catch { } catch {
recording.finishing = false; recording.finishing = false;
return null; return null;
} }
if (!(await drainRecordingToStability(recording, deps))) {
recording.finishing = false;
return null;
}
cancelPendingSettles(recording.observation);
inferMissingPostStates(recording.steps);
const cdp = recording.cdp ?? deps.cdp;
if (cdp) {
await settleUnsettledDrafts(
recording.observation,
cdp,
recording.tabId,
deps.tabsApi,
recording.steps,
);
}
recording.settled = true; recording.settled = true;
recordings.delete(sessionId); recordings.delete(sessionId);
@@ -1008,8 +582,6 @@ export async function handleRecordStart(
}); });
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL; const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
const startedAtMs = Date.now(); const startedAtMs = Date.now();
const maxPageTokens = params.max_page_tokens;
const redactValues = params.redact_values ?? false;
recordings.set(params.session_id, { recordings.set(params.session_id, {
requestId, requestId,
tabId: target.tabId, tabId: target.tabId,
@@ -1027,30 +599,23 @@ export async function handleRecordStart(
currentUrl: navigateUrl, currentUrl: navigateUrl,
pendingNavigation: false, pendingNavigation: false,
pendingNavigationDeadline: undefined, pendingNavigationDeadline: undefined,
observation: createObservationState({ maxPageTokens, redactValues }),
stoppedBy: "user_finish",
maxPageTokens: maxPageTokens ?? DEFAULT_MAX_PAGE_TOKENS,
redactValues,
armedDocuments: new Map(),
frameCaptureFailures: [],
frameCaptureStatus: "complete",
acceptingNewFrames: true,
rearmCallbacks: new Set(),
navigationCallbacks: new Set(),
acceptingNavigation: true,
actionQueue: Promise.resolve(),
...(deps.cdp ? { cdp: deps.cdp } : {}),
}); });
// Observe navigations for the whole recording lifetime; attach before // Observe navigations for the whole recording lifetime; attach before
// optional navigate so the destination load can rearm capture. // optional navigate so the destination load can rearm capture.
ensureBrowserObservationListeners(deps); ensureBrowserObservationListeners(deps);
const abortPending = async (notifyContent: boolean) => { const abortPending = async (notifyContent: boolean) => {
const pending = recordings.get(params.session_id);
recordings.delete(params.session_id); recordings.delete(params.session_id);
releaseBrowserObservationListenersIfIdle(); releaseBrowserObservationListenersIfIdle();
if (notifyContent && pending) { if (notifyContent) {
await cancelRecordingOnAllDocuments(pending, deps); try {
await deps.sendToTab(target.tabId, {
type: RECORD_CANCEL,
requestId,
});
} catch {
// Content may never have received RECORD_START.
}
} }
if (deps.bypassOverlay) { if (deps.bypassOverlay) {
try { try {
@@ -1153,28 +718,10 @@ export async function handleRecordStart(
if (cancelled) return cancelled; if (cancelled) return cancelled;
} }
const startMsg: RecordStartMessage = { const startMsg: RecordStartMessage = { type: RECORD_START, requestId, startedAtMs };
type: RECORD_START,
requestId,
startedAtMs,
showOverlay: true,
frameMode: "top",
};
try { try {
const frameDeps = frameSendDeps(deps); await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
const { armed, failures } = await armAllDocumentsForTab(target.tabId, startMsg, frameDeps);
for (const failure of failures) recordFrameFailure(active, failure);
for (const doc of armed) active.armedDocuments.set(doc.documentId, doc);
if (active.armedDocuments.size === 0) {
await abortPending(true);
return {
code: "protocol_error",
message:
"failed to start recording in any frame — reload the BrowserSkill extension, then retry",
};
}
await publishCaptureStatus(active, deps);
} catch { } catch {
await abortPending(true); await abortPending(true);
return { return {
@@ -1184,23 +731,6 @@ export async function handleRecordStart(
}; };
} }
if (deps.cdp) {
const activeRecording = recordings.get(params.session_id);
if (activeRecording) {
try {
await captureAndRegisterObservation(
activeRecording.observation,
deps.cdp,
target.tabId,
deps.tabsApi,
startUrl,
);
} catch {
// Proceed without initial observation; steps may be dropped by reducer.
}
}
}
{ {
const cancelled = await abortIfCancelled(true); const cancelled = await abortIfCancelled(true);
if (cancelled) return cancelled; if (cancelled) return cancelled;
@@ -1225,7 +755,7 @@ export async function handleRecordStop(
}; };
} }
const trace = await finishRecording(params.session_id, deps, "cli_stop"); const trace = await finishRecording(params.session_id, deps);
if (!trace) { if (!trace) {
return { return {
code: "protocol_error", code: "protocol_error",
+21 -1
View File
@@ -1,5 +1,6 @@
import type { SessionManager } from "@/session-manager/manager"; import { type SessionManager, SessionStartCleanupError } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types"; import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
import { clearRecordingForSession } from "./record"; import { clearRecordingForSession } from "./record";
import { isRpcError } from "./shared"; import { isRpcError } from "./shared";
import { returnBorrowedTab, type TabManagementDeps } from "./tabs"; import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
@@ -58,6 +59,10 @@ export interface SessionStartResult {
agent_window_id?: number; agent_window_id?: number;
} }
export interface SessionStartDeps {
signal?: AbortSignal;
}
export interface SessionStopParams { export interface SessionStopParams {
session_id: string; session_id: string;
} }
@@ -91,6 +96,7 @@ export interface SessionStopDeps {
export async function handleSessionStart( export async function handleSessionStart(
manager: SessionManager, manager: SessionManager,
params: SessionStartParams, params: SessionStartParams,
deps: SessionStartDeps = {},
): Promise<SessionStartResult | RpcError> { ): Promise<SessionStartResult | RpcError> {
if (!params?.session_id) { if (!params?.session_id) {
return { return {
@@ -104,9 +110,23 @@ export async function handleSessionStart(
const ctx = await manager.start(params.session_id, { const ctx = await manager.start(params.session_id, {
size: sizeOrErr, size: sizeOrErr,
focused: params.focused, focused: params.focused,
signal: deps.signal,
}); });
return { agent_window_id: ctx.agentWindowId }; return { agent_window_id: ctx.agentWindowId };
} catch (err) { } catch (err) {
if (err instanceof SessionStartCleanupError) {
return rpcError("protocol_error", "cleanup_failed", err.message, {
resource_type: "agent_window",
resource_id: err.windowId,
});
}
if (
typeof err === "object" &&
err !== null &&
(err as { name?: string }).name === "AbortError"
) {
return { code: "cancelled", message: "session_start aborted" };
}
// chrome.windows.create / SessionManager failures are not CDP // chrome.windows.create / SessionManager failures are not CDP
// failures (§4.5 reserves cdp_failed for raw CDP errors). Surface // failures (§4.5 reserves cdp_failed for raw CDP errors). Surface
// them as protocol_error so the CLI maps to the right exit code // them as protocol_error so the CLI maps to the right exit code
+25 -2
View File
@@ -6,7 +6,8 @@
// exactly the same sandbox + visibility rules as the M6 observation // exactly the same sandbox + visibility rules as the M6 observation
// handlers (review parity). // handlers (review parity).
import type { DialogCursor } from "@/browser-driver/chromium-cdp"; import type { CdpDebuggee, DialogCursor } from "@/browser-driver/chromium-cdp";
import type { CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
import type { SessionContext, SessionManager } from "@/session-manager/manager"; import type { SessionContext, SessionManager } from "@/session-manager/manager";
import { normaliseRef } from "@/session-manager/ref-store"; import { normaliseRef } from "@/session-manager/ref-store";
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types"; import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
@@ -51,9 +52,12 @@ export type { DialogCursor };
*/ */
export interface CdpRunner { export interface CdpRunner {
send<T = unknown>(tabId: number, method: string, params?: object): Promise<T>; send<T = unknown>(tabId: number, method: string, params?: object): Promise<T>;
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
detach?(tabId: number): Promise<void>;
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>; ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
trackSessionTab?(sessionId: string, tabId: number): void; trackSessionTab?(sessionId: string, tabId: number): void;
onEvent?(handler: (source: chrome.debugger.Debuggee, method: string, params: unknown) => void): { onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
dispose(): void; dispose(): void;
}; };
dialogCursor?(tabId: number): DialogCursor; dialogCursor?(tabId: number): DialogCursor;
@@ -68,6 +72,25 @@ export interface CdpRunner {
): ConsoleResult; ): ConsoleResult;
} }
export function sendToCdpTarget<T = unknown>(
cdp: CdpRunner,
target: CdpTarget,
method: string,
params?: object,
): Promise<T> {
if (target.sessionId && cdp.sendToTarget) {
return cdp.sendToTarget<T>(target, method, params);
}
return cdp.send<T>(target.tabId, method, params);
}
export function cdpRunnerForTarget(cdp: CdpRunner, target: CdpTarget): CdpRunner {
return {
send: <T = unknown>(_tabId: number, method: string, params?: object) =>
sendToCdpTarget<T>(cdp, target, method, params),
};
}
export interface BufferedReadBounds { export interface BufferedReadBounds {
since: number | undefined; since: number | undefined;
limit: number; limit: number;
+17 -5
View File
@@ -1,5 +1,5 @@
// Snapshot ref resolution — normalise `@eN` / `eN`, look up // Snapshot ref resolution — normalise `@eN` / `eN`, look up
// `backendNodeId` via the session RefStore, and build stable // compound CDP node identity via the session RefStore, and build stable
// `ref_not_found` errors for hard-failure tool paths. // `ref_not_found` errors for hard-failure tool paths.
import type { SessionContext } from "@/session-manager/manager"; import type { SessionContext } from "@/session-manager/manager";
@@ -10,6 +10,13 @@ import { rpcError } from "./errors";
export interface SnapshotRefLookup { export interface SnapshotRefLookup {
backendNodeId: number; backendNodeId: number;
refKey: string; refKey: string;
frameId?: string;
cdpSessionId?: string;
}
function refEntryForTab(ctx: SessionContext, refKey: string, tabId: number) {
const entry = ctx.refStore.resolveEntry(refKey);
return entry?.tabId === tabId ? entry : null;
} }
/** /**
@@ -23,9 +30,14 @@ export function lookupSnapshotRef(
tabId: number, tabId: number,
): SnapshotRefLookup | null { ): SnapshotRefLookup | null {
const refKey = normaliseRef(ref); const refKey = normaliseRef(ref);
const backendNodeId = ctx.refStore.resolve(refKey, { tabId }); const entry = refEntryForTab(ctx, refKey, tabId);
if (backendNodeId === null) return null; if (!entry) return null;
return { backendNodeId, refKey }; return {
backendNodeId: entry.backendNodeId,
refKey,
...(entry.frameId ? { frameId: entry.frameId } : {}),
...(entry.cdpSessionId ? { cdpSessionId: entry.cdpSessionId } : {}),
};
} }
/** /**
@@ -37,7 +49,7 @@ export function resolveSnapshotRef(
ctx: SessionContext, ctx: SessionContext,
ref: string, ref: string,
tabId: number, tabId: number,
): { backendNodeId: number; refKey: string } | RpcError { ): SnapshotRefLookup | RpcError {
const looked = lookupSnapshotRef(ctx, ref, tabId); const looked = lookupSnapshotRef(ctx, ref, tabId);
if (looked === null) { if (looked === null) {
return rpcError( return rpcError(
+115 -7
View File
@@ -157,6 +157,7 @@ export interface ChromeWindowsApi {
get(windowId: number): Promise<chrome.windows.Window>; get(windowId: number): Promise<chrome.windows.Window>;
getLastFocused(filters?: chrome.windows.QueryOptions): Promise<chrome.windows.Window>; getLastFocused(filters?: chrome.windows.QueryOptions): Promise<chrome.windows.Window>;
create(props: chrome.windows.CreateData): Promise<chrome.windows.Window | undefined>; create(props: chrome.windows.CreateData): Promise<chrome.windows.Window | undefined>;
remove(windowId: number): Promise<void>;
} }
export const chromeWindowsApi: ChromeWindowsApi = { export const chromeWindowsApi: ChromeWindowsApi = {
@@ -164,6 +165,7 @@ export const chromeWindowsApi: ChromeWindowsApi = {
getLastFocused: (filters) => getLastFocused: (filters) =>
filters ? chrome.windows.getLastFocused(filters) : chrome.windows.getLastFocused(), filters ? chrome.windows.getLastFocused(filters) : chrome.windows.getLastFocused(),
create: (p) => chrome.windows.create(p), create: (p) => chrome.windows.create(p),
remove: (id) => chrome.windows.remove(id),
}; };
export interface AgentOverlayResetApi { export interface AgentOverlayResetApi {
@@ -215,7 +217,9 @@ export async function handleTabList(
manager: SessionManager, manager: SessionManager,
params: TabListParams, params: TabListParams,
api: ChromeTabsApi = chromeTabsApi, api: ChromeTabsApi = chromeTabsApi,
signal?: AbortSignal,
): Promise<TabListResult | RpcError> { ): Promise<TabListResult | RpcError> {
if (signal?.aborted) return { code: "cancelled", message: "tab_list aborted" };
if (!params || typeof params.session_id !== "string" || params.session_id.length === 0) { if (!params || typeof params.session_id !== "string" || params.session_id.length === 0) {
return { return {
code: "invalid_params", code: "invalid_params",
@@ -249,6 +253,7 @@ export async function handleTabList(
const myAgentWindowId = ctx.agentWindowId; const myAgentWindowId = ctx.agentWindowId;
const allTabs = await api.query({}); const allTabs = await api.query({});
if (signal?.aborted) return { code: "cancelled", message: "tab_list aborted" };
const tabs: TabInfo[] = []; const tabs: TabInfo[] = [];
for (const t of allTabs) { for (const t of allTabs) {
if (typeof t.id !== "number") continue; if (typeof t.id !== "number") continue;
@@ -382,7 +387,12 @@ async function createTabAndCleanup(
try { try {
await getTabsApi(deps).remove(tab.id); await getTabsApi(deps).remove(tab.id);
} catch (cleanupErr) { } catch (cleanupErr) {
console.debug("[bsk tab_create] cleanup after abort failed", cleanupErr); return rpcError(
"protocol_error",
"cleanup_failed",
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
{ resource_type: "tab", resource_id: tab.id },
);
} }
} }
return { code: "cancelled", message: "tab_create aborted" }; return { code: "cancelled", message: "tab_create aborted" };
@@ -693,7 +703,17 @@ async function moveTabForBorrow(
try { try {
await tabsApi.move(tabId, { windowId: originalWindowId, index: originalIndex }); await tabsApi.move(tabId, { windowId: originalWindowId, index: originalIndex });
} catch (rollbackErr) { } catch (rollbackErr) {
console.debug("[bsk tab_borrow] rollback move failed", rollbackErr); return rpcError(
"protocol_error",
"cleanup_failed",
`tab_borrow aborted but rollback of tab ${tabId} to window ${originalWindowId} failed: ${describeError(rollbackErr)}`,
{
resource_type: "tab",
resource_id: tabId,
original_window_id: originalWindowId,
original_index: originalIndex,
},
);
} }
return { code: "cancelled", message: "tab_borrow aborted" }; return { code: "cancelled", message: "tab_borrow aborted" };
} }
@@ -825,6 +845,12 @@ export interface ReturnOutcome {
fallback: boolean; fallback: boolean;
} }
interface FallbackWindowTarget {
windowId: number;
index: number;
created: boolean;
}
function describeError(err: unknown): string { function describeError(err: unknown): string {
return err instanceof Error ? err.message : String(err); return err instanceof Error ? err.message : String(err);
} }
@@ -833,10 +859,13 @@ async function chooseFallbackWindow(
ctx: SessionContext, ctx: SessionContext,
windowsApi: ChromeWindowsApi, windowsApi: ChromeWindowsApi,
isAgentWindowId: (windowId: number) => boolean, isAgentWindowId: (windowId: number) => boolean,
): Promise<{ windowId: number; index: number } | RpcError> { signal?: AbortSignal,
): Promise<FallbackWindowTarget | RpcError> {
let lastFocusedError: unknown; let lastFocusedError: unknown;
try { try {
const last = await windowsApi.getLastFocused({ windowTypes: ["normal"] }); const last = await windowsApi.getLastFocused({ windowTypes: ["normal"] });
const cancelled = aborted(signal, "tab_return");
if (cancelled) return cancelled;
const lastId = typeof last?.id === "number" ? last.id : null; const lastId = typeof last?.id === "number" ? last.id : null;
// Never relocate a user tab into *any* session's Agent Window: this // Never relocate a user tab into *any* session's Agent Window: this
// session's is excluded explicitly, and other sessions' are excluded // session's is excluded explicitly, and other sessions' are excluded
@@ -846,12 +875,15 @@ async function chooseFallbackWindow(
// session write to it and, worse, see it destroyed when that session // session write to it and, worse, see it destroyed when that session
// stops and closes its window. // stops and closes its window.
if (lastId !== null && lastId !== ctx.agentWindowId && !isAgentWindowId(lastId)) { if (lastId !== null && lastId !== ctx.agentWindowId && !isAgentWindowId(lastId)) {
return { windowId: lastId, index: -1 }; return { windowId: lastId, index: -1, created: false };
} }
} catch (err) { } catch (err) {
lastFocusedError = err; lastFocusedError = err;
} }
const cancelled = aborted(signal, "tab_return");
if (cancelled) return cancelled;
try { try {
const created = await windowsApi.create({ const created = await windowsApi.create({
url: NEW_TAB_DEFAULT_URL, url: NEW_TAB_DEFAULT_URL,
@@ -863,7 +895,20 @@ async function chooseFallbackWindow(
message: "tab_return: failed to create fallback window", message: "tab_return: failed to create fallback window",
}; };
} }
return { windowId: created.id, index: 0 }; if (aborted(signal, "tab_return")) {
try {
await windowsApi.remove(created.id);
} catch (cleanupErr) {
return rpcError(
"protocol_error",
"cleanup_failed",
`tab_return aborted but cleanup of fallback window ${created.id} failed: ${describeError(cleanupErr)}`,
{ resource_type: "window", resource_id: created.id },
);
}
return { code: "cancelled", message: "tab_return aborted" };
}
return { windowId: created.id, index: 0, created: true };
} catch (err) { } catch (err) {
const suffix = lastFocusedError const suffix = lastFocusedError
? ` (after getLastFocused failed: ${describeError(lastFocusedError)})` ? ` (after getLastFocused failed: ${describeError(lastFocusedError)})`
@@ -875,6 +920,25 @@ async function chooseFallbackWindow(
} }
} }
async function cleanupUnusedFallbackWindow(
windowsApi: ChromeWindowsApi,
target: FallbackWindowTarget,
reason: string,
): Promise<RpcError | null> {
if (!target.created) return null;
try {
await windowsApi.remove(target.windowId);
return null;
} catch (cleanupErr) {
return rpcError(
"protocol_error",
"cleanup_failed",
`${reason}; cleanup of fallback window ${target.windowId} failed: ${describeError(cleanupErr)}`,
{ resource_type: "window", resource_id: target.windowId },
);
}
}
function resetAgentOverlaysInReturnedTab( function resetAgentOverlaysInReturnedTab(
ctx: SessionContext, ctx: SessionContext,
tabId: number, tabId: number,
@@ -911,10 +975,13 @@ export async function returnBorrowedTab(
const tabsApi = getTabsApi(deps); const tabsApi = getTabsApi(deps);
const windowsApi = getWindowsApi(deps); const windowsApi = getWindowsApi(deps);
const isAgentWindowId = getIsAgentWindowId(deps); const isAgentWindowId = getIsAgentWindowId(deps);
const alreadyCancelled = aborted(deps.signal, "tab_return");
if (alreadyCancelled) return alreadyCancelled;
let targetWindowId = entry.originalWindowId; let targetWindowId = entry.originalWindowId;
let targetIndex = entry.originalIndex; let targetIndex = entry.originalIndex;
let fallback = false; let fallback = false;
let fallbackTarget: FallbackWindowTarget | null = null;
// Check the original window is still around. // Check the original window is still around.
let originalAlive = true; let originalAlive = true;
@@ -924,14 +991,30 @@ export async function returnBorrowedTab(
console.debug("[bsk tab_return] original window gone, falling back", err); console.debug("[bsk tab_return] original window gone, falling back", err);
originalAlive = false; originalAlive = false;
} }
const cancelledAfterLookup = aborted(deps.signal, "tab_return");
if (cancelledAfterLookup) return cancelledAfterLookup;
if (!originalAlive) { if (!originalAlive) {
fallback = true; fallback = true;
const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId); const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId, deps.signal);
if ("code" in target) return target; if ("code" in target) return target;
fallbackTarget = target;
targetWindowId = target.windowId; targetWindowId = target.windowId;
targetIndex = target.index; targetIndex = target.index;
} }
const cancelledBeforeMove = aborted(deps.signal, "tab_return");
if (cancelledBeforeMove) {
if (fallbackTarget) {
const cleanupError = await cleanupUnusedFallbackWindow(
windowsApi,
fallbackTarget,
"tab_return aborted before moving the borrowed tab",
);
if (cleanupError) return cleanupError;
}
return cancelledBeforeMove;
}
try { try {
const moved = await tabsApi.move(tabId, { const moved = await tabsApi.move(tabId, {
windowId: targetWindowId, windowId: targetWindowId,
@@ -947,14 +1030,33 @@ export async function returnBorrowedTab(
fallback, fallback,
}; };
} catch (err) { } catch (err) {
if (fallbackTarget) {
const cleanupError = await cleanupUnusedFallbackWindow(
windowsApi,
fallbackTarget,
`tab_return could not move tab ${tabId}`,
);
if (cleanupError) return cleanupError;
}
const cancelledAfterMoveFailure = aborted(deps.signal, "tab_return");
if (cancelledAfterMoveFailure) return cancelledAfterMoveFailure;
if (!fallback) { if (!fallback) {
const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId); const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId, deps.signal);
if ("code" in target) { if ("code" in target) {
return { return {
code: "cdp_failed", code: "cdp_failed",
message: `tab_return: chrome.tabs.move failed: ${describeError(err)}; fallback failed: ${target.message}`, message: `tab_return: chrome.tabs.move failed: ${describeError(err)}; fallback failed: ${target.message}`,
}; };
} }
const cancelledBeforeFallbackMove = aborted(deps.signal, "tab_return");
if (cancelledBeforeFallbackMove) {
const cleanupError = await cleanupUnusedFallbackWindow(
windowsApi,
target,
"tab_return aborted before fallback move",
);
return cleanupError ?? cancelledBeforeFallbackMove;
}
try { try {
const moved = await tabsApi.move(tabId, { const moved = await tabsApi.move(tabId, {
windowId: target.windowId, windowId: target.windowId,
@@ -970,6 +1072,12 @@ export async function returnBorrowedTab(
fallback: true, fallback: true,
}; };
} catch (fallbackErr) { } catch (fallbackErr) {
const cleanupError = await cleanupUnusedFallbackWindow(
windowsApi,
target,
`tab_return fallback move for tab ${tabId} failed`,
);
if (cleanupError) return cleanupError;
return { return {
code: "cdp_failed", code: "cdp_failed",
message: `tab_return: chrome.tabs.move failed: ${describeError(err)}; fallback move failed: ${describeError(fallbackErr)}`, message: `tab_return: chrome.tabs.move failed: ${describeError(err)}; fallback move failed: ${describeError(fallbackErr)}`,
+59
View File
@@ -0,0 +1,59 @@
// Upload orchestration: validate the session-scoped request, resolve its click
// target, then delegate the browser protocol transaction to the file-input
// transaction module.
import type { SessionManager } from "@/session-manager/manager";
import type { RpcError, UploadParams, UploadResult } from "@/transport/types";
import { uploadThroughActivatedFileInput } from "./file-input-transaction";
import { clickResolvedTarget, type InteractionDeps, resolveActionTarget } from "./interaction";
import {
type CdpRunner,
enforceAgentWindow,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
const DEFAULT_TIMEOUT_MS = 120_000;
export interface UploadDeps extends InteractionDeps {
cdp: CdpRunner;
}
export async function handleUpload(
manager: SessionManager,
params: UploadParams,
deps: UploadDeps,
): Promise<UploadResult | RpcError> {
const ctx = lookupSession(manager, params, "upload");
if (isRpcError(ctx)) return ctx;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "upload");
if (denied) return denied;
if (
params.files.length === 0 ||
params.files.length > 20 ||
params.files.some((file) => !file.staged_path)
) {
return { code: "invalid_params", message: "upload requires daemon-staged files" };
}
const address = await resolveActionTarget(deps.cdp, ctx, target, params, "upload");
if (isRpcError(address)) return address;
const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
const transaction = await uploadThroughActivatedFileInput({
cdp: deps.cdp,
actionTarget: address,
files: params.files.map((file) => file.staged_path as string),
timeoutMs,
signal: deps.signal,
trigger: () => clickResolvedTarget(ctx, address, {}, deps),
});
if (isRpcError(transaction)) return transaction;
return {
tab_id: target.tabId,
used_ref: transaction.click.used_ref,
used_selector: transaction.click.used_selector,
file_names: params.files.map((file) => file.name),
};
}
@@ -18,6 +18,8 @@ function fakeSnapshotReply() {
"auto", "auto",
"type", "type",
"password", "password",
"value",
"hunter2",
]; ];
const i = (s: string) => S.indexOf(s); const i = (s: string) => S.indexOf(s);
return { return {
@@ -29,7 +31,8 @@ function fakeSnapshotReply() {
nodeType: [1, 1, 1, 1], nodeType: [1, 1, 1, 1],
nodeName: [i("html"), i("body"), i("div"), i("input")], nodeName: [i("html"), i("body"), i("div"), i("input")],
backendNodeId: [10, 11, 12, 13], backendNodeId: [10, 11, 12, 13],
attributes: [[], [], [], [i("type"), i("password")]], attributes: [[], [], [], [i("type"), i("password"), i("value"), i("hunter2")]],
inputValue: { index: [3], value: [i("hunter2")] },
}, },
layout: { layout: {
nodeIndex: [1, 2, 3], nodeIndex: [1, 2, 3],
@@ -217,7 +220,8 @@ function makeCdp(snapshot: unknown) {
{ {
state: "filled", state: "filled",
sensitive: true, sensitive: true,
defaultValue: "", defaultValue: "hunter2",
value: "hunter2",
placeholder: "secret", placeholder: "secret",
}, },
], ],
@@ -262,10 +266,11 @@ describe("captureViewModel", () => {
expect(input).toMatchObject({ expect(input).toMatchObject({
formState: "filled", formState: "filled",
formDefaultValue: "",
formPlaceholder: "secret", formPlaceholder: "secret",
attrs: { type: "password" },
}); });
expect(input?.formValue).toBeUndefined(); expect(input?.formValue).toBeUndefined();
expect(input?.formDefaultValue).toBeUndefined();
expect(cdp.send).toHaveBeenCalledWith( expect(cdp.send).toHaveBeenCalledWith(
4, 4,
"Runtime.evaluate", "Runtime.evaluate",
@@ -677,7 +682,7 @@ describe("captureViewModel", () => {
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto"); expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
}); });
it("preserves document rects while deriving viewport rects after scroll", async () => { it("subtracts scroll offset to make rects viewport-relative", async () => {
const cdp = { const cdp = {
send: vi.fn(async (_t: number, method: string) => { send: vi.fn(async (_t: number, method: string) => {
if (method === "DOMSnapshot.enable") return {}; if (method === "DOMSnapshot.enable") return {};
@@ -691,10 +696,9 @@ describe("captureViewModel", () => {
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>, }) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
}; };
const { nodes } = await captureViewModel(cdp, 4); const { nodes } = await captureViewModel(cdp, 4);
const input = nodes.find((n) => n.backendNodeId === 13); const div = nodes.find((n) => n.backendNodeId === 12);
expect(input?.documentRect).toEqual({ x: 400, y: 300, w: 200, h: 40 }); expect(div?.localRect?.y).toBe(-200);
expect(input?.localRect).toEqual({ x: 400, y: 100, w: 200, h: 40 }); expect(div?.rect).toMatchObject({ y: 0, h: 600 });
expect(input?.rect).toEqual({ x: 400, y: 100, w: 200, h: 40 });
}); });
it("normalizes device-pixel bounds by devicePixelRatio", async () => { it("normalizes device-pixel bounds by devicePixelRatio", async () => {
@@ -838,6 +842,7 @@ describe("captureViewModel", () => {
strings: S, strings: S,
documents: [ documents: [
{ {
frameId: "main-frame",
scrollOffsetX: 0, scrollOffsetX: 0,
scrollOffsetY: 0, scrollOffsetY: 0,
nodes: { nodes: {
@@ -865,6 +870,7 @@ describe("captureViewModel", () => {
}, },
// documents[1]: the iframe's sub-document with a login input // documents[1]: the iframe's sub-document with a login input
{ {
frameId: "child-frame",
scrollOffsetX: 0, scrollOffsetX: 0,
scrollOffsetY: 0, scrollOffsetY: 0,
nodes: { nodes: {
@@ -894,7 +900,14 @@ describe("captureViewModel", () => {
throw new Error(method); throw new Error(method);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>, }) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
}; };
const { nodes, iframeNodes } = await captureViewModel(cdp, 4); const {
nodes,
iframeNodes,
frameNodes,
frameOwnerBackendNodeIds,
frameParentIds,
rootFrameId,
} = await captureViewModel(cdp, 4);
// Main frame has 4 nodes; iframe is included // Main frame has 4 nodes; iframe is included
expect(nodes.find((n) => n.backendNodeId === 13)?.tag).toBe("iframe"); expect(nodes.find((n) => n.backendNodeId === 13)?.tag).toBe("iframe");
// iframeNodes keyed by the <iframe> element's backendNodeId=13 // iframeNodes keyed by the <iframe> element's backendNodeId=13
@@ -907,9 +920,13 @@ describe("captureViewModel", () => {
expect(input?.ownerFrameBackendNodeId).toBe(13); expect(input?.ownerFrameBackendNodeId).toBe(13);
expect(input?.localRect).toEqual({ x: 0, y: 0, w: 200, h: 40 }); expect(input?.localRect).toEqual({ x: 0, y: 0, w: 200, h: 40 });
expect(input?.rect).toEqual({ x: 100, y: 300, w: 200, h: 40 }); expect(input?.rect).toEqual({ x: 100, y: 300, w: 200, h: 40 });
expect(rootFrameId).toBe("main-frame");
expect(frameNodes?.get("child-frame")).toBe(subNodes);
expect(frameOwnerBackendNodeIds?.get("child-frame")).toBe(13);
expect(frameParentIds?.get("child-frame")).toBe("main-frame");
}); });
it("clips iframe sub-document rects to the iframe viewport in top coordinates", async () => { it("captures iframe documents without requiring owner-frame geometry", async () => {
const S = [ const S = [
"html", "html",
"body", "body",
@@ -937,16 +954,10 @@ describe("captureViewModel", () => {
contentDocumentIndex: { index: [2], value: [1] }, contentDocumentIndex: { index: [2], value: [1] },
}, },
layout: { layout: {
nodeIndex: [1, 2], nodeIndex: [1],
styles: [ styles: [[i("static"), i("auto")]],
[i("static"), i("auto")], bounds: [[0, 0, 1000, 800]],
[i("static"), i("auto")], paintOrders: [0],
],
bounds: [
[0, 0, 1000, 800],
[950, 760, 200, 100],
],
paintOrders: [0, 1],
}, },
}, },
{ {
@@ -984,7 +995,7 @@ describe("captureViewModel", () => {
const input = iframeNodes.get(13)?.find((n) => n.backendNodeId === 101); const input = iframeNodes.get(13)?.find((n) => n.backendNodeId === 101);
expect(input?.localRect).toEqual({ x: 20, y: 20, w: 120, h: 60 }); expect(input?.localRect).toEqual({ x: 20, y: 20, w: 120, h: 60 });
expect(input?.rect).toEqual({ x: 970, y: 780, w: 30, h: 20 }); expect(input?.rect).toBeNull();
}); });
it("recursively normalizes nested iframe sub-documents", async () => { it("recursively normalizes nested iframe sub-documents", async () => {
@@ -1079,8 +1090,10 @@ describe("captureViewModel", () => {
const nestedIframe = iframeNodes.get(13)?.find((n) => n.backendNodeId === 23); const nestedIframe = iframeNodes.get(13)?.find((n) => n.backendNodeId === 23);
const input = iframeNodes.get(23)?.find((n) => n.backendNodeId === 31); const input = iframeNodes.get(23)?.find((n) => n.backendNodeId === 31);
expect(nestedIframe?.localRect).toEqual({ x: 5, y: 6, w: 100, h: 80 });
expect(nestedIframe?.rect).toEqual({ x: 15, y: 26, w: 100, h: 80 }); expect(nestedIframe?.rect).toEqual({ x: 15, y: 26, w: 100, h: 80 });
expect(input?.ownerFrameBackendNodeId).toBe(23); expect(input?.ownerFrameBackendNodeId).toBe(23);
expect(input?.localRect).toEqual({ x: 1, y: 2, w: 40, h: 20 });
expect(input?.rect).toEqual({ x: 16, y: 28, w: 40, h: 20 }); expect(input?.rect).toEqual({ x: 16, y: 28, w: 40, h: 20 });
}); });
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import type { CdpRunner } from "../../shared";
import type { CapturedNode, CapturedViewModel } from "../capture";
import { captureFrameData } from "../frame-capture";
function ownerNode(backendNodeId: number, x: number): CapturedNode {
return {
backendNodeId,
parentBackendNodeId: 1,
frameId: "main",
ownerFrameBackendNodeId: null,
tag: "iframe",
attrs: {},
rect: { x, y: 100, w: 300, h: 200 },
localRect: { x, y: 100, w: 300, h: 200 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
};
}
function childSnapshot(frameId: string, backendNodeId: number) {
const strings = [frameId, "body", "button", "static", "auto", "pointer"];
return {
strings,
documents: [
{
frameId,
nodes: {
parentIndex: [-1, 0],
nodeName: [1, 2],
backendNodeId: [backendNodeId - 1, backendNodeId],
attributes: [[], []],
},
layout: {
nodeIndex: [0, 1],
styles: [
[3, 4, 4],
[3, 4, 5],
],
bounds: [
[0, 0, 300, 200],
[10, 20, 100, 40],
],
paintOrders: [0, 1],
},
},
],
};
}
describe("captureFrameData", () => {
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
const leftOwner = ownerNode(10, 50);
const rightOwner = ownerNode(20, 500);
const captured: CapturedViewModel = {
nodes: [leftOwner, rightOwner],
viewport: { width: 1000, height: 800 },
iframeNodes: new Map(),
frameNodes: new Map([["main", [leftOwner, rightOwner]]]),
frameOwnerBackendNodeIds: new Map(),
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
const sendToTarget = vi.fn(async (target, method) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 300, clientHeight: 200, pageX: 0, pageY: 0 } };
}
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
if (method === "DOMSnapshot.captureSnapshot") {
return target.sessionId === "left-session"
? childSnapshot("left", 101)
: childSnapshot("right", 201);
}
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
throw new Error(`unexpected ${method}`);
});
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method, params) => {
if (method === "Accessibility.enable") return {};
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
if (method === "DOM.getBoxModel") {
const backendNodeId = (params as { backendNodeId?: number })?.backendNodeId;
const x = backendNodeId === 10 ? 50 : 500;
return { model: { content: [x, 100, x + 300, 100, x + 300, 300, x, 300] } };
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
}
throw new Error(`unexpected root ${method}`);
}) as CdpRunner["send"],
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "left",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4, sessionId: "left-session" },
},
{
frameId: "right",
parentFrameId: "main",
ownerBackendNodeId: 20,
target: { tabId: 4, sessionId: "right-session" },
},
],
})),
};
const trees = await captureFrameData(cdp, 4, captured);
expect(trees.map((tree) => tree.frameId)).toEqual(["main", "left", "right"]);
expect(
captured.frameNodes?.get("left")?.find((node) => node.backendNodeId === 101)?.rect,
).toEqual({ x: 60, y: 120, w: 100, h: 40 });
expect(
captured.frameNodes?.get("right")?.find((node) => node.backendNodeId === 201)?.rect,
).toEqual({ x: 510, y: 120, w: 100, h: 40 });
expect(captured.frameOwnerBackendNodeIds).toEqual(
new Map([
["left", 10],
["right", 20],
]),
);
expect(captured.frameParentIds).toEqual(
new Map([
["left", "main"],
["right", "main"],
]),
);
});
});
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
import type { CapturedNode, CapturedViewModel } from "../capture";
import { buildFrameDocuments, type FrameAxBatch, type FrameOwnedAxNode } from "../frame-document";
function domNode(frameId: string, backendNodeId: number): CapturedNode {
return {
backendNodeId,
parentBackendNodeId: null,
frameId,
tag: "div",
attrs: {},
rect: { x: 0, y: 0, w: 20, h: 20 },
paintOrder: 0,
position: "static",
pointerEvents: "auto",
};
}
function captured(frameNodes: Map<string, CapturedNode[]>): CapturedViewModel {
return {
nodes: frameNodes.get("main") ?? [],
viewport: { width: 800, height: 600 },
iframeNodes: new Map(),
frameNodes,
rootFrameId: "main",
excludedBackendNodeIds: new Set(),
};
}
describe("buildFrameDocuments", () => {
it("partitions mixed AX results by node ownership and cuts cross-frame parent edges", () => {
const graph: CdpFrameGraph = {
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{ frameId: "child", parentFrameId: "main", target: { tabId: 4 } },
],
};
const batches: FrameAxBatch<FrameOwnedAxNode>[] = [
{
frame: graph.frames[0],
nodes: [
{ nodeId: "root", frameId: "main", backendDOMNodeId: 1, childIds: ["h"] },
{
nodeId: "h",
frameId: "child",
parentId: "root",
backendDOMNodeId: 100,
childIds: ["virtual"],
},
{ nodeId: "virtual", parentId: "h" },
],
},
];
const documents = buildFrameDocuments(
graph,
batches,
captured(
new Map([
["main", [domNode("main", 1)]],
["child", [domNode("child", 100)]],
]),
),
);
expect(documents.find((document) => document.frameId === "main")?.axNodes).toHaveLength(1);
const childNodes = documents.find((document) => document.frameId === "child")?.axNodes ?? [];
expect(childNodes.map((node) => node.nodeId)).toEqual(["h", "virtual"]);
expect(childNodes.find((node) => node.nodeId === "h")?.parentId).toBeUndefined();
expect(childNodes.find((node) => node.nodeId === "virtual")?.frameId).toBe("child");
});
it("deduplicates overlapping same-target AX results using the strongest ownership", () => {
const graph: CdpFrameGraph = {
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{ frameId: "child", parentFrameId: "main", target: { tabId: 4 } },
],
};
const childNode = { nodeId: "shared", backendDOMNodeId: 100 };
const batches: FrameAxBatch<FrameOwnedAxNode>[] = [
{
frame: graph.frames[0],
nodes: [{ ...childNode, frameId: "child" }],
},
{
frame: graph.frames[1],
nodes: [childNode],
},
];
const documents = buildFrameDocuments(
graph,
batches,
captured(
new Map([
["main", [domNode("main", 1)]],
["child", [domNode("child", 100)]],
]),
),
);
expect(documents.find((document) => document.frameId === "main")?.axNodes).toEqual([]);
expect(documents.find((document) => document.frameId === "child")?.axNodes).toHaveLength(1);
});
it("uses target-scoped backend ownership when OOPIF backend ids collide", () => {
const graph: CdpFrameGraph = {
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "left",
parentFrameId: "main",
target: { tabId: 4, sessionId: "left-session" },
},
{
frameId: "right",
parentFrameId: "main",
target: { tabId: 4, sessionId: "right-session" },
},
],
};
const batches: FrameAxBatch<FrameOwnedAxNode>[] = graph.frames.slice(1).map((frame) => ({
frame,
nodes: [{ nodeId: `${frame.frameId}-node`, backendDOMNodeId: 7 }],
}));
const documents = buildFrameDocuments(
graph,
batches,
captured(
new Map([
["main", []],
["left", [domNode("left", 7)]],
["right", [domNode("right", 7)]],
]),
),
);
expect(documents.find((document) => document.frameId === "left")?.axNodes[0]?.nodeId).toBe(
"left-node",
);
expect(documents.find((document) => document.frameId === "right")?.axNodes[0]?.nodeId).toBe(
"right-node",
);
});
it("uses captured frame parent identity instead of guessing from backend ids", () => {
const frameNodes = new Map([
["main", [domNode("main", 1)]],
["left", [domNode("left", 7)]],
["right", [domNode("right", 7)]],
["child", [domNode("child", 9)]],
]);
const capture = captured(frameNodes);
capture.frameOwnerBackendNodeIds = new Map([["child", 7]]);
capture.frameParentIds = new Map([["child", "left"]]);
const frames = ["main", "left", "right", "child"].map((frameId) => ({
frame: { frameId, target: { tabId: 4 } },
nodes: [],
}));
const documents = buildFrameDocuments(null, frames, capture);
expect(documents.find((document) => document.frameId === "child")).toMatchObject({
parentFrameId: "left",
ownerBackendNodeId: 7,
});
});
});
+207 -76
View File
@@ -7,6 +7,7 @@
import type { Rect, Viewport } from "@browser-skill/vom"; import type { Rect, Viewport } from "@browser-skill/vom";
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy"; import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge"; import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry";
import type { CdpRunner } from "../shared"; import type { CdpRunner } from "../shared";
const REQUESTED_STYLES = ["position", "pointer-events", "cursor"] as const; const REQUESTED_STYLES = ["position", "pointer-events", "cursor"] as const;
@@ -17,16 +18,15 @@ const STYLE_COL = Object.fromEntries(
export interface CapturedNode { export interface CapturedNode {
backendNodeId: number; backendNodeId: number;
parentBackendNodeId: number | null; parentBackendNodeId: number | null;
frameId?: string;
/** Owning iframe backend node id; `null` for the top-level document. */ /** Owning iframe backend node id; `null` for the top-level document. */
ownerFrameBackendNodeId?: number | null; ownerFrameBackendNodeId?: number | null;
tag: string; tag: string;
attrs: Record<string, string>; attrs: Record<string, string>;
/** Top-level viewport-relative CSS px, clipped to the owning frame viewport. */ /** Top-level viewport-relative CSS px, clipped to the owning frame viewport. */
rect: Rect | null; rect: Rect | null;
/** Frame-local viewport-relative CSS px before top-level translation/clipping. */ /** Frame-local viewport-relative CSS px before top-level projection. */
localRect?: Rect | null; localRect?: Rect | null;
/** Frame-local document-relative CSS px as reported by DOMSnapshot. */
documentRect?: Rect | null;
paintOrder: number; paintOrder: number;
position: string; position: string;
pointerEvents: string; pointerEvents: string;
@@ -59,6 +59,11 @@ export interface CapturedViewModel {
nodes: CapturedNode[]; nodes: CapturedNode[];
viewport: Viewport; viewport: Viewport;
iframeNodes: CapturedIframeNodes; iframeNodes: CapturedIframeNodes;
frameNodes?: Map<string, CapturedNode[]>;
frameOwnerBackendNodeIds?: Map<string, number>;
/** Explicit DOMSnapshot frame ancestry; never inferred from backend node ids. */
frameParentIds?: Map<string, string>;
rootFrameId?: string;
surfaceProbes?: CapturedSurfaceProbe[]; surfaceProbes?: CapturedSurfaceProbe[];
/** Backend node ids belonging to the agent overlay host + its shadow subtree. */ /** Backend node ids belonging to the agent overlay host + its shadow subtree. */
excludedBackendNodeIds: Set<number>; excludedBackendNodeIds: Set<number>;
@@ -67,6 +72,25 @@ export interface CapturedViewModel {
export interface CaptureViewModelOptions { export interface CaptureViewModelOptions {
conditionalSurfaceProbe?: boolean; conditionalSurfaceProbe?: boolean;
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>; hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
signal?: AbortSignal;
}
function captureAbortError(): Error {
const error = new Error("observation aborted");
error.name = "AbortError";
return error;
}
function isAbortError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
(error as { name?: string }).name === "AbortError"
);
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw captureAbortError();
} }
/** Sparse array format Chrome uses for infrequently-set per-node fields. */ /** Sparse array format Chrome uses for infrequently-set per-node fields. */
@@ -75,7 +99,12 @@ interface SparseArray {
value: number[]; value: number[];
} }
interface RareBooleanData {
index: number[];
}
interface SnapshotDocument { interface SnapshotDocument {
frameId?: string | number;
scrollOffsetX?: number; scrollOffsetX?: number;
scrollOffsetY?: number; scrollOffsetY?: number;
nodes?: { nodes?: {
@@ -90,6 +119,10 @@ interface SnapshotDocument {
nodeValue?: number[]; nodeValue?: number[];
/** Maps node array index → index into `documents[]` for frame content. */ /** Maps node array index → index into `documents[]` for frame content. */
contentDocumentIndex?: SparseArray; contentDocumentIndex?: SparseArray;
inputValue?: SparseArray;
textValue?: SparseArray;
inputChecked?: RareBooleanData;
optionSelected?: RareBooleanData;
}; };
layout?: { layout?: {
nodeIndex?: number[]; nodeIndex?: number[];
@@ -99,6 +132,12 @@ interface SnapshotDocument {
}; };
} }
function snapshotFrameId(document: SnapshotDocument, strings: string[]): string | undefined {
if (typeof document.frameId === "string") return document.frameId || undefined;
if (typeof document.frameId === "number") return str(strings, document.frameId) || undefined;
return undefined;
}
interface SnapshotReply { interface SnapshotReply {
strings?: string[]; strings?: string[];
documents?: SnapshotDocument[]; documents?: SnapshotDocument[];
@@ -118,6 +157,25 @@ function isFormControlTag(tag: string): boolean {
return tag === "input" || tag === "textarea" || tag === "select"; return tag === "input" || tag === "textarea" || tag === "select";
} }
function isSensitiveFormControl(node: Pick<CapturedNode, "tag" | "attrs">): boolean {
return node.tag === "input" && (node.attrs.type ?? "").toLowerCase() === "password";
}
function snapshotFormState(
value: string | undefined,
defaultValue: string,
hasDefaultValue: boolean,
sensitive: boolean,
): CapturedNode["formState"] {
if (sensitive) {
if (value === undefined && !hasDefaultValue) return undefined;
return (value ?? defaultValue) === "" ? "empty" : "filled";
}
if (value === undefined) return undefined;
if (value === "") return "empty";
return value === defaultValue ? "default" : "filled";
}
const MAX_FORM_ENRICH_CONTROLS = 250; const MAX_FORM_ENRICH_CONTROLS = 250;
interface CapturedFormState { interface CapturedFormState {
@@ -149,9 +207,8 @@ function formStateBatchExpression(maxControls: number): string {
return { return {
state, state,
sensitive, sensitive,
defaultValue,
placeholder, placeholder,
...(sensitive ? {} : { value: rawValue }), ...(sensitive ? {} : { value: rawValue, defaultValue }),
}; };
}; };
const collect = (doc) => { const collect = (doc) => {
@@ -193,10 +250,14 @@ function applyFormStates(nodes: CapturedNode[], states: CapturedFormState[]): vo
index += 1; index += 1;
if (!state) continue; if (!state) continue;
node.formState = state.state; node.formState = state.state;
node.formDefaultValue = state.defaultValue ?? "";
node.formPlaceholder = state.placeholder ?? ""; node.formPlaceholder = state.placeholder ?? "";
if (!state.sensitive && state.value !== undefined) { if (state.sensitive || isSensitiveFormControl(node)) {
node.formValue = state.value; delete node.formValue;
delete node.formDefaultValue;
delete node.attrs.value;
} else {
node.formDefaultValue = state.defaultValue ?? "";
if (state.value !== undefined) node.formValue = state.value;
} }
} }
} }
@@ -205,21 +266,25 @@ async function enrichFormControlStates(
cdp: CdpRunner, cdp: CdpRunner,
tabId: number, tabId: number,
frameNodeGroups: CapturedNode[][], frameNodeGroups: CapturedNode[][],
signal?: AbortSignal,
): Promise<void> { ): Promise<void> {
const hasControls = frameNodeGroups.some((nodes) => const hasControls = frameNodeGroups.some((nodes) =>
nodes.some((node) => isFormControlTag(node.tag)), nodes.some((node) => isFormControlTag(node.tag)),
); );
if (!hasControls) return; if (!hasControls) return;
try { try {
throwIfAborted(signal);
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", { const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS), expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
returnByValue: true, returnByValue: true,
}); });
throwIfAborted(signal);
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result)); const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result));
for (let i = 0; i < frameNodeGroups.length; i += 1) { for (let i = 0; i < frameNodeGroups.length; i += 1) {
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []); applyFormStates(frameNodeGroups[i], frameStates[i] ?? []);
} }
} catch { } catch (error) {
if (isAbortError(error)) throw error;
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes. // Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
} }
} }
@@ -251,9 +316,9 @@ interface ParseDocumentResult {
} }
interface FrameContext { interface FrameContext {
frameId?: string;
ownerFrameBackendNodeId: number | null; ownerFrameBackendNodeId: number | null;
originInTop: { x: number; y: number }; projection: GeometryProjection | null;
clipRectInTop: Rect;
scrollX: number; scrollX: number;
scrollY: number; scrollY: number;
} }
@@ -272,38 +337,6 @@ function devicePixelRatio(metrics: LayoutMetricsReply): number {
return dpr >= 1 ? dpr : 1; return dpr >= 1 ? dpr : 1;
} }
function viewportRect(viewport: Viewport): Rect {
return { x: 0, y: 0, w: Math.max(0, viewport.width), h: Math.max(0, viewport.height) };
}
function intersectRects(a: Rect, b: Rect): Rect | null {
const x1 = Math.max(a.x, b.x);
const y1 = Math.max(a.y, b.y);
const x2 = Math.min(a.x + a.w, b.x + b.w);
const y2 = Math.min(a.y + a.h, b.y + b.h);
if (x2 <= x1 || y2 <= y1) return null;
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
function rectInTop(localRect: Rect, context: FrameContext): Rect | null {
const translated = {
x: context.originInTop.x + localRect.x,
y: context.originInTop.y + localRect.y,
w: localRect.w,
h: localRect.h,
};
return intersectRects(translated, context.clipRectInTop);
}
function unclipRectInTop(localRect: Rect, context: FrameContext): Rect {
return {
x: context.originInTop.x + localRect.x,
y: context.originInTop.y + localRect.y,
w: localRect.w,
h: localRect.h,
};
}
function sparseIndexMap(sparse: SparseArray | undefined): Map<number, number> { function sparseIndexMap(sparse: SparseArray | undefined): Map<number, number> {
const out = new Map<number, number>(); const out = new Map<number, number>();
if (!sparse?.index || !sparse.value) return out; if (!sparse?.index || !sparse.value) return out;
@@ -427,8 +460,20 @@ function hoverStateExpression(): string {
})()`; })()`;
} }
async function wait(ms: number): Promise<void> { async function wait(ms: number, signal?: AbortSignal): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms)); throwIfAborted(signal);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(captureAbortError());
};
signal?.addEventListener("abort", onAbort, { once: true });
});
} }
async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> { async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> {
@@ -600,41 +645,51 @@ async function probeHoverSurfaces(
): Promise<CapturedSurfaceProbe[]> { ): Promise<CapturedSurfaceProbe[]> {
const started = Date.now(); const started = Date.now();
try { try {
throwIfAborted(options.signal);
const cssScan = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", { const cssScan = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: hoverCssTriggerScanExpression(), expression: hoverCssTriggerScanExpression(),
returnByValue: true, returnByValue: true,
}); });
throwIfAborted(options.signal);
const cssHoverPoints = runtimeValue<Array<{ x: number; y: number }>>(cssScan) ?? []; const cssHoverPoints = runtimeValue<Array<{ x: number; y: number }>>(cssScan) ?? [];
const candidates = buildHoverCandidates(nodes, cssHoverPoints); const candidates = buildHoverCandidates(nodes, cssHoverPoints);
if (candidates.length === 0) return []; if (candidates.length === 0) return [];
const results: CapturedSurfaceProbe[] = []; const results: CapturedSurfaceProbe[] = [];
const seen = new Set<number>(); const seen = new Set<number>();
throwIfAborted(options.signal);
await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined); await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined);
try { try {
throwIfAborted(options.signal);
for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) { for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) {
throwIfAborted(options.signal);
if (Date.now() - started > MAX_HOVER_PROBE_MS) break; if (Date.now() - started > MAX_HOVER_PROBE_MS) break;
if (results.length >= MAX_HOVER_SURFACES) break; if (results.length >= MAX_HOVER_SURFACES) break;
if (seen.has(candidate.backendNodeId)) continue; if (seen.has(candidate.backendNodeId)) continue;
try { try {
await clearHover(cdp, tabId); await clearHover(cdp, tabId);
await wait(HOVER_SETTLE_MS); throwIfAborted(options.signal);
await wait(HOVER_SETTLE_MS, options.signal);
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", { const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: hoverStateExpression(), expression: hoverStateExpression(),
returnByValue: true, returnByValue: true,
}); });
throwIfAborted(options.signal);
const baselineItems = runtimeValue<HoverRuntimeItem[]>(baselineReply) ?? []; const baselineItems = runtimeValue<HoverRuntimeItem[]>(baselineReply) ?? [];
throwIfAborted(options.signal);
await cdp.send(tabId, "Input.dispatchMouseEvent", { await cdp.send(tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved", type: "mouseMoved",
x: candidate.x, x: candidate.x,
y: candidate.y, y: candidate.y,
}); });
await wait(HOVER_SETTLE_MS); throwIfAborted(options.signal);
await wait(HOVER_SETTLE_MS, options.signal);
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", { const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: hoverStateExpression(), expression: hoverStateExpression(),
returnByValue: true, returnByValue: true,
}); });
throwIfAborted(options.signal);
const subItems = diffHoverItems( const subItems = diffHoverItems(
baselineItems, baselineItems,
runtimeValue<HoverRuntimeItem[]>(collected) ?? [], runtimeValue<HoverRuntimeItem[]>(collected) ?? [],
@@ -648,7 +703,8 @@ async function probeHoverSurfaces(
subItems, subItems,
confidence: confidenceForHover(candidate, subItems), confidence: confidenceForHover(candidate, subItems),
}); });
} catch { } catch (error) {
if (isAbortError(error)) throw error;
continue; continue;
} finally { } finally {
await clearHover(cdp, tabId); await clearHover(cdp, tabId);
@@ -659,6 +715,7 @@ async function probeHoverSurfaces(
} }
return results; return results;
} catch (err) { } catch (err) {
if (isAbortError(err)) throw err;
console.debug("[bsk capture] hover surface probe failed", err); console.debug("[bsk capture] hover surface probe failed", err);
return []; return [];
} }
@@ -671,13 +728,16 @@ async function probeHoverSurfaces(
export async function collectOverlayExcludedBackendIds( export async function collectOverlayExcludedBackendIds(
cdp: CdpRunner, cdp: CdpRunner,
tabId: number, tabId: number,
signal?: AbortSignal,
): Promise<Set<number>> { ): Promise<Set<number>> {
const excluded = new Set<number>(); const excluded = new Set<number>();
try { try {
throwIfAborted(signal);
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", { const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", {
depth: 0, depth: 0,
pierce: true, pierce: true,
}); });
throwIfAborted(signal);
const rootNodeId = doc.root?.nodeId; const rootNodeId = doc.root?.nodeId;
if (typeof rootNodeId !== "number") return excluded; if (typeof rootNodeId !== "number") return excluded;
@@ -685,6 +745,7 @@ export async function collectOverlayExcludedBackendIds(
nodeId: rootNodeId, nodeId: rootNodeId,
selector: OVERLAY_HOST_SELECTOR, selector: OVERLAY_HOST_SELECTOR,
}); });
throwIfAborted(signal);
if (typeof found.nodeId !== "number" || found.nodeId === 0) return excluded; if (typeof found.nodeId !== "number" || found.nodeId === 0) return excluded;
const described = await cdp.send<{ node?: CdpDomNode }>(tabId, "DOM.describeNode", { const described = await cdp.send<{ node?: CdpDomNode }>(tabId, "DOM.describeNode", {
@@ -692,8 +753,10 @@ export async function collectOverlayExcludedBackendIds(
depth: -1, depth: -1,
pierce: true, pierce: true,
}); });
throwIfAborted(signal);
collectBackendIdsFromDomNode(described.node, excluded); collectBackendIdsFromDomNode(described.node, excluded);
} catch (err) { } catch (err) {
if (isAbortError(err)) throw err;
console.debug("[bsk capture] overlay exclusion fallback failed", err); console.debug("[bsk capture] overlay exclusion fallback failed", err);
} }
return excluded; return excluded;
@@ -754,6 +817,10 @@ function parseDocumentNodes(
const posCol = STYLE_COL.position; const posCol = STYLE_COL.position;
const peCol = STYLE_COL["pointer-events"]; const peCol = STYLE_COL["pointer-events"];
const cursorCol = STYLE_COL.cursor; const cursorCol = STYLE_COL.cursor;
const inputValues = sparseIndexMap(dn.inputValue);
const textValues = sparseIndexMap(dn.textValue);
const checkedInputs = new Set(dn.inputChecked?.index ?? []);
const selectedOptions = new Set(dn.optionSelected?.index ?? []);
// Collect visible text from #text child nodes so element CapturedNodes // Collect visible text from #text child nodes so element CapturedNodes
// carry a textContent value usable as a button/link label fallback. // carry a textContent value usable as a button/link label fallback.
@@ -790,7 +857,6 @@ function parseDocumentNodes(
let rect: Rect | null = null; let rect: Rect | null = null;
let localRect: Rect | null = null; let localRect: Rect | null = null;
let documentRect: Rect | null = null;
let paintOrder = 0; let paintOrder = 0;
let position = "static"; let position = "static";
let pointerEvents = "auto"; let pointerEvents = "auto";
@@ -799,19 +865,16 @@ function parseDocumentNodes(
if (li !== undefined) { if (li !== undefined) {
const b = dl?.bounds?.[li]; const b = dl?.bounds?.[li];
if (b && b.length >= 4 && b[2] > 0 && b[3] > 0) { if (b && b.length >= 4 && b[2] > 0 && b[3] > 0) {
documentRect = { localRect = {
x: b[0] / dpr, x: b[0] / dpr - context.scrollX,
y: b[1] / dpr, y: b[1] / dpr - context.scrollY,
w: b[2] / dpr, w: b[2] / dpr,
h: b[3] / dpr, h: b[3] / dpr,
}; };
localRect = { const bounds = context.projection
x: documentRect.x - context.scrollX, ? projectRectToViewport(localRect, context.projection)
y: documentRect.y - context.scrollY, : null;
w: documentRect.w, rect = bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
h: documentRect.h,
};
rect = rectInTop(localRect, context);
} }
paintOrder = dl?.paintOrders?.[li] ?? 0; paintOrder = dl?.paintOrders?.[li] ?? 0;
const styleRow = dl?.styles?.[li] ?? []; const styleRow = dl?.styles?.[li] ?? [];
@@ -826,20 +889,44 @@ function parseDocumentNodes(
const textContent = nodeTextContent.get(n); const textContent = nodeTextContent.get(n);
const rawFormValueIndex = tag === "textarea" ? textValues.get(n) : inputValues.get(n);
const rawFormValue =
rawFormValueIndex !== undefined ? str(strings, rawFormValueIndex) : undefined;
const sensitive = isSensitiveFormControl({ tag, attrs });
const formDefaultValue = attrs.value ?? "";
const formValue = sensitive ? undefined : rawFormValue;
const formState = snapshotFormState(
rawFormValue,
formDefaultValue,
Object.prototype.hasOwnProperty.call(attrs, "value"),
sensitive,
);
if (sensitive) delete attrs.value;
nodes.push({ nodes.push({
backendNodeId, backendNodeId,
parentBackendNodeId, parentBackendNodeId,
...(context.frameId ? { frameId: context.frameId } : {}),
ownerFrameBackendNodeId: context.ownerFrameBackendNodeId, ownerFrameBackendNodeId: context.ownerFrameBackendNodeId,
tag, tag,
attrs, attrs,
rect, rect,
localRect, localRect,
documentRect,
paintOrder, paintOrder,
position, position,
pointerEvents, pointerEvents,
cursor, cursor,
textContent, textContent,
...(formValue !== undefined ? { formValue } : {}),
...(tag === "input" || tag === "textarea"
? {
formPlaceholder: attrs.placeholder ?? "",
...(!sensitive ? { formDefaultValue } : {}),
...(formState ? { formState } : {}),
}
: {}),
...(checkedInputs.has(n) ? { formValue: "true", formState: "filled" } : {}),
...(selectedOptions.has(n) ? { formValue: attrs.value ?? textContent ?? "" } : {}),
}); });
} }
return { nodes, excludedBackendNodeIds }; return { nodes, excludedBackendNodeIds };
@@ -847,6 +934,8 @@ function parseDocumentNodes(
interface ParseFrameDocumentsResult { interface ParseFrameDocumentsResult {
iframeNodes: CapturedIframeNodes; iframeNodes: CapturedIframeNodes;
frameOwnerBackendNodeIds: Map<string, number>;
frameParentIds: Map<string, string>;
excludedBackendNodeIds: Set<number>; excludedBackendNodeIds: Set<number>;
} }
@@ -860,10 +949,14 @@ function parseChildFrameDocuments(
visited = new Set<number>(), visited = new Set<number>(),
): ParseFrameDocumentsResult { ): ParseFrameDocumentsResult {
const iframeNodes: CapturedIframeNodes = new Map(); const iframeNodes: CapturedIframeNodes = new Map();
const frameOwnerBackendNodeIds = new Map<string, number>();
const frameParentIds = new Map<string, string>();
const excludedBackendNodeIds = new Set<number>(); const excludedBackendNodeIds = new Set<number>();
const parentDoc = documents[parentDocIndex]; const parentDoc = documents[parentDocIndex];
const cdi = sparseIndexMap(parentDoc?.nodes?.contentDocumentIndex); const cdi = sparseIndexMap(parentDoc?.nodes?.contentDocumentIndex);
if (cdi.size === 0) return { iframeNodes, excludedBackendNodeIds }; if (cdi.size === 0) {
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
}
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? []; const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
const parentNodeByBackendId = new Map(parentNodes.map((node) => [node.backendNodeId, node])); const parentNodeByBackendId = new Map(parentNodes.map((node) => [node.backendNodeId, node]));
@@ -875,19 +968,15 @@ function parseChildFrameDocuments(
const iframeBackendId = parentBackendIds[nodeArrayIdx]; const iframeBackendId = parentBackendIds[nodeArrayIdx];
if (iframeBackendId === undefined) continue; if (iframeBackendId === undefined) continue;
const iframeNode = parentNodeByBackendId.get(iframeBackendId); const iframeNode = parentNodeByBackendId.get(iframeBackendId);
if (!iframeNode?.localRect) continue; const projection =
parentContext.projection && iframeNode?.localRect
const iframeRectInTop = unclipRectInTop(iframeNode.localRect, parentContext); ? childFrameProjection(parentContext.projection, iframeNode.localRect)
const childClip = intersectRects(iframeRectInTop, parentContext.clipRectInTop); : null;
if (!childClip) {
iframeNodes.set(iframeBackendId, []);
continue;
}
const childContext: FrameContext = { const childContext: FrameContext = {
frameId: snapshotFrameId(childDoc, strings),
ownerFrameBackendNodeId: iframeBackendId, ownerFrameBackendNodeId: iframeBackendId,
originInTop: { x: iframeRectInTop.x, y: iframeRectInTop.y }, projection,
clipRectInTop: childClip,
scrollX: childDoc.scrollOffsetX ?? 0, scrollX: childDoc.scrollOffsetX ?? 0,
scrollY: childDoc.scrollOffsetY ?? 0, scrollY: childDoc.scrollOffsetY ?? 0,
}; };
@@ -895,6 +984,10 @@ function parseChildFrameDocuments(
nextVisited.add(childDocIndex); nextVisited.add(childDocIndex);
const parsed = parseDocumentNodes(childDoc, strings, dpr, childContext); const parsed = parseDocumentNodes(childDoc, strings, dpr, childContext);
iframeNodes.set(iframeBackendId, parsed.nodes); iframeNodes.set(iframeBackendId, parsed.nodes);
if (childContext.frameId) {
frameOwnerBackendNodeIds.set(childContext.frameId, iframeBackendId);
if (parentContext.frameId) frameParentIds.set(childContext.frameId, parentContext.frameId);
}
for (const id of parsed.excludedBackendNodeIds) excludedBackendNodeIds.add(id); for (const id of parsed.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
const nested = parseChildFrameDocuments( const nested = parseChildFrameDocuments(
@@ -909,10 +1002,16 @@ function parseChildFrameDocuments(
for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) { for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) {
iframeNodes.set(nestedFrameId, nestedNodes); iframeNodes.set(nestedFrameId, nestedNodes);
} }
for (const [frameId, ownerBackendNodeId] of nested.frameOwnerBackendNodeIds) {
frameOwnerBackendNodeIds.set(frameId, ownerBackendNodeId);
}
for (const [frameId, parentFrameId] of nested.frameParentIds) {
frameParentIds.set(frameId, parentFrameId);
}
for (const id of nested.excludedBackendNodeIds) excludedBackendNodeIds.add(id); for (const id of nested.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
} }
return { iframeNodes, excludedBackendNodeIds }; return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
} }
export async function captureViewModel( export async function captureViewModel(
@@ -920,7 +1019,9 @@ export async function captureViewModel(
tabId: number, tabId: number,
options: CaptureViewModelOptions = {}, options: CaptureViewModelOptions = {},
): Promise<CapturedViewModel> { ): Promise<CapturedViewModel> {
throwIfAborted(options.signal);
const metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {}); const metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
throwIfAborted(options.signal);
const dpr = devicePixelRatio(metrics); const dpr = devicePixelRatio(metrics);
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {}; const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
const viewport: Viewport = { const viewport: Viewport = {
@@ -931,11 +1032,13 @@ export async function captureViewModel(
const scrollY = vpSrc.pageY ?? 0; const scrollY = vpSrc.pageY ?? 0;
await cdp.send(tabId, "DOMSnapshot.enable", {}); await cdp.send(tabId, "DOMSnapshot.enable", {});
throwIfAborted(options.signal);
const snap = await cdp.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", { const snap = await cdp.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
computedStyles: REQUESTED_STYLES, computedStyles: REQUESTED_STYLES,
includePaintOrder: true, includePaintOrder: true,
includeDOMRects: true, includeDOMRects: true,
}); });
throwIfAborted(options.signal);
const strings = snap.strings ?? []; const strings = snap.strings ?? [];
const documents = snap.documents ?? []; const documents = snap.documents ?? [];
@@ -951,9 +1054,13 @@ export async function captureViewModel(
} }
const topContext: FrameContext = { const topContext: FrameContext = {
frameId: snapshotFrameId(doc0, strings),
ownerFrameBackendNodeId: null, ownerFrameBackendNodeId: null,
originInTop: { x: 0, y: 0 }, projection: {
clipRectInTop: viewportRect(viewport), sourceClips: [],
edges: [],
topViewport: viewport,
},
scrollX, scrollX,
scrollY, scrollY,
}; };
@@ -967,11 +1074,35 @@ export async function captureViewModel(
excludedBackendNodeIds.add(id); excludedBackendNodeIds.add(id);
} }
await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()]); await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()], options.signal);
throwIfAborted(options.signal);
const surfaceProbes = options.conditionalSurfaceProbe const surfaceProbes = options.conditionalSurfaceProbe
? await probeHoverSurfaces(cdp, tabId, nodes, options) ? await probeHoverSurfaces(cdp, tabId, nodes, options)
: []; : [];
throwIfAborted(options.signal);
return { nodes, viewport, iframeNodes, surfaceProbes, excludedBackendNodeIds }; const frameNodes = new Map<string, CapturedNode[]>();
if (topContext.frameId) frameNodes.set(topContext.frameId, nodes);
for (const iframeFrameNodes of iframeNodes.values()) {
const frameId = iframeFrameNodes.find((node) => node.frameId)?.frameId;
if (frameId) frameNodes.set(frameId, iframeFrameNodes);
}
for (const [frameId, ownerBackendNodeId] of frameParsed.frameOwnerBackendNodeIds) {
if (!frameNodes.has(frameId)) {
frameNodes.set(frameId, iframeNodes.get(ownerBackendNodeId) ?? []);
}
}
return {
nodes,
viewport,
iframeNodes,
frameNodes,
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
frameParentIds: frameParsed.frameParentIds,
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
surfaceProbes,
excludedBackendNodeIds,
};
} }
@@ -0,0 +1,179 @@
import type { CdpFrame, CdpFrameGraph } from "@/browser-driver/frame-graph";
import { resolveFrameProjection } from "../frame-geometry";
import { type GeometryProjection, projectRectToViewport } from "../geometry";
import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared";
import { type CapturedNode, type CapturedViewModel, captureViewModel } from "./capture";
import {
buildFrameDocuments,
type FrameAxBatch,
type FrameDocument,
type FrameOwnedAxNode,
} from "./frame-document";
export type FrameAxNode = FrameOwnedAxNode;
export type CapturedFrameDocument<T extends FrameAxNode> = FrameDocument<T>;
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException("observation aborted", "AbortError");
}
function isAbortError(err: unknown): boolean {
return err instanceof Error && err.name === "AbortError";
}
async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> {
if (!cdp.getFrameGraph) return null;
try {
return await cdp.getFrameGraph(tabId);
} catch (err) {
console.debug("[bsk observation] frame graph capture failed", err);
return null;
}
}
function transformFrameNodes(
nodes: CapturedNode[],
projection: GeometryProjection,
): CapturedNode[] {
return nodes.map((node) => ({
...node,
rect: (() => {
const bounds = projectRectToViewport(node.rect, projection);
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
})(),
}));
}
function frameOwnerNode(captured: CapturedViewModel, frame: CdpFrame): CapturedNode | undefined {
if (frame.ownerBackendNodeId === undefined) return undefined;
const parentNodes = frame.parentFrameId
? captured.frameNodes?.get(frame.parentFrameId)
: captured.nodes;
return parentNodes?.find((node) => node.backendNodeId === frame.ownerBackendNodeId);
}
async function captureMissingFrameDocuments(
cdp: CdpRunner,
tabId: number,
graph: CdpFrameGraph,
captured: CapturedViewModel,
signal?: AbortSignal,
): Promise<void> {
if (!captured.frameNodes) captured.frameNodes = new Map();
if (!captured.frameOwnerBackendNodeIds) captured.frameOwnerBackendNodeIds = new Map();
if (!captured.frameParentIds) captured.frameParentIds = new Map();
for (const frame of graph.frames) {
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
const owner = frameOwnerNode(captured, frame);
if (!owner?.rect || frame.ownerBackendNodeId === undefined) continue;
try {
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
signal,
});
const projection = await resolveFrameProjection(cdp, graph, frame.frameId);
if (!projection) continue;
const rootFrameId = child.rootFrameId ?? frame.frameId;
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
if (rootFrameId !== frame.frameId && !childFrames.has(frame.frameId)) {
childFrames.set(
frame.frameId,
child.nodes.map((node) => ({ ...node, frameId: frame.frameId })),
);
}
for (const [childFrameId, nodes] of childFrames) {
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
}
captured.iframeNodes.set(
frame.ownerBackendNodeId,
captured.frameNodes.get(frame.frameId) ?? [],
);
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
}
for (const [childFrameId, parentFrameId] of child.frameParentIds ?? []) {
captured.frameParentIds.set(childFrameId, parentFrameId);
}
} catch (err) {
if (isAbortError(err)) throw err;
console.debug("[bsk observation] child frame DOM capture failed", {
frameId: frame.frameId,
err,
});
}
}
}
async function captureAxTrees<T extends FrameAxNode>(
cdp: CdpRunner,
tabId: number,
discoveredFrames: CdpFrame[],
captured: CapturedViewModel,
signal?: AbortSignal,
): Promise<FrameAxBatch<T>[]> {
let frames = [...discoveredFrames];
const capturedFrameIds = [...(captured.frameNodes?.keys() ?? [])];
if (frames.length === 0) {
const rootFrameId = captured.rootFrameId ?? capturedFrameIds[0] ?? "root";
frames = [{ frameId: rootFrameId, target: { tabId } }];
}
for (const frameId of capturedFrameIds) {
if (!frames.some((frame) => frame.frameId === frameId)) {
frames.push({ frameId, target: { tabId } });
}
}
let firstFailure: unknown;
const trees = await Promise.all(
frames.map(async (frame): Promise<FrameAxBatch<T>> => {
try {
throwIfAborted(signal);
await sendToCdpTarget(cdp, frame.target, "Accessibility.enable", {});
const result = await sendToCdpTarget<{ nodes?: T[] }>(
cdp,
frame.target,
"Accessibility.getFullAXTree",
frame.frameId === "root" ? {} : { frameId: frame.frameId },
);
throwIfAborted(signal);
return {
frame,
nodes: result.nodes ?? [],
};
} catch (err) {
if (isAbortError(err)) throw err;
firstFailure ??= err;
console.debug("[bsk observation] frame AX capture failed", {
frameId: frame.frameId,
err,
});
return { frame, nodes: [] };
}
}),
);
const capturedNodeCount = [...captured.iframeNodes.values()].reduce(
(count, nodes) => count + nodes.length,
captured.nodes.length,
);
if (firstFailure && capturedNodeCount === 0 && trees.every((tree) => tree.nodes.length === 0)) {
throw firstFailure;
}
return trees;
}
export async function captureFrameData<T extends FrameAxNode>(
cdp: CdpRunner,
tabId: number,
captured: CapturedViewModel,
signal?: AbortSignal,
): Promise<CapturedFrameDocument<T>[]> {
const graph = await discoverFrameGraph(cdp, tabId);
const frames = graph?.frames ?? [];
if (graph) await captureMissingFrameDocuments(cdp, tabId, graph, captured, signal);
throwIfAborted(signal);
const batches = await captureAxTrees<T>(cdp, tabId, frames, captured, signal);
return buildFrameDocuments(graph, batches, captured);
}
@@ -0,0 +1,170 @@
import {
type CdpFrame,
type CdpFrameGraph,
type CdpTarget,
cdpTargetKey,
} from "@/browser-driver/frame-graph";
import type { CapturedNode, CapturedViewModel } from "./capture";
export interface FrameOwnedAxNode {
nodeId: string;
frameId?: string;
parentId?: string;
childIds?: string[];
backendDOMNodeId?: number;
}
export interface FrameAxBatch<T extends FrameOwnedAxNode> {
frame: CdpFrame;
nodes: T[];
}
export interface FrameDocument<T extends FrameOwnedAxNode> extends CdpFrame {
contextScopeId: string;
axNodes: T[];
domNodes: CapturedNode[];
}
interface Ownership {
frameId: string;
strength: number;
}
interface OwnedCandidate<T> {
node: T;
target: CdpTarget;
ownership: Ownership;
}
function targetNodeKey(target: CdpTarget, nodeId: string): string {
return `${cdpTargetKey(target)}:${nodeId}`;
}
function targetBackendKey(target: CdpTarget, backendNodeId: number): string {
return `${cdpTargetKey(target)}:${backendNodeId}`;
}
function frameList<T extends FrameOwnedAxNode>(
graph: CdpFrameGraph | null,
batches: FrameAxBatch<T>[],
captured: CapturedViewModel,
): CdpFrame[] {
const frames = new Map<string, CdpFrame>();
for (const frame of graph?.frames ?? []) frames.set(frame.frameId, frame);
for (const batch of batches) {
if (!frames.has(batch.frame.frameId)) frames.set(batch.frame.frameId, batch.frame);
}
for (const frameId of captured.frameNodes?.keys() ?? []) {
if (!frames.has(frameId))
frames.set(frameId, { frameId, target: { tabId: batches[0]?.frame.target.tabId ?? 0 } });
}
return [...frames.values()].map((frame) => {
const ownerBackendNodeId =
frame.ownerBackendNodeId ?? captured.frameOwnerBackendNodeIds?.get(frame.frameId);
const parentFrameId = frame.parentFrameId ?? captured.frameParentIds?.get(frame.frameId);
return {
...frame,
...(ownerBackendNodeId !== undefined ? { ownerBackendNodeId } : {}),
...(parentFrameId ? { parentFrameId } : {}),
};
});
}
export function buildFrameDocuments<T extends FrameOwnedAxNode>(
graph: CdpFrameGraph | null,
batches: FrameAxBatch<T>[],
captured: CapturedViewModel,
): FrameDocument<T>[] {
const frames = frameList(graph, batches, captured);
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
const backendOwner = new Map<string, string>();
for (const frame of frames) {
for (const node of captured.frameNodes?.get(frame.frameId) ?? []) {
backendOwner.set(targetBackendKey(frame.target, node.backendNodeId), frame.frameId);
}
}
const candidates = new Map<string, OwnedCandidate<T>>();
for (const batch of batches) {
const nodeById = new Map(batch.nodes.map((node) => [node.nodeId, node]));
const ownershipByNodeId = new Map<string, Ownership>();
const resolving = new Set<string>();
const resolveOwnership = (node: T): Ownership => {
const cached = ownershipByNodeId.get(node.nodeId);
if (cached) return cached;
if (node.frameId && frameById.has(node.frameId)) {
const ownership = { frameId: node.frameId, strength: 4 };
ownershipByNodeId.set(node.nodeId, ownership);
return ownership;
}
if (typeof node.backendDOMNodeId === "number") {
const frameId = backendOwner.get(
targetBackendKey(batch.frame.target, node.backendDOMNodeId),
);
if (frameId) {
const ownership = { frameId, strength: 3 };
ownershipByNodeId.set(node.nodeId, ownership);
return ownership;
}
}
if (node.parentId && !resolving.has(node.nodeId)) {
const parent = nodeById.get(node.parentId);
if (parent) {
resolving.add(node.nodeId);
const inherited = resolveOwnership(parent);
resolving.delete(node.nodeId);
const ownership = {
frameId: inherited.frameId,
strength: Math.min(2, inherited.strength),
};
ownershipByNodeId.set(node.nodeId, ownership);
return ownership;
}
}
const ownership = { frameId: batch.frame.frameId, strength: 1 };
ownershipByNodeId.set(node.nodeId, ownership);
return ownership;
};
for (const node of batch.nodes) {
const ownership = resolveOwnership(node);
const key = targetNodeKey(batch.frame.target, node.nodeId);
const existing = candidates.get(key);
if (!existing || ownership.strength > existing.ownership.strength) {
candidates.set(key, { node, target: batch.frame.target, ownership });
}
}
}
const ownershipByTargetNode = new Map(
[...candidates].map(([key, candidate]) => [key, candidate.ownership.frameId]),
);
const axNodesByFrame = new Map<string, T[]>();
for (const candidate of candidates.values()) {
const { node, target, ownership } = candidate;
const parentFrameId = node.parentId
? ownershipByTargetNode.get(targetNodeKey(target, node.parentId))
: undefined;
const childIds = node.childIds?.filter(
(childId) => ownershipByTargetNode.get(targetNodeKey(target, childId)) === ownership.frameId,
);
const ownedNode = {
...node,
frameId: ownership.frameId,
...(parentFrameId === ownership.frameId
? { parentId: node.parentId }
: { parentId: undefined }),
...(childIds ? { childIds } : {}),
};
const nodes = axNodesByFrame.get(ownership.frameId) ?? [];
nodes.push(ownedNode);
axNodesByFrame.set(ownership.frameId, nodes);
}
return frames.map((frame) => ({
...frame,
contextScopeId: frame.frameId,
axNodes: axNodesByFrame.get(frame.frameId) ?? [],
domNodes: captured.frameNodes?.get(frame.frameId) ?? [],
}));
}
+3
View File
@@ -47,7 +47,9 @@ export async function handleWindowResize(
manager: SessionManager, manager: SessionManager,
params: WindowResizeParams, params: WindowResizeParams,
api: WindowResizeApi = chromeWindowResizeApi, api: WindowResizeApi = chromeWindowResizeApi,
signal?: AbortSignal,
): Promise<WindowResizeResult | RpcError> { ): Promise<WindowResizeResult | RpcError> {
if (signal?.aborted) return { code: "cancelled", message: "window_resize aborted" };
const ctxOrErr = lookupSession(manager, params, "window_resize"); const ctxOrErr = lookupSession(manager, params, "window_resize");
if (isRpcError(ctxOrErr)) return ctxOrErr; if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr; const ctx = ctxOrErr;
@@ -64,6 +66,7 @@ export async function handleWindowResize(
} }
try { try {
if (signal?.aborted) return { code: "cancelled", message: "window_resize aborted" };
await api.update(ctx.agentWindowId, { await api.update(ctx.agentWindowId, {
width: sizeOrErr.width, width: sizeOrErr.width,
height: sizeOrErr.height, height: sizeOrErr.height,
@@ -65,11 +65,6 @@ function deferredFakeTransport(): { transport: Transport; emit: (frame: Protocol
} }
describe("performHandshake", () => { describe("performHandshake", () => {
it("advertises the Trace v3 compatibility boundary", () => {
expect(PROTOCOL_VERSION).toBe("1.1");
expect(MIN_COMPATIBLE_PROTOCOL).toBe("1.1");
});
it("sends system.handshake with identity and both compat fields", async () => { it("sends system.handshake with identity and both compat fields", async () => {
let sentFrame: ProtocolFrame | null = null; let sentFrame: ProtocolFrame | null = null;
const transport = fakeTransport((req) => { const transport = fakeTransport((req) => {
@@ -79,9 +74,9 @@ describe("performHandshake", () => {
result: { result: {
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.1", protocol_version: "1.0",
min_compatible_peer: "0.0.0", min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.1", min_compatible_protocol: "1.0",
}, },
}; };
}); });
@@ -107,7 +102,7 @@ describe("performHandshake", () => {
}, },
}); });
expect(outcome.result.min_compatible_peer).toBe("0.0.0"); expect(outcome.result.min_compatible_peer).toBe("0.0.0");
expect(outcome.result.min_compatible_protocol).toBe("1.1"); expect(outcome.result.min_compatible_protocol).toBe("1.0");
}); });
it("accepts legacy daemon reply with only min_compatible_peer", async () => { it("accepts legacy daemon reply with only min_compatible_peer", async () => {
@@ -134,8 +129,8 @@ describe("performHandshake", () => {
const response = { const response = {
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.1", protocol_version: "1.0",
min_compatible_protocol: "1.1", min_compatible_protocol: "1.0",
} satisfies HandshakeResult; } satisfies HandshakeResult;
const transport = fakeTransport((req) => ({ const transport = fakeTransport((req) => ({
id: (req as { id: string }).id, id: (req as { id: string }).id,
@@ -150,7 +145,7 @@ describe("performHandshake", () => {
}); });
expect(outcome.result.min_compatible_peer).toBeUndefined(); expect(outcome.result.min_compatible_peer).toBeUndefined();
expect(outcome.result.min_compatible_protocol).toBe("1.1"); expect(outcome.result.min_compatible_protocol).toBe("1.0");
}); });
it("rejects when the daemon responds with an error", async () => { it("rejects when the daemon responds with an error", async () => {
@@ -183,9 +178,9 @@ describe("performHandshake", () => {
result: { result: {
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.1", protocol_version: "1.0",
min_compatible_peer: "0.0.0", min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.1", min_compatible_protocol: "1.0",
}, },
}); });
emit({ emit({
@@ -193,14 +188,14 @@ describe("performHandshake", () => {
result: { result: {
server: "browser-skill-daemon", server: "browser-skill-daemon",
version: "0.1.0", version: "0.1.0",
protocol_version: "1.1", protocol_version: "1.0",
min_compatible_peer: "0.0.0", min_compatible_peer: "0.0.0",
min_compatible_protocol: "1.1", min_compatible_protocol: "1.0",
}, },
}); });
await expect(pending).resolves.toMatchObject({ await expect(pending).resolves.toMatchObject({
result: { server: "browser-skill-daemon", protocol_version: "1.1" }, result: { server: "browser-skill-daemon", protocol_version: "1.0" },
}); });
}); });
+2 -2
View File
@@ -7,7 +7,7 @@ import type {
ResponseFrame, ResponseFrame,
} from "./types"; } from "./types";
export const PROTOCOL_VERSION = "1.1"; export const PROTOCOL_VERSION = "1.0";
/** /**
* Extension semver, injected at build time from `package.json` via * Extension semver, injected at build time from `package.json` via
* Vite's `define` (see `wxt.config.ts` and `vitest.config.ts`). * Vite's `define` (see `wxt.config.ts` and `vitest.config.ts`).
@@ -18,7 +18,7 @@ export const EXTENSION_VERSION: string =
* Lowest **protocol** version this extension accepts (e.g. `"1.0"`). * Lowest **protocol** version this extension accepts (e.g. `"1.0"`).
* Must stay in sync with daemon `MIN_COMPATIBLE_PROTOCOL`. * Must stay in sync with daemon `MIN_COMPATIBLE_PROTOCOL`.
*/ */
export const MIN_COMPATIBLE_PROTOCOL = "1.1"; export const MIN_COMPATIBLE_PROTOCOL = "1.0";
/** /**
* **Deprecated** — legacy app-semver floor for wire compat with old * **Deprecated** — legacy app-semver floor for wire compat with old
* daemons. New code sends `"0.0.0"`; compat decisions ignore this. * daemons. New code sends `"0.0.0"`; compat decisions ignore this.
+77 -150
View File
@@ -3,8 +3,6 @@
// stay structural (interface, not class) so the same JSON parses on both // stay structural (interface, not class) so the same JSON parses on both
// sides without extra adapters. // sides without extra adapters.
import type { CaptureTargetDescriptor } from "@/lib/describe-target";
export type RpcId = string; export type RpcId = string;
export type ErrorCode = export type ErrorCode =
@@ -34,10 +32,23 @@ export type RpcErrorReason =
| "tab_not_active" | "tab_not_active"
| "restricted_tab_url" | "restricted_tab_url"
| "borrow_conflict" | "borrow_conflict"
| "screenshot_capture_failed"; | "screenshot_capture_failed"
| "file_input_probe_failed"
| "file_input_not_activated"
| "set_file_input_failed"
| "download_capture_failed"
| "transfer_outcome_unknown"
| "transfer_timeout"
| "cleanup_failed";
export type TransferEffectState = "none" | "committed" | "unknown";
export type TransferCleanupState = "complete" | "failed";
export interface RpcErrorData { export interface RpcErrorData {
reason?: RpcErrorReason; reason?: RpcErrorReason;
effect_state?: TransferEffectState;
phase?: string;
cleanup_state?: TransferCleanupState;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -501,6 +512,50 @@ export interface SelectResult {
dialogs?: JavaScriptDialogInfo[]; dialogs?: JavaScriptDialogInfo[];
} }
export interface UploadFile {
transfer_id: string;
name: string;
staged_path?: string;
}
export interface UploadParams {
session_id: string;
ref?: string;
selector?: string;
tab_id?: number;
files: UploadFile[];
timeout_ms?: number;
}
export interface UploadResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
file_names: string[];
}
export interface DownloadParams {
session_id: string;
ref?: string;
selector?: string;
tab_id?: number;
timeout_ms?: number;
browser_relative_dir?: string;
max_byte_size?: number;
}
export interface DownloadResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
suggested_filename: string;
byte_size: number;
mime?: string;
danger?: string;
browser_path?: string;
transfer_id?: string;
}
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// M9 tool payloads — evaluate / wait_for_navigation / wait_ms // M9 tool payloads — evaluate / wait_for_navigation / wait_ms
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@@ -658,72 +713,26 @@ export interface EmulateResult {
} }
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Semantic record payloads — mirror bsk-protocol record.rs (Trace v3) // Semantic record payloads — mirror bsk-protocol record.rs (Trace v2)
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
export const TRACE_VERSION = 3;
export const VOM_FORMAT_VERSION = 1;
export interface TargetDescriptor { export interface TargetDescriptor {
ref?: string;
role?: string; role?: string;
name?: string; name?: string;
ctx?: string; tag: string;
unmatched?: boolean; name_attr?: string;
placeholder?: string;
nearby_label?: string;
} }
export interface TraceEntry { export interface TraceEntry {
start_url: string; start_url: string;
} }
export interface RecorderInfo { export interface PageRef {
bsk: string;
vom: number;
}
export type FrameCaptureStatus = "complete" | "partial";
export type FrameCaptureFailureReason =
| "not_injectable"
| "arm_failed"
| "rearm_failed"
| "drain_failed"
| "flush_failed";
export interface FrameCaptureFailure {
reason: FrameCaptureFailureReason;
frame_id: number;
document_id?: string;
url?: string;
detail?: string;
}
export interface FrameCaptureInfo {
status: FrameCaptureStatus;
failures?: FrameCaptureFailure[];
}
export type StopReason = "user_finish" | "cli_stop";
export interface TraceState {
id: string; id: string;
url: string; url: string;
title?: string; title?: string;
/** Wire-only: full page observation text. */
body?: string;
/** Disk-only: filename under the bundle `pages/` directory. */
page?: string;
truncated?: boolean;
}
export interface StepResult {
state: string;
}
export interface StepCommon {
id: number;
state: string;
result: StepResult;
} }
export interface SelectedOption { export interface SelectedOption {
@@ -731,140 +740,66 @@ export interface SelectedOption {
label?: string; label?: string;
} }
export type NavigationCause = export interface StepEffect {
| "user_typed" navigated_to: string;
| "link"
| "form_submit"
| "reload"
| "history"
| "script"
| "browser";
export type FillCommit = "enter" | "suggestion" | "blur";
/** Geometry reported by the content script at capture time. */
export interface CaptureGeometry {
rect: { x: number; y: number; w: number; h: number };
scrollX: number;
scrollY: number;
position: string;
tag: string;
ownerFrameBackendNodeId?: number | null;
} }
/** Capture/buffer draft before v3 reduction. */ export interface StepCommon {
id: number;
page: string;
effect?: StepEffect;
}
/** Capture/buffer draft before v2 reduction. */
export type DraftTraceStep = export type DraftTraceStep =
| { | {
op: "click"; op: "click";
target: TargetDescriptor; target: TargetDescriptor;
captureTarget?: CaptureTargetDescriptor;
geometry?: CaptureGeometry;
navigated_to?: string; navigated_to?: string;
page_url?: string; page_url?: string;
title?: string;
preStateId?: string;
postStateId?: string;
observationBody?: string;
observationHash?: string;
truncated?: boolean;
} }
| { | {
op: "hover"; op: "hover";
target: TargetDescriptor; target: TargetDescriptor;
captureTarget?: CaptureTargetDescriptor;
geometry?: CaptureGeometry;
page_url?: string; page_url?: string;
title?: string;
preStateId?: string;
postStateId?: string;
observationBody?: string;
observationHash?: string;
truncated?: boolean;
} }
| { | {
op: "fill"; op: "fill";
target: TargetDescriptor; target: TargetDescriptor;
captureTarget?: CaptureTargetDescriptor;
geometry?: CaptureGeometry;
value: string; value: string;
commit?: FillCommit;
redacted?: boolean; redacted?: boolean;
navigated_to?: string;
page_url?: string; page_url?: string;
title?: string;
preStateId?: string;
postStateId?: string;
observationBody?: string;
observationHash?: string;
truncated?: boolean;
} }
| { | {
op: "press"; op: "press";
key: string; key: string;
target?: TargetDescriptor; target?: TargetDescriptor;
captureTarget?: CaptureTargetDescriptor;
geometry?: CaptureGeometry;
modifiers?: KeyModifier[]; modifiers?: KeyModifier[];
navigated_to?: string; navigated_to?: string;
page_url?: string; page_url?: string;
title?: string;
preStateId?: string;
postStateId?: string;
observationBody?: string;
observationHash?: string;
truncated?: boolean;
} }
| { | {
op: "select"; op: "select";
target: TargetDescriptor; target: TargetDescriptor;
captureTarget?: CaptureTargetDescriptor;
geometry?: CaptureGeometry;
values: string[]; values: string[];
labels?: string[]; labels?: string[];
navigated_to?: string; navigated_to?: string;
page_url?: string; page_url?: string;
title?: string;
preStateId?: string;
postStateId?: string;
observationBody?: string;
observationHash?: string;
truncated?: boolean;
}
| {
op: "scroll";
page_url?: string;
title?: string;
preStateId?: string;
postStateId?: string;
observationBody?: string;
observationHash?: string;
truncated?: boolean;
} }
| { | {
op: "navigate"; op: "navigate";
url: string; url: string;
cause?: NavigationCause;
page_url?: string; page_url?: string;
title?: string;
preStateId?: string;
postStateId?: string;
observationBody?: string;
observationHash?: string;
truncated?: boolean;
/** Raw webNavigation transition metadata for cause mapping. */
transitionType?: string;
transitionQualifiers?: string[];
}; };
/** Exported record-only step (trace v3). */ /** Exported record-only step (trace v2). */
export type Step = export type Step =
| ({ op: "navigate" } & StepCommon & { to: string; cause: NavigationCause }) | ({ op: "navigate" } & StepCommon & { to: string })
| ({ op: "click" } & StepCommon & { target: TargetDescriptor }) | ({ op: "click" } & StepCommon & { target: TargetDescriptor })
| ({ op: "hover" } & StepCommon & { target: TargetDescriptor }) | ({ op: "hover" } & StepCommon & { target: TargetDescriptor })
| ({ op: "fill" } & StepCommon & { | ({ op: "fill" } & StepCommon & {
target: TargetDescriptor; target: TargetDescriptor;
value: string; value: string;
commit: FillCommit;
redacted?: boolean; redacted?: boolean;
}) })
| ({ op: "select" } & StepCommon & { | ({ op: "select" } & StepCommon & {
@@ -875,20 +810,14 @@ export type Step =
key: string; key: string;
modifiers?: KeyModifier[]; modifiers?: KeyModifier[];
target?: TargetDescriptor; target?: TargetDescriptor;
}) });
| ({ op: "scroll" } & StepCommon);
export interface Trace { export interface Trace {
version: number;
recorded_at: string; recorded_at: string;
started_at?: string; started_at?: string;
purpose?: string; purpose?: string;
stopped_by: StopReason;
entry: TraceEntry; entry: TraceEntry;
recorder: RecorderInfo; pages: PageRef[];
/** Present when one or more frames could not be armed, rearmed, or flushed. */
frame_capture?: FrameCaptureInfo;
states: TraceState[];
steps: Step[]; steps: Step[];
} }
@@ -897,8 +826,6 @@ export interface RecordStartParams {
tab_id?: number; tab_id?: number;
url?: string; url?: string;
purpose?: string; purpose?: string;
max_page_tokens?: number;
redact_values?: boolean;
} }
export interface RecordStartResult { export interface RecordStartResult {
+3 -4
View File
@@ -25,13 +25,12 @@ export default defineConfig({
name: "BrowserSkill", name: "BrowserSkill",
description: description:
"Let AI agents use your logged-in browser in a separate Agent Window—without interrupting your work. Powered by the bsk CLI.", "Let AI agents use your logged-in browser in a separate Agent Window—without interrupting your work. Powered by the bsk CLI.",
// Chrome 116 is the first version where WebSocket activity resets the // Flat debugger sessions are required to address out-of-process iframes.
// service-worker idle timer, which the 20s heartbeat relies on to minimum_chrome_version: "125",
// keep the daemon link alive.
minimum_chrome_version: "116",
permissions: [ permissions: [
"alarms", "alarms",
"debugger", "debugger",
"downloads",
"idle", "idle",
"notifications", "notifications",
"tabs", "tabs",
+5
View File
@@ -39,6 +39,11 @@
"**", "**",
"!**/node_modules/**", "!**/node_modules/**",
"!**/dist/**", "!**/dist/**",
"!packages/dsh-plugin-browserskill/lib/**",
"!packages/dsh-plugin-browserskill/src/skill-content.generated.ts",
"!packages/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css",
"!packages/dsh-plugin-browserskill/.bsk-ui.raw.css",
"!packages/dsh-plugin-browserskill/.bsk-ui.input.css",
"!**/target/**", "!**/target/**",
"!**/.wxt/**", "!**/.wxt/**",
"!**/.output/**", "!**/.output/**",
+2
View File
@@ -53,6 +53,7 @@ flate2 = { workspace = true }
tar = { workspace = true } tar = { workspace = true }
zip = { workspace = true } zip = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
base64 = { workspace = true }
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
nix = { workspace = true } nix = { workspace = true }
@@ -63,6 +64,7 @@ windows-sys = { version = "0.59", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_Security", "Win32_Security",
"Win32_Storage_FileSystem",
] } ] }
[dev-dependencies] [dev-dependencies]
+29 -14
View File
@@ -206,6 +206,29 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) | | `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
| `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) | | `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |
### File transfer (require `--session`)
| Command | Summary |
|---------|---------|
| `bsk upload <ref-or-selector> --file <path>` | Click one upload trigger and attach an agent-readable local file (`--file` is repeatable) |
| `bsk download <ref-or-selector> --out <path>` | Click one download trigger and copy the single completed file to an exact local path (`--overwrite` is opt-in) |
The agent/harness decides whether a file transfer is appropriate and which local path belongs to the task. Treat upload as disclosure of that file to the current website, and download as accepting website-controlled bytes onto the local filesystem. Use only paths that are necessary for the user's bounded goal.
BrowserSkill enforces the mechanical boundary: files are staged under a session-scoped opaque transfer, only daemon-minted capabilities reach the extension, upload/download still obey Agent Window tab checks, and transfers are chunk/size bounded. Upload intercepts the native chooser for one transaction, locates the input activated in the resolved target's document, and assigns only the staged file paths. Download uniquely correlates one exact-target browser intent with one Chrome download in either event order, routes it through a daemon-minted relative directory, and lets the daemon validate and import it. Upload staging remains available for a later form submission and is removed when the session ends. Downloads cannot overwrite an existing destination unless `--overwrite` is explicit. BrowserSkill does not inspect file content or decide whether its meaning is sensitive.
Do not use `request-help` merely because a native file chooser or browser download is involved; try these commands first. For transfer failures, use the structured error instead of retrying blindly:
- `reason=file_input_not_activated` means the requested click did not activate exactly one `<input type="file">`; the page may use a non-input picker such as `window.showOpenFilePicker()`. Do not retry blindly. If human help is available, call `request-help` and tell the user the exact original local path to choose. The staged daemon path is internal and must not be shown to the user.
- `reason=file_input_probe_failed` means BrowserSkill could not safely establish the browser-side upload transaction. Do not repeat the same action; use `request-help` when available.
- `reason=set_file_input_failed` means BrowserSkill found the activated file input but Chrome rejected the staged path or assignment. Check the extension's file-URL access permission; otherwise use `request-help`.
- `reason=download_capture_failed` means BrowserSkill could not attribute exactly one completed download to the requested target. Do not retry blindly or accept an unrelated browser download; use `request-help` when available.
- `effect_state=none` means BrowserSkill confirmed that no file-transfer effect was committed. Follow the accompanying reason; a corrected target or explicit human fallback may be attempted.
- `effect_state=unknown` means the browser may already have attached or created the file. Do not repeat the transfer. Observe the page if that can establish the result; otherwise stop and report the uncertainty.
- `effect_state=committed` means the browser-side effect occurred even if later completion or cleanup failed. Do not repeat it; continue only after verifying the resulting page/download state.
If `request-help` returns `outcome="disabled"`, do not retry it. Stop gracefully and report the transfer mechanism that requires human intervention.
### Scripting & timing ### Scripting & timing
| Command | Summary | | Command | Summary |
@@ -265,27 +288,19 @@ retry — complete the task autonomously or stop gracefully.
### Recording — `bsk record` ### Recording — `bsk record`
Capture the user's own actions in the Agent Window to a **trace v3 bundle**, for later LLM-driven automation: Capture the user's own actions in the Agent Window to a `trace.json`, for later LLM-driven automation:
```bash ```bash
bsk record start --browser <instance-id-or-label> \ bsk record start --browser <instance-id-or-label> [--url https://…] [--purpose "publish a wiki doc"] [--output trace.json]
[--url https://…] [--purpose "publish a wiki doc"] \
[--max-page-tokens 3000] [--redact-values] \
[--output trace]
# `--url` is optional; default https://example.com/ when omitted (must be http(s)). # `--url` is optional; default https://example.com/ when omitted (must be http(s)).
# Blocks until the user clicks Finish in the recording panel, then writes: # Blocks until the user clicks Finish in the recording panel, then writes ./trace.json and closes the window.
# trace/trace.json — action chain + state index (version 3)
# trace/pages/ — one `.vom.txt` observe snapshot per settled page state
bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable bsk record stop [--output trace.json] # terminal fallback if the browser panel is unavailable
``` ```
- **Bundle layout:** `--output` is a directory (default `./trace`) containing `trace.json` and `pages/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `pages/sN.vom.txt` using the same VOM format as `bsk observe`. - The trace is a **record-only action log** (a `pages[]` dictionary + `navigate`/`click`/`fill`/`select`/`press` steps with `target` descriptors). It records *what the user did*; deciding which inputs are variable is left to the executing agent.
- Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer).
- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead.
- `--purpose` is optional context metadata; it does **not** change what gets captured. - `--purpose` is optional context metadata; it does **not** change what gets captured.
- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`. - There is **no** `bsk replay` — to redo a flow, read the trace and reuse the existing `session` / `snapshot` / `@eN` / `click` / `fill` tools. Follow **Stop when the goal is met**.
- Recording captures interactions in the **top frame and injectable child frames** (same-origin and cross-origin where the extension can attach). Embedded frames that cannot be injected are omitted; the trace may include `frame_capture.status: "partial"` with failure details.
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text. - Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
## Error handling ## Error handling
+75
View File
@@ -0,0 +1,75 @@
//! Atomic visibility boundary for files received into a same-directory temp.
use std::path::Path;
pub fn commit(temp: &Path, out: &Path, overwrite: bool) -> std::io::Result<()> {
if !overwrite {
std::fs::hard_link(temp, out)?;
std::fs::remove_file(temp)?;
return Ok(());
}
replace(temp, out)
}
#[cfg(unix)]
fn replace(temp: &Path, out: &Path) -> std::io::Result<()> {
// POSIX rename replaces an existing non-directory destination atomically.
std::fs::rename(temp, out)
}
#[cfg(windows)]
fn replace(temp: &Path, out: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
};
let from: Vec<u16> = temp.as_os_str().encode_wide().chain(Some(0)).collect();
let to: Vec<u16> = out.as_os_str().encode_wide().chain(Some(0)).collect();
// SAFETY: both buffers are NUL-terminated and remain alive for the call.
let ok = unsafe {
MoveFileExW(
from.as_ptr(),
to.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if ok == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(not(any(unix, windows)))]
fn replace(temp: &Path, out: &Path) -> std::io::Result<()> {
std::fs::rename(temp, out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_replace_never_overwrites_existing_output() {
let dir = tempfile::tempdir().unwrap();
let temp = dir.path().join("new.part");
let out = dir.path().join("out.bin");
std::fs::write(&temp, b"new").unwrap();
std::fs::write(&out, b"old").unwrap();
assert!(commit(&temp, &out, false).is_err());
assert_eq!(std::fs::read(&out).unwrap(), b"old");
}
#[test]
fn overwrite_replaces_existing_output_without_predelete() {
let dir = tempfile::tempdir().unwrap();
let temp = dir.path().join("new.part");
let out = dir.path().join("out.bin");
std::fs::write(&temp, b"new").unwrap();
std::fs::write(&out, b"old").unwrap();
commit(&temp, &out, true).unwrap();
assert_eq!(std::fs::read(&out).unwrap(), b"new");
assert!(!temp.exists());
}
}

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