From 4d6b87458b14f32b6905afd5c3ce662a252831f0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 17:05:18 +0300 Subject: [PATCH] refactor: remove Chrome workflow companion Co-authored-by: Medulla --- .github/workflows/ci.yml | 48 - CHANGELOG.md | 9 + Cargo.lock | 110 - Cargo.toml | 13 +- README.md | 20 +- docs/chrome-extension.md | 99 - extension/.gitignore | 8 - extension/README.md | 34 - .../tinyflows-chrome-extension-0.1.0.zip | Bin 13424 -> 0 bytes ...inyflows-chrome-extension-0.1.0.zip.sha256 | 1 - extension/dist/background.js | 800 --- extension/dist/manifest.json | 18 - extension/dist/popup.html | 21 - extension/dist/popup.js | 45 - extension/dist/sidepanel.html | 13 - extension/dist/sidepanel.js | 78 - extension/dist/ui.css | 21 - extension/eslint.config.js | 23 - extension/manifest.json | 18 - extension/package-lock.json | 4396 ----------------- extension/package.json | 30 - extension/playwright.config.ts | 9 - extension/scripts/build.mjs | 25 - extension/scripts/package.mjs | 30 - extension/src/background.ts | 141 - extension/src/cdp.ts | 196 - extension/src/errors.ts | 28 - extension/src/popup.html | 21 - extension/src/popup.ts | 41 - extension/src/protocol.ts | 175 - extension/src/relay.ts | 166 - extension/src/sidepanel.html | 13 - extension/src/sidepanel.ts | 61 - extension/src/tab-manager.ts | 152 - extension/src/ui.css | 21 - extension/tests/cdp.test.ts | 108 - extension/tests/e2e/extension.spec.ts | 152 - extension/tests/errors.test.ts | 16 - extension/tests/protocol.test.ts | 64 - extension/tests/relay.test.ts | 78 - extension/tests/tab-manager.test.ts | 77 - extension/tsconfig.json | 14 - extension/vitest.config.ts | 15 - protocol/browser-v1.schema.json | 111 - protocol/fixtures/browser-cancel.v1.json | 5 - protocol/fixtures/browser-request.v1.json | 12 - protocol/fixtures/browser-response.v1.json | 10 - protocol/fixtures/tab-shared.v1.json | 10 - src/browser/mod.rs | 14 - src/browser/protocol.rs | 419 -- src/browser/protocol_tests.rs | 154 - src/browser/routing.rs | 219 - src/browser/routing_tests.rs | 189 - src/companion/auth.rs | 284 -- src/companion/auth_tests.rs | 108 - src/companion/control.rs | 290 -- src/companion/control_tests.rs | 43 - src/companion/host.rs | 39 - src/companion/mod.rs | 23 - src/companion/relay.rs | 404 -- src/companion/relay_tests.rs | 162 - src/companion/server.rs | 231 - src/companion/server/api.rs | 325 -- src/companion/server/handlers.rs | 477 -- src/companion/server_tests.rs | 47 - src/companion/tabs.rs | 210 - src/companion/tabs_tests.rs | 53 - src/lib.rs | 6 - src/main.rs | 337 -- src/testkit/tools/error.rs | 6 +- tests/browser_routing_e2e.rs | 131 - tests/cli_e2e.rs | 75 - 72 files changed, 13 insertions(+), 11789 deletions(-) delete mode 100644 docs/chrome-extension.md delete mode 100644 extension/.gitignore delete mode 100644 extension/README.md delete mode 100644 extension/artifacts/tinyflows-chrome-extension-0.1.0.zip delete mode 100644 extension/artifacts/tinyflows-chrome-extension-0.1.0.zip.sha256 delete mode 100644 extension/dist/background.js delete mode 100644 extension/dist/manifest.json delete mode 100644 extension/dist/popup.html delete mode 100644 extension/dist/popup.js delete mode 100644 extension/dist/sidepanel.html delete mode 100644 extension/dist/sidepanel.js delete mode 100644 extension/dist/ui.css delete mode 100644 extension/eslint.config.js delete mode 100644 extension/manifest.json delete mode 100644 extension/package-lock.json delete mode 100644 extension/package.json delete mode 100644 extension/playwright.config.ts delete mode 100644 extension/scripts/build.mjs delete mode 100644 extension/scripts/package.mjs delete mode 100644 extension/src/background.ts delete mode 100644 extension/src/cdp.ts delete mode 100644 extension/src/errors.ts delete mode 100644 extension/src/popup.html delete mode 100644 extension/src/popup.ts delete mode 100644 extension/src/protocol.ts delete mode 100644 extension/src/relay.ts delete mode 100644 extension/src/sidepanel.html delete mode 100644 extension/src/sidepanel.ts delete mode 100644 extension/src/tab-manager.ts delete mode 100644 extension/src/ui.css delete mode 100644 extension/tests/cdp.test.ts delete mode 100644 extension/tests/e2e/extension.spec.ts delete mode 100644 extension/tests/errors.test.ts delete mode 100644 extension/tests/protocol.test.ts delete mode 100644 extension/tests/relay.test.ts delete mode 100644 extension/tests/tab-manager.test.ts delete mode 100644 extension/tsconfig.json delete mode 100644 extension/vitest.config.ts delete mode 100644 protocol/browser-v1.schema.json delete mode 100644 protocol/fixtures/browser-cancel.v1.json delete mode 100644 protocol/fixtures/browser-request.v1.json delete mode 100644 protocol/fixtures/browser-response.v1.json delete mode 100644 protocol/fixtures/tab-shared.v1.json delete mode 100644 src/browser/mod.rs delete mode 100644 src/browser/protocol.rs delete mode 100644 src/browser/protocol_tests.rs delete mode 100644 src/browser/routing.rs delete mode 100644 src/browser/routing_tests.rs delete mode 100644 src/companion/auth.rs delete mode 100644 src/companion/auth_tests.rs delete mode 100644 src/companion/control.rs delete mode 100644 src/companion/control_tests.rs delete mode 100644 src/companion/host.rs delete mode 100644 src/companion/mod.rs delete mode 100644 src/companion/relay.rs delete mode 100644 src/companion/relay_tests.rs delete mode 100644 src/companion/server.rs delete mode 100644 src/companion/server/api.rs delete mode 100644 src/companion/server/handlers.rs delete mode 100644 src/companion/server_tests.rs delete mode 100644 src/companion/tabs.rs delete mode 100644 src/companion/tabs_tests.rs delete mode 100644 src/main.rs delete mode 100644 tests/browser_routing_e2e.rs delete mode 100644 tests/cli_e2e.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04070c1a..83f6fa01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,51 +65,3 @@ jobs: --ignore-filename-regex '(/rustc/|/target/|/.cargo/(registry|git)/|/.rustup/toolchains/|/(tests|examples|benches)/)' --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@v7 - 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@v7 - with: - name: tinyflows-chrome-extension - path: | - extension/artifacts/*.zip - extension/artifacts/*.sha256 - - - name: Upload Playwright traces on failure - if: failure() - uses: actions/upload-artifact@v7 - with: - name: chrome-extension-playwright-traces - path: extension/test-results/ - if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index fccb65b3..f9b52976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 needs it and should not have to pull in a process runner and an HTTP client to get it. Still re-exported from `caps::host::mocks`. +### Removed + +- **Breaking: the Chrome workflow companion has been removed.** The + `chrome-extension` feature, `tinyflows::browser` and `tinyflows::companion` + modules, companion CLI, MV3 extension package, browser protocol fixtures, and + their dedicated CI job no longer ship. This also removes the crate's `axum` + dependency. Browser automation remains a host concern that can be exposed + through the existing `ToolInvoker` capability. + ### Fixed - A node that failed once and then **succeeded on retry** is no longer reported diff --git a/Cargo.lock b/Cargo.lock index 23599b4f..fc62a8ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,57 +67,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "base64", - "bytes", - "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", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", -] - -[[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", -] - [[package]] name = "base64" version = "0.22.1" @@ -907,12 +856,6 @@ 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 = "hyper" version = "1.10.1" @@ -926,7 +869,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1339,12 +1281,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "md-5" version = "0.10.6" @@ -1361,12 +1297,6 @@ 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 = "miniz_oxide" version = "0.8.9" @@ -2177,17 +2107,6 @@ 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_with" version = "3.17.0" @@ -2471,7 +2390,6 @@ name = "tinyflows" version = "0.8.0" dependencies = [ "async-trait", - "axum", "font8x8", "fs2", "futures-timer", @@ -2568,18 +2486,6 @@ 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.19" @@ -2664,22 +2570,6 @@ 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 0.9.4", - "sha1", - "thiserror", -] - [[package]] name = "typed-arena" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index fd4e814a..76762268 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ repository = "https://github.com/tinyhumansai/tinyflows" homepage = "https://github.com/tinyhumansai/tinyflows" documentation = "https://docs.rs/tinyflows" keywords = ["automation", "workflow", "orchestration", "pipeline"] -categories = ["command-line-utilities", "development-tools"] +categories = ["development-tools"] # Anchored with a leading `/` so a bare name like `README.md` doesn't also match # nested files (e.g. the gitignored `local/**/README.md`) and leak them into the # published package. @@ -26,14 +26,8 @@ include = [ "/Cargo.toml", "/README.md", "/LICENSE", - "/extension/dist/**", ] -[[bin]] -name = "tinyflows" -path = "src/main.rs" -required-features = ["chrome-extension"] - [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -44,7 +38,6 @@ futures-util = { version = "0.3", default-features = false, features = ["async-a getrandom = "0.4" tracing = { version = "0.1", default-features = false, features = ["std"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } -axum = { version = "0.8", default-features = false, features = ["http1", "json", "ws"], optional = true } reqwest = { version = "0.13", default-features = false, features = ["json"], optional = true } # `caps::host` only: hashing an author-supplied state key (and namespace) into # one safe path component, and staging a script in a temporary directory. @@ -67,10 +60,6 @@ jaq-json = { version = "2.0.1", features = ["serde"] } # and examples. Mocks are always available inside this crate's own tests. default = [] mock = [] -# Off by default; enables the Chrome browser protocol, native HTTP/WebSocket -# companion, and the `tinyflows` companion CLI. The core engine has no listener -# or HTTP client unless a host selects this feature (or `host-caps` below). -chrome-extension = ["dep:axum", "dep:reqwest", "tokio/net"] # Off by default; enables `caps::host` — ready-made capability implementations # for a host that runs scripts as child processes of itself and keeps state on # its own disk. Off by default because a host with a sandbox wants none of it, diff --git a/README.md b/README.md index 983853b2..8c3b090a 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,6 @@ 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 (behind the `chrome-extension` Cargo - feature): 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 ```text @@ -397,22 +392,9 @@ Install Rust 1.85 or newer with [rustup](https://rustup.rs/), then: ```sh cargo build cargo test # unit + compiler + engine tests (mocks auto-available) -cargo test --all-features # also exercises optional host and Chrome support -``` - -The Chrome extension is a separate local package: - -```sh -cd extension -npm ci -npm run verify -npm run test:e2e -npm run package +cargo test --all-features # also exercises optional host support ``` -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 deleted file mode 100644 index 6038d6c9..00000000 --- a/docs/chrome-extension.md +++ /dev/null @@ -1,99 +0,0 @@ -# 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. - -The Rust protocol, relay, and companion CLI are behind the `chrome-extension` -Cargo feature. They are absent from the default engine-only build. - -Install the companion CLI from crates.io with: - -```bash -cargo install tinyflows --features chrome-extension -``` - -## Install, build, and pair - -After `cargo install`, the `tinyflows` binary is on your `PATH`. It materializes -its embedded, versioned extension into a local directory and drives the -companion, so nothing below needs a source checkout: - -```bash -tinyflows 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 -tinyflows pair -tinyflows companion start \ - --extension-id <32-character-extension-id> \ - --workflows-dir "$PWD/workflows" -``` - -To rebuild and test the extension from a source checkout instead, build the -package first and then compile the companion from that checkout so the fresh -`extension/dist` output is embedded: - -```bash -cd extension -npm ci -npm run verify -cd .. -cargo run --features chrome-extension -- extension path -``` - -`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 deleted file mode 100644 index d9d6cd17..00000000 --- a/extension/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 4a5e8448..00000000 --- a/extension/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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 deleted file mode 100644 index a960e778e79e33b24ba72c7cbab1b3985c56782e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13424 zcmaL8V~}Uh(k=XJ+qP}n?w+=7+tapfP4~2I+s3qQ+xDGv-uH?7KR4n$wLk2r54Cnh zWaio{v$7OqKtNFekdTl7045ROp9Aji#K_Rt+T6+B)y{<8%6XCR-D`a~q36BE-{Vz_ zG0$*JZX*?X-x-%;kuCO9%8B6kj5J;+f>RLyhLsl7HvzFa;AQD&lg zsHROw=_cP6rk4x3l$BHSg%K2$PpER|&{+7U{-IecL043v!zhn-?fSy+sxa3~Ap~5& ze1*3l$9~d{-#;Knc_Q!Qr`8ZuKI0cIQvVh;pYT>P7sFo+sYIVohfvIpeJ*(@@vIxg zoW7vl+oKF3Dt;A3Gsjnb3!kc=Bq?t)4>yDaP_kvRZ00x%8BxO*475+)IbX#>24b*G#w4ydD>v^I*!kw}i+w#k zpq)CJPxrl)h5P(7p~ymAhKfX@;qR}3?d%TN-fPOxaA3G)TZR) zs=saAaO~k3xRHMTF7W96;h^x(-Qp^4JLlLu!YWt7*Fn@d!JHOZ@3*5LoP5o^XKJz~ zKOOzPuM&oqFt5*&e7Og^;%(B`+rneNlc0~}u?zqutdnP^5nr_A_}=K(pSaxqcZTZk z{hKKUDY4v!2GvQ8r{xh64Z*WUCg?DRx%75eFKKE8M24sd89_ew?58c@P2^2%sGqX; zjaTmt`@Iy64{?HM z&t(v1I`zFOwGU78Gtpe_G1=E+Ga1NH;xkt{v&yJqd0TJ~o#~90=0)7rK{^AlTTra*-j9xo#MzM>l8Mq& zXlEh5GpcIY-lP7x?xYEJ9X@{Mn*3eL@+-BgaHAJaXiti={&-Gi4WY@j=qt6vvtDY+ zX_kD^o>3pTE61>?ySw$gG89&rd6)pWczS@)W0w}%>%(+LyRaoEtMC(SdK@T;7JecwF z281^ty7AYtJ62fndES0~KwmW#Tw9i#A*4u#PGjv!{^%XINqEc4ogD70ZPd~ zrfssi$}U|sKXVk+kWizSgdl{!=^E4+H8OF0=AF@n5>>-q5cRi!;w>8?t@u{Cu%!Y= z%m;1Ov^Kae9@c{pe~?bd(-J-GROVj4q6H2X?{63^T$gzSo6N8wu@Uyn4DOH4&@7uJ-q4T7$ ze*p*9xdE09h0OE)P`z9|Zf)7D4%s?p3`T|CZC^w3W#xYK7USt)5naW;aMiCgP=sCV zZn(SoN-e^WuR^sNen8hxNPnDkRKOd=&B-p>Q>)!*wCqgP0_f~|aIj3;n%s6zmp^GP2s)1t)Z-c1Z72HkebSL|W|g-C2_*&;K0zDj?(PPT zBv3@KzZpQ%MWgtBqRE!IW%db^8`QkWZIElf z0f(}^W7t2T6JHvKBIfpvP%hWd2I<%Q=U1^d%bO9&c$LCt)NyYoVH2}wZ_4IkASyv} zQjZ~JtZl7KEnb-jPT~V^Oz=0a{3z+bizBDs9OBc_Od8=@c%8C8P$!QzCr1`L?bP#q zyP!NwkDyP{`KSHPE9qpzY}!`l?mU%jEyt#A?LiKLcGer=>rS{dog z=k0-U_^cq|?E%VL*&#@|w(JIPlneo+VI%KTOFPD{3Uq5uHfp1?Fl^&$o`P8-k|h+R z8F($H%iDu1bkz#ArgIM`b#qe}td3LA3jxk9cQ_0^!SxxLi2kjoUbuP3ji*Os%DA)m zi(3OOW8mf09%QdG@2D5Zd2^CPFB%3p?f86GWP*FupQSjq1HgCKi zSDfKC-Nv90P~4C3%Exc2(OcxVN~|#))}m{qPVt>?dBziiMP7ekz}BBy&{ta1t8;?F(rMPF31e2?tHMse z2jurW3C-9c!FKGnl zQ$!4PcgBA@gQF5WSX}TK2m%_yYI7(E?~dxMxMO~hvA69kC~AX72l1H7K$)qD+>yz$wl1?}l1_jFd7_S~Y;j+&DWHji>F1LSEkT zOC>%<_yLig6@j1=gJ3@Zgo+p)L5>3Yyc8JEe%|oYH9XAn;#Isl*7*Gb-~@Vr+5m+Q z)g{t=&zI=ce5*2?`Jl~1dn$tD82`XN;UetfS3Q5{3^GZdOfyg#`98_2+Rg6c;PJUx zLT_(4=jJ0YavFGLpd#lQeG2}p1PBx#eYcMQ!dUP`Ux;esFh zXTG-eS1wH`O;lTS3UzaEByfucssu9$$xv|I3U?kXjUBZKe8?{gS_PbQCp^+Txe_F! z^c*1P0JHAOrL@IUe~}z=y0B7_$jmQSDACNBO54YqMCK4EKZfw4cL6iR);B>VOvL1} zVB_l`YB2Xr*qrTCR$7i(TO@=myP@eH_-vQ4D{=}pMx!{+Ou&gcMaj{56WW@>W`IK5 ztS>@MeiH+gk6hDU>pxM1ysi#mBIE0aZT{&si*VxmmKRR-fEq)SLj*noV#s~tuoJ`J zLOfGe8Fn&n7g!e`+!&V?+?WLXvRr%#ohA0kKS&&+v{1XEJ+E++QcN5fhV}?sxLqEvN5@G5J=~lde)ll( zzV7lXuBR{YPOBPleFYS*)rO(rrOBWpn)a=VMLb-uxze$Hp_>Z)Sno>BHI;K09=#c= zb+ugKzM5d*N{Vvdz)olKR;rF6LJ!>-ZsDEP_YHE)dmm02=lZ|E#CvRx(D`pOPw+Zk z2((e`8W(htpTy>82}yofsa?ur4Gr$jA;>^7c)r7U;7h6D6`NZT9DU=e5H1`2Dogc7 z^5KYdv$L{-pvqcLgEVSs+F4h8_~A2kau(qsL^|j1TCVwy+D%%9%uzMGK1F0Z`0DII z8(R0Ma?%IHs>jPsoK|~t?mDgZenN_;^)ipj$)Kzhz4;@Rri;8LBbYyKlzc?d|RKzy&+RHNxwsELfRNi2ztyP?rD1oM@}Mtvn`ImZcm z5T{FkVXN@~*Otp}-|A*(Gbs$%K5E+t6q)hE?Asq}KFgsci==qtwt!0Rh+3e=eAc}) zoe!~-fMH!`(xc$bjWmy!i8$uvb>lv|csnD4n()Zp?N(ft;sgzR)@Q z?Q}lP>cpKM#M&fj2nS}dW&sDopsB=T8VB5dXVVH`l=bI$Y8_7K>ghFTB^O+jgh-H# za)E3M@-g(~vh`fJD!u4Lg7a4FTzFlyA?{_GnlM42F%D;3txp)EEm#{qTt^E(@vBTo zZc(%S*D??%&B${1(G1l&gr~73&gN|1K7R8lXX#h1ZqR0-;Y_WLT0m&qiX5(!cHta4 zIHoNN7;Rq(I=`3xPfGCbGZ2(aFHoOaVP&250ZP2Nr*cXLO03U&;|oESYKC!U-9F*s z9&AhP5fUQ1RbO9kNGlF%m#2aGJ+T}bJ(g$2EKqIPb(KoG2eC)p)RR7DbB5!bWpeEg zjw%_a$`4a0Q4xJV9%vf8eR~Rr6F5Bk(}16MooZ1@@4ZxEi&TDkxhr;+hPoAxY|R#X zRys$1K+QptwXF;9Ed-%6AFxUjC1lw)CtP;PMQ@xda7pdiiiD?fkn zRDE?B(mPqA3I){qPJSnw7KAPkHdkic8wgWP;R!?u>`1#Ay}y4qKAYIK^*)%3 zJlPd@)O?81%D{((DbC>MJKv5G_WD?P-`9#ohX55n?O?trsam5>5rWOEmkM8Y;&WR| zop@3_^e>%eL4v^}>t~r?aXf5e5+*CEqIWTU6w~4Vp#I}R+Z3S9Q#e)1wySPefUy16 z)%fbwy4Ij1TW9@TNCO8vjeBpmO#pMR(7eT|RS&xUu}LcBjk+T?a~fLf;%^@wcrf>T zbJxm;Eha{cyoj7+;co|{iTKO^$o8?*(p9FEj~E3q0(~aOsuRhrMGX$);L8oB2WkCY z#B-~|USc-MK4&cHjqGY^rQyxq_to*cBuSv_4M%Um06~yK<tD zGVh#6ZO`bZ51PTrujkR7Fh@^tMKIS_YH*)k{T@uww9}BcC~N}sBpB|%*s$JBa(S^z zf1J2xIgP#!azarEKxoaA$)!jaNvWTQDYzFkJ3@XaPSO|Ic)y~@G`Vs`y}V1-aKh?c z4plAc2zAvZ4(&85Gz4>cUe6G4mr{#PlPYu#z8}`-``FmJJ4B6~Ts{K<%B@}r%+_J^ z;0_;ZA%|jm8;11_J{?v?oy1zI+*J2jFy@Frm*lC~3wLTqvd;YB)`Vp*%2yO>NDNN^ zEeF9eJjP}E2=*_s((Y^MT=nUM1yG2H_WG0LSoA z54TJjImR6bvrbWC@Iu)Y?s#aXQa@4Pl7>^m>4%Tq|A9JLOy0?R;3^gyTAuZyXpIdE z-CpwXW1}}Bu(Y27)h#5y9w4IQTzZ%^&?|Sm*v)y-CYZbjNL*c;ES2`{uOLuM;zr<{6%0eOnsd6~` z5twE7Y$9t0^+VZSsPY(!2}(%1bcwzW%bRr?w9A>?UgZ$%YMP$?=#kUTvLa)C0KUJW z=kHe!b|x_ncxD+^g&>)ILe2?U3-juL#8XlDC+9J0k^$hN zqRx;{Ts(Ue9PxLH6XBuOce;$>Oc)D&m<|Y=Y$2DkoOx3|neJ9IP?QC6z*%Xl!t2{Z ztM-lOnJxkY)I~CT9W7WhxkIq?RMxDJ7R0bLBAowF`?M*XCZ^TaaQhOP z_u@ds2sOoUykg&J&lWvLn0%zxD4)S05DeYaDiGfWn_gY4HcWf`iiwJpPtVbKUE3%O z4(WWO+&kV(O1FAd(ndl22LGYUWB~}b>@Xg=v;4w`s#*Uf>(5-~Nza3lu^+N&&aSyy zl?;4NC8Zy$l=CzVYfNp*)1`Vyd+2N&i=@(mZ|WNw&$FJ^nT_yBuj1utDI(pgQoJ-S zo+p$qcM~01q(8*YPKaumWSh)#=RokTj@EbBK8;#D?KAK@OF*bQX_Yr_I=Vt@_3;$J;7M8n#WXU$kfGvSydUq#KV?W)!ud zKLx-|f7TrfSzT@00Whzc-FhFI^*Q`ec#84jwVD;OZmP3AI2ub-hR9Dmoe92tJ3t9A zWzbz}dx2Y1VLCwL9;Tv0-y@ZNfJL=n!f(gqZx=k~igB#La9nrmxOW)Ldlpn_|*UYM-Z-aIKn{vk3Z%CyPI@*^IDI*aGA>qNYl zZ~D9qT8d>Xeb?k30(kd0SA)L!cN}+>=4Yy*GEEb#_91JWch#46lY30r0o_+AH1R(a z_mx(p53@yvD{Ok*k+w0979rodnOsses#|pH+R0SnPR9E}CfER>i_f+nd!nefj$`~q zN6NCXiCLMR9Uh3YQq_4m1;IJBr;qI*GTA0RESa4$7)GP^Y)-xy3dt%HO!&Jv9CrqO zvJiKJpwaKMrkO#kbh6=JHz!klQNQ)rj32y#_aI%mU}nEvZjLtBlL|fye(M_^l9Yas zsG+BUs<%_KdQb7Wg5N7Ug4LAhwh_8=oLSv)h-S%7S~X{;mWKF%)JqE>LRtpR3+;>B z$i^3L`!VE*x=d|^3P-1VnavSBq0cTwFzZ|{(c78bYVfkSx=rWnm;cJcWl@ShY# zKJMBzOPx@}rQ#XBg>+o%Brdr|9%JDb{Z z*}

cjrO)+t091y}~~Fh z-neI3!UF$F8*-`>umuN72UgkWp9Wl0Q19LM&IDntGbWM@{j`i}p^-wOvO8E@Hti*C zK$-aL+U81iL{z44)83D189$#R&AMu$*XA)_0cBj2ePnxHWb~95hy~MI3$Q=8LpiYX zx5cysrpHiD9!IY@M^D^6-SAKhjA9*jrhMR($8Zr!>NaNAk@6%ulX+-~w@b7y9zV|d z^JohS#!}byx{YdqX1uV_^jhzaXM?ef8Sw;MiRkFKq7`bN%{-&T2GNwL0ql{*}(e2S)XP6^v)i385?E_Y5;WL%OYbm}wOE z?NLwRk7|QA5i}$E;egCdX&^e6&bZ;6fg*dw+?;l44|@e4oyi6$$@mmAEvm~rqoknb zWvAUqyZ$@7-xDipN!T9I{9N;so$Uz_!V~-GL5wa(9rwj9T%3*S2@Nk$Fn*%KNp?zw zxb=^riHL2+8Mwq5Awq%casn^Hp84T3xX)6t1`GI>y?69zp-9F2OqIvOuuKaL<_J;F ze9dnkk|GwZS)6N3n3JY>Bd3JeD0xb@R05I15&L?e=cFMB?Yfzu*1XeEWIx-|-r~Jm zho{0HBm*<9obX_|qMHUH5zWMU3}PhltA?k~By~ZSz-i=R@e+cpr5mvVOee8b#+`TL z;Ciw}4v)P|7MX_((itUXRW>%D zHoWd-rZdF9o8K?o{a)TGA#W0~)@V4zW4gnQc9^wUvb1J2rbdjg;VFVzh@2js;(r>rmLe)(wOX(_@r@Yq z^q3eEQ?C0>ACV2|gwDTWr4xOglnzfaEj`zgE3wSlvi1=Myn@X zo*9vTaiP>~lu!M^7T+8F@TGPT=N^fC?DAy3bhUY10$rM^oguQrfDsu$he>%4L{mfD zZe(Vo6k9roYF81XAN29nIP;q|93#0Zr{i=hwE{`N5K|Y!6B~%!&u7G#fYxCxCzesC z;10!V`*`Zz6M#galI%Ml+)&!~iR#!ld*9N#j$u-CMi2bzZ@1(?bKrA4a#U9;9{bUM zDz-T`I5QkhoWs6fW<%Zn%IToY;ny*lFrFURR<}`1x=jR=DeVL;F#*E=` z^U396FV+)7m*~O`$B!+JXxLc0jbm*bj&S5E0Z z;FnwFTBGB48y&(xbTrPJ$;-HE6-q>@J{nDjJ5R@9sC9dZZ9RgCz3gbID?CpISDNb4 z@IGjn-Cj}Dcp=H|?fhrWaFf*$qJtkrl`;-!3asDF_R)ss-nIU>pp5!PDi{aLuA2>} znD>{xWRSst;0w8S<#_K+pXQRU)C-fqwveUGYRjMO>uMVhZB-uS_UXD{df3YgI4Zxx zZjGQHFenpQg%gb5Bcy*Uso#Fz7rq6Z96dV85@6w-Avk&x(BpyYAM0#)8n}p$91}r3 z@-IBJ&I>c0eAHAXg6lrMyPbKSmyVhLT6acpF{Qt^XJ&0*neMgY+FH-o4Ppq?BVJDO^{z2H4U6Wrn7k==w|DBk zyd>LFCg)JO=@I`9vmmC!$nuns--T14dNM?uiu&*uG2sVX7F$wsPii&|Hg8_IO5DE0 zt538)w1^2*tR=6*o~J7I6xdcbD+Y0ia}Zn~n-7sg=f0m>c@i1;T%gDsY_nd(N7ChS z#i3nC{E+2d&BpmU8ZkLaDyL386!m!6Rp(wiP3lXOTRNSe3P!7t+n>j*syINAQXM0y!`UqORh4}G||4RYc`lF)$rpiqknx)wplDwkLl_3+`2MKZyuG}@PVGB14rX=Dz?p!j?gG5BNMOtg+Sbes` z$2iQPcQVQ}q5+aTDXu`>UvxjTu$>GE_H=*%KrI#wmf<=|JApJDz~98V0M2q>#_PN? z`n69dc}v7VyijbQEz^8)URh2F#f~SDF`AYzCRH%ncQbJb-hPwPnLW!FkXa2YESSlh z$BqHh_{0E6)pK{A9x#nqCxJ(J?0)yLN{($?GDiDdQtuHI9K{D@jU_JE!9(CRwNB}Y zwYFf8L`RkM^HWPNuvUuUVz`yWr=G7*rJl7T1lhwGoXuZ&dwTPi40hoQC_&d{;GY;K zz9AX5L&!Sc1_mRZ*fgA1e!{AVP1kAqavOyN#oOc1ts`wUJ!%TOX2~Opna`)Zm$E{a z)_KmK!877=EOX6cmK@}LTW#*Ab+BRH5DS@0G zzIvX`1E)~E#E)8;erp0Qww*5v;J{w1`w)$7ma{28-vaR)TPHd0$8lNW20}LU-KkHw z5-C-;CouTTHN5>sP>_LuoVpe9G#~^31VaG;h5xlR!Pd~u(#+J^kCy7Aq*sJd>lBp2 zjiZ6ekZ=Y*@d8r(cK|q6UB;js{b0Q&+eDdl(>(jS8r-ZlZv(YjMHUn?qn2!u?8di& zy6%-%^d7S4Dr?X0g6TZ;Wbb6YrGl-UC>M)C^F?gl<6He3Iqdl(ixXdQS0$fE+GBQJ zfqLj+`>NW__m?5B<>PQO4lGQOsf9OLcFLU+Xel;x_k(iq_LS8^_EQt;k19}5%NZ5n zpf0%qsuj;q7PT9qu>SHeGsz`L@gEiAvg-;pT72ILCfLv=ljlG8rf3g!h10ZzQwror zu6Js9a&Y$pj>)Le&l1X&J}IgxsapwI*Q2y=HsKdND&Dj{XnleHWuJ18*K1QC03ZVR zf3pwt-_{Wadk0qsdJ7j@n-~?L*oD8wxu%JpHtC0jNKHwCnahb6tR=Wv$&SDpX}*vP z-FPB$I5d1mUXx3ok|xZBbK*<18avO8pKJ-xW`wnch$V7ZMRj`@tFHW?Wmt5;y{ zaJDAs`b}^{$N~tWI6)o>c7rmNA~JU43_m8vwzi*Cz8-Ks{a*ce!7_`oTJgfo&H!fQ ziz@%1S|Q=)?_i-}7JGs#vFw7X88a)fo^9kAIZ!i~0jaEC6~V|7iFbJruIyK$s_-t$ zKc637WEKJ%QIkCnwMS7X3zJRLhH%%ywn#(oBs0RSy$$m%ipmOx?<(eygzPm?WOFOU z{z!j99C8wSwg|q?j|q!~|FTRvm<_b+nKL!O2;y5;Y2R3r)GXuK&xIZg?fg8r_$5YR zhC6`2GQS^3BZ>sp{!4)G%cSH)LO9QjATOQBQ|5u^2@G7o^pW|vp8hS*pALig#}{mb zR1sm(W)ma!TyvD)`Kh_(-o2on%hEW>%2UDfE~P3{=C2S(!@((s5lOt?g?M8lX)fx^ z?*kp1IB!qx_6YCm&yS5YyF3}(NWA7${!P?z!R@5Bz)lujh+TzK?WvZBrXo10LC|GJ zz>^G&5X$W@rCdoQZ#uDHg{{}^-+LR;0{i#Ee%aS&b>2#1k_AQT;z0o)SLoK?4f{9- z1ih*YWiqJSDk3ok9Snx z4-^w9cYWmgg8a)pk%GPt%>TM(9q9k&o_~9t{(twha!yfMx5H*a^f{&>ub|W_+<_}w zm>`Z^OO{NSqox-1u&5YbWiv3XYiV|el74xPWzZ49M@0B(K5TCG=a*xmznW&ghS2fg ziMTA9_qo1rTNnOU*QW=ZZnzn@Y~s@^gi~mb+uK#==dH3*3(Av8`ixFw>AVFgeo3;a zD!pUut=r^1xr=}ZeS_6_Fp;s@8(70g&3(m2I}g1EjumwbN9_Kn>ik_Q<%_c!B}3=- zOjzo-o@Weh91MEr)kc4AQH#q84#P!P);p}k>FahoSjSa6GSt---kF$*o-%bwQk#QB@EndO zSkppP8DmAE!n8hOrY^BbzmRzB08T*xG^torm&SP%Dw|z-(Z@}Tv`GiK9EmX|M?++Y zkiQ#jEkm$F;6)9Sgs|0Tmk!D~aTUM`W63bRjL+84HuA-2Wtz)#k1CS@9k~ z{fSejEa|epowmLBy88NVQRNp+f9tT)|Mt9Aqh3VoQySvZ6#&|iF+8OVS+h37Z&l`Z z)l3XL;w7V}CVo(m;$i_{t#af6?1K&%R%r<%t8?JS;Ph$=HGK({T(DJE*Tb$mckEV& zm};n6jfrTdhtg(P?8*#A2AS2LsJo%>z!Y@pxUT|2lYi3M z`;?PV9U*!Qzfsl6zcS<0^+!Ap|KY?giN3rd!p!YzpDiqtvl+0NV0k-N*889S3s~H< zqb`xO?58l3#;_|<#I4;Fu^T*jkK1@pK>rDePWUjMth7q9?o)0KE`1#T269Kp&#vSY z)Jy3|>V^{^csIl0^JH#*D^PvgN^wH!j1w0cOqWil#|KI=F4ehq&9SdWWuzOau>`Jl zN9It2dYI%%58eHV>1%;POevvDG#Z%#+{S_CLRqV>6q9}ZY51b3+EJ@-A)oclSvh`r zE{IBK$`nbY+p5iA!v%7dKqw1Woz}NC=|T(Jtm=ME#I3}2CYMIC{ad;us-VL&=hDQd z%Ij!5x3BdrkI00hpdlzf77JGFH{<6oT3J$2Y#4L8T<+!a9!=5>ft6QeOrka{==czk^JtN6RzdbCal?1*1E`hAc> zBtW5amlJ>zxms|b)I8Y$Q$c}7v2n@c^e9>@QqvKK&u#b40zDLSns$VM6O!xCUFXTM zh+1-QLN>;0}OI>twrgns<#p(CE7v_KH_5^D3>0)t!+D*pHMxd&Q7+3zR}UGPb|GzyFxu z6(egBRb`7AM6=JXW+k0P1D{uX=mxEB2nIaPkjIu&pjqN2qN#E0SSi0fR!_}KM2`5e ziQ6fep5nBOx0b@Bg*!7LN0hxh3~8SUGb^Am2StTYJAs(~Dpm<~p{i)*awU?@X;jU# zwF^$4GTU>lFQFXKRZMLP;^ppG;>#T{E}~<`WAc z^NFU0ka3Z4x;;Z{Qk@>KCon9${_0PZ&_G0&8**C?5I-#S*^j2aHnyh`>wD$Okj0s9 zp^eHfoS$ICUa+}#GXn%*SG(g%?W?E>&h$1=q3vtY^YM}}L~t9+wE^qZnq|)(^5GOT zkQ_YhGjzfH7TStphseTGRpfi_C|eP^zG1YoJ9ruWQh3uFdF-gj_F}^1@1^d<_lS%i z4W>pC824lj_oOh*(f3sH4s!qlC1Y01tN)wS8s#6P45tz|VIFI?S|?UglSZUl@t&G< zL8YEj>PyevNa8V3W!cs`r##QTLG6DqA5jo<7(o=09i5ap6c0nzukCcX5U6IHv%z?w zhD09Y*We!|BW(>MX6c?h7L_D$_Ir(pmr0I&52YoOqc_U9?BVGgDu3S^UhdCP5Wz>( zU>LRz8I#{bcs5ju;AIOuLj}{|fCjz)Ezl?)B)BwQW_>)xgl63OZ{J4eDQ42E2RUAh!Ut)pSeu-ueH?6j7AZj=_2UZ6Ee#OflYZBU74 zO*I$|-~Jng?8=%NB^& zZHhE9milpUXVtv|ucK09&lDw<-_%r8^=6E|UZlT3_i`w5_VP@Rj$Ha2K~fxZK(h_+ z&$0tD&wIc40xi+VF3(S%vk;c4(qyMN9BZ=~jb1gw>i`K_d6WZe@Lq_HE0g^v_GWJx zFRyI%k}G0^tPE-&zM=$POy))Dw>6&olwj-=Nf zWEwd-Ujvh4_9iFfoxz-gkKh}6vB!Rk!=2yYtl!0X{dby*%}D6G_E%%B|80{0@05ZM8Q&iVu*9j4Qwrd0b(PAAxb258c5qaSHIYo{&YocdPr`gsmEcd-A zFB~}_<{PHs9lF);Pc$MWQmjF8QMveIU-5CBu$zsZCl62a zA6METS!>8;Ds&m65Dy498<1V8 zIURya&u(czE=h}K$E`5Ni=V}^0LobN`gI-JGxo6TCAVMo6r$w~-=qVpbjkU;3~{!F zKM*wPzFBzlrLYn@44@lCqdavh?=E3MR3ZG&$n~VI7gD$xhL0&1L*{hI3t)x}q`BW# z<$=j|{N6q|p~e~1;vJJ0g;9V8cF`d*d2Jkm_l6%kOUEl>wyKszxG!Y(+)?(n4}bP} zo{Kt5`CA`^G`RnO#Jr5j zh&gcS1nLfL;)?^V<``)fJ{y&tFPmrVNn?_yFl!BS?3`C0P97Trpor?p)gtl+vWjO& zHL5EXHHU@NKY6C4$xojT3%tSnTV7oL#KZV2E!O|7ErEaqK>qJ_lz&!(-~dp6!|DGU zg#rJ1|HtJNRTVG*2%z{6vVzQiV*ImW@t+u3gaE*QRFVEG#y`_a=>OOU5Apw`lK-l7 z{8xZ~3TVjx09Yga9{?-o|GNC25)A%7mNQZQznc5Mg8Y-SVE+Szp8B64|Jy$PVJq { 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 deleted file mode 100644 index 9e819fe9..00000000 --- a/extension/dist/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 95ac6771..00000000 --- a/extension/dist/popup.html +++ /dev/null @@ -1,21 +0,0 @@ - - - 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 deleted file mode 100644 index d082a24a..00000000 --- a/extension/dist/popup.js +++ /dev/null @@ -1,45 +0,0 @@ -// 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 deleted file mode 100644 index fdbcb93e..00000000 --- a/extension/dist/sidepanel.html +++ /dev/null @@ -1,13 +0,0 @@ - - - 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 deleted file mode 100644 index f90dd310..00000000 --- a/extension/dist/sidepanel.js +++ /dev/null @@ -1,78 +0,0 @@ -// 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 deleted file mode 100644 index 555cf9a6..00000000 --- a/extension/dist/ui.css +++ /dev/null @@ -1,21 +0,0 @@ -: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 deleted file mode 100644 index 0431d864..00000000 --- a/extension/eslint.config.js +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 9e819fe9..00000000 --- a/extension/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 9dc8cd4b..00000000 --- a/extension/package-lock.json +++ /dev/null @@ -1,4396 +0,0 @@ -{ - "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.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "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.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "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.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "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.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "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.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "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.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", - "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.17", - "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.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "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 deleted file mode 100644 index f34d7be1..00000000 --- a/extension/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 14e870ff..00000000 --- a/extension/playwright.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 678c3df9..00000000 --- a/extension/scripts/build.mjs +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 9604c1a3..00000000 --- a/extension/scripts/package.mjs +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index ae562057..00000000 --- a/extension/src/background.ts +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 2de2559c..00000000 --- a/extension/src/cdp.ts +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index d5db6287..00000000 --- a/extension/src/errors.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 95ac6771..00000000 --- a/extension/src/popup.html +++ /dev/null @@ -1,21 +0,0 @@ - - - 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 deleted file mode 100644 index 3f2010bd..00000000 --- a/extension/src/popup.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 6ff08dbe..00000000 --- a/extension/src/protocol.ts +++ /dev/null @@ -1,175 +0,0 @@ -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 deleted file mode 100644 index 266141bf..00000000 --- a/extension/src/relay.ts +++ /dev/null @@ -1,166 +0,0 @@ -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 deleted file mode 100644 index fdbcb93e..00000000 --- a/extension/src/sidepanel.html +++ /dev/null @@ -1,13 +0,0 @@ - - - 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 deleted file mode 100644 index 09ba04af..00000000 --- a/extension/src/sidepanel.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index c7297c4e..00000000 --- a/extension/src/tab-manager.ts +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 555cf9a6..00000000 --- a/extension/src/ui.css +++ /dev/null @@ -1,21 +0,0 @@ -: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 deleted file mode 100644 index d91fb88f..00000000 --- a/extension/tests/cdp.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 523e8101..00000000 --- a/extension/tests/e2e/extension.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 59fa3bcd..00000000 --- a/extension/tests/errors.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index cc493ca0..00000000 --- a/extension/tests/protocol.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 55d9ecc0..00000000 --- a/extension/tests/relay.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index c514110e..00000000 --- a/extension/tests/tab-manager.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index bb614077..00000000 --- a/extension/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 deleted file mode 100644 index 007cef63..00000000 --- a/extension/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 6828eecb..00000000 --- a/protocol/browser-v1.schema.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "$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 deleted file mode 100644 index a9af648f..00000000 --- a/protocol/fixtures/browser-cancel.v1.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index 27e5e4ae..00000000 --- a/protocol/fixtures/browser-request.v1.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index ae64d6bc..00000000 --- a/protocol/fixtures/browser-response.v1.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index 6731a832..00000000 --- a/protocol/fixtures/tab-shared.v1.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index 5ded7a48..00000000 --- a/src/browser/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! 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 deleted file mode 100644 index 6321ffda..00000000 --- a/src/browser/protocol.rs +++ /dev/null @@ -1,419 +0,0 @@ -//! 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)] -#[path = "protocol_tests.rs"] -mod tests; diff --git a/src/browser/protocol_tests.rs b/src/browser/protocol_tests.rs deleted file mode 100644 index 87b76127..00000000 --- a/src/browser/protocol_tests.rs +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index e90cfb57..00000000 --- a/src/browser/routing.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! 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)] -#[path = "routing_tests.rs"] -mod tests; diff --git a/src/browser/routing_tests.rs b/src/browser/routing_tests.rs deleted file mode 100644 index b47f4fe3..00000000 --- a/src/browser/routing_tests.rs +++ /dev/null @@ -1,189 +0,0 @@ -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 deleted file mode 100644 index d1c21cbc..00000000 --- a/src/companion/auth.rs +++ /dev/null @@ -1,284 +0,0 @@ -//! 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)] -#[path = "auth_tests.rs"] -mod tests; diff --git a/src/companion/auth_tests.rs b/src/companion/auth_tests.rs deleted file mode 100644 index bde458af..00000000 --- a/src/companion/auth_tests.rs +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index c8799e27..00000000 --- a/src/companion/control.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! 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)] -#[path = "control_tests.rs"] -mod tests; diff --git a/src/companion/control_tests.rs b/src/companion/control_tests.rs deleted file mode 100644 index f3605268..00000000 --- a/src/companion/control_tests.rs +++ /dev/null @@ -1,43 +0,0 @@ -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/host.rs b/src/companion/host.rs deleted file mode 100644 index 82c99fe6..00000000 --- a/src/companion/host.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Host seam for embedding companions. -//! -//! By default the companion lists and runs workflows from its `workflows_dir` -//! (JSON files) via [`CompanionServer::start_workflow`](super::CompanionServer::start_workflow). -//! An embedding host (e.g. OpenHuman's flows domain) can instead own workflow -//! listing + execution by supplying a [`CompanionRunHost`] on -//! [`CompanionServerConfig::run_host`](super::CompanionServerConfig): when set, -//! the WS control channel and the HTTP native endpoints delegate to it, so -//! extension-initiated runs flow through the host's own engine (its run -//! registry, gates, and history) instead of the built-in path. - -use async_trait::async_trait; -use serde_json::Value; - -use super::{TabId, WorkflowSummary}; - -/// Lets an embedding host own the workflow-listing and run-execution the -/// companion exposes to the extension. All methods are called only for -/// already-authenticated control/native requests. -#[async_trait] -pub trait CompanionRunHost: Send + Sync { - /// The workflows the host chooses to expose to the extension side-panel - /// (e.g. only flows the user opted into browser-triggering). - async fn list_workflows(&self) -> Result, String>; - - /// Start a run of `workflow_id` bound to the explicitly-shared `tab_id`, - /// returning the host's own run id. The host is responsible for binding the - /// run to the tab and applying browser routing in its own engine. - async fn start_run( - &self, - workflow_id: &str, - tab_id: TabId, - input: Value, - ) -> Result; - - /// Cancel a previously started host run. Returns whether anything was - /// cancelled (mirrors [`CompanionServer::cancel_workflow`](super::CompanionServer::cancel_workflow)). - async fn cancel_run(&self, run_id: &str) -> bool; -} diff --git a/src/companion/mod.rs b/src/companion/mod.rs deleted file mode 100644 index dac7a151..00000000 --- a/src/companion/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! 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 host; -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 host::CompanionRunHost; -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 deleted file mode 100644 index 62c7de69..00000000 --- a/src/companion/relay.rs +++ /dev/null @@ -1,404 +0,0 @@ -//! 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)] -#[path = "relay_tests.rs"] -mod tests; diff --git a/src/companion/relay_tests.rs b/src/companion/relay_tests.rs deleted file mode 100644 index 357feb99..00000000 --- a/src/companion/relay_tests.rs +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index f7d9aa9f..00000000 --- a/src/companion/server.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! 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, SharedTab, 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. - /// Ignored for listing/running when `run_host` is set. - pub workflows_dir: PathBuf, - /// Host capabilities used for every non-browser effect. - pub capabilities: Capabilities, - /// Optional embedding-host seam. When set, workflow listing + execution - /// (over both the WS control channel and the HTTP native endpoints) delegate - /// to the host instead of `workflows_dir` + [`CompanionServer::start_workflow`]. - /// `None` preserves the built-in standalone behaviour. - pub run_host: Option>, -} - -/// 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, - run_host: Option>, - 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, -} - -mod api; - -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())); - } -} - -mod handlers; -use handlers::{ - browser_error, list_workflows, load_workflow, lock_error, native_run, native_tabs, - native_workflows, upgrade, -}; - -#[cfg(test)] -#[path = "server_tests.rs"] -mod tests; diff --git a/src/companion/server/api.rs b/src/companion/server/api.rs deleted file mode 100644 index 5b0b5972..00000000 --- a/src/companion/server/api.rs +++ /dev/null @@ -1,325 +0,0 @@ -use super::*; - -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, - run_host: config.run_host, - 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) - } - - /// Lists workflows via the configured [`CompanionRunHost`](super::CompanionRunHost) - /// if present, else from `workflows_dir`. Used by both request paths. - pub(super) async fn dispatch_list_workflows(&self) -> Result, String> { - match &self.inner.run_host { - Some(host) => host.list_workflows().await, - None => self.workflows().map_err(|error| error.to_string()), - } - } - - /// Starts a run via the run host if present, else via the built-in - /// [`start_workflow`](Self::start_workflow). Returns the run id. - pub(super) async fn dispatch_start_run( - &self, - workflow_id: &str, - tab_id: TabId, - input: Value, - ) -> Result { - match &self.inner.run_host { - Some(host) => host.start_run(workflow_id, tab_id, input).await, - None => self - .start_workflow(workflow_id, tab_id, input) - .await - .map_err(|error| error.to_string()), - } - } - - /// Cancels a run via the run host if present, else via the built-in - /// [`cancel_workflow`](Self::cancel_workflow). - pub(super) async fn dispatch_cancel_run(&self, run_id: &str) -> bool { - match &self.inner.run_host { - Some(host) => host.cancel_run(run_id).await, - None => self.cancel_workflow(run_id).await, - } - } - - /// Returns a browser relay handle usable by an **external** workflow runner - /// (an embedding host that drives its own engine rather than calling - /// [`start_workflow`](Self::start_workflow)). The returned handle shares this - /// server's live WebSocket session and pending-response map, so wrapping it in - /// a [`RoutingToolInvoker`](crate::browser::RoutingToolInvoker) lets a host - /// route `slug:"browser"` tool calls to the paired extension. - /// - /// The handle is always valid; if no extension is currently connected each - /// `execute` fails closed with `relay_disconnected`. - pub fn browser_relay(&self) -> Arc { - Arc::new(SocketRelay { - inner: self.inner.clone(), - }) - } - - /// Whether a paired extension currently holds an authenticated relay session. - /// External hosts use this to gate author-time / run-time browser readiness. - pub fn is_extension_connected(&self) -> bool { - self.inner - .relay - .lock() - .map(|relay| relay.is_connected()) - .unwrap_or(false) - } - - /// Snapshot of the tabs the user has explicitly shared with the companion. - /// Empty when no extension is connected or nothing is shared. - pub fn shared_tabs(&self) -> Vec { - self.inner - .relay - .lock() - .map(|relay| relay.tabs().list().into_iter().cloned().collect()) - .unwrap_or_default() - } - - /// Binds a workflow run to an explicitly-shared tab so an **external** - /// runner's `slug:"browser"` calls (dispatched through the handle from - /// [`browser_relay`](Self::browser_relay)) are authorized against that tab. - /// This mirrors what [`start_workflow`](Self::start_workflow) does - /// internally for native runs — an embedding host must call this before - /// executing a graph that contains browser nodes, or every browser action - /// fails with `tab_not_shared`. External runners are not registered in the - /// native run table, so their cancellation path must call - /// [`cancel_bound_run`](Self::cancel_bound_run) before unbinding. - pub fn bind_run( - &self, - run_id: impl Into, - tab_id: TabId, - ) -> Result<(), CompanionServerError> { - self.inner - .relay - .lock() - .map_err(|_| lock_error())? - .tabs_mut() - .bind_run(run_id.into(), tab_id) - .map_err(crate::companion::RelayError::from)?; - Ok(()) - } - - /// Releases a run→tab binding after an external run settles. Idempotent. - /// - /// This does not cancel in-flight browser actions. On cancellation, call - /// [`cancel_bound_run`](Self::cancel_bound_run) instead so the extension and - /// pending relay requests are both notified. - pub fn unbind_run(&self, run_id: &str) { - if let Ok(mut relay) = self.inner.relay.lock() { - relay.tabs_mut().unbind_run(run_id); - } - } - - /// Cancels browser work for an externally-owned run and releases its tab - /// binding. - /// - /// An embedding host should call this alongside cancellation of its own - /// workflow engine. Unlike [`cancel_workflow`](Self::cancel_workflow), this - /// method does not expect the run to exist in the companion's native run - /// table. Returns whether the run had a live tab binding. - pub async fn cancel_bound_run(&self, run_id: &str) -> bool { - let (was_bound, responses) = self - .inner - .relay - .lock() - .map(|mut relay| { - let was_bound = relay.tabs().binding(run_id).is_some(); - (was_bound, relay.cancel_run(run_id)) - }) - .unwrap_or_default(); - self.dispatch(responses).await; - was_bound - } - - /// 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(crate::companion::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 companion-native run and its in-flight browser action. - /// - /// Runs registered only through [`bind_run`](Self::bind_run) are externally - /// owned and must use [`cancel_bound_run`](Self::cancel_bound_run). - 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(); - self.cancel_bound_run(run_id).await; - true - } - - pub(super) 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())); - } - } - - pub(super) 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); - } - } - } -} diff --git a/src/companion/server/handlers.rs b/src/companion/server/handlers.rs deleted file mode 100644 index d1310911..00000000 --- a/src/companion/server/handlers.rs +++ /dev/null @@ -1,477 +0,0 @@ -use super::*; - -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) 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, -} - -pub(super) 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() -} - -pub(super) async fn native_workflows( - State(server): State, - headers: HeaderMap, -) -> Response { - if !native_authorized(&server, &headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - match server.dispatch_list_workflows().await { - 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})), - ) - .into_response(), - } -} - -pub(super) 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 - .dispatch_start_run(&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})), - ) - .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 -} - -pub(super) 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(crate::companion::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.dispatch_list_workflows().await { - Ok(workflows) => CompanionControlResponse::Workflows { - protocol_version: BROWSER_PROTOCOL_VERSION, - request_id, - workflows, - }, - Err(error) => control_error(request_id, "workflow_list_failed", &error), - } - } - CompanionControlRequest::WorkflowStart { - workflow_id, - tab_id, - input, - .. - } => match server.dispatch_start_run(&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), - }, - CompanionControlRequest::WorkflowCancel { run_id, .. } => control_ok( - request_id, - json!({"cancelled":server.dispatch_cancel_run(&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, - } - } - } -} - -pub(super) 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()))) -} - -pub(super) 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() -} - -pub(super) fn lock_error() -> CompanionServerError { - CompanionServerError::Listener("companion state lock poisoned".into()) -} - -pub(super) 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(), - } -} diff --git a/src/companion/server_tests.rs b/src/companion/server_tests.rs deleted file mode 100644 index 039eeeec..00000000 --- a/src/companion/server_tests.rs +++ /dev/null @@ -1,47 +0,0 @@ -use super::*; - -fn test_server(workflows_dir: PathBuf) -> CompanionServer { - CompanionServer::new(CompanionServerConfig { - policy: RelayPolicy::loopback(0), - extension_id: "a".repeat(32), - pairing_secret: PairingSecret::parse("a".repeat(32)).unwrap(), - workflows_dir, - capabilities: crate::caps::mock::mock_capabilities(), - run_host: None, - }) - .unwrap() -} - -#[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")); -} - -#[tokio::test] -async fn external_run_cancellation_clears_its_relay_binding() { - let server = test_server(PathBuf::from(".")); - server - .inner - .relay - .lock() - .unwrap() - .tabs_mut() - .share(7, 1, "https://example.com", "Example") - .unwrap(); - server.bind_run("external-1", 7).unwrap(); - - assert!(!server.cancel_workflow("external-1").await); - assert!(server.cancel_bound_run("external-1").await); - assert!( - server - .inner - .relay - .lock() - .unwrap() - .tabs() - .binding("external-1") - .is_none() - ); - assert!(!server.cancel_bound_run("external-1").await); -} diff --git a/src/companion/tabs.rs b/src/companion/tabs.rs deleted file mode 100644 index 30b70b7d..00000000 --- a/src/companion/tabs.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! 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)] -#[path = "tabs_tests.rs"] -mod tests; diff --git a/src/companion/tabs_tests.rs b/src/companion/tabs_tests.rs deleted file mode 100644 index 09c30b8b..00000000 --- a/src/companion/tabs_tests.rs +++ /dev/null @@ -1,53 +0,0 @@ -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/lib.rs b/src/lib.rs index c5e8cb4a..6cfeca86 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,14 +22,8 @@ /// Reading the expression bindings a graph declares — which node an `=` /// expression reads from, and whether it reads as prose. pub mod bindings; -/// Browser automation protocol, action validation, and tool routing. -#[cfg(feature = "chrome-extension")] -pub mod browser; pub mod caps; pub mod catalog; -/// Native companion pairing, tab authorization, relay, and control lifecycle. -#[cfg(feature = "chrome-extension")] -pub mod companion; pub mod compiler; pub mod data; /// Reading a run's steps for the failures a green outcome hides: null bindings, diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a24df2d2..00000000 --- a/src/main.rs +++ /dev/null @@ -1,337 +0,0 @@ -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(), - run_host: None, - }) - .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, - shell: None, - memory: None, - // The stub binary refuses every outside-world capability; background - // work is no exception, so `spawn` degrades to running inline. - tasks: None, - // Likewise for human review: with no provider, an `approval` node - // pauses the run and waits for a resume that the stub never sends. - approvals: None, - } -} - -#[cfg(test)] -#[path = "main_tests.rs"] -mod tests; diff --git a/src/testkit/tools/error.rs b/src/testkit/tools/error.rs index b9076899..d301cadb 100644 --- a/src/testkit/tools/error.rs +++ b/src/testkit/tools/error.rs @@ -3,10 +3,8 @@ use serde_json::Value; /// Why a tool call failed. /// -/// A closed set of stable snake_case codes, following -/// [`BrowserErrorCode`](crate::browser::protocol::BrowserErrorCode)'s precedent: -/// a caller — human or model — can branch on the code, and the message is for -/// reading rather than parsing. +/// A closed set of stable snake_case codes: a caller — human or model — can +/// branch on the code, and the message is for reading rather than parsing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] diff --git a/tests/browser_routing_e2e.rs b/tests/browser_routing_e2e.rs deleted file mode 100644 index a4ef886a..00000000 --- a/tests/browser_routing_e2e.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Mixed browser and integration routing through ordinary `tool_call` nodes. - -#![cfg(all(feature = "mock", feature = "chrome-extension"))] - -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 deleted file mode 100644 index aec25002..00000000 --- a/tests/cli_e2e.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! CLI smoke tests for the tinyflows binary. - -#![cfg(feature = "chrome-extension")] - -use std::path::PathBuf; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[test] -fn binary_prints_product_name() { - let output = Command::new(env!("CARGO_BIN_EXE_tinyflows")) - .output() - .expect("run tinyflows binary"); - - assert!( - output.status.success(), - "binary should exit successfully: {output:?}" - ); - assert_eq!( - String::from_utf8(output.stdout).expect("stdout utf8"), - "tinyflows\n" - ); - assert!( - output.stderr.is_empty(), - "binary should not write stderr: {:?}", - 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"); -}