diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4db6e08..a57a2db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,56 @@ jobs: - name: Test all features run: cargo test --all-features + + - uses: taiki-e/install-action@cargo-llvm-cov + + - name: Coverage + run: cargo llvm-cov --all-features --workspace --summary-only --fail-under-lines 90 + + chrome-extension: + name: Chrome Extension + runs-on: ubuntu-latest + defaults: + run: + working-directory: extension + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint, typecheck, unit test, and build + run: npm run verify + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run extension end-to-end tests + run: npm run test:e2e + + - name: Build deterministic release archive + run: npm run package + + - name: Upload release archive + uses: actions/upload-artifact@v6 + with: + name: tinyflows-chrome-extension + path: | + extension/artifacts/*.zip + extension/artifacts/*.sha256 + + - name: Upload Playwright traces on failure + if: failure() + uses: actions/upload-artifact@v6 + with: + name: chrome-extension-playwright-traces + path: extension/test-results/ + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 4759c2e..0eaa9c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_No unreleased changes yet._ +### Added + +- Versioned browser action contracts plus run/tab-bound `ChromeToolInvoker` and + composable `RoutingToolInvoker` support for explicit `slug: "browser"` nodes. +- An authenticated loopback companion with pairing-secret rotation, explicit + shared-tab/run binding, action correlation, timeouts, heartbeats, workflow + listing/start/cancel controls, and native CLI commands. +- A locally bundled MV3 Chrome extension with debugger-based browser actions, + visible tab-group consent, popup pairing, a workflow side panel, unit tests, + Playwright coverage, and deterministic release packaging. ## [0.3.0] - YYYY-MM-DD diff --git a/Cargo.lock b/Cargo.lock index 8fdf794..fa62424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,61 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -76,6 +131,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -155,6 +219,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -164,6 +237,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -173,6 +256,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "defmt" version = "1.1.0" @@ -205,15 +294,25 @@ dependencies = [ "thiserror", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -376,6 +475,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -454,6 +563,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -476,6 +591,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -807,12 +923,24 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.1" @@ -1267,6 +1395,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1279,6 +1418,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1286,8 +1436,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1296,6 +1446,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1418,11 +1578,15 @@ name = "tinyflows" version = "0.5.1" dependencies = [ "async-trait", + "axum", "futures-timer", + "futures-util", + "getrandom 0.3.4", "jaq-core", "jaq-json", "jaq-std", "proptest", + "reqwest", "serde", "serde_json", "thiserror", @@ -1466,6 +1630,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1492,6 +1657,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1518,6 +1695,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1556,6 +1734,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1587,6 +1766,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + [[package]] name = "typed-arena" version = "2.0.2" @@ -1641,6 +1836,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 5512b58..82894ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ include = [ "/Cargo.toml", "/README.md", "/LICENSE", + "/extension/dist/**", ] [dependencies] @@ -27,8 +28,13 @@ serde_json = "1" async-trait = "0.1" thiserror = "2" futures-timer = "3" +futures-util = "0.3" +getrandom = "0.3" tinyagents = "2.1" tracing = "0.1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } +axum = { version = "0.8", features = ["ws"] } +reqwest = { version = "0.12", default-features = false, features = ["json"] } jaq-core = "3.1.0" jaq-std = "3.0.1" jaq-json = { version = "2.0.1", features = ["serde"] } @@ -40,6 +46,5 @@ default = [] mock = [] [dev-dependencies] -tokio = { version = "1", features = ["macros", "rt", "time"] } proptest = "1" async-trait = "0.1" diff --git a/README.md b/README.md index 054d1e9..5735a95 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ Rust 2024 · MSRV 1.85 · `#![forbid(unsafe_code)]` · GPL-3.0-or-later. secrets; the crate never sees them. - Versioned wire format: graph `schema_version` and per-node `type_version`, with a `migrate` framework for load-time upgrades. +- Optional Chrome workflow companion: a native loopback relay and MV3 extension + execute explicit `tool_call` nodes with `slug: "browser"` in user-shared tabs, + while every other tool remains with the embedding host's invoker. ## How it works @@ -247,6 +250,19 @@ cargo test # unit + compiler + engine tests (mocks auto-availabl cargo test --all-features # also exercises the `mock` capability impls explicitly ``` +The Chrome extension is a separate local package: + +```sh +cd extension +npm ci +npm run verify +npm run test:e2e +npm run package +``` + +See [Chrome workflow companion](docs/chrome-extension.md) for installation, +pairing, the browser node contract, and the security boundary. + The crate is `#![forbid(unsafe_code)]` and fully documented (`#![warn(missing_docs)]`). The CI gate is: diff --git a/docs/chrome-extension.md b/docs/chrome-extension.md new file mode 100644 index 0000000..0dae66f --- /dev/null +++ b/docs/chrome-extension.md @@ -0,0 +1,79 @@ +# Chrome workflow companion + +TinyFlows can drive ordinary signed-in Chrome tabs through an unpacked Manifest V3 extension. The native companion remains the workflow runtime: it validates and executes `WorkflowGraph`s, owns retries and credentials, and dispatches non-browser integrations. The extension receives only a correlated browser action and the tab already bound to that run. + +## Build, install, and pair + +```bash +cd extension +npm ci +npm run verify +npm run build +cd .. +cargo run -- extension path +``` + +Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select the printed directory. The CLI materializes its embedded, versioned extension there, so the path remains valid after `cargo install` removes its build sources. Copy the extension id shown by Chrome, then start the companion: + +```bash +cargo run -- pair +cargo run -- companion start \ + --extension-id <32-character-extension-id> \ + --workflows-dir "$PWD/workflows" +``` + +`pair` prints a loopback relay URL and a pairing token. Paste both into the toolbar popup. The token is stored in `~/.tinyflows/credentials/chrome-extension-relay.secret` with mode `0600` on Unix. It is carried in `Sec-WebSocket-Protocol`, never in the URL. Rotate it with `tinyflows pair --rotate`, restart the companion, and pair the extension again. + +Click **Share with TinyFlows** on an ordinary HTTP(S) tab. Chrome places it in the clearly named **TinyFlows shared tabs** group and shows its debugger banner. Moving the tab out of that group, closing it, or detaching the debugger revokes access immediately. The side panel starts a workflow in its own shared tab; the CLI always requires an explicit tab: + +```bash +tinyflows tabs +tinyflows workflows +tinyflows run checkout --tab 42 --input '{"query":"boots"}' +``` + +Workflow files are `.json` in the configured directory. The standalone CLI intentionally rejects LLM, HTTP, code, and non-browser integration effects because it has no credentials. An embedding host constructs `CompanionServer` with its own `Capabilities`; `RoutingToolInvoker` then sends only `slug: "browser"` to Chrome and delegates every other slug, arguments object, and `connection_ref` unchanged. + +## Browser node contract + +Browser work remains an ordinary `tool_call`; `WorkflowGraph`, `NodeKind`, and `Capabilities` are unchanged: + +```json +{ + "kind": "tool_call", + "config": { + "slug": "browser", + "args": { + "action": "click", + "selector": "button[type=submit]" + }, + "retry": { "max_attempts": 2, "backoff": "fixed", "backoff_ms": 500 } + } +} +``` + +`args.action` is mandatory and deterministic. Supported actions are `open`, `snapshot`, `click`, `fill`, `type`, `get_text`, `get_title`, `get_url`, `screenshot`, `wait`, `press`, `hover`, `scroll`, `is_visible`, `close`, and semantic `find`. The canonical v1 schema and cross-language fixtures live in [`protocol/`](../protocol/). + +Failures reach normal TinyFlows retry and error-port handling as capability errors prefixed with a stable code, including `tab_not_shared`, `tab_revoked`, `relay_disconnected`, `unsupported_page`, `action_timeout`, and `element_not_found`. A relay disconnect or user revocation always fails the in-flight action; it is retried only if the node declares a retry policy. + +## Security boundary + +- The HTTP/WebSocket listener is fixed to `127.0.0.1`; non-loopback policies are rejected. +- The WebSocket upgrade requires the exact paired `chrome-extension://` origin, protocol v1, and host-local token. +- Native CLI endpoints require the same token in an `Authorization` header. +- The native registry contains only tabs announced after group and debugger validation. Every run is immutably bound to one tab generation. +- Restricted Chrome pages and non-HTTP(S) navigation are refused. Unshared tabs are never enumerated to a run. +- The MV3 worker uses `chrome.debugger` directly and bundles all executable JavaScript locally; there is no page bridge or remotely hosted code. +- Workflow state, credentials, approvals, integration dispatch, and retry policy never cross into the extension. + +## Upgrade and troubleshooting + +Protocol messages declare `protocol_version: 1` and reject unknown fields. Upgrade the companion and extension together when the major protocol version changes. A version mismatch fails closed with `protocol_mismatch`. + +- **Popup stays reconnecting:** confirm the companion is listening on the same port and rerun `tinyflows pair`; tokens must contain at least 32 ASCII alphanumeric characters. +- **Unauthorized relay:** pass the extension id displayed by the currently loaded unpacked extension. Reloading from a path can change the id if Chrome does not preserve it. +- **Tab missing from `tinyflows tabs`:** share it after the relay connects. Remove and re-add it if the debugger or group was changed while disconnected. +- **`unsupported_page`:** navigate to a regular `http://` or `https://` page; Chrome internal, extension, DevTools, and view-source pages are unavailable. +- **`relay_disconnected` or `tab_revoked`:** the action stopped. Reconnect/re-share, then rerun or rely on an explicitly declared workflow retry. + +Build a deterministic store-ready archive with `npm run package`; it writes the ZIP and SHA-256 checksum under `extension/artifacts/`. Chrome Web Store submission and OpenHuman host wiring are intentionally separate follow-up work. diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 0000000..d9d6cd1 --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +# `dist/` is committed because the Rust CLI embeds and materializes the +# unpacked extension for cargo-installed binaries. +artifacts/*.zip +!artifacts/*.sha256 +coverage/ +test-results/ +playwright-report/ diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..4a5e844 --- /dev/null +++ b/extension/README.md @@ -0,0 +1,34 @@ +# TinyFlows Browser Companion + +This Manifest V3 extension executes explicit TinyFlows browser actions in Chrome tabs that the user shares. The workflow runtime, credentials, approvals, retries, and non-browser integrations remain in the native TinyFlows companion. + +## Development install + +```bash +npm ci +npm run verify +npm run test:e2e +npm run package +``` + +Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select `extension/dist`. The package command also creates a reproducible store-ready ZIP and SHA-256 file under `extension/artifacts/`. + +Use the toolbar popup to enter the pairing string printed by the native companion. Pairing credentials are stored in `chrome.storage.local` and sent only through the WebSocket subprotocol to the loopback relay; they are never placed in a URL. The relay URL must resolve to `localhost`, `127.0.0.1`, or `::1` over `ws://`. + +## Sharing and running + +The popup shares only the active ordinary HTTP(S) tab. Shared tabs are placed in the clearly named **TinyFlows shared tabs** group and attached through `chrome.debugger`. Removing a tab from that group, closing it, renaming/removing the group, or detaching the debugger revokes automation. Restricted browser pages are refused. + +The side panel lists workflows exposed by the paired companion, starts a workflow bound to its active tab, shows native run events, and can cancel the current run. It never runs workflow graphs itself. + +Browser tool nodes use the explicit `browser` slug and a typed action object. Supported actions are `open`, `snapshot`, `click`, `fill`, `type`, `get_text`, `get_title`, `get_url`, `screenshot`, `wait`, `press`, `hover`, `scroll`, `is_visible`, `close`, and `find`. Other tool slugs stay with the host-provided integration invoker. + +## Permissions + +- `debugger`: send Chrome DevTools Protocol commands after explicit sharing. +- `tabs` and `tabGroups`: bind and visibly group only shared tabs. +- `storage`: persist pairing configuration and attachment metadata across service-worker restarts. +- `sidePanel`: display workflows and run progress. +- ``: allow ordinary sites to be automated after explicit sharing; it does not expose unshared tabs. + +All extension JavaScript is bundled locally. There are no remotely hosted scripts or permanent content-script bridges. diff --git a/extension/artifacts/tinyflows-chrome-extension-0.1.0.zip b/extension/artifacts/tinyflows-chrome-extension-0.1.0.zip new file mode 100644 index 0000000..a960e77 Binary files /dev/null and b/extension/artifacts/tinyflows-chrome-extension-0.1.0.zip differ diff --git a/extension/artifacts/tinyflows-chrome-extension-0.1.0.zip.sha256 b/extension/artifacts/tinyflows-chrome-extension-0.1.0.zip.sha256 new file mode 100644 index 0000000..3826ae9 --- /dev/null +++ b/extension/artifacts/tinyflows-chrome-extension-0.1.0.zip.sha256 @@ -0,0 +1 @@ +8fbab369befd45f265090de209084bc69444270185792b60071c2e38026d3b1d tinyflows-chrome-extension-0.1.0.zip diff --git a/extension/dist/background.js b/extension/dist/background.js new file mode 100644 index 0000000..e5d8c50 --- /dev/null +++ b/extension/dist/background.js @@ -0,0 +1,800 @@ +// src/errors.ts +var BrowserError = class extends Error { + constructor(code, message, retryable = false) { + super(message); + this.code = code; + this.retryable = retryable; + this.name = "BrowserError"; + } +}; +async function toBrowserError(error, tabId, tabsApi) { + if (error instanceof BrowserError) return error; + const message = error instanceof Error ? error.message : String(error); + if (tabId !== void 0) { + try { + await (tabsApi ?? chrome.tabs).get(tabId); + } catch { + return new BrowserError("tab_revoked", "The shared tab no longer exists"); + } + } + return new BrowserError("browser_failure", message || "Unknown browser error"); +} + +// src/cdp.ts +var CdpExecutor = class { + constructor(debuggerApi = chrome.debugger, tabsApi = chrome.tabs) { + this.debuggerApi = debuggerApi; + this.tabsApi = tabsApi; + } + async execute(request, controller = new AbortController()) { + const operation = this.run(request.tab_id, request.action, controller.signal); + return withTimeout(operation, request.timeout_ms, controller); + } + async run(tabId, action, signal) { + ensureActive(signal); + const target = { tabId }; + switch (action.action) { + case "open": { + const url = action.url; + if (!/^https?:\/\//i.test(url)) throw new BrowserError("unsupported_page", "Only HTTP(S) URLs can be opened"); + const marker = `__tinyflows_navigation_${Date.now()}_${Math.random().toString(36).slice(2)}`; + await evaluate(this.debuggerApi, target, `globalThis[${JSON.stringify(marker)}]=true`); + const navigation = await this.debuggerApi.sendCommand(target, "Page.navigate", { url }); + if (navigation.errorText) throw new BrowserError("browser_failure", navigation.errorText); + await waitForReady(this.debuggerApi, target, signal, navigation.loaderId ? marker : void 0); + return { url }; + } + case "snapshot": + return this.debuggerApi.sendCommand(target, "Accessibility.getFullAXTree", {}); + case "get_title": + return evaluate(this.debuggerApi, target, "document.title"); + case "get_url": + return evaluate(this.debuggerApi, target, "location.href"); + case "get_text": + return evaluate(this.debuggerApi, target, textExpression(action.selector)); + case "is_visible": + return evaluate(this.debuggerApi, target, visibleExpression(action.selector)); + case "find": + return evaluate(this.debuggerApi, target, findExpression(action.query)); + case "screenshot": { + const options = { format: "png", fromSurface: true, captureBeyondViewport: action.full_page ?? false }; + if (action.selector) { + options.clip = await elementRect(this.debuggerApi, target, action.selector); + options.captureBeyondViewport = true; + } else if (action.full_page) { + const metrics = await this.debuggerApi.sendCommand(target, "Page.getLayoutMetrics", {}); + if (metrics.cssContentSize) options.clip = { ...metrics.cssContentSize, scale: 1 }; + } + const result = await this.debuggerApi.sendCommand(target, "Page.captureScreenshot", options); + return result; + } + case "click": { + const point = await elementPoint(this.debuggerApi, target, action.selector); + ensureActive(signal); + await mouse(this.debuggerApi, target, "mousePressed", point, 1); + await mouse(this.debuggerApi, target, "mouseReleased", point, 1); + return { clicked: true }; + } + case "hover": { + const point = await elementPoint(this.debuggerApi, target, action.selector); + ensureActive(signal); + await mouse(this.debuggerApi, target, "mouseMoved", point, 0); + return { hovered: true }; + } + case "fill": { + const { selector, value } = action; + const ok = await evaluate(this.debuggerApi, target, fillExpression(selector, value)); + if (!ok) throw new BrowserError("element_not_found", `No element matches ${selector}`); + return { filled: true }; + } + case "type": { + if (action.selector) { + const ok = await evaluate(this.debuggerApi, target, `(() => { const e=document.querySelector(${JSON.stringify(action.selector)}); if(!e)return null; e.focus(); return true; })()`); + if (ok !== true) throw new BrowserError("element_not_found", `No element matches ${action.selector}`); + } + await this.debuggerApi.sendCommand(target, "Input.insertText", { text: action.text }); + return { typed: true }; + } + case "press": { + const key = action.key; + await this.debuggerApi.sendCommand(target, "Input.dispatchKeyEvent", { type: "keyDown", key }); + await this.debuggerApi.sendCommand(target, "Input.dispatchKeyEvent", { type: "keyUp", key }); + return { pressed: key }; + } + case "scroll": { + const x = action.x ?? 0; + const y = action.y ?? 0; + await this.debuggerApi.sendCommand(target, "Runtime.evaluate", { + expression: `window.scrollBy(${JSON.stringify(x)},${JSON.stringify(y)})`, + returnByValue: true + }); + return { x, y }; + } + case "wait": { + const selector = action.selector; + const ms = action.duration_ms ?? (selector ? 15e3 : 1e3); + if (!selector) { + await delay(ms); + return { waitedMs: ms }; + } + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + ensureActive(signal); + if (await evaluate(this.debuggerApi, target, visibleExpression(selector))) return { visible: true }; + await delay(Math.min(100, Math.max(1, deadline - Date.now()))); + } + throw new BrowserError("element_not_found", `Element did not appear: ${selector}`); + } + case "close": + await this.tabsApi.remove(tabId); + return { closed: true }; + } + } +}; +async function evaluate(api, target, expression) { + const response = await api.sendCommand(target, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + const result = response; + if (result.exceptionDetails) { + throw new BrowserError( + "browser_failure", + result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Page evaluation failed" + ); + } + return result.result?.value; +} +async function elementPoint(api, target, selector) { + const expression = `(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e)return null; e.scrollIntoView({block:"center",inline:"center"}); const r=e.getBoundingClientRect(); const s=getComputedStyle(e); if(r.width<=0||r.height<=0||s.visibility==="hidden"||s.display==="none"||s.pointerEvents==="none")return null; const x=r.left+r.width/2,y=r.top+r.height/2,hit=document.elementFromPoint(x,y); if(!hit||!(hit===e||e.contains(hit)))return null; return {x,y}; })()`; + const point = await evaluate(api, target, expression); + if (!point || typeof point.x !== "number" || typeof point.y !== "number") { + throw new BrowserError("element_not_found", `No element matches ${selector}`); + } + return { x: point.x, y: point.y }; +} +async function elementRect(api, target, selector) { + const expression = `(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e)return null; const r=e.getBoundingClientRect(); return {x:r.left+scrollX,y:r.top+scrollY,width:r.width,height:r.height,scale:1}; })()`; + const rect = await evaluate(api, target, expression); + if (!rect || typeof rect.x !== "number" || typeof rect.y !== "number" || typeof rect.width !== "number" || typeof rect.height !== "number") { + throw new BrowserError("element_not_found", `No element matches ${selector}`); + } + return rect; +} +async function mouse(api, target, type, point, clickCount) { + await api.sendCommand(target, "Input.dispatchMouseEvent", { type, x: point.x, y: point.y, button: "left", clickCount }); +} +function textExpression(selector) { + return selector ? `document.querySelector(${JSON.stringify(selector)})?.textContent ?? null` : 'document.body?.innerText ?? ""'; +} +function visibleExpression(selector) { + return `(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e)return false; const r=e.getBoundingClientRect(); const s=getComputedStyle(e); return r.width>0&&r.height>0&&s.visibility!=="hidden"&&s.display!=="none"; })()`; +} +function fillExpression(selector, value) { + return `(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!(e instanceof HTMLInputElement||e instanceof HTMLTextAreaElement))return false; e.focus(); const set=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e),"value")?.set; set?.call(e,${JSON.stringify(value)}); e.dispatchEvent(new Event("input",{bubbles:true})); e.dispatchEvent(new Event("change",{bubbles:true})); return true; })()`; +} +function findExpression(text) { + return `(() => { const q=${JSON.stringify(text)}.toLowerCase(); return [...document.querySelectorAll("a,button,input,textarea,select,[role],[aria-label]")].filter(e => ((e.textContent||"")+" "+(e.getAttribute("aria-label")||"")).toLowerCase().includes(q)).slice(0,50).map(e => ({tag:e.tagName.toLowerCase(),text:(e.textContent||"").trim().slice(0,500),role:e.getAttribute("role"),ariaLabel:e.getAttribute("aria-label")})); })()`; +} +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +async function withTimeout(operation, ms, controller) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new BrowserError("action_timeout", `Browser action timed out after ${ms}ms`, true)); + }, ms); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} +function ensureActive(signal) { + if (signal.aborted) throw new BrowserError("cancelled", "Browser action was cancelled"); +} +async function waitForReady(api, target, signal, previousDocumentMarker) { + while (true) { + ensureActive(signal); + const state = await evaluate(api, target, `({ + ready: document.readyState, + previousDocument: ${previousDocumentMarker ? `globalThis[${JSON.stringify(previousDocumentMarker)}] === true` : "false"} + })`); + if (!state.previousDocument && (state.ready === "interactive" || state.ready === "complete")) return; + await delay(25); + } +} + +// src/protocol.ts +var PROTOCOL_VERSION = 1; +function tabSharedEvent(tab) { + return { event: "tab_shared", protocol_version: PROTOCOL_VERSION, tab }; +} +function isBrowserRequest(value) { + if (!isRecordWithKeys(value, ["protocol_version", "request_id", "run_id", "tab_id", "timeout_ms", "action"])) return false; + return value.protocol_version === PROTOCOL_VERSION && isId(value.request_id) && isId(value.run_id) && Number.isSafeInteger(value.tab_id) && value.tab_id >= 0 && Number.isSafeInteger(value.timeout_ms) && value.timeout_ms >= 1 && value.timeout_ms <= 6e4 && isBrowserAction(value.action); +} +function isBrowserCancel(value) { + return isRecordWithKeys(value, ["protocol_version", "type", "request_id"]) && value.protocol_version === PROTOCOL_VERSION && value.type === "browser.cancel" && isId(value.request_id); +} +function isBrowserAction(value) { + if (!isRecord(value) || typeof value.action !== "string") return false; + switch (value.action) { + case "open": + return exactStrings(value, ["action", "url"], ["url"]); + case "snapshot": + case "get_title": + case "get_url": + case "close": + return hasExactKeys(value, ["action"]); + case "click": + case "hover": + case "is_visible": + return exactStrings(value, ["action", "selector"], ["selector"]); + case "fill": + return hasExactKeys(value, ["action", "selector", "value"]) && typeof value.selector === "string" && value.selector.length > 0 && typeof value.value === "string"; + case "type": + return exactStrings(value, ["action", "text"], ["text"], ["selector"]); + case "get_text": + return exactStrings(value, ["action"], [], ["selector"]); + case "screenshot": + return hasOnlyKeys(value, ["action", "selector", "full_page"]) && optionalString(value.selector) && (value.full_page === void 0 || typeof value.full_page === "boolean"); + case "wait": + return hasOnlyKeys(value, ["action", "duration_ms", "selector"]) && optionalString(value.selector) && (value.duration_ms === void 0 || Number.isSafeInteger(value.duration_ms) && value.duration_ms >= 0); + case "press": + return exactStrings(value, ["action", "key"], ["key"]); + case "scroll": + return hasOnlyKeys(value, ["action", "x", "y"]) && optionalInteger(value.x) && optionalInteger(value.y); + case "find": + return exactStrings(value, ["action", "query"], ["query"]); + default: + return false; + } +} +function isControlResponse(value) { + if (!isRecord(value) || value.protocol_version !== PROTOCOL_VERSION || !isId(value.request_id)) return false; + switch (value.status) { + case "ok": + return hasExactKeys(value, ["status", "protocol_version", "request_id", "result"]); + case "error": + return hasExactKeys(value, ["status", "protocol_version", "request_id", "code", "message"]) && isId(value.code) && typeof value.message === "string"; + case "workflows": + return hasExactKeys(value, ["status", "protocol_version", "request_id", "workflows"]) && Array.isArray(value.workflows); + case "tabs": + return hasExactKeys(value, ["status", "protocol_version", "request_id", "tabs"]) && Array.isArray(value.tabs); + case "connection": + return hasExactKeys(value, ["status", "protocol_version", "request_id", "connected"]) && typeof value.connected === "boolean"; + default: + return false; + } +} +function isRunEvent(value) { + if (!isRecord(value) || value.protocol_version !== PROTOCOL_VERSION || !isId(value.run_id)) return false; + switch (value.event) { + case "started": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "tab_id"]) && Number.isSafeInteger(value.tab_id); + case "step_started": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "node_id", "node_kind"]) && isId(value.node_id) && isId(value.node_kind); + case "step_completed": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "node_id", "node_kind", "status", "duration_ms"]) && isId(value.node_id) && isId(value.node_kind) && (value.status === "success" || value.status === "error") && Number.isSafeInteger(value.duration_ms) && value.duration_ms >= 0; + case "awaiting_approval": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "pending_approvals"]) && Array.isArray(value.pending_approvals) && value.pending_approvals.every(isId); + case "browser_action_started": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "request_id", "tab_id", "action"]) && isId(value.request_id) && Number.isSafeInteger(value.tab_id) && isId(value.action); + case "browser_action_completed": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "request_id", "output"]) && isId(value.request_id); + case "browser_action_failed": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "request_id", "code", "message"]) && isId(value.request_id) && isId(value.code) && typeof value.message === "string"; + case "completed": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "status"]) && value.status === "success"; + case "failed": + return hasExactKeys(value, ["event", "protocol_version", "run_id", "code", "message"]) && isId(value.code) && typeof value.message === "string"; + case "cancelled": + return hasExactKeys(value, ["event", "protocol_version", "run_id"]); + default: + return false; + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isRecordWithKeys(value, keys) { + return isRecord(value) && hasExactKeys(value, keys); +} +function hasExactKeys(value, keys) { + return Object.keys(value).length === keys.length && hasOnlyKeys(value, keys) && keys.every((key) => key in value); +} +function hasOnlyKeys(value, keys) { + return Object.keys(value).every((key) => keys.includes(key)); +} +function exactStrings(value, requiredKeys, requiredStrings, optionalStrings = []) { + if (!requiredKeys.every((key) => key in value) || !hasOnlyKeys(value, [...requiredKeys, ...optionalStrings])) return false; + return requiredStrings.every((key) => typeof value[key] === "string" && value[key].length > 0) && optionalStrings.every((key) => optionalString(value[key])); +} +function optionalString(value) { + return value === void 0 || typeof value === "string"; +} +function optionalInteger(value) { + return value === void 0 || Number.isSafeInteger(value); +} +function isId(value) { + return typeof value === "string" && value.length > 0 && value.length <= 256; +} + +// src/relay.ts +var CONFIG_KEY = "tinyflows.relayConfig.v1"; +var RelayClient = class { + constructor(onBrowserRequest, onState, onRunEvent, storage = chrome.storage, makeWebSocket = (url, protocols) => new WebSocket(url, protocols)) { + this.onBrowserRequest = onBrowserRequest; + this.onState = onState; + this.onRunEvent = onRunEvent; + this.storage = storage; + this.makeWebSocket = makeWebSocket; + } + socket; + retryTimer; + heartbeatTimer; + retries = 0; + stopped = false; + pending = /* @__PURE__ */ new Map(); + onBrowserCancel = () => void 0; + async start() { + this.stopped = false; + const config = await this.getConfig(); + if (!config) { + this.onState("unpaired"); + return; + } + this.connect(config); + } + setBrowserCancelHandler(handler) { + this.onBrowserCancel = handler; + } + stop() { + this.stopped = true; + if (this.retryTimer) clearTimeout(this.retryTimer); + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + const socket = this.socket; + this.socket = void 0; + socket?.close(1e3, "extension stopped"); + this.rejectPending("Relay disconnected"); + } + async configure(config) { + assertConfig(config); + await this.storage.local.set({ [CONFIG_KEY]: config }); + this.stop(); + this.retries = 0; + await this.start(); + } + async getConfig() { + const value = (await this.storage.local.get(CONFIG_KEY))[CONFIG_KEY]; + if (!isRelayConfig(value)) return void 0; + return value; + } + async request(method, params, timeoutMs = 15e3) { + if (!this.socket || this.socket.readyState !== 1) throw new Error("Relay is not connected"); + const request_id = crypto.randomUUID(); + const request = controlRequest(method, request_id, params); + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(request_id); + reject(new Error(`${method} timed out`)); + }, timeoutMs); + this.pending.set(request_id, { resolve, reject, timer }); + }); + this.socket.send(JSON.stringify(request)); + return result; + } + send(message) { + if (this.socket?.readyState === 1) this.socket.send(JSON.stringify(message)); + } + connect(config) { + if (this.stopped) return; + this.onState(this.retries ? "reconnecting" : "connecting"); + const protocols = ["tinyflows.v1", `tinyflows.auth.${config.pairingToken}`]; + const socket = this.makeWebSocket(config.url, protocols); + this.socket = socket; + socket.onopen = () => { + if (socket !== this.socket) return socket.close(); + this.retries = 0; + this.onState("connected"); + this.heartbeatTimer = setInterval(() => this.send({ protocol_version: PROTOCOL_VERSION, type: "heartbeat" }), 15e3); + }; + socket.onmessage = (event) => { + void this.handleMessage(String(event.data)); + }; + socket.onerror = () => { + if (socket === this.socket) this.onState("failed"); + }; + socket.onclose = () => { + if (socket !== this.socket) return; + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.rejectPending("Relay disconnected"); + if (!this.stopped) this.scheduleReconnect(config); + }; + } + async handleMessage(raw) { + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (isBrowserRequest(message)) { + this.send(await this.onBrowserRequest(message)); + } else if (isBrowserCancel(message)) { + this.onBrowserCancel(message.request_id); + } else if (isControlResponse(message)) { + this.settle(message); + } else if (isRunEvent(message)) { + this.onRunEvent(message); + } + } + settle(response) { + const pending = this.pending.get(response.request_id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(response.request_id); + switch (response.status) { + case "ok": + pending.resolve(response.result); + break; + case "workflows": + pending.resolve(response.workflows); + break; + case "tabs": + pending.resolve(response.tabs); + break; + case "connection": + pending.resolve({ connected: response.connected }); + break; + case "error": + pending.reject(new Error(response.message)); + break; + } + } + scheduleReconnect(config) { + this.retries += 1; + this.onState(this.retries >= 8 ? "failed" : "reconnecting"); + const delay2 = Math.min(3e4, 500 * 2 ** Math.min(this.retries - 1, 6)); + this.retryTimer = setTimeout(() => this.connect(config), delay2); + } + rejectPending(message) { + for (const item of this.pending.values()) { + clearTimeout(item.timer); + item.reject(new Error(message)); + } + this.pending.clear(); + } +}; +function assertConfig(value) { + if (!isRelayConfig(value)) throw new Error("Use a loopback ws:// URL and a base64url pairing token"); +} +function isRelayConfig(value) { + if (typeof value !== "object" || value === null) return false; + const item = value; + if (typeof item.url !== "string" || typeof item.pairingToken !== "string" || !/^[A-Za-z0-9_-]{32,512}$/.test(item.pairingToken)) return false; + try { + const url = new URL(item.url); + return url.protocol === "ws:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"); + } catch { + return false; + } +} +function controlRequest(method, request_id, params) { + const base = { protocol_version: PROTOCOL_VERSION, request_id }; + switch (method) { + case "workflow.list": + return { ...base, method }; + case "tab.list": + return { ...base, method }; + case "connection.status": + return { ...base, method }; + case "workflow.start": + return { ...base, method, workflow_id: String(params.workflow_id ?? ""), tab_id: Number(params.tab_id), input: params.input ?? {} }; + case "workflow.cancel": + return { ...base, method, run_id: String(params.run_id ?? "") }; + case "run.subscribe": + return { ...base, method, run_id: String(params.run_id ?? "") }; + } +} + +// src/tab-manager.ts +var STORAGE_KEY = "tinyflows.sharedTabs.v1"; +var GROUP_TITLE = "TinyFlows shared tabs"; +var TabManager = class { + constructor(api = chrome) { + this.api = api; + } + shared = /* @__PURE__ */ new Map(); + async rehydrate() { + const saved = (await this.api.storage.local.get(STORAGE_KEY))[STORAGE_KEY]; + if (Array.isArray(saved)) { + for (const item of saved) { + if (isSharedTab(item)) this.shared.set(item.tabId, item); + } + } + for (const tabId of [...this.shared.keys()]) { + try { + await this.assertShared(tabId); + const targets = await this.api.debugger.getTargets(); + if (!targets.some((target) => target.tabId === tabId && target.attached)) { + await this.api.debugger.attach({ tabId }, "1.3"); + } + await this.setBadge(tabId, "connected"); + } catch { + await this.revoke(tabId, false); + } + } + return this.list(); + } + async share(tabId) { + if (this.shared.has(tabId)) return (await this.assertShared(tabId)).shared; + const tab = await this.api.tabs.get(tabId); + ensureSupported(tab.url); + if (tab.windowId === void 0) throw new BrowserError("invalid_request", "Tab has no window"); + await this.api.debugger.attach({ tabId }, "1.3"); + let groupId; + try { + const groups = await this.api.tabGroups.query({ windowId: tab.windowId, title: GROUP_TITLE }); + const existing = groups[0]; + if (existing) { + groupId = existing.id; + await this.api.tabs.group({ groupId, tabIds: [tabId] }); + } else { + groupId = await this.api.tabs.group({ tabIds: [tabId] }); + await this.api.tabGroups.update(groupId, { title: GROUP_TITLE, color: "blue", collapsed: false }); + } + } catch (error) { + try { + await this.api.debugger.detach({ tabId }); + } catch { + } + throw error; + } + const shared = { tabId, groupId, windowId: tab.windowId, attachedAt: Date.now() }; + this.shared.set(tabId, shared); + await this.persist(); + await this.setBadge(tabId, "connected"); + return shared; + } + async revoke(tabId, ungroup = true) { + const existed = this.shared.delete(tabId); + if (existed) await this.persist(); + try { + await this.api.debugger.detach({ tabId }); + } catch { + } + if (ungroup) { + try { + await this.api.tabs.ungroup([tabId]); + } catch { + } + } + await this.setBadge(tabId, "idle"); + } + async toggle(tabId) { + if (this.shared.has(tabId)) { + await this.revoke(tabId); + return false; + } + await this.share(tabId); + return true; + } + async assertShared(tabId) { + const record = this.shared.get(tabId); + if (!record) throw new BrowserError("tab_not_shared", "The tab was not explicitly shared"); + let tab; + try { + tab = await this.api.tabs.get(tabId); + } catch { + throw new BrowserError("tab_revoked", "The shared tab no longer exists"); + } + ensureSupported(tab.url); + if (tab.groupId !== record.groupId) { + await this.revoke(tabId, false); + throw new BrowserError("tab_revoked", "The tab was removed from the TinyFlows group"); + } + const group = await this.api.tabGroups.get(record.groupId).catch(() => void 0); + if (!group || group.title !== GROUP_TITLE) { + await this.revoke(tabId, false); + throw new BrowserError("tab_revoked", "The TinyFlows group was removed or renamed"); + } + return { shared: record, tab }; + } + list() { + return [...this.shared.values()].sort((a, b) => a.tabId - b.tabId); + } + has(tabId) { + return this.shared.has(tabId); + } + async announcement(tabId) { + const { shared, tab } = await this.assertShared(tabId); + return { + id: tabId, + window_id: shared.windowId, + url: tab.url ?? "", + title: tab.title ?? "" + }; + } + async markAll(state) { + await Promise.all(this.list().map(({ tabId }) => this.setBadge(tabId, state))); + } + async setBadge(tabId, state) { + const visual = { + connected: { text: "ON", color: "#16794f" }, + reconnecting: { text: "\u2026", color: "#ad6b00" }, + failed: { text: "!", color: "#b42318" }, + idle: { text: "", color: "#59636e" } + }[state]; + try { + await this.api.action.setBadgeBackgroundColor({ tabId, color: visual.color }); + await this.api.action.setBadgeText({ tabId, text: visual.text }); + } catch { + } + } + async persist() { + await this.api.storage.local.set({ [STORAGE_KEY]: this.list() }); + } +}; +function ensureSupported(url) { + if (!url || !/^https?:\/\//i.test(url)) { + throw new BrowserError("unsupported_page", "Chrome does not permit automation on this page"); + } +} +function isSharedTab(value) { + if (typeof value !== "object" || value === null) return false; + const item = value; + return Number.isInteger(item.tabId) && Number.isInteger(item.groupId) && Number.isInteger(item.windowId) && typeof item.attachedAt === "number"; +} + +// src/background.ts +var tabs = new TabManager(); +var executor = new CdpExecutor(); +var relayState = "unpaired"; +var closingWorkflowTabs = /* @__PURE__ */ new Set(); +var actions = /* @__PURE__ */ new Map(); +var bootPromise; +var relay = new RelayClient(handleBrowserRequest, handleRelayState, (event) => { + void chrome.runtime.sendMessage({ type: "run.event", event }).catch(() => void 0); +}); +relay.setBrowserCancelHandler((requestId) => actions.get(requestId)?.abort()); +async function handleBrowserRequest(request) { + const controller = new AbortController(); + actions.set(request.request_id, controller); + if (request.action.action === "close") closingWorkflowTabs.add(request.tab_id); + notifyRunEvent({ + event: "browser_action_started", + protocol_version: PROTOCOL_VERSION, + run_id: request.run_id, + request_id: request.request_id, + tab_id: request.tab_id, + action: request.action.action + }); + relay.send({ event: "action_started", protocol_version: PROTOCOL_VERSION, request_id: request.request_id, run_id: request.run_id, tab_id: request.tab_id }); + try { + await tabs.assertShared(request.tab_id); + const data = await executor.execute(request, controller); + relay.send({ event: "action_completed", protocol_version: PROTOCOL_VERSION, request_id: request.request_id, result: { data } }); + notifyRunEvent({ + event: "browser_action_completed", + protocol_version: PROTOCOL_VERSION, + run_id: request.run_id, + request_id: request.request_id, + output: data + }); + if (request.action.action === "close") { + await tabs.revoke(request.tab_id, false); + setTimeout(() => relay.send({ event: "tab_revoked", protocol_version: PROTOCOL_VERSION, tab_id: request.tab_id }), 0); + } + return { status: "ok", protocol_version: PROTOCOL_VERSION, request_id: request.request_id, result: { data } }; + } catch (cause) { + const error = await toBrowserError(cause, request.tab_id); + const data = { code: error.code, message: error.message }; + relay.send({ event: "action_failed", protocol_version: PROTOCOL_VERSION, request_id: request.request_id, error: data }); + notifyRunEvent({ + event: "browser_action_failed", + protocol_version: PROTOCOL_VERSION, + run_id: request.run_id, + request_id: request.request_id, + code: error.code, + message: error.message + }); + return { status: "error", protocol_version: PROTOCOL_VERSION, request_id: request.request_id, error: data }; + } finally { + actions.delete(request.request_id); + closingWorkflowTabs.delete(request.tab_id); + } +} +function notifyRunEvent(event) { + void chrome.runtime.sendMessage({ type: "run.event", event }).catch(() => void 0); +} +function handleRelayState(state) { + relayState = state; + if (state !== "connected") { + for (const controller of actions.values()) controller.abort(); + } + const badge = state === "connected" ? "connected" : state === "failed" ? "failed" : "reconnecting"; + void tabs.markAll(badge); + if (state === "connected") void announceAllSharedTabs(); + void chrome.runtime.sendMessage({ type: "relay.state", state }).catch(() => void 0); +} +chrome.runtime.onInstalled.addListener(() => { + void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }); +}); +chrome.runtime.onStartup.addListener(() => { + void bootOnce(); +}); +chrome.tabs.onRemoved.addListener((tabId) => { + if (tabs.has(tabId)) { + void tabs.revoke(tabId, false); + if (!closingWorkflowTabs.has(tabId)) relay.send({ event: "tab_revoked", protocol_version: PROTOCOL_VERSION, tab_id: tabId }); + } +}); +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (tabs.has(tabId) && changeInfo.groupId !== void 0 && !closingWorkflowTabs.has(tabId)) { + void tabs.assertShared(tabId).catch(() => relay.send({ event: "tab_revoked", protocol_version: PROTOCOL_VERSION, tab_id: tabId })); + } +}); +chrome.debugger.onDetach.addListener((source) => { + if (source.tabId !== void 0 && tabs.has(source.tabId)) { + if (closingWorkflowTabs.has(source.tabId)) return; + void tabs.revoke(source.tabId, false); + relay.send({ event: "tab_revoked", protocol_version: PROTOCOL_VERSION, tab_id: source.tabId }); + } +}); +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + void handleUiMessage(message).then(sendResponse, (error) => { + const result = error instanceof Error ? error.message : String(error); + sendResponse({ ok: false, error: result }); + }); + return true; +}); +async function handleUiMessage(message) { + if (!message || typeof message !== "object") throw new BrowserError("invalid_request", "Invalid UI request"); + const item = message; + switch (item.type) { + case "state": + return { ok: true, relayState, tabs: tabs.list(), config: await relay.getConfig() }; + case "tab.toggle": { + if (!Number.isInteger(item.tabId)) throw new Error("Missing tab id"); + const tabId = item.tabId; + const shared = await tabs.toggle(tabId); + if (shared && relayState === "connected") relay.send(tabSharedEvent(await tabs.announcement(tabId))); + else if (!shared) relay.send({ event: "tab_revoked", protocol_version: PROTOCOL_VERSION, tab_id: tabId }); + return { ok: true, shared }; + } + case "relay.configure": + await relay.configure({ url: String(item.url ?? ""), pairingToken: String(item.pairingToken ?? "") }); + return { ok: true }; + case "workflow.list": + return { ok: true, result: await relay.request("workflow.list", {}) }; + case "workflow.start": { + const result = await relay.request("workflow.start", { workflow_id: item.workflowId, tab_id: item.tabId }); + const runId = result && typeof result === "object" ? String(result.run_id ?? "") : ""; + if (runId) await relay.request("run.subscribe", { run_id: runId }); + return { ok: true, result }; + } + case "workflow.cancel": + return { ok: true, result: await relay.request("workflow.cancel", { run_id: item.runId }) }; + default: + throw new Error("Unknown UI request"); + } +} +async function announceAllSharedTabs() { + for (const { tabId } of tabs.list()) { + try { + relay.send(tabSharedEvent(await tabs.announcement(tabId))); + } catch { + } + } +} +async function boot() { + await tabs.rehydrate(); + await relay.start(); +} +function bootOnce() { + bootPromise ??= boot(); + return bootPromise; +} +void bootOnce(); diff --git a/extension/dist/manifest.json b/extension/dist/manifest.json new file mode 100644 index 0000000..9e819fe --- /dev/null +++ b/extension/dist/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 3, + "name": "TinyFlows Browser Companion", + "description": "Runs explicit TinyFlows browser steps in tabs you share.", + "version": "0.1.0", + "minimum_chrome_version": "116", + "permissions": ["debugger", "tabs", "tabGroups", "storage", "sidePanel"], + "host_permissions": [""], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { + "default_title": "Share this tab with TinyFlows", + "default_popup": "popup.html" + }, + "side_panel": { "default_path": "sidepanel.html" }, + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + } +} diff --git a/extension/dist/popup.html b/extension/dist/popup.html new file mode 100644 index 0000000..95ac677 --- /dev/null +++ b/extension/dist/popup.html @@ -0,0 +1,21 @@ + + + TinyFlows + +
TF

TinyFlows

Checking companion…

+
+

This tab

+

Checking sharing status…

+ +

Only tabs in the “TinyFlows shared tabs” group can be controlled. Removing a tab from the group revokes access.

+
+
+

Pair companion

+ + +

+
+ + + + diff --git a/extension/dist/popup.js b/extension/dist/popup.js new file mode 100644 index 0000000..d082a24 --- /dev/null +++ b/extension/dist/popup.js @@ -0,0 +1,45 @@ +// src/relay.ts +var DEFAULT_URL = "ws://127.0.0.1:32189/v1/extension"; + +// src/popup.ts +var $ = (id) => document.getElementById(id); +var status = $("relay-status"); +var detail = $("tab-detail"); +var toggle = $("toggle-tab"); +var url = $("relay-url"); +var token = $("pairing-token"); +var result = $("pair-result"); +var activeTabId; +async function load() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + activeTabId = tab?.id; + const state = await chrome.runtime.sendMessage({ type: "state" }); + status.textContent = `Companion: ${state.relayState}`; + url.value = state.config?.url ?? DEFAULT_URL; + const shared = activeTabId !== void 0 && state.tabs.some((item) => item.tabId === activeTabId); + detail.textContent = shared ? "This tab is explicitly shared." : "This tab is private."; + toggle.textContent = shared ? "Stop sharing this tab" : "Share with TinyFlows"; + toggle.disabled = activeTabId === void 0 || !tab?.url?.startsWith("http"); +} +toggle.addEventListener("click", async () => { + if (activeTabId === void 0) return; + toggle.disabled = true; + try { + await chrome.runtime.sendMessage({ type: "tab.toggle", tabId: activeTabId }); + await load(); + } catch (error) { + detail.textContent = error instanceof Error ? error.message : String(error); + } +}); +$("pair").addEventListener("click", async () => { + result.textContent = "Pairing\u2026"; + const response = await chrome.runtime.sendMessage({ type: "relay.configure", url: url.value.trim(), pairingToken: token.value.trim() }); + result.textContent = response.ok ? "Pairing saved. Connecting\u2026" : response.error; + result.className = response.ok ? "success" : "error"; + token.value = ""; +}); +$("open-panel").addEventListener("click", async () => { + if (activeTabId !== void 0) await chrome.sidePanel.open({ tabId: activeTabId }); + window.close(); +}); +void load(); diff --git a/extension/dist/sidepanel.html b/extension/dist/sidepanel.html new file mode 100644 index 0000000..fdbcb93 --- /dev/null +++ b/extension/dist/sidepanel.html @@ -0,0 +1,13 @@ + + + TinyFlows workflows + +
TF

TinyFlows

Connecting…

+
+

Workflows

Loading workflows…

+

Current run

No active run.

+

Event journal

    +
    + + + diff --git a/extension/dist/sidepanel.js b/extension/dist/sidepanel.js new file mode 100644 index 0000000..f90dd31 --- /dev/null +++ b/extension/dist/sidepanel.js @@ -0,0 +1,78 @@ +// src/sidepanel.ts +var $ = (id) => document.getElementById(id); +var workflows = $("workflows"); +var run = $("run"); +var events = $("events"); +var cancel = $("cancel"); +var activeRunId; +async function refresh() { + const state = await chrome.runtime.sendMessage({ type: "state" }); + $("connection").textContent = `Companion: ${state.relayState} \xB7 ${state.tabs.length} shared tab(s)`; + const response = await chrome.runtime.sendMessage({ type: "workflow.list" }); + if (!response.ok) { + workflows.innerHTML = `

    `; + workflows.querySelector("p").textContent = response.error; + return; + } + const list = Array.isArray(response.result) ? response.result : []; + workflows.replaceChildren(...list.map(workflowCard)); + if (list.length === 0) workflows.innerHTML = '

    No workflows exposed by the companion.

    '; +} +function workflowCard(value) { + const item = value && typeof value === "object" ? value : {}; + const id = String(item.id ?? item.workflow_id ?? ""); + const card = document.createElement("article"); + card.className = "card"; + const title = document.createElement("strong"); + title.textContent = String(item.name ?? id); + const description = document.createElement("p"); + description.className = "muted"; + description.textContent = String(item.description ?? ""); + const button = document.createElement("button"); + button.textContent = "Run in this tab"; + button.addEventListener("click", () => void start(id)); + card.append(title, description, button); + return card; +} +async function start(workflowId) { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab?.id === void 0) return showError("No active tab"); + const response = await chrome.runtime.sendMessage({ type: "workflow.start", workflowId, tabId: tab.id }); + if (!response.ok) return showError(response.error); + const value = response.result; + activeRunId = String(value?.run_id ?? value?.id ?? ""); + run.textContent = `Run ${activeRunId || "started"}`; + cancel.hidden = !activeRunId; + if (activeRunId) addEvent({ event: "started", protocol_version: 1, run_id: activeRunId, tab_id: tab.id }); +} +function showError(message) { + run.innerHTML = '

    '; + run.querySelector("p").textContent = message; +} +function addEvent(value) { + const event = value; + const runId = typeof event.run_id === "string" ? event.run_id : void 0; + if (!activeRunId || runId !== activeRunId) return; + const li = document.createElement("li"); + const details = { ...event }; + delete details.event; + delete details.protocol_version; + delete details.run_id; + li.textContent = `${String(event.event ?? "event")} \xB7 ${JSON.stringify(details)}`; + events.prepend(li); + while (events.children.length > 200) events.lastElementChild?.remove(); +} +chrome.runtime.onMessage.addListener((message) => { + if (message.type === "relay.state") $("connection").textContent = `Companion: ${message.state}`; + if (message.type === "run.event") addEvent(message.event); +}); +$("refresh").addEventListener("click", () => void refresh()); +cancel.addEventListener("click", async () => { + if (!activeRunId) return; + const response = await chrome.runtime.sendMessage({ type: "workflow.cancel", runId: activeRunId }); + if (!response.ok) return showError(response.error); + addEvent({ event: "cancel_requested", data: { run_id: activeRunId } }); + activeRunId = void 0; + cancel.hidden = true; +}); +void refresh().catch((error) => showError(error instanceof Error ? error.message : String(error))); diff --git a/extension/dist/ui.css b/extension/dist/ui.css new file mode 100644 index 0000000..555cf9a --- /dev/null +++ b/extension/dist/ui.css @@ -0,0 +1,21 @@ +:root { color-scheme: light dark; font: 14px/1.45 system-ui, sans-serif; --accent:#3367d6; --border:#c8d0dc; --muted:#667085; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; color: CanvasText; background: Canvas; } +.popup { width: 360px; padding-bottom: 14px; } +.panel { min-width: 260px; } +header { display:flex; align-items:center; gap:12px; padding:16px; border-bottom:1px solid var(--border); } +.mark { display:grid; place-items:center; width:36px; height:36px; border-radius:10px; background:var(--accent); color:white; font-weight:800; } +h1,h2,p { margin:0; } h1 { font-size:18px; } h2 { font-size:14px; margin-bottom:10px; } +header p,.muted,.fine-print { color:var(--muted); } .fine-print { margin-top:9px; font-size:12px; } +section { padding:15px 16px; border-bottom:1px solid var(--border); } +label { display:block; color:var(--muted); font-size:12px; margin:9px 0; } +input { display:block; width:100%; margin-top:4px; padding:8px; border:1px solid var(--border); border-radius:6px; background:Canvas; color:CanvasText; } +button { width:100%; border:0; border-radius:7px; padding:9px 12px; background:var(--accent); color:white; font:inherit; font-weight:650; cursor:pointer; } +button:disabled { opacity:.5; cursor:default; } button.secondary { color:CanvasText; background:ButtonFace; border:1px solid var(--border); } +button.danger { background:#b42318; } button.compact { width:auto; padding:5px 9px; } +#open-panel { width:calc(100% - 32px); margin:14px 16px 0; } +#pair-result { min-height:20px; padding-top:6px; color:var(--muted); } +.section-heading { display:flex; align-items:center; justify-content:space-between; } +.cards { display:grid; gap:8px; }.card { padding:10px; border:1px solid var(--border); border-radius:8px; }.card button { margin-top:8px; } +.events { margin:0; padding-left:22px; max-height:45vh; overflow:auto; }.events li { padding:6px 0; border-bottom:1px solid var(--border); overflow-wrap:anywhere; } +.error { color:#b42318; }.success { color:#16794f; } diff --git a/extension/eslint.config.js b/extension/eslint.config.js new file mode 100644 index 0000000..0431d86 --- /dev/null +++ b/extension/eslint.config.js @@ -0,0 +1,23 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'artifacts', 'coverage', 'node_modules'] }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-explicit-any': 'off' + } + }, + { + files: ['scripts/*.mjs'], + languageOptions: { globals: { process: 'readonly', Buffer: 'readonly', console: 'readonly' } } + }, + { + files: ['tests/e2e/*.ts'], + rules: { 'no-empty-pattern': 'off' } + } +); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..9e819fe --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 3, + "name": "TinyFlows Browser Companion", + "description": "Runs explicit TinyFlows browser steps in tabs you share.", + "version": "0.1.0", + "minimum_chrome_version": "116", + "permissions": ["debugger", "tabs", "tabGroups", "storage", "sidePanel"], + "host_permissions": [""], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { + "default_title": "Share this tab with TinyFlows", + "default_popup": "popup.html" + }, + "side_panel": { "default_path": "sidepanel.html" }, + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + } +} diff --git a/extension/package-lock.json b/extension/package-lock.json new file mode 100644 index 0000000..bf6287a --- /dev/null +++ b/extension/package-lock.json @@ -0,0 +1,4396 @@ +{ + "name": "tinyflows-chrome-extension", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tinyflows-chrome-extension", + "version": "0.1.0", + "devDependencies": { + "@eslint/js": "^9.31.0", + "@playwright/test": "^1.54.1", + "@types/chrome": "^0.1.1", + "@types/node": "^24.0.15", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^3.2.4", + "esbuild": "^0.25.8", + "eslint": "^9.31.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0", + "vitest": "^3.2.4", + "ws": "^8.21.1", + "yazl": "^3.3.1" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.0.tgz", + "integrity": "sha512-rd6n9Sefya1R1Vd594TRwzeHq2VnmICbvh3jebyt1Hs/6OoCSYNVpjGRRsKSTwAq3SKl09w0AfRCU57hoDLmxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/chrome": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.43.tgz", + "integrity": "sha512-ukH/HhmR6ht+UTX3PLUWJxgJ/RQcK2Foj4lBzsF24SIWsXgqhGuXqjd8FFuwioPP7d/JUKLM4g8GZxw3F4HTcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", + "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.7", + "vitest": "3.2.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "^1.0.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..f34d7be --- /dev/null +++ b/extension/package.json @@ -0,0 +1,30 @@ +{ + "name": "tinyflows-chrome-extension", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node scripts/build.mjs", + "lint": "eslint .", + "test": "vitest run --coverage", + "test:e2e": "npm run build && playwright test", + "typecheck": "tsc --noEmit", + "verify": "npm run lint && npm run typecheck && npm test && npm run build", + "package": "npm run build && node scripts/package.mjs" + }, + "devDependencies": { + "@eslint/js": "^9.31.0", + "@playwright/test": "^1.54.1", + "@types/chrome": "^0.1.1", + "@types/node": "^24.0.15", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^3.2.4", + "esbuild": "^0.25.8", + "eslint": "^9.31.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0", + "vitest": "^3.2.4", + "ws": "^8.21.1", + "yazl": "^3.3.1" + } +} diff --git a/extension/playwright.config.ts b/extension/playwright.config.ts new file mode 100644 index 0000000..14e870f --- /dev/null +++ b/extension/playwright.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + use: { trace: 'retain-on-failure' }, + reporter: [['line']], + workers: 1 +}); diff --git a/extension/scripts/build.mjs b/extension/scripts/build.mjs new file mode 100644 index 0000000..678c3df --- /dev/null +++ b/extension/scripts/build.mjs @@ -0,0 +1,25 @@ +import { cp, mkdir, rm } from 'node:fs/promises'; +import { build } from 'esbuild'; + +await rm('dist', { recursive: true, force: true }); +await mkdir('dist', { recursive: true }); +await build({ + entryPoints: { + background: 'src/background.ts', + popup: 'src/popup.ts', + sidepanel: 'src/sidepanel.ts' + }, + outdir: 'dist', + bundle: true, + format: 'esm', + platform: 'browser', + target: 'chrome116', + sourcemap: false, + legalComments: 'none' +}); +await Promise.all([ + cp('manifest.json', 'dist/manifest.json'), + cp('src/popup.html', 'dist/popup.html'), + cp('src/sidepanel.html', 'dist/sidepanel.html'), + cp('src/ui.css', 'dist/ui.css') +]); diff --git a/extension/scripts/package.mjs b/extension/scripts/package.mjs new file mode 100644 index 0000000..9604c1a --- /dev/null +++ b/extension/scripts/package.mjs @@ -0,0 +1,30 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import yazl from 'yazl'; + +async function files(root, dir = root) { + const entries = await readdir(dir); + const result = []; + for (const entry of entries.sort()) { + const path = join(dir, entry); + if ((await stat(path)).isDirectory()) result.push(...await files(root, path)); + else result.push({ path, name: relative(root, path) }); + } + return result; +} + +await mkdir('artifacts', { recursive: true }); +const output = 'artifacts/tinyflows-chrome-extension-0.1.0.zip'; +const zip = new yazl.ZipFile(); +for (const file of await files('dist')) { + zip.addFile(file.path, file.name, { mtime: new Date('1980-01-01T00:00:00Z'), mode: 0o100644 }); +} +zip.end(); +await new Promise((resolve, reject) => { + zip.outputStream.pipe(createWriteStream(output)).on('close', resolve).on('error', reject); +}); +const digest = createHash('sha256').update(await readFile(output)).digest('hex'); +await writeFile(`${output}.sha256`, `${digest} ${output.split('/').at(-1)}\n`); +console.log(`${output}\nsha256 ${digest}`); diff --git a/extension/src/background.ts b/extension/src/background.ts new file mode 100644 index 0000000..ae56205 --- /dev/null +++ b/extension/src/background.ts @@ -0,0 +1,141 @@ +import { CdpExecutor } from './cdp'; +import { BrowserError, toBrowserError } from './errors'; +import { PROTOCOL_VERSION, tabSharedEvent } from './protocol'; +import type { BrowserRequest, BrowserResponse } from './protocol'; +import { RelayClient } from './relay'; +import type { RelayState } from './relay'; +import { TabManager } from './tab-manager'; + +const tabs = new TabManager(); +const executor = new CdpExecutor(); +let relayState: RelayState = 'unpaired'; +const closingWorkflowTabs = new Set(); +const actions = new Map(); +let bootPromise: Promise | undefined; + +const relay = new RelayClient(handleBrowserRequest, handleRelayState, (event) => { + void chrome.runtime.sendMessage({ type: 'run.event', event }).catch(() => undefined); +}); +relay.setBrowserCancelHandler((requestId) => actions.get(requestId)?.abort()); + +async function handleBrowserRequest(request: BrowserRequest): Promise { + const controller = new AbortController(); + actions.set(request.request_id, controller); + if (request.action.action === 'close') closingWorkflowTabs.add(request.tab_id); + notifyRunEvent({ event: 'browser_action_started', protocol_version: PROTOCOL_VERSION, run_id: request.run_id, + request_id: request.request_id, tab_id: request.tab_id, action: request.action.action }); + relay.send({ event: 'action_started', protocol_version: PROTOCOL_VERSION, request_id: request.request_id, run_id: request.run_id, tab_id: request.tab_id }); + try { + await tabs.assertShared(request.tab_id); + const data = await executor.execute(request, controller); + relay.send({ event: 'action_completed', protocol_version: PROTOCOL_VERSION, request_id: request.request_id, result: { data } }); + notifyRunEvent({ event: 'browser_action_completed', protocol_version: PROTOCOL_VERSION, run_id: request.run_id, + request_id: request.request_id, output: data }); + if (request.action.action === 'close') { + await tabs.revoke(request.tab_id, false); + setTimeout(() => relay.send({ event: 'tab_revoked', protocol_version: PROTOCOL_VERSION, tab_id: request.tab_id }), 0); + } + return { status: 'ok', protocol_version: PROTOCOL_VERSION, request_id: request.request_id, result: { data } }; + } catch (cause) { + const error = await toBrowserError(cause, request.tab_id); + const data = { code: error.code, message: error.message }; + relay.send({ event: 'action_failed', protocol_version: PROTOCOL_VERSION, request_id: request.request_id, error: data }); + notifyRunEvent({ event: 'browser_action_failed', protocol_version: PROTOCOL_VERSION, run_id: request.run_id, + request_id: request.request_id, code: error.code, message: error.message }); + return { status: 'error', protocol_version: PROTOCOL_VERSION, request_id: request.request_id, error: data }; + } finally { + actions.delete(request.request_id); + closingWorkflowTabs.delete(request.tab_id); + } +} + +function notifyRunEvent(event: unknown): void { + void chrome.runtime.sendMessage({ type: 'run.event', event }).catch(() => undefined); +} + +function handleRelayState(state: RelayState): void { + relayState = state; + if (state !== 'connected') { + for (const controller of actions.values()) controller.abort(); + } + const badge = state === 'connected' ? 'connected' : state === 'failed' ? 'failed' : 'reconnecting'; + void tabs.markAll(badge); + if (state === 'connected') void announceAllSharedTabs(); + void chrome.runtime.sendMessage({ type: 'relay.state', state }).catch(() => undefined); +} + +chrome.runtime.onInstalled.addListener(() => { + void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }); +}); +chrome.runtime.onStartup.addListener(() => { void bootOnce(); }); +chrome.tabs.onRemoved.addListener((tabId) => { + if (tabs.has(tabId)) { + void tabs.revoke(tabId, false); + if (!closingWorkflowTabs.has(tabId)) relay.send({ event: 'tab_revoked', protocol_version: PROTOCOL_VERSION, tab_id: tabId }); + } +}); +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (tabs.has(tabId) && changeInfo.groupId !== undefined && !closingWorkflowTabs.has(tabId)) { + void tabs.assertShared(tabId).catch(() => relay.send({ event: 'tab_revoked', protocol_version: PROTOCOL_VERSION, tab_id: tabId })); + } +}); +chrome.debugger.onDetach.addListener((source) => { + if (source.tabId !== undefined && tabs.has(source.tabId)) { + if (closingWorkflowTabs.has(source.tabId)) return; + void tabs.revoke(source.tabId, false); + relay.send({ event: 'tab_revoked', protocol_version: PROTOCOL_VERSION, tab_id: source.tabId }); + } +}); + +chrome.runtime.onMessage.addListener((message: unknown, _sender, sendResponse) => { + void handleUiMessage(message).then(sendResponse, (error: unknown) => { + const result = error instanceof Error ? error.message : String(error); + sendResponse({ ok: false, error: result }); + }); + return true; +}); + +async function handleUiMessage(message: unknown): Promise { + if (!message || typeof message !== 'object') throw new BrowserError('invalid_request', 'Invalid UI request'); + const item = message as Record; + switch (item.type) { + case 'state': return { ok: true, relayState, tabs: tabs.list(), config: await relay.getConfig() }; + case 'tab.toggle': { + if (!Number.isInteger(item.tabId)) throw new Error('Missing tab id'); + const tabId = item.tabId as number; + const shared = await tabs.toggle(tabId); + if (shared && relayState === 'connected') relay.send(tabSharedEvent(await tabs.announcement(tabId))); + else if (!shared) relay.send({ event: 'tab_revoked', protocol_version: PROTOCOL_VERSION, tab_id: tabId }); + return { ok: true, shared }; + } + case 'relay.configure': + await relay.configure({ url: String(item.url ?? ''), pairingToken: String(item.pairingToken ?? '') }); + return { ok: true }; + case 'workflow.list': return { ok: true, result: await relay.request('workflow.list', {}) }; + case 'workflow.start': { + const result = await relay.request('workflow.start', { workflow_id: item.workflowId, tab_id: item.tabId }); + const runId = result && typeof result === 'object' ? String((result as Record).run_id ?? '') : ''; + if (runId) await relay.request('run.subscribe', { run_id: runId }); + return { ok: true, result }; + } + case 'workflow.cancel': return { ok: true, result: await relay.request('workflow.cancel', { run_id: item.runId }) }; + default: throw new Error('Unknown UI request'); + } +} + +async function announceAllSharedTabs(): Promise { + for (const { tabId } of tabs.list()) { + try { relay.send(tabSharedEvent(await tabs.announcement(tabId))); } + catch { /* assertShared revokes stale attachment metadata */ } + } +} + +async function boot(): Promise { + await tabs.rehydrate(); + await relay.start(); +} +function bootOnce(): Promise { + bootPromise ??= boot(); + return bootPromise; +} +void bootOnce(); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts new file mode 100644 index 0000000..2de2559 --- /dev/null +++ b/extension/src/cdp.ts @@ -0,0 +1,196 @@ +import { BrowserError } from './errors'; +import type { BrowserAction, BrowserRequest } from './protocol'; + +type DebuggerApi = Pick; +type TabsApi = Pick; + +export class CdpExecutor { + constructor( + private readonly debuggerApi: DebuggerApi = chrome.debugger, + private readonly tabsApi: TabsApi = chrome.tabs + ) {} + + async execute(request: BrowserRequest, controller = new AbortController()): Promise { + const operation = this.run(request.tab_id, request.action, controller.signal); + return withTimeout(operation, request.timeout_ms, controller); + } + + private async run(tabId: number, action: BrowserAction, signal: AbortSignal): Promise { + ensureActive(signal); + const target = { tabId }; + switch (action.action) { + case 'open': { + const url = action.url; + if (!/^https?:\/\//i.test(url)) throw new BrowserError('unsupported_page', 'Only HTTP(S) URLs can be opened'); + const marker = `__tinyflows_navigation_${Date.now()}_${Math.random().toString(36).slice(2)}`; + await evaluate(this.debuggerApi, target, `globalThis[${JSON.stringify(marker)}]=true`); + const navigation = await this.debuggerApi.sendCommand(target, 'Page.navigate', { url }) as { + errorText?: string; loaderId?: string; + }; + if (navigation.errorText) throw new BrowserError('browser_failure', navigation.errorText); + await waitForReady(this.debuggerApi, target, signal, navigation.loaderId ? marker : undefined); + return { url }; + } + case 'snapshot': + return this.debuggerApi.sendCommand(target, 'Accessibility.getFullAXTree', {}); + case 'get_title': return evaluate(this.debuggerApi, target, 'document.title'); + case 'get_url': return evaluate(this.debuggerApi, target, 'location.href'); + case 'get_text': return evaluate(this.debuggerApi, target, textExpression(action.selector)); + case 'is_visible': return evaluate(this.debuggerApi, target, visibleExpression(action.selector)); + case 'find': return evaluate(this.debuggerApi, target, findExpression(action.query)); + case 'screenshot': { + const options: Record = { format: 'png', fromSurface: true, captureBeyondViewport: action.full_page ?? false }; + if (action.selector) { + options.clip = await elementRect(this.debuggerApi, target, action.selector); + options.captureBeyondViewport = true; + } + else if (action.full_page) { + const metrics = await this.debuggerApi.sendCommand(target, 'Page.getLayoutMetrics', {}) as { cssContentSize?: {x:number;y:number;width:number;height:number} }; + if (metrics.cssContentSize) options.clip = { ...metrics.cssContentSize, scale: 1 }; + } + const result = await this.debuggerApi.sendCommand(target, 'Page.captureScreenshot', options); + return result; + } + case 'click': { + const point = await elementPoint(this.debuggerApi, target, action.selector); + ensureActive(signal); + await mouse(this.debuggerApi, target, 'mousePressed', point, 1); + await mouse(this.debuggerApi, target, 'mouseReleased', point, 1); + return { clicked: true }; + } + case 'hover': { + const point = await elementPoint(this.debuggerApi, target, action.selector); + ensureActive(signal); + await mouse(this.debuggerApi, target, 'mouseMoved', point, 0); + return { hovered: true }; + } + case 'fill': { + const { selector, value } = action; + const ok = await evaluate(this.debuggerApi, target, fillExpression(selector, value)); + if (!ok) throw new BrowserError('element_not_found', `No element matches ${selector}`); + return { filled: true }; + } + case 'type': { + if (action.selector) { + const ok = await evaluate(this.debuggerApi, target, `(() => { const e=document.querySelector(${JSON.stringify(action.selector)}); if(!e)return null; e.focus(); return true; })()`); + if (ok !== true) throw new BrowserError('element_not_found', `No element matches ${action.selector}`); + } + await this.debuggerApi.sendCommand(target, 'Input.insertText', { text: action.text }); + return { typed: true }; + } + case 'press': { + const key = action.key; + await this.debuggerApi.sendCommand(target, 'Input.dispatchKeyEvent', { type: 'keyDown', key }); + await this.debuggerApi.sendCommand(target, 'Input.dispatchKeyEvent', { type: 'keyUp', key }); + return { pressed: key }; + } + case 'scroll': { + const x = action.x ?? 0; const y = action.y ?? 0; + await this.debuggerApi.sendCommand(target, 'Runtime.evaluate', { + expression: `window.scrollBy(${JSON.stringify(x)},${JSON.stringify(y)})`, returnByValue: true + }); + return { x, y }; + } + case 'wait': { + const selector = action.selector; + const ms = action.duration_ms ?? (selector ? 15_000 : 1000); + if (!selector) { await delay(ms); return { waitedMs: ms }; } + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + ensureActive(signal); + if (await evaluate(this.debuggerApi, target, visibleExpression(selector))) return { visible: true }; + await delay(Math.min(100, Math.max(1, deadline - Date.now()))); + } + throw new BrowserError('element_not_found', `Element did not appear: ${selector}`); + } + case 'close': + await this.tabsApi.remove(tabId); + return { closed: true }; + } + } +} + +async function evaluate(api: DebuggerApi, target: chrome.debugger.Debuggee, expression: string): Promise { + const response = await api.sendCommand(target, 'Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + const result = response as { + result?: { value?: unknown }; + exceptionDetails?: { exception?: { description?: string }; text?: string }; + }; + if (result.exceptionDetails) { + throw new BrowserError( + 'browser_failure', + result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? 'Page evaluation failed' + ); + } + return result.result?.value; +} + +async function elementPoint(api: DebuggerApi, target: chrome.debugger.Debuggee, selector: string) { + const expression = `(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e)return null; e.scrollIntoView({block:"center",inline:"center"}); const r=e.getBoundingClientRect(); const s=getComputedStyle(e); if(r.width<=0||r.height<=0||s.visibility==="hidden"||s.display==="none"||s.pointerEvents==="none")return null; const x=r.left+r.width/2,y=r.top+r.height/2,hit=document.elementFromPoint(x,y); if(!hit||!(hit===e||e.contains(hit)))return null; return {x,y}; })()`; + const point = await evaluate(api, target, expression) as { x?: unknown; y?: unknown } | null; + if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') { + throw new BrowserError('element_not_found', `No element matches ${selector}`); + } + return { x: point.x, y: point.y }; +} + +async function elementRect(api: DebuggerApi, target: chrome.debugger.Debuggee, selector: string) { + const expression = `(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e)return null; const r=e.getBoundingClientRect(); return {x:r.left+scrollX,y:r.top+scrollY,width:r.width,height:r.height,scale:1}; })()`; + const rect = await evaluate(api, target, expression) as {x?:unknown;y?:unknown;width?:unknown;height?:unknown;scale?:unknown} | null; + if (!rect || typeof rect.x !== 'number' || typeof rect.y !== 'number' || typeof rect.width !== 'number' || typeof rect.height !== 'number') { + throw new BrowserError('element_not_found', `No element matches ${selector}`); + } + return rect; +} + +async function mouse(api: DebuggerApi, target: chrome.debugger.Debuggee, type: string, point: {x: number; y: number}, clickCount: number) { + await api.sendCommand(target, 'Input.dispatchMouseEvent', { type, x: point.x, y: point.y, button: 'left', clickCount }); +} + +function textExpression(selector?: string): string { + return selector + ? `document.querySelector(${JSON.stringify(selector)})?.textContent ?? null` + : 'document.body?.innerText ?? ""'; +} +function visibleExpression(selector: string): string { + return `(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e)return false; const r=e.getBoundingClientRect(); const s=getComputedStyle(e); return r.width>0&&r.height>0&&s.visibility!=="hidden"&&s.display!=="none"; })()`; +} +function fillExpression(selector: string, value: string): string { + return `(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!(e instanceof HTMLInputElement||e instanceof HTMLTextAreaElement))return false; e.focus(); const set=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e),"value")?.set; set?.call(e,${JSON.stringify(value)}); e.dispatchEvent(new Event("input",{bubbles:true})); e.dispatchEvent(new Event("change",{bubbles:true})); return true; })()`; +} +function findExpression(text: string): string { + return `(() => { const q=${JSON.stringify(text)}.toLowerCase(); return [...document.querySelectorAll("a,button,input,textarea,select,[role],[aria-label]")].filter(e => ((e.textContent||"")+" "+(e.getAttribute("aria-label")||"")).toLowerCase().includes(q)).slice(0,50).map(e => ({tag:e.tagName.toLowerCase(),text:(e.textContent||"").trim().slice(0,500),role:e.getAttribute("role"),ariaLabel:e.getAttribute("aria-label")})); })()`; +} +function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function withTimeout(operation: Promise, ms: number, controller: AbortController): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new BrowserError('action_timeout', `Browser action timed out after ${ms}ms`, true)); + }, ms); + }); + try { return await Promise.race([operation, timeout]); } + finally { if (timer) clearTimeout(timer); } +} + +function ensureActive(signal: AbortSignal): void { + if (signal.aborted) throw new BrowserError('cancelled', 'Browser action was cancelled'); +} + +async function waitForReady( + api: DebuggerApi, + target: chrome.debugger.Debuggee, + signal: AbortSignal, + previousDocumentMarker?: string +): Promise { + while (true) { + ensureActive(signal); + const state = await evaluate(api, target, `({ + ready: document.readyState, + previousDocument: ${previousDocumentMarker ? `globalThis[${JSON.stringify(previousDocumentMarker)}] === true` : 'false'} + })`) as { ready?: unknown; previousDocument?: unknown }; + if (!state.previousDocument && (state.ready === 'interactive' || state.ready === 'complete')) return; + await delay(25); + } +} diff --git a/extension/src/errors.ts b/extension/src/errors.ts new file mode 100644 index 0000000..d5db628 --- /dev/null +++ b/extension/src/errors.ts @@ -0,0 +1,28 @@ +import type { BrowserErrorCode } from './protocol'; + +export class BrowserError extends Error { + constructor( + public readonly code: BrowserErrorCode, + message: string, + public readonly retryable = false + ) { + super(message); + this.name = 'BrowserError'; + } +} + +type TabsApi = Pick; + +export async function toBrowserError( + error: unknown, + tabId?: number, + tabsApi?: TabsApi +): Promise { + if (error instanceof BrowserError) return error; + const message = error instanceof Error ? error.message : String(error); + if (tabId !== undefined) { + try { await (tabsApi ?? chrome.tabs).get(tabId); } + catch { return new BrowserError('tab_revoked', 'The shared tab no longer exists'); } + } + return new BrowserError('browser_failure', message || 'Unknown browser error'); +} diff --git a/extension/src/popup.html b/extension/src/popup.html new file mode 100644 index 0000000..95ac677 --- /dev/null +++ b/extension/src/popup.html @@ -0,0 +1,21 @@ + + + TinyFlows + +
    TF

    TinyFlows

    Checking companion…

    +
    +

    This tab

    +

    Checking sharing status…

    + +

    Only tabs in the “TinyFlows shared tabs” group can be controlled. Removing a tab from the group revokes access.

    +
    +
    +

    Pair companion

    + + +

    +
    + + + + diff --git a/extension/src/popup.ts b/extension/src/popup.ts new file mode 100644 index 0000000..3f2010b --- /dev/null +++ b/extension/src/popup.ts @@ -0,0 +1,41 @@ +import { DEFAULT_URL } from './relay'; + +const $ = (id: string) => document.getElementById(id) as T; +const status = $('relay-status'); +const detail = $('tab-detail'); +const toggle = $('toggle-tab'); +const url = $('relay-url'); +const token = $('pairing-token'); +const result = $('pair-result'); +let activeTabId: number | undefined; + +async function load(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + activeTabId = tab?.id; + const state = await chrome.runtime.sendMessage({ type: 'state' }); + status.textContent = `Companion: ${state.relayState}`; + url.value = state.config?.url ?? DEFAULT_URL; + const shared = activeTabId !== undefined && state.tabs.some((item: {tabId:number}) => item.tabId === activeTabId); + detail.textContent = shared ? 'This tab is explicitly shared.' : 'This tab is private.'; + toggle.textContent = shared ? 'Stop sharing this tab' : 'Share with TinyFlows'; + toggle.disabled = activeTabId === undefined || !tab?.url?.startsWith('http'); +} + +toggle.addEventListener('click', async () => { + if (activeTabId === undefined) return; + toggle.disabled = true; + try { await chrome.runtime.sendMessage({ type: 'tab.toggle', tabId: activeTabId }); await load(); } + catch (error) { detail.textContent = error instanceof Error ? error.message : String(error); } +}); +$('pair').addEventListener('click', async () => { + result.textContent = 'Pairing…'; + const response = await chrome.runtime.sendMessage({ type: 'relay.configure', url: url.value.trim(), pairingToken: token.value.trim() }); + result.textContent = response.ok ? 'Pairing saved. Connecting…' : response.error; + result.className = response.ok ? 'success' : 'error'; + token.value = ''; +}); +$('open-panel').addEventListener('click', async () => { + if (activeTabId !== undefined) await chrome.sidePanel.open({ tabId: activeTabId }); + window.close(); +}); +void load(); diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts new file mode 100644 index 0000000..6ff08db --- /dev/null +++ b/extension/src/protocol.ts @@ -0,0 +1,175 @@ +export const PROTOCOL_VERSION = 1 as const; + +export type BrowserAction = + | { action: 'open'; url: string } + | { action: 'snapshot' } + | { action: 'click'; selector: string } + | { action: 'fill'; selector: string; value: string } + | { action: 'type'; selector?: string; text: string } + | { action: 'get_text'; selector?: string } + | { action: 'get_title' } + | { action: 'get_url' } + | { action: 'screenshot'; selector?: string; full_page?: boolean } + | { action: 'wait'; duration_ms?: number; selector?: string } + | { action: 'press'; key: string } + | { action: 'hover'; selector: string } + | { action: 'scroll'; x?: number; y?: number } + | { action: 'is_visible'; selector: string } + | { action: 'close' } + | { action: 'find'; query: string }; + +export const BROWSER_ERROR_CODES = [ + 'tab_not_shared', 'tab_revoked', 'relay_disconnected', 'unsupported_page', + 'action_timeout', 'element_not_found', 'invalid_request', 'protocol_mismatch', + 'cancelled', 'browser_failure' +] as const; +export type BrowserErrorCode = typeof BROWSER_ERROR_CODES[number]; +export interface BrowserErrorData { code: BrowserErrorCode; message: string; details?: unknown } + +export interface BrowserRequest { + protocol_version: typeof PROTOCOL_VERSION; + request_id: string; + run_id: string; + tab_id: number; + timeout_ms: number; + action: BrowserAction; +} + +export interface BrowserCancel { + protocol_version: typeof PROTOCOL_VERSION; + type: 'browser.cancel'; + request_id: string; +} + +export type BrowserResponse = + | { status: 'ok'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; result: { data: unknown } } + | { status: 'error'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; error: BrowserErrorData }; + +export type BrowserEvent = + | { event: 'action_started'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; run_id: string; tab_id: number } + | { event: 'action_completed'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; result: { data: unknown } } + | { event: 'action_failed'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; error: BrowserErrorData } + | { event: 'tab_revoked'; protocol_version: typeof PROTOCOL_VERSION; tab_id: number } + | { event: 'relay_disconnected'; protocol_version: typeof PROTOCOL_VERSION }; + +export interface TabSharedEvent { + event: 'tab_shared'; + protocol_version: typeof PROTOCOL_VERSION; + tab: { id: number; window_id: number; url: string; title: string }; +} + +export function tabSharedEvent(tab: TabSharedEvent['tab']): TabSharedEvent { + return { event: 'tab_shared', protocol_version: PROTOCOL_VERSION, tab }; +} + +export type ControlRequest = + | { method: 'workflow.list'; protocol_version: typeof PROTOCOL_VERSION; request_id: string } + | { method: 'workflow.start'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; workflow_id: string; tab_id: number; input: unknown } + | { method: 'workflow.cancel'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; run_id: string } + | { method: 'run.subscribe'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; run_id: string } + | { method: 'tab.list'; protocol_version: typeof PROTOCOL_VERSION; request_id: string } + | { method: 'connection.status'; protocol_version: typeof PROTOCOL_VERSION; request_id: string }; +export type ControlResponse = + | { status: 'ok'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; result: unknown } + | { status: 'error'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; code: string; message: string } + | { status: 'workflows'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; workflows: Array<{id:string; name:string}> } + | { status: 'tabs'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; tabs: unknown[] } + | { status: 'connection'; protocol_version: typeof PROTOCOL_VERSION; request_id: string; connected: boolean }; +export type RunEvent = + | { event: 'started'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; tab_id: number } + | { event: 'step_started'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; node_id: string; node_kind: string } + | { event: 'step_completed'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; node_id: string; node_kind: string; status: 'success' | 'error'; duration_ms: number } + | { event: 'awaiting_approval'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; pending_approvals: string[] } + | { event: 'browser_action_started'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; request_id: string; tab_id: number; action: string } + | { event: 'browser_action_completed'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; request_id: string; output: unknown } + | { event: 'browser_action_failed'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; request_id: string; code: string; message: string } + | { event: 'completed'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; status: 'success' } + | { event: 'failed'; protocol_version: typeof PROTOCOL_VERSION; run_id: string; code: string; message: string } + | { event: 'cancelled'; protocol_version: typeof PROTOCOL_VERSION; run_id: string }; + +export function isBrowserRequest(value: unknown): value is BrowserRequest { + if (!isRecordWithKeys(value, ['protocol_version', 'request_id', 'run_id', 'tab_id', 'timeout_ms', 'action'])) return false; + return value.protocol_version === PROTOCOL_VERSION && isId(value.request_id) && isId(value.run_id) && + Number.isSafeInteger(value.tab_id) && (value.tab_id as number) >= 0 && + Number.isSafeInteger(value.timeout_ms) && (value.timeout_ms as number) >= 1 && + (value.timeout_ms as number) <= 60_000 && isBrowserAction(value.action); +} + +export function isBrowserCancel(value: unknown): value is BrowserCancel { + return isRecordWithKeys(value, ['protocol_version', 'type', 'request_id']) && + value.protocol_version === PROTOCOL_VERSION && value.type === 'browser.cancel' && isId(value.request_id); +} + +export function isBrowserAction(value: unknown): value is BrowserAction { + if (!isRecord(value) || typeof value.action !== 'string') return false; + switch (value.action) { + case 'open': return exactStrings(value, ['action', 'url'], ['url']); + case 'snapshot': case 'get_title': case 'get_url': case 'close': return hasExactKeys(value, ['action']); + case 'click': case 'hover': case 'is_visible': return exactStrings(value, ['action', 'selector'], ['selector']); + case 'fill': return hasExactKeys(value, ['action', 'selector', 'value']) && + typeof value.selector === 'string' && value.selector.length > 0 && typeof value.value === 'string'; + case 'type': return exactStrings(value, ['action', 'text'], ['text'], ['selector']); + case 'get_text': return exactStrings(value, ['action'], [], ['selector']); + case 'screenshot': return hasOnlyKeys(value, ['action', 'selector', 'full_page']) && + optionalString(value.selector) && (value.full_page === undefined || typeof value.full_page === 'boolean'); + case 'wait': return hasOnlyKeys(value, ['action', 'duration_ms', 'selector']) && optionalString(value.selector) && + (value.duration_ms === undefined || (Number.isSafeInteger(value.duration_ms) && (value.duration_ms as number) >= 0)); + case 'press': return exactStrings(value, ['action', 'key'], ['key']); + case 'scroll': return hasOnlyKeys(value, ['action', 'x', 'y']) && optionalInteger(value.x) && optionalInteger(value.y); + case 'find': return exactStrings(value, ['action', 'query'], ['query']); + default: return false; + } +} + +export function isControlResponse(value: unknown): value is ControlResponse { + if (!isRecord(value) || value.protocol_version !== PROTOCOL_VERSION || !isId(value.request_id)) return false; + switch (value.status) { + case 'ok': return hasExactKeys(value, ['status', 'protocol_version', 'request_id', 'result']); + case 'error': return hasExactKeys(value, ['status', 'protocol_version', 'request_id', 'code', 'message']) && isId(value.code) && typeof value.message === 'string'; + case 'workflows': return hasExactKeys(value, ['status', 'protocol_version', 'request_id', 'workflows']) && Array.isArray(value.workflows); + case 'tabs': return hasExactKeys(value, ['status', 'protocol_version', 'request_id', 'tabs']) && Array.isArray(value.tabs); + case 'connection': return hasExactKeys(value, ['status', 'protocol_version', 'request_id', 'connected']) && typeof value.connected === 'boolean'; + default: return false; + } +} +export function isRunEvent(value: unknown): value is RunEvent { + if (!isRecord(value) || value.protocol_version !== PROTOCOL_VERSION || !isId(value.run_id)) return false; + switch (value.event) { + case 'started': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'tab_id']) && Number.isSafeInteger(value.tab_id); + case 'step_started': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'node_id', 'node_kind']) && isId(value.node_id) && isId(value.node_kind); + case 'step_completed': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'node_id', 'node_kind', 'status', 'duration_ms']) && + isId(value.node_id) && isId(value.node_kind) && (value.status === 'success' || value.status === 'error') && + Number.isSafeInteger(value.duration_ms) && (value.duration_ms as number) >= 0; + case 'awaiting_approval': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'pending_approvals']) && + Array.isArray(value.pending_approvals) && value.pending_approvals.every(isId); + case 'browser_action_started': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'request_id', 'tab_id', 'action']) && + isId(value.request_id) && Number.isSafeInteger(value.tab_id) && isId(value.action); + case 'browser_action_completed': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'request_id', 'output']) && isId(value.request_id); + case 'browser_action_failed': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'request_id', 'code', 'message']) && + isId(value.request_id) && isId(value.code) && typeof value.message === 'string'; + case 'completed': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'status']) && value.status === 'success'; + case 'failed': return hasExactKeys(value, ['event', 'protocol_version', 'run_id', 'code', 'message']) && isId(value.code) && typeof value.message === 'string'; + case 'cancelled': return hasExactKeys(value, ['event', 'protocol_version', 'run_id']); + default: return false; + } +} +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function isRecordWithKeys(value: unknown, keys: string[]): value is Record { + return isRecord(value) && hasExactKeys(value, keys); +} +function hasExactKeys(value: Record, keys: string[]): boolean { + return Object.keys(value).length === keys.length && hasOnlyKeys(value, keys) && keys.every((key) => key in value); +} +function hasOnlyKeys(value: Record, keys: string[]): boolean { + return Object.keys(value).every((key) => keys.includes(key)); +} +function exactStrings(value: Record, requiredKeys: string[], requiredStrings: string[], optionalStrings: string[] = []): boolean { + if (!requiredKeys.every((key) => key in value) || !hasOnlyKeys(value, [...requiredKeys, ...optionalStrings])) return false; + return requiredStrings.every((key) => typeof value[key] === 'string' && (value[key] as string).length > 0) && + optionalStrings.every((key) => optionalString(value[key])); +} +function optionalString(value: unknown): boolean { return value === undefined || typeof value === 'string'; } +function optionalInteger(value: unknown): boolean { return value === undefined || Number.isSafeInteger(value); } +function isId(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= 256; } diff --git a/extension/src/relay.ts b/extension/src/relay.ts new file mode 100644 index 0000000..266141b --- /dev/null +++ b/extension/src/relay.ts @@ -0,0 +1,166 @@ +import { PROTOCOL_VERSION, isBrowserCancel, isBrowserRequest, isControlResponse, isRunEvent } from './protocol'; +import type { BrowserRequest, BrowserResponse, ControlRequest, ControlResponse, RunEvent } from './protocol'; + +const CONFIG_KEY = 'tinyflows.relayConfig.v1'; +const DEFAULT_URL = 'ws://127.0.0.1:32189/v1/extension'; +export interface RelayConfig { url: string; pairingToken: string } +export type RelayState = 'unpaired' | 'connecting' | 'connected' | 'reconnecting' | 'failed'; + +type StorageApi = Pick; +type WebSocketFactory = (url: string, protocols: string[]) => WebSocket; + +export class RelayClient { + private socket: WebSocket | undefined; + private retryTimer?: ReturnType; + private heartbeatTimer?: ReturnType; + private retries = 0; + private stopped = false; + private pending = new Map void; reject: (reason: Error) => void; timer: ReturnType }>(); + private onBrowserCancel: (requestId: string) => void = () => undefined; + + constructor( + private readonly onBrowserRequest: (request: BrowserRequest) => Promise, + private readonly onState: (state: RelayState) => void, + private readonly onRunEvent: (event: RunEvent) => void, + private readonly storage: StorageApi = chrome.storage, + private readonly makeWebSocket: WebSocketFactory = (url, protocols) => new WebSocket(url, protocols) + ) {} + + async start(): Promise { + this.stopped = false; + const config = await this.getConfig(); + if (!config) { this.onState('unpaired'); return; } + this.connect(config); + } + + setBrowserCancelHandler(handler: (requestId: string) => void): void { + this.onBrowserCancel = handler; + } + + stop(): void { + this.stopped = true; + if (this.retryTimer) clearTimeout(this.retryTimer); + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + const socket = this.socket; + this.socket = undefined; + socket?.close(1000, 'extension stopped'); + this.rejectPending('Relay disconnected'); + } + + async configure(config: RelayConfig): Promise { + assertConfig(config); + await this.storage.local.set({ [CONFIG_KEY]: config }); + this.stop(); + this.retries = 0; + await this.start(); + } + + async getConfig(): Promise { + const value = (await this.storage.local.get(CONFIG_KEY))[CONFIG_KEY]; + if (!isRelayConfig(value)) return undefined; + return value; + } + + async request(method: ControlRequest['method'], params: Record, timeoutMs = 15_000): Promise { + if (!this.socket || this.socket.readyState !== 1) throw new Error('Relay is not connected'); + const request_id = crypto.randomUUID(); + const request = controlRequest(method, request_id, params); + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.pending.delete(request_id); reject(new Error(`${method} timed out`)); }, timeoutMs); + this.pending.set(request_id, { resolve, reject, timer }); + }); + this.socket.send(JSON.stringify(request)); + return result; + } + + send(message: unknown): void { + if (this.socket?.readyState === 1) this.socket.send(JSON.stringify(message)); + } + + private connect(config: RelayConfig): void { + if (this.stopped) return; + this.onState(this.retries ? 'reconnecting' : 'connecting'); + const protocols = ['tinyflows.v1', `tinyflows.auth.${config.pairingToken}`]; + const socket = this.makeWebSocket(config.url, protocols); + this.socket = socket; + socket.onopen = () => { + if (socket !== this.socket) return socket.close(); + this.retries = 0; + this.onState('connected'); + this.heartbeatTimer = setInterval(() => this.send({ protocol_version: PROTOCOL_VERSION, type: 'heartbeat' }), 15_000); + }; + socket.onmessage = (event) => { void this.handleMessage(String(event.data)); }; + socket.onerror = () => { if (socket === this.socket) this.onState('failed'); }; + socket.onclose = () => { + if (socket !== this.socket) return; + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.rejectPending('Relay disconnected'); + if (!this.stopped) this.scheduleReconnect(config); + }; + } + + private async handleMessage(raw: string): Promise { + let message: unknown; + try { message = JSON.parse(raw); } catch { return; } + if (isBrowserRequest(message)) { + this.send(await this.onBrowserRequest(message)); + } else if (isBrowserCancel(message)) { + this.onBrowserCancel(message.request_id); + } else if (isControlResponse(message)) { + this.settle(message); + } else if (isRunEvent(message)) { + this.onRunEvent(message); + } + } + + private settle(response: ControlResponse): void { + const pending = this.pending.get(response.request_id); + if (!pending) return; + clearTimeout(pending.timer); this.pending.delete(response.request_id); + switch (response.status) { + case 'ok': pending.resolve(response.result); break; + case 'workflows': pending.resolve(response.workflows); break; + case 'tabs': pending.resolve(response.tabs); break; + case 'connection': pending.resolve({ connected: response.connected }); break; + case 'error': pending.reject(new Error(response.message)); break; + } + } + + private scheduleReconnect(config: RelayConfig): void { + this.retries += 1; + this.onState(this.retries >= 8 ? 'failed' : 'reconnecting'); + const delay = Math.min(30_000, 500 * 2 ** Math.min(this.retries - 1, 6)); + this.retryTimer = setTimeout(() => this.connect(config), delay); + } + + private rejectPending(message: string): void { + for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error(message)); } + this.pending.clear(); + } +} + +function assertConfig(value: RelayConfig): void { + if (!isRelayConfig(value)) throw new Error('Use a loopback ws:// URL and a base64url pairing token'); +} +function isRelayConfig(value: unknown): value is RelayConfig { + if (typeof value !== 'object' || value === null) return false; + const item = value as Record; + if (typeof item.url !== 'string' || typeof item.pairingToken !== 'string' || !/^[A-Za-z0-9_-]{32,512}$/.test(item.pairingToken)) return false; + try { + const url = new URL(item.url); + return url.protocol === 'ws:' && (url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]'); + } catch { return false; } +} +export { DEFAULT_URL }; + +function controlRequest(method: ControlRequest['method'], request_id: string, params: Record): ControlRequest { + const base = { protocol_version: PROTOCOL_VERSION, request_id }; + switch (method) { + case 'workflow.list': return { ...base, method }; + case 'tab.list': return { ...base, method }; + case 'connection.status': return { ...base, method }; + case 'workflow.start': return { ...base, method, workflow_id: String(params.workflow_id ?? ''), tab_id: Number(params.tab_id), input: params.input ?? {} }; + case 'workflow.cancel': return { ...base, method, run_id: String(params.run_id ?? '') }; + case 'run.subscribe': return { ...base, method, run_id: String(params.run_id ?? '') }; + } +} diff --git a/extension/src/sidepanel.html b/extension/src/sidepanel.html new file mode 100644 index 0000000..fdbcb93 --- /dev/null +++ b/extension/src/sidepanel.html @@ -0,0 +1,13 @@ + + + TinyFlows workflows + +
    TF

    TinyFlows

    Connecting…

    +
    +

    Workflows

    Loading workflows…

    +

    Current run

    No active run.

    +

    Event journal

      +
      + + + diff --git a/extension/src/sidepanel.ts b/extension/src/sidepanel.ts new file mode 100644 index 0000000..09ba04a --- /dev/null +++ b/extension/src/sidepanel.ts @@ -0,0 +1,61 @@ +const $ = (id: string) => document.getElementById(id) as T; +const workflows = $('workflows'); +const run = $('run'); +const events = $('events'); +const cancel = $('cancel'); +let activeRunId: string | undefined; + +async function refresh(): Promise { + const state = await chrome.runtime.sendMessage({ type: 'state' }); + $('connection').textContent = `Companion: ${state.relayState} · ${state.tabs.length} shared tab(s)`; + const response = await chrome.runtime.sendMessage({ type: 'workflow.list' }); + if (!response.ok) { workflows.innerHTML = `

      `; workflows.querySelector('p')!.textContent = response.error; return; } + const list = Array.isArray(response.result) ? response.result : []; + workflows.replaceChildren(...list.map(workflowCard)); + if (list.length === 0) workflows.innerHTML = '

      No workflows exposed by the companion.

      '; +} + +function workflowCard(value: unknown): HTMLElement { + const item = (value && typeof value === 'object' ? value : {}) as Record; + const id = String(item.id ?? item.workflow_id ?? ''); + const card = document.createElement('article'); card.className = 'card'; + const title = document.createElement('strong'); title.textContent = String(item.name ?? id); + const description = document.createElement('p'); description.className = 'muted'; description.textContent = String(item.description ?? ''); + const button = document.createElement('button'); button.textContent = 'Run in this tab'; + button.addEventListener('click', () => void start(id)); + card.append(title, description, button); return card; +} + +async function start(workflowId: string): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab?.id === undefined) return showError('No active tab'); + const response = await chrome.runtime.sendMessage({ type: 'workflow.start', workflowId, tabId: tab.id }); + if (!response.ok) return showError(response.error); + const value = response.result as Record | undefined; + activeRunId = String(value?.run_id ?? value?.id ?? ''); + run.textContent = `Run ${activeRunId || 'started'}`; cancel.hidden = !activeRunId; + if (activeRunId) addEvent({ event: 'started', protocol_version: 1, run_id: activeRunId, tab_id: tab.id }); +} +function showError(message: string): void { run.innerHTML = '

      '; run.querySelector('p')!.textContent = message; } +function addEvent(value: unknown): void { + const event = value as Record; + const runId = typeof event.run_id === 'string' ? event.run_id : undefined; + if (!activeRunId || runId !== activeRunId) return; + const li = document.createElement('li'); + const details = { ...event }; + delete details.event; delete details.protocol_version; delete details.run_id; + li.textContent = `${String(event.event ?? 'event')} · ${JSON.stringify(details)}`; + events.prepend(li); while (events.children.length > 200) events.lastElementChild?.remove(); +} +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'relay.state') $('connection').textContent = `Companion: ${message.state}`; + if (message.type === 'run.event') addEvent(message.event); +}); +$('refresh').addEventListener('click', () => void refresh()); +cancel.addEventListener('click', async () => { + if (!activeRunId) return; + const response = await chrome.runtime.sendMessage({ type: 'workflow.cancel', runId: activeRunId }); + if (!response.ok) return showError(response.error); + addEvent({ event: 'cancel_requested', data: { run_id: activeRunId } }); activeRunId = undefined; cancel.hidden = true; +}); +void refresh().catch((error) => showError(error instanceof Error ? error.message : String(error))); diff --git a/extension/src/tab-manager.ts b/extension/src/tab-manager.ts new file mode 100644 index 0000000..c7297c4 --- /dev/null +++ b/extension/src/tab-manager.ts @@ -0,0 +1,152 @@ +import { BrowserError } from './errors'; + +const STORAGE_KEY = 'tinyflows.sharedTabs.v1'; +export const GROUP_TITLE = 'TinyFlows shared tabs'; + +export type BadgeState = 'connected' | 'reconnecting' | 'failed' | 'idle'; +export interface SharedTab { tabId: number; groupId: number; windowId: number; attachedAt: number } +export interface SharedTabAnnouncement { id: number; window_id: number; url: string; title: string } + +type ChromeApi = Pick; + +export class TabManager { + private shared = new Map(); + + constructor(private readonly api: ChromeApi = chrome) {} + + async rehydrate(): Promise { + const saved = (await this.api.storage.local.get(STORAGE_KEY))[STORAGE_KEY]; + if (Array.isArray(saved)) { + for (const item of saved) { + if (isSharedTab(item)) this.shared.set(item.tabId, item); + } + } + for (const tabId of [...this.shared.keys()]) { + try { + await this.assertShared(tabId); + const targets = await this.api.debugger.getTargets(); + if (!targets.some((target) => target.tabId === tabId && target.attached)) { + await this.api.debugger.attach({ tabId }, '1.3'); + } + await this.setBadge(tabId, 'connected'); + } catch { + await this.revoke(tabId, false); + } + } + return this.list(); + } + + async share(tabId: number): Promise { + if (this.shared.has(tabId)) return (await this.assertShared(tabId)).shared; + const tab = await this.api.tabs.get(tabId); + ensureSupported(tab.url); + if (tab.windowId === undefined) throw new BrowserError('invalid_request', 'Tab has no window'); + + await this.api.debugger.attach({ tabId }, '1.3'); + let groupId: number; + try { + const groups = await this.api.tabGroups.query({ windowId: tab.windowId, title: GROUP_TITLE }); + const existing = groups[0]; + if (existing) { + groupId = existing.id; + await this.api.tabs.group({ groupId, tabIds: [tabId] }); + } else { + groupId = await this.api.tabs.group({ tabIds: [tabId] }); + await this.api.tabGroups.update(groupId, { title: GROUP_TITLE, color: 'blue', collapsed: false }); + } + } catch (error) { + try { await this.api.debugger.detach({ tabId }); } catch { /* attach rollback */ } + throw error; + } + const shared = { tabId, groupId, windowId: tab.windowId, attachedAt: Date.now() }; + this.shared.set(tabId, shared); + await this.persist(); + await this.setBadge(tabId, 'connected'); + return shared; + } + + async revoke(tabId: number, ungroup = true): Promise { + const existed = this.shared.delete(tabId); + if (existed) await this.persist(); + try { await this.api.debugger.detach({ tabId }); } catch { /* already detached */ } + if (ungroup) { + try { await this.api.tabs.ungroup([tabId]); } catch { /* tab already closed */ } + } + await this.setBadge(tabId, 'idle'); + } + + async toggle(tabId: number): Promise { + if (this.shared.has(tabId)) { + await this.revoke(tabId); + return false; + } + await this.share(tabId); + return true; + } + + async assertShared(tabId: number): Promise<{ shared: SharedTab; tab: chrome.tabs.Tab }> { + const record = this.shared.get(tabId); + if (!record) throw new BrowserError('tab_not_shared', 'The tab was not explicitly shared'); + let tab: chrome.tabs.Tab; + try { tab = await this.api.tabs.get(tabId); } + catch { throw new BrowserError('tab_revoked', 'The shared tab no longer exists'); } + ensureSupported(tab.url); + if (tab.groupId !== record.groupId) { + await this.revoke(tabId, false); + throw new BrowserError('tab_revoked', 'The tab was removed from the TinyFlows group'); + } + const group = await this.api.tabGroups.get(record.groupId).catch(() => undefined); + if (!group || group.title !== GROUP_TITLE) { + await this.revoke(tabId, false); + throw new BrowserError('tab_revoked', 'The TinyFlows group was removed or renamed'); + } + return { shared: record, tab }; + } + + list(): SharedTab[] { return [...this.shared.values()].sort((a, b) => a.tabId - b.tabId); } + has(tabId: number): boolean { return this.shared.has(tabId); } + + async announcement(tabId: number): Promise { + const { shared, tab } = await this.assertShared(tabId); + return { + id: tabId, + window_id: shared.windowId, + url: tab.url ?? '', + title: tab.title ?? '' + }; + } + + async markAll(state: BadgeState): Promise { + await Promise.all(this.list().map(({ tabId }) => this.setBadge(tabId, state))); + } + + async setBadge(tabId: number, state: BadgeState): Promise { + const visual = { + connected: { text: 'ON', color: '#16794f' }, + reconnecting: { text: '…', color: '#ad6b00' }, + failed: { text: '!', color: '#b42318' }, + idle: { text: '', color: '#59636e' } + }[state]; + try { + await this.api.action.setBadgeBackgroundColor({ tabId, color: visual.color }); + await this.api.action.setBadgeText({ tabId, text: visual.text }); + } catch { /* tab may have closed */ } + } + + private async persist(): Promise { + await this.api.storage.local.set({ [STORAGE_KEY]: this.list() }); + } +} + +function ensureSupported(url?: string): void { + if (!url || !/^https?:\/\//i.test(url)) { + throw new BrowserError('unsupported_page', 'Chrome does not permit automation on this page'); + } +} + +function isSharedTab(value: unknown): value is SharedTab { + if (typeof value !== 'object' || value === null) return false; + const item = value as Record; + return Number.isInteger(item.tabId) && Number.isInteger(item.groupId) && + Number.isInteger(item.windowId) && typeof item.attachedAt === 'number'; +} diff --git a/extension/src/ui.css b/extension/src/ui.css new file mode 100644 index 0000000..555cf9a --- /dev/null +++ b/extension/src/ui.css @@ -0,0 +1,21 @@ +:root { color-scheme: light dark; font: 14px/1.45 system-ui, sans-serif; --accent:#3367d6; --border:#c8d0dc; --muted:#667085; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; color: CanvasText; background: Canvas; } +.popup { width: 360px; padding-bottom: 14px; } +.panel { min-width: 260px; } +header { display:flex; align-items:center; gap:12px; padding:16px; border-bottom:1px solid var(--border); } +.mark { display:grid; place-items:center; width:36px; height:36px; border-radius:10px; background:var(--accent); color:white; font-weight:800; } +h1,h2,p { margin:0; } h1 { font-size:18px; } h2 { font-size:14px; margin-bottom:10px; } +header p,.muted,.fine-print { color:var(--muted); } .fine-print { margin-top:9px; font-size:12px; } +section { padding:15px 16px; border-bottom:1px solid var(--border); } +label { display:block; color:var(--muted); font-size:12px; margin:9px 0; } +input { display:block; width:100%; margin-top:4px; padding:8px; border:1px solid var(--border); border-radius:6px; background:Canvas; color:CanvasText; } +button { width:100%; border:0; border-radius:7px; padding:9px 12px; background:var(--accent); color:white; font:inherit; font-weight:650; cursor:pointer; } +button:disabled { opacity:.5; cursor:default; } button.secondary { color:CanvasText; background:ButtonFace; border:1px solid var(--border); } +button.danger { background:#b42318; } button.compact { width:auto; padding:5px 9px; } +#open-panel { width:calc(100% - 32px); margin:14px 16px 0; } +#pair-result { min-height:20px; padding-top:6px; color:var(--muted); } +.section-heading { display:flex; align-items:center; justify-content:space-between; } +.cards { display:grid; gap:8px; }.card { padding:10px; border:1px solid var(--border); border-radius:8px; }.card button { margin-top:8px; } +.events { margin:0; padding-left:22px; max-height:45vh; overflow:auto; }.events li { padding:6px 0; border-bottom:1px solid var(--border); overflow-wrap:anywhere; } +.error { color:#b42318; }.success { color:#16794f; } diff --git a/extension/tests/cdp.test.ts b/extension/tests/cdp.test.ts new file mode 100644 index 0000000..d91fb88 --- /dev/null +++ b/extension/tests/cdp.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CdpExecutor } from '../src/cdp'; +import type { BrowserAction, BrowserRequest } from '../src/protocol'; + +function request(action: BrowserAction, timeout_ms = 1000): BrowserRequest { + return { protocol_version: 1, request_id: 'r', run_id: 'run', tab_id: 7, timeout_ms, action }; +} +function executor(responses: unknown[] = []) { + const sendCommand = vi.fn(async () => responses.shift() ?? {}); + const remove = vi.fn(async () => undefined); + return { instance: new CdpExecutor({ sendCommand } as any, { remove } as any), sendCommand, remove }; +} + +describe('CDP action execution', () => { + it('navigates only to HTTP pages and returns structured data', async () => { + const { instance, sendCommand } = executor([ + { result: { value: true } }, { loaderId: 'destination' }, + { result: { value: { ready: 'complete', previousDocument: true } } }, + { result: { value: { ready: 'complete', previousDocument: false } } } + ]); + await expect(instance.execute(request({ action: 'open', url: 'https://example.com' }))).resolves.toEqual({ url: 'https://example.com' }); + expect(sendCommand).toHaveBeenCalledWith({ tabId: 7 }, 'Page.navigate', { url: 'https://example.com' }); + expect(sendCommand).toHaveBeenCalledTimes(4); + await expect(instance.execute(request({ action: 'open', url: 'chrome://settings' }))).rejects.toMatchObject({ code: 'unsupported_page' }); + }); + + it('surfaces navigation failures instead of reporting an open success', async () => { + const { instance } = executor([{ result: { value: true } }, { errorText: 'net::ERR_FAILED' }]); + await expect(instance.execute(request({ action: 'open', url: 'https://example.com' }))).rejects.toMatchObject({ + code: 'browser_failure', message: 'net::ERR_FAILED' + }); + }); + + it('executes evaluate, keyboard, mouse, screenshot, and close actions', async () => { + const { instance, sendCommand, remove } = executor([ + { result: { value: 'A title' } }, { result: { value: { x: 10, y: 20 } } }, + {}, {}, { data: 'png' }, {}, {} + ]); + await expect(instance.execute(request({ action: 'get_title' }))).resolves.toBe('A title'); + await expect(instance.execute(request({ action: 'click', selector: '#go' }))).resolves.toEqual({ clicked: true }); + await expect(instance.execute(request({ action: 'screenshot' }))).resolves.toEqual({ data: 'png' }); + await expect(instance.execute(request({ action: 'press', key: 'Enter' }))).resolves.toEqual({ pressed: 'Enter' }); + await expect(instance.execute(request({ action: 'close' }))).resolves.toEqual({ closed: true }); + expect(remove).toHaveBeenCalledWith(7); + expect((sendCommand.mock.calls as unknown[][]).some((call) => call[1] === 'Input.dispatchMouseEvent')).toBe(true); + expect((sendCommand.mock.calls as unknown[][]).some((call) => + call[1] === 'Runtime.evaluate' && JSON.stringify(call[2]).includes('scrollIntoView') + )).toBe(true); + }); + + it('fails with stable errors when an element disappears or an action times out', async () => { + const missing = executor([{ result: { value: null } }]).instance; + await expect(missing.execute(request({ action: 'hover', selector: '.gone' }))).rejects.toMatchObject({ code: 'element_not_found' }); + vi.useFakeTimers(); + const slow = new CdpExecutor({ sendCommand: () => new Promise(() => undefined) } as any, { remove: vi.fn() } as any); + const pending = slow.execute(request({ action: 'snapshot' }, 100)); + const rejected = expect(pending).rejects.toMatchObject({ code: 'action_timeout', retryable: true }); + await vi.advanceTimersByTimeAsync(100); + await rejected; + vi.useRealTimers(); + }); + + it('requires click and hover targets to be visible and hit-testable', async () => { + const { instance, sendCommand } = executor([{ result: { value: null } }]); + await expect(instance.execute(request({ action: 'click', selector: '#hidden' }))).rejects.toMatchObject({ + code: 'element_not_found' + }); + expect(sendCommand).not.toHaveBeenCalledWith( + { tabId: 7 }, 'Input.dispatchMouseEvent', expect.anything() + ); + expect(JSON.stringify((sendCommand.mock.calls as unknown[][])[0]?.[2])).toContain('elementFromPoint'); + }); + + it('fills, types, scrolls, waits, and finds semantically', async () => { + const { instance, sendCommand } = executor([ + { result: { value: true } }, { result: { value: true } }, {}, {}, + { result: { value: true } }, { result: { value: [{ tag: 'button', text: 'Buy' }] } } + ]); + await expect(instance.execute(request({ action: 'fill', selector: '#q', value: 'abc' }))).resolves.toEqual({ filled: true }); + await expect(instance.execute(request({ action: 'type', selector: '#q', text: 'd' }))).resolves.toEqual({ typed: true }); + await expect(instance.execute(request({ action: 'scroll', y: 20 }))).resolves.toEqual({ x: 0, y: 20 }); + await expect(instance.execute(request({ action: 'wait', selector: '#done', duration_ms: 100 }))).resolves.toEqual({ visible: true }); + await expect(instance.execute(request({ action: 'find', query: 'Buy' }))).resolves.toEqual([{ tag: 'button', text: 'Buy' }]); + expect(sendCommand).toHaveBeenCalled(); + }); + + it('reads text and visibility and clips element screenshots', async () => { + const { instance, sendCommand } = executor([ + { result: { value: 'text' } }, { result: { value: true } }, + { result: { value: { x: 1, y: 2, width: 30, height: 40, scale: 1 } } }, { data: 'clip' }, + { cssContentSize: { x: 0, y: 0, width: 100, height: 200 } }, { data: 'full' } + ]); + await expect(instance.execute(request({ action: 'get_text', selector: '#x' }))).resolves.toBe('text'); + await expect(instance.execute(request({ action: 'is_visible', selector: '#x' }))).resolves.toBe(true); + await expect(instance.execute(request({ action: 'screenshot', selector: '#x' }))).resolves.toEqual({ data: 'clip' }); + await expect(instance.execute(request({ action: 'screenshot', full_page: true }))).resolves.toEqual({ data: 'full' }); + expect(sendCommand).toHaveBeenCalledWith({ tabId: 7 }, 'Page.captureScreenshot', expect.objectContaining({ + clip: expect.any(Object), captureBeyondViewport: true + })); + }); + + it('surfaces page evaluation diagnostics', async () => { + const { instance } = executor([{ exceptionDetails: { exception: { description: 'ReferenceError: broken' } } }]); + await expect(instance.execute(request({ action: 'get_title' }))).rejects.toMatchObject({ + code: 'browser_failure', message: 'ReferenceError: broken' + }); + }); +}); diff --git a/extension/tests/e2e/extension.spec.ts b/extension/tests/e2e/extension.spec.ts new file mode 100644 index 0000000..523e810 --- /dev/null +++ b/extension/tests/e2e/extension.spec.ts @@ -0,0 +1,152 @@ +import { test as base, chromium, expect } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { WebSocketServer, type WebSocket } from 'ws'; + +const test = base.extend<{ context: BrowserContext; extensionId: string; profile: string }>({ + profile: async ({}, use) => { + const profile = await mkdtemp(join(tmpdir(), 'tinyflows-extension-')); + await use(profile); await rm(profile, { recursive: true, force: true }); + }, + context: async ({ profile }, use) => { + const path = resolve('dist'); + const context = await chromium.launchPersistentContext(profile, { + channel: 'chromium', headless: true, + args: [`--disable-extensions-except=${path}`, `--load-extension=${path}`] + }); + await use(context); await context.close(); + }, + extensionId: async ({ context }, use) => { + let worker = context.serviceWorkers()[0]; + worker ??= await context.waitForEvent('serviceworker'); + await use(new URL(worker.url()).host); + } +}); + +test('loads local MV3 pages and keeps ordinary tabs private by default', async ({ context, extensionId }) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/sidepanel.html`); + await expect(page.locator('h1')).toHaveText('TinyFlows'); + + const state = await page.evaluate(async () => chrome.runtime.sendMessage({ type: 'state' })); + expect(state.tabs).toEqual([]); +}); + +test('explicitly shares and revokes an ordinary tab through the TinyFlows group', async ({ context, extensionId }) => { + await context.route('http://tinyflows.test/', (route) => route.fulfill({ contentType: 'text/html', body: 'Fixture' })); + const target = await context.newPage(); + await target.goto('http://tinyflows.test/'); + const worker = context.serviceWorkers()[0]!; + const tabId = await worker.evaluate(async () => { + const tabs = await chrome.tabs.query({ title: 'Fixture' }); return tabs[0]!.id!; + }); + const control = await context.newPage(); await control.goto(`chrome-extension://${extensionId}/popup.html`); + const shared = await control.evaluate(async (id) => chrome.runtime.sendMessage({ type: 'tab.toggle', tabId: id }), tabId); + expect(shared).toMatchObject({ ok: true, shared: true }); + const childPromise = context.waitForEvent('page'); + await target.evaluate(() => { window.open('http://tinyflows.test/?child=1'); }); + const child = await childPromise; + await child.waitForLoadState(); + const state = await control.evaluate(async () => chrome.runtime.sendMessage({ type: 'state' })); + expect(state.tabs).toEqual([expect.objectContaining({ tabId })]); + await child.close(); + const revoked = await control.evaluate(async (id) => chrome.runtime.sendMessage({ type: 'tab.toggle', tabId: id }), tabId); + expect(revoked).toMatchObject({ ok: true, shared: false }); +}); + +test('executes a deterministic signed-in shopping journey over the relay', async ({ context, extensionId }) => { + let extensionSocket: WebSocket | undefined; + const messages: unknown[] = []; + // Locate the test relay's ephemeral port without exposing a credential in the URL. + const relayServer = new WebSocketServer({ port: 0, host: '127.0.0.1', handleProtocols(protocols) { + return protocols.has('tinyflows.v1') ? 'tinyflows.v1' : false; + }}); + try { + await new Promise((resolveListening) => relayServer.once('listening', resolveListening)); + const relayConnected = new Promise((resolveConnected) => { + relayServer.on('connection', (socket) => { + extensionSocket = socket; + socket.on('message', (data) => messages.push(JSON.parse(data.toString()))); + resolveConnected(); + }); + }); + const address = relayServer.address(); + if (!address || typeof address === 'string') throw new Error('missing relay port'); + + await context.route('http://shop.tinyflows.test/', (route) => route.fulfill({ + contentType: 'text/html', + body: `TinyFlows Shop +
      +
      + ` + })); + const target = await context.newPage(); + await target.goto('http://shop.tinyflows.test/'); + const worker = context.serviceWorkers()[0]!; + const tabId = await worker.evaluate(async () => { + const tabs = await chrome.tabs.query({ title: 'TinyFlows Shop' }); return tabs[0]!.id!; + }); + const control = await context.newPage(); + await control.goto(`chrome-extension://${extensionId}/popup.html`); + await control.evaluate(async ({ port }) => chrome.runtime.sendMessage({ + type: 'relay.configure', + url: `ws://127.0.0.1:${port}`, + pairingToken: '0123456789abcdef0123456789abcdef' + }), { port: address.port }); + await relayConnected; + await control.evaluate(async (id) => chrome.runtime.sendMessage({ type: 'tab.toggle', tabId: id }), tabId); + await expect.poll(() => messages.some((message) => (message as {event?:string}).event === 'tab_shared')).toBe(true); + + let sequence = 0; + async function action(value: Record): Promise { + const requestId = `journey:${++sequence}`; + extensionSocket!.send(JSON.stringify({ + protocol_version: 1, request_id: requestId, run_id: 'journey', tab_id: tabId, + timeout_ms: 5000, action: value + })); + await expect.poll(() => messages.find((message) => + (message as {status?:string;request_id?:string}).status && + (message as {request_id?:string}).request_id === requestId + )).toBeTruthy(); + const response = messages.find((message) => + (message as {status?:string;request_id?:string}).status && + (message as {request_id?:string}).request_id === requestId + ) as {status:string;result?:{data:unknown};error?:unknown}; + expect(response.status, JSON.stringify(response.error)).toBe('ok'); + return response.result?.data; + } + + await action({ action: 'fill', selector: '#email', value: 'person@example.com' }); + await action({ action: 'click', selector: '#sign-in' }); + expect(await action({ action: 'is_visible', selector: '#shop' })).toBe(true); + await action({ action: 'fill', selector: '#search', value: 'Trail Boot' }); + expect(await action({ action: 'get_text', selector: '.product-name' })).toBe('Trail Boot'); + await action({ action: 'click', selector: '#result' }); + expect(await action({ action: 'get_text', selector: '#details' })).toContain('$89'); + const closeRequestId = `journey:${sequence + 1}`; + await action({ action: 'close' }); + await expect.poll(() => messages.some((message) => + (message as {event?:string;tab_id?:number}).event === 'tab_revoked' && + (message as {tab_id?:number}).tab_id === tabId + )).toBe(true); + const closeResponseIndex = messages.findIndex((message) => + (message as {status?:string;request_id?:string}).status === 'ok' && + (message as {request_id?:string}).request_id === closeRequestId + ); + const revokeIndex = messages.findIndex((message) => + (message as {event?:string;tab_id?:number}).event === 'tab_revoked' && + (message as {tab_id?:number}).tab_id === tabId + ); + expect(closeResponseIndex).toBeGreaterThanOrEqual(0); + expect(revokeIndex).toBeGreaterThan(closeResponseIndex); + + } finally { + extensionSocket?.close(); + await new Promise((resolveClosed) => relayServer.close(() => resolveClosed())); + } +}); diff --git a/extension/tests/errors.test.ts b/extension/tests/errors.test.ts new file mode 100644 index 0000000..59fa3bc --- /dev/null +++ b/extension/tests/errors.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { BrowserError, toBrowserError } from '../src/errors'; + +describe('browser errors', () => { + it('preserves stable errors and classifies Chrome failures', async () => { + const stable = new BrowserError('cancelled', 'cancelled'); + await expect(toBrowserError(stable)).resolves.toBe(stable); + const missing = { get: async () => { throw new Error('wording may change'); } }; + await expect(toBrowserError(new Error('debugger failed'), 7, missing)).resolves.toMatchObject({ code: 'tab_revoked' }); + const existing = { get: async () => ({ id: 7 }) as chrome.tabs.Tab }; + await expect(toBrowserError(new Error('No tab with id: 7'), 7, existing)).resolves.toMatchObject({ + code: 'browser_failure', message: 'No tab with id: 7' + }); + await expect(toBrowserError('bad')).resolves.toMatchObject({ code: 'browser_failure', message: 'bad' }); + }); +}); diff --git a/extension/tests/protocol.test.ts b/extension/tests/protocol.test.ts new file mode 100644 index 0000000..cc493ca --- /dev/null +++ b/extension/tests/protocol.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { isBrowserAction, isBrowserCancel, isBrowserRequest, isControlResponse, isRunEvent, tabSharedEvent } from '../src/protocol'; + +const base = { + protocol_version: 1, request_id: 'req-1', run_id: 'run-1', tab_id: 4, timeout_ms: 1000, + action: { action: 'click', selector: '#save' } +}; + +describe('browser protocol validation', () => { + it('accepts the canonical request and every action shape', () => { + expect(isBrowserRequest(base)).toBe(true); + const actions = [ + { action: 'open', url: 'https://example.com' }, { action: 'snapshot' }, + { action: 'fill', selector: '#q', value: 'tiny' }, { action: 'fill', selector: '#q', value: '' }, + { action: 'type', text: 'hi', selector: '#q' }, + { action: 'get_text' }, { action: 'get_title' }, { action: 'get_url' }, + { action: 'screenshot', full_page: true }, { action: 'wait', duration_ms: 20, selector: '#x' }, + { action: 'press', key: 'Enter' }, { action: 'hover', selector: '#x' }, + { action: 'scroll', x: 0, y: 2 }, { action: 'is_visible', selector: '#x' }, + { action: 'close' }, { action: 'find', query: 'Buy' } + ]; + for (const action of actions) expect(isBrowserAction(action), JSON.stringify(action)).toBe(true); + }); + + it('rejects wrong versions, unknown fields, invalid bounds, and malformed actions', () => { + expect(isBrowserRequest({ ...base, protocol_version: 2 })).toBe(false); + expect(isBrowserRequest({ ...base, extra: true })).toBe(false); + expect(isBrowserRequest({ ...base, timeout_ms: 0 })).toBe(false); + expect(isBrowserRequest({ ...base, timeout_ms: 60_001 })).toBe(false); + expect(isBrowserRequest({ ...base, action: { action: 'click' } })).toBe(false); + expect(isBrowserAction({ action: 'snapshot', surprise: true })).toBe(false); + expect(isBrowserAction({ action: 'unknown' })).toBe(false); + expect(isBrowserAction(null)).toBe(false); + }); + + it('validates control responses and run events separately', () => { + expect(isControlResponse({ protocol_version: 1, status: 'workflows', request_id: 'r', workflows: [] })).toBe(true); + expect(isControlResponse({ protocol_version: 1, status: 'ok', request_id: '', result: null })).toBe(false); + expect(isRunEvent({ event: 'step_started', protocol_version: 1, run_id: 'r', node_id: 'n', node_kind: 'tool_call' })).toBe(true); + expect(isRunEvent({ event: 'step_completed', protocol_version: 1, run_id: 'r', node_id: 'n', node_kind: 'tool_call', status: 'success', duration_ms: 12 })).toBe(true); + expect(isRunEvent({ event: 'step_completed', protocol_version: 1, run_id: 'r', node_id: 'n', node_kind: 'tool_call', status: 'success', duration_ms: 12, output: { secret: true } })).toBe(false); + expect(isRunEvent({ event: 'completed', protocol_version: 1, run_id: 'r', status: 'success' })).toBe(true); + expect(isRunEvent({ event: 'completed', protocol_version: 1, run_id: 'r', status: 'success', output: { secret: true } })).toBe(false); + expect(isRunEvent({ event: 'awaiting_approval', protocol_version: 1, run_id: 'r', pending_approvals: ['gate'] })).toBe(true); + expect(isRunEvent({ event: 'browser_action_started', protocol_version: 1, run_id: 'r', request_id: 'q', tab_id: 1, action: 'click' })).toBe(true); + expect(isRunEvent({ event: 'cancelled', protocol_version: 1, run_id: 'r', extra: 1 })).toBe(false); + }); + + it('builds the strict canonical shared-tab announcement', () => { + expect(tabSharedEvent({ id: 9, window_id: 2, url: 'https://example.com', title: 'Example' })).toEqual({ + event: 'tab_shared', protocol_version: 1, + tab: { id: 9, window_id: 2, url: 'https://example.com', title: 'Example' } + }); + }); + + it('accepts the same canonical repository fixture as Rust', () => { + const fixtureUrl = new URL('../../protocol/fixtures/browser-request.v1.json', import.meta.url); + const fixture: unknown = JSON.parse(readFileSync(fixtureUrl, 'utf8')); + expect(isBrowserRequest(fixture)).toBe(true); + const cancelUrl = new URL('../../protocol/fixtures/browser-cancel.v1.json', import.meta.url); + expect(isBrowserCancel(JSON.parse(readFileSync(cancelUrl, 'utf8')))).toBe(true); + }); +}); diff --git a/extension/tests/relay.test.ts b/extension/tests/relay.test.ts new file mode 100644 index 0000000..55d9ecc --- /dev/null +++ b/extension/tests/relay.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RelayClient } from '../src/relay'; +import type { BrowserRequest, BrowserResponse } from '../src/protocol'; + +class FakeSocket { + readyState = 0; sent: string[] = []; protocols: string[]; + onopen: ((event?: unknown) => void) | null = null; onclose: (() => void) | null = null; + onerror: (() => void) | null = null; onmessage: ((event: {data:string}) => void) | null = null; + constructor(public url: string, protocols: string[]) { this.protocols = protocols; } + send(value: string) { this.sent.push(value); } + close() { this.readyState = 3; } + open() { this.readyState = 1; this.onopen?.(); } + message(value: unknown) { this.onmessage?.({ data: JSON.stringify(value) }); } +} +const TOKEN = '0123456789abcdef0123456789abcdef'; +function setup(config: unknown = { url: 'ws://127.0.0.1:32189/v1/extension', pairingToken: TOKEN }) { + const sockets: FakeSocket[] = []; const states: string[] = []; const events: unknown[] = []; + let stored: Record = { 'tinyflows.relayConfig.v1': config }; + const storage = { local: { get: vi.fn(async () => stored), set: vi.fn(async (value) => { stored = value; }) } }; + const browser = vi.fn(async (request: BrowserRequest): Promise => + ({ status: 'ok', protocol_version: 1, request_id: request.request_id, result: { data: 'ok' } })); + const relay = new RelayClient(browser, (state) => states.push(state), (event) => events.push(event), storage as any, + (url, protocols) => { const socket = new FakeSocket(url, protocols); sockets.push(socket); return socket as any; }); + return { relay, sockets, states, events }; +} + +describe('authenticated relay', () => { + it('puts the secret in a websocket subprotocol and handles browser requests', async () => { + const { relay, sockets, states } = setup(); await relay.start(); + expect(sockets[0]?.url).not.toContain(TOKEN); + expect(sockets[0]?.protocols).toContain(`tinyflows.auth.${TOKEN}`); + sockets[0]?.open(); expect(states).toContain('connected'); + sockets[0]?.message({ protocol_version: 1, request_id: 'r', run_id: 'x', tab_id: 1, timeout_ms: 1000, action: { action: 'get_title' } }); + await vi.waitFor(() => expect(sockets[0]?.sent.some((item) => JSON.parse(item).status === 'ok')).toBe(true)); + relay.send({ event: 'tab_shared', protocol_version: 1, tab: { id: 1, window_id: 2, url: 'https://example.com', title: 'Example' } }); + expect(JSON.parse(sockets[0]!.sent.at(-1)!)).toMatchObject({ event: 'tab_shared', tab: { id: 1 } }); + relay.stop(); + }); + + it('correlates control replies and forwards run events', async () => { + const { relay, sockets, events } = setup(); await relay.start(); sockets[0]?.open(); + const pending = relay.request('workflow.list', {}); + const sent = JSON.parse(sockets[0]!.sent.at(-1)!); + expect(sent).toEqual({ protocol_version: 1, request_id: sent.request_id, method: 'workflow.list' }); + sockets[0]?.message({ protocol_version: 1, status: 'workflows', request_id: sent.request_id, workflows: [{ id: 'one', name: 'One' }] }); + await expect(pending).resolves.toEqual([{ id: 'one', name: 'One' }]); + sockets[0]?.message({ event: 'step_started', protocol_version: 1, run_id: 'run', node_id: 'one', node_kind: 'browser' }); + expect(events).toHaveLength(1); relay.stop(); + }); + + it('forwards correlated browser cancellation', async () => { + const { relay, sockets } = setup(); + const cancelled: string[] = []; + relay.setBrowserCancelHandler((requestId) => cancelled.push(requestId)); + await relay.start(); sockets[0]?.open(); + sockets[0]?.message({ protocol_version: 1, type: 'browser.cancel', request_id: 'run:7' }); + expect(cancelled).toEqual(['run:7']); relay.stop(); + }); + + it('rejects non-loopback config and stays unpaired without config', async () => { + const empty = setup(null); await empty.relay.start(); expect(empty.states).toContain('unpaired'); + const configured = setup(); + await expect(configured.relay.configure({ url: 'wss://evil.example/ws', pairingToken: TOKEN })).rejects.toThrow(/loopback/); + await expect(configured.relay.configure({ url: 'ws://127.0.0.1:32189/ws', pairingToken: `${TOKEN.slice(0, 30)}-_` })).resolves.toBeUndefined(); + configured.relay.stop(); + }); + + it('ignores close and error events from a replaced socket', async () => { + const { relay, sockets, states } = setup(); + await relay.start(); sockets[0]?.open(); + await relay.configure({ url: 'ws://127.0.0.1:32190/v1/extension', pairingToken: TOKEN }); + sockets[1]?.open(); + sockets[0]?.onerror?.(); sockets[0]?.onclose?.(); + expect(states.at(-1)).toBe('connected'); + expect(sockets).toHaveLength(2); + relay.stop(); + }); +}); diff --git a/extension/tests/tab-manager.test.ts b/extension/tests/tab-manager.test.ts new file mode 100644 index 0000000..c514110 --- /dev/null +++ b/extension/tests/tab-manager.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GROUP_TITLE, TabManager } from '../src/tab-manager'; + +function api(tabOverrides: Record = {}) { + let stored: Record = {}; + const tab = { id: 5, windowId: 1, groupId: -1, url: 'https://example.com', title: 'Example', ...tabOverrides }; + const mock = { + tabs: { + get: vi.fn(async () => tab), group: vi.fn(async (options: {groupId?:number}) => { tab.groupId = options.groupId ?? 9; return tab.groupId; }), + ungroup: vi.fn(async () => { tab.groupId = -1; }) + }, + tabGroups: { + query: vi.fn(async () => []), update: vi.fn(async () => ({ id: 9 })), + get: vi.fn(async () => ({ id: 9, title: GROUP_TITLE })) + }, + debugger: { attach: vi.fn(async () => undefined), detach: vi.fn(async () => undefined), getTargets: vi.fn(async () => []) }, + storage: { local: { get: vi.fn(async () => stored), set: vi.fn(async (value) => { stored = value; }) } }, + action: { setBadgeText: vi.fn(async () => undefined), setBadgeBackgroundColor: vi.fn(async () => undefined) } + }; + return { mock, tab, setStored: (value: Record) => { stored = value; } }; +} + +describe('explicit tab sharing', () => { + it('groups and attaches a tab, then revokes it explicitly', async () => { + const { mock, tab } = api(); const manager = new TabManager(mock as any); + await expect(manager.share(5)).resolves.toMatchObject({ tabId: 5, groupId: 9 }); + expect(mock.tabGroups.update).toHaveBeenCalledWith(9, expect.objectContaining({ title: GROUP_TITLE })); + expect(mock.debugger.attach).toHaveBeenCalledWith({ tabId: 5 }, '1.3'); + expect(manager.has(5)).toBe(true); + await manager.revoke(5); + expect(tab.groupId).toBe(-1); expect(manager.has(5)).toBe(false); + }); + + it('fails closed for unshared, regrouped, and restricted tabs', async () => { + const fixture = api(); const manager = new TabManager(fixture.mock as any); + await expect(manager.assertShared(5)).rejects.toMatchObject({ code: 'tab_not_shared' }); + await manager.share(5); fixture.tab.groupId = 22; + await expect(manager.assertShared(5)).rejects.toMatchObject({ code: 'tab_revoked' }); + const restricted = api({ url: 'chrome://settings' }); + await expect(new TabManager(restricted.mock as any).share(5)).rejects.toMatchObject({ code: 'unsupported_page' }); + }); + + it('does not share a tab owned by another debugger', async () => { + const fixture = api(); + fixture.mock.debugger.attach.mockRejectedValue(new Error('Another debugger is already attached')); + const manager = new TabManager(fixture.mock as any); + await expect(manager.share(5)).rejects.toThrow(/already attached/); + expect(manager.has(5)).toBe(false); + expect(fixture.mock.tabs.group).not.toHaveBeenCalled(); + }); + + it('rehydrates only valid saved tabs and restores debugger sessions', async () => { + const fixture = api({ groupId: 9 }); + fixture.setStored({ 'tinyflows.sharedTabs.v1': [{ tabId: 5, groupId: 9, windowId: 1, attachedAt: 1 }, { bad: true }] }); + const manager = new TabManager(fixture.mock as any); + await expect(manager.rehydrate()).resolves.toHaveLength(1); + expect(fixture.mock.debugger.attach).toHaveBeenCalledWith({ tabId: 5 }, '1.3'); + }); + + it('reuses the named group, toggles sharing, and updates all badges', async () => { + const fixture = api(); fixture.mock.tabGroups.query.mockResolvedValue([{ id: 12 }] as any); + const manager = new TabManager(fixture.mock as any); + await expect(manager.toggle(5)).resolves.toBe(true); + expect(fixture.mock.tabs.group).toHaveBeenCalledWith({ groupId: 12, tabIds: [5] }); + await manager.markAll('reconnecting'); + await expect(manager.announcement(5)).resolves.toEqual({ id: 5, window_id: 1, url: 'https://example.com', title: 'Example' }); + expect(fixture.mock.tabs.get).toHaveBeenCalledTimes(2); + await expect(manager.toggle(5)).resolves.toBe(false); + }); + + it('revokes a saved tab when its group was renamed', async () => { + const fixture = api({ groupId: 9 }); fixture.setStored({ 'tinyflows.sharedTabs.v1': [{ tabId: 5, groupId: 9, windowId: 1, attachedAt: 1 }] }); + fixture.mock.tabGroups.get.mockResolvedValue({ id: 9, title: 'Other' } as any); + const manager = new TabManager(fixture.mock as any); await manager.rehydrate(); + expect(manager.list()).toEqual([]); + }); +}); diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..bb61407 --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["chrome", "node", "vitest/globals"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noEmit": true + }, + "include": ["src", "tests", "scripts"] +} diff --git a/extension/vitest.config.ts b/extension/vitest.config.ts new file mode 100644 index 0000000..007cef6 --- /dev/null +++ b/extension/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + exclude: ['tests/e2e/**', 'node_modules/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/background.ts', 'src/popup.ts', 'src/sidepanel.ts'], + thresholds: { lines: 90, statements: 90 } + } + } +}); diff --git a/protocol/browser-v1.schema.json b/protocol/browser-v1.schema.json new file mode 100644 index 0000000..6828eec --- /dev/null +++ b/protocol/browser-v1.schema.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tinyflows.dev/protocol/browser-v1.schema.json", + "title": "TinyFlows browser relay protocol v1", + "$defs": { + "action": { + "oneOf": [ + {"type":"object","additionalProperties":false,"required":["action","url"],"properties":{"action":{"const":"open"},"url":{"type":"string","pattern":"^https?://"}}}, + {"type":"object","additionalProperties":false,"required":["action"],"properties":{"action":{"enum":["snapshot","get_title","get_url","close"]}}}, + {"type":"object","additionalProperties":false,"required":["action","selector"],"properties":{"action":{"enum":["click","hover","is_visible"]},"selector":{"type":"string","minLength":1}}}, + {"type":"object","additionalProperties":false,"required":["action","selector","value"],"properties":{"action":{"const":"fill"},"selector":{"type":"string","minLength":1},"value":{"type":"string"}}}, + {"type":"object","additionalProperties":false,"required":["action","text"],"properties":{"action":{"const":"type"},"selector":{"type":"string"},"text":{"type":"string"}}}, + {"type":"object","additionalProperties":false,"required":["action"],"properties":{"action":{"const":"get_text"},"selector":{"type":"string"}}}, + {"type":"object","additionalProperties":false,"required":["action"],"properties":{"action":{"const":"screenshot"},"selector":{"type":"string"},"full_page":{"type":"boolean"}}}, + {"type":"object","additionalProperties":false,"required":["action"],"properties":{"action":{"const":"wait"},"duration_ms":{"type":"integer","minimum":0},"selector":{"type":"string"}}}, + {"type":"object","additionalProperties":false,"required":["action","key"],"properties":{"action":{"const":"press"},"key":{"type":"string","minLength":1}}}, + {"type":"object","additionalProperties":false,"required":["action"],"properties":{"action":{"const":"scroll"},"x":{"type":"integer"},"y":{"type":"integer"}}}, + {"type":"object","additionalProperties":false,"required":["action","query"],"properties":{"action":{"const":"find"},"query":{"type":"string","minLength":1}}} + ] + }, + "error": { + "type":"object", + "additionalProperties":false, + "required":["code","message"], + "properties":{ + "code":{"enum":["tab_not_shared","tab_revoked","relay_disconnected","unsupported_page","action_timeout","element_not_found","invalid_request","protocol_mismatch","cancelled","browser_failure"]}, + "message":{"type":"string"}, + "details":{} + } + } + }, + "oneOf": [ + { + "title":"BrowserRequest", + "type":"object", + "additionalProperties":false, + "required":["protocol_version","request_id","run_id","tab_id","timeout_ms","action"], + "properties":{ + "protocol_version":{"const":1}, + "request_id":{"type":"string","minLength":1}, + "run_id":{"type":"string","minLength":1}, + "tab_id":{"type":"integer","minimum":0}, + "timeout_ms":{"type":"integer","minimum":1,"maximum":60000}, + "action":{"$ref":"#/$defs/action"} + } + }, + { + "title":"BrowserResponseOk", + "type":"object", + "additionalProperties":false, + "required":["status","protocol_version","request_id","result"], + "properties":{"status":{"const":"ok"},"protocol_version":{"const":1},"request_id":{"type":"string","minLength":1},"result":{"type":"object","additionalProperties":false,"required":["data"],"properties":{"data":{}}}} + }, + { + "title":"BrowserResponseError", + "type":"object", + "additionalProperties":false, + "required":["status","protocol_version","request_id","error"], + "properties":{"status":{"const":"error"},"protocol_version":{"const":1},"request_id":{"type":"string","minLength":1},"error":{"$ref":"#/$defs/error"}} + }, + { + "title":"BrowserCancel", + "type":"object", + "additionalProperties":false, + "required":["protocol_version","type","request_id"], + "properties":{"protocol_version":{"const":1},"type":{"const":"browser.cancel"},"request_id":{"type":"string","minLength":1}} + }, + { + "title":"TabSharedEvent", + "type":"object", + "additionalProperties":false, + "required":["event","protocol_version","tab"], + "properties":{"event":{"const":"tab_shared"},"protocol_version":{"const":1},"tab":{"type":"object","additionalProperties":false,"required":["id","window_id","url","title"],"properties":{"id":{"type":"integer","minimum":0},"window_id":{"type":"integer","minimum":0},"url":{"type":"string","pattern":"^https?://"},"title":{"type":"string"}}}} + }, + { + "title":"BrowserActionStartedEvent", + "type":"object", + "additionalProperties":false, + "required":["event","protocol_version","request_id","run_id","tab_id"], + "properties":{"event":{"const":"action_started"},"protocol_version":{"const":1},"request_id":{"type":"string","minLength":1},"run_id":{"type":"string","minLength":1},"tab_id":{"type":"integer","minimum":0}} + }, + { + "title":"BrowserActionCompletedEvent", + "type":"object", + "additionalProperties":false, + "required":["event","protocol_version","request_id","result"], + "properties":{"event":{"const":"action_completed"},"protocol_version":{"const":1},"request_id":{"type":"string","minLength":1},"result":{"type":"object","additionalProperties":false,"required":["data"],"properties":{"data":{}}}} + }, + { + "title":"BrowserActionFailedEvent", + "type":"object", + "additionalProperties":false, + "required":["event","protocol_version","request_id","error"], + "properties":{"event":{"const":"action_failed"},"protocol_version":{"const":1},"request_id":{"type":"string","minLength":1},"error":{"$ref":"#/$defs/error"}} + }, + { + "title":"TabRevokedEvent", + "type":"object", + "additionalProperties":false, + "required":["event","protocol_version","tab_id"], + "properties":{"event":{"const":"tab_revoked"},"protocol_version":{"const":1},"tab_id":{"type":"integer","minimum":0}} + }, + { + "title":"RelayDisconnectedEvent", + "type":"object", + "additionalProperties":false, + "required":["event","protocol_version"], + "properties":{"event":{"const":"relay_disconnected"},"protocol_version":{"const":1}} + } + ] +} diff --git a/protocol/fixtures/browser-cancel.v1.json b/protocol/fixtures/browser-cancel.v1.json new file mode 100644 index 0000000..a9af648 --- /dev/null +++ b/protocol/fixtures/browser-cancel.v1.json @@ -0,0 +1,5 @@ +{ + "protocol_version": 1, + "type": "browser.cancel", + "request_id": "run-fixture:1" +} diff --git a/protocol/fixtures/browser-request.v1.json b/protocol/fixtures/browser-request.v1.json new file mode 100644 index 0000000..27e5e4a --- /dev/null +++ b/protocol/fixtures/browser-request.v1.json @@ -0,0 +1,12 @@ +{ + "protocol_version": 1, + "request_id": "run-fixture:1", + "run_id": "run-fixture", + "tab_id": 42, + "timeout_ms": 30000, + "action": { + "action": "fill", + "selector": "#email", + "value": "person@example.com" + } +} diff --git a/protocol/fixtures/browser-response.v1.json b/protocol/fixtures/browser-response.v1.json new file mode 100644 index 0000000..ae64d6b --- /dev/null +++ b/protocol/fixtures/browser-response.v1.json @@ -0,0 +1,10 @@ +{ + "status": "ok", + "protocol_version": 1, + "request_id": "run-fixture:1", + "result": { + "data": { + "filled": true + } + } +} diff --git a/protocol/fixtures/tab-shared.v1.json b/protocol/fixtures/tab-shared.v1.json new file mode 100644 index 0000000..6731a83 --- /dev/null +++ b/protocol/fixtures/tab-shared.v1.json @@ -0,0 +1,10 @@ +{ + "event": "tab_shared", + "protocol_version": 1, + "tab": { + "id": 42, + "window_id": 7, + "url": "https://example.test/login", + "title": "Fixture login" + } +} diff --git a/src/browser/mod.rs b/src/browser/mod.rs new file mode 100644 index 0000000..5ded7a4 --- /dev/null +++ b/src/browser/mod.rs @@ -0,0 +1,14 @@ +//! Browser automation protocol and host-side tool routing. +//! +//! This module is intentionally outside the workflow engine. Browser actions +//! remain ordinary `tool_call` nodes with the exact slug `"browser"`; hosts +//! opt into the Chrome companion by installing a [`RoutingToolInvoker`]. + +mod protocol; +mod routing; + +pub use protocol::{ + BROWSER_PROTOCOL_VERSION, BrowserAction, BrowserCancel, BrowserCancelType, BrowserError, + BrowserErrorCode, BrowserEvent, BrowserRequest, BrowserResponse, BrowserResult, +}; +pub use routing::{BrowserRelay, ChromeToolInvoker, RoutingToolInvoker}; diff --git a/src/browser/protocol.rs b/src/browser/protocol.rs new file mode 100644 index 0000000..c5655b3 --- /dev/null +++ b/src/browser/protocol.rs @@ -0,0 +1,574 @@ +//! Versioned messages exchanged with the TinyFlows Chrome extension. + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +/// The first supported browser relay protocol version. +pub const BROWSER_PROTOCOL_VERSION: u32 = 1; + +/// One browser operation sent to an explicitly shared Chrome tab. +/// +/// The enum is internally tagged, so workflow arguments use the direct form +/// `{ "action": "click", "selector": "button" }`. Unknown actions and +/// unknown fields are rejected instead of being silently ignored. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserAction { + /// Navigate the shared tab to `url`. + Open { + /// The HTTP(S) URL to open. + url: String, + }, + /// Capture a structured accessibility/DOM snapshot. + Snapshot, + /// Click the element matching `selector`. + Click { + /// A CSS selector in the current document. + selector: String, + }, + /// Replace an input's current value. + Fill { + /// A CSS selector for the input. + selector: String, + /// The complete replacement value. + value: String, + }, + /// Type text, optionally into a selected element. + Type { + /// A CSS selector for the target, or the focused element when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + selector: Option, + /// Text to enter as keyboard input. + text: String, + }, + /// Read visible text from an element or the whole document. + GetText { + /// A CSS selector, or the document when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + selector: Option, + }, + /// Read the current document title. + GetTitle, + /// Read the current document URL. + GetUrl, + /// Capture a PNG screenshot. + Screenshot { + /// A CSS selector to capture, or the viewport when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + selector: Option, + /// Capture the full document rather than only the viewport. + #[serde(default)] + full_page: bool, + }, + /// Wait for a duration and/or for an element to appear. + Wait { + /// Delay in milliseconds before completing. + #[serde(default, skip_serializing_if = "Option::is_none")] + duration_ms: Option, + /// A CSS selector that must become present. + #[serde(default, skip_serializing_if = "Option::is_none")] + selector: Option, + }, + /// Send a Chrome DevTools key name such as `Enter`. + Press { + /// The key to press. + key: String, + }, + /// Move the pointer over an element. + Hover { + /// A CSS selector for the target. + selector: String, + }, + /// Scroll the document by the supplied pixel deltas. + Scroll { + /// Horizontal delta in CSS pixels. + #[serde(default)] + x: i64, + /// Vertical delta in CSS pixels. + #[serde(default)] + y: i64, + }, + /// Test whether an element is currently visible. + IsVisible { + /// A CSS selector for the target. + selector: String, + }, + /// Close the explicitly shared tab. + Close, + /// Semantically locate an element by human-readable text or role. + Find { + /// Human-readable description to locate. + query: String, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +enum BrowserActionUnchecked { + Open { + url: String, + }, + Snapshot, + Click { + selector: String, + }, + Fill { + selector: String, + value: String, + }, + Type { + #[serde(default)] + selector: Option, + text: String, + }, + GetText { + #[serde(default)] + selector: Option, + }, + GetTitle, + GetUrl, + Screenshot { + #[serde(default)] + selector: Option, + #[serde(default)] + full_page: bool, + }, + Wait { + #[serde(default)] + duration_ms: Option, + #[serde(default)] + selector: Option, + }, + Press { + key: String, + }, + Hover { + selector: String, + }, + Scroll { + #[serde(default)] + x: i64, + #[serde(default)] + y: i64, + }, + IsVisible { + selector: String, + }, + Close, + Find { + query: String, + }, +} + +impl<'de> Deserialize<'de> for BrowserAction { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let object = value + .as_object() + .ok_or_else(|| serde::de::Error::custom("browser action must be an object"))?; + let action = object.get("action").and_then(Value::as_str); + if matches!(action, Some("snapshot" | "get_title" | "get_url" | "close")) + && object.len() != 1 + { + return Err(serde::de::Error::custom( + "unit browser actions accept only the `action` field", + )); + } + serde_json::from_value::(value) + .map(Into::into) + .map_err(serde::de::Error::custom) + } +} + +impl From for BrowserAction { + fn from(value: BrowserActionUnchecked) -> Self { + match value { + BrowserActionUnchecked::Open { url } => Self::Open { url }, + BrowserActionUnchecked::Snapshot => Self::Snapshot, + BrowserActionUnchecked::Click { selector } => Self::Click { selector }, + BrowserActionUnchecked::Fill { selector, value } => Self::Fill { selector, value }, + BrowserActionUnchecked::Type { selector, text } => Self::Type { selector, text }, + BrowserActionUnchecked::GetText { selector } => Self::GetText { selector }, + BrowserActionUnchecked::GetTitle => Self::GetTitle, + BrowserActionUnchecked::GetUrl => Self::GetUrl, + BrowserActionUnchecked::Screenshot { + selector, + full_page, + } => Self::Screenshot { + selector, + full_page, + }, + BrowserActionUnchecked::Wait { + duration_ms, + selector, + } => Self::Wait { + duration_ms, + selector, + }, + BrowserActionUnchecked::Press { key } => Self::Press { key }, + BrowserActionUnchecked::Hover { selector } => Self::Hover { selector }, + BrowserActionUnchecked::Scroll { x, y } => Self::Scroll { x, y }, + BrowserActionUnchecked::IsVisible { selector } => Self::IsVisible { selector }, + BrowserActionUnchecked::Close => Self::Close, + BrowserActionUnchecked::Find { query } => Self::Find { query }, + } + } +} + +/// A correlated command sent from the native companion to Chrome. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserRequest { + /// Protocol version used to encode this message. + pub protocol_version: u32, + /// Unique correlation id for this action. + pub request_id: String, + /// Workflow run that owns this action. + pub run_id: String, + /// Explicitly shared Chrome tab targeted by this action. + pub tab_id: u64, + /// Maximum time the relay may spend on the action. + pub timeout_ms: u64, + /// Browser operation to execute. + pub action: BrowserAction, +} + +/// A native cancellation instruction for one in-flight browser action. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserCancel { + /// Protocol version used to encode this message. + pub protocol_version: u32, + /// Fixed message discriminator. + #[serde(rename = "type")] + pub message_type: BrowserCancelType, + /// Correlation id of the action that must stop. + pub request_id: String, +} + +/// Wire discriminator for [`BrowserCancel`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BrowserCancelType { + /// Cancel one correlated browser action. + #[serde(rename = "browser.cancel")] + BrowserCancel, +} + +/// Structured successful action data returned by Chrome. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserResult { + /// Action-specific JSON output. + pub data: Value, +} + +/// Stable, machine-readable browser failure categories. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserErrorCode { + /// The requested tab was never explicitly shared. + TabNotShared, + /// The user revoked access while the run was active. + TabRevoked, + /// The companion lost its authenticated extension connection. + RelayDisconnected, + /// Chrome forbids debugger access to the requested page. + UnsupportedPage, + /// The action exceeded its bounded deadline. + ActionTimeout, + /// No matching element was found. + ElementNotFound, + /// The request did not match the versioned protocol schema. + InvalidRequest, + /// The peers do not support a common protocol version. + ProtocolMismatch, + /// The owning workflow cancelled the action. + Cancelled, + /// Chrome or the debugger reported an otherwise unclassified failure. + BrowserFailure, +} + +impl BrowserErrorCode { + /// Returns the stable wire code used in engine error messages and events. + pub const fn as_str(self) -> &'static str { + match self { + Self::TabNotShared => "tab_not_shared", + Self::TabRevoked => "tab_revoked", + Self::RelayDisconnected => "relay_disconnected", + Self::UnsupportedPage => "unsupported_page", + Self::ActionTimeout => "action_timeout", + Self::ElementNotFound => "element_not_found", + Self::InvalidRequest => "invalid_request", + Self::ProtocolMismatch => "protocol_mismatch", + Self::Cancelled => "cancelled", + Self::BrowserFailure => "browser_failure", + } + } +} + +/// Structured browser action failure. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrowserError { + /// Stable failure category. + pub code: BrowserErrorCode, + /// Human-readable, non-secret diagnostic. + pub message: String, + /// Optional action-specific diagnostic fields. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// The outcome of a correlated browser command. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserResponse { + /// The action completed successfully. + Ok { + /// Protocol version used to encode this message. + protocol_version: u32, + /// Correlation id copied from the request. + request_id: String, + /// Structured action output. + result: BrowserResult, + }, + /// The action failed with a stable error. + Error { + /// Protocol version used to encode this message. + protocol_version: u32, + /// Correlation id copied from the request. + request_id: String, + /// Structured action failure. + error: BrowserError, + }, +} + +impl BrowserResponse { + /// Returns the response protocol version. + pub const fn protocol_version(&self) -> u32 { + match self { + Self::Ok { + protocol_version, .. + } + | Self::Error { + protocol_version, .. + } => *protocol_version, + } + } + + /// Returns the request correlation id. + pub fn request_id(&self) -> &str { + match self { + Self::Ok { request_id, .. } | Self::Error { request_id, .. } => request_id, + } + } +} + +/// Progress and lifecycle events emitted by the browser relay. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserEvent { + /// Chrome accepted an action for execution. + ActionStarted { + /// Protocol version used to encode this message. + protocol_version: u32, + /// Correlated request id. + request_id: String, + /// Owning workflow run. + run_id: String, + /// Target Chrome tab. + tab_id: u64, + }, + /// Chrome completed an action. + ActionCompleted { + /// Protocol version used to encode this message. + protocol_version: u32, + /// Correlated request id. + request_id: String, + /// Structured action output. + result: BrowserResult, + }, + /// Chrome failed an action. + ActionFailed { + /// Protocol version used to encode this message. + protocol_version: u32, + /// Correlated request id. + request_id: String, + /// Structured action failure. + error: BrowserError, + }, + /// The user revoked a tab while it was shared. + TabRevoked { + /// Protocol version used to encode this message. + protocol_version: u32, + /// Revoked Chrome tab. + tab_id: u64, + }, + /// The authenticated relay connection closed. + RelayDisconnected { + /// Protocol version used to encode this message. + protocol_version: u32, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn every_action_has_the_stable_snake_case_wire_name() { + let cases = [ + (json!({"action":"open","url":"https://example.com"}), "open"), + (json!({"action":"snapshot"}), "snapshot"), + (json!({"action":"click","selector":"#go"}), "click"), + ( + json!({"action":"fill","selector":"#q","value":"hi"}), + "fill", + ), + (json!({"action":"type","text":"hi"}), "type"), + (json!({"action":"get_text"}), "get_text"), + (json!({"action":"get_title"}), "get_title"), + (json!({"action":"get_url"}), "get_url"), + (json!({"action":"screenshot"}), "screenshot"), + (json!({"action":"wait","duration_ms":1}), "wait"), + (json!({"action":"press","key":"Enter"}), "press"), + (json!({"action":"hover","selector":"a"}), "hover"), + (json!({"action":"scroll","y":100}), "scroll"), + ( + json!({"action":"is_visible","selector":"main"}), + "is_visible", + ), + (json!({"action":"close"}), "close"), + (json!({"action":"find","query":"checkout"}), "find"), + ]; + + for (wire, name) in cases { + let action: BrowserAction = serde_json::from_value(wire).expect(name); + assert_eq!(serde_json::to_value(action).unwrap()["action"], name); + } + } + + #[test] + fn action_schema_rejects_missing_action_unknown_actions_and_extra_fields() { + assert!(serde_json::from_value::(json!({"selector":"x"})).is_err()); + assert!(serde_json::from_value::(json!({"action":"tap"})).is_err()); + assert!( + serde_json::from_value::( + json!({"action":"click","selector":"x","secret":"nope"}) + ) + .is_err() + ); + for action in ["snapshot", "get_title", "get_url", "close"] { + assert!( + serde_json::from_value::(json!({"action":action,"extra":true})) + .is_err(), + "{action} must reject unknown fields" + ); + } + } + + #[test] + fn request_schema_is_versioned_and_strict() { + let request = BrowserRequest { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id: "run-1:1".into(), + run_id: "run-1".into(), + tab_id: 42, + timeout_ms: 30_000, + action: BrowserAction::GetTitle, + }; + let mut wire = serde_json::to_value(&request).unwrap(); + assert_eq!( + serde_json::from_value::(wire.clone()).unwrap(), + request + ); + wire["unexpected"] = json!(true); + assert!(serde_json::from_value::(wire).is_err()); + } + + #[test] + fn error_codes_are_stable_and_reject_unknown_values() { + for (code, wire) in [ + (BrowserErrorCode::TabNotShared, "tab_not_shared"), + (BrowserErrorCode::TabRevoked, "tab_revoked"), + (BrowserErrorCode::RelayDisconnected, "relay_disconnected"), + (BrowserErrorCode::UnsupportedPage, "unsupported_page"), + (BrowserErrorCode::ActionTimeout, "action_timeout"), + (BrowserErrorCode::ElementNotFound, "element_not_found"), + ] { + assert_eq!(code.as_str(), wire); + assert_eq!(serde_json::to_value(code).unwrap(), wire); + } + assert!(serde_json::from_str::("\"mystery\"").is_err()); + } + + #[test] + fn responses_are_correlated_and_strict() { + let response = BrowserResponse::Ok { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id: "r:7".into(), + result: BrowserResult { + data: json!({"title":"TinyFlows"}), + }, + }; + assert_eq!(response.protocol_version(), 1); + assert_eq!(response.request_id(), "r:7"); + let wire = serde_json::to_value(response).unwrap(); + assert_eq!(wire["status"], "ok"); + assert!( + serde_json::from_value::(json!({ + "status":"ok", "protocol_version":1, "request_id":"r:7", + "result":{"data":null}, "extra":true + })) + .is_err() + ); + } + + #[test] + fn canonical_repository_fixtures_decode_as_rust_contracts() { + let request: BrowserRequest = serde_json::from_str(include_str!( + "../../protocol/fixtures/browser-request.v1.json" + )) + .unwrap(); + let response: BrowserResponse = serde_json::from_str(include_str!( + "../../protocol/fixtures/browser-response.v1.json" + )) + .unwrap(); + let cancel: BrowserCancel = serde_json::from_str(include_str!( + "../../protocol/fixtures/browser-cancel.v1.json" + )) + .unwrap(); + assert_eq!(request.request_id, response.request_id()); + assert_eq!(request.request_id, cancel.request_id); + assert_eq!( + request.action, + BrowserAction::Fill { + selector: "#email".into(), + value: "person@example.com".into(), + } + ); + } + + #[test] + fn cancellation_message_is_strict_and_versioned() { + let cancel = BrowserCancel { + protocol_version: BROWSER_PROTOCOL_VERSION, + message_type: BrowserCancelType::BrowserCancel, + request_id: "run:1".into(), + }; + assert_eq!( + serde_json::to_value(&cancel).unwrap()["type"], + "browser.cancel" + ); + assert!( + serde_json::from_value::(json!({ + "protocol_version":1,"type":"browser.cancel","request_id":"r","extra":true + })) + .is_err() + ); + } +} diff --git a/src/browser/routing.rs b/src/browser/routing.rs new file mode 100644 index 0000000..19a7f15 --- /dev/null +++ b/src/browser/routing.rs @@ -0,0 +1,409 @@ +//! Browser relay adapter and deterministic tool routing. + +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use async_trait::async_trait; +use serde_json::Value; + +use super::protocol::{ + BROWSER_PROTOCOL_VERSION, BrowserAction, BrowserError, BrowserErrorCode, BrowserRequest, + BrowserResponse, +}; +use crate::caps::ToolInvoker; +use crate::error::{EngineError, Result}; + +/// Default deadline attached to a browser action when the host does not override it. +pub const DEFAULT_BROWSER_ACTION_TIMEOUT_MS: u64 = 30_000; +/// Largest deadline accepted by both sides of protocol v1. +pub const MAX_BROWSER_ACTION_TIMEOUT_MS: u64 = 60_000; + +/// Authenticated transport from the native companion to the Chrome extension. +/// +/// Implementations own WebSocket authentication, connection health, and tab +/// registration. Returning an error fails the workflow step closed; TinyFlows' +/// ordinary node retry and error-port policies then decide what happens next. +#[async_trait] +pub trait BrowserRelay: Send + Sync { + /// Sends one correlated request and waits for its terminal response. + async fn execute( + &self, + request: BrowserRequest, + ) -> std::result::Result; +} + +/// A [`ToolInvoker`] that turns `browser` tool calls into Chrome relay requests. +/// +/// Each instance is bound to exactly one workflow run and one explicitly shared +/// tab. There is no fallback tab selection. Construct a fresh instance when a +/// side panel or CLI run selects its owning tab. +pub struct ChromeToolInvoker { + relay: Arc, + run_id: String, + tab_id: u64, + timeout_ms: u64, + sequence: AtomicU64, +} + +impl ChromeToolInvoker { + /// Creates an invoker bound to `run_id` and the explicitly shared `tab_id`. + pub fn new(relay: Arc, run_id: impl Into, tab_id: u64) -> Self { + Self { + relay, + run_id: run_id.into(), + tab_id, + timeout_ms: DEFAULT_BROWSER_ACTION_TIMEOUT_MS, + sequence: AtomicU64::new(0), + } + } + + /// Overrides the bounded per-action relay deadline. + pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self { + self.timeout_ms = timeout_ms; + self + } + + fn next_request_id(&self) -> String { + let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; + format!("{}:{sequence}", self.run_id) + } + + fn capability_error(error: &BrowserError) -> EngineError { + EngineError::Capability(format!( + "browser:{}: {}", + error.code.as_str(), + error.message + )) + } +} + +#[async_trait] +impl ToolInvoker for ChromeToolInvoker { + async fn invoke(&self, slug: &str, args: Value, _conn: Option<&str>) -> Result { + if slug != "browser" { + return Err(Self::capability_error(&BrowserError { + code: BrowserErrorCode::InvalidRequest, + message: format!("ChromeToolInvoker only accepts slug `browser`, got `{slug}`"), + details: None, + })); + } + + let action: BrowserAction = serde_json::from_value(args).map_err(|error| { + Self::capability_error(&BrowserError { + code: BrowserErrorCode::InvalidRequest, + message: format!("invalid browser args: {error}"), + details: None, + }) + })?; + if !(1..=MAX_BROWSER_ACTION_TIMEOUT_MS).contains(&self.timeout_ms) { + return Err(Self::capability_error(&BrowserError { + code: BrowserErrorCode::InvalidRequest, + message: format!( + "browser timeout must be between 1 and {MAX_BROWSER_ACTION_TIMEOUT_MS} ms" + ), + details: None, + })); + } + validate_action(&action).map_err(|message| { + Self::capability_error(&BrowserError { + code: BrowserErrorCode::InvalidRequest, + message, + details: None, + }) + })?; + let request_id = self.next_request_id(); + let request = BrowserRequest { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id: request_id.clone(), + run_id: self.run_id.clone(), + tab_id: self.tab_id, + timeout_ms: self.timeout_ms, + action, + }; + + let response = self + .relay + .execute(request) + .await + .map_err(|error| Self::capability_error(&error))?; + + if response.protocol_version() != BROWSER_PROTOCOL_VERSION { + return Err(Self::capability_error(&BrowserError { + code: BrowserErrorCode::ProtocolMismatch, + message: format!( + "relay responded with protocol version {}, expected {}", + response.protocol_version(), + BROWSER_PROTOCOL_VERSION + ), + details: None, + })); + } + if response.request_id() != request_id { + return Err(Self::capability_error(&BrowserError { + code: BrowserErrorCode::InvalidRequest, + message: format!( + "relay response correlation mismatch: expected `{request_id}`, got `{}`", + response.request_id() + ), + details: None, + })); + } + + match response { + BrowserResponse::Ok { result, .. } => Ok(result.data), + BrowserResponse::Error { error, .. } => Err(Self::capability_error(&error)), + } + } +} + +fn validate_action(action: &BrowserAction) -> std::result::Result<(), String> { + let required = match action { + BrowserAction::Open { url } => { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err("browser open URL must use HTTP(S)".into()); + } + Some(("url", url.as_str())) + } + BrowserAction::Click { selector } + | BrowserAction::Hover { selector } + | BrowserAction::IsVisible { selector } => Some(("selector", selector.as_str())), + BrowserAction::Fill { selector, .. } => Some(("selector", selector.as_str())), + BrowserAction::Press { key } => Some(("key", key.as_str())), + BrowserAction::Find { query } => Some(("query", query.as_str())), + BrowserAction::Type { text, .. } => Some(("text", text.as_str())), + BrowserAction::Snapshot + | BrowserAction::GetText { .. } + | BrowserAction::GetTitle + | BrowserAction::GetUrl + | BrowserAction::Screenshot { .. } + | BrowserAction::Wait { .. } + | BrowserAction::Scroll { .. } + | BrowserAction::Close => None, + }; + if let Some((field, value)) = required + && value.is_empty() + { + return Err(format!("browser action field `{field}` must not be empty")); + } + Ok(()) +} + +/// Routes explicit browser calls to Chrome and delegates every other tool unchanged. +pub struct RoutingToolInvoker { + browser: Arc, + fallback: Arc, +} + +impl RoutingToolInvoker { + /// Creates a router around one run/tab-bound browser invoker and a host invoker. + pub fn new(browser: Arc, fallback: Arc) -> Self { + Self { browser, fallback } + } +} + +#[async_trait] +impl ToolInvoker for RoutingToolInvoker { + async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result { + if slug == "browser" { + self.browser.invoke(slug, args, conn).await + } else { + self.fallback.invoke(slug, args, conn).await + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use serde_json::json; + + use super::*; + use crate::error::EngineError; + + struct RecordingRelay { + requests: Mutex>, + response: Mutex>>, + } + + impl RecordingRelay { + fn success(data: Value) -> Arc { + Arc::new(Self { + requests: Mutex::new(Vec::new()), + response: Mutex::new(Some(Ok(BrowserResponse::Ok { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id: String::new(), + result: super::super::protocol::BrowserResult { data }, + }))), + }) + } + } + + #[async_trait] + impl BrowserRelay for RecordingRelay { + async fn execute( + &self, + request: BrowserRequest, + ) -> std::result::Result { + self.requests.lock().unwrap().push(request.clone()); + let response = self.response.lock().unwrap().take().unwrap(); + match response { + Ok(BrowserResponse::Ok { + protocol_version, + result, + .. + }) => Ok(BrowserResponse::Ok { + protocol_version, + request_id: request.request_id, + result, + }), + other => other, + } + } + } + + #[derive(Default)] + struct RecordingFallback { + calls: Mutex)>>, + } + + #[async_trait] + impl ToolInvoker for RecordingFallback { + async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result { + self.calls.lock().unwrap().push(( + slug.to_owned(), + args.clone(), + conn.map(str::to_owned), + )); + Ok(json!({"fallback": slug, "args": args})) + } + } + + #[tokio::test] + async fn chrome_invoker_requires_an_explicit_action() { + let relay = RecordingRelay::success(Value::Null); + let invoker = ChromeToolInvoker::new(relay, "run-1", 7); + let error = invoker + .invoke("browser", json!({"selector":"main"}), None) + .await + .expect_err("missing args.action must fail"); + assert!( + matches!(error, EngineError::Capability(message) if message.starts_with("browser:invalid_request:")) + ); + } + + #[tokio::test] + async fn chrome_invoker_rejects_invalid_semantics_before_relaying() { + let relay = RecordingRelay::success(Value::Null); + let invoker = ChromeToolInvoker::new(relay.clone(), "run-1", 7).with_timeout_ms(60_001); + let error = invoker + .invoke("browser", json!({"action":"click","selector":"#go"}), None) + .await + .expect_err("oversized timeout must fail"); + assert!(error.to_string().contains("timeout must be between")); + assert!(relay.requests.lock().unwrap().is_empty()); + + let relay = RecordingRelay::success(Value::Null); + let invoker = ChromeToolInvoker::new(relay.clone(), "run-1", 7); + let error = invoker + .invoke( + "browser", + json!({"action":"open","url":"chrome://settings"}), + None, + ) + .await + .expect_err("restricted URL must fail natively"); + assert!(error.to_string().contains("must use HTTP(S)")); + assert!(relay.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn chrome_invoker_binds_run_tab_timeout_and_correlation() { + let relay = RecordingRelay::success(json!({"title":"Example"})); + let invoker = ChromeToolInvoker::new(relay.clone(), "run-42", 91).with_timeout_ms(1234); + + let output = invoker + .invoke("browser", json!({"action":"get_title"}), None) + .await + .expect("browser action"); + assert_eq!(output, json!({"title":"Example"})); + let requests = relay.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].protocol_version, BROWSER_PROTOCOL_VERSION); + assert_eq!(requests[0].request_id, "run-42:1"); + assert_eq!(requests[0].run_id, "run-42"); + assert_eq!(requests[0].tab_id, 91); + assert_eq!(requests[0].timeout_ms, 1234); + assert_eq!(requests[0].action, BrowserAction::GetTitle); + } + + #[tokio::test] + async fn router_delegates_non_browser_slug_args_and_connection_unchanged() { + let relay = RecordingRelay::success(Value::Null); + let browser = Arc::new(ChromeToolInvoker::new(relay.clone(), "run", 1)); + let fallback = Arc::new(RecordingFallback::default()); + let router = RoutingToolInvoker::new(browser, fallback.clone()); + let args = json!({"to":"person@example.com","body":"hello"}); + + let output = router + .invoke("gmail.send", args.clone(), Some("acct-9")) + .await + .expect("fallback action"); + assert_eq!(output["fallback"], "gmail.send"); + assert!(relay.requests.lock().unwrap().is_empty()); + assert_eq!( + fallback.calls.lock().unwrap().as_slice(), + &[("gmail.send".into(), args, Some("acct-9".into()))] + ); + } + + #[tokio::test] + async fn stable_relay_error_code_reaches_engine_retry_surface() { + let relay = Arc::new(RecordingRelay { + requests: Mutex::new(Vec::new()), + response: Mutex::new(Some(Err(BrowserError { + code: BrowserErrorCode::TabRevoked, + message: "user removed tab from TinyFlows group".into(), + details: None, + }))), + }); + let invoker = ChromeToolInvoker::new(relay, "run", 1); + + let error = invoker + .invoke("browser", json!({"action":"snapshot"}), None) + .await + .expect_err("revoked tab must fail closed"); + assert!( + matches!(error, EngineError::Capability(message) if message.starts_with("browser:tab_revoked:")) + ); + } + + #[tokio::test] + async fn mismatched_response_correlation_fails_closed() { + struct WrongCorrelation; + #[async_trait] + impl BrowserRelay for WrongCorrelation { + async fn execute( + &self, + _request: BrowserRequest, + ) -> std::result::Result { + Ok(BrowserResponse::Ok { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id: "another-run:99".into(), + result: super::super::protocol::BrowserResult { data: Value::Null }, + }) + } + } + + let invoker = ChromeToolInvoker::new(Arc::new(WrongCorrelation), "run", 1); + let error = invoker + .invoke("browser", json!({"action":"get_url"}), None) + .await + .expect_err("cross-run response must fail"); + assert!( + matches!(error, EngineError::Capability(message) if message.starts_with("browser:invalid_request:")) + ); + } +} diff --git a/src/companion/auth.rs b/src/companion/auth.rs new file mode 100644 index 0000000..7f9d658 --- /dev/null +++ b/src/companion/auth.rs @@ -0,0 +1,392 @@ +//! Pairing-secret persistence and WebSocket upgrade authentication. + +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// The WebSocket subprotocol prefix carrying the relay protocol version. +pub const PROTOCOL_SUBPROTOCOL: &str = "tinyflows.v1"; + +/// The WebSocket subprotocol prefix carrying the pairing secret. +pub const AUTH_SUBPROTOCOL_PREFIX: &str = "tinyflows.auth."; + +/// Errors produced while authenticating an extension connection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthError { + /// The request did not originate from the paired extension. + OriginMismatch, + /// The client did not offer the supported relay protocol. + ProtocolMismatch, + /// The authentication subprotocol was absent or malformed. + MissingAuthentication, + /// The supplied authentication secret was invalid. + InvalidAuthentication, + /// More than one authentication subprotocol was supplied. + AmbiguousAuthentication, +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::OriginMismatch => "websocket origin does not match the paired extension", + Self::ProtocolMismatch => "no supported relay protocol was offered", + Self::MissingAuthentication => "authentication websocket subprotocol is missing", + Self::InvalidAuthentication => "authentication secret is invalid", + Self::AmbiguousAuthentication => "multiple authentication secrets were supplied", + }) + } +} + +impl std::error::Error for AuthError {} + +/// A secret used to pair and authenticate the extension. +/// +/// Its debug representation is intentionally redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct PairingSecret(String); + +impl PairingSecret { + /// Parses a pairing secret, rejecting values too small to resist guessing. + pub fn parse(value: impl Into) -> io::Result { + let value = value.into(); + if value.len() < 32 || !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "pairing secret must contain at least 32 ASCII alphanumeric characters", + )); + } + Ok(Self(value)) + } + + /// Generates a 256-bit secret from operating-system randomness. + pub fn generate() -> io::Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(io::Error::other)?; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to a String cannot fail"); + } + Ok(Self(encoded)) + } + + /// Returns the secret for placement in a WebSocket subprotocol value. + pub fn expose(&self) -> &str { + &self.0 + } + + fn matches(&self, candidate: &str) -> bool { + let expected = self.0.as_bytes(); + let candidate = candidate.as_bytes(); + let mut difference = expected.len() ^ candidate.len(); + let compared_len = expected.len().max(candidate.len()); + for index in 0..compared_len { + let left = expected.get(index).copied().unwrap_or_default(); + let right = candidate.get(index).copied().unwrap_or_default(); + difference |= usize::from(left ^ right); + } + difference == 0 + } +} + +impl std::fmt::Debug for PairingSecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("PairingSecret([REDACTED])") + } +} + +/// Owner-only persistent storage for the host-local pairing secret. +#[derive(Debug, Clone)] +pub struct SecretStore { + path: PathBuf, +} + +impl SecretStore { + /// Creates a store targeting `path`. + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// Returns the secret file path. + pub fn path(&self) -> &Path { + &self.path + } + + /// Loads the current secret, generating it atomically when absent. + pub fn load_or_create(&self) -> io::Result { + match self.load() { + Ok(secret) => Ok(secret), + Err(error) if error.kind() == io::ErrorKind::NotFound => self.rotate(), + Err(error) => Err(error), + } + } + + /// Loads the persisted secret and verifies owner-only permissions on Unix. + pub fn load(&self) -> io::Result { + let metadata = fs::metadata(&self.path)?; + verify_owner_only(&metadata)?; + let value = fs::read_to_string(&self.path)?; + PairingSecret::parse(value.trim().to_owned()) + } + + /// Replaces the current secret and returns the new value. + pub fn rotate(&self) -> io::Result { + let secret = PairingSecret::generate()?; + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + set_owner_only_directory(parent)?; + } + + let temporary = self.path.with_extension("tmp"); + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + set_owner_only_file_options(&mut options); + let mut file = options.open(&temporary)?; + file.write_all(secret.expose().as_bytes())?; + file.write_all(b"\n")?; + file.sync_all()?; + fs::rename(&temporary, &self.path)?; + set_owner_only_file(&self.path)?; + Ok(secret) + } +} + +/// Headers relevant to an incoming WebSocket upgrade. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebSocketHandshake<'a> { + /// Exact value of the HTTP `Origin` header. + pub origin: &'a str, + /// Values offered in `Sec-WebSocket-Protocol`, already split on commas. + pub subprotocols: &'a [&'a str], +} + +/// The result of successfully authenticating a WebSocket upgrade. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticatedSession { + /// Subprotocol the server must echo in the upgrade response. + pub negotiated_subprotocol: &'static str, +} + +/// Validates the exact extension origin and secret-bearing subprotocol. +#[derive(Debug, Clone)] +pub struct Authenticator { + expected_origin: String, + secret: PairingSecret, +} + +impl Authenticator { + /// Creates an authenticator for a paired Chrome extension id. + pub fn new(extension_id: &str, secret: PairingSecret) -> io::Result { + if extension_id.len() != 32 || !extension_id.bytes().all(|byte| matches!(byte, b'a'..=b'p')) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Chrome extension id must be 32 characters in the range a-p", + )); + } + Ok(Self { + expected_origin: format!("chrome-extension://{extension_id}"), + secret, + }) + } + + /// Authenticates one upgrade without accepting credentials from its URL. + pub fn authenticate( + &self, + handshake: &WebSocketHandshake<'_>, + ) -> Result { + if handshake.origin != self.expected_origin { + return Err(AuthError::OriginMismatch); + } + if !handshake + .subprotocols + .iter() + .any(|value| value.trim() == PROTOCOL_SUBPROTOCOL) + { + return Err(AuthError::ProtocolMismatch); + } + + let candidates = handshake + .subprotocols + .iter() + .map(|value| value.trim()) + .filter_map(|value| value.strip_prefix(AUTH_SUBPROTOCOL_PREFIX)) + .collect::>(); + let candidate = match candidates.as_slice() { + [] => return Err(AuthError::MissingAuthentication), + [candidate] => *candidate, + _ => return Err(AuthError::AmbiguousAuthentication), + }; + if !self.secret.matches(candidate) { + return Err(AuthError::InvalidAuthentication); + } + Ok(AuthenticatedSession { + negotiated_subprotocol: PROTOCOL_SUBPROTOCOL, + }) + } + + /// Returns the one origin accepted by this authenticator. + pub fn expected_origin(&self) -> &str { + &self.expected_origin + } +} + +#[cfg(unix)] +fn set_owner_only_file_options(options: &mut OpenOptions) { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); +} + +#[cfg(not(unix))] +fn set_owner_only_file_options(_options: &mut OpenOptions) {} + +#[cfg(unix)] +fn set_owner_only_file(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_owner_only_file(_path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_owner_only_directory(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) +} + +#[cfg(not(unix))] +fn set_owner_only_directory(_path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn verify_owner_only(metadata: &fs::Metadata) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "pairing secret must not be accessible by group or other users", + )); + } + Ok(()) +} + +#[cfg(not(unix))] +fn verify_owner_only(_metadata: &fs::Metadata) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + const EXTENSION_ID: &str = "abcdefghijklmnopabcdefghijklmnop"; + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + + fn authenticator() -> Authenticator { + Authenticator::new(EXTENSION_ID, PairingSecret::parse(SECRET).unwrap()).unwrap() + } + + #[test] + fn authenticates_only_exact_origin_protocol_and_secret() { + let protocols = [ + PROTOCOL_SUBPROTOCOL, + "tinyflows.auth.0123456789abcdef0123456789abcdef", + ]; + let result = authenticator().authenticate(&WebSocketHandshake { + origin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + subprotocols: &protocols, + }); + assert_eq!(result.unwrap().negotiated_subprotocol, PROTOCOL_SUBPROTOCOL); + } + + #[test] + fn rejects_lookalike_origin_and_url_style_auth() { + let protocols = [PROTOCOL_SUBPROTOCOL]; + assert_eq!( + authenticator() + .authenticate(&WebSocketHandshake { + origin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop.example", + subprotocols: &protocols, + }) + .unwrap_err(), + AuthError::OriginMismatch + ); + assert_eq!( + authenticator() + .authenticate(&WebSocketHandshake { + origin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + subprotocols: &protocols, + }) + .unwrap_err(), + AuthError::MissingAuthentication + ); + } + + #[test] + fn rejects_wrong_protocol_secret_and_ambiguous_credentials() { + let origin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; + let wrong_protocol = [ + "tinyflows.v2", + "tinyflows.auth.0123456789abcdef0123456789abcdef", + ]; + assert_eq!( + authenticator() + .authenticate(&WebSocketHandshake { + origin, + subprotocols: &wrong_protocol, + }) + .unwrap_err(), + AuthError::ProtocolMismatch + ); + + let wrong_secret = [ + PROTOCOL_SUBPROTOCOL, + "tinyflows.auth.ffffffffffffffffffffffffffffffff", + ]; + assert_eq!( + authenticator() + .authenticate(&WebSocketHandshake { + origin, + subprotocols: &wrong_secret, + }) + .unwrap_err(), + AuthError::InvalidAuthentication + ); + + let ambiguous = [ + PROTOCOL_SUBPROTOCOL, + "tinyflows.auth.0123456789abcdef0123456789abcdef", + "tinyflows.auth.ffffffffffffffffffffffffffffffff", + ]; + assert_eq!( + authenticator() + .authenticate(&WebSocketHandshake { + origin, + subprotocols: &ambiguous, + }) + .unwrap_err(), + AuthError::AmbiguousAuthentication + ); + } + + #[test] + fn secret_store_round_trips_and_rotates() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!("tinyflows-secret-{unique}")); + let store = SecretStore::new(directory.join("pairing-secret")); + let first = store.load_or_create().unwrap(); + assert_eq!(store.load().unwrap(), first); + let second = store.rotate().unwrap(); + assert_ne!(first, second); + assert_eq!(store.load().unwrap(), second); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src/companion/control.rs b/src/companion/control.rs new file mode 100644 index 0000000..647624e --- /dev/null +++ b/src/companion/control.rs @@ -0,0 +1,333 @@ +//! Strictly versioned native companion control messages. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{RunId, SharedTab, TabId}; + +/// Metadata for a workflow exposed from the companion's configured directory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkflowSummary { + /// Stable host-local workflow id. + pub id: String, + /// Human-readable workflow name. + pub name: String, +} + +/// A native workflow run event sent to side-panel subscribers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)] +pub enum RunEvent { + /// The native host accepted a run. + Started { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Explicit tab selected when the run started. + tab_id: TabId, + }, + /// A workflow node began executing. + StepStarted { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Workflow node id. + node_id: String, + /// Node kind shown by the side panel. + node_kind: String, + }, + /// A workflow node completed. + StepCompleted { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Workflow node id. + node_id: String, + /// Node kind shown by the side panel. + node_kind: String, + /// Stable execution status without host-owned output data. + status: String, + /// Wall-clock execution duration. + duration_ms: u64, + }, + /// A workflow paused at one or more native approval gates. + AwaitingApproval { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Gate node ids awaiting a host-owned approval decision. + pending_approvals: Vec, + }, + /// Chrome began a browser action for this run. + BrowserActionStarted { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Browser request correlation id. + request_id: String, + /// Explicit target tab. + tab_id: TabId, + /// Stable browser action name. + action: String, + }, + /// Chrome completed a browser action for this run. + BrowserActionCompleted { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Browser request correlation id. + request_id: String, + /// Structured browser result. + output: Value, + }, + /// Chrome failed a browser action for this run. + BrowserActionFailed { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Browser request correlation id. + request_id: String, + /// Stable browser failure code. + code: String, + /// Actionable non-secret diagnostic. + message: String, + }, + /// A workflow run reached a terminal success state. + Completed { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Stable terminal status without host-owned workflow output. + status: String, + }, + /// A workflow run reached a terminal failure state. + Failed { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + /// Stable machine-readable failure code. + code: String, + /// Human-readable non-secret diagnostic. + message: String, + }, + /// The native host cancelled the run. + Cancelled { + /// Control protocol version. + protocol_version: u32, + /// Run receiving this event. + run_id: RunId, + }, +} + +/// Requests accepted by the authenticated companion control socket. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)] +pub enum CompanionControlRequest { + /// List workflows exposed from the configured host directory. + #[serde(rename = "workflow.list")] + WorkflowList { + /// Control protocol version. + protocol_version: u32, + /// Correlation id chosen by the caller. + request_id: String, + }, + /// Start a native workflow bound to an explicitly shared tab. + #[serde(rename = "workflow.start")] + WorkflowStart { + /// Control protocol version. + protocol_version: u32, + /// Correlation id chosen by the caller. + request_id: String, + /// Host-local workflow id. + workflow_id: String, + /// Explicit shared tab owned by the initiating side panel or CLI. + tab_id: TabId, + /// Structured initial workflow input. + #[serde(default)] + input: Value, + }, + /// Cancel a native workflow run. + #[serde(rename = "workflow.cancel")] + WorkflowCancel { + /// Control protocol version. + protocol_version: u32, + /// Correlation id chosen by the caller. + request_id: String, + /// Run to cancel. + run_id: RunId, + }, + /// Subscribe this authenticated connection to native run events. + #[serde(rename = "run.subscribe")] + RunSubscribe { + /// Control protocol version. + protocol_version: u32, + /// Correlation id chosen by the caller. + request_id: String, + /// Run whose events should be delivered. + run_id: RunId, + }, + /// List only tabs the user explicitly shared. + #[serde(rename = "tab.list")] + TabList { + /// Control protocol version. + protocol_version: u32, + /// Correlation id chosen by the caller. + request_id: String, + }, + /// Query paired relay connection status. + #[serde(rename = "connection.status")] + ConnectionStatus { + /// Control protocol version. + protocol_version: u32, + /// Correlation id chosen by the caller. + request_id: String, + }, +} + +impl CompanionControlRequest { + /// Returns the declared protocol version. + pub const fn protocol_version(&self) -> u32 { + match self { + Self::WorkflowList { + protocol_version, .. + } + | Self::WorkflowStart { + protocol_version, .. + } + | Self::WorkflowCancel { + protocol_version, .. + } + | Self::RunSubscribe { + protocol_version, .. + } + | Self::TabList { + protocol_version, .. + } + | Self::ConnectionStatus { + protocol_version, .. + } => *protocol_version, + } + } + + /// Returns the request correlation id. + pub fn request_id(&self) -> &str { + match self { + Self::WorkflowList { request_id, .. } + | Self::WorkflowStart { request_id, .. } + | Self::WorkflowCancel { request_id, .. } + | Self::RunSubscribe { request_id, .. } + | Self::TabList { request_id, .. } + | Self::ConnectionStatus { request_id, .. } => request_id, + } + } +} + +/// Correlated responses returned by the native companion control surface. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum CompanionControlResponse { + /// Request completed successfully. + Ok { + /// Control protocol version. + protocol_version: u32, + /// Correlation id copied from the request. + request_id: String, + /// Method-specific structured result. + result: Value, + }, + /// Request failed without exposing host credentials or internal state. + Error { + /// Control protocol version. + protocol_version: u32, + /// Correlation id copied from the request. + request_id: String, + /// Stable machine-readable failure code. + code: String, + /// Human-readable non-secret diagnostic. + message: String, + }, + /// Workflow listing result. + Workflows { + /// Control protocol version. + protocol_version: u32, + /// Correlation id copied from the request. + request_id: String, + /// Workflows exposed by the native host. + workflows: Vec, + }, + /// Explicit shared-tab listing result. + Tabs { + /// Control protocol version. + protocol_version: u32, + /// Correlation id copied from the request. + request_id: String, + /// Only currently shared tabs. + tabs: Vec, + }, + /// Paired extension connection status. + Connection { + /// Control protocol version. + protocol_version: u32, + /// Correlation id copied from the request. + request_id: String, + /// Whether an authenticated extension socket is live. + connected: bool, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn methods_use_dotted_names_and_reject_unknown_fields() { + let request: CompanionControlRequest = serde_json::from_value(json!({ + "method": "workflow.start", + "protocol_version": 1, + "request_id": "control-1", + "workflow_id": "login", + "tab_id": 42, + "input": {"query":"shoes"} + })) + .unwrap(); + assert_eq!(request.protocol_version(), 1); + assert_eq!(request.request_id(), "control-1"); + + assert!( + serde_json::from_value::(json!({ + "method": "tab.list", "protocol_version": 1, + "request_id": "control-2", "include_unshared": true + })) + .is_err() + ); + } + + #[test] + fn completed_event_contains_no_host_owned_output() { + let event = RunEvent::Completed { + protocol_version: 1, + run_id: "run-1".into(), + status: "success".into(), + }; + assert_eq!( + serde_json::to_value(event).unwrap(), + json!({ + "event": "completed", + "protocol_version": 1, + "run_id": "run-1", + "status": "success" + }) + ); + } +} diff --git a/src/companion/mod.rs b/src/companion/mod.rs new file mode 100644 index 0000000..e1ca542 --- /dev/null +++ b/src/companion/mod.rs @@ -0,0 +1,21 @@ +//! Native companion state used by the TinyFlows Chrome extension relay. +//! +//! This module deliberately separates security and lifecycle policy from the +//! HTTP/WebSocket adapter. A server adapter must bind [`RelayPolicy::bind_addr`], +//! authenticate every upgrade with [`Authenticator`], and drive [`RelayState`] +//! for the lifetime of the authenticated socket. + +mod auth; +mod control; +mod relay; +mod server; +mod tabs; + +pub use auth::{ + AUTH_SUBPROTOCOL_PREFIX, AuthError, AuthenticatedSession, Authenticator, PROTOCOL_SUBPROTOCOL, + PairingSecret, SecretStore, WebSocketHandshake, +}; +pub use control::{CompanionControlRequest, CompanionControlResponse, RunEvent, WorkflowSummary}; +pub use relay::{DisconnectOutcome, PendingAction, RelayError, RelayPolicy, RelayState, SessionId}; +pub use server::{CompanionServer, CompanionServerConfig, CompanionServerError}; +pub use tabs::{RunBinding, RunId, SharedTab, TabId, TabRegistry, TabRegistryError}; diff --git a/src/companion/relay.rs b/src/companion/relay.rs new file mode 100644 index 0000000..e2c2df7 --- /dev/null +++ b/src/companion/relay.rs @@ -0,0 +1,566 @@ +//! Fail-closed relay connection, heartbeat, and action-correlation state. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::{Duration, Instant}; + +use crate::browser::{ + BROWSER_PROTOCOL_VERSION, BrowserError, BrowserErrorCode, BrowserEvent, BrowserRequest, + BrowserResponse, +}; + +use super::{RunId, TabId, TabRegistry, TabRegistryError}; + +/// Identifier for one authenticated extension WebSocket session. +pub type SessionId = String; + +/// Security and deadline policy enforced by a relay adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayPolicy { + /// Listener address. Construction guarantees this address is loopback-only. + pub bind_addr: SocketAddr, + /// Largest action timeout accepted from a workflow request. + pub maximum_action_timeout: Duration, + /// Maximum time without an authenticated heartbeat before fail-closed disconnect. + pub heartbeat_timeout: Duration, +} + +impl RelayPolicy { + /// Creates policy for a loopback-only listener. + pub fn loopback(port: u16) -> Self { + Self { + bind_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port), + maximum_action_timeout: Duration::from_secs(60), + heartbeat_timeout: Duration::from_secs(30), + } + } + + /// Validates policy supplied by an embedding host. + pub fn validate(&self) -> Result<(), RelayError> { + if !self.bind_addr.ip().is_loopback() { + return Err(RelayError::NonLoopbackBind); + } + if self.maximum_action_timeout.is_zero() || self.heartbeat_timeout.is_zero() { + return Err(RelayError::InvalidTimeout); + } + Ok(()) + } +} + +/// One browser command awaiting a terminal extension response. +#[derive(Debug, Clone)] +pub struct PendingAction { + /// Correlation id copied from the browser request. + pub request_id: String, + /// Run that owns the action. + pub run_id: RunId, + /// Explicit target tab. + pub tab_id: TabId, + /// Authenticated extension session that received the action. + pub session_id: SessionId, + /// Absolute timeout deadline. + pub deadline: Instant, +} + +/// Terminal outcomes produced when an authenticated relay disconnects. +#[derive(Debug, Clone, PartialEq)] +pub struct DisconnectOutcome { + /// Browser lifecycle event broadcast to native run observers. + pub event: BrowserEvent, + /// Correlated failures for every action interrupted by the disconnect. + pub responses: Vec, +} + +/// Relay state-machine failures. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayError { + /// A listener policy attempted to expose the relay beyond loopback. + NonLoopbackBind, + /// A configured or requested timeout was zero or exceeded policy. + InvalidTimeout, + /// No authenticated extension session is connected. + RelayDisconnected, + /// A request used an unsupported protocol version. + ProtocolMismatch, + /// A request id is already pending. + DuplicateRequestId, + /// A response did not correlate to a pending request. + UnknownRequestId, + /// A response came from a session other than the one receiving the request. + SessionMismatch, + /// Explicit shared-tab policy rejected the request. + Tab(TabRegistryError), +} + +impl RelayError { + /// Stable wire error code for the failure. + pub fn browser_code(&self) -> BrowserErrorCode { + match self { + Self::RelayDisconnected => BrowserErrorCode::RelayDisconnected, + Self::ProtocolMismatch => BrowserErrorCode::ProtocolMismatch, + Self::InvalidTimeout => BrowserErrorCode::ActionTimeout, + Self::Tab(TabRegistryError::TabNotShared | TabRegistryError::RunTabMismatch) => { + BrowserErrorCode::TabNotShared + } + Self::Tab(TabRegistryError::TabRevoked) => BrowserErrorCode::TabRevoked, + Self::Tab(TabRegistryError::UnsupportedPage) => BrowserErrorCode::UnsupportedPage, + Self::DuplicateRequestId + | Self::UnknownRequestId + | Self::SessionMismatch + | Self::NonLoopbackBind + | Self::Tab(TabRegistryError::RunAlreadyBound) => BrowserErrorCode::InvalidRequest, + } + } +} + +impl std::fmt::Display for RelayError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Tab(error) => write!(formatter, "tab authorization failed: {error}"), + other => formatter.write_str(match other { + Self::NonLoopbackBind => "relay listener must bind to a loopback address", + Self::InvalidTimeout => "relay timeout is outside the allowed bounds", + Self::RelayDisconnected => "authenticated extension relay is disconnected", + Self::ProtocolMismatch => "browser protocol version is unsupported", + Self::DuplicateRequestId => "browser request id is already pending", + Self::UnknownRequestId => "browser response has an unknown request id", + Self::SessionMismatch => "browser response came from the wrong session", + Self::Tab(_) => unreachable!("handled above"), + }), + } + } +} + +impl std::error::Error for RelayError {} + +impl From for RelayError { + fn from(error: TabRegistryError) -> Self { + Self::Tab(error) + } +} + +/// Native state for one active authenticated extension connection. +/// +/// Replacing or losing a session fails every action owned by the prior session. +/// The transport must deliver those correlated failures back to waiting native +/// calls; ordinary TinyFlows retry policies decide whether another attempt runs. +#[derive(Debug)] +pub struct RelayState { + policy: RelayPolicy, + connected: Option, + last_heartbeat: Option, + tabs: TabRegistry, + pending: HashMap, +} + +impl RelayState { + /// Creates disconnected relay state after validating loopback/deadline policy. + pub fn new(policy: RelayPolicy) -> Result { + policy.validate()?; + Ok(Self { + policy, + connected: None, + last_heartbeat: None, + tabs: TabRegistry::new(), + pending: HashMap::new(), + }) + } + + /// Returns the explicit shared-tab registry. + pub fn tabs(&self) -> &TabRegistry { + &self.tabs + } + + /// Returns mutable access for authenticated share/revoke messages. + pub fn tabs_mut(&mut self) -> &mut TabRegistry { + &mut self.tabs + } + + /// Installs an authenticated socket as the sole active extension session. + /// + /// The caller must first disconnect an existing session so interrupted + /// actions receive deterministic errors rather than being silently adopted. + pub fn connect(&mut self, session_id: SessionId, now: Instant) -> Result<(), RelayError> { + if self.connected.is_some() { + return Err(RelayError::SessionMismatch); + } + self.connected = Some(session_id); + self.last_heartbeat = Some(now); + Ok(()) + } + + /// Records an authenticated heartbeat from the current session. + pub fn heartbeat(&mut self, session_id: &str, now: Instant) -> Result<(), RelayError> { + if self.connected.as_deref() != Some(session_id) { + return Err(RelayError::SessionMismatch); + } + self.last_heartbeat = Some(now); + Ok(()) + } + + /// Registers a validated, explicitly bound action before sending it. + pub fn begin_action( + &mut self, + request: &BrowserRequest, + now: Instant, + ) -> Result<&PendingAction, RelayError> { + if request.protocol_version != BROWSER_PROTOCOL_VERSION { + return Err(RelayError::ProtocolMismatch); + } + let session_id = self + .connected + .as_ref() + .ok_or(RelayError::RelayDisconnected)? + .clone(); + self.tabs.authorize(&request.run_id, request.tab_id)?; + let timeout = Duration::from_millis(request.timeout_ms); + if timeout.is_zero() || timeout > self.policy.maximum_action_timeout { + return Err(RelayError::InvalidTimeout); + } + if self.pending.contains_key(&request.request_id) { + return Err(RelayError::DuplicateRequestId); + } + self.pending.insert( + request.request_id.clone(), + PendingAction { + request_id: request.request_id.clone(), + run_id: request.run_id.clone(), + tab_id: request.tab_id, + session_id, + deadline: now + timeout, + }, + ); + Ok(self + .pending + .get(&request.request_id) + .expect("pending action was just inserted")) + } + + /// Accepts a terminal response only from the owning authenticated session. + pub fn complete_action( + &mut self, + session_id: &str, + response: &BrowserResponse, + ) -> Result { + if response.protocol_version() != BROWSER_PROTOCOL_VERSION { + return Err(RelayError::ProtocolMismatch); + } + let pending = self + .pending + .get(response.request_id()) + .ok_or(RelayError::UnknownRequestId)?; + if pending.session_id != session_id || self.connected.as_deref() != Some(session_id) { + return Err(RelayError::SessionMismatch); + } + Ok(self + .pending + .remove(response.request_id()) + .expect("pending action was checked above")) + } + + /// Fails and removes actions whose bounded deadlines elapsed. + pub fn expire_actions(&mut self, now: Instant) -> Vec { + let mut expired = self + .pending + .values() + .filter(|action| action.deadline <= now) + .map(|action| action.request_id.clone()) + .collect::>(); + expired.sort(); + expired + .into_iter() + .filter_map(|request_id| { + self.pending.remove(&request_id)?; + Some(error_response( + request_id, + BrowserErrorCode::ActionTimeout, + "browser action exceeded its deadline", + )) + }) + .collect() + } + + /// Disconnects a stale heartbeat and fails every interrupted action. + pub fn disconnect_if_stale(&mut self, now: Instant) -> Option { + let last = self.last_heartbeat?; + if now.saturating_duration_since(last) < self.policy.heartbeat_timeout { + return None; + } + let session_id = self.connected.clone()?; + Some(self.disconnect(&session_id)) + } + + /// Disconnects the current authenticated session and fails closed. + pub fn disconnect(&mut self, session_id: &str) -> DisconnectOutcome { + if self.connected.as_deref() != Some(session_id) { + return DisconnectOutcome { + event: BrowserEvent::RelayDisconnected { + protocol_version: BROWSER_PROTOCOL_VERSION, + }, + responses: Vec::new(), + }; + } + self.connected = None; + self.last_heartbeat = None; + let mut request_ids = self.pending.keys().cloned().collect::>(); + request_ids.sort(); + let responses = request_ids + .into_iter() + .filter_map(|request_id| { + self.pending.remove(&request_id)?; + Some(error_response( + request_id, + BrowserErrorCode::RelayDisconnected, + "authenticated extension relay disconnected", + )) + }) + .collect(); + DisconnectOutcome { + event: BrowserEvent::RelayDisconnected { + protocol_version: BROWSER_PROTOCOL_VERSION, + }, + responses, + } + } + + /// Revokes a tab and fails actions targeting it immediately. + pub fn revoke_tab(&mut self, tab_id: TabId) -> (BrowserEvent, Vec) { + self.tabs.revoke(tab_id); + let mut request_ids = self + .pending + .values() + .filter(|pending| pending.tab_id == tab_id) + .map(|pending| pending.request_id.clone()) + .collect::>(); + request_ids.sort(); + let responses = request_ids + .into_iter() + .filter_map(|request_id| { + self.pending.remove(&request_id)?; + Some(error_response( + request_id, + BrowserErrorCode::TabRevoked, + "user revoked access to the shared tab", + )) + }) + .collect(); + ( + BrowserEvent::TabRevoked { + protocol_version: BROWSER_PROTOCOL_VERSION, + tab_id, + }, + responses, + ) + } + + /// Cancels all in-flight browser work for a native workflow run. + pub fn cancel_run(&mut self, run_id: &str) -> Vec { + self.tabs.unbind_run(run_id); + let mut request_ids = self + .pending + .values() + .filter(|pending| pending.run_id == run_id) + .map(|pending| pending.request_id.clone()) + .collect::>(); + request_ids.sort(); + request_ids + .into_iter() + .filter_map(|request_id| { + self.pending.remove(&request_id)?; + Some(error_response( + request_id, + BrowserErrorCode::Cancelled, + "workflow run cancelled the browser action", + )) + }) + .collect() + } + + /// Returns whether an authenticated extension session is live. + pub fn is_connected(&self) -> bool { + self.connected.is_some() + } + + /// Returns the number of in-flight actions, primarily for host diagnostics. + pub fn pending_count(&self) -> usize { + self.pending.len() + } +} + +fn error_response(request_id: String, code: BrowserErrorCode, message: &str) -> BrowserResponse { + BrowserResponse::Error { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id, + error: BrowserError { + code, + message: message.to_owned(), + details: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::browser::BrowserAction; + + fn request(id: &str, run_id: &str, tab_id: TabId, timeout_ms: u64) -> BrowserRequest { + BrowserRequest { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id: id.into(), + run_id: run_id.into(), + tab_id, + timeout_ms, + action: BrowserAction::GetTitle, + } + } + + fn connected_state(now: Instant) -> RelayState { + let mut state = RelayState::new(RelayPolicy::loopback(3210)).unwrap(); + state + .tabs_mut() + .share(7, 1, "https://example.test", "Example") + .unwrap(); + state.tabs_mut().bind_run("run-1", 7).unwrap(); + state.connect("session-1".into(), now).unwrap(); + state + } + + #[test] + fn policy_rejects_non_loopback_listener() { + let mut policy = RelayPolicy::loopback(3210); + policy.bind_addr = "0.0.0.0:3210".parse().unwrap(); + assert_eq!(policy.validate(), Err(RelayError::NonLoopbackBind)); + } + + #[test] + fn action_requires_connection_binding_and_bounded_timeout() { + let now = Instant::now(); + let mut disconnected = RelayState::new(RelayPolicy::loopback(3210)).unwrap(); + assert_eq!( + disconnected + .begin_action(&request("r0", "run-1", 7, 1000), now) + .unwrap_err(), + RelayError::RelayDisconnected + ); + + let mut state = connected_state(now); + assert!( + state + .begin_action(&request("r1", "run-1", 7, 1000), now) + .is_ok() + ); + assert_eq!( + state + .begin_action(&request("r2", "run-1", 8, 1000), now) + .unwrap_err(), + RelayError::Tab(TabRegistryError::RunTabMismatch) + ); + assert_eq!( + state + .begin_action(&request("r3", "run-1", 7, 60_001), now) + .unwrap_err(), + RelayError::InvalidTimeout + ); + } + + #[test] + fn timeout_disconnect_revocation_and_cancel_all_fail_closed() { + let now = Instant::now(); + let mut state = connected_state(now); + state + .begin_action(&request("timeout", "run-1", 7, 100), now) + .unwrap(); + let expired = state.expire_actions(now + Duration::from_millis(100)); + assert!(matches!( + &expired[0], + BrowserResponse::Error { error, .. } + if error.code == BrowserErrorCode::ActionTimeout + )); + + state + .begin_action(&request("revoked", "run-1", 7, 1000), now) + .unwrap(); + let (_, revoked) = state.revoke_tab(7); + assert!(matches!( + &revoked[0], + BrowserResponse::Error { error, .. } + if error.code == BrowserErrorCode::TabRevoked + )); + + state + .tabs_mut() + .share(7, 1, "https://example.test", "Example") + .unwrap(); + state.tabs_mut().bind_run("run-2", 7).unwrap(); + state + .begin_action(&request("cancelled", "run-2", 7, 1000), now) + .unwrap(); + let cancelled = state.cancel_run("run-2"); + assert!(matches!( + &cancelled[0], + BrowserResponse::Error { error, .. } + if error.code == BrowserErrorCode::Cancelled + )); + + state.tabs_mut().bind_run("run-3", 7).unwrap(); + state + .begin_action(&request("disconnect", "run-3", 7, 1000), now) + .unwrap(); + let disconnected = state.disconnect("session-1"); + assert!(matches!( + &disconnected.responses[0], + BrowserResponse::Error { error, .. } + if error.code == BrowserErrorCode::RelayDisconnected + )); + assert!(!state.is_connected()); + } + + #[test] + fn response_must_match_protocol_request_and_session() { + let now = Instant::now(); + let mut state = connected_state(now); + state + .begin_action(&request("r1", "run-1", 7, 1000), now) + .unwrap(); + let response = error_response("r1".into(), BrowserErrorCode::ElementNotFound, "not found"); + assert_eq!( + state.complete_action("session-2", &response).unwrap_err(), + RelayError::SessionMismatch + ); + assert_eq!( + state + .complete_action("session-1", &response) + .unwrap() + .request_id, + "r1" + ); + } + + #[test] + fn heartbeat_refreshes_liveness_and_stale_session_fails_actions() { + let now = Instant::now(); + let mut state = connected_state(now); + state + .begin_action(&request("pending", "run-1", 7, 60_000), now) + .unwrap(); + + let refreshed = now + Duration::from_secs(20); + state.heartbeat("session-1", refreshed).unwrap(); + assert!( + state + .disconnect_if_stale(now + Duration::from_secs(40)) + .is_none() + ); + + let outcome = state + .disconnect_if_stale(now + Duration::from_secs(50)) + .expect("thirty seconds without a heartbeat is stale"); + assert!(matches!( + &outcome.responses[0], + BrowserResponse::Error { error, .. } + if error.code == BrowserErrorCode::RelayDisconnected + )); + assert!(!state.is_connected()); + } +} diff --git a/src/companion/server.rs b/src/companion/server.rs new file mode 100644 index 0000000..e8f2d9f --- /dev/null +++ b/src/companion/server.rs @@ -0,0 +1,882 @@ +//! Authenticated loopback WebSocket adapter and native workflow runner. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use axum::Router; +use axum::extract::ws::{Message, WebSocket}; +use axum::extract::{Json, State, WebSocketUpgrade}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::sync::{mpsc, oneshot}; + +use crate::browser::{ + BROWSER_PROTOCOL_VERSION, BrowserCancel, BrowserCancelType, BrowserError, BrowserErrorCode, + BrowserEvent, BrowserRelay, BrowserRequest, BrowserResponse, ChromeToolInvoker, + RoutingToolInvoker, +}; +use crate::caps::Capabilities; +use crate::compiler::compile; +use crate::engine::{CancellationToken, run_cancellable_with_observer}; +use crate::model::WorkflowGraph; +use crate::observability::{ExecutionStep, RunObserver, StepStatus}; + +use super::{ + Authenticator, CompanionControlRequest, CompanionControlResponse, PROTOCOL_SUBPROTOCOL, + PairingSecret, RelayPolicy, RelayState, RunEvent, TabId, WebSocketHandshake, WorkflowSummary, +}; + +/// Configuration for the native Chrome companion. +#[derive(Clone)] +pub struct CompanionServerConfig { + /// Loopback/deadline policy for the relay listener. + pub policy: RelayPolicy, + /// Exact Chrome extension id allowed by the WebSocket origin check. + pub extension_id: String, + /// Host-local pairing secret required in a WebSocket subprotocol. + pub pairing_secret: PairingSecret, + /// Directory containing workflow JSON files exposed to the side panel. + pub workflows_dir: PathBuf, + /// Host capabilities used for every non-browser effect. + pub capabilities: Capabilities, +} + +/// Errors produced by companion configuration, I/O, or workflow startup. +#[derive(Debug, thiserror::Error)] +pub enum CompanionServerError { + /// Relay or tab policy rejected an operation. + #[error("relay policy error: {0}")] + Relay(#[from] super::RelayError), + /// Pairing or extension-id configuration was invalid. + #[error("authentication configuration error: {0}")] + Authentication(#[from] std::io::Error), + /// The loopback listener failed. + #[error("listener error: {0}")] + Listener(String), + /// A workflow file could not be read, validated, or decoded. + #[error("workflow error: {0}")] + Workflow(String), +} + +type PendingSender = oneshot::Sender>; + +struct ServerInner { + bind_addr: SocketAddr, + native_secret: String, + authenticator: Authenticator, + relay: Mutex, + outbound: Mutex>>, + pending: tokio::sync::Mutex>, + workflows_dir: PathBuf, + capabilities: Capabilities, + runs: Mutex>, + next_session: AtomicU64, + next_run: AtomicU64, +} + +/// Loopback-only native companion used by the TinyFlows Chrome extension. +#[derive(Clone)] +pub struct CompanionServer { + inner: Arc, +} + +impl CompanionServer { + /// Validates configuration and creates a disconnected server. + pub fn new(config: CompanionServerConfig) -> Result { + config.policy.validate()?; + let bind_addr = config.policy.bind_addr; + let native_secret = config.pairing_secret.expose().to_owned(); + let authenticator = Authenticator::new(&config.extension_id, config.pairing_secret)?; + Ok(Self { + inner: Arc::new(ServerInner { + bind_addr, + native_secret, + authenticator, + relay: Mutex::new(RelayState::new(config.policy)?), + outbound: Mutex::new(None), + pending: tokio::sync::Mutex::new(HashMap::new()), + workflows_dir: config.workflows_dir, + capabilities: config.capabilities, + runs: Mutex::new(HashMap::new()), + next_session: AtomicU64::new(0), + next_run: AtomicU64::new(0), + }), + }) + } + + /// Returns the loopback socket address the server will bind. + pub fn bind_addr(&self) -> SocketAddr { + self.inner.bind_addr + } + + /// Runs the authenticated WebSocket endpoint until listener failure. + pub async fn serve(self) -> Result<(), CompanionServerError> { + let listener = tokio::net::TcpListener::bind(self.inner.bind_addr) + .await + .map_err(|error| CompanionServerError::Listener(error.to_string()))?; + let app = Router::new() + .route("/v1/extension", get(upgrade)) + .route("/v1/native/tabs", get(native_tabs)) + .route("/v1/native/workflows", get(native_workflows)) + .route("/v1/native/runs", post(native_run)) + .with_state(self); + axum::serve(listener, app) + .await + .map_err(|error| CompanionServerError::Listener(error.to_string())) + } + + /// Lists valid workflow JSON files from the configured directory. + pub fn workflows(&self) -> Result, CompanionServerError> { + list_workflows(&self.inner.workflows_dir) + } + + /// Starts a native run bound to one explicit shared tab. + pub async fn start_workflow( + &self, + workflow_id: &str, + tab_id: TabId, + input: Value, + ) -> Result { + let graph = load_workflow(&self.inner.workflows_dir, workflow_id)?; + let node_kinds = graph + .nodes + .iter() + .map(|node| { + let kind = serde_json::to_value(&node.kind) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_else(|| "unknown".into()); + (node.id.clone(), kind) + }) + .collect::>(); + let compiled = + compile(&graph).map_err(|error| CompanionServerError::Workflow(error.to_string()))?; + let sequence = self.inner.next_run.fetch_add(1, Ordering::Relaxed) + 1; + let run_id = format!("chrome-run-{sequence}"); + self.inner + .relay + .lock() + .map_err(|_| lock_error())? + .tabs_mut() + .bind_run(run_id.clone(), tab_id) + .map_err(super::RelayError::from)?; + + let token = CancellationToken::new(); + self.inner + .runs + .lock() + .map_err(|_| lock_error())? + .insert(run_id.clone(), token.clone()); + let server = self.clone(); + let spawned_run_id = run_id.clone(); + tokio::spawn(async move { + server.send_json(&RunEvent::Started { + protocol_version: BROWSER_PROTOCOL_VERSION, + run_id: spawned_run_id.clone(), + tab_id, + }); + let browser = Arc::new(ChromeToolInvoker::new( + Arc::new(SocketRelay { + inner: server.inner.clone(), + }), + spawned_run_id.clone(), + tab_id, + )); + let mut capabilities = server.inner.capabilities.clone(); + capabilities.tools = Arc::new(RoutingToolInvoker::new( + browser, + server.inner.capabilities.tools.clone(), + )); + let observer = Arc::new(CompanionObserver { + server: server.clone(), + run_id: spawned_run_id.clone(), + node_kinds, + }) as Arc; + match run_cancellable_with_observer(&compiled, input, &capabilities, token, &observer) + .await + { + Ok(value) if value.cancelled => server.send_json(&RunEvent::Cancelled { + protocol_version: BROWSER_PROTOCOL_VERSION, + run_id: spawned_run_id.clone(), + }), + Ok(value) if !value.pending_approvals.is_empty() => { + server.send_json(&RunEvent::AwaitingApproval { + protocol_version: BROWSER_PROTOCOL_VERSION, + run_id: spawned_run_id.clone(), + pending_approvals: value.pending_approvals, + }); + } + Ok(_) => server.send_json(&RunEvent::Completed { + protocol_version: BROWSER_PROTOCOL_VERSION, + run_id: spawned_run_id.clone(), + status: "success".into(), + }), + Err(_) => server.send_json(&RunEvent::Failed { + protocol_version: BROWSER_PROTOCOL_VERSION, + run_id: spawned_run_id.clone(), + code: "workflow_failed".into(), + message: "Workflow execution failed in the native companion".into(), + }), + } + if let Ok(mut relay) = server.inner.relay.lock() { + relay.tabs_mut().unbind_run(&spawned_run_id); + } + if let Ok(mut runs) = server.inner.runs.lock() { + runs.remove(&spawned_run_id); + } + }); + Ok(run_id) + } + + /// Cancels a live run and its in-flight browser action. + pub async fn cancel_workflow(&self, run_id: &str) -> bool { + let token = self + .inner + .runs + .lock() + .ok() + .and_then(|runs| runs.get(run_id).cloned()); + let Some(token) = token else { return false }; + token.cancel(); + let responses = self + .inner + .relay + .lock() + .map(|mut relay| relay.cancel_run(run_id)) + .unwrap_or_default(); + self.dispatch(responses).await; + true + } + + fn send_json(&self, value: &T) { + let Ok(text) = serde_json::to_string(value) else { + return; + }; + if let Ok(outbound) = self.inner.outbound.lock() + && let Some(sender) = outbound.as_ref() + { + let _ = sender.send(Message::Text(text.into())); + } + } + + async fn dispatch(&self, responses: Vec) { + let mut pending = self.inner.pending.lock().await; + for response in responses { + if matches!(response, BrowserResponse::Error { .. }) { + self.send_json(&BrowserCancel { + protocol_version: BROWSER_PROTOCOL_VERSION, + message_type: BrowserCancelType::BrowserCancel, + request_id: response.request_id().to_owned(), + }); + } + if let Some(sender) = pending.remove(response.request_id()) { + let result = match response { + BrowserResponse::Error { error, .. } => Err(error), + success => Ok(success), + }; + let _ = sender.send(result); + } + } + } +} + +struct CompanionObserver { + server: CompanionServer, + run_id: String, + node_kinds: HashMap, +} + +impl RunObserver for CompanionObserver { + fn on_step_start(&self, node_id: &str) { + let node_kind = self + .node_kinds + .get(node_id) + .cloned() + .unwrap_or_else(|| "unknown".into()); + self.server.send_json(&RunEvent::StepStarted { + protocol_version: BROWSER_PROTOCOL_VERSION, + run_id: self.run_id.clone(), + node_id: node_id.to_owned(), + node_kind, + }); + } + + fn on_step_finish(&self, step: &ExecutionStep) { + let node_kind = self + .node_kinds + .get(&step.node_id) + .cloned() + .unwrap_or_else(|| "unknown".into()); + self.server.send_json(&RunEvent::StepCompleted { + protocol_version: BROWSER_PROTOCOL_VERSION, + run_id: self.run_id.clone(), + node_id: step.node_id.clone(), + node_kind, + status: match step.status { + StepStatus::Success => "success", + StepStatus::Error => "error", + } + .into(), + duration_ms: u64::try_from(step.duration_ms).unwrap_or(u64::MAX), + }); + } +} + +struct SocketRelay { + inner: Arc, +} + +#[async_trait] +impl BrowserRelay for SocketRelay { + async fn execute( + &self, + request: BrowserRequest, + ) -> std::result::Result { + self.inner + .relay + .lock() + .map_err(|_| browser_error(BrowserErrorCode::RelayDisconnected, "relay unavailable"))? + .begin_action(&request, Instant::now()) + .map_err(|error| browser_error(error.browser_code(), &error.to_string()))?; + let (sender, receiver) = oneshot::channel(); + self.inner + .pending + .lock() + .await + .insert(request.request_id.clone(), sender); + let wire = serde_json::to_string(&request) + .map_err(|error| browser_error(BrowserErrorCode::InvalidRequest, &error.to_string()))?; + let outbound = self + .inner + .outbound + .lock() + .map_err(|_| browser_error(BrowserErrorCode::RelayDisconnected, "relay unavailable"))? + .clone() + .ok_or_else(|| { + browser_error( + BrowserErrorCode::RelayDisconnected, + "extension is disconnected", + ) + })?; + if outbound.send(Message::Text(wire.into())).is_err() { + self.inner.pending.lock().await.remove(&request.request_id); + return Err(browser_error( + BrowserErrorCode::RelayDisconnected, + "extension connection closed", + )); + } + match tokio::time::timeout(Duration::from_millis(request.timeout_ms), receiver).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err(browser_error( + BrowserErrorCode::RelayDisconnected, + "relay response channel closed", + )), + Err(_) => { + self.inner.pending.lock().await.remove(&request.request_id); + if let Ok(mut relay) = self.inner.relay.lock() { + let _ = relay.expire_actions(Instant::now()); + } + send_cancel(&self.inner, &request.request_id); + Err(browser_error( + BrowserErrorCode::ActionTimeout, + "browser action exceeded its deadline", + )) + } + } + } +} + +fn send_cancel(inner: &ServerInner, request_id: &str) { + let cancel = BrowserCancel { + protocol_version: BROWSER_PROTOCOL_VERSION, + message_type: BrowserCancelType::BrowserCancel, + request_id: request_id.to_owned(), + }; + let Ok(wire) = serde_json::to_string(&cancel) else { + return; + }; + if let Ok(outbound) = inner.outbound.lock() + && let Some(sender) = outbound.as_ref() + { + let _ = sender.send(Message::Text(wire.into())); + } +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct NativeRunRequest { + workflow_id: String, + tab_id: TabId, + #[serde(default)] + input: Value, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct HeartbeatMessage { + protocol_version: u32, + #[serde(rename = "type")] + message_type: HeartbeatType, +} + +#[derive(serde::Deserialize)] +enum HeartbeatType { + #[serde(rename = "heartbeat")] + Heartbeat, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct TabSharedMessage { + protocol_version: u32, + event: TabSharedType, + tab: AnnouncedTab, +} + +#[derive(serde::Deserialize)] +enum TabSharedType { + #[serde(rename = "tab_shared")] + TabShared, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct AnnouncedTab { + id: u64, + window_id: u64, + url: String, + title: String, +} + +async fn native_tabs(State(server): State, headers: HeaderMap) -> Response { + if !native_authorized(&server, &headers) { + return StatusCode::UNAUTHORIZED.into_response(); + } + let tabs = server + .inner + .relay + .lock() + .map(|relay| relay.tabs().list().into_iter().cloned().collect::>()) + .unwrap_or_default(); + Json(json!({"protocol_version":BROWSER_PROTOCOL_VERSION,"tabs":tabs})).into_response() +} + +async fn native_workflows(State(server): State, headers: HeaderMap) -> Response { + if !native_authorized(&server, &headers) { + return StatusCode::UNAUTHORIZED.into_response(); + } + match server.workflows() { + Ok(workflows) => Json(json!({ + "protocol_version":BROWSER_PROTOCOL_VERSION, + "workflows":workflows + })) + .into_response(), + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(json!({"code":"workflow_list_failed","message":error.to_string()})), + ) + .into_response(), + } +} + +async fn native_run( + State(server): State, + headers: HeaderMap, + Json(request): Json, +) -> Response { + if !native_authorized(&server, &headers) { + return StatusCode::UNAUTHORIZED.into_response(); + } + match server + .start_workflow(&request.workflow_id, request.tab_id, request.input) + .await + { + Ok(run_id) => Json(json!({ + "protocol_version":BROWSER_PROTOCOL_VERSION, + "run_id":run_id + })) + .into_response(), + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(json!({"code":"workflow_start_failed","message":error.to_string()})), + ) + .into_response(), + } +} + +fn native_authorized(server: &CompanionServer, headers: &HeaderMap) -> bool { + let candidate = header(headers, "authorization"); + let candidate = candidate.strip_prefix("Bearer ").unwrap_or_default(); + constant_time_eq(candidate.as_bytes(), server.inner.native_secret.as_bytes()) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + difference |= usize::from( + left.get(index).copied().unwrap_or_default() + ^ right.get(index).copied().unwrap_or_default(), + ); + } + difference == 0 +} + +async fn upgrade( + State(server): State, + headers: HeaderMap, + websocket: WebSocketUpgrade, +) -> Response { + let origin = header(&headers, "origin"); + let protocols_header = header(&headers, "sec-websocket-protocol"); + let offered = protocols_header + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>(); + if server + .inner + .authenticator + .authenticate(&WebSocketHandshake { + origin: &origin, + subprotocols: &offered, + }) + .is_err() + { + return (StatusCode::UNAUTHORIZED, "unauthorized extension relay").into_response(); + } + websocket + .protocols([PROTOCOL_SUBPROTOCOL]) + .on_upgrade(move |socket| extension_session(server, socket)) +} + +async fn extension_session(server: CompanionServer, socket: WebSocket) { + let session_id = format!( + "extension-session-{}", + server.inner.next_session.fetch_add(1, Ordering::Relaxed) + 1 + ); + let (mut sink, mut stream) = socket.split(); + let (sender, mut receiver) = mpsc::unbounded_channel::(); + { + let Ok(mut relay) = server.inner.relay.lock() else { + return; + }; + if relay.is_connected() || relay.connect(session_id.clone(), Instant::now()).is_err() { + return; + } + } + if let Ok(mut outbound) = server.inner.outbound.lock() { + *outbound = Some(sender); + } + let writer = tokio::spawn(async move { + while let Some(message) = receiver.recv().await { + if sink.send(message).await.is_err() { + break; + } + } + }); + let mut heartbeat_check = tokio::time::interval(Duration::from_secs(5)); + heartbeat_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + heartbeat_check.tick().await; + loop { + tokio::select! { + message = stream.next() => match message { + Some(Ok(Message::Text(text))) => { + if handle_text(&server, &session_id, &text).await.is_err() { + break; + } + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + _ => {} + }, + _ = heartbeat_check.tick() => { + let responses = { + server.inner.relay.lock().ok() + .and_then(|mut relay| relay.disconnect_if_stale(Instant::now())) + .map(|outcome| outcome.responses) + }; + if let Some(responses) = responses { + server.dispatch(responses).await; + break; + } + }, + } + } + if let Ok(mut outbound) = server.inner.outbound.lock() { + *outbound = None; + } + let responses = { + server + .inner + .relay + .lock() + .map(|mut relay| relay.disconnect(&session_id).responses) + .unwrap_or_default() + }; + server.dispatch(responses).await; + writer.abort(); +} + +async fn handle_text(server: &CompanionServer, session: &str, text: &str) -> Result<(), ()> { + let value: Value = serde_json::from_str(text).map_err(|_| ())?; + if let Ok(heartbeat) = serde_json::from_value::(value.clone()) { + if heartbeat.protocol_version != BROWSER_PROTOCOL_VERSION { + return Err(()); + } + let HeartbeatType::Heartbeat = heartbeat.message_type; + return server + .inner + .relay + .lock() + .map_err(|_| ())? + .heartbeat(session, Instant::now()) + .map_err(|_| ()); + } + if let Ok(response) = serde_json::from_value::(value.clone()) { + let completion = server + .inner + .relay + .lock() + .map_err(|_| ())? + .complete_action(session, &response); + if matches!(completion, Err(super::RelayError::UnknownRequestId)) { + return Ok(()); + } + completion.map_err(|_| ())?; + if let Some(sender) = server + .inner + .pending + .lock() + .await + .remove(response.request_id()) + { + let _ = sender.send(Ok(response)); + } + return Ok(()); + } + if let Ok(message) = serde_json::from_value::(value.clone()) { + if message.protocol_version != BROWSER_PROTOCOL_VERSION { + return Err(()); + } + let TabSharedType::TabShared = message.event; + server + .inner + .relay + .lock() + .map_err(|_| ())? + .tabs_mut() + .share( + message.tab.id, + message.tab.window_id, + message.tab.url, + message.tab.title, + ) + .map_err(|_| ())?; + return Ok(()); + } + if let Ok(event) = serde_json::from_value::(value.clone()) { + let version = match &event { + BrowserEvent::ActionStarted { + protocol_version, .. + } + | BrowserEvent::ActionCompleted { + protocol_version, .. + } + | BrowserEvent::ActionFailed { + protocol_version, .. + } + | BrowserEvent::TabRevoked { + protocol_version, .. + } + | BrowserEvent::RelayDisconnected { protocol_version } => *protocol_version, + }; + if version != BROWSER_PROTOCOL_VERSION { + return Err(()); + } + match event { + BrowserEvent::TabRevoked { tab_id, .. } => { + let (_, responses) = server + .inner + .relay + .lock() + .map_err(|_| ())? + .revoke_tab(tab_id); + server.dispatch(responses).await; + return Ok(()); + } + BrowserEvent::ActionStarted { .. } + | BrowserEvent::ActionCompleted { .. } + | BrowserEvent::ActionFailed { .. } => return Ok(()), + BrowserEvent::RelayDisconnected { .. } => return Err(()), + } + } + let request = serde_json::from_value::(value).map_err(|_| ())?; + let response = handle_control(server, request).await; + server.send_json(&response); + Ok(()) +} + +async fn handle_control( + server: &CompanionServer, + request: CompanionControlRequest, +) -> CompanionControlResponse { + let request_id = request.request_id().to_owned(); + if request.protocol_version() != BROWSER_PROTOCOL_VERSION { + return control_error( + request_id, + "protocol_mismatch", + "unsupported control protocol", + ); + } + match request { + CompanionControlRequest::WorkflowList { .. } => match server.workflows() { + Ok(workflows) => CompanionControlResponse::Workflows { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id, + workflows, + }, + Err(error) => control_error(request_id, "workflow_list_failed", &error.to_string()), + }, + CompanionControlRequest::WorkflowStart { + workflow_id, + tab_id, + input, + .. + } => match server.start_workflow(&workflow_id, tab_id, input).await { + Ok(run_id) => control_ok(request_id, json!({"run_id":run_id})), + Err(error) => control_error(request_id, "workflow_start_failed", &error.to_string()), + }, + CompanionControlRequest::WorkflowCancel { run_id, .. } => control_ok( + request_id, + json!({"cancelled":server.cancel_workflow(&run_id).await}), + ), + CompanionControlRequest::RunSubscribe { run_id, .. } => { + control_ok(request_id, json!({"subscribed":run_id})) + } + CompanionControlRequest::TabList { .. } => { + let tabs = server + .inner + .relay + .lock() + .map(|relay| relay.tabs().list().into_iter().cloned().collect()) + .unwrap_or_default(); + CompanionControlResponse::Tabs { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id, + tabs, + } + } + CompanionControlRequest::ConnectionStatus { .. } => { + let connected = server + .inner + .relay + .lock() + .map(|relay| relay.is_connected()) + .unwrap_or(false); + CompanionControlResponse::Connection { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id, + connected, + } + } + } +} + +fn load_workflow(directory: &Path, id: &str) -> Result { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(CompanionServerError::Workflow("invalid workflow id".into())); + } + let path = directory.join(format!("{id}.json")); + let source = std::fs::read_to_string(&path) + .map_err(|error| CompanionServerError::Workflow(format!("{}: {error}", path.display())))?; + serde_json::from_str(&source) + .map_err(|error| CompanionServerError::Workflow(format!("{}: {error}", path.display()))) +} + +fn list_workflows(directory: &Path) -> Result, CompanionServerError> { + let entries = std::fs::read_dir(directory).map_err(|error| { + CompanionServerError::Workflow(format!("{}: {error}", directory.display())) + })?; + let mut workflows = Vec::new(); + for entry in entries { + let path = entry + .map_err(|error| CompanionServerError::Workflow(error.to_string()))? + .path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(id) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + let graph = load_workflow(directory, id)?; + workflows.push(WorkflowSummary { + id: id.to_owned(), + name: if graph.name.is_empty() { + id.to_owned() + } else { + graph.name + }, + }); + } + workflows.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(workflows) +} + +fn header(headers: &HeaderMap, name: &str) -> String { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned() +} + +fn lock_error() -> CompanionServerError { + CompanionServerError::Listener("companion state lock poisoned".into()) +} + +fn browser_error(code: BrowserErrorCode, message: &str) -> BrowserError { + BrowserError { + code, + message: message.to_owned(), + details: None, + } +} + +fn control_ok(request_id: String, result: Value) -> CompanionControlResponse { + CompanionControlResponse::Ok { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id, + result, + } +} + +fn control_error(request_id: String, code: &str, message: &str) -> CompanionControlResponse { + CompanionControlResponse::Error { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id, + code: code.to_owned(), + message: message.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workflow_ids_cannot_escape_the_configured_directory() { + let error = load_workflow(Path::new("/tmp"), "../secret").unwrap_err(); + assert!(error.to_string().contains("invalid workflow id")); + } +} diff --git a/src/companion/tabs.rs b/src/companion/tabs.rs new file mode 100644 index 0000000..b071339 --- /dev/null +++ b/src/companion/tabs.rs @@ -0,0 +1,263 @@ +//! Explicit shared-tab and workflow-run binding registry. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Chrome tab identifier assigned by the browser. +pub type TabId = u64; + +/// Stable workflow-run identifier assigned by the native host. +pub type RunId = String; + +/// Metadata for a tab the user explicitly shared with TinyFlows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SharedTab { + /// Browser-assigned tab id. + pub id: TabId, + /// Browser window containing the tab. + pub window_id: u64, + /// Last reported regular-site URL. + pub url: String, + /// Last reported title. + pub title: String, + /// Monotonic generation incremented each time this tab is shared. + pub generation: u64, +} + +/// Immutable binding between a workflow run and its explicitly selected tab. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunBinding { + /// Native workflow run id. + pub run_id: RunId, + /// Shared tab controlled by this run. + pub tab_id: TabId, + /// Sharing generation captured when the run started. + pub tab_generation: u64, +} + +/// Errors produced by explicit tab sharing and run binding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TabRegistryError { + /// A run attempted to use a tab the user did not share. + TabNotShared, + /// A tab was detached and its previous sharing generation is no longer valid. + TabRevoked, + /// A run is already bound to a different tab. + RunAlreadyBound, + /// A browser command attempted to address another run's tab. + RunTabMismatch, + /// The URL is a browser-internal or otherwise unsupported page. + UnsupportedPage, +} + +impl TabRegistryError { + /// Stable error code returned across the relay protocol. + pub fn code(&self) -> &'static str { + match self { + Self::TabNotShared => "tab_not_shared", + Self::TabRevoked => "tab_revoked", + Self::RunAlreadyBound => "run_already_bound", + Self::RunTabMismatch => "run_tab_mismatch", + Self::UnsupportedPage => "unsupported_page", + } + } +} + +impl std::fmt::Display for TabRegistryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.code()) + } +} + +impl std::error::Error for TabRegistryError {} + +/// Registry containing only user-shared tabs and explicit run bindings. +#[derive(Debug, Default)] +pub struct TabRegistry { + shared: HashMap, + bindings: HashMap, + generations: HashMap, +} + +impl TabRegistry { + /// Creates an empty registry. + pub fn new() -> Self { + Self::default() + } + + /// Shares or refreshes a regular-site tab. + /// + /// Calling this for an already-shared tab updates its metadata without + /// changing the generation, so active runs remain valid. + pub fn share( + &mut self, + id: TabId, + window_id: u64, + url: impl Into, + title: impl Into, + ) -> Result<&SharedTab, TabRegistryError> { + let url = url.into(); + if !is_supported_url(&url) { + return Err(TabRegistryError::UnsupportedPage); + } + let generation = self + .shared + .get(&id) + .map(|tab| tab.generation) + .unwrap_or_else(|| { + let next = self.generations.get(&id).copied().unwrap_or(0) + 1; + self.generations.insert(id, next); + next + }); + self.shared.insert( + id, + SharedTab { + id, + window_id, + url, + title: title.into(), + generation, + }, + ); + Ok(self.shared.get(&id).expect("shared tab was just inserted")) + } + + /// Revokes access immediately and returns affected workflow run ids. + pub fn revoke(&mut self, tab_id: TabId) -> Vec { + self.shared.remove(&tab_id); + self.bindings + .values() + .filter(|binding| binding.tab_id == tab_id) + .map(|binding| binding.run_id.clone()) + .collect::>() + } + + /// Binds a newly started run to one explicitly shared tab. + pub fn bind_run( + &mut self, + run_id: impl Into, + tab_id: TabId, + ) -> Result { + let run_id = run_id.into(); + if let Some(existing) = self.bindings.get(&run_id) { + if existing.tab_id == tab_id { + return Ok(existing.clone()); + } + return Err(TabRegistryError::RunAlreadyBound); + } + let tab = self + .shared + .get(&tab_id) + .ok_or(TabRegistryError::TabNotShared)?; + let binding = RunBinding { + run_id: run_id.clone(), + tab_id, + tab_generation: tab.generation, + }; + self.bindings.insert(run_id, binding.clone()); + Ok(binding) + } + + /// Removes a completed or cancelled run's tab binding. + pub fn unbind_run(&mut self, run_id: &str) -> Option { + self.bindings.remove(run_id) + } + + /// Authorizes an action only when it targets its run's still-shared tab. + pub fn authorize( + &self, + run_id: &str, + requested_tab: TabId, + ) -> Result<&SharedTab, TabRegistryError> { + let binding = self + .bindings + .get(run_id) + .ok_or(TabRegistryError::TabNotShared)?; + if binding.tab_id != requested_tab { + return Err(TabRegistryError::RunTabMismatch); + } + let tab = self + .shared + .get(&binding.tab_id) + .ok_or(TabRegistryError::TabRevoked)?; + if tab.generation != binding.tab_generation { + return Err(TabRegistryError::TabRevoked); + } + Ok(tab) + } + + /// Returns the tab bound to a run, if one is active. + pub fn binding(&self, run_id: &str) -> Option<&RunBinding> { + self.bindings.get(run_id) + } + + /// Lists only tabs currently shared by the user in deterministic order. + pub fn list(&self) -> Vec<&SharedTab> { + let mut tabs = self.shared.values().collect::>(); + tabs.sort_by_key(|tab| tab.id); + tabs + } +} + +fn is_supported_url(url: &str) -> bool { + let scheme = url.split_once(':').map(|(scheme, _)| scheme); + matches!(scheme, Some("http" | "https")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binds_only_explicitly_shared_regular_tabs() { + let mut registry = TabRegistry::new(); + assert_eq!( + registry.bind_run("run-1", 7).unwrap_err(), + TabRegistryError::TabNotShared + ); + assert_eq!( + registry + .share(7, 1, "chrome://settings", "Settings") + .unwrap_err(), + TabRegistryError::UnsupportedPage + ); + registry + .share(7, 1, "https://example.test", "Example") + .unwrap(); + registry.bind_run("run-1", 7).unwrap(); + assert_eq!(registry.authorize("run-1", 7).unwrap().id, 7); + } + + #[test] + fn never_falls_back_to_another_shared_tab() { + let mut registry = TabRegistry::new(); + registry.share(7, 1, "https://one.test", "One").unwrap(); + registry.share(8, 1, "https://two.test", "Two").unwrap(); + registry.bind_run("run-1", 7).unwrap(); + assert_eq!( + registry.authorize("run-1", 8).unwrap_err(), + TabRegistryError::RunTabMismatch + ); + } + + #[test] + fn revocation_invalidates_runs_and_new_share_gets_new_generation() { + let mut registry = TabRegistry::new(); + let first_generation = registry + .share(7, 1, "https://example.test", "Example") + .unwrap() + .generation; + registry.bind_run("run-1", 7).unwrap(); + assert_eq!(registry.revoke(7), vec!["run-1"]); + let second_generation = registry + .share(7, 1, "https://example.test", "Example") + .unwrap() + .generation; + assert!(second_generation > first_generation); + assert_eq!( + registry.authorize("run-1", 7).unwrap_err(), + TabRegistryError::TabRevoked + ); + } +} diff --git a/src/engine.rs b/src/engine.rs index e494329..fce276d 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -514,6 +514,21 @@ pub async fn run_cancellable( token: CancellationToken, ) -> Result { let observer = Arc::new(crate::observability::NoopObserver) as Arc; + run_cancellable_with_observer(workflow, input, capabilities, token, &observer).await +} + +/// Like [`run_cancellable`], while also reporting lifecycle and step records to +/// `observer` as they settle. +/// +/// # Errors +/// Same as [`run_cancellable`]. +pub async fn run_cancellable_with_observer( + workflow: &CompiledWorkflow, + input: Value, + capabilities: &Capabilities, + token: CancellationToken, + observer: &Arc, +) -> Result { let checkpointer: Arc> = Arc::new(InMemoryCheckpointer::::default()); let thread_id = default_thread_id(workflow)?; @@ -521,7 +536,7 @@ pub async fn run_cancellable( workflow, input, capabilities, - &observer, + observer, checkpointer, thread_id, None, @@ -907,6 +922,7 @@ fn build_graph( // is observed promptly instead of after the whole delay elapses. const BACKOFF_POLL_MS: u64 = 25; + observer.on_step_start(&node.id); let mut output = None; let mut last_err: Option = None; let started = Instant::now(); diff --git a/src/lib.rs b/src/lib.rs index ac313bf..2a5976c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,8 +19,12 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +/// Browser automation protocol, action validation, and tool routing. +pub mod browser; pub mod caps; pub mod catalog; +/// Native companion pairing, tab authorization, relay, and control lifecycle. +pub mod companion; pub mod compiler; pub mod data; pub mod engine; diff --git a/src/main.rs b/src/main.rs index ff05322..dc17e21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,343 @@ -fn main() { - println!("{}", tinyflows::product_name()); +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinyflows::caps::{ + CodeLanguage, CodeRunner, HttpClient, LlmProvider, StateStore, ToolInvoker, WorkflowResolver, +}; +use tinyflows::companion::{CompanionServer, CompanionServerConfig, RelayPolicy, SecretStore}; +use tinyflows::error::{EngineError, Result as EngineResult}; +use tinyflows::model::WorkflowGraph; + +#[tokio::main] +async fn main() { + let arguments = std::env::args().skip(1).collect::>(); + if arguments.is_empty() { + println!("{}", tinyflows::product_name()); + return; + } + if let Err(error) = dispatch(&arguments).await { + eprintln!("tinyflows: {error}"); + std::process::exit(2); + } +} + +async fn dispatch(arguments: &[String]) -> Result<(), String> { + match arguments + .iter() + .map(String::as_str) + .collect::>() + .as_slice() + { + ["extension", "path"] => { + println!("{}", extension_path()?.display()); + Ok(()) + } + ["pair", rest @ ..] => pair(rest), + ["companion", "start", rest @ ..] => start_companion(rest).await, + ["tabs", rest @ ..] => native_get("tabs", rest).await, + ["workflows", rest @ ..] => native_get("workflows", rest).await, + ["run", workflow_id, rest @ ..] => native_run(workflow_id, rest).await, + ["help"] | ["--help"] | ["-h"] => { + print_help(); + Ok(()) + } + _ => Err("unknown command; run `tinyflows help`".into()), + } +} + +fn pair(arguments: &[&str]) -> Result<(), String> { + let state_dir = option_path(arguments, "--state-dir")?.unwrap_or_else(default_state_dir); + let port = option_u16(arguments, "--port")?.unwrap_or(32189); + let store = SecretStore::new(secret_path(&state_dir)); + let secret = if arguments.contains(&"--rotate") { + store.rotate() + } else { + store.load_or_create() + } + .map_err(|error| error.to_string())?; + println!("relay_url=ws://127.0.0.1:{port}/v1/extension"); + println!("pairing_token={}", secret.expose()); + Ok(()) +} + +async fn start_companion(arguments: &[&str]) -> Result<(), String> { + let extension_id = required_option(arguments, "--extension-id")?; + let state_dir = option_path(arguments, "--state-dir")?.unwrap_or_else(default_state_dir); + let workflows_dir = + option_path(arguments, "--workflows-dir")?.unwrap_or_else(|| state_dir.join("workflows")); + let port = option_u16(arguments, "--port")?.unwrap_or(32189); + std::fs::create_dir_all(&workflows_dir).map_err(|error| error.to_string())?; + let secret = SecretStore::new(secret_path(&state_dir)) + .load_or_create() + .map_err(|error| error.to_string())?; + let server = CompanionServer::new(CompanionServerConfig { + policy: RelayPolicy::loopback(port), + extension_id: extension_id.to_owned(), + pairing_secret: secret, + workflows_dir, + capabilities: standalone_capabilities(), + }) + .map_err(|error| error.to_string())?; + eprintln!("TinyFlows companion listening on {}", server.bind_addr()); + server.serve().await.map_err(|error| error.to_string()) +} + +async fn native_get(resource: &str, arguments: &[&str]) -> Result<(), String> { + let (url, secret) = native_connection(arguments)?; + let response = native_client()? + .get(format!("{url}/v1/native/{resource}")) + .bearer_auth(secret.expose()) + .send() + .await + .map_err(|error| error.to_string())?; + print_response(response).await +} + +async fn native_run(workflow_id: &str, arguments: &[&str]) -> Result<(), String> { + let tab_id = required_option(arguments, "--tab")? + .parse::() + .map_err(|_| "--tab must be a non-negative integer".to_string())?; + let input = option(arguments, "--input")? + .map(serde_json::from_str) + .transpose() + .map_err(|error| format!("invalid --input JSON: {error}"))? + .unwrap_or(Value::Null); + let (url, secret) = native_connection(arguments)?; + let response = native_client()? + .post(format!("{url}/v1/native/runs")) + .bearer_auth(secret.expose()) + .json(&json!({"workflow_id":workflow_id,"tab_id":tab_id,"input":input})) + .send() + .await + .map_err(|error| error.to_string())?; + print_response(response).await +} + +fn native_client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| error.to_string()) +} + +fn native_connection( + arguments: &[&str], +) -> Result<(String, tinyflows::companion::PairingSecret), String> { + let state_dir = option_path(arguments, "--state-dir")?.unwrap_or_else(default_state_dir); + let port = option_u16(arguments, "--port")?.unwrap_or(32189); + let secret = SecretStore::new(secret_path(&state_dir)) + .load() + .map_err(|error| error.to_string())?; + Ok((format!("http://127.0.0.1:{port}"), secret)) +} + +async fn print_response(response: reqwest::Response) -> Result<(), String> { + let status = response.status(); + let text = response.text().await.map_err(|error| error.to_string())?; + if !status.is_success() { + return Err(format!("companion returned {status}: {text}")); + } + let value: Value = serde_json::from_str(&text).map_err(|error| error.to_string())?; + println!( + "{}", + serde_json::to_string_pretty(&value).map_err(|error| error.to_string())? + ); + Ok(()) +} + +fn option<'a>(arguments: &'a [&str], name: &str) -> Result, String> { + let Some(index) = arguments.iter().position(|argument| *argument == name) else { + return Ok(None); + }; + arguments + .get(index + 1) + .copied() + .map(Some) + .ok_or_else(|| format!("{name} requires a value")) +} + +fn required_option<'a>(arguments: &'a [&str], name: &str) -> Result<&'a str, String> { + option(arguments, name)?.ok_or_else(|| format!("missing required {name}")) +} + +fn option_path(arguments: &[&str], name: &str) -> Result, String> { + option(arguments, name).map(|value| value.map(PathBuf::from)) +} + +fn option_u16(arguments: &[&str], name: &str) -> Result, String> { + option(arguments, name)? + .map(|value| { + value + .parse::() + .map_err(|_| format!("{name} must be a valid port")) + }) + .transpose() +} + +const EXTENSION_FILES: &[(&str, &[u8])] = &[ + ( + "background.js", + include_bytes!("../extension/dist/background.js"), + ), + ( + "manifest.json", + include_bytes!("../extension/dist/manifest.json"), + ), + ("popup.html", include_bytes!("../extension/dist/popup.html")), + ("popup.js", include_bytes!("../extension/dist/popup.js")), + ( + "sidepanel.html", + include_bytes!("../extension/dist/sidepanel.html"), + ), + ( + "sidepanel.js", + include_bytes!("../extension/dist/sidepanel.js"), + ), + ("ui.css", include_bytes!("../extension/dist/ui.css")), +]; + +fn extension_path() -> Result { + let directory = default_state_dir() + .join("extension") + .join(env!("CARGO_PKG_VERSION")); + std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?; + for (name, contents) in EXTENSION_FILES { + let destination = directory.join(name); + let current = std::fs::read(&destination).ok(); + if current.as_deref() != Some(*contents) { + std::fs::write(&destination, contents).map_err(|error| error.to_string())?; + } + } + Ok(directory) +} + +fn default_state_dir() -> PathBuf { + if let Some(value) = std::env::var_os("TINYFLOWS_HOME") { + return PathBuf::from(value); + } + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".tinyflows") +} + +fn secret_path(state_dir: &Path) -> PathBuf { + state_dir.join("credentials/chrome-extension-relay.secret") +} + +fn print_help() { + println!( + "tinyflows commands:\n extension path\n pair [--rotate] [--port N] [--state-dir PATH]\n companion start --extension-id ID [--workflows-dir PATH] [--port N]\n tabs [--port N]\n workflows [--port N]\n run WORKFLOW_ID --tab TAB_ID [--input JSON] [--port N]" + ); +} + +fn unavailable(capability: &str) -> EngineError { + EngineError::Capability(format!( + "{capability} is not configured in the standalone companion; embed CompanionServer with host capabilities" + )) +} + +struct NoLlm; +#[async_trait] +impl LlmProvider for NoLlm { + async fn complete(&self, _request: Value, _conn: Option<&str>) -> EngineResult { + Err(unavailable("llm")) + } +} + +struct NoTools; +#[async_trait] +impl ToolInvoker for NoTools { + async fn invoke(&self, slug: &str, _args: Value, _conn: Option<&str>) -> EngineResult { + Err(unavailable(&format!("integration tool `{slug}`"))) + } +} + +struct NoHttp; +#[async_trait] +impl HttpClient for NoHttp { + async fn request(&self, _request: Value, _conn: Option<&str>) -> EngineResult { + Err(unavailable("http client")) + } +} + +struct NoCode; +#[async_trait] +impl CodeRunner for NoCode { + async fn run( + &self, + _language: CodeLanguage, + _source: &str, + _input: Value, + ) -> EngineResult { + Err(unavailable("code runner")) + } +} + +#[derive(Default)] +struct MemoryState(Mutex>); +#[async_trait] +impl StateStore for MemoryState { + async fn load(&self, key: &str) -> EngineResult> { + Ok(self + .0 + .lock() + .map_err(|_| state_lock_error())? + .get(key) + .cloned()) + } + async fn store(&self, key: &str, value: Value) -> EngineResult<()> { + self.0 + .lock() + .map_err(|_| state_lock_error())? + .insert(key.to_owned(), value); + Ok(()) + } +} + +fn state_lock_error() -> EngineError { + EngineError::Capability("standalone companion state store lock poisoned".into()) +} + +struct NoResolver; +#[async_trait] +impl WorkflowResolver for NoResolver { + async fn resolve(&self, _workflow_id: &str) -> EngineResult { + Err(unavailable("workflow resolver")) + } +} + +fn standalone_capabilities() -> tinyflows::caps::Capabilities { + tinyflows::caps::Capabilities { + llm: Arc::new(NoLlm), + tools: Arc::new(NoTools), + http: Arc::new(NoHttp), + code: Arc::new(NoCode), + state: Arc::new(MemoryState::default()), + resolver: Arc::new(NoResolver), + agent: None, + } +} + +#[cfg(test)] +mod tests { + use std::panic::{AssertUnwindSafe, catch_unwind}; + + use super::*; + + #[tokio::test] + async fn poisoned_memory_state_is_an_error_not_a_missing_key() { + let state = MemoryState::default(); + let _ = catch_unwind(AssertUnwindSafe(|| { + let _guard = state.0.lock().unwrap(); + panic!("poison state lock"); + })); + + assert!(state.load("missing").await.is_err()); + assert!(state.store("key", Value::Null).await.is_err()); + } } diff --git a/src/observability.rs b/src/observability.rs index 8b40a85..9b85ccf 100644 --- a/src/observability.rs +++ b/src/observability.rs @@ -110,6 +110,12 @@ pub trait RunObserver: Send + Sync { let _ = run_id; } + /// Called once per non-trigger node activation, immediately before its + /// first execution attempt. + fn on_step_start(&self, node_id: &str) { + let _ = node_id; + } + /// Called once per non-trigger node activation, as each step finishes. fn on_step_finish(&self, step: &ExecutionStep) { let _ = step; @@ -138,6 +144,7 @@ mod tests { fn noop_observer_callbacks_are_inert() { let observer = NoopObserver; observer.on_run_start("run-0"); + observer.on_step_start("n"); observer.on_step_finish(&ExecutionStep { node_id: "n".to_string(), status: StepStatus::Success, diff --git a/tests/browser_routing_e2e.rs b/tests/browser_routing_e2e.rs new file mode 100644 index 0000000..107238d --- /dev/null +++ b/tests/browser_routing_e2e.rs @@ -0,0 +1,131 @@ +//! Mixed browser and integration routing through ordinary `tool_call` nodes. + +#![cfg(feature = "mock")] + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinyflows::browser::{ + BROWSER_PROTOCOL_VERSION, BrowserError, BrowserRelay, BrowserRequest, BrowserResponse, + BrowserResult, ChromeToolInvoker, RoutingToolInvoker, +}; +use tinyflows::caps::{ToolInvoker, mock::mock_capabilities}; +use tinyflows::compiler::compile; +use tinyflows::engine::run; +use tinyflows::error::Result; +use tinyflows::model::{Edge, Node, NodeKind, WorkflowGraph}; + +#[derive(Default)] +struct Relay { + requests: Mutex>, +} + +#[async_trait] +impl BrowserRelay for Relay { + async fn execute( + &self, + request: BrowserRequest, + ) -> std::result::Result { + self.requests.lock().unwrap().push(request.clone()); + Ok(BrowserResponse::Ok { + protocol_version: BROWSER_PROTOCOL_VERSION, + request_id: request.request_id, + result: BrowserResult { + data: json!({"title": "TinyFlows Shop"}), + }, + }) + } +} + +#[derive(Default)] +struct Integrations { + calls: Mutex)>>, +} + +#[async_trait] +impl ToolInvoker for Integrations { + async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result { + self.calls + .lock() + .unwrap() + .push((slug.to_owned(), args.clone(), conn.map(str::to_owned))); + Ok(json!({"sent": true, "slug": slug})) + } +} + +fn node(id: &str, kind: NodeKind, config: Value) -> Node { + Node { + id: id.into(), + kind, + type_version: 1, + name: id.into(), + config, + ports: vec![], + position: None, + } +} + +#[tokio::test] +async fn mixed_workflow_routes_browser_and_host_integration_without_ambiguity() { + let graph = WorkflowGraph { + name: "browser then email".into(), + nodes: vec![ + node("start", NodeKind::Trigger, Value::Null), + node( + "title", + NodeKind::ToolCall, + json!({"slug":"browser", "args":{"action":"get_title"}}), + ), + node( + "email", + NodeKind::ToolCall, + json!({ + "slug":"gmail.send", + "connection_ref":"composio:gmail:work", + "args":{"subject":"Browser run complete"} + }), + ), + ], + edges: vec![ + Edge { + from_node: "start".into(), + from_port: "main".into(), + to_node: "title".into(), + to_port: "main".into(), + }, + Edge { + from_node: "title".into(), + from_port: "main".into(), + to_node: "email".into(), + to_port: "main".into(), + }, + ], + ..Default::default() + }; + + let relay = Arc::new(Relay::default()); + let integrations = Arc::new(Integrations::default()); + let browser = Arc::new(ChromeToolInvoker::new(relay.clone(), "run-mixed", 42)); + let mut caps = mock_capabilities(); + caps.tools = Arc::new(RoutingToolInvoker::new(browser, integrations.clone())); + + let outcome = run(&compile(&graph).unwrap(), Value::Null, &caps) + .await + .unwrap(); + + assert_eq!(relay.requests.lock().unwrap()[0].tab_id, 42); + assert_eq!(relay.requests.lock().unwrap()[0].run_id, "run-mixed"); + assert_eq!( + integrations.calls.lock().unwrap().as_slice(), + &[( + "gmail.send".into(), + json!({"subject":"Browser run complete"}), + Some("composio:gmail:work".into()), + ),] + ); + assert_eq!( + outcome.output["nodes"]["email"]["items"][0]["json"]["json"]["sent"], + true + ); +} diff --git a/tests/cli_e2e.rs b/tests/cli_e2e.rs index 5a6ae07..6bc0eea 100644 --- a/tests/cli_e2e.rs +++ b/tests/cli_e2e.rs @@ -1,6 +1,8 @@ //! CLI smoke tests for the tinyflows binary. +use std::path::PathBuf; use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn binary_prints_product_name() { @@ -22,3 +24,50 @@ fn binary_prints_product_name() { String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn extension_path_and_pairing_commands_are_scriptable() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let state = std::env::temp_dir().join(format!("tinyflows-cli-pair-{unique}")); + let path = Command::new(env!("CARGO_BIN_EXE_tinyflows")) + .args(["extension", "path"]) + .env("TINYFLOWS_HOME", &state) + .output() + .expect("print extension path"); + assert!(path.status.success()); + let unpacked = PathBuf::from(String::from_utf8(path.stdout).expect("utf8 path").trim()); + assert_eq!( + unpacked, + state.join("extension").join(env!("CARGO_PKG_VERSION")) + ); + assert!(unpacked.join("manifest.json").is_file()); + assert!(unpacked.join("background.js").is_file()); + + let paired = Command::new(env!("CARGO_BIN_EXE_tinyflows")) + .args(["pair", "--state-dir"]) + .arg(&state) + .output() + .expect("create pairing token"); + assert!(paired.status.success(), "{paired:?}"); + let stdout = String::from_utf8(paired.stdout).expect("utf8 output"); + assert!(stdout.contains("relay_url=ws://127.0.0.1:32189/v1/extension")); + let token = stdout + .lines() + .find_map(|line| line.strip_prefix("pairing_token=")) + .expect("pairing token"); + assert_eq!(token.len(), 64); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let secret = state.join("credentials/chrome-extension-relay.secret"); + assert_eq!( + std::fs::metadata(secret).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + std::fs::remove_dir_all(state).expect("clean temporary state"); +}