diff --git a/.env.example b/.env.example index b77c668a3..0aaef3c13 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,9 @@ VITE_AUTH_ENABLE_ANONYMOUS=false # Show app download links/banners in the web UI (set to "true" for internal testing deployments) # When enabled, desktop users see a sidebar section + bottom-right banner; mobile users see a top banner # VITE_SHOW_APP_DOWNLOADS="true" + +# iroh relay for the ACP/MCP bridge transport (build-time; Vite inlines it). +# Unset = the n0 public relays (default). Set to a self-hosted iroh-relay wss URL +# to route every bridge dial through it — n0 DNS discovery + crypto are kept, only +# the relay hop changes. The CLI bridge has its own runtime env: THUNDERBOLT_IROH_RELAY_URL. +# VITE_IROH_RELAY_URL="wss://relay.example.com" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002ca52ef..c374a0b3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,10 @@ jobs: runs-on: ubuntu-latest outputs: rust: ${{ steps.filter.outputs.rust }} + agent-core: ${{ steps.filter.outputs.agent-core }} + cli: ${{ steps.filter.outputs.cli }} + wasm-artifact: ${{ steps.filter.outputs.wasm-artifact }} + crate-src: ${{ steps.filter.outputs.crate-src }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -41,6 +45,23 @@ jobs: - 'src-tauri/build.rs' - 'src-tauri/rust-toolchain.toml' - 'src-tauri/.cargo/**' + - 'crates/**' + agent-core: + - 'shared/agent-core/**' + cli: + - 'cli/**' + wasm-artifact: + - 'src/acp/iroh/pkg/**' + # Inputs that change the compiled wasm (README.md excluded — it doesn't). + # Pairs with the wasm-artifact staleness gate: touching any of these MUST + # ship a regenerated src/acp/iroh/pkg in the same PR. + crate-src: + - 'crates/thunderbolt-acp-client/src/**' + - 'crates/thunderbolt-acp-client/Cargo.toml' + - 'crates/thunderbolt-acp-client/Cargo.lock' + - 'crates/thunderbolt-acp-client/rust-toolchain.toml' + - 'crates/thunderbolt-acp-client/.cargo/**' + - 'crates/thunderbolt-acp-client/build.sh' typescript: runs-on: ubuntu-latest @@ -126,6 +147,113 @@ jobs: path: .metrics-baseline key: pr-metrics-main-${{ github.sha }} + # shared/agent-core is an isolated module (also consumed by cli/). Its unit tests + # are NOT part of the default `bun run test` — they only need to run when the + # module itself changes, so gate them on the agent-core path filter. Mirrors the + # `rust` job's detect-changes pattern and the `typescript` job's Bun setup. + agent-core: + needs: detect-changes + if: needs.detect-changes.outputs.agent-core == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.bun/install/cache + node_modules + key: bun-${{ hashFiles('bun.lock') }} + restore-keys: | + bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run agent-core tests (5x) + run: bun run test:agent-core:5x + + # cli/ is a self-contained package (own bun.lock, no imports from src/ or shared/), + # so its tests and typecheck aren't reached by the `typescript` job. Gate on the + # `cli/**` path filter — same detect-changes mechanism as the `rust` and + # `agent-core` jobs — so it runs only when cli/ changes. + cli: + needs: detect-changes + if: needs.detect-changes.outputs.cli == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Cache Bun dependencies (cli) + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.bun/install/cache + cli/node_modules + key: cli-bun-${{ hashFiles('cli/bun.lock') }} + restore-keys: | + cli-bun- + + - name: Install cli dependencies + run: cd cli && bun install --frozen-lockfile + + - name: Type check cli + run: cd cli && bun run typecheck + + - name: Run cli tests (5x) + run: cd cli && bun run test:5x + + # The iroh ACP client (P2P/QUIC crypto core) ships as a prebuilt wasm artifact at + # src/acp/iroh/pkg so the web app imports it without a wasm toolchain in CI. The + # committed binary embeds ~750 absolute ~/.cargo build paths, so it is byte-for-byte + # reproducible only on the machine that built it — a Linux/macOS CI rebuild can never + # match hash-for-hash (see the crate README's provenance note). CI therefore enforces + # provenance two ways, neither needing a wasm toolchain: + # 1. Staleness gate — a change to the crate source MUST ship a regenerated pkg/ in + # the same PR, so editing the crate can't silently leave the committed wasm stale + # (the `rust` job host-compiles the crate but never touches the binary). + # 2. Tamper-evidence — the committed pkg/ must match its CHECKSUMS.txt manifest. + # Reproduce a bit-identical artifact locally on the pinned toolchain with + # `crates/thunderbolt-acp-client/build.sh --verify` (see that crate's README). + wasm-artifact: + needs: detect-changes + if: needs.detect-changes.outputs.wasm-artifact == 'true' || needs.detect-changes.outputs.crate-src == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + # Crate source changed but the committed pkg/ didn't — the wasm wasn't rebuilt, + # so it's now stale relative to its source. Fail with the fix instruction. + - name: Guard against stale wasm artifact + if: needs.detect-changes.outputs.crate-src == 'true' && needs.detect-changes.outputs.wasm-artifact != 'true' + run: | + echo "::error::crates/thunderbolt-acp-client changed but src/acp/iroh/pkg was not regenerated — the committed wasm is stale." + echo "Rebuild and commit the artifact in the same change: crates/thunderbolt-acp-client/build.sh (then commit src/acp/iroh/pkg)." + exit 1 + + - name: Verify committed wasm artifacts match checksums manifest + if: needs.detect-changes.outputs.wasm-artifact == 'true' + run: cd src/acp/iroh/pkg && shasum -a 256 -c CHECKSUMS.txt + rust: needs: detect-changes if: needs.detect-changes.outputs.rust == 'true' @@ -189,6 +317,21 @@ jobs: cargo clippy --all-targets -- -D warnings cargo test + # crates/ holds the standalone thunderbolt-acp-client crate (own Cargo.toml + # and Cargo.lock, not a src-tauri workspace member), so the src-tauri build + # above never compiles it. Its wasm cdylib is the deployed artifact; the rlib + # target lets these host-target checks run without a wasm toolchain — the + # .cargo/config.toml rustflags are scoped to wasm32, so no --target override. + - name: Build and check ACP client crate + env: + CARGO_INCREMENTAL: 0 + RUSTC_WRAPPER: '' + run: | + cd crates/thunderbolt-acp-client + cargo build --all-targets + cargo clippy --all-targets -- -D warnings + cargo test + backend: runs-on: ubuntu-latest # Healthy run is ~130s; a CPU-starved one was observed at 4920s (~82min). A diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 000000000..2291030b9 --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,158 @@ +name: CLI Release +run-name: CLI release for v${{ inputs.version }} + +# Builds the standalone `thunderbolt` CLI binaries and attaches them, plus a +# SHA256SUMS manifest, to the shared `v{version}` GitHub Release. The desktop +# app derives download URLs from its own version: +# +# https://github.com/thunderbird/thunderbolt/releases/download/v{APP_VERSION}/thunderbolt-{target} +# https://github.com/thunderbird/thunderbolt/releases/download/v{APP_VERSION}/SHA256SUMS +# +# where {target} is one of: darwin-arm64 | linux-x64 | linux-arm64. +# +# Native-runner matrix (no cross-compile): the CLI depends on @number0/iroh, a +# NAPI addon that ships a per-platform prebuilt `.node`. `bun build --compile` +# embeds only the `.node` that is installed on the build machine, so each target +# MUST build on a runner of its own architecture — `bun install` there pulls the +# matching optional iroh dependency automatically. +# +# darwin-x64 is intentionally absent: iroh 1.0.0 publishes no x86_64-apple-darwin +# addon (`@number0/iroh-darwin-x64` does not exist on npm), so an Intel-macOS CLI +# binary cannot load iroh and is not shipped. Intel-Mac desktop users therefore +# have no one-click CLI install until iroh gains that target upstream. +# +# Publishing (flipping the draft release to public) is owned by the desktop +# release workflow, which shares the `v{version}` tag. This workflow only uploads +# assets via `gh release upload`, which never mutates the release's draft state — +# so it cannot race the desktop publish flip back into draft. + +on: + workflow_call: + inputs: + version: + description: 'Version to release (e.g., 1.2.3 without v prefix)' + required: true + type: string + nightly: + description: 'Is this a nightly build?' + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.2.3 without v prefix)' + required: true + type: string + nightly: + description: 'Is this a nightly build?' + required: false + default: false + type: boolean + +concurrency: + group: cli-release-${{ inputs.version }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + ensure-release: + name: Ensure Draft Release + runs-on: ubuntu-24.04 + steps: + # For a regular release the draft already exists (version-bump created it). + # For a nightly it is created by the desktop workflow's draft job, which + # runs in parallel — create it here if we win the race, confirm it if we + # lose. Either way the build matrix can upload afterwards. + - name: Ensure the shared draft release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + VERSION: ${{ inputs.version }} + NIGHTLY: ${{ inputs.nightly }} + run: | + if gh release view "v${VERSION}" --repo "$REPO" >/dev/null 2>&1; then + exit 0 + fi + FLAGS="--draft" + [ "$NIGHTLY" = "true" ] && FLAGS="$FLAGS --prerelease" + gh release create "v${VERSION}" --repo "$REPO" $FLAGS \ + --title "Release v${VERSION}" \ + --notes "Release assets are uploaded by CI." \ + || gh release view "v${VERSION}" --repo "$REPO" + + build: + name: Build ${{ matrix.target }} + needs: ensure-release + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: darwin-arm64 + - os: ubuntu-24.04 + target: linux-x64 + - os: ubuntu-24.04-arm + target: linux-arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: v${{ inputs.version }} + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Cache Bun dependencies (cli) + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.bun/install/cache + cli/node_modules + key: cli-bun-${{ matrix.os }}-${{ hashFiles('cli/bun.lock') }} + restore-keys: | + cli-bun-${{ matrix.os }}- + + - name: Build CLI binary + run: cd cli && bun install --frozen-lockfile && bun run build:${{ matrix.target }} + + # Upload as a workflow artifact rather than straight to the Release, so a + # failed leg can never leave a partial set of binaries on the release with + # no SHA256SUMS. The publish job below attaches everything atomically once + # ALL legs have succeeded. + - name: Upload CLI binary artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: thunderbolt-${{ matrix.target }} + path: cli/dist/thunderbolt-${{ matrix.target }} + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish CLI assets + # `needs: build` (default success()) gates this on EVERY leg succeeding, so + # publishing is atomic: either all binaries + SHA256SUMS land, or nothing does. + needs: build + runs-on: ubuntu-24.04 + steps: + - name: Download all CLI binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + pattern: thunderbolt-* + merge-multiple: true + + - name: Generate SHA256SUMS and upload all assets to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + VERSION: ${{ inputs.version }} + run: | + cd dist + sha256sum thunderbolt-* > SHA256SUMS + gh release upload "v${VERSION}" thunderbolt-* SHA256SUMS --clobber --repo "$REPO" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9c59278d..c814fca08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,3 +79,17 @@ jobs: with: version: ${{ needs.version_bump.outputs.new_version }} nightly: ${{ github.event_name == 'schedule' }} + + # The CLI rides the unified `v{version}` release tag: its binaries attach to the + # same GitHub Release, and the desktop workflow's publish job flips that shared + # tag to public. It therefore builds only for `all` and nightly runs — not for + # single-platform (ios/android/desktop) dispatches, which produce RC tags the + # CLI would not belong to. + cli: + name: CLI Release + needs: version_bump + if: needs.version_bump.result == 'success' && (inputs.platform == 'all' || github.event_name == 'schedule') + uses: ./.github/workflows/cli-release.yml + with: + version: ${{ needs.version_bump.outputs.new_version }} + nightly: ${{ github.event_name == 'schedule' }} diff --git a/.prettierignore b/.prettierignore index 849730770..d05623392 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,4 +9,7 @@ thunderbird-mcp-bridge thunderbird-mcp-native package-lock.json bun.lock -*.lock \ No newline at end of file +*.lock +# Generated wasm-bindgen glue for the iroh ACP client (build artifact). +src/acp/iroh/pkg +crates/*/target \ No newline at end of file diff --git a/backend/bun.lock b/backend/bun.lock index ba4073c33..310a6f628 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -6,6 +6,7 @@ "name": "thunderbolt-backend-elysia", "dependencies": { "@agentclientprotocol/sdk": "^0.22.1", + "@better-auth/api-key": "^1.6.9", "@better-auth/sso": "^1.6.9", "@electric-sql/pglite": "^0.4.4", "@elysiajs/cors": "^1.4.0", @@ -86,6 +87,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@better-auth/api-key": ["@better-auth/api-key@1.6.9", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "better-auth": "^1.6.9" } }, "sha512-MPDNmvcCwDpix911kFYRn9XCebJjaCNuj16OA//difNCJoPRn2kD6KQ/3+B3rlSWl46x098SgN7Y3e8kU8nIwg=="], + "@better-auth/core": ["@better-auth/core@1.6.9", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w=="], "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="], diff --git a/backend/drizzle/0021_overrated_mindworm.sql b/backend/drizzle/0021_overrated_mindworm.sql new file mode 100644 index 000000000..86866c89b --- /dev/null +++ b/backend/drizzle/0021_overrated_mindworm.sql @@ -0,0 +1,6 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at http://mozilla.org/MPL/2.0/. + +ALTER TABLE "powersync"."devices" ADD COLUMN "node_id" text;--> statement-breakpoint +ALTER TABLE "powersync"."devices" ADD COLUMN "node_id_attested_at" timestamp; \ No newline at end of file diff --git a/backend/drizzle/0022_bizarre_zzzax.sql b/backend/drizzle/0022_bizarre_zzzax.sql new file mode 100644 index 000000000..ba22a1fb8 --- /dev/null +++ b/backend/drizzle/0022_bizarre_zzzax.sql @@ -0,0 +1,5 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at http://mozilla.org/MPL/2.0/. + +ALTER TABLE "powersync"."devices" ADD COLUMN "device_type" text DEFAULT 'normal' NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/0023_dry_quasimodo.sql b/backend/drizzle/0023_dry_quasimodo.sql new file mode 100644 index 000000000..8f564e21d --- /dev/null +++ b/backend/drizzle/0023_dry_quasimodo.sql @@ -0,0 +1,48 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at http://mozilla.org/MPL/2.0/. + +CREATE TABLE "apikey" ( + "id" text PRIMARY KEY NOT NULL, + "config_id" text DEFAULT 'default' NOT NULL, + "name" text, + "start" text, + "prefix" text, + "key" text NOT NULL, + "reference_id" text NOT NULL, + "refill_interval" integer, + "refill_amount" integer, + "last_refill_at" timestamp, + "enabled" boolean DEFAULT true NOT NULL, + "rate_limit_enabled" boolean DEFAULT true NOT NULL, + "rate_limit_time_window" integer, + "rate_limit_max" integer, + "request_count" integer DEFAULT 0 NOT NULL, + "remaining" integer, + "last_request" timestamp, + "expires_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "permissions" text, + "metadata" text +); +--> statement-breakpoint +CREATE TABLE "device_code" ( + "id" text PRIMARY KEY NOT NULL, + "device_code" text NOT NULL, + "user_code" text NOT NULL, + "user_id" text, + "expires_at" timestamp NOT NULL, + "status" text NOT NULL, + "last_polled_at" timestamp, + "polling_interval" integer, + "client_id" text, + "scope" text +); +--> statement-breakpoint +ALTER TABLE "apikey" ADD CONSTRAINT "apikey_reference_id_user_id_fk" FOREIGN KEY ("reference_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "device_code" ADD CONSTRAINT "device_code_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key");--> statement-breakpoint +CREATE INDEX "apikey_referenceId_idx" ON "apikey" USING btree ("reference_id");--> statement-breakpoint +CREATE INDEX "device_code_deviceCode_idx" ON "device_code" USING btree ("device_code");--> statement-breakpoint +CREATE INDEX "device_code_userCode_idx" ON "device_code" USING btree ("user_code"); \ No newline at end of file diff --git a/backend/drizzle/meta/0021_snapshot.json b/backend/drizzle/meta/0021_snapshot.json new file mode 100644 index 000000000..93db6dd10 --- /dev/null +++ b/backend/drizzle/meta/0021_snapshot.json @@ -0,0 +1,2218 @@ +{ + "id": "d89abf7b-a292-429d-85bc-33095d917080", + "prevId": "f5c953ed-42f2-4ee1-b614-2ef307e1cde0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_deviceId_idx": { + "name": "session_deviceId_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "waitlist_status_idx": { + "name": "waitlist_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_batch_id_idx": { + "name": "waitlist_batch_id_idx", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.agents": { + "name": "agents", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agents_user_id": { + "name": "idx_agents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_user_id_user_id_fk": { + "name": "agents_user_id_user_id_fk", + "tableFrom": "agents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_id_user_id_pk": { + "name": "agents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_messages": { + "name": "chat_messages", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_thread_id": { + "name": "chat_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache": { + "name": "cache", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_messages_user_id": { + "name": "idx_chat_messages_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_threads": { + "name": "chat_threads", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "was_triggered_by_automation": { + "name": "was_triggered_by_automation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "context_size": { + "name": "context_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acp_session_id": { + "name": "acp_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_threads_user_id": { + "name": "idx_chat_threads_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.devices": { + "name": "devices", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trusted": { + "name": "trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "approval_pending": { + "name": "approval_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mlkem_public_key": { + "name": "mlkem_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_id_attested_at": { + "name": "node_id_attested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_devices_user_id": { + "name": "idx_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_user_id_fk": { + "name": "devices_user_id_user_id_fk", + "tableFrom": "devices", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.model_profiles": { + "name": "model_profiles", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "max_steps": { + "name": "max_steps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "nudge_threshold": { + "name": "nudge_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_system_message_mode_developer": { + "name": "use_system_message_mode_developer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "tools_override": { + "name": "tools_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_previews_override": { + "name": "link_previews_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_mode_addendum": { + "name": "chat_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_mode_addendum": { + "name": "search_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "research_mode_addendum": { + "name": "research_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "citation_reinforcement_enabled": { + "name": "citation_reinforcement_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "citation_reinforcement_prompt": { + "name": "citation_reinforcement_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_final_step": { + "name": "nudge_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_preventive": { + "name": "nudge_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_retry": { + "name": "nudge_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_final_step": { + "name": "nudge_search_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_preventive": { + "name": "nudge_search_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_retry": { + "name": "nudge_search_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_options": { + "name": "provider_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_model_profiles_user_id": { + "name": "idx_model_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_profiles_user_id_user_id_fk": { + "name": "model_profiles_user_id_user_id_fk", + "tableFrom": "model_profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_profiles_id_user_id_pk": { + "name": "model_profiles_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.models": { + "name": "models", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "tool_usage": { + "name": "tool_usage", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_confidential": { + "name": "is_confidential", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "start_with_reasoning": { + "name": "start_with_reasoning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "supports_parallel_tool_calls": { + "name": "supports_parallel_tool_calls", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_models_user_id": { + "name": "idx_models_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "models_user_id_user_id_fk": { + "name": "models_user_id_user_id_fk", + "tableFrom": "models", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "models_id_user_id_pk": { + "name": "models_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.modes": { + "name": "modes", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_modes_user_id": { + "name": "idx_modes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "modes_user_id_user_id_fk": { + "name": "modes_user_id_user_id_fk", + "tableFrom": "modes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "modes_id_user_id_pk": { + "name": "modes_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.prompts": { + "name": "prompts", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_prompts_user_id": { + "name": "idx_prompts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prompts_user_id_user_id_fk": { + "name": "prompts_user_id_user_id_fk", + "tableFrom": "prompts", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "prompts_id_user_id_pk": { + "name": "prompts_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.settings": { + "name": "settings", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_settings_user_id": { + "name": "idx_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_id_user_id_pk": { + "name": "settings_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.skills": { + "name": "skills", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_skills_user_id": { + "name": "idx_skills_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_user_id_user_id_fk": { + "name": "skills_user_id_user_id_fk", + "tableFrom": "skills", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skills_id_user_id_pk": { + "name": "skills_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.tasks": { + "name": "tasks", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item": { + "name": "item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_complete": { + "name": "is_complete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_tasks_user_id": { + "name": "idx_tasks_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_user_id_user_id_fk": { + "name": "tasks_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tasks_id_user_id_pk": { + "name": "tasks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.triggers": { + "name": "triggers", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_time": { + "name": "trigger_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_triggers_user_id": { + "name": "idx_triggers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "triggers_user_id_user_id_fk": { + "name": "triggers_user_id_user_id_fk", + "tableFrom": "triggers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expire": { + "name": "expire", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rate_limits_expire_idx": { + "name": "rate_limits_expire_idx", + "columns": [ + { + "expression": "expire", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encryption_metadata": { + "name": "encryption_metadata", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canary_iv": { + "name": "canary_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_ctext": { + "name": "canary_ctext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_secret_hash": { + "name": "canary_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encryption_metadata_user_id_user_id_fk": { + "name": "encryption_metadata_user_id_user_id_fk", + "tableFrom": "encryption_metadata", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.envelopes": { + "name": "envelopes", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapped_ck": { + "name": "wrapped_ck", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_envelopes_user_id": { + "name": "idx_envelopes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "envelopes_device_id_devices_id_fk": { + "name": "envelopes_device_id_devices_id_fk", + "tableFrom": "envelopes", + "tableTo": "devices", + "schemaTo": "powersync", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "envelopes_user_id_user_id_fk": { + "name": "envelopes_user_id_user_id_fk", + "tableFrom": "envelopes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_challenge": { + "name": "otp_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_token": { + "name": "challenge_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_challenge_email_unique": { + "name": "otp_challenge_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0022_snapshot.json b/backend/drizzle/meta/0022_snapshot.json new file mode 100644 index 000000000..6d505145b --- /dev/null +++ b/backend/drizzle/meta/0022_snapshot.json @@ -0,0 +1,2225 @@ +{ + "id": "a6a3e956-f2ba-4baa-8397-5d9c7a123e09", + "prevId": "d89abf7b-a292-429d-85bc-33095d917080", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_deviceId_idx": { + "name": "session_deviceId_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "waitlist_status_idx": { + "name": "waitlist_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_batch_id_idx": { + "name": "waitlist_batch_id_idx", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.agents": { + "name": "agents", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agents_user_id": { + "name": "idx_agents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_user_id_user_id_fk": { + "name": "agents_user_id_user_id_fk", + "tableFrom": "agents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_id_user_id_pk": { + "name": "agents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_messages": { + "name": "chat_messages", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_thread_id": { + "name": "chat_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache": { + "name": "cache", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_messages_user_id": { + "name": "idx_chat_messages_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_threads": { + "name": "chat_threads", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "was_triggered_by_automation": { + "name": "was_triggered_by_automation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "context_size": { + "name": "context_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acp_session_id": { + "name": "acp_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_threads_user_id": { + "name": "idx_chat_threads_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.devices": { + "name": "devices", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trusted": { + "name": "trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "approval_pending": { + "name": "approval_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mlkem_public_key": { + "name": "mlkem_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_id_attested_at": { + "name": "node_id_attested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_devices_user_id": { + "name": "idx_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_user_id_fk": { + "name": "devices_user_id_user_id_fk", + "tableFrom": "devices", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.model_profiles": { + "name": "model_profiles", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "max_steps": { + "name": "max_steps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "nudge_threshold": { + "name": "nudge_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_system_message_mode_developer": { + "name": "use_system_message_mode_developer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "tools_override": { + "name": "tools_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_previews_override": { + "name": "link_previews_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_mode_addendum": { + "name": "chat_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_mode_addendum": { + "name": "search_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "research_mode_addendum": { + "name": "research_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "citation_reinforcement_enabled": { + "name": "citation_reinforcement_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "citation_reinforcement_prompt": { + "name": "citation_reinforcement_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_final_step": { + "name": "nudge_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_preventive": { + "name": "nudge_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_retry": { + "name": "nudge_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_final_step": { + "name": "nudge_search_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_preventive": { + "name": "nudge_search_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_retry": { + "name": "nudge_search_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_options": { + "name": "provider_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_model_profiles_user_id": { + "name": "idx_model_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_profiles_user_id_user_id_fk": { + "name": "model_profiles_user_id_user_id_fk", + "tableFrom": "model_profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_profiles_id_user_id_pk": { + "name": "model_profiles_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.models": { + "name": "models", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "tool_usage": { + "name": "tool_usage", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_confidential": { + "name": "is_confidential", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "start_with_reasoning": { + "name": "start_with_reasoning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "supports_parallel_tool_calls": { + "name": "supports_parallel_tool_calls", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_models_user_id": { + "name": "idx_models_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "models_user_id_user_id_fk": { + "name": "models_user_id_user_id_fk", + "tableFrom": "models", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "models_id_user_id_pk": { + "name": "models_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.modes": { + "name": "modes", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_modes_user_id": { + "name": "idx_modes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "modes_user_id_user_id_fk": { + "name": "modes_user_id_user_id_fk", + "tableFrom": "modes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "modes_id_user_id_pk": { + "name": "modes_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.prompts": { + "name": "prompts", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_prompts_user_id": { + "name": "idx_prompts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prompts_user_id_user_id_fk": { + "name": "prompts_user_id_user_id_fk", + "tableFrom": "prompts", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "prompts_id_user_id_pk": { + "name": "prompts_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.settings": { + "name": "settings", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_settings_user_id": { + "name": "idx_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_id_user_id_pk": { + "name": "settings_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.skills": { + "name": "skills", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_skills_user_id": { + "name": "idx_skills_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_user_id_user_id_fk": { + "name": "skills_user_id_user_id_fk", + "tableFrom": "skills", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skills_id_user_id_pk": { + "name": "skills_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.tasks": { + "name": "tasks", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item": { + "name": "item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_complete": { + "name": "is_complete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_tasks_user_id": { + "name": "idx_tasks_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_user_id_user_id_fk": { + "name": "tasks_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tasks_id_user_id_pk": { + "name": "tasks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.triggers": { + "name": "triggers", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_time": { + "name": "trigger_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_triggers_user_id": { + "name": "idx_triggers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "triggers_user_id_user_id_fk": { + "name": "triggers_user_id_user_id_fk", + "tableFrom": "triggers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expire": { + "name": "expire", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rate_limits_expire_idx": { + "name": "rate_limits_expire_idx", + "columns": [ + { + "expression": "expire", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encryption_metadata": { + "name": "encryption_metadata", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canary_iv": { + "name": "canary_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_ctext": { + "name": "canary_ctext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_secret_hash": { + "name": "canary_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encryption_metadata_user_id_user_id_fk": { + "name": "encryption_metadata_user_id_user_id_fk", + "tableFrom": "encryption_metadata", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.envelopes": { + "name": "envelopes", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapped_ck": { + "name": "wrapped_ck", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_envelopes_user_id": { + "name": "idx_envelopes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "envelopes_device_id_devices_id_fk": { + "name": "envelopes_device_id_devices_id_fk", + "tableFrom": "envelopes", + "tableTo": "devices", + "schemaTo": "powersync", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "envelopes_user_id_user_id_fk": { + "name": "envelopes_user_id_user_id_fk", + "tableFrom": "envelopes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_challenge": { + "name": "otp_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_token": { + "name": "challenge_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_challenge_email_unique": { + "name": "otp_challenge_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0023_snapshot.json b/backend/drizzle/meta/0023_snapshot.json new file mode 100644 index 000000000..0d6d70545 --- /dev/null +++ b/backend/drizzle/meta/0023_snapshot.json @@ -0,0 +1,2539 @@ +{ + "id": "7b54bed8-c61a-4528-a81d-a6c7133808fe", + "prevId": "a6a3e956-f2ba-4baa-8397-5d9c7a123e09", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikey_reference_id_user_id_fk": { + "name": "apikey_reference_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_code_deviceCode_idx": { + "name": "device_code_deviceCode_idx", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_userCode_idx": { + "name": "device_code_userCode_idx", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_deviceId_idx": { + "name": "session_deviceId_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_new": { + "name": "is_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "waitlist_status_idx": { + "name": "waitlist_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_batch_id_idx": { + "name": "waitlist_batch_id_idx", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.agents": { + "name": "agents", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agents_user_id": { + "name": "idx_agents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_user_id_user_id_fk": { + "name": "agents_user_id_user_id_fk", + "tableFrom": "agents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agents_id_user_id_pk": { + "name": "agents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_messages": { + "name": "chat_messages", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_thread_id": { + "name": "chat_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache": { + "name": "cache", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_messages_user_id": { + "name": "idx_chat_messages_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.chat_threads": { + "name": "chat_threads", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "was_triggered_by_automation": { + "name": "was_triggered_by_automation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "context_size": { + "name": "context_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acp_session_id": { + "name": "acp_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_chat_threads_user_id": { + "name": "idx_chat_threads_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.devices": { + "name": "devices", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trusted": { + "name": "trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "approval_pending": { + "name": "approval_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mlkem_public_key": { + "name": "mlkem_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_id_attested_at": { + "name": "node_id_attested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_devices_user_id": { + "name": "idx_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_user_id_fk": { + "name": "devices_user_id_user_id_fk", + "tableFrom": "devices", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.model_profiles": { + "name": "model_profiles", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "max_steps": { + "name": "max_steps", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "nudge_threshold": { + "name": "nudge_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_system_message_mode_developer": { + "name": "use_system_message_mode_developer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "tools_override": { + "name": "tools_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_previews_override": { + "name": "link_previews_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_mode_addendum": { + "name": "chat_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_mode_addendum": { + "name": "search_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "research_mode_addendum": { + "name": "research_mode_addendum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "citation_reinforcement_enabled": { + "name": "citation_reinforcement_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "citation_reinforcement_prompt": { + "name": "citation_reinforcement_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_final_step": { + "name": "nudge_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_preventive": { + "name": "nudge_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_retry": { + "name": "nudge_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_final_step": { + "name": "nudge_search_final_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_preventive": { + "name": "nudge_search_preventive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nudge_search_retry": { + "name": "nudge_search_retry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_options": { + "name": "provider_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_model_profiles_user_id": { + "name": "idx_model_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_profiles_user_id_user_id_fk": { + "name": "model_profiles_user_id_user_id_fk", + "tableFrom": "model_profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_profiles_id_user_id_pk": { + "name": "model_profiles_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.models": { + "name": "models", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "tool_usage": { + "name": "tool_usage", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_confidential": { + "name": "is_confidential", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "start_with_reasoning": { + "name": "start_with_reasoning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "supports_parallel_tool_calls": { + "name": "supports_parallel_tool_calls", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_models_user_id": { + "name": "idx_models_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "models_user_id_user_id_fk": { + "name": "models_user_id_user_id_fk", + "tableFrom": "models", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "models_id_user_id_pk": { + "name": "models_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.modes": { + "name": "modes", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_modes_user_id": { + "name": "idx_modes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "modes_user_id_user_id_fk": { + "name": "modes_user_id_user_id_fk", + "tableFrom": "modes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "modes_id_user_id_pk": { + "name": "modes_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.prompts": { + "name": "prompts", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_prompts_user_id": { + "name": "idx_prompts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prompts_user_id_user_id_fk": { + "name": "prompts_user_id_user_id_fk", + "tableFrom": "prompts", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "prompts_id_user_id_pk": { + "name": "prompts_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.settings": { + "name": "settings", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_settings_user_id": { + "name": "idx_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_id_user_id_pk": { + "name": "settings_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.skills": { + "name": "skills", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_skills_user_id": { + "name": "idx_skills_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_user_id_user_id_fk": { + "name": "skills_user_id_user_id_fk", + "tableFrom": "skills", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skills_id_user_id_pk": { + "name": "skills_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.tasks": { + "name": "tasks", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item": { + "name": "item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_complete": { + "name": "is_complete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "default_hash": { + "name": "default_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_tasks_user_id": { + "name": "idx_tasks_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_user_id_user_id_fk": { + "name": "tasks_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tasks_id_user_id_pk": { + "name": "tasks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "powersync.triggers": { + "name": "triggers", + "schema": "powersync", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_time": { + "name": "trigger_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_triggers_user_id": { + "name": "idx_triggers_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "triggers_user_id_user_id_fk": { + "name": "triggers_user_id_user_id_fk", + "tableFrom": "triggers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limits": { + "name": "rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expire": { + "name": "expire", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rate_limits_expire_idx": { + "name": "rate_limits_expire_idx", + "columns": [ + { + "expression": "expire", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encryption_metadata": { + "name": "encryption_metadata", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canary_iv": { + "name": "canary_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_ctext": { + "name": "canary_ctext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canary_secret_hash": { + "name": "canary_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encryption_metadata_user_id_user_id_fk": { + "name": "encryption_metadata_user_id_user_id_fk", + "tableFrom": "encryption_metadata", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.envelopes": { + "name": "envelopes", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapped_ck": { + "name": "wrapped_ck", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_envelopes_user_id": { + "name": "idx_envelopes_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "envelopes_device_id_devices_id_fk": { + "name": "envelopes_device_id_devices_id_fk", + "tableFrom": "envelopes", + "tableTo": "devices", + "schemaTo": "powersync", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "envelopes_user_id_user_id_fk": { + "name": "envelopes_user_id_user_id_fk", + "tableFrom": "envelopes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_challenge": { + "name": "otp_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge_token": { + "name": "challenge_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_challenge_email_unique": { + "name": "otp_challenge_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 16a6dae4a..fd2061fb6 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -148,6 +148,27 @@ "when": 1781546434494, "tag": "0020_conscious_silverclaw", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1782728897095, + "tag": "0021_overrated_mindworm", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1783438243625, + "tag": "0022_bizarre_zzzax", + "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1783439554245, + "tag": "0023_dry_quasimodo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 7af588edb..61277e147 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^0.22.1", + "@better-auth/api-key": "^1.6.9", "@better-auth/sso": "^1.6.9", "@electric-sql/pglite": "^0.4.4", "@elysiajs/cors": "^1.4.0", diff --git a/backend/src/api/encryption.test.ts b/backend/src/api/encryption.test.ts index 068c40078..7f1579002 100644 --- a/backend/src/api/encryption.test.ts +++ b/backend/src/api/encryption.test.ts @@ -51,7 +51,12 @@ describe('Encryption API', () => { const now = new Date() const expiresAt = new Date(now.getTime() + 3600 * 1000) - const createUserAndSession = async (userId: string, token: string, email = `${userId}@test.com`) => { + const createUserAndSession = async ( + userId: string, + token: string, + email = `${userId}@test.com`, + deviceId?: string, + ) => { await db.insert(userTable).values({ id: userId, name: 'Test User', @@ -67,6 +72,7 @@ describe('Encryption API', () => { createdAt: now, updatedAt: now, userId, + ...(deviceId ? { deviceId } : {}), }) } @@ -1358,4 +1364,465 @@ describe('Encryption API', () => { expect(response.status).toBe(204) }) }) + + // ─── PATCH /devices/:deviceId/node-id ─────────────────────────────── + + describe('PATCH /devices/:deviceId/node-id', () => { + const nodeId = 'k51qzi5uqu5dh-test-endpoint-id' + + const patchNodeId = (token: string, callerDeviceId: string, targetDeviceId: string, body: object) => + app.handle( + new Request(`${baseUrl}/devices/${targetDeviceId}/node-id`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${signToken(token)}`, + 'X-Device-ID': callerDeviceId, + }, + body: JSON.stringify(body), + }), + ) + + it('rejects without canarySecret (body validation)', async () => { + await createUserAndSession(p('u-nid-nobody'), p('tok-nid-nobody')) + await insertDevice(p('d-nid-nobody-caller'), p('u-nid-nobody'), { trusted: true }) + await insertDevice(p('d-nid-nobody-target'), p('u-nid-nobody'), { trusted: true }) + + const response = await patchNodeId(p('tok-nid-nobody'), p('d-nid-nobody-caller'), p('d-nid-nobody-target'), { + nodeId, + }) + + expect(response.status).toBe(422) + }) + + it('rejects with wrong canarySecret', async () => { + await createUserAndSession(p('u-nid-bad'), p('tok-nid-bad')) + await insertDevice(p('d-nid-bad-caller'), p('u-nid-bad'), { trusted: true }) + await insertDevice(p('d-nid-bad-target'), p('u-nid-bad'), { trusted: true }) + await insertCanaryWithSecret(p('u-nid-bad')) + + const response = await patchNodeId(p('tok-nid-bad'), p('d-nid-bad-caller'), p('d-nid-bad-target'), { + nodeId, + canarySecret: 'wrong-secret', + }) + + expect(response.status).toBe(403) + expect((await response.json()).error).toBe('Invalid canary secret') + }) + + it('rejects when caller device is not trusted', async () => { + await createUserAndSession(p('u-nid-untrusted'), p('tok-nid-untrusted')) + await insertDevice(p('d-nid-untrusted-caller'), p('u-nid-untrusted')) // pending, not trusted + await insertDevice(p('d-nid-untrusted-target'), p('u-nid-untrusted'), { trusted: true }) + await insertCanaryWithSecret(p('u-nid-untrusted')) + + const response = await patchNodeId( + p('tok-nid-untrusted'), + p('d-nid-untrusted-caller'), + p('d-nid-untrusted-target'), + { nodeId, canarySecret: testCanarySecret }, + ) + + expect(response.status).toBe(403) + expect((await response.json()).error).toBe('Only trusted devices can set a device node ID') + }) + + it('returns 404 when target device does not exist', async () => { + await createUserAndSession(p('u-nid-missing'), p('tok-nid-missing')) + await insertDevice(p('d-nid-missing-caller'), p('u-nid-missing'), { trusted: true }) + await insertCanaryWithSecret(p('u-nid-missing')) + + const response = await patchNodeId(p('tok-nid-missing'), p('d-nid-missing-caller'), p('d-nid-missing-absent'), { + nodeId, + canarySecret: testCanarySecret, + }) + + expect(response.status).toBe(404) + }) + + it('rejects binding a node ID to a revoked target', async () => { + await createUserAndSession(p('u-nid-revoked'), p('tok-nid-revoked')) + await insertDevice(p('d-nid-revoked-caller'), p('u-nid-revoked'), { trusted: true }) + await insertDevice(p('d-nid-revoked-target'), p('u-nid-revoked'), { trusted: true, revokedAt: now }) + await insertCanaryWithSecret(p('u-nid-revoked')) + + const response = await patchNodeId(p('tok-nid-revoked'), p('d-nid-revoked-caller'), p('d-nid-revoked-target'), { + nodeId, + canarySecret: testCanarySecret, + }) + + expect(response.status).toBe(404) + }) + + it('rejects binding a node ID to a denied target, so a denied peer cannot restore its P2P binding', async () => { + await createUserAndSession(p('u-nid-denied'), p('tok-nid-denied')) + await insertDevice(p('d-nid-denied-caller'), p('u-nid-denied'), { trusted: true }) + // Denied state denyDevice leaves: trusted=false, approvalPending=false, revokedAt=null. + await insertDevice(p('d-nid-denied-target'), p('u-nid-denied'), { trusted: false, approvalPending: false }) + await insertCanaryWithSecret(p('u-nid-denied')) + + const response = await patchNodeId(p('tok-nid-denied'), p('d-nid-denied-caller'), p('d-nid-denied-target'), { + nodeId, + canarySecret: testCanarySecret, + }) + + expect(response.status).toBe(404) + }) + + it('sets node_id with a valid canarySecret from a trusted device', async () => { + await createUserAndSession(p('u-nid-ok'), p('tok-nid-ok')) + await insertDevice(p('d-nid-ok-caller'), p('u-nid-ok'), { trusted: true }) + await insertDevice(p('d-nid-ok-target'), p('u-nid-ok'), { trusted: true }) + await insertCanaryWithSecret(p('u-nid-ok')) + + const response = await patchNodeId(p('tok-nid-ok'), p('d-nid-ok-caller'), p('d-nid-ok-target'), { + nodeId, + canarySecret: testCanarySecret, + }) + + expect(response.status).toBe(200) + expect((await response.json()).nodeId).toBe(nodeId) + + const [row] = await db + .select() + .from(devicesTable) + .where(eq(devicesTable.id, p('d-nid-ok-target'))) + expect(row.nodeId).toBe(nodeId) + expect(row.nodeIdAttestedAt).not.toBeNull() + }) + }) + + // ─── POST /devices/me/node-id (self-enroll) ───────────────────────── + + describe('POST /devices/me/node-id (self-enroll)', () => { + const nodeId = 'k51qzi5uqu5dh-self-enroll' + + const selfEnroll = (token: string, callerDeviceId: string | undefined, body: object) => + app.handle( + new Request(`${baseUrl}/devices/me/node-id`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${signToken(token)}`, + ...(callerDeviceId ? { 'X-Device-ID': callerDeviceId } : {}), + }, + body: JSON.stringify(body), + }), + ) + + it('returns 401 without auth', async () => { + const response = await app.handle( + new Request(`${baseUrl}/devices/me/node-id`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Device-ID': p('d-se') }, + body: JSON.stringify({ nodeId }), + }), + ) + expect(response.status).toBe(401) + }) + + it('returns 400 when X-Device-ID header missing', async () => { + await createUserAndSession(p('u-se-nohdr'), p('tok-se-nohdr'), undefined, p('d-se-nohdr')) + await insertDevice(p('d-se-nohdr'), p('u-se-nohdr'), { trusted: true }) + + const response = await selfEnroll(p('tok-se-nohdr'), undefined, { nodeId }) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('X-Device-ID header is required') + }) + + it('rejects with 422 when nodeId missing (body validation)', async () => { + await createUserAndSession(p('u-se-noid'), p('tok-se-noid'), undefined, p('d-se-noid')) + await insertDevice(p('d-se-noid'), p('u-se-noid'), { trusted: true }) + + const response = await selfEnroll(p('tok-se-noid'), p('d-se-noid'), {}) + expect(response.status).toBe(422) + }) + + it("writes the caller's own node_id when X-Device-ID matches the session device", async () => { + await createUserAndSession(p('u-se-ok'), p('tok-se-ok'), undefined, p('d-se-ok')) + await insertDevice(p('d-se-ok'), p('u-se-ok'), { trusted: true }) + + const response = await selfEnroll(p('tok-se-ok'), p('d-se-ok'), { nodeId }) + expect(response.status).toBe(200) + expect((await response.json()).nodeId).toBe(nodeId) + + const [row] = await db + .select() + .from(devicesTable) + .where(eq(devicesTable.id, p('d-se-ok'))) + expect(row.nodeId).toBe(nodeId) + expect(row.nodeIdAttestedAt).not.toBeNull() + }) + + it('self-enrolls a pending device (not yet trusted) — harmless, allowlist only surfaces trusted', async () => { + await createUserAndSession(p('u-se-pending'), p('tok-se-pending'), undefined, p('d-se-pending')) + await insertDevice(p('d-se-pending'), p('u-se-pending')) // pending, not trusted + + const response = await selfEnroll(p('tok-se-pending'), p('d-se-pending'), { nodeId }) + expect(response.status).toBe(200) + + const [row] = await db + .select() + .from(devicesTable) + .where(eq(devicesTable.id, p('d-se-pending'))) + expect(row.nodeId).toBe(nodeId) + }) + + it("rejects writing another same-account device's node_id (X-Device-ID != session device)", async () => { + // Session is bound to the caller's own device, but X-Device-ID targets a sibling device. + await createUserAndSession(p('u-se-other'), p('tok-se-other'), undefined, p('d-se-self')) + await insertDevice(p('d-se-self'), p('u-se-other'), { trusted: true }) + await insertDevice(p('d-se-victim'), p('u-se-other'), { trusted: true }) + + const response = await selfEnroll(p('tok-se-other'), p('d-se-victim'), { nodeId }) + expect(response.status).toBe(403) + expect((await response.json()).error).toBe('X-Device-ID does not match the authenticated device') + + // The victim device's node_id must be untouched. + const [victim] = await db + .select() + .from(devicesTable) + .where(eq(devicesTable.id, p('d-se-victim'))) + expect(victim.nodeId).toBeNull() + }) + + it('rejects when the session is not linked to any device', async () => { + await createUserAndSession(p('u-se-unlinked'), p('tok-se-unlinked')) // no session.deviceId + await insertDevice(p('d-se-unlinked'), p('u-se-unlinked'), { trusted: true }) + + const response = await selfEnroll(p('tok-se-unlinked'), p('d-se-unlinked'), { nodeId }) + expect(response.status).toBe(403) + expect((await response.json()).error).toBe('X-Device-ID does not match the authenticated device') + }) + + it('returns 404 when the caller device is revoked (setDeviceNodeId matches 0 rows)', async () => { + await createUserAndSession(p('u-se-revoked'), p('tok-se-revoked'), undefined, p('d-se-revoked')) + await insertDevice(p('d-se-revoked'), p('u-se-revoked'), { trusted: true, revokedAt: now }) + + const response = await selfEnroll(p('tok-se-revoked'), p('d-se-revoked'), { nodeId }) + expect(response.status).toBe(404) + expect((await response.json()).error).toBe('Device not found') + }) + }) + + // ─── GET /devices/allowlist ───────────────────────────────────────── + + describe('GET /devices/allowlist', () => { + const getAllowlist = (token: string) => + app.handle( + new Request(`${baseUrl}/devices/allowlist`, { + headers: { Authorization: `Bearer ${signToken(token)}` }, + }), + ) + + const insertDeviceWithNode = async ( + id: string, + userId: string, + nodeId: string | null, + options: { + trusted?: boolean + approvalPending?: boolean + revokedAt?: Date + deviceType?: 'normal' | 'bridge' + } = {}, + ) => { + const { trusted = true, approvalPending = !trusted, revokedAt, deviceType = 'normal' } = options + await db.insert(devicesTable).values({ + id, + userId, + name: 'Node Device', + trusted, + approvalPending, + deviceType, + lastSeen: now, + createdAt: now, + ...(nodeId ? { nodeId, nodeIdAttestedAt: now } : {}), + ...(revokedAt ? { revokedAt } : {}), + }) + } + + it('returns 401 without auth', async () => { + const response = await app.handle(new Request(`${baseUrl}/devices/allowlist`)) + expect(response.status).toBe(401) + }) + + it('returns only trusted, non-revoked, non-null node_ids for the caller account', async () => { + await createUserAndSession(p('u-al'), p('tok-al')) + await insertDeviceWithNode(p('al-trusted'), p('u-al'), 'node-trusted', { deviceType: 'normal' }) + await insertDeviceWithNode(p('al-bridge'), p('u-al'), 'node-bridge', { deviceType: 'bridge' }) + // Excluded: pending (untrusted), revoked, trusted-but-no-node_id. + await insertDeviceWithNode(p('al-pending'), p('u-al'), 'node-pending', { trusted: false }) + await insertDeviceWithNode(p('al-revoked'), p('u-al'), 'node-revoked', { trusted: true, revokedAt: now }) + await insertDeviceWithNode(p('al-nonode'), p('u-al'), null, { trusted: true }) + + const response = await getAllowlist(p('tok-al')) + expect(response.status).toBe(200) + const rows = (await response.json()).nodeIds as Array<{ nodeId: string; deviceType: string }> + expect(rows.map((n) => n.nodeId).sort()).toEqual(['node-bridge', 'node-trusted']) + expect(rows.find((n) => n.nodeId === 'node-bridge')?.deviceType).toBe('bridge') + }) + + it("excludes a denied device's node_id (trusted=false, approvalPending=false)", async () => { + await createUserAndSession(p('u-al-denied'), p('tok-al-denied')) + await insertDeviceWithNode(p('al-denied'), p('u-al-denied'), 'node-denied', { + trusted: false, + approvalPending: false, + }) + + const response = await getAllowlist(p('tok-al-denied')) + expect(response.status).toBe(200) + expect((await response.json()).nodeIds).toEqual([]) + }) + + it('never leaks another account rows', async () => { + await createUserAndSession(p('u-al-a'), p('tok-al-a'), `${p('al-a')}@test.com`) + await createUserAndSession(p('u-al-b'), p('tok-al-b'), `${p('al-b')}@test.com`) + await insertDeviceWithNode(p('al-mine'), p('u-al-a'), 'node-mine') + await insertDeviceWithNode(p('al-theirs'), p('u-al-b'), 'node-theirs') + + const response = await getAllowlist(p('tok-al-a')) + expect(response.status).toBe(200) + const nodeIds = (await response.json()).nodeIds as Array<{ nodeId: string }> + expect(nodeIds.map((n) => n.nodeId)).toEqual(['node-mine']) + }) + + it('returns an empty list when the account has no bound trusted devices', async () => { + await createUserAndSession(p('u-al-empty'), p('tok-al-empty')) + + const response = await getAllowlist(p('tok-al-empty')) + expect(response.status).toBe(200) + expect((await response.json()).nodeIds).toEqual([]) + }) + }) + + // ─── POST /devices/bridge (register bridge) ───────────────────────── + + describe('POST /devices/bridge', () => { + const bridgeNodeId = 'k51qzi5uqu5dh-bridge-server' + + const registerBridge = (token: string, body: object) => + app.handle( + new Request(`${baseUrl}/devices/bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${signToken(token)}` }, + body: JSON.stringify(body), + }), + ) + + it('returns 401 without auth', async () => { + const response = await app.handle( + new Request(`${baseUrl}/devices/bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nodeId: bridgeNodeId }), + }), + ) + expect(response.status).toBe(401) + }) + + it('rejects with 422 when nodeId missing (body validation)', async () => { + await createUserAndSession(p('u-br-noid'), p('tok-br-noid')) + const response = await registerBridge(p('tok-br-noid'), { name: 'My Bridge' }) + expect(response.status).toBe(422) + }) + + it("registers a bridge as a trusted device_type='bridge' on the caller's account", async () => { + await createUserAndSession(p('u-br-ok'), p('tok-br-ok')) + + const response = await registerBridge(p('tok-br-ok'), { nodeId: bridgeNodeId, name: 'My Bridge' }) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.nodeId).toBe(bridgeNodeId) + expect(json.deviceType).toBe('bridge') + + const [row] = await db.select().from(devicesTable).where(eq(devicesTable.id, json.id)) + expect(row.userId).toBe(p('u-br-ok')) + expect(row.deviceType).toBe('bridge') + expect(row.trusted).toBe(true) + expect(row.approvalPending).toBe(false) + expect(row.revokedAt).toBeNull() + expect(row.nodeId).toBe(bridgeNodeId) + expect(row.nodeIdAttestedAt).not.toBeNull() + expect(row.name).toBe('My Bridge') + }) + + it('defaults the name to "Bridge" when omitted', async () => { + await createUserAndSession(p('u-br-noname'), p('tok-br-noname')) + const response = await registerBridge(p('tok-br-noname'), { nodeId: bridgeNodeId }) + expect(response.status).toBe(200) + const [row] = await db + .select() + .from(devicesTable) + .where(eq(devicesTable.id, (await response.json()).id)) + expect(row.name).toBe('Bridge') + }) + + it('is idempotent on repeat add of the same bridge (no duplicate rows)', async () => { + await createUserAndSession(p('u-br-idem'), p('tok-br-idem')) + + const first = await registerBridge(p('tok-br-idem'), { nodeId: bridgeNodeId, name: 'Bridge A' }) + const firstId = (await first.json()).id + const second = await registerBridge(p('tok-br-idem'), { nodeId: bridgeNodeId, name: 'Bridge B' }) + const secondId = (await second.json()).id + expect(secondId).toBe(firstId) + + const rows = await db + .select() + .from(devicesTable) + .where(eq(devicesTable.userId, p('u-br-idem'))) + expect(rows.length).toBe(1) + expect(rows[0].name).toBe('Bridge B') + }) + + it('refuses to resurrect a revoked bridge (same NodeId) — stays revoked', async () => { + await createUserAndSession(p('u-br-rev'), p('tok-br-rev')) + + const first = await registerBridge(p('tok-br-rev'), { nodeId: bridgeNodeId }) + const bridgeId = (await first.json()).id + await db.update(devicesTable).set({ revokedAt: now, trusted: false }).where(eq(devicesTable.id, bridgeId)) + + const response = await registerBridge(p('tok-br-rev'), { nodeId: bridgeNodeId }) + expect(response.status).toBe(403) + expect((await response.json()).error).toBe('Device has been revoked') + + // The row must remain revoked and untrusted — revocation is not undone by a re-add. + const [row] = await db.select().from(devicesTable).where(eq(devicesTable.id, bridgeId)) + expect(row.revokedAt).not.toBeNull() + expect(row.trusted).toBe(false) + }) + + it('scopes the bridge row to the caller and never collides across accounts (same nodeId)', async () => { + await createUserAndSession(p('u-br-a'), p('tok-br-a'), `${p('br-a')}@test.com`) + await createUserAndSession(p('u-br-b'), p('tok-br-b'), `${p('br-b')}@test.com`) + + const a = await registerBridge(p('tok-br-a'), { nodeId: bridgeNodeId, name: 'A bridge' }) + const b = await registerBridge(p('tok-br-b'), { nodeId: bridgeNodeId, name: 'B bridge' }) + const aId = (await a.json()).id + const bId = (await b.json()).id + expect(aId).not.toBe(bId) + + const [aRow] = await db.select().from(devicesTable).where(eq(devicesTable.id, aId)) + const [bRow] = await db.select().from(devicesTable).where(eq(devicesTable.id, bId)) + expect(aRow.userId).toBe(p('u-br-a')) + expect(aRow.name).toBe('A bridge') + expect(bRow.userId).toBe(p('u-br-b')) + expect(bRow.name).toBe('B bridge') + }) + + it("ignores any client-supplied deviceType and always sets 'bridge'", async () => { + await createUserAndSession(p('u-br-spoof'), p('tok-br-spoof')) + + // deviceType is not part of the route body schema, so a client attempt is dropped by + // validation-stripping and the server still fixes device_type='bridge'. + const response = await registerBridge(p('tok-br-spoof'), { nodeId: bridgeNodeId, deviceType: 'normal' }) + expect(response.status).toBe(200) + expect((await response.json()).deviceType).toBe('bridge') + + const [row] = await db + .select() + .from(devicesTable) + .where(eq(devicesTable.userId, p('u-br-spoof'))) + expect(row.deviceType).toBe('bridge') + }) + }) }) diff --git a/backend/src/api/encryption.ts b/backend/src/api/encryption.ts index 45ddbf636..d33737773 100644 --- a/backend/src/api/encryption.ts +++ b/backend/src/api/encryption.ts @@ -9,8 +9,11 @@ import { getDeviceById, linkSessionToDevice, registerDevice, + registerBridgeDevice, denyDevice, markDeviceTrusted, + setDeviceNodeId, + getTrustedNodeIds, getEnvelopeByDeviceId, hasEnvelopesForUser, upsertEnvelope, @@ -417,6 +420,134 @@ export const createEncryptionRoutes = (auth: Auth, database: typeof DbType) => }), }, ) + .post( + '/devices/:deviceId/node-id', + async ({ params, body, request, set, user: sessionUser }) => { + const userId = sessionUser!.id + const callerDeviceId = request.headers.get('x-device-id')?.trim() + + if (!callerDeviceId) { + set.status = 400 + return { error: 'X-Device-ID header is required' } + } + + // Proof-of-CK-possession prevents X-Device-ID spoofing: only a device that holds the + // Content Key can decrypt the canary and produce this secret. Mirrors the deny route. + const validProof = await verifyCanaryProof(database, userId, body.canarySecret) + if (!validProof) { + set.status = 403 + return { error: 'Invalid canary secret' } + } + + // Caller must be a trusted device (defense-in-depth — Option B1: only a trusted app + // device may attest another device's P2P identity). + const callerDevice = await getDeviceById(database, callerDeviceId) + if (!callerDevice || callerDevice.userId !== userId || !callerDevice.trusted) { + set.status = 403 + return { error: 'Only trusted devices can set a device node ID' } + } + + const updated = await setDeviceNodeId(database, params.deviceId, userId, body.nodeId) + if (updated.length === 0) { + set.status = 404 + return { error: 'Device not found' } + } + + return { nodeId: body.nodeId } + }, + { + auth: true, + body: t.Object({ + nodeId: t.String({ minLength: 1, maxLength: 2048 }), + canarySecret: t.String({ maxLength: 500 }), + }), + }, + ) + // Self-enroll: a device binds its OWN iroh endpoint identity (node_id) — no canary / + // Content Key. Proof-of-possession happens at the iroh handshake on connect, so declaring a + // node_id you can't dial as grants nothing. The caller is pinned to the session's server-set + // deviceId (from linkSessionToDevice), so it can only write the device its session is bound + // to — not an arbitrary target the way the canary-gated POST /devices/:deviceId/node-id can. + // The trust boundary is the account (same-account is auto-trusted by design, D1); a live + // same-account session is trusted to declare its own node_id, and a rogue one is mitigated by + // revoke + the bridge's heartbeat re-check (D7/D8), not by intra-account isolation here. + .post( + '/devices/me/node-id', + async ({ body, request, set, user: sessionUser, session }) => { + const userId = sessionUser!.id + const callerDeviceId = request.headers.get('x-device-id')?.trim() + + if (!callerDeviceId) { + set.status = 400 + return { error: 'X-Device-ID header is required' } + } + + // Pin to the session's bound device. A null (never-linked) session.deviceId also fails + // this, fail-closed. This is the server-side identity — X-Device-ID alone is client-set. + if (session.deviceId !== callerDeviceId) { + set.status = 403 + return { error: 'X-Device-ID does not match the authenticated device' } + } + + const updated = await setDeviceNodeId(database, callerDeviceId, userId, body.nodeId) + if (updated.length === 0) { + set.status = 404 + return { error: 'Device not found' } + } + + return { nodeId: body.nodeId } + }, + { + auth: true, + body: t.Object({ + nodeId: t.String({ minLength: 1, maxLength: 2048 }), + }), + }, + ) + // Account allowlist: the trusted, non-revoked node_ids of the caller's account (D2). The + // bridge fetches this with a bearer, caches it, and auto-allows same-account iroh peers. + // Scoped to the caller's user_id — never leaks another account's rows. + .get( + '/devices/allowlist', + async ({ user: sessionUser }) => { + const userId = sessionUser!.id + const nodeIds = await getTrustedNodeIds(database, userId) + return { nodeIds } + }, + { auth: true }, + ) + // Register a BRIDGE device on the caller's account (D4 step 2). Adding an ACP/MCP bridge in the + // app registers it here as a device with server-set `device_type='bridge'` (clients can't set + // device_type — it's deny-listed from PowerSync upload, so a bridge MUST be created via this + // route, not raw sync). Inserted trusted + non-revoked because the user deliberately added + // their own bridge. Scoped to the caller's account (registerBridgeDevice derives the row id + // from userId, and the `bridge-` id namespace is reserved from client uploads), so it can + // never write another user's row. node_id here is the bridge's SERVER NodeId; it surfaces in + // getTrustedNodeIds (the account allowlist), which is intentional and harmless — no peer can + // dial as the bridge's key without its ed25519 private key, so listing it grants nothing. + // A revoked bridge is not re-addable with the same NodeId (registerBridgeDevice returns no + // row) — mirroring how a revoked normal device is refused re-registration; bring the bridge + // back by re-keying it (a fresh NodeId). + .post( + '/devices/bridge', + async ({ body, set, user: sessionUser }) => { + const userId = sessionUser!.id + const name = body.name?.trim() || 'Bridge' + const [device] = await registerBridgeDevice(database, { userId, nodeId: body.nodeId, name }) + if (!device) { + set.status = 403 + return { error: 'Device has been revoked' } + } + return { id: device.id, nodeId: device.nodeId, deviceType: device.deviceType } + }, + { + auth: true, + body: t.Object({ + nodeId: t.String({ minLength: 1, maxLength: 2048 }), + name: t.Optional(t.String({ maxLength: 100 })), + }), + }, + ) .post( '/devices/me/cancel-pending', async ({ request, set, user: sessionUser }) => { diff --git a/backend/src/api/powersync.test.ts b/backend/src/api/powersync.test.ts index 6b78615c1..668e3a5d3 100644 --- a/backend/src/api/powersync.test.ts +++ b/backend/src/api/powersync.test.ts @@ -59,6 +59,8 @@ const powersyncSettings: Settings = { oidcDiscoveryUrl: '', betterAuthUrl: 'http://localhost:8000', betterAuthSecret, + deviceAuthExpiresIn: '30m', + deviceAuthInterval: '5s', e2eeEnabled: true, rateLimitEnabled: false, swaggerEnabled: false, diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 154b742c5..f550b5a44 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -21,7 +21,8 @@ import { getTrustedIpHeaders } from '@/utils/request' import { createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { anonymous, bearer, emailOTP } from 'better-auth/plugins' +import { anonymous, bearer, deviceAuthorization, emailOTP, type TimeString } from 'better-auth/plugins' +import { apiKey } from '@better-auth/api-key' import { sso } from '@better-auth/sso' import { isAutoApprovedDomain, @@ -349,6 +350,27 @@ export const createAuth = (database: typeof DbType, emailDeps: AuthEmailDeps = { await sendSignInEmail({ email: normalizedEmail, otp, verifyUrl }) }, }), + // Device Authorization Grant (RFC 8628) — lets the headless `thunderbolt` CLI log in: + // it requests a device+user code, the user approves at `${appUrl}/device`, then polls + // /device/token, which returns the account's session token as `access_token`. Turning + // that into a credential the CLI can replay (this stack runs bearer with requireSignature, + // so the raw token is not directly usable as `Authorization: Bearer`) is the CLI-login + // phase's job. verificationUri is derived from appUrl so self-hosters point their own + // frontend without any hardcoded host. + deviceAuthorization({ + // Validated at runtime by the plugin's own time-string schema; the cast just + // narrows the env-derived string to Better Auth's TimeString literal type. + expiresIn: settings.deviceAuthExpiresIn as TimeString, + interval: settings.deviceAuthInterval as TimeString, + verificationUri: `${settings.appUrl}/device`, + }), + // API keys (PATs) for zero-human CI / self-host. enableSessionForAPIKeys mocks a session + // for the key's owner when an `x-api-key` header is present, so a key authenticates as the + // account that created it — the escape hatch when the interactive device grant can't run. + // The plugin's per-key rate limit defaults to 10 requests/day, which is unusable for + // automation; disable it and rely on the account/IP-level limits already in this stack. + // A leaked PAT is mitigated by revoking the key (same posture as a compromised device). + apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), // Anonymous plugin is operator-gated: register only when AUTH_ALLOW_ANONYMOUS=true. // Otherwise /v1/api/auth/sign-in/anonymous returns 404 — defense-in-depth against // a malicious client bypassing the frontend `VITE_AUTH_ENABLE_ANONYMOUS` overlay. diff --git a/backend/src/auth/device-auth-apikey.test.ts b/backend/src/auth/device-auth-apikey.test.ts new file mode 100644 index 000000000..2055ea752 --- /dev/null +++ b/backend/src/auth/device-auth-apikey.test.ts @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { session as sessionTable, user } from '@/db/schema' +import { authHeaders, createTestApp, type TestAppHandle } from '@/test-utils/e2e' +import { eq } from 'drizzle-orm' + +const authBase = 'http://localhost/v1/api/auth' +const clientId = 'thunderbolt-cli' + +const postJson = (app: TestAppHandle['app'], path: string, body: unknown, headers: Record = {}) => + app.handle( + new Request(`${authBase}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }), + ) + +const getSession = (app: TestAppHandle['app'], headers: Record) => + app.handle(new Request(`${authBase}/get-session`, { headers })) + +const deviceTokenBody = (deviceCode: string) => ({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: clientId, +}) + +type DeviceCodeResponse = { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete: string + expires_in: number + interval: number +} + +describe('Device Authorization Grant (RFC 8628)', () => { + let harness: TestAppHandle + + beforeEach(async () => { + harness = await createTestApp() + }) + + afterEach(async () => { + await harness.cleanup() + }) + + const requestDeviceCode = async (): Promise => { + const res = await postJson(harness.app, '/device/code', { client_id: clientId }) + expect(res.status).toBe(200) + return (await res.json()) as DeviceCodeResponse + } + + it('issues device + user codes pointing at the frontend /device page with an RFC-8628 polling interval', async () => { + const body = await requestDeviceCode() + + expect(body.device_code).toBeTruthy() + expect(body.user_code).toBeTruthy() + // verificationUri is derived from appUrl (self-hostable, no hardcoded host). + expect(body.verification_uri).toBe('http://localhost:1420/device') + expect(body.verification_uri_complete).toBe(`http://localhost:1420/device?user_code=${body.user_code}`) + expect(body.interval).toBe(5) + expect(body.expires_in).toBe(1800) + }) + + it('returns authorization_pending while the user has not approved yet', async () => { + const { device_code: deviceCode } = await requestDeviceCode() + + const pendingRes = await postJson(harness.app, '/device/token', deviceTokenBody(deviceCode)) + expect(pendingRes.status).toBe(400) + expect(((await pendingRes.json()) as { error: string }).error).toBe('authorization_pending') + }) + + it('exchanges an approved code for a bearer that authenticates as the approving account', async () => { + const { device_code: deviceCode, user_code: userCode } = await requestDeviceCode() + + const approveRes = await postJson(harness.app, '/device/approve', { userCode }, authHeaders(harness.bearerToken)) + expect(approveRes.status).toBe(200) + + const tokenRes = await postJson(harness.app, '/device/token', deviceTokenBody(deviceCode)) + expect(tokenRes.status).toBe(200) + const granted = (await tokenRes.json()) as { access_token: string; token_type: string } + expect(granted.token_type).toBe('Bearer') + expect(granted.access_token).toBeTruthy() + + // The grant minted a real session row bound to the approving account. + const [owner] = await harness.db.select({ id: user.id }).from(user).where(eq(user.email, harness.email)).limit(1) + const [grantedSession] = await harness.db + .select({ userId: sessionTable.userId }) + .from(sessionTable) + .where(eq(sessionTable.token, granted.access_token)) + .limit(1) + expect(grantedSession?.userId).toBe(owner.id) + }) + + it('rejects device approval from an unauthenticated caller', async () => { + const { user_code: userCode } = await requestDeviceCode() + const res = await postJson(harness.app, '/device/approve', { userCode }) + expect(res.status).toBe(401) + }) +}) + +describe('API key authentication', () => { + let harness: TestAppHandle + + beforeEach(async () => { + harness = await createTestApp() + }) + + afterEach(async () => { + await harness.cleanup() + }) + + it('authenticates as the owning account when the key is sent via x-api-key', async () => { + const createRes = await postJson(harness.app, '/api-key/create', {}, authHeaders(harness.bearerToken)) + expect(createRes.status).toBe(200) + const { key } = (await createRes.json()) as { key: string } + expect(key).toBeTruthy() + + const sessionRes = await getSession(harness.app, { 'x-api-key': key }) + expect(sessionRes.status).toBe(200) + const session = (await sessionRes.json()) as { user: { email: string } } | null + expect(session?.user.email).toBe(harness.email) + }) + + it('rejects an unknown api key', async () => { + const res = await getSession(harness.app, { 'x-api-key': 'not-a-real-key' }) + expect(res.status).toBe(403) + expect(((await res.json()) as { code: string }).code).toBe('INVALID_API_KEY') + }) + + it('does not throttle a key to the plugin default of 10 requests/day', async () => { + const createRes = await postJson(harness.app, '/api-key/create', {}, authHeaders(harness.bearerToken)) + const { key } = (await createRes.json()) as { key: string } + + // 12 > the plugin's default maxRequests (10) — all must succeed since per-key + // rate limiting is disabled for headless CI/self-host use. + for (const _ of Array.from({ length: 12 })) { + const res = await getSession(harness.app, { 'x-api-key': key }) + expect(res.status).toBe(200) + const session = (await res.json()) as { user: { email: string } } | null + expect(session?.user.email).toBe(harness.email) + } + }) +}) diff --git a/backend/src/config/settings.ts b/backend/src/config/settings.ts index cff4914a3..5c0db3b2e 100644 --- a/backend/src/config/settings.ts +++ b/backend/src/config/settings.ts @@ -51,6 +51,13 @@ const settingsSchema = z betterAuthUrl: z.string().default('http://localhost:8000'), betterAuthSecret: z.string().min(1), + // Device Authorization Grant (RFC 8628) — used by the `thunderbolt` CLI to log in + // headless. `deviceAuthExpiresIn` is how long the device/user code stays valid before + // the CLI must restart the flow; `deviceAuthInterval` is the minimum client polling + // gap. Better Auth time strings ('30m', '5s', '1h'). Defaults follow RFC 8628 §3.2. + deviceAuthExpiresIn: z.string().default('30m'), + deviceAuthInterval: z.string().default('5s'), + // General settings logLevel: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).default('INFO'), port: z.coerce.number().default(8000), @@ -177,6 +184,8 @@ const parseSettings = (): Settings => { samlCert: process.env.SAML_CERT || '', betterAuthUrl: process.env.BETTER_AUTH_URL || 'http://localhost:8000', betterAuthSecret: process.env.BETTER_AUTH_SECRET, + deviceAuthExpiresIn: process.env.DEVICE_AUTH_EXPIRES_IN || '30m', + deviceAuthInterval: process.env.DEVICE_AUTH_INTERVAL || '5s', logLevel: (process.env.LOG_LEVEL || 'INFO').toUpperCase(), port: process.env.PORT || '8000', appUrl: process.env.APP_URL || 'http://localhost:1420', diff --git a/backend/src/dal/devices.test.ts b/backend/src/dal/devices.test.ts index 3277fb812..9698f8a06 100644 --- a/backend/src/dal/devices.test.ts +++ b/backend/src/dal/devices.test.ts @@ -7,7 +7,17 @@ import { devicesTable } from '@/db/schema' import { createTestDb } from '@/test-utils/db' import { eq } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { countActiveDevices, denyDevice, getDeviceById, markDeviceTrusted, revokeDevice, upsertDevice } from './devices' +import { + countActiveDevices, + denyDevice, + getDeviceById, + getTrustedNodeIds, + markDeviceTrusted, + registerDevice, + revokeDevice, + setDeviceNodeId, + upsertDevice, +} from './devices' describe('devices DAL', () => { let db: Awaited>['db'] @@ -88,6 +98,25 @@ describe('devices DAL', () => { const rows = await db.select().from(devicesTable).where(eq(devicesTable.id, 'd5')) expect(rows[0].revokedAt).toBeNull() }) + + it('clears the iroh node binding so a revoked endpoint stops syncing', async () => { + const now = new Date() + await db.insert(devicesTable).values({ + id: 'd6', + userId, + name: 'Bound Laptop', + lastSeen: now, + createdAt: now, + trusted: true, + nodeId: 'node-abc', + nodeIdAttestedAt: now, + }) + await revokeDevice(db, 'd6', userId) + const rows = await db.select().from(devicesTable).where(eq(devicesTable.id, 'd6')) + expect(rows[0].revokedAt).not.toBeNull() + expect(rows[0].nodeId).toBeNull() + expect(rows[0].nodeIdAttestedAt).toBeNull() + }) }) describe('countActiveDevices', () => { @@ -156,6 +185,24 @@ describe('devices DAL', () => { expect(row!.revokedAt).toBeNull() }) + it('clears the stale iroh node binding on deny, mirroring revoke', async () => { + const now = new Date() + await db.insert(devicesTable).values({ + id: 'd-deny-node', + userId, + name: 'Pending Bound', + approvalPending: true, + nodeId: 'stale-deny-node', + nodeIdAttestedAt: now, + lastSeen: now, + createdAt: now, + }) + const rows = await denyDevice(db, 'd-deny-node', userId) + expect(rows[0].approvalPending).toBe(false) + expect(rows[0].nodeId).toBeNull() + expect(rows[0].nodeIdAttestedAt).toBeNull() + }) + it('is a no-op on a trusted device (TOCTOU race guard)', async () => { const now = new Date() await db.insert(devicesTable).values({ @@ -210,6 +257,130 @@ describe('devices DAL', () => { }) }) + describe('setDeviceNodeId', () => { + const seedDevice = (over: Record) => + db + .insert(devicesTable) + .values({ id: 'd-bind', userId, name: 'Bind', lastSeen: new Date(), createdAt: new Date(), ...over }) + + it('binds a node_id on a trusted device', async () => { + await seedDevice({ trusted: true, approvalPending: false }) + const rows = await setDeviceNodeId(db, 'd-bind', userId, 'node-trusted') + expect(rows[0].nodeId).toBe('node-trusted') + expect(rows[0].nodeIdAttestedAt).not.toBeNull() + }) + + it('binds a node_id on a pending device (attestation during pairing)', async () => { + await seedDevice({ trusted: false, approvalPending: true }) + const rows = await setDeviceNodeId(db, 'd-bind', userId, 'node-pending') + expect(rows[0].nodeId).toBe('node-pending') + }) + + it('refuses to (re-)bind a DENIED device so a denied peer cannot restore its P2P binding', async () => { + // The state denyDevice leaves: trusted=false, approvalPending=false, revokedAt=null. + await seedDevice({ trusted: false, approvalPending: false }) + const rows = await setDeviceNodeId(db, 'd-bind', userId, 'node-denied') + expect(rows).toHaveLength(0) + }) + + it('refuses to bind a revoked device', async () => { + await seedDevice({ trusted: true, approvalPending: false, revokedAt: new Date() }) + const rows = await setDeviceNodeId(db, 'd-bind', userId, 'node-revoked') + expect(rows).toHaveLength(0) + }) + }) + + describe('getTrustedNodeIds', () => { + const seed = ( + id: string, + nodeId: string | null, + over: { trusted?: boolean; approvalPending?: boolean; revokedAt?: Date; deviceType?: 'normal' | 'bridge' } = {}, + forUserId = userId, + ) => { + const now = new Date() + const { trusted = true, approvalPending = !trusted, revokedAt, deviceType = 'normal' } = over + return db.insert(devicesTable).values({ + id, + userId: forUserId, + name: id, + trusted, + approvalPending, + deviceType, + lastSeen: now, + createdAt: now, + ...(nodeId ? { nodeId, nodeIdAttestedAt: now } : {}), + ...(revokedAt ? { revokedAt } : {}), + }) + } + + it('returns node_id + device_type for trusted, non-revoked, bound devices only', async () => { + await seed('tn-trusted', 'node-a', { deviceType: 'normal' }) + await seed('tn-bridge', 'node-b', { deviceType: 'bridge' }) + await seed('tn-pending', 'node-c', { trusted: false }) + await seed('tn-revoked', 'node-d', { trusted: true, revokedAt: new Date() }) + await seed('tn-nonode', null, { trusted: true }) + + const rows = await getTrustedNodeIds(db, userId) + const byNode = Object.fromEntries(rows.map((r) => [r.nodeId, r.deviceType])) + expect(Object.keys(byNode).sort()).toEqual(['node-a', 'node-b']) + expect(byNode['node-b']).toBe('bridge') + }) + + it('is scoped to the user and never returns another account rows', async () => { + const now = new Date() + const otherUserId = 'other-user-nodeids' + await db.insert(user).values({ + id: otherUserId, + name: 'Other', + email: 'other-nodeids@test.com', + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await seed('tn-mine', 'node-mine') + await seed('tn-theirs', 'node-theirs', {}, otherUserId) + + const rows = await getTrustedNodeIds(db, userId) + expect(rows.map((r) => r.nodeId)).toEqual(['node-mine']) + }) + + it('returns an empty array when no trusted bound devices exist', async () => { + const rows = await getTrustedNodeIds(db, userId) + expect(rows).toEqual([]) + }) + }) + + describe('registerDevice', () => { + it('clears the stale iroh node binding on re-registration, mirroring revoke', async () => { + const now = new Date() + await db.insert(devicesTable).values({ + id: 'd-reg-rebind', + userId, + name: 'Old Bound', + trusted: true, + approvalPending: false, + publicKey: 'old-pk', + mlkemPublicKey: 'old-mlkem', + nodeId: 'stale-node-id', + nodeIdAttestedAt: now, + lastSeen: now, + createdAt: now, + }) + const rows = await registerDevice(db, { + id: 'd-reg-rebind', + userId, + name: 'Old Bound', + publicKey: 'new-pk', + mlkemPublicKey: 'new-mlkem', + }) + expect(rows[0].trusted).toBe(false) + expect(rows[0].approvalPending).toBe(true) + expect(rows[0].publicKey).toBe('new-pk') + expect(rows[0].nodeId).toBeNull() + expect(rows[0].nodeIdAttestedAt).toBeNull() + }) + }) + describe('markDeviceTrusted', () => { it('returns updated row when device is not revoked', async () => { const now = new Date() diff --git a/backend/src/dal/devices.ts b/backend/src/dal/devices.ts index bca03b7a3..e7a28053c 100644 --- a/backend/src/dal/devices.ts +++ b/backend/src/dal/devices.ts @@ -4,7 +4,14 @@ import type { db as DbType } from '@/db/client' import { devicesTable } from '@/db/schema' -import { and, count, eq, isNull } from 'drizzle-orm' +import { and, count, eq, isNotNull, isNull, or } from 'drizzle-orm' +import { createHash } from 'crypto' + +/** Deterministic device id for a bridge, derived from (userId, nodeId). Keying the row on this + * makes re-registration of the same bridge an idempotent upsert on (userId, nodeId) without a + * dedicated unique constraint, and guarantees one account can never collide with another's id. */ +const bridgeDeviceId = (userId: string, nodeId: string) => + `bridge-${createHash('sha256').update(`${userId}:${nodeId}`).digest('hex')}` /** Get a device by ID. Returns userId, trusted, approvalPending, publicKey, and revokedAt, or null if not found. */ export const getDeviceById = async (database: typeof DbType, deviceId: string) => @@ -53,11 +60,13 @@ export const upsertDevice = async ( .returning() /** Revoke a device for a specific user. Sets revokedAt timestamp and clears approval state. + * Also clears the iroh P2P binding (node_id/node_id_attested_at) so the revoked endpoint + * identity stops syncing and a bridge operator's allowlist entry for it goes stale. * Only matches non-revoked devices so re-revoking is a no-op. */ export const revokeDevice = async (database: typeof DbType, deviceId: string, userId: string) => database .update(devicesTable) - .set({ revokedAt: new Date(), trusted: false, approvalPending: false }) + .set({ revokedAt: new Date(), trusted: false, approvalPending: false, nodeId: null, nodeIdAttestedAt: null }) .where(and(eq(devicesTable.id, deviceId), eq(devicesTable.userId, userId), isNull(devicesTable.revokedAt))) .returning() @@ -92,15 +101,96 @@ export const countActiveDevices = async (database: typeof DbType, userId: string return rows[0]?.count ?? 0 } -/** Deny a pending device by clearing approval_pending. The trusted=false guard prevents - * a TOCTOU race from revoking a concurrently-approved device. */ +/** Deny a pending device by clearing approval_pending, and clear its iroh P2P binding + * (node_id/node_id_attested_at) so a denied device stops being dialable — mirroring + * revokeDevice. The trusted=false guard prevents a TOCTOU race from revoking a + * concurrently-approved device. */ export const denyDevice = async (database: typeof DbType, deviceId: string, userId: string) => database .update(devicesTable) - .set({ approvalPending: false }) + .set({ approvalPending: false, nodeId: null, nodeIdAttestedAt: null }) .where(and(eq(devicesTable.id, deviceId), eq(devicesTable.userId, userId), eq(devicesTable.trusted, false))) .returning() +/** + * Bind a device to an iroh P2P endpoint identity (node_id) and stamp the attestation time. + * Only matches non-revoked devices that are trusted or still pending approval — a DENIED device + * (trusted=false, approval_pending=false) is excluded so a denied peer cannot be re-bound after + * denyDevice cleared its node_id, and a revoked device cannot be re-bound either. + * Returns updated rows so callers can detect the 0-row (not found / revoked / denied) case. + */ +export const setDeviceNodeId = async (database: typeof DbType, deviceId: string, userId: string, nodeId: string) => + database + .update(devicesTable) + .set({ nodeId, nodeIdAttestedAt: new Date() }) + .where( + and( + eq(devicesTable.id, deviceId), + eq(devicesTable.userId, userId), + isNull(devicesTable.revokedAt), + or(eq(devicesTable.trusted, true), eq(devicesTable.approvalPending, true)), + ), + ) + .returning() + +/** + * List the account's iroh allowlist: the endpoint identities (node_id) of every trusted, + * non-revoked device that has bound one. Scoped to `userId`, so it never returns another + * account's rows. A headless bridge fetches this (bearer-auth) to auto-allow same-account + * peers without embedding PowerSync or holding the E2EE Content Key. Denied/pending devices + * are excluded (only `trusted` rows), as are revoked ones and rows with a null node_id. + */ +export const getTrustedNodeIds = async (database: typeof DbType, userId: string) => + database + .select({ nodeId: devicesTable.nodeId, deviceType: devicesTable.deviceType }) + .from(devicesTable) + .where( + and( + eq(devicesTable.userId, userId), + eq(devicesTable.trusted, true), + isNull(devicesTable.revokedAt), + isNotNull(devicesTable.nodeId), + ), + ) + +/** + * Register (or idempotently re-register) a BRIDGE device on the caller's account (D4 step 2). + * A bridge is a device with `device_type='bridge'`, keyed by its server NodeId. The user + * deliberately added their own ACP/MCP bridge, so it is inserted trusted and non-revoked. + * `device_type` is set here on the server — clients can never set it (it's deny-listed from + * PowerSync upload). The row id is derived from (userId, nodeId), so re-adding the same bridge + * upserts the same row instead of duplicating. The `bridge-` id namespace is reserved from client + * uploads (see powersync.ts), so the conflict is always the caller's own row. + * + * A REVOKED bridge is never silently resurrected: `setWhere` requires `revoked_at IS NULL`, so an + * upsert onto a revoked row updates nothing and `.returning()` is empty — mirroring how a revoked + * normal device is refused re-registration. Callers treat the empty result as "revoked". + */ +export const registerBridgeDevice = async ( + database: typeof DbType, + bridge: { userId: string; nodeId: string; name: string }, +) => { + const now = new Date() + const fields = { + name: bridge.name, + deviceType: 'bridge' as const, + trusted: true, + approvalPending: false, + nodeId: bridge.nodeId, + nodeIdAttestedAt: now, + lastSeen: now, + } + return database + .insert(devicesTable) + .values({ id: bridgeDeviceId(bridge.userId, bridge.nodeId), userId: bridge.userId, createdAt: now, ...fields }) + .onConflictDoUpdate({ + target: devicesTable.id, + set: fields, + setWhere: and(eq(devicesTable.userId, bridge.userId), isNull(devicesTable.revokedAt)), + }) + .returning() +} + /** * Register a device with a public key for encryption. * Used during the encryption setup flow when the FE sends POST /devices. @@ -125,7 +215,8 @@ export const registerDevice = async ( // On conflict: update public keys, reset trusted to false and mark as pending approval // (device must go through approval flow again), and update lastSeen. This handles both // concurrent re-registration races and pre-encryption devices that were backfilled as - // trusted without an envelope. + // trusted without an envelope. Also clear the iroh P2P binding (node_id/node_id_attested_at) + // so the old endpoint identity stops syncing while keys/trust reset — mirroring revokeDevice. .onConflictDoUpdate({ target: devicesTable.id, set: { @@ -133,6 +224,8 @@ export const registerDevice = async ( mlkemPublicKey: device.mlkemPublicKey, trusted: false, approvalPending: true, + nodeId: null, + nodeIdAttestedAt: null, lastSeen: new Date(), }, setWhere: eq(devicesTable.userId, device.userId), diff --git a/backend/src/dal/index.ts b/backend/src/dal/index.ts index d333f47e1..bce18540e 100644 --- a/backend/src/dal/index.ts +++ b/backend/src/dal/index.ts @@ -10,6 +10,9 @@ export { denyDevice, markDeviceTrusted, registerDevice, + registerBridgeDevice, + setDeviceNodeId, + getTrustedNodeIds, countActiveDevices, } from './devices' diff --git a/backend/src/dal/powersync.test.ts b/backend/src/dal/powersync.test.ts new file mode 100644 index 000000000..207981b97 --- /dev/null +++ b/backend/src/dal/powersync.test.ts @@ -0,0 +1,359 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { user } from '@/db/auth-schema' +import { chatThreadsTable, devicesTable, modelsTable, settingsTable } from '@/db/schema' +import { createTestDb } from '@/test-utils/db' +import { and, eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { applyOperation } from './powersync' + +describe('powersync upload gate (applyOperation)', () => { + let db: Awaited>['db'] + let cleanup: () => Promise + const userId = 'user-a' + const otherUserId = 'user-b' + + const insertUser = async (id: string, email: string) => { + const now = new Date() + await db.insert(user).values({ id, name: id, email, emailVerified: true, createdAt: now, updatedAt: now }) + } + + beforeEach(async () => { + const testEnv = await createTestDb() + db = testEnv.db + cleanup = testEnv.cleanup + await insertUser(userId, 'a@test.com') + await insertUser(otherUserId, 'b@test.com') + }) + + afterEach(async () => { + if (cleanup) { + await cleanup() + } + }) + + describe('invalid / malformed operations', () => { + it('rejects an unknown table name', async () => { + const ok = await applyOperation(db, { op: 'PUT', type: 'not_a_table', id: 'x', data: { foo: 1 } }, userId) + expect(ok).toBe(false) + }) + }) + + describe('DELETE allowlist (security: protected tables)', () => { + it('REFUSES to delete a devices row via upload — identity-deletion vector', async () => { + const now = new Date() + await db + .insert(devicesTable) + .values({ id: 'dev-1', userId, name: 'Phone', trusted: true, lastSeen: now, createdAt: now }) + + const ok = await applyOperation(db, { op: 'DELETE', type: 'devices', id: 'dev-1' }, userId) + + expect(ok).toBe(false) + const rows = await db.select().from(devicesTable).where(eq(devicesTable.id, 'dev-1')) + // row fully intact — not deleted, not mutated + expect(rows).toHaveLength(1) + expect(rows[0].name).toBe('Phone') + expect(rows[0].trusted).toBe(true) + expect(rows[0].revokedAt).toBeNull() + }) + + it('allows deleting a non-protected table row owned by the user', async () => { + await applyOperation(db, { op: 'PUT', type: 'models', id: 'm-del', data: { name: 'Custom' } }, userId) + + const ok = await applyOperation(db, { op: 'DELETE', type: 'models', id: 'm-del' }, userId) + + expect(ok).toBe(true) + const rows = await db.select().from(modelsTable).where(eq(modelsTable.id, 'm-del')) + expect(rows).toHaveLength(0) + }) + + it('does NOT delete another user’s row even on an allowed table (user isolation)', async () => { + await applyOperation(db, { op: 'PUT', type: 'chat_threads', id: 't-iso', data: { title: 'A' } }, userId) + + // user-b tries to delete user-a's thread (chat_threads pk is global id) + const ok = await applyOperation(db, { op: 'DELETE', type: 'chat_threads', id: 't-iso' }, otherUserId) + + expect(ok).toBe(false) + const rows = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 't-iso')) + expect(rows).toHaveLength(1) + expect(rows[0].userId).toBe(userId) + }) + + it('returns false when deleting a non-existent row', async () => { + const ok = await applyOperation(db, { op: 'DELETE', type: 'models', id: 'ghost' }, userId) + expect(ok).toBe(false) + }) + }) + + describe('PUT (insert-or-upsert)', () => { + it('forces user_id to the authenticated user, ignoring a spoofed payload user_id', async () => { + const ok = await applyOperation( + db, + { op: 'PUT', type: 'chat_threads', id: 't-spoof', data: { title: 'Mine', user_id: otherUserId } }, + userId, + ) + + expect(ok).toBe(true) + const rows = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 't-spoof')) + expect(rows[0].userId).toBe(userId) + }) + + it('ignores a spoofed payload id and uses op.id', async () => { + await applyOperation( + db, + { op: 'PUT', type: 'chat_threads', id: 'real-id', data: { id: 'fake-id', title: 'X' } }, + userId, + ) + const real = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 'real-id')) + const fake = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 'fake-id')) + expect(real).toHaveLength(1) + expect(fake).toHaveLength(0) + }) + + it('strips EVERY server-managed deny column on devices (client cannot grant itself trust)', async () => { + const ok = await applyOperation( + db, + { + op: 'PUT', + type: 'devices', + id: 'dev-trust', + data: { + name: 'Evil', // the only non-protected field + trusted: true, + approval_pending: true, + public_key: 'attacker-pk', + mlkem_public_key: 'attacker-mlkem', + revoked_at: '2020-01-01T00:00:00.000Z', + app_version: '99.99.99', + device_type: 'bridge', + node_id: 'attacker-node', + node_id_attested_at: '2020-01-01T00:00:00.000Z', + }, + }, + userId, + ) + + expect(ok).toBe(true) + const rows = await db.select().from(devicesTable).where(eq(devicesTable.id, 'dev-trust')) + expect(rows[0].name).toBe('Evil') // non-protected column is applied + expect(rows[0].trusted).toBe(false) // server-managed defaults, never client-set + expect(rows[0].approvalPending).toBe(false) + expect(rows[0].publicKey).toBeNull() + expect(rows[0].mlkemPublicKey).toBeNull() + expect(rows[0].revokedAt).toBeNull() + expect(rows[0].appVersion).toBeNull() + expect(rows[0].deviceType).toBe('normal') // client can't relabel itself a bridge + expect(rows[0].nodeId).toBeNull() + expect(rows[0].nodeIdAttestedAt).toBeNull() + }) + + it('REFUSES to create a devices row in the reserved bridge- id namespace (squat vector)', async () => { + // The bridge- id namespace is server-owned (POST /devices/bridge). A client upload must not + // be able to pre-create a row there, or it could squat another account's deterministic + // bridge id and block that account's later registration. + const ok = await applyOperation( + db, + { op: 'PUT', type: 'devices', id: 'bridge-deadbeef', data: { name: 'Squat' } }, + userId, + ) + expect(ok).toBe(false) + const rows = await db.select().from(devicesTable).where(eq(devicesTable.id, 'bridge-deadbeef')) + expect(rows).toHaveLength(0) + }) + + it('updates an existing row on conflict (upsert)', async () => { + await applyOperation(db, { op: 'PUT', type: 'chat_threads', id: 't-up', data: { title: 'First' } }, userId) + await applyOperation(db, { op: 'PUT', type: 'chat_threads', id: 't-up', data: { title: 'Second' } }, userId) + + const rows = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 't-up')) + expect(rows).toHaveLength(1) + expect(rows[0].title).toBe('Second') + }) + + it('does NOT overwrite another user’s row when a global-pk id collides (cross-user isolation)', async () => { + await applyOperation( + db, + { op: 'PUT', type: 'chat_threads', id: 't-shared', data: { title: 'Owned by A' } }, + userId, + ) + + // user-b PUTs the same global id; insert conflicts, but setWhere(userId=b) matches no row + const ok = await applyOperation( + db, + { op: 'PUT', type: 'chat_threads', id: 't-shared', data: { title: 'Hijacked by B' } }, + otherUserId, + ) + + expect(ok).toBe(true) // gate accepts (no error) but the update affects 0 rows + const rows = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 't-shared')) + expect(rows).toHaveLength(1) + expect(rows[0].userId).toBe(userId) + expect(rows[0].title).toBe('Owned by A') + }) + + it('does not clobber an existing row when an upsert carries no real columns (only unknowns)', async () => { + await applyOperation(db, { op: 'PUT', type: 'models', id: 'm-noop', data: { name: 'Original' } }, userId) + + // data has only unknown columns -> the conflicting upsert has nothing to update + const ok = await applyOperation(db, { op: 'PUT', type: 'models', id: 'm-noop', data: { bogus: 'z' } }, userId) + + expect(ok).toBe(true) + const rows = await db.select().from(modelsTable).where(eq(modelsTable.id, 'm-noop')) + expect(rows[0].name).toBe('Original') // untouched, no clobber to null + }) + + it('creates a bare row (id + user_id + defaults) when an insert carries only unknown columns', async () => { + const ok = await applyOperation(db, { op: 'PUT', type: 'models', id: 'm-bare', data: { bogus: 'z' } }, userId) + + expect(ok).toBe(true) + const rows = await db.select().from(modelsTable).where(eq(modelsTable.id, 'm-bare')) + expect(rows).toHaveLength(1) + expect(rows[0].userId).toBe(userId) + expect(rows[0].name).toBeNull() + expect(rows[0].enabled).toBe(1) // schema default applied + }) + + it('converts ISO timestamp strings to Date on the PUT path too', async () => { + const iso = '2026-03-04T05:06:07.000Z' + await applyOperation(db, { op: 'PUT', type: 'chat_threads', id: 't-put-ts', data: { deleted_at: iso } }, userId) + const rows = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 't-put-ts')) + expect(rows[0].deletedAt).toBeInstanceOf(Date) + expect(rows[0].deletedAt!.toISOString()).toBe(iso) + }) + + it('upserts settings whose DB pk column is named "id" but maps to the "key" field', async () => { + await applyOperation(db, { op: 'PUT', type: 'settings', id: 'theme', data: { value: 'dark' } }, userId) + await applyOperation(db, { op: 'PUT', type: 'settings', id: 'theme', data: { value: 'light' } }, userId) + + const rows = await db + .select() + .from(settingsTable) + .where(and(eq(settingsTable.key, 'theme'), eq(settingsTable.userId, userId))) + expect(rows).toHaveLength(1) + expect(rows[0].value).toBe('light') + }) + }) + + describe('PATCH', () => { + it('returns true (drainable no-op) for empty data and leaves the target untouched', async () => { + await applyOperation(db, { op: 'PUT', type: 'models', id: 'm-empty', data: { name: 'Keep' } }, userId) + const ok = await applyOperation(db, { op: 'PATCH', type: 'models', id: 'm-empty', data: {} }, userId) + expect(ok).toBe(true) + const rows = await db.select().from(modelsTable).where(eq(modelsTable.id, 'm-empty')) + expect(rows[0].name).toBe('Keep') + }) + + it('returns true (drainable no-op) when data is omitted entirely', async () => { + const ok = await applyOperation(db, { op: 'PATCH', type: 'models', id: 'whatever' }, userId) + expect(ok).toBe(true) + }) + + it('strips a spoofed id from the patch so it cannot rewrite the primary key', async () => { + await applyOperation(db, { op: 'PUT', type: 'chat_threads', id: 't-pid', data: { title: 'Orig' } }, userId) + + const ok = await applyOperation( + db, + { op: 'PATCH', type: 'chat_threads', id: 't-pid', data: { id: 'hijack', title: 'New' } }, + userId, + ) + + expect(ok).toBe(true) + const stillThere = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 't-pid')) + const hijacked = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 'hijack')) + expect(stillThere[0].title).toBe('New') // allowed field applied + expect(hijacked).toHaveLength(0) // pk untouched + }) + + it('clears a timestamp column when patched with null (un-soft-delete)', async () => { + const iso = '2026-01-02T03:04:05.000Z' + await applyOperation(db, { op: 'PUT', type: 'chat_threads', id: 't-undel', data: { deleted_at: iso } }, userId) + + const ok = await applyOperation( + db, + { op: 'PATCH', type: 'chat_threads', id: 't-undel', data: { deleted_at: null } }, + userId, + ) + + expect(ok).toBe(true) + const rows = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 't-undel')) + expect(rows[0].deletedAt).toBeNull() + }) + + it('returns true (drainable no-op) when only unknown/server-managed columns remain after stripping', async () => { + const now = new Date() + await db.insert(devicesTable).values({ id: 'dev-strip', userId, name: 'Phone', lastSeen: now, createdAt: now }) + + // trusted is deny-listed, user_id is stripped, foo is unknown -> empty patch + const ok = await applyOperation( + db, + { op: 'PATCH', type: 'devices', id: 'dev-strip', data: { trusted: true, user_id: otherUserId, foo: 1 } }, + userId, + ) + + expect(ok).toBe(true) + const rows = await db.select().from(devicesTable).where(eq(devicesTable.id, 'dev-strip')) + expect(rows[0].trusted).toBe(false) + expect(rows[0].userId).toBe(userId) + }) + + it('applies allowed columns but strips deny-listed ones in the same patch', async () => { + const now = new Date() + await db.insert(devicesTable).values({ id: 'dev-mix', userId, name: 'Old', lastSeen: now, createdAt: now }) + + const ok = await applyOperation( + db, + { op: 'PATCH', type: 'devices', id: 'dev-mix', data: { name: 'Renamed', trusted: true } }, + userId, + ) + + expect(ok).toBe(true) + const rows = await db.select().from(devicesTable).where(eq(devicesTable.id, 'dev-mix')) + expect(rows[0].name).toBe('Renamed') + expect(rows[0].trusted).toBe(false) + }) + + it('returns false when patching a non-existent row', async () => { + const ok = await applyOperation( + db, + { op: 'PATCH', type: 'chat_threads', id: 'nope', data: { title: 'X' } }, + userId, + ) + expect(ok).toBe(false) + }) + + it('does not patch another user’s row and reports false', async () => { + await applyOperation(db, { op: 'PUT', type: 'chat_threads', id: 't-patch-iso', data: { title: 'A' } }, userId) + + const ok = await applyOperation( + db, + { op: 'PATCH', type: 'chat_threads', id: 't-patch-iso', data: { title: 'B' } }, + otherUserId, + ) + + expect(ok).toBe(false) + const rows = await db.select().from(chatThreadsTable).where(eq(chatThreadsTable.id, 't-patch-iso')) + expect(rows[0].title).toBe('A') + }) + + it('converts ISO timestamp strings to Date for timestamp columns (soft-delete)', async () => { + await applyOperation(db, { op: 'PUT', type: 'chat_threads', id: 't-ts', data: { title: 'A' } }, userId) + const iso = '2026-01-02T03:04:05.000Z' + + const ok = await applyOperation( + db, + { op: 'PATCH', type: 'chat_threads', id: 't-ts', data: { deleted_at: iso } }, + userId, + ) + + expect(ok).toBe(true) + const rows = await db + .select() + .from(chatThreadsTable) + .where(and(eq(chatThreadsTable.id, 't-ts'), eq(chatThreadsTable.userId, userId))) + expect(rows[0].deletedAt).toBeInstanceOf(Date) + expect(rows[0].deletedAt!.toISOString()).toBe(iso) + }) + }) +}) diff --git a/backend/src/dal/powersync.ts b/backend/src/dal/powersync.ts index a106825fd..ec2d12181 100644 --- a/backend/src/dal/powersync.ts +++ b/backend/src/dal/powersync.ts @@ -17,12 +17,37 @@ const validTables = new Set(powersyncTableNames) /** DB column names that clients cannot set via PowerSync upload (server-managed fields). */ const uploadDenyColumns: Partial> = { - devices: ['revoked_at', 'trusted', 'public_key', 'mlkem_public_key', 'approval_pending', 'app_version'], + devices: [ + 'revoked_at', + 'trusted', + 'public_key', + 'mlkem_public_key', + 'approval_pending', + 'app_version', + // device_type discriminates a bridge from a normal device and drives the account allowlist + // (bridges auto-trust same-account peers). Only the bridge-registration route may set it, so a + // client can't relabel its own device a 'bridge' via a raw PowerSync upload. + 'device_type', + // node_id binds a device to a P2P identity — trust-sensitive. Only settable via the + // canary-gated PATCH /devices/:id/node-id route, never via a raw PowerSync upload. + 'node_id', + 'node_id_attested_at', + ], } /** Tables that cannot be deleted via PowerSync upload — must use dedicated API endpoints. */ const uploadDenyDelete = new Set(['devices']) +/** + * The `bridge-${sha256(userId:nodeId)}` device id namespace is derived and written exclusively by + * the server (registerBridgeDevice / POST /devices/bridge). A raw client upload sets `user_id` to + * itself but `id` freely, so without this guard a client could pre-create a row at a victim's + * deterministic bridge id and squat it (the victim's later upsert would conflict on a foreign row + * and fail). Reserving the prefix from all client ops keeps bridge rows server-owned. + */ +const isReservedDeviceId = (tableName: PowerSyncTableName, id: string) => + tableName === 'devices' && id.startsWith('bridge-') + type PowerSyncOperation = { op: 'PUT' | 'PATCH' | 'DELETE' type: string @@ -82,6 +107,10 @@ export const applyOperation = async ( return false } + if (isReservedDeviceId(tableName, op.id)) { + return false + } + const validDbNames = new Set(Object.keys(dbNameToKey)) const tableWithUserId = table as AnyPgTable & { userId: typeof table.userId } diff --git a/backend/src/db/auth-schema.ts b/backend/src/db/auth-schema.ts index 4530c2e57..dcc451a58 100644 --- a/backend/src/db/auth-schema.ts +++ b/backend/src/db/auth-schema.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { relations } from 'drizzle-orm' -import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core' +import { pgTable, text, timestamp, boolean, integer, index } from 'drizzle-orm/pg-core' import type { User as SharedUser } from '@shared/types/auth' @@ -98,6 +98,71 @@ export const ssoProvider = pgTable('sso_provider', { .notNull(), }) +/** + * Backs the Better Auth `deviceAuthorization` plugin (RFC 8628). One row per in-flight + * device grant: created on /device/code, claimed + approved at /device/approve, consumed + * on /device/token. Column JS keys must match the plugin's field names (`deviceCode`, + * `userCode`, …); SQL names are snake_case. `userId` is null until the user approves. + */ +export const deviceCode = pgTable( + 'device_code', + { + id: text('id').primaryKey(), + deviceCode: text('device_code').notNull(), + userCode: text('user_code').notNull(), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at').notNull(), + status: text('status').notNull(), + lastPolledAt: timestamp('last_polled_at'), + pollingInterval: integer('polling_interval'), + clientId: text('client_id'), + scope: text('scope'), + }, + (table) => [ + index('device_code_deviceCode_idx').on(table.deviceCode), + index('device_code_userCode_idx').on(table.userCode), + ], +) + +/** + * Backs the Better Auth `apiKey` plugin. A personal access token owned by a user + * (`referenceId` → user.id) for headless CI / self-host auth. `key` stores the hashed + * secret; column JS keys mirror the plugin's field names. + */ +export const apikey = pgTable( + 'apikey', + { + id: text('id').primaryKey(), + configId: text('config_id').default('default').notNull(), + name: text('name'), + start: text('start'), + prefix: text('prefix'), + key: text('key').notNull(), + referenceId: text('reference_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + refillInterval: integer('refill_interval'), + refillAmount: integer('refill_amount'), + lastRefillAt: timestamp('last_refill_at'), + enabled: boolean('enabled').default(true).notNull(), + rateLimitEnabled: boolean('rate_limit_enabled').default(true).notNull(), + rateLimitTimeWindow: integer('rate_limit_time_window'), + rateLimitMax: integer('rate_limit_max'), + requestCount: integer('request_count').default(0).notNull(), + remaining: integer('remaining'), + lastRequest: timestamp('last_request'), + expiresAt: timestamp('expires_at'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + permissions: text('permissions'), + metadata: text('metadata'), + }, + (table) => [index('apikey_key_idx').on(table.key), index('apikey_referenceId_idx').on(table.referenceId)], +) + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), diff --git a/backend/src/db/powersync-schema.ts b/backend/src/db/powersync-schema.ts index 3f4fc6e04..bc091963e 100644 --- a/backend/src/db/powersync-schema.ts +++ b/backend/src/db/powersync-schema.ts @@ -242,6 +242,15 @@ export const devicesTable = powersyncSchema.table( createdAt: timestamp('created_at').defaultNow(), revokedAt: timestamp('revoked_at'), appVersion: text('app_version'), + // Discriminates a normal device from an iroh bridge device (ACP/MCP). A bridge is just a + // device with this marker, so the whole device lifecycle (revoke, cap, listing) still applies. + deviceType: text('device_type', { enum: ['normal', 'bridge'] }) + .notNull() + .default('normal'), + // iroh P2P endpoint identity for this device. Nullable: only set once a trusted device + // attests it via the canary-gated POST /devices/:id/node-id route (proof-of-CK-possession). + nodeId: text('node_id'), + nodeIdAttestedAt: timestamp('node_id_attested_at'), }, (table) => [index('idx_devices_user_id').on(table.userId)], ) @@ -256,7 +265,7 @@ export const agentsTable = powersyncSchema.table( .references(() => user.id, { onDelete: 'cascade' }), name: text('name').notNull(), type: text('type', { enum: ['remote-acp', 'managed-acp'] }).notNull(), - transport: text('transport', { enum: ['websocket'] }).notNull(), + transport: text('transport', { enum: ['websocket', 'iroh'] }).notNull(), url: text('url').notNull(), description: text('description'), icon: text('icon'), diff --git a/backend/src/test-utils/settings.ts b/backend/src/test-utils/settings.ts index f4a0b9662..a4c3217a7 100644 --- a/backend/src/test-utils/settings.ts +++ b/backend/src/test-utils/settings.ts @@ -33,6 +33,8 @@ export const createTestSettings = (overrides: Partial = {}): Settings samlCert: '', betterAuthUrl: 'http://localhost:8000', betterAuthSecret: 'test-secret-at-least-32-chars-long!!', + deviceAuthExpiresIn: '30m', + deviceAuthInterval: '5s', logLevel: 'INFO' as const, port: 8000, appUrl: 'http://localhost:1420', diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 3f452c4cc..67ed9d5a3 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -13,6 +13,17 @@ }, "types": ["bun-types"] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "test/**/*.ts", "../shared/**/*.ts"], + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.d.ts", + "test/**/*.ts", + "../shared/acp-types.ts", + "../shared/powersync-tables.ts", + "../shared/proxy-protocol.ts", + "../shared/url.ts", + "../shared/ws-bearer.ts", + "../shared/types/auth.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/bun.lock b/bun.lock index 51925fdb1..a98aaa573 100644 --- a/bun.lock +++ b/bun.lock @@ -11,9 +11,13 @@ "@ai-sdk/openai": "^3.0.11", "@ai-sdk/openai-compatible": "^2.0.12", "@ai-sdk/react": "^3.0.39", + "@anthropic-ai/sdk": "0.91.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@earendil-works/pi-agent-core": "0.80.2", + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2", "@hookform/resolvers": "^5.2.1", "@icons-pack/react-simple-icons": "^13.13.0", "@journeyapps/wa-sqlite": "^1.7.0", @@ -54,23 +58,31 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", "@tauri-apps/plugin-updater": "^2.10.1", + "@zenfs/core": "^2.5.7", + "@zenfs/dom": "^1.2.9", "acorn": "^8.17.0", "ai": "^6.0.37", "better-auth": "^1.6.9", "bson": "^6.10.4", + "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "css-tree": "^3.2.1", "dayjs": "^1.11.13", "drizzle-orm": "^0.45.2", + "events": "^3.3.0", "framer-motion": "^12.23.12", "input-otp": "^1.4.2", + "jsqr": "^1.4.0", + "just-bash": "^3.0.2", "katex": "^0.16.0", "lucide-react": "^1.8.0", "mammoth": "^1.12.0", "maplibre-gl": "^5.24.0", + "path-browserify": "^1.0.1", "posthog-js": "^1.288.1", + "qrcode": "^1.5.4", "react": "^19.2.1", "react-dom": "^19.2.1", "react-hook-form": "^7.62.0", @@ -111,6 +123,7 @@ "@types/bun": "^1.2.20", "@types/css-tree": "^2.3.11", "@types/node": "^25.8.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/sinonjs__fake-timers": "^15.0.1", @@ -175,6 +188,8 @@ "@ai-sdk/react": ["@ai-sdk/react@3.0.192", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.190", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-QB7ViYcM1o2zN6Zo1nVD1FqAkiivCPOi1vmT/OmWAxctmXUGTdWOL7MBs9q3zuzPbZpkty4xDERwemWYoZx6Kw=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], @@ -185,6 +200,54 @@ "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="], + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.974.25", "", { "dependencies": { "@aws-sdk/types": "^3.973.14", "@aws-sdk/xml-builder": "^3.972.32", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.28.0", "@smithy/signature-v4": "^5.6.0", "@smithy/types": "^4.15.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-fJFkx6u6wCqGMV/v6EAxiwa2UzEukbvr1hNPv4MrD3yj4IFz011jZg42/eSTOP/u5kJ0tlILqEjCWtT8GiKZvA=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Xo+/zf5k5pZdo53X8aVXN4MJGfU/M1P7yMM/GbNY/x9fyRZGEzjhKqW38GA0FSQQ9TYKs+bfPyz5ja4bi6pjTQ=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.53", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/fetch-http-handler": "^5.6.1", "@smithy/node-http-handler": "^4.9.1", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-7E9oFUcf9YWe+ttGiWhe/cCSI+pswwelzgQMoKXgPJi1AIfS27TK6et5ZULqEqHu30zbN+jh1RqlwcXqY/aXyg=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.58", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/credential-provider-env": "^3.972.51", "@aws-sdk/credential-provider-http": "^3.972.53", "@aws-sdk/credential-provider-login": "^3.972.57", "@aws-sdk/credential-provider-process": "^3.972.51", "@aws-sdk/credential-provider-sso": "^3.972.57", "@aws-sdk/credential-provider-web-identity": "^3.972.57", "@aws-sdk/nested-clients": "^3.997.25", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/credential-provider-imds": "^4.4.4", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-MPr0hD8pyDGfF3dWXvFOILhcKTB9ptqJOJK9JEuDQzpc2HgKisY16eR7IrKUXxSbz8LZj+LHz/CS8Y5G1ai7yw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.57", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/nested-clients": "^3.997.25", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-kPWc/SCrl9agKeywxKwPEoQHanWag0LcNQrcZpEQpjNifkxq6tQENhgrrS9al317CF6yytyihlX+FhPHlk0QjA=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.60", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.51", "@aws-sdk/credential-provider-http": "^3.972.53", "@aws-sdk/credential-provider-ini": "^3.972.58", "@aws-sdk/credential-provider-process": "^3.972.51", "@aws-sdk/credential-provider-sso": "^3.972.57", "@aws-sdk/credential-provider-web-identity": "^3.972.57", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/credential-provider-imds": "^4.4.4", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-hE2hIBJQjCDRx8TbSqpVQ+/o2mIrJZQZbQ3LlwE2bJf7z47x5GmhcvGwZPqJH7Oq//SzTXEBGSZ4qSpK3yPbhw=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-081dD2RlnmY+G05v6E73KfACvDjPjnttrLjGHE2SSglbID25UcuijbWpL4g+XR5T2Kl4oIJoVBXi64s+2f009Q=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.57", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/nested-clients": "^3.997.25", "@aws-sdk/token-providers": "3.1077.0", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-dC7ZyX3EHKHLOeVUEDzzGvk0L1s6N06YDrau7P0rGXL/j1cO+DzN2w1x9vcEh7zljVCR3019f5mi1Th+GGTURw=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.57", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/nested-clients": "^3.997.25", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-HtWM3FV2o7NJFJSUqFLBlxmV9RxQRHpzCvQaP1n1Qo4CxQSvwpJ8ERWHiLqXMFDgDXyELt+EZNFcpG6XQRcJbQ=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.24", "", { "dependencies": { "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-O2tFBFQnP68GRNahxYJYZ4NVlGZ/hBe2oH58EKPPjbf7Yc04ZhKFdzAMblRrzeGdun9pBwE+CyLjFH/tr4pYNw=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.20", "", { "dependencies": { "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-VAI4wBVWOg5h1pZVmSEKe8kAW/7odKfbzO9uB23e1AICQh2pp/ROUhFacDXmwgJZZt49dIF6nEvzPTvHiO1cUA=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.33", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/fetch-http-handler": "^5.6.1", "@smithy/signature-v4": "^5.6.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-e24VZXVZjpfVxpQ4ghf4LYV/i/x0znERdVcSzPU0+ktjmnd0k1fdPlcYsImDqIDaLZilbbwMLhuQY8d/dBzrGA=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.25", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/signature-v4-multi-region": "^3.996.37", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/fetch-http-handler": "^5.6.1", "@smithy/node-http-handler": "^4.9.1", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-VpRQ3wR6l+fwRHV5veJL2ehtyQFrGyH/2CJG9DVtb8H3xyqqnZWSTSrq/CJJ7DvDlDgrPRiW2SkYA8pN6VWCFQ=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.37", "", { "dependencies": { "@aws-sdk/types": "^3.973.14", "@smithy/signature-v4": "^5.6.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-u8qd064XsHzM0Mk+yH4IPKn/ZC9rdniEKs+neBHNlsPZirw3rcLvmrH4ImoKC4yF7A0I/MbcC3dseARnJLiAhg=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.14", "", { "dependencies": { "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-vH4pEu9YBEwr67yT+GVcmKX0GzfIrIYUn+MF5vXg9OspouVnAekuyVyawFvZHEK7WlcwVDwNrqI3ZBDUAiyu9A=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.32", "", { "dependencies": { "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-2loKuOMRFDg1nwdni5AtJ9S5juVbRNPNsPC7tWTfkHyycPwACMhxepspUHi8GhvfNlL2cQo3sPMod1uib+KZ0w=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], @@ -241,6 +304,8 @@ "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], "@chromatic-com/storybook": ["@chromatic-com/storybook@5.2.1", "", { "dependencies": { "@neoconfetti/react": "^1.0.0", "chromatic": "16.10.0", "jsonfile": "^6.1.0", "strip-ansi": "^7.1.0" }, "peerDependencies": { "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0" } }, "sha512-z6I7NJk/0VngA64y5TNYaB4Hc2X8+90n4op6lBt9PvWk5TmIlFLDqdX33rlrwbNRkkYijVgA/wO04rVYXi5Mlg=="], @@ -267,6 +332,14 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.2", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.2", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-BF9WPhixIFjT6Kp3Iz3H6ugkU+4AWotM8py96XE5pIK0arJbQKMWbR+dXSWWDEmR5yc/aFQODnuyowOEzMGO7Q=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ=="], + + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.2", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.2", "@earendil-works/pi-ai": "^0.80.2", "@earendil-works/pi-tui": "^0.80.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-m9v7OUit0s9LklWfh61ca/XY5INjUzjtYtNZwy3cNvyjOLk3IpBgghP8aAp0iH35rLaiRwuuWiJ8t88ODMWY+A=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.3", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -365,6 +438,8 @@ "@freedomofpress/tuf-browser": ["@freedomofpress/tuf-browser@0.1.11", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7" } }, "sha512-d76ohB/AS5+zI+lnbiFMX/BIK3nT18hZ8q3Na6X4vyYaSYjXkeknzhAnbx1da+8ObWLwsaZLue46haEch28qtQ=="], + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], @@ -393,6 +468,16 @@ "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="], + "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.32.0", "", {}, "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg=="], + + "@jitl/quickjs-wasmfile-debug-asyncify": ["@jitl/quickjs-wasmfile-debug-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw=="], + + "@jitl/quickjs-wasmfile-debug-sync": ["@jitl/quickjs-wasmfile-debug-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q=="], + + "@jitl/quickjs-wasmfile-release-asyncify": ["@jitl/quickjs-wasmfile-release-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q=="], + + "@jitl/quickjs-wasmfile-release-sync": ["@jitl/quickjs-wasmfile-release-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg=="], + "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="], "@journeyapps/wa-sqlite": ["@journeyapps/wa-sqlite@1.7.0", "", {}, "sha512-xQZ670aKDo4FWxIFpLqY0AGDpURr09aYIO08UkLdWvFw8C018nCiqY0ILJudGRhRdXORhv7K2xD2OXZ2QxU39w=="], @@ -427,10 +512,38 @@ "@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.2", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.1.0" } }, "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw=="], + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], + + "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], + + "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="], + + "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="], + + "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="], + + "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="], + + "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="], + + "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="], + + "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="], + + "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="], + + "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="], + "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="], + + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@mongodb-js/zstd": ["@mongodb-js/zstd@7.0.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "prebuild-install": "^7.1.3" } }, "sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], "@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="], @@ -469,6 +582,8 @@ "@noble/post-quantum": ["@noble/post-quantum@0.6.1", "", { "dependencies": { "@noble/ciphers": "~2.2.0", "@noble/curves": "~2.2.0", "@noble/hashes": "~2.2.0" } }, "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A=="], + "@nodable/entities": ["@nodable/entities@2.2.0", "", {}, "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg=="], + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@3.0.0", "", {}, "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA=="], "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], @@ -749,12 +864,32 @@ "@scure/bip39": ["@scure/bip39@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0", "@scure/base": "2.2.0" } }, "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], "@sinonjs/fake-timers": ["@sinonjs/fake-timers@15.4.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA=="], "@size-limit/file": ["@size-limit/file@12.1.0", "", { "peerDependencies": { "size-limit": "12.1.0" } }, "sha512-eGwDcIufnNnvJRzv3liDOn6MAOGgmOTUdpeGQ2KuRTlgIgO54AJH1ilvktlJc6PIjNfwpYY0dOGyap1QgM1swQ=="], + "@smithy/core": ["@smithy/core@3.29.0", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.5", "", { "dependencies": { "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.6.1", "", { "dependencies": { "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ=="], + + "@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -871,6 +1006,10 @@ "@tinfoilsh/verifier": ["@tinfoilsh/verifier@1.1.4", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/sigstore-browser": "^0.1.11", "@freedomofpress/tuf-browser": "^0.1.8" } }, "sha512-fcO0shcAIPBUG6xrWifsSF01motVa4fpma820FbgTtHzuaivoXzfeArylDXWlRcd+KWxn0wipVj0OCyjFMOr7w=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -917,12 +1056,16 @@ "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="], "@types/sinonjs__fake-timers": ["@types/sinonjs__fake-timers@15.0.1", "", {}, "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w=="], @@ -987,12 +1130,22 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], + + "@zenfs/core": ["@zenfs/core@2.5.7", "", { "dependencies": { "@types/node": "^25.2.0", "buffer": "^6.0.3", "eventemitter3": "^5.0.1", "kerium": "^1.3.4", "memium": "^0.4.0", "readable-stream": "^4.5.2", "utilium": "^3.0.0" }, "bin": { "make-index": "scripts/make-index.js", "zenfs-test": "scripts/test.js", "zci": "scripts/ci-cli.js" } }, "sha512-g3C86mvi91Q2JuzGOwjG83tqEvZ0sl94wtw6AdUvEuRYocovFU3BwGrMobhg3ecvMQlVbqMJHaoA+jZgJ4duig=="], + + "@zenfs/dom": ["@zenfs/dom@1.2.9", "", { "peerDependencies": { "@zenfs/core": "^2.3.11", "kerium": "^1.3.4", "utilium": "^3.0.0" } }, "sha512-+fVY9XDDml/aYCe9BC2LByM4vHZNXOIfTvuR19r78rV4BxWBy8fgc8GLmO2tHsdKe+nTiyaNt5F7qid55So42Q=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ai": ["ai@6.0.190", "", { "dependencies": { "@ai-sdk/gateway": "3.0.119", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-T+ixHbWZ6jmHRREpVVJTkFyWJeCekCdzLPan7lp1F32jG5OUw4+odlVYjtMRXVzogU+pWzpMmXdRiHUmdL/q0w=="], "ajv": ["ajv@6.15.0", "", { "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" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -1005,6 +1158,8 @@ "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -1055,16 +1210,26 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -1081,12 +1246,16 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -1097,6 +1266,8 @@ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "chromatic": ["chromatic@16.10.0", "", { "dependencies": { "semver": "^7.3.5" }, "peerDependencies": { "@chromatic-com/cypress": "^0.*.* || ^1.0.0", "@chromatic-com/playwright": "^0.*.* || ^1.0.0", "@chromatic-com/vitest": "^0.*.* || ^1.0.0" }, "optionalPeers": ["@chromatic-com/cypress", "@chromatic-com/playwright", "@chromatic-com/vitest"], "bin": { "chroma": "dist/bin.cjs", "chromatic": "dist/bin.cjs", "chromatic-cli": "dist/bin.cjs" } }, "sha512-nFsztmnu7rFiGafUJgXvLUNpqmRylz92eNvzBoJNTKKQj4EQUyxznwnfpf1dTs7hXtWD8JwcH92jADydaHA1sw=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -1151,6 +1322,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -1163,12 +1336,18 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -1193,6 +1372,10 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], @@ -1213,6 +1396,8 @@ "earcut": ["earcut@3.0.2", "", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ehbp": ["ehbp@0.2.0", "", { "dependencies": { "@panva/hpke-noble": "^1.0.3", "hpke": "^1.0.1" } }, "sha512-hYkeupwvY0S1h9RpcrCD8pAgc6yfxfMqbI0y6khtS+ZNEByZAf116LTA6aBFB2JJQFsUaOEO7convZVrVhyeZA=="], @@ -1225,6 +1410,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -1291,12 +1478,18 @@ "event-iterator": ["event-iterator@2.0.0", "", {}, "sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -1319,12 +1512,20 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + + "fast-xml-parser": ["fast-xml-parser@5.9.3", "", { "dependencies": { "@nodable/entities": "^2.2.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^1.0.1", "path-expression-matcher": "^1.5.0", "strnum": "^2.4.1", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -1335,12 +1536,16 @@ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1349,6 +1554,10 @@ "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -1367,6 +1576,8 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -1377,6 +1588,10 @@ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1425,8 +1640,12 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "hono": ["hono@4.12.21", "", {}, "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "hpke": ["hpke@1.1.1", "", {}, "sha512-FOx9R77nOSAWuKPoHDC3oWSslpjgaIaxl9W3ZaXuT/XRIwRioNGzSpoJuXOkR74oJw0lScvwHDSZUNOqtkUiGQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], @@ -1437,10 +1656,16 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], @@ -1451,6 +1676,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], @@ -1525,6 +1752,8 @@ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "is-unsafe": ["is-unsafe@1.0.1", "", {}, "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA=="], + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -1557,10 +1786,14 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -1573,14 +1806,24 @@ "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "just-bash": ["just-bash@3.0.2", "", { "dependencies": { "diff": "^8.0.2", "fast-xml-parser": "^5.7.3", "file-type": "^21.2.0", "ini": "^6.0.0", "minimatch": "^10.1.1", "modern-tar": "^0.7.3", "papaparse": "^5.5.3", "quickjs-emscripten": "^0.32.0", "re2js": "^1.2.1", "seek-bzip": "^2.0.0", "smol-toml": "^1.6.0", "sprintf-js": "^1.1.3", "sql.js": "^1.13.0", "turndown": "^7.2.2", "yaml": "^2.8.2" }, "optionalDependencies": { "@mongodb-js/zstd": "^7.0.0", "node-liblzma": "^2.0.3" }, "bin": { "just-bash": "dist/bin/just-bash.js", "just-bash-shell": "dist/bin/shell/shell.js" } }, "sha512-uY8LMD2NpADp1HlYUcZSw9ffuq0tRhW76sg8xkeo+ZRDMu7mbekWbKbYDWquazVVi7pqpGZlic/liMFktQKy6w=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "kdbush": ["kdbush@4.1.0", "", {}, "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ=="], + "kerium": ["kerium@1.3.9", "", { "dependencies": { "utilium": "^3.0.0" } }, "sha512-uoiTcutvUOjOpck8mmUUq7EsuNPhdfFoYMVehLmJGfdLmWC3nABU8KB63MQM0ydkAM4q+2zZmRIKovbx2LF8iA=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], @@ -1655,6 +1898,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -1693,6 +1938,8 @@ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memium": ["memium@0.4.5", "", { "dependencies": { "kerium": "^1.3.2", "utilium": "^3.0.0" } }, "sha512-mnmHWWlcyKnkKSSU0qL1Jsg+CsoikVZ2xsOatAN89R+j4V3m3WlQr0nUoqcjKDyUVF5ZGdNinRiZGVd+7A+TJQ=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge-refs": ["merge-refs@2.0.0", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg=="], @@ -1761,6 +2008,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -1769,6 +2018,10 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "modern-tar": ["modern-tar@0.7.6", "", {}, "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg=="], + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], @@ -1789,12 +2042,26 @@ "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-abi": ["node-abi@3.93.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-Cu6yUpX5Iavugm8BeX7c0wgU9CvOqfd1yM6A1d2q2ZMjym7GjpASv2GdRcTq3Fx+Sb5OgBkEEpw4VnAbY6Y5RA=="], + + "node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "node-liblzma": ["node-liblzma@2.2.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "bin": { "nxz": "lib/cli/nxz.js" } }, "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g=="], + "node-releases": ["node-releases@2.0.45", "", {}, "sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg=="], "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], @@ -1825,7 +2092,7 @@ "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], - "openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="], + "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], @@ -1843,16 +2110,28 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "papaparse": ["papaparse@5.5.4", "", {}, "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.6.1", "", {}, "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1895,16 +2174,22 @@ "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "protobufjs": ["protobufjs@7.6.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ=="], @@ -1913,18 +2198,30 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "quickjs-emscripten": ["quickjs-emscripten@0.32.0", "", { "dependencies": { "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-release-sync": "0.32.0", "quickjs-emscripten-core": "0.32.0" } }, "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA=="], + + "quickjs-emscripten-core": ["quickjs-emscripten-core@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg=="], + "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "re2js": ["re2js@1.4.0", "", {}, "sha512-KTOIcZTSOpOxbu3i0+T6mFQ6tkxXKlTxfcMFs1trQbsMnG84qNq+DjXr8Afu+FEFjvF1NNlldpC7roPyazFI8g=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-docgen": ["react-docgen@8.0.3", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w=="], @@ -1951,7 +2248,7 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -1977,6 +2274,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], @@ -1985,6 +2284,8 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -2017,12 +2318,16 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "seek-bzip": ["seek-bzip@2.0.0", "", { "dependencies": { "commander": "^6.0.0" }, "bin": { "seek-bunzip": "bin/seek-bunzip", "seek-table": "bin/seek-bzip-table" } }, "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg=="], + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -2049,7 +2354,11 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], @@ -2057,6 +2366,8 @@ "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "smol-toml": ["smol-toml@1.7.0", "", {}, "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -2065,7 +2376,9 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -2093,7 +2406,7 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -2103,6 +2416,12 @@ "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -2125,6 +2444,10 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], "tinfoil": ["tinfoil@1.1.4", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.26", "@freedomofpress/sigstore-browser": "^0.1.11", "@tinfoilsh/verifier": "1.1.4", "ehbp": "^0.2.0", "openai": "^6.17.0" }, "peerDependencies": { "ai": "^6.0.69" }, "optionalPeers": ["ai"] }, "sha512-Ris0RaWb5uu+N02CX0LPhi4LwUiMoyEGUpxTF6CsTWWz6ThT9Z7P1VXoXwLwriuJnzePh/cwRA71UCULwoEmJg=="], @@ -2149,6 +2472,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], @@ -2159,6 +2484,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], @@ -2169,6 +2496,10 @@ "tsx": ["tsx@4.22.3", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], @@ -2177,6 +2508,8 @@ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], @@ -2187,6 +2520,8 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], @@ -2231,6 +2566,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utilium": ["utilium@3.4.0", "", { "dependencies": { "eventemitter3": "^5.0.1" }, "optionalDependencies": { "@xterm/xterm": "^5.5.0" }, "bin": { "lice": "scripts/lice.js" } }, "sha512-z4PavqKX0P4XB9SKtXRWWBCDyoDxV6vmTEH4WDBN1/lrYf6MDVk+gntCY6YbryOKjBHGIsYHOS6BTzHf5cF9vQ=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -2255,6 +2592,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "web-vitals": ["web-vitals@5.2.0", "", {}, "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA=="], "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], @@ -2273,6 +2612,8 @@ "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -2295,6 +2636,8 @@ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], @@ -2325,16 +2668,28 @@ "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], + "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.2", "", { "dependencies": { "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1077.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.25", "@aws-sdk/nested-clients": "^3.997.25", "@aws-sdk/types": "^3.973.14", "@smithy/core": "^3.28.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-sRUkfZ3fpOco95jZHsQUQiXvuIVLvCmWVclFg6dRFDyfsYs6Pdr/NuZ2+yJxeHN+6WAfDh2aZ8nlZntnvuhZUQ=="], + + "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.2", "", { "dependencies": { "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@earendil-works/pi-ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@earendil-works/pi-coding-agent/undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@freedomofpress/crypto-browser/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "@maplibre/maplibre-gl-style-spec/@mapbox/unitbezier": ["@mapbox/unitbezier@1.0.0", "", {}, "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ=="], "@maplibre/vt-pbf/pbf": ["pbf@5.1.0", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg=="], @@ -2407,10 +2762,16 @@ "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "ast-v8-to-istanbul/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "better-call/set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2437,6 +2798,8 @@ "jsdom/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], @@ -2447,6 +2810,8 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -2455,10 +2820,18 @@ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "qrcode/pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "react-docgen/doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -2467,12 +2840,20 @@ "safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "seek-bzip/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "tinfoil/openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -2555,6 +2936,8 @@ "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -2563,6 +2946,14 @@ "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], @@ -2622,5 +3013,19 @@ "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/bunfig.toml b/bunfig.toml index 3930394d5..9109b8a0b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,15 @@ [install] minimumReleaseAge = 604800 # 7 days in seconds +# The Pi harness packages (the in-browser agent engine) are actively released and +# pinned to an exact version (0.80.2), so the 7-day quarantine — meant to avoid +# auto-pulling brand-new floating versions — would otherwise block this exact pin. +# Scope the exemption to the `@earendil-works/*` packages only; the global +# quarantine stays in force for every other dependency. +minimumReleaseAgeExcludes = [ + "@earendil-works/pi-agent-core", + "@earendil-works/pi-ai", + "@earendil-works/pi-coding-agent", +] [test] preload = ["./happydom.ts", "./src/testing-library.ts"] diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..bb7db7e51 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,98 @@ +# ⚡ thunderbolt + +A single-binary terminal coding agent. It operates directly in your working +directory with four tools — **bash**, **read**, **write**, **edit** — built on +the [Pi harness](https://www.npmjs.com/package/@earendil-works/pi-agent-core) +and talking to Claude. Give it a task as one prompt or drop into an interactive +REPL; there's no daemon, no config file, and nothing to install but the binary. + +## Install + +**From source** (requires [Bun](https://bun.sh)): + +```sh +cd cli +bun install +bun run build # compiles dist/thunderbolt +./install.sh # copies it to ~/.local/bin +``` + +**Prebuilt binary** (self-contained, no Bun required). Each release attaches one +binary per target plus a `SHA256SUMS` manifest. Pick your target +(`darwin-arm64`, `linux-x64`, or `linux-arm64` — Intel macs aren't built yet, +see below) and verify the checksum before running: + +```sh +TARGET=darwin-arm64 +BASE=https://github.com/thunderbird/thunderbolt/releases/latest/download + +curl -fsSLO "$BASE/thunderbolt-$TARGET" +curl -fsSLO "$BASE/SHA256SUMS" +grep " thunderbolt-$TARGET\$" SHA256SUMS | shasum -a 256 -c - + +chmod +x "thunderbolt-$TARGET" +# macOS only: clear the download quarantine so Gatekeeper allows the unsigned binary +xattr -d com.apple.quarantine "thunderbolt-$TARGET" 2>/dev/null || true +mv "thunderbolt-$TARGET" ~/.local/bin/thunderbolt +``` + +> **What the checksum covers.** `SHA256SUMS` and the binary come from the same +> release over the same TLS connection, so the checksum catches a corrupted or +> truncated download but *not* a compromised release host — whoever could swap the +> binary could swap its digest too. The binaries are unsigned and the quarantine +> strip bypasses macOS Gatekeeper. Signature verification (minisign) over the +> manifest against a pinned key is the planned follow-up hardening. + +> Intel macOS (`darwin-x64`) has no binary: the CLI's `@number0/iroh` P2P addon +> ships no `x86_64-apple-darwin` build, so an Intel-mac binary can't load it. +> Build from source above until iroh adds that target. + +## Usage + +Run a single task and exit: + +```sh +thunderbolt "fix the failing test in utils.ts" +``` + +Start an interactive session (type a task, or `exit` to quit): + +```sh +thunderbolt +``` + +### Flags + +| Flag | Description | +| --------------------- | ----------------------------------------------------------------- | +| `-m`, `--model ` | Anthropic model id (default: `claude-opus-4-8`) | +| `--thinking ` | Reasoning depth: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` (default: `medium`) | +| `-y`, `--yolo` | Auto-approve every tool call (alias: `--dangerously-skip-permissions`) | +| `-h`, `--help` | Show help and exit | +| `-v`, `--version` | Print the version and exit | + +Requires **`ANTHROPIC_API_KEY`** in your environment +(https://console.anthropic.com). + +### Environment + +| Variable | Description | +| ----------------------------- | --------------------------------------------------------------------------- | +| `ANTHROPIC_API_KEY` | Anthropic API key (required). | +| `THUNDERBOLT_IROH_RELAY_URL` | iroh relay for the `bridge` transport. Unset = the n0 public relays (default); set to a self-hosted iroh-relay wss URL to override. n0 DNS discovery + crypto are kept — only the relay hop changes. Read at runtime (no rebuild). | + +## How it maps to the proposal + +Part 1 — the Pi harness is the engine that runs the agent loop and the four +tools. Part 3 — `thunderbolt agent` is the default (and only) subcommand, so +plain `thunderbolt` *is* the agent. + +## Demo + +```sh +export ANTHROPIC_API_KEY=sk-ant-... + +thunderbolt "summarize what this repo does in three bullets" +thunderbolt --thinking high "find and fix the off-by-one bug in src/range.ts" +thunderbolt --yolo "run the test suite and fix whatever breaks" +``` diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 000000000..8aa76ae03 --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,334 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@thunderbolt/cli", + "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@earendil-works/pi-agent-core": "0.80.2", + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2", + "@earendil-works/pi-tui": "0.80.2", + "@number0/iroh": "1.0.0", + "qrcode-terminal": "^0.12.0", + }, + "devDependencies": { + "@types/bun": "^1.2.20", + "@types/qrcode-terminal": "^0.12.2", + "typescript": "^6.0.3", + }, + }, + }, + "packages": { + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.22.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.974.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@aws-sdk/xml-builder": "^3.972.31", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.56", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/credential-provider-env": "^3.972.49", "@aws-sdk/credential-provider-http": "^3.972.51", "@aws-sdk/credential-provider-login": "^3.972.55", "@aws-sdk/credential-provider-process": "^3.972.49", "@aws-sdk/credential-provider-sso": "^3.972.55", "@aws-sdk/credential-provider-web-identity": "^3.972.55", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.58", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.49", "@aws-sdk/credential-provider-http": "^3.972.51", "@aws-sdk/credential-provider-ini": "^3.972.56", "@aws-sdk/credential-provider-process": "^3.972.49", "@aws-sdk/credential-provider-sso": "^3.972.55", "@aws-sdk/credential-provider-web-identity": "^3.972.55", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/token-providers": "3.1074.0", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.22", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.18", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.23", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.23", "@aws-sdk/signature-v4-multi-region": "^3.996.35", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.35", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.13", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.31", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.2", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.2", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-BF9WPhixIFjT6Kp3Iz3H6ugkU+4AWotM8py96XE5pIK0arJbQKMWbR+dXSWWDEmR5yc/aFQODnuyowOEzMGO7Q=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ=="], + + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.2", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.2", "@earendil-works/pi-ai": "^0.80.2", "@earendil-works/pi-tui": "^0.80.2", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-m9v7OUit0s9LklWfh61ca/XY5INjUzjtYtNZwy3cNvyjOLk3IpBgghP8aAp0iH35rLaiRwuuWiJ8t88ODMWY+A=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.2", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-OvOAMIbXiC9OSse17YMiXIsI9AS5XM/ZV8N/k+UzdlRpPILDQYmLElevgGW92kkXR8qHBClIdzhCjuzlBGvphA=="], + + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], + + "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], + + "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="], + + "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="], + + "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="], + + "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="], + + "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="], + + "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="], + + "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="], + + "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="], + + "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="], + + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="], + + "@number0/iroh": ["@number0/iroh@1.0.0", "", { "optionalDependencies": { "@number0/iroh-android-arm-eabi": "1.0.0", "@number0/iroh-android-arm64": "1.0.0", "@number0/iroh-darwin-arm64": "1.0.0", "@number0/iroh-linux-arm-gnueabihf": "1.0.0", "@number0/iroh-linux-arm-musleabihf": "1.0.0", "@number0/iroh-linux-arm64-gnu": "1.0.0", "@number0/iroh-linux-arm64-musl": "1.0.0", "@number0/iroh-linux-x64-gnu": "1.0.0", "@number0/iroh-linux-x64-musl": "1.0.0", "@number0/iroh-win32-arm64-msvc": "1.0.0", "@number0/iroh-win32-x64-msvc": "1.0.0" } }, "sha512-QeuRYxfLPm8HGbWKYpZZA//hCn8J+WO8hVrEAU2VQG0BLnfHTX9tcqGbQJLzcePFdtFVVXVIkm3IkSAUWajOXg=="], + + "@number0/iroh-android-arm-eabi": ["@number0/iroh-android-arm-eabi@1.0.0", "", { "os": "android", "cpu": "arm" }, "sha512-AkrdmoQ2YhC+X0JgD14SNE+KOSGLKC8z5HSWnPY5tvSXrASPbwCtvWFCVmyPKWj2kQHv0blMI5lBGeGFe+v54w=="], + + "@number0/iroh-android-arm64": ["@number0/iroh-android-arm64@1.0.0", "", { "os": "android", "cpu": "arm64" }, "sha512-EkZh6hl5XJ1JQlWWANc2yDdScP5yGbi9IlF9bttd4DuUelAI8yqEMzXq4YdPB0kQqPlmPk8nEGLEtPWbVCNTLg=="], + + "@number0/iroh-darwin-arm64": ["@number0/iroh-darwin-arm64@1.0.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zmf9CE1l6TWy9zDlNs3EvcxPxHfVQKvkod7wuF6llkGi/2WaSRc+x1vaaMEfpguaxnRfP4NNvIKyy8Ni4imIQg=="], + + "@number0/iroh-linux-arm-gnueabihf": ["@number0/iroh-linux-arm-gnueabihf@1.0.0", "", { "os": "linux", "cpu": "arm" }, "sha512-obp3eZIJrOGD0rUL1Y0SSCFUNa6NQAsZS+z2khmurqD/rOIIjzvVNYakS4+MEWQLzF1acuUvN7aprHAceUkiuQ=="], + + "@number0/iroh-linux-arm-musleabihf": ["@number0/iroh-linux-arm-musleabihf@1.0.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5OXTzmdej7CPaQCReg3vf0/jDCRdVgiOsbC0gcyoPBOdNPs7tZgSoXRBQsod7ioObrjegPM2UfvdF+bIyGst4Q=="], + + "@number0/iroh-linux-arm64-gnu": ["@number0/iroh-linux-arm64-gnu@1.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-whQ36eFP+9aYRJxpZn/Q35EVlvEJm45AFSO2mfctVYvZFJKC/YjZWue4p60VWuW4/yBdjBTErwUum5mMppoeLQ=="], + + "@number0/iroh-linux-arm64-musl": ["@number0/iroh-linux-arm64-musl@1.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Xb4Yy9I2MXlv2SeZZcEIkJIgG1do+5F4R5GkXb4L0if/5vTuuKj7KGQE/ehaIJjNcv+TE+KIOhUvGCxO02l7Nw=="], + + "@number0/iroh-linux-x64-gnu": ["@number0/iroh-linux-x64-gnu@1.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZOOWURPAWhnBmj5ElLaB/wCjoWNdrjOblaOs0YViW+dlTc5qwInSGJdYEDRdSoZ61MIP0gI2H9EwENe8UYWRcg=="], + + "@number0/iroh-linux-x64-musl": ["@number0/iroh-linux-x64-musl@1.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sJzuehJ9BnuZe4lnP6cWA1wDedC+rbOiI/Hu4V9BGTUE50Z/s28rXDQFJCqVWz1qseXCqHYQ/Jt3U1XdX+Gmtw=="], + + "@number0/iroh-win32-arm64-msvc": ["@number0/iroh-win32-arm64-msvc@1.0.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-7KgpUvinndcJnQgbYdV0zs7nGTxKi9zZDBxyTX9PRlQm/9TfTDZhSkur+sZYW2xca2P31XGkjZYZ78yAjYviqQ=="], + + "@number0/iroh-win32-x64-msvc": ["@number0/iroh-win32-x64-msvc@1.0.0", "", { "os": "win32", "cpu": "x64" }, "sha512-mNgHds9US3IWfrvZn+yBuyE/kjrM//jSFpWn1llu3aiNgGq8Rf7yW2TJWvmrFT+eeTi2osOsh1nJfykXTe9xYQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + + "@smithy/core": ["@smithy/core@3.26.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.5.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.5.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA=="], + + "@smithy/types": ["@smithy/types@4.15.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + + "@types/qrcode-terminal": ["@types/qrcode-terminal@0.12.2", "", {}, "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + + "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], + + "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1074.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.23", "@aws-sdk/nested-clients": "^3.997.23", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg=="], + + "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.2", "", { "dependencies": { "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ=="], + + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + } +} diff --git a/cli/bunfig.toml b/cli/bunfig.toml new file mode 100644 index 000000000..d9e01914a --- /dev/null +++ b/cli/bunfig.toml @@ -0,0 +1,18 @@ +# Mirror the repo root's supply-chain quarantine: hold every new dependency +# version for 7 days before it's installable (blunts a compromised-release window). +# (Bun reads bunfig.toml from the current working directory, so install/build from +# cli/ uses this file, not the root's — hence the duplication.) +[install] +minimumReleaseAge = 604800 # 7 days in seconds +# The Pi harness packages are actively released and pinned to an exact version +# (0.80.2), so the quarantine — meant to stop auto-pulling brand-new floating +# versions — would otherwise block this exact pin. Scope the exemption to the +# `@earendil-works/*` packages only; the quarantine stays in force for everything +# else (including @number0/iroh, pinned exact in package.json at a >7-day-old +# version so it installs cleanly without an exemption). +minimumReleaseAgeExcludes = [ + "@earendil-works/pi-agent-core", + "@earendil-works/pi-ai", + "@earendil-works/pi-coding-agent", + "@earendil-works/pi-tui", +] diff --git a/cli/install.sh b/cli/install.sh new file mode 100755 index 000000000..ca8908a01 --- /dev/null +++ b/cli/install.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# install.sh — build (if needed) and install the `thunderbolt` binary into +# ~/.local/bin. POSIX sh; idempotent (re-running overwrites the install). +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BINARY="${SCRIPT_DIR}/dist/thunderbolt" + +if [ ! -f "${BINARY}" ]; then + echo "thunderbolt: dist/thunderbolt missing — building…" + (cd "${SCRIPT_DIR}" && bun run build) +fi + +INSTALL_DIR="${HOME}/.local/bin" +mkdir -p "${INSTALL_DIR}" +cp "${BINARY}" "${INSTALL_DIR}/thunderbolt" +chmod +x "${INSTALL_DIR}/thunderbolt" + +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) echo "note: ${INSTALL_DIR} is not on your PATH — add it:" \ + && echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" ;; +esac + +echo "installed: ${INSTALL_DIR}/thunderbolt" diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 000000000..208264830 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,37 @@ +{ + "name": "@thunderbolt/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "MPL-2.0", + "description": "thunderbolt — a portable, single-binary terminal coding agent (bash/read/write/edit) built on the Pi harness.", + "bin": { + "thunderbolt": "./src/index.ts" + }, + "scripts": { + "dev": "bun run src/index.ts", + "start": "bun run src/index.ts", + "typecheck": "tsc --noEmit", + "test": "bun test ./src --timeout 5000 --randomize", + "test:5x": "bun test ./src --timeout 5000 --randomize --rerun-each 5", + "build": "bun build --compile --minify --sourcemap src/index.ts --outfile dist/thunderbolt", + "build:darwin-arm64": "bun build --compile --minify --target=bun-darwin-arm64 src/index.ts --outfile dist/thunderbolt-darwin-arm64", + "build:linux-x64": "bun build --compile --minify --target=bun-linux-x64 src/index.ts --outfile dist/thunderbolt-linux-x64", + "build:linux-arm64": "bun build --compile --minify --target=bun-linux-arm64 src/index.ts --outfile dist/thunderbolt-linux-arm64", + "build:all": "bun run build:darwin-arm64 && bun run build:linux-x64 && bun run build:linux-arm64" + }, + "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@earendil-works/pi-agent-core": "0.80.2", + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2", + "@earendil-works/pi-tui": "0.80.2", + "@number0/iroh": "1.0.0", + "qrcode-terminal": "^0.12.0" + }, + "devDependencies": { + "@types/bun": "^1.2.20", + "@types/qrcode-terminal": "^0.12.2", + "typescript": "^6.0.3" + } +} diff --git a/cli/src/acp/harness-agent.test.ts b/cli/src/acp/harness-agent.test.ts new file mode 100644 index 000000000..67c43516d --- /dev/null +++ b/cli/src/acp/harness-agent.test.ts @@ -0,0 +1,333 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Drives the built-in ACP agent through a real in-memory ACP connection pair — + * a {@link ClientSideConnection} talking to an {@link AgentSideConnection} over + * two linked byte streams — backed by a fake harness so the full + * initialize → newSession → prompt round-trip runs with no API key. Asserts the + * harness run events stream out as ACP `session/update`s, the tool-permission + * request round-trips to the client, and `session/cancel` yields `cancelled`. + */ + +import { describe, expect, test } from 'bun:test' +import { + AgentSideConnection, + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, +} from '@agentclientprotocol/sdk' +import type { + Client, + RequestPermissionRequest, + SessionNotification, + Stream, +} from '@agentclientprotocol/sdk' +import type { StopReason as PiStopReason, AssistantMessage } from '@earendil-works/pi-ai' +import { InMemorySessionRepo } from '@earendil-works/pi-agent-core' +import type { AgentHarnessEvent, Session as PiSession, ToolCallEvent, ToolCallResult } from '@earendil-works/pi-agent-core' +import { createHarnessAgent } from './harness-agent.ts' +import type { BuildServeHarness } from './harness-agent.ts' +import type { SessionStore } from './session-store.ts' +import type { ServeConfig } from '../agent/types.ts' + +const config: ServeConfig = { model: 'fake', cwd: process.cwd(), yolo: false, thinking: 'medium' } + +/** A fake {@link SessionStore} backed by Pi's real in-memory repo (no disk, no + * mocks): each id maps to one session, and it records the new/resume calls so a + * test can assert the agent routed to the right one. */ +const fakeStore = (): SessionStore & { created: string[]; resumed: Array<{ id: string; cwd: string }> } => { + const repo = new InMemorySessionRepo() + const byId = new Map>() + const created: string[] = [] + const resumed: Array<{ id: string; cwd: string }> = [] + const get = (id: string): Promise => { + const existing = byId.get(id) + if (existing) return existing + const fresh = repo.create({ id }) + byId.set(id, fresh) + return fresh + } + return { + created, + resumed, + createSession: (id) => { + created.push(id) + return get(id) + }, + openOrCreate: (id, cwd) => { + resumed.push({ id, cwd }) + return get(id) + }, + } +} + +/** A minimal-but-valid Pi assistant message; the translator only reads the + * streamed deltas and the final `stopReason`, but the event types require a + * fully-shaped message, so this fills in the rest with zeros. */ +const assistantMessage = (stopReason: PiStopReason): AssistantMessage => ({ + role: 'assistant', + content: [], + api: 'anthropic-messages', + provider: 'anthropic', + model: 'fake', + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + timestamp: 0, +}) + +/** Wire a client and the built-in agent together over an in-memory ndjson pipe + * pair, returning the client connection plus the buffers the client records. */ +const connectPair = ( + buildServeHarness: BuildServeHarness, + store: SessionStore = fakeStore(), +): { + client: ClientSideConnection + updates: SessionNotification[] + permissions: RequestPermissionRequest[] +} => { + const agentToClient = new TransformStream() + const clientToAgent = new TransformStream() + const agentStream: Stream = ndJsonStream(agentToClient.writable, clientToAgent.readable) + const clientStream: Stream = ndJsonStream(clientToAgent.writable, agentToClient.readable) + + new AgentSideConnection((conn) => createHarnessAgent(conn, config, store, buildServeHarness), agentStream) + + const updates: SessionNotification[] = [] + const permissions: RequestPermissionRequest[] = [] + const handler: Client = { + sessionUpdate: async (params) => { + updates.push(params) + }, + requestPermission: async (params) => { + permissions.push(params) + return { outcome: { outcome: 'selected', optionId: 'allow-once' } } + }, + } + const client = new ClientSideConnection(() => handler, clientStream) + return { client, updates, permissions } +} + +/** A fake harness whose prompt streams a text delta, asks to run `bash` (driving + * the permission round-trip), then reports the tool completing. */ +const streamingBuilder: BuildServeHarness = async () => { + let emit: (event: AgentHarnessEvent) => void = () => {} + let gate: ((event: ToolCallEvent) => Promise) | null = null + return { + harness: { + subscribe: (listener) => { + emit = listener + return () => { + emit = () => {} + } + }, + registerToolCallGate: (h) => { + gate = h + }, + prompt: async (text) => { + emit({ + type: 'message_update', + message: assistantMessage('stop'), + assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: `you said: ${text}`, partial: assistantMessage('stop') }, + }) + const decision = await gate?.({ type: 'tool_call', toolCallId: 't1', toolName: 'bash', input: { command: 'echo hi' } }) + if (!decision?.block) { + emit({ type: 'tool_execution_start', toolCallId: 't1', toolName: 'bash', args: { command: 'echo hi' } }) + emit({ + type: 'tool_execution_end', + toolCallId: 't1', + toolName: 'bash', + result: { content: [{ type: 'text', text: 'hi' }], details: {} }, + isError: false, + }) + } + return assistantMessage('stop') + }, + waitForIdle: async () => {}, + abort: async () => {}, + }, + dispose: async () => {}, + } +} + +describe('createHarnessAgent (ACP server)', () => { + test('initialize advertises resume (not loadSession) and negotiates the protocol version', async () => { + const { client } = connectPair(streamingBuilder) + const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + expect(init.protocolVersion).toBe(PROTOCOL_VERSION) + expect(init.agentCapabilities?.loadSession).toBe(false) + expect(init.agentCapabilities?.sessionCapabilities?.resume).toBeDefined() + expect(init.agentInfo?.name).toBe('thunderbolt') + }) + + test('session/resume opens the stored session by id and injects it into the harness (no replay)', async () => { + const store = fakeStore() + const injected: PiSession[] = [] + const capturingBuilder: BuildServeHarness = async (_config, session) => { + injected.push(session) + return { + harness: { + subscribe: () => () => {}, + registerToolCallGate: () => {}, + prompt: async () => assistantMessage('stop'), + waitForIdle: async () => {}, + abort: async () => {}, + }, + dispose: async () => {}, + } + } + + const threadId = '11111111-1111-4111-8111-111111111111' + const { client, updates } = connectPair(capturingBuilder, store) + await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const response = await client.resumeSession({ sessionId: threadId, cwd: process.cwd(), mcpServers: [] }) + + // Resume returns an empty response and replays nothing to the client. + expect(response).toEqual({}) + expect(updates).toHaveLength(0) + // It routed through the store by the client-supplied id + cwd... + expect(store.resumed).toEqual([{ id: threadId, cwd: process.cwd() }]) + // ...and handed that exact session to the harness builder. + expect(injected).toHaveLength(1) + expect((await injected[0].getMetadata()).id).toBe(threadId) + + // A resumed session is live: a prompt against it succeeds. + const prompt = await client.prompt({ sessionId: threadId, prompt: [{ type: 'text', text: 'hi' }] }) + expect(prompt.stopReason).toBe('end_turn') + }) + + test('session/resume rejects a path-traversal id before it reaches the store', async () => { + const store = fakeStore() + const { client } = connectPair(streamingBuilder, store) + await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + + await expect( + client.resumeSession({ sessionId: '../../../../../tmp/x', cwd: process.cwd(), mcpServers: [] }), + ).rejects.toThrow(/invalid session id/) + + // The guard short-circuits before the store's path builder ever runs, so no + // `.jsonl` can be written outside the sessions root. + expect(store.resumed).toHaveLength(0) + }) + + test('re-resuming a live session id disposes the prior harness (no leak)', async () => { + const disposed: number[] = [] + let n = 0 + const trackingBuilder: BuildServeHarness = async () => { + const id = n++ + return { + harness: { + subscribe: () => () => {}, + registerToolCallGate: () => {}, + prompt: async () => assistantMessage('stop'), + waitForIdle: async () => {}, + abort: async () => {}, + }, + dispose: async () => { + disposed.push(id) + }, + } + } + + const threadId = '22222222-2222-4222-8222-222222222222' + const { client } = connectPair(trackingBuilder) + await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await client.resumeSession({ sessionId: threadId, cwd: process.cwd(), mcpServers: [] }) + await client.resumeSession({ sessionId: threadId, cwd: process.cwd(), mcpServers: [] }) + + // The first harness (id 0) was torn down when the second replaced it. + expect(disposed).toEqual([0]) + }) + + test('a prompt streams text + tool-call updates and round-trips a permission request', async () => { + const { client, updates, permissions } = connectPair(streamingBuilder) + await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const response = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello agent' }] }) + expect(response.stopReason).toBe('end_turn') + + // The gated `bash` tool asked the client for permission exactly once. + expect(permissions).toHaveLength(1) + expect(permissions[0].toolCall.toolCallId).toBe('t1') + expect(permissions[0].toolCall.kind).toBe('execute') + + const kinds = updates.map((u) => u.update.sessionUpdate) + expect(kinds).toContain('agent_message_chunk') + expect(kinds).toContain('tool_call') + expect(kinds).toContain('tool_call_update') + + const textChunk = updates.find((u) => u.update.sessionUpdate === 'agent_message_chunk') + expect(textChunk?.update).toMatchObject({ content: { type: 'text', text: 'you said: hello agent' } }) + + const toolCall = updates.find((u) => u.update.sessionUpdate === 'tool_call') + expect(toolCall?.update).toMatchObject({ toolCallId: 't1', kind: 'execute', status: 'in_progress' }) + + const toolDone = updates.find((u) => u.update.sessionUpdate === 'tool_call_update') + expect(toolDone?.update).toMatchObject({ toolCallId: 't1', status: 'completed' }) + }) + + test('a denied permission blocks the tool and the model never sees it run', async () => { + const agentToClient = new TransformStream() + const clientToAgent = new TransformStream() + new AgentSideConnection( + (conn) => createHarnessAgent(conn, config, fakeStore(), streamingBuilder), + ndJsonStream(agentToClient.writable, clientToAgent.readable), + ) + const updates: SessionNotification[] = [] + const denyingClient: Client = { + sessionUpdate: async (params) => { + updates.push(params) + }, + requestPermission: async () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } }), + } + const client = new ClientSideConnection(() => denyingClient, ndJsonStream(clientToAgent.writable, agentToClient.readable)) + + await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + + // Rejected: no tool_call / tool_call_update was streamed for the blocked bash. + const kinds = updates.map((u) => u.update.sessionUpdate) + expect(kinds).not.toContain('tool_call') + expect(kinds).not.toContain('tool_call_update') + }) + + test('session/cancel aborts the in-flight turn and resolves as cancelled', async () => { + let release: (() => void) | null = null + let abortedEarly = false + const cancellingBuilder: BuildServeHarness = async () => ({ + harness: { + subscribe: () => () => {}, + registerToolCallGate: () => {}, + prompt: async () => { + if (!abortedEarly) await new Promise((resolve) => (release = resolve)) + return assistantMessage('aborted') + }, + waitForIdle: async () => {}, + abort: async () => { + abortedEarly = true + release?.() + }, + }, + dispose: async () => {}, + }) + + const { client } = connectPair(cancellingBuilder) + await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const pending = client.prompt({ sessionId, prompt: [{ type: 'text', text: 'long task' }] }) + await client.cancel({ sessionId }) + const response = await pending + expect(response.stopReason).toBe('cancelled') + }) +}) diff --git a/cli/src/acp/harness-agent.ts b/cli/src/acp/harness-agent.ts new file mode 100644 index 000000000..44e855932 --- /dev/null +++ b/cli/src/acp/harness-agent.ts @@ -0,0 +1,346 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The built-in Pi coding agent, exposed as an ACP {@link Agent}. + * + * This is the server half of `thunderbolt acp serve`: it implements the ACP + * `Agent` interface on top of {@link buildHarness}, so the iroh/wss bridge can + * expose OUR coding agent to a remote ACP client (the bridge otherwise only + * proxies external stdio ACP agents). Each `session/new` builds its own harness + * bound to the requested cwd; harness run events stream back as ACP + * `session/update` notifications via {@link createHarnessToAcpTranslator}, and + * tool-permission requests round-trip to the client over `session/request_permission`. + * + * Sessions persist to disk on the bridge machine (via {@link SessionStore}), so a + * reconnect can rebuild the agent's execution context with `session/resume` + * (advertised `sessionCapabilities.resume`). We use `resume` — which explicitly + * does NOT replay history — rather than `loadSession`, because our app renders + * the transcript from PowerSync and must not receive a replay; `loadSession` + * stays unadvertised (`loadSession: false`). The live `Map` + * below remains the per-connection registry, now hydrated from disk on resume. + */ + +import { PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk' +import type { + Agent, + AgentSideConnection, + AuthenticateResponse, + CancelNotification, + ContentBlock, + InitializeRequest, + InitializeResponse, + NewSessionRequest, + NewSessionResponse, + PermissionOption, + PromptRequest, + PromptResponse, + RequestPermissionOutcome, + ResumeSessionRequest, + ResumeSessionResponse, + SessionId, +} from '@agentclientprotocol/sdk' +import type { AgentHarnessEvent, Session as PiSession, ToolCallEvent, ToolCallResult } from '@earendil-works/pi-agent-core' +import type { AssistantMessage } from '@earendil-works/pi-ai' +import { VERSION } from '../cli.ts' +import { buildHarness } from '../agent/harness.ts' +import type { HarnessConfig, ServeConfig } from '../agent/types.ts' +import { createHarnessToAcpTranslator, toAcpStopReason, toToolKind } from './harness-to-acp.ts' +import type { SessionStore } from './session-store.ts' + +/** + * The slice of the Pi {@link AgentHarness} the ACP agent drives. A hand-written + * surface (rather than the full harness) keeps it tiny and lets a test supply a + * fake without reconstructing the harness's generic `on`/`subscribe` shapes — so + * the round-trip can be exercised with no API key. + */ +export type ServeHarness = { + /** Subscribe to run events; returns an unsubscribe function. */ + subscribe: (listener: (event: AgentHarnessEvent) => void) => () => void + /** Register the pre-execution permission gate for tool calls. */ + registerToolCallGate: (handler: (event: ToolCallEvent) => Promise) => void + /** Run one prompt turn, resolving with the final assistant message. */ + prompt: (text: string) => Promise + /** Resolve once the harness has settled after a prompt. */ + waitForIdle: () => Promise + /** Abort the in-flight turn (drives the ACP `cancelled` stop reason). */ + abort: () => Promise +} + +/** Builds a {@link ServeHarness} for one session, paired with its teardown. + * `session` is the disk-backed Pi session to run on (new or resumed). + * Injectable so tests can swap in a fake. */ +export type BuildServeHarness = ( + config: HarnessConfig, + session: PiSession, +) => Promise<{ harness: ServeHarness; dispose: () => Promise }> + +/** Production builder: adapts the real {@link buildHarness} to the narrow + * {@link ServeHarness} surface the agent needs. */ +const defaultBuildServeHarness: BuildServeHarness = async (config, session) => { + const { harness, dispose } = await buildHarness(config, session) + return { + harness: { + subscribe: (listener) => harness.subscribe(listener), + registerToolCallGate: (handler) => { + harness.on('tool_call', handler) + }, + prompt: (text) => harness.prompt(text), + waitForIdle: () => harness.waitForIdle(), + abort: async () => { + await harness.abort() + }, + }, + dispose, + } +} + +/** A live ACP session: its harness, the run-event subscription feeding the ACP + * client, and a teardown that releases the harness's execution environment. */ +type Session = { + readonly harness: ServeHarness + readonly unsubscribe: () => void + readonly dispose: () => Promise +} + +/** Canonical UUID shape that `crypto.randomUUID()` mints for every `session/new`. + * A `session/resume` id is always a past mint, so a legitimate one matches; the + * guard exists because the resumed id is client-supplied (ACP `z.string()`) and + * flows into the on-disk path builder, which `path.join`s it — a crafted `..` + * id would escape the sessions root and overwrite an arbitrary `.jsonl`. */ +const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** Tools that run unguarded — pure reads with no side effects. Mirrors the + * interactive CLI's gate (`agent/permissions.ts`). */ +const READ_ONLY_TOOLS = new Set(['read']) + +/** The permission choices offered to the ACP client for a gated tool call. + * `allow-always` allows that tool for the rest of the session; the others are + * one-shot. Mirrors the interactive gate's allow-once/allow-session/deny. */ +const PERMISSION_OPTIONS: PermissionOption[] = [ + { optionId: 'allow-once', name: 'Allow', kind: 'allow_once' }, + { optionId: 'allow-always', name: 'Always allow', kind: 'allow_always' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, +] + +/** Flatten an ACP prompt's content blocks into the plain text the harness takes. + * Only text blocks are kept — image/audio/resource blocks are not advertised in + * `promptCapabilities`, so a spec-respecting client never sends them. */ +const promptText = (blocks: ContentBlock[]): string => + blocks + .filter((block): block is ContentBlock & { type: 'text'; text: string } => block.type === 'text') + .map((block) => block.text) + .join('\n') + +/** + * Map the client's permission decision to a Pi {@link ToolCallResult}. + * `undefined` lets the tool run; a blocking result stops it with a reason the + * model sees. `allow-always` additionally remembers the tool for the session. + * + * @param outcome - the client's `session/request_permission` outcome + * @param toolName - the tool being decided on + * @param sessionAllowed - the per-session set of tools allowed without re-asking + */ +const toToolCallResult = ( + outcome: RequestPermissionOutcome, + toolName: string, + sessionAllowed: Set, +): ToolCallResult | undefined => { + if (outcome.outcome === 'cancelled') return { block: true, reason: 'permission request cancelled' } + if (outcome.optionId === 'allow-always') { + sessionAllowed.add(toolName) + return undefined + } + if (outcome.optionId === 'allow-once') return undefined + return { block: true, reason: `user rejected ${toolName}` } +} + +/** + * Register the tool-permission gate on a session's harness. In `yolo` mode no + * hook is attached and every tool runs unguarded; otherwise read-only tools and + * session-allowed tools pass through, and any other tool round-trips to the ACP + * client via `session/request_permission` before it runs. + */ +const attachAcpPermissionGate = ( + harness: ServeHarness, + conn: AgentSideConnection, + sessionId: SessionId, + yolo: boolean, +): void => { + if (yolo) return + + const sessionAllowed = new Set() + + harness.registerToolCallGate(async ({ toolCallId, toolName, input }) => { + if (READ_ONLY_TOOLS.has(toolName) || sessionAllowed.has(toolName)) return undefined + + const { outcome } = await conn.requestPermission({ + sessionId, + options: PERMISSION_OPTIONS, + toolCall: { toolCallId, title: toolName, kind: toToolKind(toolName), rawInput: input, status: 'pending' }, + }) + return toToolCallResult(outcome, toolName, sessionAllowed) + }) +} + +/** + * Build the ACP {@link Agent} that fronts the built-in Pi harness for one + * connection. All sessions for the connection share `config` (model, thinking, + * yolo) and the connection's lifetime; each session gets its own harness bound + * to the cwd from `session/new`. Sessions are disposed when the connection closes. + * + * @param conn - the agent-side ACP connection (used to push updates + ask permission) + * @param config - the resolved serve configuration + * @param store - disk-backed session store keyed by ACP `sessionId` (new + resume) + * @param buildServeHarness - harness builder; injected by tests, defaults to the real one + */ +export const createHarnessAgent = ( + conn: AgentSideConnection, + config: ServeConfig, + store: SessionStore, + buildServeHarness: BuildServeHarness = defaultBuildServeHarness, +): Agent => { + const sessions = new Map() + + // Release every session's execution environment when the connection ends, so + // a dropped client never leaks the harness's temp dirs / shell. Deferred a + // microtask because `AgentSideConnection` invokes this factory *before* it + // wires up `conn.closed`, which would otherwise throw when read here. Disposes + // independently (one failure can't strand the rest) and logs any failure. + queueMicrotask(() => { + void conn.closed + .then(async () => { + const open = [...sessions.values()] + sessions.clear() + const outcomes = await Promise.allSettled( + open.map(async (session) => { + session.unsubscribe() + await session.dispose() + }), + ) + for (const outcome of outcomes) { + if (outcome.status === 'rejected') { + process.stderr.write(`⚡ acp serve: session dispose failed: ${String(outcome.reason)}\n`) + } + } + }) + .catch((err) => { + process.stderr.write(`⚡ acp serve: connection cleanup error: ${err instanceof Error ? err.message : String(err)}\n`) + }) + }) + + const requireSession = (sessionId: SessionId): Session => { + const session = sessions.get(sessionId) + if (!session) throw RequestError.invalidParams(undefined, `unknown session '${sessionId}'`) + return session + } + + const initialize = async (_params: InitializeRequest): Promise => ({ + // We implement exactly one protocol version, so we always answer with it — + // the only version we could honestly negotiate. A client that can't speak it + // disconnects (per ACP initialization). + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'thunderbolt', version: VERSION }, + agentCapabilities: { + // We do not replay history (the app renders from PowerSync), so we + // advertise `resume` — no-replay context restore — not `loadSession`. + loadSession: false, + sessionCapabilities: { resume: {} }, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + }, + authMethods: [], + }) + + /** Per-session harness config: connection-wide settings + the request's cwd. */ + const harnessConfigFor = (cwd: string): HarnessConfig => ({ + model: config.model, + cwd, + yolo: config.yolo, + thinking: config.thinking, + provider: config.provider, + baseUrl: config.baseUrl, + apiKey: config.apiKey, + announceModel: true, + }) + + /** Build the harness on `session`, wire its run events + permission gate to the + * ACP connection, and register it live. Shared by new and resume — the only + * difference between them is which {@link PiSession} is handed in. */ + const activate = async (sessionId: SessionId, cwd: string, session: PiSession, phase: string): Promise => { + const { harness, dispose } = await buildServeHarness(harnessConfigFor(cwd), session) + + // If the client vanished while the harness was being built, the cleanup + // microtask already ran against a map without this session — dispose now so + // the freshly-built harness can't leak its temp dirs / shell. + if (conn.signal.aborted) { + await dispose() + throw RequestError.internalError(undefined, `connection closed during ${phase}`) + } + + const translator = createHarnessToAcpTranslator((update) => { + // Fire-and-forget: the SDK serializes writes on one queue, so updates + // emitted in order arrive in order. A rejection means the client went away + // mid-turn (the stream closed) — benign teardown, not an error to surface — + // so swallow it to avoid an unhandled rejection; the connection close is the + // real signal that ends the run. + void conn.sessionUpdate({ sessionId, update }).catch(() => {}) + }) + const unsubscribe = harness.subscribe((event) => translator.handle(event)) + attachAcpPermissionGate(harness, conn, sessionId, config.yolo) + + // Re-activating a live id (e.g. a repeated session/resume on one connection) + // replaces the entry — tear down the prior harness + subscription first so it + // can't leak its execution env or double-append the shared disk log. + const previous = sessions.get(sessionId) + sessions.set(sessionId, { harness, unsubscribe, dispose }) + if (previous) { + previous.unsubscribe() + await previous.dispose() + } + } + + const newSession = async (params: NewSessionRequest): Promise => { + const sessionId = crypto.randomUUID() + const session = await store.createSession(sessionId, params.cwd) + await activate(sessionId, params.cwd, session, 'session/new') + return { sessionId } + } + + // Resume a prior thread on a fresh process: rehydrate the agent's execution + // context from the on-disk log keyed by the client-supplied sessionId. No + // history is replayed to the client (the app already rendered it from + // PowerSync); we only re-seed the harness. An unknown-but-well-formed id + // self-heals to a fresh session inside the store (migration / cache-clear); a + // malformed id is rejected up front (see the guard below). + const resumeSession = async (params: ResumeSessionRequest): Promise => { + // Reject a crafted id at the wire boundary before it reaches the on-disk path + // builder — a `..` segment would let the write escape the sessions root. + if (!SESSION_ID_PATTERN.test(params.sessionId)) { + throw RequestError.invalidParams(undefined, `invalid session id '${params.sessionId}'`) + } + const session = await store.openOrCreate(params.sessionId, params.cwd) + await activate(params.sessionId, params.cwd, session, 'session/resume') + return {} + } + + const prompt = async (params: PromptRequest): Promise => { + const { harness } = requireSession(params.sessionId) + const result = await harness.prompt(promptText(params.prompt)) + await harness.waitForIdle() + // A failed turn resolves (it doesn't throw) with `stopReason: 'error'`, which + // has no ACP equivalent — surface it loudly as a JSON-RPC error instead. + if (result.stopReason === 'error') throw new Error(result.errorMessage ?? 'the model request failed') + return { stopReason: toAcpStopReason(result.stopReason) } + } + + const cancel = async (params: CancelNotification): Promise => { + await sessions.get(params.sessionId)?.harness.abort() + } + + // No authentication: the transport (loopback wss / allowlisted iroh) is the + // trust boundary, so `authenticate` is a no-op the client should never need. + const authenticate = async (): Promise => ({}) + + return { initialize, newSession, resumeSession, prompt, cancel, authenticate } +} diff --git a/cli/src/acp/harness-to-acp.test.ts b/cli/src/acp/harness-to-acp.test.ts new file mode 100644 index 000000000..638e99ca5 --- /dev/null +++ b/cli/src/acp/harness-to-acp.test.ts @@ -0,0 +1,225 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for the pure Pi→ACP translation: the `toToolKind` / `toAcpStopReason` + * mapping tables and the per-event `createHarnessToAcpTranslator` branching + * (every harness event → zero or one ACP SessionUpdate, including the + * tool-result content extraction and the lifecycle drop-through). + */ + +import { describe, expect, test } from 'bun:test' +import type { AgentHarnessEvent } from '@earendil-works/pi-agent-core' +import type { AssistantMessageEvent, StopReason as PiStopReason } from '@earendil-works/pi-ai' +import type { SessionUpdate } from '@agentclientprotocol/sdk' +import { createHarnessToAcpTranslator, toAcpStopReason, toToolKind } from './harness-to-acp.ts' + +/** Build a `message_update` event around an inner assistant-message delta. The + * translator only reads `assistantMessageEvent`, so `message` is elided. */ +const messageUpdate = (inner: Partial & { type: string }): AgentHarnessEvent => + ({ type: 'message_update', assistantMessageEvent: inner } as unknown as AgentHarnessEvent) + +/** Collect every update the translator emits for a single event. */ +const translate = (event: AgentHarnessEvent): SessionUpdate[] => { + const out: SessionUpdate[] = [] + createHarnessToAcpTranslator((u) => out.push(u)).handle(event) + return out +} + +describe('toToolKind', () => { + test('maps the four built-in coding tools to their ACP kinds', () => { + expect(toToolKind('bash')).toBe('execute') + expect(toToolKind('read')).toBe('read') + expect(toToolKind('write')).toBe('edit') + expect(toToolKind('edit')).toBe('edit') + }) + + test('falls back to `other` for any unknown / empty tool name', () => { + expect(toToolKind('grep')).toBe('other') + expect(toToolKind('')).toBe('other') + expect(toToolKind('BASH')).toBe('other') // case-sensitive: not the same key + }) +}) + +describe('toAcpStopReason', () => { + test('length → max_tokens, aborted → cancelled', () => { + expect(toAcpStopReason('length')).toBe('max_tokens') + expect(toAcpStopReason('aborted')).toBe('cancelled') + }) + + test('stop / toolUse / error all collapse to end_turn', () => { + const collapsing: PiStopReason[] = ['stop', 'toolUse', 'error'] + for (const reason of collapsing) expect(toAcpStopReason(reason)).toBe('end_turn') + }) +}) + +describe('createHarnessToAcpTranslator — message_update', () => { + test('text_delta → a single agent_message_chunk carrying the delta', () => { + const out = translate(messageUpdate({ type: 'text_delta', delta: 'hello' })) + expect(out).toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hello' } }]) + }) + + test('thinking_delta → agent_thought_chunk, not a message chunk', () => { + const out = translate(messageUpdate({ type: 'thinking_delta', delta: 'pondering' })) + expect(out).toEqual([{ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'pondering' } }]) + }) + + test('a non-delta inner event (text_start) emits nothing', () => { + expect(translate(messageUpdate({ type: 'text_start' }))).toEqual([]) + }) +}) + +describe('createHarnessToAcpTranslator — tool_execution_start', () => { + test('emits an in_progress tool_call with the mapped kind and forwarded args', () => { + const out = translate({ + type: 'tool_execution_start', + toolCallId: 't1', + toolName: 'bash', + args: { command: 'ls' }, + } as AgentHarnessEvent) + expect(out).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 't1', + title: 'bash', + kind: 'execute', + status: 'in_progress', + rawInput: { command: 'ls' }, + }, + ]) + }) + + test('an unknown tool keeps its title but degrades kind to `other`', () => { + const [update] = translate({ + type: 'tool_execution_start', + toolCallId: 't2', + toolName: 'curl', + args: {}, + } as AgentHarnessEvent) + expect(update).toMatchObject({ title: 'curl', kind: 'other' }) + }) + + test('missing args default to an empty rawInput object', () => { + const [update] = translate({ + type: 'tool_execution_start', + toolCallId: 't3', + toolName: 'read', + args: undefined, + } as unknown as AgentHarnessEvent) + expect(update).toMatchObject({ rawInput: {} }) + }) +}) + +describe('createHarnessToAcpTranslator — tool_execution_end', () => { + const end = (result: unknown, isError: boolean): AgentHarnessEvent => + ({ type: 'tool_execution_end', toolCallId: 't1', toolName: 'bash', result, isError } as AgentHarnessEvent) + + /** Translate an end event and narrow to the `tool_call_update` variant so its + * `content` / `rawOutput` fields are typed. */ + const update = (result: unknown, isError: boolean) => { + const [u] = translate(end(result, isError)) + if (u.sessionUpdate !== 'tool_call_update') throw new Error(`expected tool_call_update, got ${u.sessionUpdate}`) + return u + } + + test('a successful result → completed, forwarding rawOutput and text content', () => { + const result = { content: [{ type: 'text', text: 'done' }] } + const out = translate(end(result, false)) + expect(out).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 't1', + status: 'completed', + rawOutput: result, + content: [{ type: 'content', content: { type: 'text', text: 'done' } }], + }, + ]) + }) + + test('isError flips the status to failed and still forwards rawOutput', () => { + const result = { content: [{ type: 'text', text: 'boom' }] } + expect(update(result, true)).toMatchObject({ status: 'failed', toolCallId: 't1', rawOutput: result }) + }) + + test('selects blocks by a string `text` field, regardless of declared `type`', () => { + // The predicate keys off `text` presence, not `type`: a block typed `image` + // but carrying a `text` string is kept; blocks without `text` are dropped. + const result = { + content: [ + { type: 'image', data: 'xxx' }, // no `text` → dropped + { type: 'image', text: 'alt-kept' }, // has `text` → kept despite type + { type: 'text', text: 'keep me' }, + { type: 'resource' }, // no `text` → dropped + ], + } + expect(update(result, false).content).toEqual([ + { type: 'content', content: { type: 'text', text: 'alt-kept' } }, + { type: 'content', content: { type: 'text', text: 'keep me' } }, + ]) + }) + + test('multiple text blocks all survive, in order', () => { + const result = { content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] } + expect(update(result, false).content).toEqual([ + { type: 'content', content: { type: 'text', text: 'a' } }, + { type: 'content', content: { type: 'text', text: 'b' } }, + ]) + }) + + test('a result with no text blocks yields undefined content (relies on rawOutput)', () => { + const u = update({ content: [{ type: 'image', data: 'x' }] }, false) + expect(u.content).toBeUndefined() + expect(u.rawOutput).toEqual({ content: [{ type: 'image', data: 'x' }] }) + }) + + test('a result without a content array yields undefined content', () => { + expect(update({ details: {} }, false).content).toBeUndefined() + }) + + test('a non-object / null result yields undefined content but still completes', () => { + expect(update(null, false)).toMatchObject({ status: 'completed', content: undefined }) + }) +}) + +describe('createHarnessToAcpTranslator — lifecycle / unmapped events', () => { + test('lifecycle and intermediate events produce no session update', () => { + const dropped: AgentHarnessEvent[] = [ + { type: 'agent_start' } as AgentHarnessEvent, + { type: 'turn_start' } as AgentHarnessEvent, + { type: 'turn_end', message: {}, toolResults: [] } as unknown as AgentHarnessEvent, + { type: 'message_start' } as unknown as AgentHarnessEvent, + { type: 'message_end' } as unknown as AgentHarnessEvent, + { type: 'agent_end', messages: [] } as unknown as AgentHarnessEvent, + { + type: 'tool_execution_update', + toolCallId: 't1', + toolName: 'bash', + args: {}, + partialResult: {}, + } as AgentHarnessEvent, + ] + for (const event of dropped) expect(translate(event)).toEqual([]) + }) +}) + +describe('createHarnessToAcpTranslator — stateless ordering across a sequence', () => { + test('one translator preserves emit order and drops lifecycle events inline', () => { + const out: SessionUpdate[] = [] + const { handle } = createHarnessToAcpTranslator((u) => out.push(u)) + // A realistic turn: lifecycle → text → tool start → tool end → lifecycle. + handle({ type: 'turn_start' } as AgentHarnessEvent) + handle(messageUpdate({ type: 'text_delta', delta: 'one ' })) + handle(messageUpdate({ type: 'text_delta', delta: 'two' })) + handle({ type: 'tool_execution_start', toolCallId: 'x', toolName: 'read', args: { path: 'a' } } as AgentHarnessEvent) + handle({ type: 'tool_execution_end', toolCallId: 'x', toolName: 'read', result: { content: [] }, isError: false } as AgentHarnessEvent) + handle({ type: 'turn_end', message: {}, toolResults: [] } as unknown as AgentHarnessEvent) + + expect(out.map((u) => u.sessionUpdate)).toEqual([ + 'agent_message_chunk', + 'agent_message_chunk', + 'tool_call', + 'tool_call_update', + ]) + }) +}) diff --git a/cli/src/acp/harness-to-acp.ts b/cli/src/acp/harness-to-acp.ts new file mode 100644 index 000000000..5914df183 --- /dev/null +++ b/cli/src/acp/harness-to-acp.ts @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Translator: Pi {@link AgentHarness} run → ACP `session/update` notifications. + * + * The inverse of `src/acp/translators/acp-to-ai-sdk.ts` (which consumes ACP + * updates) and the ACP-shaped sibling of `shared/agent-core/pi-to-aisdk-stream.ts` + * (which emits AI SDK chunks for the in-browser app). Here a harness drives an + * ACP *client* over the wire, so each Pi event becomes a {@link SessionUpdate}. + * + * Event mapping (Pi `AgentHarnessEvent` → ACP `SessionUpdate`): + * message_update (text_delta) → agent_message_chunk (text) + * message_update (thinking_delta) → agent_thought_chunk (text) + * tool_execution_start → tool_call { status: in_progress } + * tool_execution_end → tool_call_update { completed | failed } + * + * Lifecycle events (agent_start, turn_start, turn_end, agent_end) have no ACP + * `sessionUpdate` equivalent — the ACP turn lifecycle is the `session/prompt` + * request/response itself — so they are dropped here; the prompt handler maps + * the final {@link StopReason} from the harness's resolved `AssistantMessage`. + */ + +import type { StopReason as PiStopReason } from '@earendil-works/pi-ai' +import type { AgentHarnessEvent } from '@earendil-works/pi-agent-core' +import type { SessionUpdate, StopReason, ToolCallContent, ToolKind } from '@agentclientprotocol/sdk' + +/** The coding tools the built-in harness exposes, mapped to the ACP {@link ToolKind} + * the client uses to pick an icon / UI treatment. Unknown tools fall back to + * `other` so an added tool degrades gracefully rather than mis-labelling. */ +const TOOL_KINDS: Record = { + bash: 'execute', + read: 'read', + write: 'edit', + edit: 'edit', +} + +/** Map a built-in tool name to its ACP {@link ToolKind}. */ +export const toToolKind = (toolName: string): ToolKind => TOOL_KINDS[toolName] ?? 'other' + +/** + * Map a Pi {@link PiStopReason} to the ACP {@link StopReason} returned from + * `session/prompt`. `error` has no ACP stop reason — the prompt handler surfaces + * it as a JSON-RPC error instead — so it falls through to `end_turn` here only as + * a defensive default that the handler never actually reaches. + */ +export const toAcpStopReason = (reason: PiStopReason): StopReason => { + switch (reason) { + case 'length': + return 'max_tokens' + case 'aborted': + return 'cancelled' + case 'stop': + case 'toolUse': + case 'error': + return 'end_turn' + } +} + +/** Narrows an unknown tool-result content block to one carrying text. */ +const isTextContent = (block: unknown): block is { text: string } => + typeof block === 'object' && block !== null && typeof (block as { text?: unknown }).text === 'string' + +/** + * Map a Pi `AgentToolResult` to ACP `ToolCallContent[]`, keeping the text blocks + * the client renders. Returns `undefined` when the result carries no text so the + * update relies on `rawOutput` alone rather than emitting an empty collection. + */ +const toToolCallContent = (result: unknown): ToolCallContent[] | undefined => { + if (typeof result !== 'object' || result === null) return undefined + const content = (result as { content?: unknown }).content + if (!Array.isArray(content)) return undefined + const blocks: ToolCallContent[] = content + .filter(isTextContent) + .map((block) => ({ type: 'content', content: { type: 'text', text: block.text } })) + return blocks.length > 0 ? blocks : undefined +} + +/** Sink for the {@link SessionUpdate}s a translator produces. The agent wraps + * this to call `connection.sessionUpdate({ sessionId, update })`. */ +export type AcpUpdateSink = (update: SessionUpdate) => void + +/** + * Builds a stateless translator that turns each harness event into zero or one + * ACP {@link SessionUpdate}, pushed into `emit`. Stateless because the ACP + * client tracks its own per-`toolCallId` lifecycle — unlike the AI SDK stream, + * which has to synthesize start/end part boundaries. + * + * @param emit - sink invoked once per produced session update + * @returns an object exposing `handle` to feed it harness events + */ +export const createHarnessToAcpTranslator = (emit: AcpUpdateSink): { handle: (event: AgentHarnessEvent) => void } => { + const handle = (event: AgentHarnessEvent): void => { + switch (event.type) { + case 'message_update': { + const inner = event.assistantMessageEvent + if (inner.type === 'text_delta') { + emit({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: inner.delta } }) + } else if (inner.type === 'thinking_delta') { + emit({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: inner.delta } }) + } + return + } + case 'tool_execution_start': { + emit({ + sessionUpdate: 'tool_call', + toolCallId: event.toolCallId, + title: event.toolName, + kind: toToolKind(event.toolName), + status: 'in_progress', + rawInput: event.args ?? {}, + }) + return + } + case 'tool_execution_end': { + emit({ + sessionUpdate: 'tool_call_update', + toolCallId: event.toolCallId, + status: event.isError ? 'failed' : 'completed', + rawOutput: event.result as unknown, + content: toToolCallContent(event.result), + }) + return + } + // Lifecycle events (agent_start, turn_start/turn_end, message_*, agent_end) + // and every other harness hook have no ACP `sessionUpdate`; the turn + // lifecycle is the `session/prompt` request/response itself. + default: + return + } + } + + return { handle } +} diff --git a/cli/src/acp/serve.ts b/cli/src/acp/serve.ts new file mode 100644 index 000000000..906d3e25e --- /dev/null +++ b/cli/src/acp/serve.ts @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `thunderbolt acp serve` — run the built-in coding agent as a stdio ACP + * JSON-RPC server. + * + * stdin/stdout ARE the JSON-RPC channel (newline-delimited JSON), so this path + * never attaches the terminal renderer and routes every log line to stderr. The + * intended deployment is behind the iroh/wss bridge, which spawns one + * `acp serve` process per connection and pumps its stdio over the network: + * + * thunderbolt acp --transport iroh -- thunderbolt acp serve + * + * The process lives for exactly one connection; it exits when the stream closes. + */ + +import { AgentSideConnection, ndJsonStream } from '@agentclientprotocol/sdk' +import type { ServeConfig } from '../agent/types.ts' +import { createHarnessAgent } from './harness-agent.ts' +import { createSessionStore, defaultSessionsDir } from './session-store.ts' + +/** Adapt this process's stdout to the `WritableStream` the ACP + * `ndJsonStream` writes encoded messages into, honoring write backpressure via + * the write callback so a slow reader throttles us rather than buffering + * unboundedly. */ +const stdoutWritable = (): WritableStream => + new WritableStream({ + write: (chunk) => + new Promise((resolve, reject) => { + process.stdout.write(chunk, (err) => (err ? reject(err) : resolve())) + }), + }) + +/** + * Serve the built-in harness as an ACP agent over stdio until the client + * disconnects (the stream closes). Returns when the connection is fully closed. + * + * @param config - the resolved serve configuration (model, thinking, yolo) + */ +export const runAcpServe = async (config: ServeConfig): Promise => { + // One process serves one connection, but the store's fs must outlive every + // per-session harness (it captures the fs for each turn's appends), so build it + // once here rather than per session. + const store = createSessionStore(defaultSessionsDir()) + const stream = ndJsonStream(stdoutWritable(), Bun.stdin.stream()) + const connection = new AgentSideConnection((conn) => createHarnessAgent(conn, config, store), stream) + await connection.closed +} diff --git a/cli/src/acp/session-store.test.ts b/cli/src/acp/session-store.test.ts new file mode 100644 index 000000000..6aea6693b --- /dev/null +++ b/cli/src/acp/session-store.test.ts @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Exercises the disk-backed {@link createSessionStore} against a real temp + * directory (no mocks): a session persisted by one store instance rehydrates on + * a *fresh* instance — proving the entry log survives a process restart, which + * is the whole point of `session/resume` — and the resume path trims an + * incomplete trailing turn so the next prompt starts clean. + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { AssistantMessage, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai' +import { createSessionStore } from './session-store.ts' + +const CWD = '/workspace/project' + +const userMessage = (text: string): UserMessage => ({ role: 'user', content: text, timestamp: 0 }) + +const assistantText = (text: string): AssistantMessage => ({ + role: 'assistant', + content: [{ type: 'text', text }], + api: 'anthropic-messages', + provider: 'anthropic', + model: 'fake', + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: 'stop', + timestamp: 0, +}) + +const assistantToolCall = (toolCallId: string, name: string): AssistantMessage => ({ + ...assistantText(''), + content: [{ type: 'toolCall', id: toolCallId, name, arguments: { command: 'echo hi' } }], + stopReason: 'toolUse', +}) + +const toolResult = (toolCallId: string, name: string): ToolResultMessage => ({ + role: 'toolResult', + toolCallId, + toolName: name, + content: [{ type: 'text', text: 'hi' }], + isError: false, + timestamp: 0, +}) + +describe('createSessionStore', () => { + let dir: string + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'thunderbolt-store-')) + }) + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + test('a persisted session rehydrates full execution context on a fresh store', async () => { + const writer = createSessionStore(dir) + const session = await writer.createSession('s1', CWD) + await session.appendThinkingLevelChange('high') + await session.appendMessage(userMessage('build it')) + await session.appendMessage(assistantToolCall('t1', 'bash')) + await session.appendMessage(toolResult('t1', 'bash')) + await session.appendMessage(assistantText('done')) + + // A brand-new store instance over the same dir = a fresh process resuming. + const resumed = await createSessionStore(dir).openOrCreate('s1', CWD) + const context = await resumed.buildContext() + + expect(context.messages).toHaveLength(4) + expect(context.messages[0]).toMatchObject({ role: 'user', content: 'build it' }) + // The tool call AND its result survive — execution continuity, not just text. + expect(context.messages[1]).toMatchObject({ role: 'assistant', content: [{ type: 'toolCall', id: 't1' }] }) + expect(context.messages[2]).toMatchObject({ role: 'toolResult', toolCallId: 't1' }) + expect(context.messages[3]).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'done' }] }) + // A non-message entry (thinking level) is preserved too. + expect(context.thinkingLevel).toBe('high') + }) + + test('an unknown id self-heals to a fresh empty session (no throw)', async () => { + const resumed = await createSessionStore(dir).openOrCreate('never-persisted', CWD) + expect((await resumed.buildContext()).messages).toHaveLength(0) + expect((await resumed.getMetadata()).id).toBe('never-persisted') + }) + + test('resume drops a trailing dangling tool_use (killed mid-turn)', async () => { + const writer = createSessionStore(dir) + const session = await writer.createSession('s2', CWD) + await session.appendMessage(assistantText('earlier answer')) // last clean boundary + await session.appendMessage(userMessage('do more')) + await session.appendMessage(assistantToolCall('t9', 'bash')) // no tool_result → dangling + + const resumed = await createSessionStore(dir).openOrCreate('s2', CWD) + const context = await resumed.buildContext() + + expect(context.messages).toHaveLength(1) + expect(context.messages[0]).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'earlier answer' }] }) + }) + + test('resume drops a trailing bare user prompt (killed before the reply)', async () => { + const writer = createSessionStore(dir) + const session = await writer.createSession('s3', CWD) + await session.appendMessage(assistantText('earlier answer')) + await session.appendMessage(userMessage('unanswered')) + + const resumed = await createSessionStore(dir).openOrCreate('s3', CWD) + const context = await resumed.buildContext() + + expect(context.messages).toHaveLength(1) + expect(context.messages[0]).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'earlier answer' }] }) + }) + + test('resume leaves a clean session untouched', async () => { + const writer = createSessionStore(dir) + const session = await writer.createSession('s4', CWD) + await session.appendMessage(userMessage('hi')) + await session.appendMessage(assistantText('hello')) + + const resumed = await createSessionStore(dir).openOrCreate('s4', CWD) + expect((await resumed.buildContext()).messages).toHaveLength(2) + }) +}) diff --git a/cli/src/acp/session-store.ts b/cli/src/acp/session-store.ts new file mode 100644 index 000000000..3e6e180a0 --- /dev/null +++ b/cli/src/acp/session-store.ts @@ -0,0 +1,118 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Disk-backed store for the ACP server's per-session Pi entry logs, keyed by the + * agent-issued `sessionId`. + * + * The bridge spawns a fresh `acp serve` process per connection and kills it on + * disconnect, so in-memory session state cannot survive a reconnect. Persisting + * each session's Pi entry log to disk on the bridge machine — exactly where the + * workspace files it describes live — lets a fresh process rehydrate the agent's + * full execution context (messages, tool calls/results, compaction) on + * `session/resume`. This mirrors Claude Code / Codex: sessions are keyed to the + * machine + cwd, and cross-machine resume is intentionally unsupported. + * + * Backed by Pi's {@link JsonlSessionRepo}, which needs a {@link FileSystem}; the + * only Node one Pi exports is {@link NodeExecutionEnv}. We hold a single, + * process-lifetime env whose lifetime outlives every per-session workspace env + * (those are disposed on session teardown, but the store's fs is captured for + * every append during a turn, so it must not be torn down with them). + */ + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { JsonlSessionRepo } from '@earendil-works/pi-agent-core' +import type { Session } from '@earendil-works/pi-agent-core' +import { NodeExecutionEnv } from '@earendil-works/pi-agent-core/node' + +/** Opens/creates the disk-backed Pi session for a given ACP `sessionId`. */ +export type SessionStore = { + /** Mint a fresh on-disk session log under `id` for `session/new`. */ + createSession: (id: string, cwd: string) => Promise + /** Resume by `id` for `session/resume`: open the existing log (trimming any + * incomplete trailing turn) or, if none exists, create a fresh one. */ + openOrCreate: (id: string, cwd: string) => Promise +} + +/** Default on-disk root for persisted ACP sessions on the bridge machine. */ +export const defaultSessionsDir = (): string => join(homedir(), '.thunderbolt', 'acp', 'sessions') + +/** + * Trim a trailing incomplete turn from a just-opened session so the next prompt + * starts from a clean assistant boundary. + * + * The bridge SIGTERMs the serve process on disconnect, so a kill can land + * mid-turn — leaving the log ending on an assistant `tool_use` whose + * `tool_result` was never written, or a bare user prompt with no response. + * Either shape makes the model API reject the next appended user message. We + * walk the active branch, track unresolved `tool_use` ids, and move the leaf + * back to the last entry after which the conversation was a complete assistant + * turn (or empty). Non-destructive: dropped entries stay in the JSONL, just off + * the active branch. + * + * We deliberately anchor on the last *assistant* boundary rather than any clean + * entry: it guarantees valid role alternation for the next prompt. The one cost + * is that a kill during the turn immediately after a compaction rewinds past that + * compaction entry, re-expanding the pre-compaction history — which pi's + * prompt-time overflow compaction re-collapses on the next turn (a rare, self- + * healing case; a special compact-on-resume path is intentionally not built). + */ +const sanitizeResumedTail = async (session: Session): Promise => { + const branch = await session.getBranch() + const unresolved = new Set() + // `null` marks "the empty prefix is a clean boundary" — used when no complete + // assistant turn precedes the incomplete tail. + let cleanBoundaryId: string | null = null + let lastMessageId: string | null = null + + for (const entry of branch) { + if (entry.type !== 'message') continue + lastMessageId = entry.id + const message = entry.message + if (message.role === 'assistant') { + for (const block of message.content) { + if (block.type === 'toolCall') unresolved.add(block.id) + } + // A complete assistant turn = every prior tool call answered. A user turn + // awaiting a response is never itself a clean boundary. + if (unresolved.size === 0) cleanBoundaryId = entry.id + } else if (message.role === 'toolResult') { + unresolved.delete(message.toolCallId) + } + } + + if (lastMessageId !== cleanBoundaryId) await session.moveTo(cleanBoundaryId) +} + +/** + * Build the process-lifetime session store rooted at `sessionsDir`. The repo + * namespaces files as `//_.jsonl` + * itself, and self-creates the directory tree on first write. + * + * @param sessionsDir - absolute root directory for persisted session logs + */ +export const createSessionStore = (sessionsDir: string): SessionStore => { + const fs = new NodeExecutionEnv({ cwd: sessionsDir }) + const repo = new JsonlSessionRepo({ fs, sessionsRoot: sessionsDir }) + + return { + createSession: (id, cwd) => repo.create({ id, cwd }), + openOrCreate: async (id, cwd) => { + // `open` needs the full timestamped metadata, which only `list` yields — + // the path can't be reconstructed from the id alone. + const existing = (await repo.list({ cwd })).find((meta) => meta.id === id) + if (!existing) { + // Unknown id: a thread whose `acp_session_id` predates disk persistence, + // or a cache-clear. Self-heal by minting a fresh log under the same id; + // the visible transcript is unaffected (it renders from PowerSync). + process.stderr.write(`⚡ acp serve: no on-disk session '${id}', creating fresh\n`) + return repo.create({ id, cwd }) + } + const session = await repo.open(existing) + await sanitizeResumedTail(session) + return session + }, + } +} diff --git a/cli/src/agent/harness.ts b/cli/src/agent/harness.ts new file mode 100644 index 000000000..d5a1c3634 --- /dev/null +++ b/cli/src/agent/harness.ts @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Assembles the Pi `AgentHarness` — the spine that lets the CLI actually talk + * to Claude. It binds a Node execution environment (real bash + filesystem) to + * the working directory, opens an in-memory session, resolves the model, and + * registers the four coding tools (bash/read/write/edit). + */ + +import { AgentHarness, InMemorySessionRepo } from '@earendil-works/pi-agent-core' +import type { Session } from '@earendil-works/pi-agent-core' +import { NodeExecutionEnv } from '@earendil-works/pi-agent-core/node' +import { createBashTool, createEditTool, createReadTool, createWriteTool } from '@earendil-works/pi-coding-agent' +import { resolveModel } from './model.ts' +import { buildSystemPrompt } from './system-prompt.ts' +import type { HarnessBundle, HarnessConfig } from './types.ts' + +/** + * Builds a ready-to-run harness for a single CLI invocation, paired with a + * `dispose` that releases the execution environment. Renderer and permission + * hooks are attached by the caller, not here. + * + * When `session` is supplied (the ACP server's new/resume paths), the harness + * folds its persisted entry log into every turn via `session.buildContext()`, so + * a disk-opened session rehydrates the full prior conversation with no other + * change here. Omitting it (the one-shot / REPL CLI) keeps the ephemeral + * in-memory session. Note the harness's model/thinking/active-tools come from + * `config`, not the recorded session — a resumed thread runs under the + * connection's current config, which the app keeps consistent per thread. + * + * @param config - the resolved harness configuration + * @param session - an existing session to resume; defaults to a fresh in-memory one + * @returns the constructed harness and its teardown function + */ +export const buildHarness = async (config: HarnessConfig, session?: Session): Promise => { + const env = new NodeExecutionEnv({ cwd: config.cwd }) + const activeSession = session ?? (await new InMemorySessionRepo().create({})) + const { models, model } = resolveModel({ + model: config.model, + provider: config.provider, + baseUrl: config.baseUrl, + apiKey: config.apiKey, + }) + const tools = [ + createBashTool(config.cwd), + createReadTool(config.cwd), + createWriteTool(config.cwd), + createEditTool(config.cwd), + ] + + const harness = new AgentHarness({ + env, + session: activeSession, + models, + model, + tools, + activeToolNames: tools.map((tool) => tool.name), + thinkingLevel: config.thinking, + systemPrompt: buildSystemPrompt({ cwd: config.cwd, modelId: config.announceModel ? config.model : undefined }), + }) + + return { + harness, + dispose: async () => { + await env.cleanup() + }, + } +} diff --git a/cli/src/agent/model.test.ts b/cli/src/agent/model.test.ts new file mode 100644 index 000000000..ffe44ec21 --- /dev/null +++ b/cli/src/agent/model.test.ts @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Branch coverage for `resolveModel`: the anthropic-vs-openai-compat routing, + * the required-input guards for openai-compat (base URL + api key), and the + * unknown-Anthropic-id failure. These run with no network — anthropic resolves + * against Pi's wired built-in catalog and openai-compat synthesizes a local + * descriptor. + */ + +import { builtinModels } from '@earendil-works/pi-ai/providers/all' +import { describe, expect, test } from 'bun:test' +import { resolveModel } from './model.ts' + +/** Pull a real catalog id from Pi's wired provider rather than hard-coding one, + * so this stays green across catalog churn. */ +const KNOWN_ANTHROPIC = builtinModels().getModels('anthropic')[0]!.id + +describe('resolveModel — openai-compat branch', () => { + test('throws when --base-url is missing', () => { + expect(() => resolveModel({ model: 'mimo', provider: 'openai-compat', apiKey: 'k' })).toThrow(/--base-url/) + }) + + test('throws when the api key is missing even with a base URL', () => { + expect(() => resolveModel({ model: 'mimo', provider: 'openai-compat', baseUrl: 'https://h/v1' })).toThrow( + /requires an api key/, + ) + }) + + test('rejects an empty-string base URL (falsy guard, not just undefined)', () => { + expect(() => resolveModel({ model: 'mimo', provider: 'openai-compat', baseUrl: '', apiKey: 'k' })).toThrow( + /--base-url/, + ) + }) + + test('rejects an empty-string api key', () => { + expect(() => resolveModel({ model: 'mimo', provider: 'openai-compat', baseUrl: 'https://h/v1', apiKey: '' })).toThrow( + /requires an api key/, + ) + }) + + test('resolves a synthetic model carrying the upstream id and base URL', () => { + const { models, model } = resolveModel({ + model: 'mimo-v2.5-pro', + provider: 'openai-compat', + baseUrl: 'https://token-plan-sgp.xiaomimimo.com/v1', + apiKey: 'secret', + }) + expect(model.id).toBe('mimo-v2.5-pro') + expect(model.provider).toBe('openai-compat') + expect(model.baseUrl).toBe('https://token-plan-sgp.xiaomimimo.com/v1') + // The model is registered in the returned collection under its provider. + expect(models.getModel('openai-compat', 'mimo-v2.5-pro')?.id).toBe('mimo-v2.5-pro') + }) + + test('the resolved model descriptor does not embed the secret key', () => { + const { model } = resolveModel({ + model: 'mimo', + provider: 'openai-compat', + baseUrl: 'https://h/v1', + apiKey: 'super-secret-key', + }) + expect(JSON.stringify(model)).not.toContain('super-secret-key') + }) +}) + +describe('resolveModel — anthropic branch (default)', () => { + test('defaults to anthropic when no provider is given and resolves a known id', () => { + const { model } = resolveModel({ model: KNOWN_ANTHROPIC }) + expect(model.id).toBe(KNOWN_ANTHROPIC) + expect(model.provider).toBe('anthropic') + }) + + test('throws on an unknown Anthropic id', () => { + expect(() => resolveModel({ model: 'claude-does-not-exist', provider: 'anthropic' })).toThrow( + /Unknown Anthropic model/, + ) + }) + + test('ignores base URL / api key on the anthropic branch (never requires --base-url)', () => { + // anthropic resolution must not trip the openai-compat base-url guard. + const { model } = resolveModel({ model: KNOWN_ANTHROPIC, provider: 'anthropic', baseUrl: 'https://ignored' }) + expect(model.provider).toBe('anthropic') + }) +}) diff --git a/cli/src/agent/model.ts b/cli/src/agent/model.ts new file mode 100644 index 000000000..6189d63fc --- /dev/null +++ b/cli/src/agent/model.ts @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Resolves the model the harness runs, branching on the requested provider: + * + * - `anthropic` (default): looks the id up in Pi's built-in catalog. Pi's + * `@earendil-works/pi-ai/providers/all` is the only entry point that wires + * the providers (bare `createModels()` returns an empty collection); the + * wired anthropic provider resolves `ANTHROPIC_API_KEY` from the environment. + * - `openai-compat`: synthesizes an OpenAI-compatible model bound to a custom + * base URL + bearer key (see {@link buildOpenAiCompatModel}), so the CLI can + * run a non-Anthropic endpoint like Xiaomi MiMo. + */ + +import type { Api, Model, Models } from '@earendil-works/pi-ai' +import { builtinModels } from '@earendil-works/pi-ai/providers/all' +import { buildOpenAiCompatModel } from './openai-compat-model.ts' +import type { ModelProvider } from './types.ts' + +/** Default provider when none is specified. */ +const ANTHROPIC: ModelProvider = 'anthropic' + +/** Inputs for {@link resolveModel}: the model id plus the provider routing. */ +export type ResolveModelOptions = { + /** Model id to run (Anthropic catalog id, or upstream openai-compat id). */ + readonly model: string + /** Backend to resolve against (defaults to `anthropic`). */ + readonly provider?: ModelProvider + /** OpenAI-compatible base URL — required for `openai-compat`. */ + readonly baseUrl?: string + /** Bearer api key — required for `openai-compat`. */ + readonly apiKey?: string +} + +/** Resolves a single Anthropic model against Pi's wired built-in catalog. */ +const resolveAnthropic = (requestedId: string): { models: Models; model: Model } => { + const models = builtinModels() + const model = models.getModel(ANTHROPIC, requestedId) + if (!model) { + throw new Error(`Unknown Anthropic model "${requestedId}".`) + } + return { models, model } +} + +/** Resolves an OpenAI-compatible model, requiring the base URL + key the + * endpoint needs. */ +const resolveOpenAiCompat = (opts: ResolveModelOptions): { models: Models; model: Model } => { + if (!opts.baseUrl) { + throw new Error('the openai-compat provider requires --base-url') + } + if (!opts.apiKey) { + throw new Error( + 'the openai-compat provider requires an api key (pass --api-key or set THUNDERBOLT_OPENAI_COMPAT_KEY)', + ) + } + return buildOpenAiCompatModel({ modelId: opts.model, baseUrl: opts.baseUrl, apiKey: opts.apiKey }) +} + +/** + * Builds the wired provider collection and resolves a single model, ready for + * the harness. + * + * @param opts - the model id and provider routing (base URL / api key for openai-compat) + * @returns the provider collection and the resolved model + * @throws if an Anthropic id is unknown, or required openai-compat inputs are missing + */ +export const resolveModel = (opts: ResolveModelOptions): { models: Models; model: Model } => + opts.provider === 'openai-compat' ? resolveOpenAiCompat(opts) : resolveAnthropic(opts.model) diff --git a/cli/src/agent/openai-compat-model.test.ts b/cli/src/agent/openai-compat-model.test.ts new file mode 100644 index 000000000..47c046f2e --- /dev/null +++ b/cli/src/agent/openai-compat-model.test.ts @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Coverage for `buildOpenAiCompatModel`: the synthetic descriptor it produces + * (the `reasoning: false` portability clamp, the base URL the key will be sent + * to, provider registration) and the `requireOpenAiCompletions` guard that + * surfaces a mis-dispatched model loudly instead of guessing — exercised via the + * provider's `stream` entry point, which narrows before any network call. + */ + +import type { Api, AssistantMessageEventStream, Context, Model } from '@earendil-works/pi-ai' +import { describe, expect, test } from 'bun:test' +import { type OpenAiStreamFns, buildOpenAiCompatModel } from './openai-compat-model.ts' + +const opts = { modelId: 'mimo-v2.5-pro', baseUrl: 'https://h.example/v1', apiKey: 'k' } + +/** A fake pair of raw stream fns that record the options they were handed and + * return an inert stream, so the bearer-key injection is observable offline. */ +const capturingStreamFns = () => { + const calls: { fn: 'stream' | 'streamSimple'; model: Model; options: Record }[] = [] + const inert = {} as AssistantMessageEventStream + const streamFns = { + stream: ((model, _context, options) => { + calls.push({ fn: 'stream', model, options: { ...options } }) + return inert + }) as OpenAiStreamFns['stream'], + streamSimple: ((model, _context, options) => { + calls.push({ fn: 'streamSimple', model, options: { ...options } }) + return inert + }) as OpenAiStreamFns['streamSimple'], + } + return { streamFns, calls } +} + +describe('buildOpenAiCompatModel — synthetic descriptor', () => { + test('carries the upstream id, base URL, and provider id', () => { + const { model } = buildOpenAiCompatModel(opts) + expect(model.id).toBe('mimo-v2.5-pro') + expect(model.name).toBe('mimo-v2.5-pro') + expect(model.provider).toBe('openai-compat') + expect(model.baseUrl).toBe('https://h.example/v1') + expect(model.api).toBe('openai-completions') + }) + + test('clamps reasoning off for cross-endpoint portability', () => { + // Non-reasoning endpoints reject a `reasoning_effort`; `reasoning: false` + // makes Pi send none. This is a behavioral contract, not cosmetic. + const { model } = buildOpenAiCompatModel(opts) + expect(model.reasoning).toBe(false) + }) + + test('registers the model in the returned collection under its provider', () => { + const { models } = buildOpenAiCompatModel(opts) + expect(models.getModel('openai-compat', 'mimo-v2.5-pro')?.id).toBe('mimo-v2.5-pro') + expect(models.getProvider('openai-compat')?.baseUrl).toBe('https://h.example/v1') + }) +}) + +describe('buildOpenAiCompatModel — requireOpenAiCompletions guard', () => { + test('the provider stream rejects a model dispatched with a non-completions api', () => { + const { models, model } = buildOpenAiCompatModel(opts) + const provider = models.getProvider('openai-compat') + if (!provider) throw new Error('provider not registered') + + // A model whose api is not openai-completions must be rejected synchronously, + // before the openai SDK is ever constructed (so no network is touched). + const mismatched = { ...model, api: 'anthropic-messages' } as Model + expect(() => provider.stream(mismatched, {} as Context, {})).toThrow(/Expected an "openai-completions" model/) + }) + + test('streamSimple applies the same guard', () => { + const { models, model } = buildOpenAiCompatModel(opts) + const provider = models.getProvider('openai-compat') + if (!provider) throw new Error('provider not registered') + const mismatched = { ...model, api: 'anthropic-messages' } as Model + expect(() => provider.streamSimple(mismatched, {} as Context, {})).toThrow(/got "anthropic-messages"/) + }) +}) + +describe('buildOpenAiCompatModel — bearer key injection (security)', () => { + test('stream injects opts.apiKey onto the per-call options', () => { + const { streamFns, calls } = capturingStreamFns() + const { models, model } = buildOpenAiCompatModel(opts, streamFns) + const provider = models.getProvider('openai-compat')! + provider.stream(model, {} as Context, { maxTokens: 256 }) + expect(calls).toHaveLength(1) + expect(calls[0]!.options.apiKey).toBe('k') + // Caller-supplied options must be preserved alongside the injected key. + expect(calls[0]!.options.maxTokens).toBe(256) + }) + + test('the injected key overrides any caller-supplied options.apiKey (no spoofing)', () => { + const { streamFns, calls } = capturingStreamFns() + const { models, model } = buildOpenAiCompatModel(opts, streamFns) + const provider = models.getProvider('openai-compat')! + // A caller trying to slip a different key in must not win over the resolved one. + provider.stream(model, {} as Context, { apiKey: 'attacker-supplied' } as never) + expect(calls[0]!.options.apiKey).toBe('k') + }) + + test('streamSimple also injects the configured key', () => { + const { streamFns, calls } = capturingStreamFns() + const { models, model } = buildOpenAiCompatModel(opts, streamFns) + const provider = models.getProvider('openai-compat')! + provider.streamSimple(model, {} as Context, {}) + expect(calls[0]).toMatchObject({ fn: 'streamSimple', options: { apiKey: 'k' } }) + }) +}) diff --git a/cli/src/agent/openai-compat-model.ts b/cli/src/agent/openai-compat-model.ts new file mode 100644 index 000000000..686c7635c --- /dev/null +++ b/cli/src/agent/openai-compat-model.ts @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Builds a Pi `openai-completions` model bound to a custom base URL + bearer + * key, so the CLI can run any OpenAI-compatible endpoint (e.g. Xiaomi MiMo at + * `https://token-plan-sgp.xiaomimimo.com/v1`) instead of only Anthropic. + * + * This is the CLI sibling of `shared/agent-core/openai-compat-model.ts`, but + * deliberately simpler. The app's version SYNCHRONOUSLY swaps `globalThis.fetch` + * around Pi's SDK-client construction because it must route every call through + * the browser CORS proxy and Pi's `openai-completions` exposes no `fetch` seam. + * The CLI has no proxy: it runs in Bun with a real global `fetch`, and Pi builds + * its client via `new OpenAI({ apiKey, baseURL: model.baseUrl })`, which natively + * hits that base URL and sets `Authorization: Bearer ` itself. So the only + * wiring needed is (1) a synthetic model descriptor carrying the base URL and + * (2) injecting the api key on Pi's per-call options (`getClientApiKey` returns + * `options.apiKey` when present) — no global mutation, no fetch override. + * + * Documented reliance (re-verify on `@earendil-works/pi-ai` / `openai` bumps): + * Pi's `openai-completions` `createClient` reads `model.baseUrl` and + * `options.apiKey`; the `openai` SDK sends the bearer `Authorization` header. + */ + +import { + type Api, + type Model, + type Models, + type ProviderStreams, + createModels, + createProvider, + envApiKeyAuth, + hasApi, +} from '@earendil-works/pi-ai' +import { + stream as openaiStream, + streamSimple as openaiStreamSimple, +} from '@earendil-works/pi-ai/api/openai-completions' + +/** The Pi API this provider exclusively serves. */ +const API = 'openai-completions' + +/** Context window used by Pi's token-budget math when the synthetic model + * carries none. openai-completions never caps tokens unless the caller passes + * `maxTokens` (the harness does not), so a generous default is harmless. */ +const DEFAULT_CONTEXT_WINDOW = 128_000 + +/** Advisory max-output budget on the synthetic model. Only reaches the wire if + * the caller sets `options.maxTokens`; it exists solely to satisfy the `Model` + * shape. */ +const DEFAULT_MAX_TOKENS = 8_192 + +/** Inputs for {@link buildOpenAiCompatModel}. */ +export type BuildOpenAiCompatModelOptions = { + /** Upstream model id sent on the wire, e.g. `mimo-v2.5-pro`. */ + readonly modelId: string + /** OpenAI-compatible base URL, e.g. `https://token-plan-sgp.xiaomimimo.com/v1`. */ + readonly baseUrl: string + /** Bearer key handed to the OpenAI SDK (sent as `Authorization: Bearer `). */ + readonly apiKey: string +} + +/** Pi provider id for every CLI openai-compatible endpoint. */ +const PROVIDER = 'openai-compat' + +/** The raw Pi stream entry points this provider wraps. Injectable so the bearer + * key injection can be verified without a live OpenAI endpoint; defaults to the + * real `openai-completions` functions in production. */ +export type OpenAiStreamFns = { + readonly stream: typeof openaiStream + readonly streamSimple: typeof openaiStreamSimple +} + +const DEFAULT_STREAM_FNS: OpenAiStreamFns = { stream: openaiStream, streamSimple: openaiStreamSimple } + +/** + * Narrows a dispatched `Model` to the openai-completions model this + * provider exclusively serves, surfacing misuse loudly rather than guessing. + */ +const requireOpenAiCompletions = (model: Model): Model => { + if (!hasApi(model, API)) { + throw new Error(`Expected an "${API}" model, got "${model.api}".`) + } + return model +} + +/** + * Synthesize the Pi `Model<"openai-completions">` descriptor. Custom-URL models + * live outside Pi's built-in catalog, so we build the descriptor directly. + * `reasoning: false` keeps the request portable across OpenAI-compatible + * endpoints — Pi clamps the harness `thinkingLevel` to `off` and sends no + * `reasoning_effort`, which a non-reasoning model would otherwise reject. + */ +const synthesizeModel = (opts: BuildOpenAiCompatModelOptions): Model => ({ + id: opts.modelId, + name: opts.modelId, + api: API, + provider: PROVIDER, + baseUrl: opts.baseUrl, + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, +}) + +/** + * Resolves an OpenAI-compatible model and wires it through a Pi provider bound + * to `opts.baseUrl` + `opts.apiKey`. Drop-in sibling of `resolveModel`'s + * Anthropic branch: returns the same `{ models, model }` shape the harness + * consumes. + * + * @param opts - model id, base URL, and bearer api key + * @param streamFns - the raw Pi stream functions to wrap (injectable for tests) + * @returns the wired provider collection and the synthetic model + */ +export const buildOpenAiCompatModel = ( + opts: BuildOpenAiCompatModelOptions, + streamFns: OpenAiStreamFns = DEFAULT_STREAM_FNS, +): { models: Models; model: Model } => { + const model = synthesizeModel(opts) + + // Inject the api key on every call (Pi's openai client reads `options.apiKey`); + // the SDK resolves the base URL from `model.baseUrl` and adds the bearer header. + const api: ProviderStreams = { + stream: (resolved, context, options) => + streamFns.stream(requireOpenAiCompletions(resolved), context, { ...options, apiKey: opts.apiKey }), + streamSimple: (resolved, context, options) => + streamFns.streamSimple(requireOpenAiCompletions(resolved), context, { ...options, apiKey: opts.apiKey }), + } + + const models = createModels() + models.setProvider( + createProvider({ + id: PROVIDER, + name: PROVIDER, + baseUrl: opts.baseUrl, + // Advisory only: the real credential rides on the per-call options above. + // An empty env list makes env resolution a graceful no-op. + auth: { apiKey: envApiKeyAuth(`${PROVIDER} API key`, []) }, + models: [model], + api, + }), + ) + + return { models, model } +} diff --git a/cli/src/agent/permissions.test.ts b/cli/src/agent/permissions.test.ts new file mode 100644 index 000000000..cf3bab629 --- /dev/null +++ b/cli/src/agent/permissions.test.ts @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Coverage for the interactive tool-permission gate. The gate is the security + * boundary between the model and the host's write/edit/bash tools, so every + * branch matters: yolo bypass, read-only passthrough, the three decision + * outcomes, session-allow memory scoping, and the human summary builder. The + * harness is faked down to the single `on('tool_call', …)` seam the gate uses; + * we invoke the captured handler directly with no real agent loop. + */ + +import type { AgentHarness } from '@earendil-works/pi-agent-core' +import { describe, expect, test } from 'bun:test' +import { attachPermissionGate } from './permissions.ts' +import type { PermissionDecision, PermissionPrompt, PermissionRequest } from './types.ts' + +type ToolCall = { type: 'tool_call'; toolCallId: string; toolName: string; input: Record } +type GateResult = { block: true; reason: string } | undefined +type GateHandler = (event: ToolCall) => Promise | GateResult + +/** A fake harness exposing only the `on` seam the gate uses, capturing the + * registered `tool_call` handler so a test can drive it directly. */ +const fakeHarness = () => { + let handler: GateHandler | null = null + const harness = { + on: (type: string, h: GateHandler) => { + if (type === 'tool_call') handler = h + return () => {} + }, + } as unknown as AgentHarness + return { harness, getHandler: () => handler } +} + +/** A prompt stub that always answers `decision` and records every request. */ +const constantAsk = (decision: PermissionDecision) => { + const seen: PermissionRequest[] = [] + const ask: PermissionPrompt = async (request) => { + seen.push(request) + return decision + } + return { ask, seen } +} + +const call = (toolName: string, input: Record = {}): ToolCall => ({ + type: 'tool_call', + toolCallId: 't', + toolName, + input, +}) + +describe('attachPermissionGate — bypass + passthrough', () => { + test('yolo mode attaches no hook at all', () => { + const { harness, getHandler } = fakeHarness() + attachPermissionGate(harness, { yolo: true, ask: async () => 'deny' }) + expect(getHandler()).toBeNull() + }) + + test('read-only tools run unguarded — the prompt is never consulted', async () => { + const { harness, getHandler } = fakeHarness() + const { ask, seen } = constantAsk('deny') + attachPermissionGate(harness, { yolo: false, ask }) + const result = await getHandler()!(call('read', { path: '/etc/passwd' })) + expect(result).toBeUndefined() + expect(seen).toHaveLength(0) + }) +}) + +describe('attachPermissionGate — decisions', () => { + test('allow-once permits the call but does not remember it', async () => { + const { harness, getHandler } = fakeHarness() + const { ask, seen } = constantAsk('allow-once') + attachPermissionGate(harness, { yolo: false, ask }) + const handler = getHandler()! + expect(await handler(call('write', { path: 'a.ts' }))).toBeUndefined() + expect(await handler(call('write', { path: 'b.ts' }))).toBeUndefined() + // Asked both times — allow-once grants no session memory. + expect(seen).toHaveLength(2) + }) + + test('deny blocks the call with a reason naming the tool', async () => { + const { harness, getHandler } = fakeHarness() + const { ask } = constantAsk('deny') + attachPermissionGate(harness, { yolo: false, ask }) + const result = await getHandler()!(call('bash', { command: 'rm -rf /' })) + expect(result).toEqual({ block: true, reason: 'User denied bash' }) + }) + + test('a throwing prompt rejects rather than silently returning allow (fail-closed)', async () => { + const { harness, getHandler } = fakeHarness() + const ask: PermissionPrompt = async () => { + throw new Error('stdin closed') + } + attachPermissionGate(harness, { yolo: false, ask }) + // The gate must not resolve to `undefined` (which would run the tool) when the + // prompt fails — the rejection propagates so the call is not auto-approved. + await expect(getHandler()!(call('bash', { command: 'rm -rf /' }))).rejects.toThrow('stdin closed') + }) +}) + +describe('attachPermissionGate — allow-session scoping', () => { + test('allow-session suppresses re-prompts for the same tool only', async () => { + const { harness, getHandler } = fakeHarness() + const { ask, seen } = constantAsk('allow-session') + attachPermissionGate(harness, { yolo: false, ask }) + const handler = getHandler()! + + // First bash prompts and is remembered. + expect(await handler(call('bash', { command: 'ls' }))).toBeUndefined() + // Second bash is auto-allowed without prompting. + expect(await handler(call('bash', { command: 'pwd' }))).toBeUndefined() + expect(seen).toHaveLength(1) + + // A different gated tool is still prompted — session memory is per-tool. + expect(await handler(call('write', { path: 'x.ts' }))).toBeUndefined() + expect(seen).toHaveLength(2) + expect(seen.map((r) => r.toolName)).toEqual(['bash', 'write']) + }) + + test('two same-tool calls launched before the first decision both prompt (allowlist set on resolve)', async () => { + const { harness, getHandler } = fakeHarness() + let resolveCount = 0 + const ask: PermissionPrompt = async () => { + resolveCount += 1 + return 'allow-session' + } + attachPermissionGate(harness, { yolo: false, ask }) + const handler = getHandler()! + // Both launched concurrently: neither sees the other's session grant yet, + // because `sessionAllowed.add` runs only after `ask` resolves. + await Promise.all([handler(call('bash', { command: 'a' })), handler(call('bash', { command: 'b' }))]) + expect(resolveCount).toBe(2) + // After the grant lands, a third call is auto-allowed. + await handler(call('bash', { command: 'c' })) + expect(resolveCount).toBe(2) + }) + + test('a tool allowed for the session via one harness does not bleed into another gate', async () => { + const askA = constantAsk('allow-session') + const a = fakeHarness() + attachPermissionGate(a.harness, { yolo: false, ask: askA.ask }) + await a.getHandler()!(call('bash', { command: 'ls' })) + + // A freshly-attached gate starts with an empty session allowlist. + const askB = constantAsk('deny') + const b = fakeHarness() + attachPermissionGate(b.harness, { yolo: false, ask: askB.ask }) + const result = await b.getHandler()!(call('bash', { command: 'ls' })) + expect(result).toEqual({ block: true, reason: 'User denied bash' }) + }) +}) + +describe('attachPermissionGate — summary builder', () => { + /** Capture the summary surfaced for a given tool call by allowing it through. */ + const summaryFor = async (toolName: string, input: Record): Promise => { + const { harness, getHandler } = fakeHarness() + const { ask, seen } = constantAsk('allow-once') + attachPermissionGate(harness, { yolo: false, ask }) + await getHandler()!(call(toolName, input)) + return seen[0]!.summary + } + + test('bash summarizes to its command', async () => { + expect(await summaryFor('bash', { command: 'echo hi' })).toBe('echo hi') + }) + + test('write/edit summarize to the target path', async () => { + expect(await summaryFor('write', { path: 'src/a.ts', content: 'x' })).toBe('src/a.ts') + expect(await summaryFor('edit', { path: 'src/b.ts', oldText: 'a', newText: 'b' })).toBe('src/b.ts') + }) + + test('bash without a string command falls back to the JSON of the input', async () => { + expect(await summaryFor('bash', { command: 123 })).toBe(JSON.stringify({ command: 123 })) + }) + + test('an unknown tool with no path falls back to the JSON of the input', async () => { + expect(await summaryFor('weird', { foo: 'bar' })).toBe(JSON.stringify({ foo: 'bar' })) + }) +}) diff --git a/cli/src/agent/permissions.ts b/cli/src/agent/permissions.ts new file mode 100644 index 000000000..91fce7228 --- /dev/null +++ b/cli/src/agent/permissions.ts @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { AgentHarness } from '@earendil-works/pi-agent-core' +import type { PermissionPrompt, PermissionRequest } from './types.ts' + +/** Tools that run unguarded — pure reads with no side effects. */ +const READ_ONLY_TOOLS = new Set(['read']) + +/** + * Builds the one-line summary shown to the user for a gated tool call. `bash` + * summarizes to its command; `write`/`edit` to their target path (both tools + * use an `input.path` field). + * + * @param toolName - the tool being invoked + * @param input - the tool's validated arguments + * @returns a human-readable one-liner + */ +const summarize = (toolName: string, input: Record): string => { + if (toolName === 'bash' && typeof input.command === 'string') return input.command + if (typeof input.path === 'string') return input.path + return JSON.stringify(input) +} + +/** + * Registers the interactive tool-permission gate on the harness. + * + * In `yolo` mode no hook is attached and every tool call runs unguarded. + * Otherwise each write/edit/bash call is approved via {@link PermissionPrompt}: + * `allow-once` runs it, `allow-session` runs it and allows that tool for the + * rest of the session, and `deny` blocks it with an error tool result. Read-only + * tools are always allowed. + * + * @param harness - the Pi harness to gate + * @param opts.yolo - when true, auto-approve everything (no gate) + * @param opts.ask - prompt used to ask the user for a decision + */ +export const attachPermissionGate = (harness: AgentHarness, opts: { yolo: boolean; ask: PermissionPrompt }): void => { + if (opts.yolo) return + + const sessionAllowed = new Set() + + harness.on('tool_call', async ({ toolName, input }) => { + if (READ_ONLY_TOOLS.has(toolName) || sessionAllowed.has(toolName)) return undefined + + const request: PermissionRequest = { toolName, summary: summarize(toolName, input) } + const decision = await opts.ask(request) + if (decision === 'allow-once') return undefined + if (decision === 'allow-session') { + sessionAllowed.add(toolName) + return undefined + } + return { block: true, reason: `User denied ${toolName}` } + }) +} diff --git a/cli/src/agent/run.test.ts b/cli/src/agent/run.test.ts new file mode 100644 index 000000000..c109dd8c8 --- /dev/null +++ b/cli/src/agent/run.test.ts @@ -0,0 +1,92 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for the `runAgent` pre-flight guard: the anthropic provider (the + * default) refuses to start without `ANTHROPIC_API_KEY`, and the guard keys off + * the *resolved* provider (undefined → anthropic) so it fires before any harness + * is built. Only this fail-fast branch is unit-tested; the success path drives a + * live model and belongs to integration coverage. + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { runAgent, shouldUseTui } from './run.ts' +import type { RunConfig } from './types.ts' + +const KEY = 'ANTHROPIC_API_KEY' +let saved: string | undefined + +beforeEach(() => { + saved = process.env[KEY] + delete process.env[KEY] +}) + +afterEach(() => { + if (saved === undefined) delete process.env[KEY] + else process.env[KEY] = saved +}) + +const oneshot = (overrides: Partial = {}): RunConfig => + ({ + model: 'claude-opus-4-8', + cwd: process.cwd(), + yolo: false, + thinking: 'medium', + mode: 'oneshot', + prompt: 'hi', + ...overrides, + }) as RunConfig + +const repl = (overrides: Partial = {}): RunConfig => + ({ + model: 'claude-opus-4-8', + cwd: process.cwd(), + yolo: false, + thinking: 'medium', + mode: 'repl', + noTui: false, + ...overrides, + }) as RunConfig + +describe('runAgent — ANTHROPIC_API_KEY guard', () => { + test('throws a friendly error for the explicit anthropic provider with no key', async () => { + await expect(runAgent(oneshot({ provider: 'anthropic' }))).rejects.toThrow(/ANTHROPIC_API_KEY/) + }) + + test('the default (unset) provider also requires the key', async () => { + // provider omitted → `?? 'anthropic'` → same guard fires. + await expect(runAgent(oneshot({ provider: undefined }))).rejects.toThrow(/set ANTHROPIC_API_KEY/) + }) + + test('openai-compat skips the anthropic guard — it fails on its own missing config instead', async () => { + // The inverse branch: with no ANTHROPIC_API_KEY set, an anthropic run throws the + // key error, but openai-compat must get past the guard and fail downstream in + // model resolution (missing --base-url) — proving the provider condition matters. + await expect( + runAgent(oneshot({ provider: 'openai-compat', baseUrl: undefined, apiKey: undefined })), + ).rejects.toThrow(/base-url/) + }) +}) + +describe('shouldUseTui — REPL mode selection', () => { + test('a REPL on a TTY with no opt-out uses the TUI', () => { + expect(shouldUseTui(repl(), { isTty: true, noTuiEnv: false })).toBe(true) + }) + + test('a piped (non-TTY) REPL falls back to the plain loop', () => { + expect(shouldUseTui(repl(), { isTty: false, noTuiEnv: false })).toBe(false) + }) + + test('THUNDERBOLT_NO_TUI forces the plain loop even on a TTY', () => { + expect(shouldUseTui(repl(), { isTty: true, noTuiEnv: true })).toBe(false) + }) + + test('the --no-tui flag forces the plain loop even on a TTY', () => { + expect(shouldUseTui(repl({ noTui: true }), { isTty: true, noTuiEnv: false })).toBe(false) + }) + + test('oneshot runs never use the TUI, even on a TTY', () => { + expect(shouldUseTui(oneshot(), { isTty: true, noTuiEnv: false })).toBe(false) + }) +}) diff --git a/cli/src/agent/run.ts b/cli/src/agent/run.ts new file mode 100644 index 000000000..9963c026a --- /dev/null +++ b/cli/src/agent/run.ts @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The agent run-loop: wires the harness, renderer, terminal I/O, and + * permission gate together, then drives either a single prompt (oneshot) or + * an interactive REPL. Harness teardown is guaranteed via `finally`. + */ + +import type { AgentHarness } from '@earendil-works/pi-agent-core' +import { buildHarness } from './harness.ts' +import { attachRenderer } from '../ui/render.ts' +import { runTuiRepl } from '../ui/tui.ts' +import { attachPermissionGate } from './permissions.ts' +import { createTerminalIO } from '../ui/prompt.ts' +import { printBanner } from '../banner.ts' +import type { RunConfig, TerminalIO } from './types.ts' + +/** + * Decides whether the interactive REPL should use the rich TUI. The TUI is the + * default, but it needs a real terminal to own — a piped/redirected stdout, the + * `--no-tui` flag, or `THUNDERBOLT_NO_TUI` all fall back to the plain readline + * loop. oneshot runs never use the TUI. Pure so mode selection is unit-testable. + * + * @param config - the resolved run configuration + * @param env.isTty - whether stdout is a terminal + * @param env.noTuiEnv - whether `THUNDERBOLT_NO_TUI` is set + * @returns true when the TUI REPL should run + */ +export const shouldUseTui = (config: RunConfig, env: { isTty: boolean; noTuiEnv: boolean }): boolean => + config.mode === 'repl' && !config.noTui && env.isTty && !env.noTuiEnv + +/** + * Interactive read-eval loop: prompt the user, run each line through the + * harness, and wait for the agent to go idle before reading the next one. + * `exit`/`quit` ends the loop; blank lines are skipped. + */ +const runRepl = async (harness: AgentHarness, io: TerminalIO): Promise => { + while (true) { + const line = await io.readLine('› ') + // `null` is EOF (Ctrl-D / closed pipe); `exit`/`quit` are explicit quits. + if (line === null || line === 'exit' || line === 'quit') return + if (line === '') continue + await harness.prompt(line) + await harness.waitForIdle() + } +} + +/** + * Runs the agent for a single CLI invocation. Requires `ANTHROPIC_API_KEY`; + * exits with a friendly message when it's absent. + * + * @param config - the resolved configuration from `parseArgs` + */ +export const runAgent = async (config: RunConfig): Promise => { + // Anthropic resolves its key from the environment; openai-compat carries its + // own (validated in `resolveModel`), so it must not demand ANTHROPIC_API_KEY. + if ((config.provider ?? 'anthropic') === 'anthropic' && !process.env.ANTHROPIC_API_KEY) { + throw new Error('set ANTHROPIC_API_KEY to run the agent (https://console.anthropic.com).') + } + + const { harness, dispose } = await buildHarness(config) + + try { + if ( + shouldUseTui(config, { isTty: Boolean(process.stdout.isTTY), noTuiEnv: Boolean(process.env.THUNDERBOLT_NO_TUI) }) + ) { + // The TUI owns stdin/stdout and its own renderer and permission gate. + await runTuiRepl(harness, { yolo: config.yolo }) + return + } + + // Plain path: oneshot runs and the non-TTY / --no-tui REPL fallback. Both + // stream to stdout and read permission answers over a shared readline. + attachRenderer(harness) + const io = createTerminalIO() + attachPermissionGate(harness, { yolo: config.yolo, ask: io.ask }) + try { + if (config.mode === 'oneshot') { + const result = await harness.prompt(config.prompt) + await harness.waitForIdle() + // A failed turn (bad key, rate limit) resolves rather than throwing, and + // the renderer prints the error — propagate it to the exit code too. + if (result.stopReason === 'error') process.exitCode = 1 + process.stdout.write('\n') + } else { + printBanner() + await runRepl(harness, io) + } + } finally { + io.close() + } + } finally { + await dispose() + } +} diff --git a/cli/src/agent/system-prompt.test.ts b/cli/src/agent/system-prompt.test.ts new file mode 100644 index 000000000..f28b08073 --- /dev/null +++ b/cli/src/agent/system-prompt.test.ts @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for `buildSystemPrompt` — specifically the `modelId` branch that + * powers the ACP "self-identify" feature: the model is named only when an id is + * passed, and the working directory is always interpolated. + */ + +import { describe, expect, test } from 'bun:test' +import { buildSystemPrompt } from './system-prompt.ts' + +describe('buildSystemPrompt', () => { + test('names the model when a modelId is provided', () => { + expect(buildSystemPrompt({ cwd: '/work', modelId: 'claude-opus-4-8' })).toContain('powered by claude-opus-4-8') + }) + + test('omits the "powered by" clause when no modelId is given', () => { + expect(buildSystemPrompt({ cwd: '/work' })).not.toContain('powered by') + }) + + test('always interpolates the working directory', () => { + expect(buildSystemPrompt({ cwd: '/home/me/project' })).toContain('Working directory: /home/me/project') + }) +}) diff --git a/cli/src/agent/system-prompt.ts b/cli/src/agent/system-prompt.ts new file mode 100644 index 000000000..2408f902b --- /dev/null +++ b/cli/src/agent/system-prompt.ts @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Builds the coding-agent system prompt. Tuned for Claude Opus 4.8 per + * Anthropic's migration guidance: default to silence between tool calls, + * take autonomy on small reversible decisions, and avoid over-engineering. + * + * @param params.cwd - the working directory the agent operates in + * @param params.modelId - when set, names the underlying model so an exposed ACP + * agent can self-identify; omitted for the standalone CLI + * @returns the system prompt string + */ +export const buildSystemPrompt = ({ cwd, modelId }: { cwd: string; modelId?: string }): string => `\ +You are thunderbolt, a terminal coding agent${modelId ? `, powered by ${modelId}` : ''}. You operate directly in the user's \ +working directory and complete software tasks end-to-end. + +Working directory: ${cwd} + +# Tools +You have four tools, byte-identical to a real Unix shell and filesystem: +- bash — run shell commands (grep, sed, find, git, language toolchains, tests, …) +- read — read a file +- write — create or overwrite a file +- edit — replace a span within a file + +Prefer bash for exploration (grep/find/ls) and for running builds and tests. Use \ +read before edit. Make the smallest change that fully solves the task. + +# How to work +- When you have enough information to act, act. Don't re-derive facts already \ + established or narrate a plan you're about to execute — just execute it. +- Default to silence between tool calls. Only write text when you find something, \ + change direction, or hit a blocker — one sentence each. Don't narrate routine \ + actions ("Now I'll…", "Let me check…"). +- For minor, reversible choices (a name, a default, which of two equivalent \ + approaches), pick a reasonable option and proceed. For destructive or \ + irreversible actions, stop and explain before acting. +- Don't add features, refactors, abstractions, or defensive error handling beyond \ + what the task requires. Do the simplest thing that works. +- Verify your work: run the build and tests when they exist, and inspect output \ + rather than assuming success. Report outcomes faithfully — if tests fail, say so \ + with the output; if a step was skipped, say that. + +# Finishing +When the task is complete, end with one or two sentences on what changed and any \ +follow-up the user should know about. Lead with the outcome. Don't recap every \ +file you touched — the user watched it happen.` diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts new file mode 100644 index 000000000..f902b2dd4 --- /dev/null +++ b/cli/src/agent/types.ts @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Shared module contracts for the thunderbolt CLI. + * + * These types are the stable seam between the CLI's modules (arg parsing, + * harness assembly, the streaming renderer, and the permission gate) so each + * can be built and reasoned about independently. Pi types (e.g. `AgentHarness`) + * are imported directly from `@earendil-works/pi-agent-core` where needed. + */ + +import type { AgentHarness } from '@earendil-works/pi-agent-core' + +/** + * A constructed harness paired with a teardown function. `buildHarness` + * returns this so callers release the underlying execution environment + * (temp dirs, shell) without reaching into Pi internals. + */ +export type HarnessBundle = { + readonly harness: AgentHarness + readonly dispose: () => Promise +} + +/** Reasoning depth passed to the Pi harness (`thinkingLevel`). */ +export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' + +/** Which model backend the harness talks to: Anthropic's built-in catalog, or + * any OpenAI-compatible endpoint at a custom base URL (e.g. Xiaomi MiMo). */ +export type ModelProvider = 'anthropic' | 'openai-compat' + +/** Wire protocol whose local stdio process the bridge exposes over the network. + * Drives only logging — the stdio↔transport pump is byte-identical for both. */ +export type BridgeProtocol = 'acp' | 'mcp' + +/** Network transport a bridge exposes its stdio process over. `wss` is a + * loopback-only WebSocket; `iroh` is the authenticated P2P/E2E transport. */ +export type BridgeTransport = 'wss' | 'iroh' + +/** + * Fully-resolved configuration for an `acp`/`mcp` bridge invocation, produced by + * {@link parseArgs} and consumed by the bridge runner. + */ +export type BridgeConfig = { + /** Which protocol's stdio process is being bridged. */ + readonly protocol: BridgeProtocol + /** Network transport exposing the process. */ + readonly transport: BridgeTransport + /** TCP port the WebSocket server listens on (`wss` only; ignored by `iroh`). */ + readonly port: number + /** The spawned stdio agent command: `command[0]` is the executable. */ + readonly command: readonly string[] +} + +/** + * Configuration for an `acp`/`mcp connect` invocation: dial a remote iroh bridge + * and pump a local client into it. + */ +export type ConnectConfig = { + /** Which protocol the local client speaks (drives only logging). */ + readonly protocol: BridgeProtocol + /** The remote bridge to dial: a connection ticket or a bare NodeId. */ + readonly target: string + /** Optional local stdio client to spawn; empty means bridge this process's + * own stdin/stdout (so a JSON-RPC line can be piped through). */ + readonly command: readonly string[] +} + +/** A `thunderbolt iroh` admin action: inspect identity, mint a pairing ticket, + * or extend the peer allowlist. */ +export type IrohAdminAction = + | { readonly kind: 'id' } + | { readonly kind: 'pair' } + | { readonly kind: 'allow'; readonly nodeId: string } + +/** + * Settings a single harness needs to be assembled, shared by every entry point + * (oneshot run, REPL, and the ACP server's per-session harness). `buildHarness` + * consumes exactly this; the run/serve configs extend it with their own fields. + */ +export type HarnessConfig = { + /** Model id: an Anthropic catalog id (e.g. `claude-opus-4-8`) for the + * `anthropic` provider, or the upstream id (e.g. `mimo-v2.5-pro`) for + * `openai-compat`. */ + readonly model: string + /** Working directory the agent's bash/fs tools are bound to. */ + readonly cwd: string + /** When true, auto-approve every tool call (no interactive gate). */ + readonly yolo: boolean + /** Reasoning depth for the harness. */ + readonly thinking: ThinkingLevel + /** Model backend to use (defaults to `anthropic`). */ + readonly provider?: ModelProvider + /** OpenAI-compatible base URL — required when `provider` is `openai-compat`. */ + readonly baseUrl?: string + /** Bearer api key for `openai-compat` (flows in only via flag/env at runtime, + * never persisted). Ignored by the `anthropic` provider, which reads + * `ANTHROPIC_API_KEY` from the environment. */ + readonly apiKey?: string + /** When true, the system prompt names the underlying model so an exposed ACP + * agent can self-identify. The standalone CLI leaves this off. */ + readonly announceModel?: boolean +} + +/** + * Configuration for an `acp serve` invocation: run THIS coding agent as a stdio + * ACP JSON-RPC server. `cwd` is the process default; each ACP `session/new` + * supplies its own working directory, which overrides it per session. + */ +export type ServeConfig = HarnessConfig + +/** + * Fully-resolved configuration for a single CLI invocation, produced by + * {@link parseArgs} and consumed by the agent runner. The discriminated `mode` + * makes `prompt` present exactly when (and only when) it's a oneshot run. + */ +export type RunConfig = + | (HarnessConfig & { readonly mode: 'oneshot'; readonly prompt: string }) + | (HarnessConfig & { + readonly mode: 'repl' + /** Force the plain readline REPL, never the interactive TUI (`--no-tui`). + * The TUI is otherwise the default when stdout is a TTY. */ + readonly noTui: boolean + }) + +/** Result of parsing argv: a run, a bridge, a connect, an ACP server, an iroh + * admin action, a login, or a terminal info action. */ +export type ParsedArgs = + | { readonly kind: 'run'; readonly config: RunConfig } + | { readonly kind: 'bridge'; readonly config: BridgeConfig } + | { readonly kind: 'connect'; readonly config: ConnectConfig } + | { readonly kind: 'acp-serve'; readonly config: ServeConfig } + | { readonly kind: 'iroh-admin'; readonly action: IrohAdminAction } + | { readonly kind: 'login' } + | { readonly kind: 'help' } + | { readonly kind: 'version' } + | { readonly kind: 'error'; readonly message: string } + +/** User's answer when asked to approve a tool call. */ +export type PermissionDecision = 'allow-once' | 'allow-session' | 'deny' + +/** A request surfaced to the user before a gated tool call runs. */ +export type PermissionRequest = { + /** Tool being invoked, e.g. `bash`, `write`, `edit`. */ + readonly toolName: string + /** One-line human summary, e.g. the bash command or target path. */ + readonly summary: string + /** Optional multi-line detail (diff, full command, etc.). */ + readonly detail?: string +} + +/** Asks the user to approve a gated tool call. Injected into the gate. */ +export type PermissionPrompt = (request: PermissionRequest) => Promise + +/** + * Interactive terminal I/O over a single shared readline interface — used both + * for the REPL input loop and for permission prompts so they don't fight over + * stdin. + */ +export type TerminalIO = { + /** Read one line of input for the given prompt label; `null` at EOF (Ctrl-D). */ + readonly readLine: (prompt: string) => Promise + /** Ask the user to approve a tool call. */ + readonly ask: PermissionPrompt + /** Tear down the readline interface. */ + readonly close: () => void +} diff --git a/cli/src/auth/config.test.ts b/cli/src/auth/config.test.ts new file mode 100644 index 000000000..d254fee33 --- /dev/null +++ b/cli/src/auth/config.test.ts @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Pure resolver coverage for CLI auth config: cloud-URL / PAT env precedence and + * the Better Auth base-URL derivation. Env is injected (not read from + * `process.env`) so these stay side-effect-free. + */ + +import { describe, expect, test } from 'bun:test' +import { DEFAULT_CLOUD_URL, authBaseUrl, isSecureCloudUrl, resolveCloudUrl, resolvePatToken } from './config.ts' + +describe('resolveCloudUrl', () => { + test('uses THUNDERBOLT_CLOUD_URL when set', () => { + expect(resolveCloudUrl({ THUNDERBOLT_CLOUD_URL: 'https://api.example.com/v1' })).toBe('https://api.example.com/v1') + }) + + test('falls back to the localhost default when unset or empty', () => { + expect(resolveCloudUrl({})).toBe(DEFAULT_CLOUD_URL) + expect(resolveCloudUrl({ THUNDERBOLT_CLOUD_URL: '' })).toBe(DEFAULT_CLOUD_URL) + }) +}) + +describe('authBaseUrl', () => { + test('rewrites a …/v1 cloud URL to the Better Auth base path', () => { + expect(authBaseUrl('http://localhost:8000/v1')).toBe('http://localhost:8000/v1/api/auth') + }) + + test('tolerates a trailing slash', () => { + expect(authBaseUrl('https://api.example.com/v1/')).toBe('https://api.example.com/v1/api/auth') + }) + + test('appends the full path when the base lacks /v1', () => { + expect(authBaseUrl('https://api.example.com')).toBe('https://api.example.com/v1/api/auth') + }) +}) + +describe('resolvePatToken', () => { + test('returns the PAT when set', () => { + expect(resolvePatToken({ THUNDERBOLT_TOKEN: 'pat-abc' })).toBe('pat-abc') + }) + + test('is undefined when unset or empty', () => { + expect(resolvePatToken({})).toBeUndefined() + expect(resolvePatToken({ THUNDERBOLT_TOKEN: '' })).toBeUndefined() + }) +}) + +describe('isSecureCloudUrl', () => { + test('accepts any https URL', () => { + expect(isSecureCloudUrl('https://api.example.com/v1')).toBe(true) + }) + + test('accepts plain http only for loopback hosts', () => { + expect(isSecureCloudUrl('http://localhost:8000/v1')).toBe(true) + expect(isSecureCloudUrl('http://127.0.0.1:8000/v1')).toBe(true) + expect(isSecureCloudUrl('http://api.localhost/v1')).toBe(true) + }) + + test('rejects plain http to a remote host (would leak the bearer)', () => { + expect(isSecureCloudUrl('http://selfhost.example/v1')).toBe(false) + }) +}) diff --git a/cli/src/auth/config.ts b/cli/src/auth/config.ts new file mode 100644 index 000000000..c7d2ce225 --- /dev/null +++ b/cli/src/auth/config.ts @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Static configuration for CLI auth: how to reach the backend, the OAuth client + * id the device grant announces, and the environment seams that let CI / self-host + * override the cloud URL and short-circuit the interactive flow with a PAT. + * + * Mirrors the app's contract: the cloud URL is a `…/v1` base (like + * `VITE_THUNDERBOLT_CLOUD_URL`), and Better Auth is mounted under `/v1/api/auth` + * (see `src/contexts/auth-context.tsx`, `backend/src/auth/auth.ts` `basePath`). + */ + +/** Env values, defaulting to the process environment. Kept as a plain map so the + * pure resolvers can be unit-tested without touching `process.env`. */ +type Env = Record + +/** Cloud URL used when `THUNDERBOLT_CLOUD_URL` is unset — matches the app default. */ +export const DEFAULT_CLOUD_URL = 'http://localhost:8000/v1' + +/** Client id the CLI presents to the device-authorization endpoint (RFC 8628). */ +export const CLI_CLIENT_ID = 'thunderbolt-cli' + +/** + * Resolve the backend cloud URL: the `THUNDERBOLT_CLOUD_URL` env var (the CLI's + * mirror of the app's `VITE_THUNDERBOLT_CLOUD_URL`) or the localhost default. + * + * @param env - environment map (defaults to `process.env`) + */ +export const resolveCloudUrl = (env: Env = process.env): string => env.THUNDERBOLT_CLOUD_URL || DEFAULT_CLOUD_URL + +/** + * Derive the Better Auth base URL from a `…/v1` cloud URL. Strips a trailing + * slash and a trailing `/v1`, then appends `/v1/api/auth` — reproducing exactly + * how `src/contexts/auth-context.tsx` builds `baseURL` + `basePath`. + * + * @param cloudUrl - the `…/v1` cloud URL + */ +export const authBaseUrl = (cloudUrl: string): string => + `${cloudUrl.replace(/\/+$/, '').replace(/\/v1$/, '')}/v1/api/auth` + +/** + * Resolve a personal access token / api key from the environment. When set, the + * CLI uses it directly as the credential and skips the interactive device grant + * (the zero-human CI / self-host escape hatch). An empty string counts as unset. + * + * @param env - environment map (defaults to `process.env`) + */ +export const resolvePatToken = (env: Env = process.env): string | undefined => env.THUNDERBOLT_TOKEN || undefined + +/** Hosts for which plain HTTP is safe (the token never leaves the machine). */ +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) + +/** + * Whether the cloud URL is safe to send a replayable bearer to: HTTPS anywhere, + * or plain HTTP only to a loopback host (the dev/self-host default). Blocks + * leaking the minted token to a remote host over cleartext (RFC 8628 §3.1 mandates + * TLS for the device flow). + * + * @param cloudUrl - the resolved backend cloud URL + */ +export const isSecureCloudUrl = (cloudUrl: string): boolean => { + const { protocol, hostname } = new URL(cloudUrl) + if (protocol === 'https:') return true + if (protocol === 'http:') return LOOPBACK_HOSTS.has(hostname) || hostname.endsWith('.localhost') + return false +} diff --git a/cli/src/auth/device-grant.test.ts b/cli/src/auth/device-grant.test.ts new file mode 100644 index 000000000..d0996c2ce --- /dev/null +++ b/cli/src/auth/device-grant.test.ts @@ -0,0 +1,115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * State-machine coverage for the RFC 8628 poll loop. Time and the network are + * injected (DI over mocking): a controllable fake clock records sleeps and + * advances a virtual now on each sleep, and a scripted transport returns a queued + * sequence of poll results. No real timers or sockets are touched. + */ + +import { describe, expect, it } from 'bun:test' +import { DeviceGrantError, pollForToken, type DeviceCodeResponse, type TokenPollResult } from './device-grant.ts' + +const code: DeviceCodeResponse = { + deviceCode: 'dev-code', + userCode: 'WDJB-MJHT', + verificationUri: 'https://app.test/device', + verificationUriComplete: 'https://app.test/device?user_code=WDJB-MJHT', + intervalSeconds: 5, + expiresInSeconds: 300, +} + +/** Fake clock: `sleep` advances a virtual now and logs the requested delay. */ +const fakeClock = (start = 0) => { + const sleeps: number[] = [] + const state = { now: start } + return { + sleeps, + clock: { + now: () => state.now, + sleep: async (ms: number) => { + sleeps.push(ms) + state.now += ms + }, + }, + } +} + +/** Transport that dispenses a scripted sequence of `pollToken` results. */ +const scriptedTransport = (results: TokenPollResult[]) => { + const calls = { count: 0 } + return { + calls, + transport: { + requestCode: async () => code, + pollToken: async () => { + const next = results[calls.count] + calls.count += 1 + if (!next) throw new Error('transport exhausted: pollToken called more times than scripted') + return next + }, + }, + } +} + +describe('pollForToken', () => { + it('polls immediately, then sleeps the interval between attempts, until approved', async () => { + const { clock, sleeps } = fakeClock() + const { transport, calls } = scriptedTransport([ + { kind: 'pending' }, + { kind: 'pending' }, + { kind: 'approved', token: 'signed.jwt' }, + ]) + + expect(await pollForToken(code, transport, clock)).toBe('signed.jwt') + expect(calls.count).toBe(3) + // Poll-first: three polls, an interval sleep only *between* them (no lead wait). + expect(sleeps).toEqual([5000, 5000]) + }) + + it('widens the interval by 5s after slow_down (RFC 8628 §3.5)', async () => { + const { clock, sleeps } = fakeClock() + const { transport } = scriptedTransport([ + { kind: 'pending' }, + { kind: 'slow_down' }, + { kind: 'approved', token: 'tok' }, + ]) + + await pollForToken(code, transport, clock) + expect(sleeps).toEqual([5000, 10000]) + }) + + it('throws access_denied when the user denies (on the very first poll)', async () => { + const { clock, sleeps } = fakeClock() + const { transport, calls } = scriptedTransport([{ kind: 'denied' }]) + + const err = await pollForToken(code, transport, clock).catch((e: unknown) => e) + expect(err).toBeInstanceOf(DeviceGrantError) + expect((err as DeviceGrantError).reason).toBe('access_denied') + expect(calls.count).toBe(1) + expect(sleeps).toEqual([]) + }) + + it('throws expired_token when the server reports expiry', async () => { + const { clock } = fakeClock() + const { transport } = scriptedTransport([{ kind: 'pending' }, { kind: 'expired' }]) + + const err = await pollForToken(code, transport, clock).catch((e: unknown) => e) + expect(err).toBeInstanceOf(DeviceGrantError) + expect((err as DeviceGrantError).reason).toBe('expired_token') + }) + + it('enforces the client-side deadline as a backstop once elapsed', async () => { + const { clock } = fakeClock() + // Deadline 6s out with a 5s interval: poll, sleep 5s, poll, sleep 5s → now 10s > 6s. + const shortCode = { ...code, expiresInSeconds: 6 } + const { transport, calls } = scriptedTransport([{ kind: 'pending' }, { kind: 'pending' }]) + + const err = await pollForToken(shortCode, transport, clock).catch((e: unknown) => e) + expect(err).toBeInstanceOf(DeviceGrantError) + expect((err as DeviceGrantError).reason).toBe('expired_token') + expect(calls.count).toBe(2) + }) +}) diff --git a/cli/src/auth/device-grant.ts b/cli/src/auth/device-grant.ts new file mode 100644 index 000000000..ea89110f8 --- /dev/null +++ b/cli/src/auth/device-grant.ts @@ -0,0 +1,109 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The RFC 8628 device-authorization grant state machine, isolated from I/O so the + * poll loop is fully unit-testable. Time (`Clock`) and the network + * (`DeviceGrantTransport`) are injected; the loop itself is pure control flow: + * sleep the interval, poll, and act on the result — approving, backing off on + * `slow_down`, or aborting on denial / expiry. + */ + +/** Injected time seam: current epoch millis + an awaitable sleep. Real impl is + * {@link systemClock}; tests pass a controllable fake (no real timers). */ +export type Clock = { + readonly now: () => number + readonly sleep: (ms: number) => Promise +} + +/** Wall-clock implementation of {@link Clock} backed by `Date.now` / `setTimeout`. */ +export const systemClock: Clock = { + now: () => Date.now(), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +} + +/** Parsed `/device/code` response (RFC 8628 §3.2), normalized to camelCase. */ +export type DeviceCodeResponse = { + readonly deviceCode: string + readonly userCode: string + readonly verificationUri: string + readonly verificationUriComplete: string + readonly intervalSeconds: number + readonly expiresInSeconds: number +} + +/** Outcome of one `/device/token` poll (RFC 8628 §3.5). `approved` carries the + * signed bearer; the rest map the standard error codes. */ +export type TokenPollResult = + | { readonly kind: 'approved'; readonly token: string } + | { readonly kind: 'pending' } + | { readonly kind: 'slow_down' } + | { readonly kind: 'denied' } + | { readonly kind: 'expired' } + +/** Network seam for the device grant: request codes, then poll for the token. */ +export type DeviceGrantTransport = { + readonly requestCode: () => Promise + readonly pollToken: (deviceCode: string) => Promise +} + +/** Terminal reasons the grant can fail with, surfaced to the user. */ +export type DeviceGrantReason = 'access_denied' | 'expired_token' + +/** Raised when the grant ends without a token (user denied, or the code expired). */ +export class DeviceGrantError extends Error { + constructor( + readonly reason: DeviceGrantReason, + message: string, + ) { + super(message) + this.name = 'DeviceGrantError' + } +} + +/** RFC 8628 §3.5: on `slow_down`, the client raises its poll interval by 5s. */ +const SLOW_DOWN_INCREMENT_MS = 5000 + +/** + * Poll the token endpoint until the user approves, denies, or the code expires. + * Polls immediately, then waits `interval` between attempts, widening it by 5s on + * `slow_down`. A client-side deadline from `expiresInSeconds` backstops the + * server's own `expired_token`. Polling first (rather than sleeping first) avoids + * an initial dead wait and the pathological case where a short-lived code expires + * before the CLI ever asks the endpoint. + * + * @param code - the device/user codes from {@link DeviceGrantTransport.requestCode} + * @param transport - network seam used to poll the token endpoint + * @param clock - injected time seam (real: {@link systemClock}) + * @returns the signed bearer token on approval + * @throws {DeviceGrantError} when the user denies or the code expires + */ +export const pollForToken = async ( + code: DeviceCodeResponse, + transport: DeviceGrantTransport, + clock: Clock, +): Promise => { + const deadlineMs = clock.now() + code.expiresInSeconds * 1000 + + const poll = async (intervalMs: number): Promise => { + if (clock.now() >= deadlineMs) { + throw new DeviceGrantError('expired_token', 'the device code expired before it was approved') + } + + const result = await transport.pollToken(code.deviceCode) + if (result.kind === 'approved') return result.token + if (result.kind === 'denied') { + throw new DeviceGrantError('access_denied', 'login was denied on the approval page') + } + if (result.kind === 'expired') { + throw new DeviceGrantError('expired_token', 'the device code expired before it was approved') + } + + const nextIntervalMs = result.kind === 'slow_down' ? intervalMs + SLOW_DOWN_INCREMENT_MS : intervalMs + await clock.sleep(nextIntervalMs) + return poll(nextIntervalMs) + } + + return poll(code.intervalSeconds * 1000) +} diff --git a/cli/src/auth/http-transport.test.ts b/cli/src/auth/http-transport.test.ts new file mode 100644 index 000000000..d9d433e8e --- /dev/null +++ b/cli/src/auth/http-transport.test.ts @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Wire-contract coverage for the fetch-backed transport, exercised with an + * injected `fetchFn` (DI over mocking — no global `fetch` patched). Pins the two + * load-bearing invariants: the approval token is taken from the `set-auth-token` + * header (never the unsignable raw `access_token` body), and the RFC 8628 §3.5 + * error codes map to the right poll results. + */ + +import { describe, expect, it } from 'bun:test' +import { CLI_CLIENT_ID } from './config.ts' +import { createHttpTransport, type FetchFn } from './http-transport.ts' + +const AUTH_BASE = 'https://api.test/v1/api/auth' + +/** A fetch fn that records requests and returns one scripted response. */ +const stubFetch = (response: Response) => { + const requests: { url: string; body: unknown }[] = [] + const fetchFn: FetchFn = async (url, init) => { + requests.push({ url, body: JSON.parse(String(init.body)) }) + return response + } + return { fetchFn, requests } +} + +const jsonResponse = (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, ...init }) + +describe('createHttpTransport.requestCode', () => { + it('posts the client id and normalizes the snake_case body to camelCase', async () => { + const { fetchFn, requests } = stubFetch( + jsonResponse({ + device_code: 'dc', + user_code: 'WDJB-MJHT', + verification_uri: 'https://api.test/device', + verification_uri_complete: 'https://api.test/device?user_code=WDJB-MJHT', + interval: 5, + expires_in: 1800, + }), + ) + + const code = await createHttpTransport(AUTH_BASE, fetchFn).requestCode() + + expect(code).toEqual({ + deviceCode: 'dc', + userCode: 'WDJB-MJHT', + verificationUri: 'https://api.test/device', + verificationUriComplete: 'https://api.test/device?user_code=WDJB-MJHT', + intervalSeconds: 5, + expiresInSeconds: 1800, + }) + expect(requests[0].url).toBe(`${AUTH_BASE}/device/code`) + expect(requests[0].body).toEqual({ client_id: CLI_CLIENT_ID }) + }) + + it('throws when the code request is rejected', async () => { + const { fetchFn } = stubFetch(jsonResponse({ error: 'invalid_client' }, { status: 400, statusText: 'Bad Request' })) + await expect(createHttpTransport(AUTH_BASE, fetchFn).requestCode()).rejects.toThrow( + /device authorization request failed/, + ) + }) +}) + +describe('createHttpTransport.pollToken', () => { + it('returns the signed set-auth-token header, not the raw access_token body', async () => { + const { fetchFn, requests } = stubFetch( + jsonResponse( + { access_token: 'RAW-UNSIGNED-SESSION-TOKEN', token_type: 'Bearer' }, + { headers: { 'set-auth-token': 'SIGNED.hmac' } }, + ), + ) + + const result = await createHttpTransport(AUTH_BASE, fetchFn).pollToken('dc') + + expect(result).toEqual({ kind: 'approved', token: 'SIGNED.hmac' }) + expect(requests[0].url).toBe(`${AUTH_BASE}/device/token`) + expect(requests[0].body).toEqual({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: 'dc', + client_id: CLI_CLIENT_ID, + }) + }) + + it('throws when a 200 approval is missing the set-auth-token header', async () => { + const { fetchFn } = stubFetch(jsonResponse({ access_token: 'RAW' })) + await expect(createHttpTransport(AUTH_BASE, fetchFn).pollToken('dc')).rejects.toThrow(/set-auth-token/) + }) + + it('maps the RFC 8628 §3.5 error codes to poll results', async () => { + const cases = [ + ['authorization_pending', { kind: 'pending' }], + ['slow_down', { kind: 'slow_down' }], + ['expired_token', { kind: 'expired' }], + ['access_denied', { kind: 'denied' }], + ] as const + + for (const [error, expected] of cases) { + const { fetchFn } = stubFetch(jsonResponse({ error }, { status: 400 })) + expect(await createHttpTransport(AUTH_BASE, fetchFn).pollToken('dc')).toEqual(expected) + } + }) + + it('throws on an unrecognized error code', async () => { + const { fetchFn } = stubFetch(jsonResponse({ error: 'invalid_grant' }, { status: 400 })) + await expect(createHttpTransport(AUTH_BASE, fetchFn).pollToken('dc')).rejects.toThrow(/invalid_grant/) + }) +}) diff --git a/cli/src/auth/http-transport.ts b/cli/src/auth/http-transport.ts new file mode 100644 index 000000000..21d654a7a --- /dev/null +++ b/cli/src/auth/http-transport.ts @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The real `fetch`-backed {@link DeviceGrantTransport} that talks to Better Auth's + * `deviceAuthorization` endpoints. This is the integration seam the pure state + * machine ({@link pollForToken}) drives: `pollForToken`'s own tests inject a fake + * transport, while this module's wire contract is covered directly in + * `http-transport.test.ts` with an injected `fetchFn`. + * + * On approval the token endpoint mints a session and (because this stack runs the + * bearer plugin with `requireSignature`) exposes the *signed* bearer via the + * `set-auth-token` response header — the raw `access_token` body value is a bare, + * unsignable session token and is deliberately not used. + */ + +import { CLI_CLIENT_ID } from './config.ts' +import type { DeviceCodeResponse, DeviceGrantTransport, TokenPollResult } from './device-grant.ts' + +/** RFC 8628 grant type for the token exchange. */ +const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code' + +/** Raw `/device/code` 200 body (snake_case on the wire, RFC 8628 §3.2). */ +type DeviceCodeBody = { + readonly device_code: string + readonly user_code: string + readonly verification_uri: string + readonly verification_uri_complete: string + readonly interval: number + readonly expires_in: number +} + +/** Raw `/device/token` 400 body carrying the RFC 8628 §3.5 error code. */ +type DeviceTokenErrorBody = { readonly error: string; readonly error_description?: string } + +/** Map a `/device/token` error code to a {@link TokenPollResult}; unknown codes + * are non-recoverable and surface loudly. */ +const mapPollError = (error: string): TokenPollResult => { + if (error === 'authorization_pending') return { kind: 'pending' } + if (error === 'slow_down') return { kind: 'slow_down' } + if (error === 'expired_token') return { kind: 'expired' } + if (error === 'access_denied') return { kind: 'denied' } + throw new Error(`device authorization failed: ${error}`) +} + +/** The subset of `fetch` this transport uses; injectable so the wire contract can + * be unit-tested without a real network. */ +export type FetchFn = (url: string, init: RequestInit) => Promise + +/** + * Build a {@link DeviceGrantTransport} bound to a Better Auth base URL + * (`…/v1/api/auth`, from {@link authBaseUrl}). + * + * @param authBaseUrl - the Better Auth base URL + * @param fetchFn - HTTP fetch (defaults to the global `fetch`) + */ +export const createHttpTransport = (authBaseUrl: string, fetchFn: FetchFn = fetch): DeviceGrantTransport => ({ + requestCode: async () => { + const res = await fetchFn(`${authBaseUrl}/device/code`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ client_id: CLI_CLIENT_ID }), + }) + if (!res.ok) { + throw new Error(`device authorization request failed (${res.status} ${res.statusText})`) + } + const body = (await res.json()) as DeviceCodeBody + return { + deviceCode: body.device_code, + userCode: body.user_code, + verificationUri: body.verification_uri, + verificationUriComplete: body.verification_uri_complete, + intervalSeconds: body.interval, + expiresInSeconds: body.expires_in, + } satisfies DeviceCodeResponse + }, + + pollToken: async (deviceCode) => { + const res = await fetchFn(`${authBaseUrl}/device/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: DEVICE_CODE_GRANT_TYPE, + device_code: deviceCode, + client_id: CLI_CLIENT_ID, + }), + }) + if (res.ok) { + const signedToken = res.headers.get('set-auth-token') + if (!signedToken) { + throw new Error('device token response is missing the set-auth-token header') + } + return { kind: 'approved', token: signedToken } + } + const body = (await res.json()) as DeviceTokenErrorBody + return mapPollError(body.error) + }, +}) diff --git a/cli/src/auth/login.test.ts b/cli/src/auth/login.test.ts new file mode 100644 index 000000000..dbf99808b --- /dev/null +++ b/cli/src/auth/login.test.ts @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Flow coverage for {@link performLogin} with every dependency injected: the PAT + * short-circuit, the interactive happy path (persist + QR gate), and the + * link-only fallback. No real network, timers, or filesystem. + */ + +import { describe, expect, it } from 'bun:test' +import { performLogin, type LoginDeps } from './login.ts' +import type { CliAuthConfig } from './token-store.ts' +import type { DeviceCodeResponse, TokenPollResult } from './device-grant.ts' + +const code: DeviceCodeResponse = { + deviceCode: 'dev-code', + userCode: 'WDJB-MJHT', + verificationUri: 'https://app.test/device', + verificationUriComplete: 'https://app.test/device?user_code=WDJB-MJHT', + intervalSeconds: 5, + expiresInSeconds: 300, +} + +/** Build DI deps with sensible defaults, recording every side effect. */ +const makeDeps = (overrides: Partial = {}) => { + const printed: string[] = [] + const stored: CliAuthConfig[] = [] + const qrTexts: string[] = [] + const requestCalls = { count: 0 } + const pollResults: TokenPollResult[] = [{ kind: 'approved', token: 'signed.jwt' }] + const pollIndex = { count: 0 } + + const deps: LoginDeps = { + env: { cloudUrl: 'https://api.test/v1' }, + transport: { + requestCode: async () => { + requestCalls.count += 1 + return code + }, + pollToken: async () => { + const next = pollResults[pollIndex.count] + pollIndex.count += 1 + return next + }, + }, + clock: { now: () => 0, sleep: async () => {} }, + storeToken: async (config) => { + stored.push(config) + }, + print: (line) => printed.push(line), + renderQr: (text) => qrTexts.push(text), + qrEnv: { isTty: true, columns: 120 }, + ...overrides, + } + + return { deps, printed, stored, qrTexts, requestCalls } +} + +describe('performLogin', () => { + it('uses THUNDERBOLT_TOKEN directly and skips the interactive flow', async () => { + const { deps, printed, stored, requestCalls } = makeDeps({ + env: { cloudUrl: 'https://api.test/v1', patToken: 'pat-xyz' }, + }) + + const token = await performLogin(deps) + + expect(token).toBe('pat-xyz') + expect(requestCalls.count).toBe(0) + expect(stored).toEqual([]) // an env PAT is ephemeral — never persisted + expect(printed.some((line) => line.includes('THUNDERBOLT_TOKEN'))).toBe(true) + }) + + it('runs the device grant, prints the link + code, and persists the token', async () => { + const { deps, printed, stored, qrTexts } = makeDeps() + + const token = await performLogin(deps) + + expect(token).toBe('signed.jwt') + expect(stored).toEqual([{ token: 'signed.jwt', cloudUrl: 'https://api.test/v1' }]) + expect(printed.some((line) => line.includes(code.verificationUri) && line.includes(code.userCode))).toBe(true) + expect(qrTexts).toEqual([code.verificationUriComplete]) + }) + + it('prints the link but skips the QR when the terminal cannot render one', async () => { + const { deps, printed, qrTexts } = makeDeps({ qrEnv: { isTty: false, columns: 120 } }) + + await performLogin(deps) + + expect(qrTexts).toEqual([]) + expect(printed.some((line) => line.includes(code.verificationUri))).toBe(true) + }) + + it('refuses the interactive flow over plain http to a remote host', async () => { + const { deps, stored } = makeDeps({ env: { cloudUrl: 'http://selfhost.example/v1' } }) + + const err = await performLogin(deps).catch((e: unknown) => e) + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain('insecure') + expect(stored).toEqual([]) // never minted or persisted a token + }) +}) diff --git a/cli/src/auth/login.ts b/cli/src/auth/login.ts new file mode 100644 index 000000000..070720242 --- /dev/null +++ b/cli/src/auth/login.ts @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `thunderbolt login`: authenticate the CLI against the account with the RFC 8628 + * device grant, or short-circuit via a `THUNDERBOLT_TOKEN` PAT for CI / self-host. + * + * {@link performLogin} is the DI'd core (transport / clock / storage / output all + * injected) so the flow is unit-testable without real network or timers; + * {@link runLogin} is the thin wiring that binds the real implementations. + */ + +import { authBaseUrl, isSecureCloudUrl, resolveCloudUrl, resolvePatToken } from './config.ts' +import { pollForToken, systemClock, type Clock, type DeviceGrantTransport } from './device-grant.ts' +import { createHttpTransport } from './http-transport.ts' +import { renderTerminalQr, shouldRenderQr, type QrEnv } from './qr.ts' +import { storeAuthConfig, type CliAuthConfig } from './token-store.ts' + +/** Resolved environment inputs for a login: which backend, and an optional PAT. */ +export type LoginEnv = { + readonly cloudUrl: string + readonly patToken?: string +} + +/** Everything {@link performLogin} needs, injected so the flow has no ambient I/O. */ +export type LoginDeps = { + readonly env: LoginEnv + readonly transport: DeviceGrantTransport + readonly clock: Clock + readonly storeToken: (config: CliAuthConfig) => Promise + readonly print: (line: string) => void + readonly renderQr: (text: string) => void + readonly qrEnv: QrEnv +} + +/** + * Run the login flow with all dependencies injected. When a PAT is present it is + * used directly and nothing else runs; otherwise it requests device+user codes, + * shows the link (+ best-effort QR), polls until approval, and persists the token. + * + * @param deps - injected transport, clock, storage, output, and QR sink + * @returns the resolved bearer token + */ +export const performLogin = async (deps: LoginDeps): Promise => { + if (deps.env.patToken) { + deps.print('Using THUNDERBOLT_TOKEN from the environment; skipping interactive login.') + return deps.env.patToken + } + + // The device grant transmits a replayable bearer to this host, so refuse plain + // HTTP to anything but loopback rather than leak it over cleartext. + if (!isSecureCloudUrl(deps.env.cloudUrl)) { + throw new Error( + `refusing to log in over insecure transport: ${deps.env.cloudUrl}. Use an https:// URL (plain http is allowed only for localhost).`, + ) + } + + const code = await deps.transport.requestCode() + deps.print(`\nTo authorize this CLI, open:\n ${code.verificationUri}\nand enter the code: ${code.userCode}\n`) + if (shouldRenderQr(deps.qrEnv)) deps.renderQr(code.verificationUriComplete) + + deps.print('Waiting for approval…') + const token = await pollForToken(code, deps.transport, deps.clock) + await deps.storeToken({ token, cloudUrl: deps.env.cloudUrl }) + + deps.print('Login successful. Credentials saved.') + return token +} + +/** + * Entry point for the `login` subcommand: binds the real transport, clock, + * storage, and terminal, then runs {@link performLogin}. + */ +export const runLogin = async (): Promise => { + const cloudUrl = resolveCloudUrl() + await performLogin({ + env: { cloudUrl, patToken: resolvePatToken() }, + transport: createHttpTransport(authBaseUrl(cloudUrl)), + clock: systemClock, + storeToken: storeAuthConfig, + print: (line) => console.log(line), + renderQr: (text) => renderTerminalQr(text), + qrEnv: { isTty: Boolean(process.stdout.isTTY), columns: process.stdout.columns ?? 0 }, + }) +} diff --git a/cli/src/auth/qr.test.ts b/cli/src/auth/qr.test.ts new file mode 100644 index 000000000..acd87fc81 --- /dev/null +++ b/cli/src/auth/qr.test.ts @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Coverage for the link-only fallback decision. The QR render itself is a + * best-effort side effect and intentionally not unit-tested; only the gate that + * chooses link-only vs QR is. + */ + +import { describe, expect, test } from 'bun:test' +import { shouldRenderQr } from './qr.ts' + +describe('shouldRenderQr', () => { + test('renders on a wide interactive TTY', () => { + expect(shouldRenderQr({ isTty: true, columns: 80 })).toBe(true) + expect(shouldRenderQr({ isTty: true, columns: 200 })).toBe(true) + }) + + test('falls back to link-only when not a TTY (piped/redirected)', () => { + expect(shouldRenderQr({ isTty: false, columns: 200 })).toBe(false) + }) + + test('falls back to link-only when the terminal is too narrow', () => { + expect(shouldRenderQr({ isTty: true, columns: 79 })).toBe(false) + }) +}) diff --git a/cli/src/auth/qr.ts b/cli/src/auth/qr.ts new file mode 100644 index 000000000..0e9623b8d --- /dev/null +++ b/cli/src/auth/qr.ts @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Best-effort terminal QR of the device-grant verification URL. Purely a + * convenience for scanning with a phone — the printed link + code are always the + * authoritative path — so it degrades to nothing when the output isn't an + * interactive terminal or is too narrow to hold a QR legibly. + */ + +import { generate } from 'qrcode-terminal' + +/** Terminal capabilities that gate whether a QR is worth rendering. */ +export type QrEnv = { + readonly isTty: boolean + readonly columns: number +} + +/** Minimum terminal width to attempt a (small-mode) QR; below this, link-only. */ +const MIN_QR_COLUMNS = 80 + +/** + * Decide whether a terminal QR should be rendered: only on an interactive TTY + * wide enough to hold one. This is the tested fallback decision; the render + * itself is not unit-tested. + * + * @param env - the terminal's TTY flag and column count + */ +export const shouldRenderQr = (env: QrEnv): boolean => env.isTty && env.columns >= MIN_QR_COLUMNS + +/** + * Render a compact QR of `text` to `print`. Callers gate this behind + * {@link shouldRenderQr}, which is where the "can't render → link-only" + * degradation lives; a bounded verification URL always fits within QR capacity, + * so this stays a straight render with no error-swallowing. + * + * @param text - the URL to encode (the verification_uri_complete) + * @param print - line sink (defaults to `console.log`) + */ +export const renderTerminalQr = (text: string, print: (line: string) => void = console.log): void => { + generate(text, { small: true }, (qr) => print(qr)) +} diff --git a/cli/src/auth/token-store.test.ts b/cli/src/auth/token-store.test.ts new file mode 100644 index 000000000..952b8653a --- /dev/null +++ b/cli/src/auth/token-store.test.ts @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Round-trip + permission coverage for the CLI credential store, exercised + * against a real isolated `THUNDERBOLT_HOME` (DI-over-mocking: the file store is + * the unit, so it runs on a real temp filesystem rather than a mocked fs). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DEFAULT_CLOUD_URL } from './config.ts' +import { loadAuthConfig, resolveBridgeCredential, storeAuthConfig } from './token-store.ts' + +let home: string +const prevHome = process.env.THUNDERBOLT_HOME + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'tb-auth-')) + process.env.THUNDERBOLT_HOME = home +}) + +afterEach(async () => { + if (prevHome === undefined) delete process.env.THUNDERBOLT_HOME + else process.env.THUNDERBOLT_HOME = prevHome + await rm(home, { recursive: true, force: true }) +}) + +describe('token store', () => { + it('returns null before any login', async () => { + expect(await loadAuthConfig()).toBeNull() + }) + + it('round-trips the stored credential', async () => { + await storeAuthConfig({ token: 'signed.jwt', cloudUrl: 'https://api.test/v1' }) + expect(await loadAuthConfig()).toEqual({ token: 'signed.jwt', cloudUrl: 'https://api.test/v1' }) + }) + + it('overwrites a prior login', async () => { + await storeAuthConfig({ token: 'first', cloudUrl: 'https://a/v1' }) + await storeAuthConfig({ token: 'second', cloudUrl: 'https://b/v1' }) + expect(await loadAuthConfig()).toEqual({ token: 'second', cloudUrl: 'https://b/v1' }) + }) + + it('persists the credential owner-only (0600)', async () => { + await storeAuthConfig({ token: 'signed.jwt', cloudUrl: 'https://api.test/v1' }) + expect((await stat(join(home, 'auth.json'))).mode & 0o777).toBe(0o600) + }) +}) + +describe('resolveBridgeCredential — env PAT vs stored login', () => { + const prevToken = process.env.THUNDERBOLT_TOKEN + const prevCloud = process.env.THUNDERBOLT_CLOUD_URL + + beforeEach(() => { + delete process.env.THUNDERBOLT_TOKEN + delete process.env.THUNDERBOLT_CLOUD_URL + }) + afterEach(() => { + if (prevToken === undefined) delete process.env.THUNDERBOLT_TOKEN + else process.env.THUNDERBOLT_TOKEN = prevToken + if (prevCloud === undefined) delete process.env.THUNDERBOLT_CLOUD_URL + else process.env.THUNDERBOLT_CLOUD_URL = prevCloud + }) + + it('prefers the env PAT as an api-key credential, even over a stored login', async () => { + process.env.THUNDERBOLT_TOKEN = 'pat-xyz' + process.env.THUNDERBOLT_CLOUD_URL = 'https://ci.example/v1' + await storeAuthConfig({ token: 'session.jwt', cloudUrl: 'https://stored/v1' }) + + expect(await resolveBridgeCredential()).toEqual({ + token: 'pat-xyz', + cloudUrl: 'https://ci.example/v1', + kind: 'apiKey', + }) + }) + + it('resolves the PAT against the default cloud URL when THUNDERBOLT_CLOUD_URL is unset', async () => { + process.env.THUNDERBOLT_TOKEN = 'pat-xyz' + + expect(await resolveBridgeCredential()).toEqual({ + token: 'pat-xyz', + cloudUrl: DEFAULT_CLOUD_URL, + kind: 'apiKey', + }) + }) + + it('falls back to the stored device-grant session when there is no env PAT', async () => { + await storeAuthConfig({ token: 'session.jwt', cloudUrl: 'https://stored/v1' }) + + expect(await resolveBridgeCredential()).toEqual({ + token: 'session.jwt', + cloudUrl: 'https://stored/v1', + kind: 'session', + }) + }) + + it('returns null (Standalone) when neither an env PAT nor a stored login exists', async () => { + expect(await resolveBridgeCredential()).toBeNull() + }) +}) diff --git a/cli/src/auth/token-store.ts b/cli/src/auth/token-store.ts new file mode 100644 index 000000000..c0f188dbc --- /dev/null +++ b/cli/src/auth/token-store.ts @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Persistence for the CLI's account credential: the signed bearer minted by the + * device grant plus the cloud URL it belongs to, stored as owner-only JSON at + * `~/.thunderbolt/auth.json` (mode 0600). Reuses the secure filesystem helpers + * that back the iroh identity/allowlist stores so the credential gets the same + * `0600`-in-`0700` treatment. Honors `THUNDERBOLT_HOME` for isolated test/multi + * account homes, matching `iroh/paths.ts`. + * + * Also resolves the *effective* credential a running bridge authenticates with + * ({@link resolveBridgeCredential}): the env PAT (a Better Auth api key, the + * zero-human CI / self-host escape hatch) takes precedence over the stored login, + * and each carries a {@link CredentialKind} so the caller sends the right wire + * header (`x-api-key` for api keys, `Authorization: Bearer` for device sessions). + */ + +import { homedir } from 'node:os' +import { join } from 'node:path' +import { enforceSecureFile, readFileOrNull, writeSecureFile } from '../iroh/storage.ts' +import { resolveCloudUrl, resolvePatToken } from './config.ts' + +/** Root for all thunderbolt CLI state; `THUNDERBOLT_HOME` overrides the default. */ +const baseDir = (): string => process.env.THUNDERBOLT_HOME ?? join(homedir(), '.thunderbolt') + +/** Path to the persisted CLI auth config. */ +const authConfigPath = (): string => join(baseDir(), 'auth.json') + +/** The persisted CLI credential: a signed bearer bound to a specific backend. */ +export type CliAuthConfig = { + readonly token: string + readonly cloudUrl: string +} + +/** + * Load the stored credential, or `null` when the CLI has never logged in. + * + * @returns the persisted {@link CliAuthConfig}, or `null` if absent + */ +export const loadAuthConfig = async (): Promise => { + const path = authConfigPath() + const raw = await readFileOrNull(path) + if (raw === null) return null + // Force 0600 on read so a credential restored/copied with lax permissions + // self-heals — mirrors the iroh identity loader. + await enforceSecureFile(path) + return JSON.parse(raw) as CliAuthConfig +} + +/** + * Persist the credential owner-only (0600), replacing any prior login. + * + * @param config - the credential to store + */ +export const storeAuthConfig = async (config: CliAuthConfig): Promise => { + await writeSecureFile(baseDir(), authConfigPath(), `${JSON.stringify(config, null, 2)}\n`) +} + +/** Which auth scheme a credential presents on the wire: an interactive device-grant + * session (`Authorization: Bearer`) or a Better Auth api key / PAT (`x-api-key` — + * the apiKey plugin authenticates ONLY via that header, never the bearer one). */ +export type CredentialKind = 'session' | 'apiKey' + +/** The effective credential a running bridge authenticates with: the token, the + * backend it belongs to, and the wire scheme. Resolved from the env PAT (CI / + * self-host) or the stored device-grant login. */ +export type BridgeCredential = { + readonly token: string + readonly cloudUrl: string + readonly kind: CredentialKind +} + +/** + * Resolve the credential a running bridge should authenticate with. The env PAT + * (`THUNDERBOLT_TOKEN`, a Better Auth api key) wins as the zero-human CI / self-host + * escape hatch — it authenticates via `x-api-key` against the cloud URL from the env + * (`THUNDERBOLT_CLOUD_URL`, or the default). Otherwise the stored device-grant login + * is used as a session bearer. Returns `null` in Standalone / no-credential mode, + * where the bridge falls back to the manual `iroh allow` file. + * + * @returns the effective {@link BridgeCredential}, or `null` when none is configured + */ +export const resolveBridgeCredential = async (): Promise => { + const patToken = resolvePatToken() + if (patToken) return { token: patToken, cloudUrl: resolveCloudUrl(), kind: 'apiKey' } + const stored = await loadAuthConfig() + if (!stored) return null + return { token: stored.token, cloudUrl: stored.cloudUrl, kind: 'session' } +} diff --git a/cli/src/banner.ts b/cli/src/banner.ts new file mode 100644 index 000000000..190c7b77f --- /dev/null +++ b/cli/src/banner.ts @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Standalone REPL banner. Uses raw ANSI so it stays decoupled from the UI + * theme, and skips color when NO_COLOR is set or stdout isn't a TTY. + */ + +import { VERSION } from './cli.ts' + +/** Whether to emit ANSI color: only on an interactive TTY without NO_COLOR. */ +const useColor = (): boolean => !process.env.NO_COLOR && process.stdout.isTTY === true + +/** + * Builds the two-line REPL header (title + version, then a one-line hint) as a + * string. Color is applied only on an interactive TTY. Returned rather than + * written so the TUI can wrap it in a component instead of touching stdout, + * which would corrupt the differential renderer. + */ +export const bannerText = (): string => { + const color = useColor() + const bold = color ? '\x1b[1m' : '' + const yellow = color ? '\x1b[33m' : '' + const dim = color ? '\x1b[2m' : '' + const reset = color ? '\x1b[0m' : '' + + return ( + `${bold}${yellow}⚡ thunderbolt${reset} ${dim}v${VERSION}${reset}\n` + + `${dim}type a task, or 'exit' to quit${reset}` + ) +} + +/** + * Prints the REPL header to stdout for the plain (non-TUI) interactive loop. + * No-op-safe in non-TTY environments (the text still prints, just without + * color). + */ +export const printBanner = (): void => { + process.stdout.write(`${bannerText()}\n\n`) +} diff --git a/cli/src/cli.test.ts b/cli/src/cli.test.ts new file mode 100644 index 000000000..0cab02cf4 --- /dev/null +++ b/cli/src/cli.test.ts @@ -0,0 +1,297 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Branch + edge-case coverage for `parseArgs` — the pure argv → ParsedArgs + * folder. Focus areas: the openai-compat api-key precedence (flag wins over + * `THUNDERBOLT_OPENAI_COMPAT_KEY`, and the key never bleeds into the prompt), + * the value-validating flags, and the bridge/serve/connect subcommand routing. + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { parseArgs } from './cli.ts' + +const ENV_KEY = 'THUNDERBOLT_OPENAI_COMPAT_KEY' + +/** Narrow a ParsedArgs to a `run` config or fail loudly. */ +const runConfig = (argv: string[]) => { + const parsed = parseArgs(argv) + if (parsed.kind !== 'run') throw new Error(`expected run, got ${parsed.kind}: ${JSON.stringify(parsed)}`) + return parsed.config +} + +describe('parseArgs — resolveApiKey precedence (security)', () => { + const saved = process.env[ENV_KEY] + beforeEach(() => { + delete process.env[ENV_KEY] + }) + afterEach(() => { + if (saved === undefined) delete process.env[ENV_KEY] + else process.env[ENV_KEY] = saved + }) + + test('--api-key flag wins over the env var', () => { + process.env[ENV_KEY] = 'env-key' + const config = runConfig([ + '--provider', + 'openai-compat', + '--base-url', + 'https://h/v1', + '--api-key', + 'flag-key', + 'hi', + ]) + expect(config.apiKey).toBe('flag-key') + }) + + test('falls back to the env var when no flag is given', () => { + process.env[ENV_KEY] = 'env-key' + const config = runConfig(['--provider', 'openai-compat', '--base-url', 'https://h/v1', 'hi']) + expect(config.apiKey).toBe('env-key') + }) + + test('is undefined when neither flag nor env is set', () => { + const config = runConfig(['--provider', 'openai-compat', '--base-url', 'https://h/v1', 'hi']) + expect(config.apiKey).toBeUndefined() + }) + + test('does not auto-forward a standard OPENAI_API_KEY (dedicated var only)', () => { + const savedOpenai = process.env.OPENAI_API_KEY + process.env.OPENAI_API_KEY = 'sk-real-openai' + try { + const config = runConfig(['--provider', 'openai-compat', '--base-url', 'https://h/v1', 'hi']) + expect(config.apiKey).toBeUndefined() + } finally { + if (savedOpenai === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = savedOpenai + } + }) + + test('the api key never leaks into the prompt positionals', () => { + const config = runConfig(['--api-key', 'super-secret', 'fix', 'the', 'bug']) + if (config.mode !== 'oneshot') throw new Error('expected oneshot') + expect(config.prompt).toBe('fix the bug') + expect(config.prompt).not.toContain('super-secret') + expect(config.apiKey).toBe('super-secret') + }) + + test('an --api-key consumed at the end of argv does not become a positional prompt', () => { + const config = runConfig(['hello', '--api-key', 'k']) + if (config.mode !== 'oneshot') throw new Error('expected oneshot') + expect(config.prompt).toBe('hello') + }) +}) + +describe('parseArgs — flag validation', () => { + test('rejects an unknown --provider value', () => { + const parsed = parseArgs(['--provider', 'gemini']) + expect(parsed).toEqual({ kind: 'error', message: expect.stringContaining("invalid --provider 'gemini'") }) + }) + + test('rejects a --provider with no following value', () => { + expect(parseArgs(['--provider'])).toEqual({ kind: 'error', message: 'thunderbolt: --provider requires a value' }) + }) + + test('rejects an invalid --thinking level', () => { + const parsed = parseArgs(['--thinking', 'ultra']) + expect(parsed).toEqual({ kind: 'error', message: expect.stringContaining("invalid --thinking level 'ultra'") }) + }) + + test('reports the specific missing-value message for each value-taking flag', () => { + expect(parseArgs(['--model'])).toEqual({ kind: 'error', message: 'thunderbolt: --model requires a value' }) + expect(parseArgs(['--base-url'])).toEqual({ kind: 'error', message: 'thunderbolt: --base-url requires a value' }) + expect(parseArgs(['--api-key'])).toEqual({ kind: 'error', message: 'thunderbolt: --api-key requires a value' }) + expect(parseArgs(['--thinking'])).toEqual({ kind: 'error', message: 'thunderbolt: --thinking requires a value' }) + }) + + test('accepts a valid provider/thinking and threads them into the config', () => { + const config = runConfig(['--provider', 'openai-compat', '--base-url', 'https://h/v1', '--thinking', 'high', 'go']) + expect(config.provider).toBe('openai-compat') + expect(config.thinking).toBe('high') + expect(config.baseUrl).toBe('https://h/v1') + }) + + test('the -m alias sets the model just like --model', () => { + expect(runConfig(['-m', 'claude-x', 'go']).model).toBe('claude-x') + }) +}) + +describe('parseArgs — defaults', () => { + test('an empty argv yields the documented default config', () => { + const config = runConfig([]) + expect(config.mode).toBe('repl') + expect(config.model).toBe('claude-opus-4-8') + expect(config.provider).toBe('anthropic') + expect(config.thinking).toBe('medium') + expect(config.yolo).toBe(false) + expect(config.baseUrl).toBeUndefined() + }) +}) + +describe('parseArgs — run mode + yolo aliases', () => { + test('no prompt yields repl mode; a prompt yields oneshot', () => { + expect(parseArgs([]).kind).toBe('run') + const repl = runConfig([]) + expect(repl.mode).toBe('repl') + expect(runConfig(['do', 'it']).mode).toBe('oneshot') + }) + + test('strips a leading `agent` subcommand before scanning flags', () => { + const config = runConfig(['agent', '--model', 'x', 'prompt']) + expect(config.model).toBe('x') + if (config.mode !== 'oneshot') throw new Error('expected oneshot') + expect(config.prompt).toBe('prompt') + }) + + test('all three yolo spellings set the flag', () => { + expect(runConfig(['-y', 'p']).yolo).toBe(true) + expect(runConfig(['--yolo', 'p']).yolo).toBe(true) + expect(runConfig(['--dangerously-skip-permissions', 'p']).yolo).toBe(true) + expect(runConfig(['p']).yolo).toBe(false) + }) + + test('--no-tui sets noTui on a repl config; it defaults to false', () => { + const off = runConfig(['--no-tui']) + if (off.mode !== 'repl') throw new Error('expected repl') + expect(off.noTui).toBe(true) + const on = runConfig([]) + if (on.mode !== 'repl') throw new Error('expected repl') + expect(on.noTui).toBe(false) + }) + + test('--help / --version short-circuit over a run', () => { + expect(parseArgs(['--help', 'ignored']).kind).toBe('help') + expect(parseArgs(['-h']).kind).toBe('help') + expect(parseArgs(['--version']).kind).toBe('version') + expect(parseArgs(['-v']).kind).toBe('version') + }) +}) + +describe('parseArgs — bridge subcommands (acp / mcp)', () => { + test('parses a wss bridge with an explicit port and the post-`--` command', () => { + const parsed = parseArgs(['acp', '--transport', 'wss', '--port', '9001', '--', 'npx', 'agent']) + expect(parsed).toEqual({ + kind: 'bridge', + config: { protocol: 'acp', transport: 'wss', port: 9001, command: ['npx', 'agent'] }, + }) + }) + + test('defaults the port per protocol when omitted', () => { + const acp = parseArgs(['acp', '--', 'cmd']) + const mcp = parseArgs(['mcp', '--', 'cmd']) + if (acp.kind !== 'bridge' || mcp.kind !== 'bridge') throw new Error('expected bridge') + expect(acp.config.port).toBe(8839) + expect(mcp.config.port).toBe(8840) + }) + + test('rejects an unknown --transport', () => { + const parsed = parseArgs(['acp', '--transport', 'tcp', '--', 'cmd']) + expect(parsed).toEqual({ kind: 'error', message: expect.stringContaining("invalid --transport 'tcp'") }) + }) + + test('rejects a non-numeric or out-of-range --port', () => { + expect(parseArgs(['acp', '--port', '0x10', '--', 'cmd']).kind).toBe('error') + expect(parseArgs(['acp', '--port', '70000', '--', 'cmd']).kind).toBe('error') + expect(parseArgs(['acp', '--port', '1e3', '--', 'cmd']).kind).toBe('error') + }) + + test('treats an unrecognized pre-`--` token as a forgotten separator', () => { + const parsed = parseArgs(['acp', 'npx', 'agent']) + expect(parsed).toEqual({ kind: 'error', message: expect.stringContaining("forget '--'") }) + }) + + test('requires a command after the `--` separator', () => { + const parsed = parseArgs(['mcp', '--transport', 'wss', '--']) + expect(parsed).toEqual({ kind: 'error', message: expect.stringContaining('missing agent command') }) + }) + + test('missing separator entirely is a missing-command error', () => { + expect(parseArgs(['acp']).kind).toBe('error') + }) +}) + +describe('parseArgs — acp serve', () => { + const saved = process.env[ENV_KEY] + afterEach(() => { + if (saved === undefined) delete process.env[ENV_KEY] + else process.env[ENV_KEY] = saved + }) + + test('resolves the same flag set as a run, including api-key precedence', () => { + process.env[ENV_KEY] = 'env-key' + const parsed = parseArgs([ + 'acp', + 'serve', + '--provider', + 'openai-compat', + '--base-url', + 'https://h/v1', + '--api-key', + 'flag-key', + ]) + if (parsed.kind !== 'acp-serve') throw new Error(`expected acp-serve, got ${parsed.kind}`) + expect(parsed.config.apiKey).toBe('flag-key') + expect(parsed.config.provider).toBe('openai-compat') + }) + + test('rejects a stray positional (serve takes no prompt)', () => { + const parsed = parseArgs(['acp', 'serve', 'unexpected']) + expect(parsed).toEqual({ kind: 'error', message: expect.stringContaining("unexpected argument 'unexpected'") }) + }) +}) + +describe('parseArgs — connect + iroh admin', () => { + test('connect parses the dial target and post-`--` client command', () => { + const parsed = parseArgs(['acp', 'connect', 'ticket123', '--', 'local', 'client']) + expect(parsed).toEqual({ + kind: 'connect', + config: { protocol: 'acp', target: 'ticket123', command: ['local', 'client'] }, + }) + }) + + test('connect with only a target (no local command) yields an empty command', () => { + expect(parseArgs(['mcp', 'connect', 'node-abc'])).toEqual({ + kind: 'connect', + config: { protocol: 'mcp', target: 'node-abc', command: [] }, + }) + }) + + test('connect without a target is an error', () => { + expect(parseArgs(['mcp', 'connect']).kind).toBe('error') + }) + + test('connect rejects a second bare token before `--`', () => { + const parsed = parseArgs(['acp', 'connect', 'ticket', 'stray']) + expect(parsed).toEqual({ kind: 'error', message: expect.stringContaining("unexpected argument 'stray'") }) + }) + + test('iroh allow requires a nodeid; id/pair route to admin actions', () => { + expect(parseArgs(['iroh', 'id'])).toEqual({ kind: 'iroh-admin', action: { kind: 'id' } }) + expect(parseArgs(['iroh', 'pair'])).toEqual({ kind: 'iroh-admin', action: { kind: 'pair' } }) + expect(parseArgs(['iroh', 'allow', 'node-xyz'])).toEqual({ + kind: 'iroh-admin', + action: { kind: 'allow', nodeId: 'node-xyz' }, + }) + expect(parseArgs(['iroh', 'allow']).kind).toBe('error') + expect(parseArgs(['iroh', 'bogus']).kind).toBe('error') + }) +}) + +describe('parseArgs — login', () => { + test('bare `login` routes to the login action', () => { + expect(parseArgs(['login'])).toEqual({ kind: 'login' }) + }) + + test('login --help / -h short-circuits to help', () => { + expect(parseArgs(['login', '--help']).kind).toBe('help') + expect(parseArgs(['login', '-h']).kind).toBe('help') + }) + + test('login rejects a stray positional (it takes no arguments)', () => { + expect(parseArgs(['login', 'extra'])).toEqual({ + kind: 'error', + message: expect.stringContaining("unexpected argument 'extra'"), + }) + }) +}) diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 000000000..6a7167868 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,431 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Command-line surface for the thunderbolt CLI: version, help text, and the + * pure `parseArgs` that turns `Bun.argv.slice(2)` into a {@link ParsedArgs}. + * No I/O happens here — the caller decides what to do with the result. + */ + +import type { + BridgeConfig, + BridgeProtocol, + BridgeTransport, + ModelProvider, + ParsedArgs, + RunConfig, + ServeConfig, + ThinkingLevel, +} from './agent/types.ts' +import { DEFAULT_CLOUD_URL } from './auth/config.ts' + +/** Released version of the CLI, surfaced by `--version` and the banner. */ +export const VERSION = '0.1.0' + +/** Default Anthropic model when `--model` is omitted. */ +const DEFAULT_MODEL = 'claude-opus-4-8' + +/** Default model backend when `--provider` is omitted. */ +const DEFAULT_PROVIDER: ModelProvider = 'anthropic' + +/** All valid `--provider` values. */ +const MODEL_PROVIDERS: readonly ModelProvider[] = ['anthropic', 'openai-compat'] + +/** All valid `--thinking` levels, in increasing depth. */ +const THINKING_LEVELS: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] + +/** Default `--transport` when omitted (loopback WebSocket). */ +const DEFAULT_TRANSPORT: BridgeTransport = 'wss' + +/** All supported `--transport` values: `wss` (loopback) and `iroh` (P2P/E2E). */ +const TRANSPORTS: readonly BridgeTransport[] = ['wss', 'iroh'] + +/** Default `--port` per bridge protocol — distinct so `acp` and `mcp` bridges + * can run side by side without colliding on their defaults. */ +const DEFAULT_BRIDGE_PORT: Record = { acp: 8839, mcp: 8840 } + +/** Usage text printed by `--help`/`-h`. */ +export const HELP_TEXT = `⚡ thunderbolt v${VERSION} — a single-binary terminal coding agent. + +USAGE + thunderbolt [options] [prompt] + thunderbolt agent [options] [prompt] + thunderbolt login + thunderbolt acp serve [options] + thunderbolt acp --transport [--port N] -- + thunderbolt mcp --transport [--port N] -- + thunderbolt acp connect [-- ] + thunderbolt iroh > + + With a prompt, runs it once and exits. With no prompt, starts an + interactive REPL. Built on the Pi harness; talks to Claude. + +SUBCOMMANDS + agent run the coding agent (default when omitted) + login authenticate this CLI with your Thunderbolt account (device grant) + acp serve expose THIS coding agent as a stdio ACP server (for bridging) + acp bridge a local stdio ACP agent over the network the app can reach + mcp bridge a local stdio MCP server over the network the app can reach + iroh manage the P2P identity / pairing ticket / peer allowlist + +TOOLS + bash run shell commands + read read a file + write create or overwrite a file + edit replace a span within a file + +OPTIONS + -m, --model model id (default: ${DEFAULT_MODEL}) + --provider

model backend: ${MODEL_PROVIDERS.join(' | ')} (default: ${DEFAULT_PROVIDER}) + --base-url OpenAI-compatible base URL (required for openai-compat) + --api-key bearer key for openai-compat (or set + THUNDERBOLT_OPENAI_COMPAT_KEY; the flag wins) + --thinking reasoning depth: ${THINKING_LEVELS.join(' | ')} (default: medium) + -y, --yolo auto-approve every tool call (alias: + --dangerously-skip-permissions) + --no-tui use the plain readline REPL, not the interactive TUI + (the TUI is the default when stdout is a terminal) + -h, --help show this help and exit + -v, --version print the version and exit + +BRIDGE OPTIONS (acp / mcp) + --transport network transport: ${TRANSPORTS.join(' | ')} (default: ${DEFAULT_TRANSPORT}) + --port listen port, wss only (default: acp ${DEFAULT_BRIDGE_PORT.acp}, mcp ${DEFAULT_BRIDGE_PORT.mcp}) + -- everything after this is the stdio command to spawn + +IROH TRANSPORT (P2P, end-to-end encrypted) + thunderbolt iroh id print the ACP bridge NodeId + connection ticket + thunderbolt iroh pair print an ACP ticket to share out-of-band + thunderbolt iroh allow trust a peer (the allowlist is the auth gate) + Only allowlisted peers may drive an iroh bridge; the NodeId is an ed25519 key, + so the QUIC handshake authenticates and end-to-end encrypts every session. + iroh id|pair are ACP-only — the MCP bridge has its own NodeId, printed by + thunderbolt mcp --transport iroh on startup. + +LOGIN (device authorization grant) + thunderbolt login open the printed link (or scan the QR) and approve in the + app to bind this CLI to your account. Set THUNDERBOLT_CLOUD_URL + to point at a self-hosted backend (default ${DEFAULT_CLOUD_URL}), + or THUNDERBOLT_TOKEN to a PAT to skip the interactive flow (CI). + +EXAMPLES + thunderbolt "fix the failing test in utils.ts" + thunderbolt --thinking high "refactor the auth module" + thunderbolt --yolo "run the test suite and fix what breaks" + thunderbolt + THUNDERBOLT_OPENAI_COMPAT_KEY=sk-… thunderbolt --provider openai-compat --base-url https://host/v1 --model my-model "hello" + thunderbolt acp --transport wss -- npx @zed-industries/claude-code-acp + thunderbolt mcp --transport wss --port 9001 -- uvx mcp-server-fetch + thunderbolt acp --transport iroh -- thunderbolt acp serve # share THIS agent + thunderbolt acp --transport iroh -- npx @zed-industries/claude-code-acp + thunderbolt iroh id + thunderbolt acp connect endpoint1abc… # dial a remote iroh bridge + +Requires ANTHROPIC_API_KEY (https://console.anthropic.com) for the default +anthropic provider; openai-compat instead uses --api-key / +THUNDERBOLT_OPENAI_COMPAT_KEY.` + +/** Type guard: is `value` one of the supported {@link ThinkingLevel}s? */ +const isThinkingLevel = (value: string): value is ThinkingLevel => + (THINKING_LEVELS as readonly string[]).includes(value) + +/** Type guard: is `value` one of the supported {@link ModelProvider}s? */ +const isProvider = (value: string): value is ModelProvider => (MODEL_PROVIDERS as readonly string[]).includes(value) + +/** Flag/positional state accumulated while scanning argv. */ +type Flags = { + readonly model: string + readonly yolo: boolean + readonly noTui: boolean + readonly thinking: ThinkingLevel + readonly provider: ModelProvider + readonly baseUrl?: string + readonly apiKey?: string + readonly positionals: readonly string[] +} + +const DEFAULT_FLAGS: Flags = { + model: DEFAULT_MODEL, + yolo: false, + noTui: false, + thinking: 'medium', + provider: DEFAULT_PROVIDER, + positionals: [], +} + +/** Outcome of scanning argv: resolved flags, or a parse error message. */ +type ScanResult = { readonly ok: true; readonly flags: Flags } | { readonly ok: false; readonly message: string } + +/** + * Recursively folds the token stream into {@link Flags}. Value-taking flags + * consume the following token; anything unrecognized becomes a positional. + */ +const scanTokens = (tokens: readonly string[], index: number, flags: Flags): ScanResult => { + if (index >= tokens.length) return { ok: true, flags } + + const token = tokens[index] + const next = tokens[index + 1] + + if (token === '--model' || token === '-m') { + if (next === undefined) return { ok: false, message: 'thunderbolt: --model requires a value' } + return scanTokens(tokens, index + 2, { ...flags, model: next }) + } + + if (token === '--provider') { + if (next === undefined) return { ok: false, message: 'thunderbolt: --provider requires a value' } + if (!isProvider(next)) { + return { + ok: false, + message: `thunderbolt: invalid --provider '${next}' (expected one of: ${MODEL_PROVIDERS.join(', ')})`, + } + } + return scanTokens(tokens, index + 2, { ...flags, provider: next }) + } + + if (token === '--base-url') { + if (next === undefined) return { ok: false, message: 'thunderbolt: --base-url requires a value' } + return scanTokens(tokens, index + 2, { ...flags, baseUrl: next }) + } + + if (token === '--api-key') { + if (next === undefined) return { ok: false, message: 'thunderbolt: --api-key requires a value' } + return scanTokens(tokens, index + 2, { ...flags, apiKey: next }) + } + + if (token === '--thinking') { + if (next === undefined) return { ok: false, message: 'thunderbolt: --thinking requires a value' } + if (!isThinkingLevel(next)) { + return { + ok: false, + message: `thunderbolt: invalid --thinking level '${next}' (expected one of: ${THINKING_LEVELS.join(', ')})`, + } + } + return scanTokens(tokens, index + 2, { ...flags, thinking: next }) + } + + if (token === '--yolo' || token === '-y' || token === '--dangerously-skip-permissions') { + return scanTokens(tokens, index + 1, { ...flags, yolo: true }) + } + + if (token === '--no-tui') { + return scanTokens(tokens, index + 1, { ...flags, noTui: true }) + } + + return scanTokens(tokens, index + 1, { ...flags, positionals: [...flags.positionals, token] }) +} + +/** Bridge flag state accumulated while scanning the tokens before `--`. */ +type BridgeFlags = { readonly transport: BridgeTransport; readonly port: number } + +type BridgeScanResult = + | { readonly ok: true; readonly flags: BridgeFlags } + | { readonly ok: false; readonly message: string } + +/** Type guard: is `value` a supported {@link BridgeTransport}? */ +const isTransport = (value: string): value is BridgeTransport => (TRANSPORTS as readonly string[]).includes(value) + +/** + * Folds the bridge flag tokens (everything before the `--` separator) into + * {@link BridgeFlags}. Only `--transport` and `--port` are recognized; an + * unknown token is reported as a likely missing `--` before the agent command. + */ +const scanBridgeFlags = (tokens: readonly string[], index: number, flags: BridgeFlags): BridgeScanResult => { + if (index >= tokens.length) return { ok: true, flags } + + const token = tokens[index] + const next = tokens[index + 1] + + if (token === '--transport') { + if (next === undefined) return { ok: false, message: 'thunderbolt: --transport requires a value' } + if (!isTransport(next)) { + return { + ok: false, + message: `thunderbolt: invalid --transport '${next}' (expected one of: ${TRANSPORTS.join(', ')})`, + } + } + return scanBridgeFlags(tokens, index + 2, { ...flags, transport: next }) + } + + if (token === '--port') { + if (next === undefined) return { ok: false, message: 'thunderbolt: --port requires a value' } + // Digits-only so `0x10`/`1e3`/` 5 ` don't slip past `Number` coercion. + if (!/^\d+$/.test(next) || Number(next) > 65535) { + return { ok: false, message: `thunderbolt: invalid --port '${next}' (expected an integer 0-65535)` } + } + return scanBridgeFlags(tokens, index + 2, { ...flags, port: Number(next) }) + } + + return { + ok: false, + message: `thunderbolt: unrecognized bridge option '${token}' (did you forget '--' before the command?)`, + } +} + +/** + * Parses an `acp`/`mcp` bridge invocation. Splits the tokens at the first `--` + * separator: flags before it, the stdio command to spawn after it. + */ +const parseBridgeArgs = (protocol: BridgeProtocol, rest: string[]): ParsedArgs => { + const separator = rest.indexOf('--') + const flagTokens = separator === -1 ? rest : rest.slice(0, separator) + const command = separator === -1 ? [] : rest.slice(separator + 1) + + if (flagTokens.includes('--help') || flagTokens.includes('-h')) return { kind: 'help' } + + const scan = scanBridgeFlags(flagTokens, 0, { transport: DEFAULT_TRANSPORT, port: DEFAULT_BRIDGE_PORT[protocol] }) + if (!scan.ok) return { kind: 'error', message: scan.message } + + if (command.length === 0) { + return { + kind: 'error', + message: `thunderbolt ${protocol}: missing agent command (e.g. thunderbolt ${protocol} --transport ${DEFAULT_TRANSPORT} -- )`, + } + } + + const config: BridgeConfig = { protocol, transport: scan.flags.transport, port: scan.flags.port, command } + return { kind: 'bridge', config } +} + +/** + * Parses an `acp`/`mcp connect` invocation: dial a remote iroh bridge by ticket + * or NodeId, optionally spawning a local client after `--`. The first token is + * the dial target; everything after `--` is the local stdio command. + */ +const parseConnectArgs = (protocol: BridgeProtocol, rest: string[]): ParsedArgs => { + if (rest.includes('--help') || rest.includes('-h')) return { kind: 'help' } + + const separator = rest.indexOf('--') + const before = separator === -1 ? rest : rest.slice(0, separator) + const command = separator === -1 ? [] : rest.slice(separator + 1) + + const target = before[0] + if (target === undefined) { + return { kind: 'error', message: `thunderbolt ${protocol} connect: missing to dial` } + } + if (before.length > 1) { + return { + kind: 'error', + message: `thunderbolt ${protocol} connect: unexpected argument '${before[1]}' (did you forget '--' before the command?)`, + } + } + + return { kind: 'connect', config: { protocol, target, command } } +} + +/** + * Resolves the openai-compat bearer key: the explicit `--api-key` flag wins, + * else the dedicated `THUNDERBOLT_OPENAI_COMPAT_KEY` env var. Returns `undefined` + * when neither is set (the anthropic provider ignores it; `resolveModel` rejects + * openai-compat). + * + * Deliberately scoped to a dedicated var rather than falling back to a standard + * key like `OPENAI_API_KEY`: openai-compat sends this key to an arbitrary + * `--base-url`, so auto-forwarding a real OpenAI key to a third-party host would + * leak credentials. `THUNDERBOLT_OPENAI_COMPAT_KEY` is the explicit opt-in for + * "this key is meant for whatever host I point at". + * + * @param flagApiKey - the value passed via `--api-key`, if any + * @returns the resolved api key, or `undefined` + */ +const resolveApiKey = (flagApiKey?: string): string | undefined => + flagApiKey ?? process.env.THUNDERBOLT_OPENAI_COMPAT_KEY + +/** + * Parses an `acp serve` invocation: run the built-in agent as a stdio ACP + * server. Reuses the run-flag scanner (`--model`/`--provider`/`--base-url`/ + * `--api-key`/`--thinking`/`--yolo`); the per-session working directory comes + * from each ACP `session/new`, so `cwd` here is just the process default. No + * positional prompt is accepted. + */ +const parseServeArgs = (rest: string[]): ParsedArgs => { + if (rest.includes('--help') || rest.includes('-h')) return { kind: 'help' } + + const scan = scanTokens(rest, 0, DEFAULT_FLAGS) + if (!scan.ok) return { kind: 'error', message: scan.message } + if (scan.flags.positionals.length > 0) { + return { kind: 'error', message: `thunderbolt acp serve: unexpected argument '${scan.flags.positionals[0]}'` } + } + + const config: ServeConfig = { + model: scan.flags.model, + cwd: process.cwd(), + yolo: scan.flags.yolo, + thinking: scan.flags.thinking, + provider: scan.flags.provider, + baseUrl: scan.flags.baseUrl, + apiKey: resolveApiKey(scan.flags.apiKey), + } + return { kind: 'acp-serve', config } +} + +/** + * Parses a `thunderbolt login` invocation. Takes no positional arguments — the + * device-authorization flow is fully interactive (or driven headlessly by the + * `THUNDERBOLT_TOKEN` PAT), so anything extra is a usage error. + */ +const parseLoginArgs = (rest: string[]): ParsedArgs => { + if (rest.includes('--help') || rest.includes('-h')) return { kind: 'help' } + if (rest.length > 0) return { kind: 'error', message: `thunderbolt login: unexpected argument '${rest[0]}'` } + return { kind: 'login' } +} + +/** + * Parses a `thunderbolt iroh` admin invocation into its sub-action + * (`id` | `pair` | `allow `). + */ +const parseIrohAdminArgs = (rest: string[]): ParsedArgs => { + const action = rest[0] + if (action === undefined || action === '--help' || action === '-h') return { kind: 'help' } + if (action === 'id') return { kind: 'iroh-admin', action: { kind: 'id' } } + if (action === 'pair') return { kind: 'iroh-admin', action: { kind: 'pair' } } + if (action === 'allow') { + const nodeId = rest[1] + if (nodeId === undefined) return { kind: 'error', message: 'thunderbolt iroh allow: missing ' } + return { kind: 'iroh-admin', action: { kind: 'allow', nodeId } } + } + return { + kind: 'error', + message: `thunderbolt iroh: unknown action '${action}' (expected: id | pair | allow )`, + } +} + +/** + * Parses CLI arguments (already stripped of runtime/script — pass + * `Bun.argv.slice(2)`) into a {@link ParsedArgs}. Pure: no I/O, no exits. + * + * @param argv - the raw argument tokens + * @returns a terminal info action (`help`/`version`/`error`), a `run` with a + * fully-resolved {@link RunConfig}, or a `bridge` with a {@link BridgeConfig} + */ +export const parseArgs = (argv: string[]): ParsedArgs => { + const subcommand = argv[0] + if (subcommand === 'login') return parseLoginArgs(argv.slice(1)) + if (subcommand === 'iroh') return parseIrohAdminArgs(argv.slice(1)) + if (subcommand === 'acp' || subcommand === 'mcp') { + if (subcommand === 'acp' && argv[1] === 'serve') return parseServeArgs(argv.slice(2)) + if (argv[1] === 'connect') return parseConnectArgs(subcommand, argv.slice(2)) + return parseBridgeArgs(subcommand, argv.slice(1)) + } + + if (argv.includes('--help') || argv.includes('-h')) return { kind: 'help' } + if (argv.includes('--version') || argv.includes('-v')) return { kind: 'version' } + + const tokens = subcommand === 'agent' ? argv.slice(1) : argv + const scan = scanTokens(tokens, 0, DEFAULT_FLAGS) + if (!scan.ok) return { kind: 'error', message: scan.message } + + const prompt = scan.flags.positionals.join(' ') + const base = { + model: scan.flags.model, + cwd: process.cwd(), + yolo: scan.flags.yolo, + thinking: scan.flags.thinking, + provider: scan.flags.provider, + baseUrl: scan.flags.baseUrl, + apiKey: resolveApiKey(scan.flags.apiKey), + } + const config: RunConfig = + prompt.length > 0 ? { ...base, mode: 'oneshot', prompt } : { ...base, mode: 'repl', noTui: scan.flags.noTui } + return { kind: 'run', config } +} diff --git a/cli/src/commands/bridge.test.ts b/cli/src/commands/bridge.test.ts new file mode 100644 index 000000000..89781b71b --- /dev/null +++ b/cli/src/commands/bridge.test.ts @@ -0,0 +1,169 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Regression coverage for the loopback bridge's upgrade gate — the control that + * stops a drive-by web page from connecting to `ws://127.0.0.1:` and + * driving host RCE. Every path, origin, and token combination an attacker could + * present must be rejected before `srv.upgrade`; only an allowlisted origin with + * the exact per-run token may pass. + */ + +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test' +import type { BridgeProc } from './bridge.ts' +import { + atProcCapacity, + authorizeUpgrade, + bridgeAllowedOrigins, + forwardFrameToStdin, + generateBridgeToken, + MAX_ACTIVE_PROCS, +} from './bridge.ts' + +const TOKEN = generateBridgeToken() +const ORIGINS = bridgeAllowedOrigins() + +/** Build an upgrade request at the given path/query with an optional Origin. */ +const upgradeRequest = (path: string, origin?: string): Request => + new Request(`http://127.0.0.1:8839${path}`, origin === undefined ? undefined : { headers: { origin } }) + +describe('authorizeUpgrade', () => { + test('accepts an allowlisted origin with the exact token', () => { + const req = upgradeRequest(`/?token=${TOKEN}`, 'http://localhost:1420') + expect(authorizeUpgrade(req, TOKEN, ORIGINS)).toEqual({ ok: true }) + }) + + test('accepts every built-in app origin', () => { + for (const origin of ['http://localhost:1420', 'tauri://localhost', 'http://tauri.localhost']) { + const req = upgradeRequest(`/?token=${TOKEN}`, origin) + expect(authorizeUpgrade(req, TOKEN, ORIGINS).ok).toBe(true) + } + }) + + test('rejects a missing Origin header (non-browser / drive-by client)', () => { + const req = upgradeRequest(`/?token=${TOKEN}`) + expect(authorizeUpgrade(req, TOKEN, ORIGINS)).toEqual({ ok: false, status: 403, reason: "forbidden origin '(none)'" }) + }) + + test('rejects an off-allowlist origin even with the correct token', () => { + const req = upgradeRequest(`/?token=${TOKEN}`, 'https://evil.example') + const decision = authorizeUpgrade(req, TOKEN, ORIGINS) + expect(decision).toEqual({ ok: false, status: 403, reason: "forbidden origin 'https://evil.example'" }) + }) + + test('rejects a missing token from an allowlisted origin', () => { + const req = upgradeRequest('/', 'http://localhost:1420') + expect(authorizeUpgrade(req, TOKEN, ORIGINS)).toEqual({ ok: false, status: 401, reason: 'missing or invalid token' }) + }) + + test('rejects a wrong token of equal length', () => { + const wrong = 'f'.repeat(TOKEN.length) + const req = upgradeRequest(`/?token=${wrong}`, 'http://localhost:1420') + expect(authorizeUpgrade(req, TOKEN, ORIGINS)).toEqual({ ok: false, status: 401, reason: 'missing or invalid token' }) + }) + + test('rejects a token of the wrong length (constant-time compare guard)', () => { + const req = upgradeRequest('/?token=short', 'http://localhost:1420') + expect(authorizeUpgrade(req, TOKEN, ORIGINS).ok).toBe(false) + }) + + test('rejects any non-root path even with valid origin and token', () => { + const req = upgradeRequest(`/admin?token=${TOKEN}`, 'http://localhost:1420') + expect(authorizeUpgrade(req, TOKEN, ORIGINS)).toEqual({ ok: false, status: 404, reason: 'unknown path (only / is bridged)' }) + }) +}) + +describe('generateBridgeToken', () => { + test('is 256 bits of hex and unique per call', () => { + const a = generateBridgeToken() + const b = generateBridgeToken() + expect(a).toMatch(/^[0-9a-f]{64}$/) + expect(a).not.toBe(b) + }) +}) + +describe('bridgeAllowedOrigins', () => { + afterEach(() => { + delete process.env.THUNDERBOLT_APP_ORIGIN + }) + + test('contains the built-in origins and nothing else by default', () => { + delete process.env.THUNDERBOLT_APP_ORIGIN + expect([...bridgeAllowedOrigins()].sort()).toEqual( + ['http://localhost:1420', 'http://tauri.localhost', 'tauri://localhost'].sort(), + ) + }) + + test('adds comma-separated origins from THUNDERBOLT_APP_ORIGIN, trimming blanks', () => { + process.env.THUNDERBOLT_APP_ORIGIN = 'https://app.thunderbolt.example, , https://beta.example' + const origins = bridgeAllowedOrigins() + expect(origins.has('https://app.thunderbolt.example')).toBe(true) + expect(origins.has('https://beta.example')).toBe(true) + expect(origins.has('')).toBe(false) + }) +}) + +describe('atProcCapacity — shared live-subprocess cap', () => { + const procs = (n: number): Set => new Set(Array.from({ length: n }, () => ({}) as BridgeProc)) + + test('allows work below the ceiling and refuses at or above it', () => { + expect(atProcCapacity(procs(0))).toBe(false) + expect(atProcCapacity(procs(MAX_ACTIVE_PROCS - 1))).toBe(false) + expect(atProcCapacity(procs(MAX_ACTIVE_PROCS))).toBe(true) + expect(atProcCapacity(procs(MAX_ACTIVE_PROCS + 1))).toBe(true) + }) +}) + +describe('forwardFrameToStdin', () => { + let stderr: ReturnType + beforeEach(() => { + stderr = spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + afterEach(() => { + stderr.mockRestore() + }) + + /** A fake subprocess whose stdin write/flush are controllable. */ + const fakeProc = (flush: () => Promise): { proc: BridgeProc; write: ReturnType } => { + const write = mock(() => 1) + return { proc: { stdin: { write, flush } } as unknown as BridgeProc, write } + } + + test('appends the ndjson newline, writes, and awaits the flush; never closes on success', async () => { + const { proc, write } = fakeProc(async () => 0) + const close = mock((_code: number, _reason: string) => {}) + await forwardFrameToStdin(proc, '{"jsonrpc":"2.0"}', close) + expect(write.mock.calls[0][0]).toBe('{"jsonrpc":"2.0"}\n') + expect(close).not.toHaveBeenCalled() + }) + + test('logs loudly and closes 1011 when the flush fails (EPIPE on a dead pipe)', async () => { + const { proc } = fakeProc(async () => { + throw new Error('EPIPE') + }) + const close = mock((_code: number, _reason: string) => {}) + await forwardFrameToStdin(proc, 'frame', close) + expect(close).toHaveBeenCalledTimes(1) + expect(close.mock.calls[0][0]).toBe(1011) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0][0])).toContain('stdin write failed') + }) + + test('closes 1011 when the synchronous write itself throws', async () => { + const flush = mock(async () => 0) + const proc = { + stdin: { + write: mock(() => { + throw new Error('write blew up') + }), + flush, + }, + } as unknown as BridgeProc + const close = mock((_code: number, _reason: string) => {}) + await forwardFrameToStdin(proc, 'frame', close) + expect(flush).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledTimes(1) + expect(close.mock.calls[0][0]).toBe(1011) + }) +}) diff --git a/cli/src/commands/bridge.ts b/cli/src/commands/bridge.ts new file mode 100644 index 000000000..c74083252 --- /dev/null +++ b/cli/src/commands/bridge.ts @@ -0,0 +1,329 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Transparent stdio ↔ WebSocket bridge for ACP agents and MCP servers. + * + * Spawns a local JSON-RPC-over-stdio process and exposes it over a WebSocket so + * the in-browser app can reach it as a `remote-acp` agent / remote MCP server. + * The pump is framing-aware on both edges: + * - WS → stdin: every WebSocket frame is exactly one JSON-RPC object (the app + * sends `JSON.stringify(msg)` per frame), so we forward it verbatim plus the + * trailing newline that the ndjson stdio framing requires. + * - stdout → WS: the process emits newline-delimited JSON, so we split on + * newlines and send each complete object as its own WebSocket frame — what + * the app's transport expects (`JSON.parse(event.data)` once per message). + * + * Lifecycle is 1:1: each WebSocket connection spawns its own dedicated process; + * closing the socket kills the process, and a process exit closes the socket. + * ACP and MCP share this exact pump (both speak ndjson JSON-RPC over stdio). + */ + +import { randomBytes, timingSafeEqual } from 'node:crypto' +import type { ServerWebSocket, Subprocess } from 'bun' +import type { BridgeConfig } from '../agent/types.ts' + +/** Per-connection state: the spawned process this socket is pumping. `null` + * only in the instant between upgrade and `open` (or after a spawn failure). */ +type BridgeSocketData = { + proc: Subprocess<'pipe', 'pipe', 'inherit'> | null +} + +type BridgeSocket = ServerWebSocket + +/** A bridged stdio subprocess: piped stdin/stdout, inherited stderr. Shared by + * the WebSocket and iroh transports, which pump it identically. */ +export type BridgeProc = Subprocess<'pipe', 'pipe', 'inherit'> + +/** One-shot decoder for the rare binary inbound frame. Safe to share: it's only + * ever called without `{ stream: true }`, so it holds no cross-call state. The + * stdout pump deliberately uses its own decoder (streaming state must not be + * shared across concurrent connections). */ +const frameDecoder = new TextDecoder() + +/** + * Pump a process's newline-delimited-JSON stdout to the socket, one JSON object + * per WebSocket frame. Resolves when stdout closes (the process exited or was + * killed). A trailing object without a final newline is flushed at close. + */ +const pumpStdoutToSocket = async (proc: BridgeProc, ws: BridgeSocket): Promise => { + // Per-pump decoder: `{ stream: true }` retains partial-multibyte state, which + // would corrupt other connections if shared. Each connection gets its own. + const decoder = new TextDecoder() + const reader = proc.stdout.getReader() + let buffer = '' + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + const trimmed = line.trim() + if (trimmed) ws.send(trimmed) + } + } + buffer += decoder.decode() + const trailing = buffer.trim() + if (trailing) ws.send(trailing) +} + +/** + * Forward one inbound WebSocket frame to the agent's stdin, appending the newline + * the ndjson stdio framing requires. The flush is awaited only so a flush-time + * failure surfaces in the catch (not for WS-read backpressure — the caller fires + * this off with `void`). A write/flush failure — most commonly `EPIPE` when a + * frame arrives after the agent has exited and closed its stdin — is logged loudly + * and closes the socket abnormally (1011) instead of throwing uncaught and downing + * the bridge. Mirrors the iroh pump's `writeToStdin`: a dead pipe is a real IO + * boundary, not a defensive guard on trusted data. + */ +export const forwardFrameToStdin = async ( + proc: BridgeProc, + text: string, + close: (code: number, reason: string) => void, +): Promise => { + try { + proc.stdin.write(text + '\n') + await proc.stdin.flush() + } catch (err) { + process.stderr.write( + `thunderbolt bridge: stdin write failed: ${err instanceof Error ? err.message : String(err)}\n`, + ) + close(1011, 'agent stdin closed') + } +} + +/** Spawn the bridged agent, returning `null` if the executable can't be found + * (the parser guarantees a non-empty command). Bun.spawn throws synchronously + * on ENOENT — catching it at this connection boundary keeps one bad command + * from killing the whole server, and surfaces the reason on the operator's + * stderr so the failure is loud rather than silent. */ +export const spawnAgent = (command: readonly string[]): BridgeProc | null => { + try { + return Bun.spawn({ cmd: [...command], stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }) + } catch (err) { + process.stderr.write( + `thunderbolt bridge: failed to spawn '${command[0]}': ${err instanceof Error ? err.message : String(err)}\n`, + ) + return null + } +} + +/** Hard ceiling on concurrently-live bridged subprocesses per bridge server, + * shared by both transports. The handshake gates (rate limit + concurrent- + * handshake cap) bound *connection setup*, not established sessions — so an + * allowlisted/authorized peer holding many connections open would otherwise + * spawn unbounded long-lived agents and exhaust the host. At the cap a new + * connection is refused instead of spawning. Generous enough that real + * concurrent use never hits it. */ +export const MAX_ACTIVE_PROCS = 16 + +/** Whether a bridge is at its live-subprocess ceiling and must refuse new work. + * The single source of the cap policy for both the WebSocket and iroh bridges. */ +export const atProcCapacity = (activeProcs: ReadonlySet): boolean => activeProcs.size >= MAX_ACTIVE_PROCS + +/** Bearer-style flags whose *following* argv element is a secret to hide. */ +const SECRET_FLAGS = new Set(['--api-key', '--token']) +/** An env-style `NAME=value` token whose NAME looks like a credential (ends in + * one of `KEY`/`TOKEN`/`SECRET`/`PASSWORD`/`PASSWD`/`CREDENTIAL`/`CRED(S)`, e.g. + * `OPENAI_API_KEY=sk-…`, `GITHUB_TOKEN=ghp_…`, `DB_SECRET=…`). Case-sensitive + * uppercase names only, so benign lowercase words like `monkey=foo` are left + * alone; the optional `[A-Z0-9_]*` prefix lets a bare `PASSWORD=…` match too. */ +const KEY_ASSIGNMENT = /^[A-Z0-9_]*(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|CREDS?)=/ + +/** Redact the value of an env-style credential assignment, leaving its name. */ +const redactKeyAssignment = (arg: string): string => (KEY_ASSIGNMENT.test(arg) ? arg.replace(/=.*/s, '=***') : arg) + +/** Redact a joined `--flag=value` secret (e.g. `--api-key=sk-…` / `--token=ghp_…`), + * keeping the flag name and hiding everything after the first `=`. Returns `null` + * when the prefix isn't a secret flag, so callers can fall through. */ +const redactJoinedFlag = (arg: string): string | null => { + const eq = arg.indexOf('=') + return eq !== -1 && SECRET_FLAGS.has(arg.slice(0, eq)) ? `${arg.slice(0, eq)}=***` : null +} + +/** Redact a single argv element in place: joined secret flag, else env assignment. */ +const redactArg = (arg: string): string => redactJoinedFlag(arg) ?? redactKeyAssignment(arg) + +/** + * Render an argv as a single space-joined string with credentials redacted, for + * logging the bridged command without leaking secrets to stdout/scrollback/CI. + * Everything after `--` can carry a bearer token (e.g. an openai-compat agent's + * `--api-key sk-…`); this hides the value following a bare `--api-key`/`--token`, + * the tail of a joined `--api-key=sk-…`/`--token=…`, and any credential-looking + * uppercase env assignment (`*KEY`/`*TOKEN`/`*SECRET`/`*PASSWORD`/…), replacing it + * with `***`. Pure and total — a trailing bare secret + * flag with no following value is simply left as-is. + */ +export const redactArgv = (argv: readonly string[]): string => + argv + .reduce<{ out: string[]; hide: boolean }>( + (acc, arg) => + acc.hide + ? { out: [...acc.out, '***'], hide: false } + : { out: [...acc.out, redactArg(arg)], hide: SECRET_FLAGS.has(arg) }, + { out: [], hide: false }, + ) + .out.join(' ') + +/** Origins the Thunderbolt app's webview presents as `Origin` on the WebSocket + * handshake: the Vite dev server plus the native Tauri webview origins (which + * vary by OS). A drive-by page the user visits in a normal browser cannot forge + * any of these. A self-hosted/cloud build adds its own origin(s) via the + * comma-separated `THUNDERBOLT_APP_ORIGIN`. */ +const DEFAULT_APP_ORIGINS = ['http://localhost:1420', 'tauri://localhost', 'http://tauri.localhost'] as const + +/** + * The set of `Origin` header values allowed to upgrade the loopback bridge: the + * built-in app origins plus any configured via `THUNDERBOLT_APP_ORIGIN`. + */ +export const bridgeAllowedOrigins = (): ReadonlySet => { + const configured = (process.env.THUNDERBOLT_APP_ORIGIN ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0) + return new Set([...DEFAULT_APP_ORIGINS, ...configured]) +} + +/** Mint the per-run bridge secret: 256 bits of CSPRNG entropy, hex-encoded. It is + * printed once as the `token` query param of the listen URL and required on every + * upgrade, so only a client the user explicitly handed that URL to can connect. */ +export const generateBridgeToken = (): string => randomBytes(32).toString('hex') + +/** Constant-time token comparison. A length mismatch short-circuits to `false` + * (`timingSafeEqual` throws on unequal lengths); the token length is fixed and + * non-secret, so the early return leaks nothing exploitable. */ +const tokensMatch = (presented: string, expected: string): boolean => { + const a = Buffer.from(presented) + const b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) +} + +/** Accept an upgrade, or reject it with an HTTP status + operator-facing reason. */ +export type UpgradeDecision = { readonly ok: true } | { readonly ok: false; readonly status: number; readonly reason: string } + +/** + * Authorize a WebSocket upgrade for the loopback bridge. Three independent gates, + * all required: the request path must be exactly `/`, the `Origin` header must be + * an allowlisted Thunderbolt app origin, and the `token` query param must equal + * the per-run secret (constant-time compared). + * + * This closes the drive-by host-RCE vector. The bridge spawns the host coding + * agent (bash) per connection, and WebSocket upgrades bypass CORS — so without + * these checks any site the user visits while the bridge runs could connect to + * `ws://127.0.0.1:` and drive arbitrary host execution (the ACP permission + * gate is useless against a malicious client that auto-approves). A drive-by page + * can neither guess the token nor forge an allowlisted `Origin`, so it never + * reaches `srv.upgrade`. + */ +export const authorizeUpgrade = ( + req: Request, + token: string, + allowedOrigins: ReadonlySet, +): UpgradeDecision => { + const url = new URL(req.url) + if (url.pathname !== '/') return { ok: false, status: 404, reason: 'unknown path (only / is bridged)' } + + const origin = req.headers.get('origin') + if (!origin || !allowedOrigins.has(origin)) { + return { ok: false, status: 403, reason: `forbidden origin '${origin ?? '(none)'}'` } + } + + const presented = url.searchParams.get('token') + if (!presented || !tokensMatch(presented, token)) return { ok: false, status: 401, reason: 'missing or invalid token' } + + return { ok: true } +} + +/** + * Start the bridge server: listen for WebSocket connections and pump each one + * to its own freshly-spawned `config.command` process. Returns once the server + * is listening; the live server keeps the process alive until interrupted. + */ +export const runBridge = async (config: BridgeConfig): Promise => { + const token = generateBridgeToken() + const allowedOrigins = bridgeAllowedOrigins() + // Cap concurrently-live agents: the upgrade gate authorizes *connections*, not + // sessions, so an authorized client holding many sockets open would otherwise + // spawn unbounded bash processes. At the ceiling a new socket is refused. + const activeProcs = new Set() + const server = Bun.serve({ + // Loopback-only: this transport is unauthenticated, so it must not be + // reachable from the LAN (Bun's default binds every interface). The G3 + // consumer is the app running on the same machine; authenticated remote + // access is the separate iroh transport (G4). + hostname: '127.0.0.1', + port: config.port, + fetch(req, srv) { + // Loopback alone is not a security boundary: WebSocket upgrades bypass CORS, + // so any page the user visits can reach this port. Gate every upgrade on an + // allowlisted Origin + the unguessable per-run token before spawning bash. + const decision = authorizeUpgrade(req, token, allowedOrigins) + if (!decision.ok) return new Response(`thunderbolt bridge: ${decision.reason}\n`, { status: decision.status }) + if (srv.upgrade(req, { data: { proc: null } })) return undefined + return new Response('thunderbolt bridge: WebSocket endpoint only\n', { status: 426 }) + }, + websocket: { + open(ws) { + if (atProcCapacity(activeProcs)) { + ws.close(1013, 'bridge at capacity') + return + } + const proc = spawnAgent(config.command) + if (!proc) { + ws.close(1011, `failed to spawn '${config.command[0]}'`) + return + } + activeProcs.add(proc) + void proc.exited.then(() => activeProcs.delete(proc)) + ws.data.proc = proc + void (async () => { + try { + await pumpStdoutToSocket(proc, ws) + } catch (err) { + process.stderr.write( + `thunderbolt bridge: stdout pump error: ${err instanceof Error ? err.message : String(err)}\n`, + ) + } + })() + void (async () => { + const code = await proc.exited + // 1000 (normal) only on a clean exit; a non-zero/killed exit is an + // abnormal close (1011) so the app surfaces it instead of seeing + // a successful shutdown. + ws.close(code === 0 ? 1000 : 1011, `agent exited (code ${code})`) + })() + }, + message(ws, message) { + const proc = ws.data.proc + if (!proc) return + const text = typeof message === 'string' ? message : frameDecoder.decode(message) + void forwardFrameToStdin(proc, text, (code, reason) => ws.close(code, reason)) + }, + close(ws) { + ws.data.proc?.kill() + }, + }, + }) + + // A signal stops the server with `closeActiveConnections`, which fires each + // socket's `close` handler and so kills every spawned agent before exit. + const shutdown = (): void => { + server.stop(true) + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + + // Advertise the exact bound address (not `localhost`, which can resolve to + // the IPv6 `::1` and miss this IPv4-only loopback bind). The token rides in the + // URL, so pasting it whole into the app authenticates with no client change. + const url = `ws://127.0.0.1:${server.port}/?token=${token}` + process.stdout.write( + `⚡ thunderbolt ${config.protocol} bridge (${config.transport}) listening on ws://127.0.0.1:${server.port}\n` + + ` spawning per connection: ${redactArgv(config.command)}\n` + + ` set this as the agent URL in the app (includes the access token): ${url}\n`, + ) +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 000000000..89b1fbda5 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env bun +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Binary entrypoint. Parses argv, dispatches the terminal info actions + * (`help`/`version`/`error`) inline, and runs the agent inside the single + * top-level try/catch that turns any uncaught failure into a clean exit. + */ + +import { HELP_TEXT, VERSION, parseArgs } from './cli.ts' +import { runAgent } from './agent/run.ts' +import { runAcpServe } from './acp/serve.ts' +import { runBridge } from './commands/bridge.ts' +import { runIrohBridge } from './iroh/bridge.ts' +import { runIrohConnect } from './iroh/connect.ts' +import { runIrohAdmin } from './iroh/admin.ts' +import { runLogin } from './auth/login.ts' + +const parsed = parseArgs(Bun.argv.slice(2)) + +switch (parsed.kind) { + case 'help': + console.log(HELP_TEXT) + break + case 'version': + console.log(VERSION) + break + case 'error': + process.stderr.write(parsed.message + '\n') + process.exitCode = 1 + break + case 'run': + try { + await runAgent(parsed.config) + } catch (err) { + process.stderr.write(`thunderbolt: ${err instanceof Error ? err.message : String(err)}\n`) + process.exitCode = 1 + } + break + case 'bridge': + try { + if (parsed.config.transport === 'iroh') await runIrohBridge(parsed.config) + else await runBridge(parsed.config) + } catch (err) { + process.stderr.write(`thunderbolt: ${err instanceof Error ? err.message : String(err)}\n`) + process.exitCode = 1 + } + break + case 'connect': + try { + await runIrohConnect(parsed.config) + } catch (err) { + process.stderr.write(`thunderbolt: ${err instanceof Error ? err.message : String(err)}\n`) + process.exitCode = 1 + } + break + case 'acp-serve': + try { + await runAcpServe(parsed.config) + } catch (err) { + process.stderr.write(`thunderbolt: ${err instanceof Error ? err.message : String(err)}\n`) + process.exitCode = 1 + } + break + case 'iroh-admin': + try { + await runIrohAdmin(parsed.action) + } catch (err) { + process.stderr.write(`thunderbolt: ${err instanceof Error ? err.message : String(err)}\n`) + process.exitCode = 1 + } + break + case 'login': + try { + await runLogin() + } catch (err) { + process.stderr.write(`thunderbolt: ${err instanceof Error ? err.message : String(err)}\n`) + process.exitCode = 1 + } + break +} diff --git a/cli/src/iroh/account-allowlist.test.ts b/cli/src/iroh/account-allowlist.test.ts new file mode 100644 index 000000000..b1c80b6c6 --- /dev/null +++ b/cli/src/iroh/account-allowlist.test.ts @@ -0,0 +1,198 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the account-scoped allowlist (D2/D8): the credential-fetched, in-memory + * cache of the account's trusted NodeIds. Covers the wire contract (URL, auth header + * per credential kind, body parsing), the cache swap on refresh, the soft-fail on a + * transient fetch error (keep last-known-good, never throw), and self-revocation + * (the bridge's own NodeId dropping out of a populated list disables auto-trust). + * The fetch seam is injected — no network. + */ + +import { describe, expect, it, mock, spyOn } from 'bun:test' +import type { BridgeCredential } from '../auth/token-store.ts' +import { createAccountAllowlist, fetchAccountAllowlist, type FetchFn } from './account-allowlist.ts' + +/** Build a bridge credential for the wire tests; defaults to a device-grant session. */ +const cred = (overrides: Partial = {}): BridgeCredential => ({ + cloudUrl: 'https://api.test/v1', + token: 'signed.jwt', + kind: 'session', + ...overrides, +}) + +/** Build a JSON `Response` for the injected fetch. */ +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + +describe('fetchAccountAllowlist — wire contract', () => { + it('GETs /devices/allowlist with the session bearer and returns the node ids', async () => { + const seen: { url: string; auth: string | null; apiKey: string | null } = { url: '', auth: null, apiKey: null } + const fetchFn: FetchFn = async (url, init) => { + seen.url = url + seen.auth = new Headers(init?.headers).get('authorization') + seen.apiKey = new Headers(init?.headers).get('x-api-key') + return jsonResponse({ nodeIds: [{ nodeId: 'peer-a' }, { nodeId: 'peer-b' }] }) + } + + const ids = await fetchAccountAllowlist(cred({ token: 'signed.jwt' }), fetchFn) + + expect(seen.url).toBe('https://api.test/v1/devices/allowlist') + expect(seen.auth).toBe('Bearer signed.jwt') + expect(seen.apiKey).toBeNull() // a session never sends x-api-key + expect(ids).toEqual(['peer-a', 'peer-b']) + }) + + it('sends the PAT via x-api-key (not Authorization) for an api-key credential', async () => { + const seen: { auth: string | null; apiKey: string | null } = { auth: null, apiKey: null } + const fetchFn: FetchFn = async (_url, init) => { + seen.auth = new Headers(init?.headers).get('authorization') + seen.apiKey = new Headers(init?.headers).get('x-api-key') + return jsonResponse({ nodeIds: [] }) + } + + await fetchAccountAllowlist(cred({ token: 'pat-xyz', kind: 'apiKey' }), fetchFn) + + // The apiKey plugin authenticates ONLY via x-api-key — a bearer would 401. + expect(seen.apiKey).toBe('pat-xyz') + expect(seen.auth).toBeNull() + }) + + it('strips a trailing slash from the cloud URL before appending the path', async () => { + let seenUrl = '' + const fetchFn: FetchFn = async (url) => { + seenUrl = url + return jsonResponse({ nodeIds: [] }) + } + + await fetchAccountAllowlist(cred({ cloudUrl: 'https://api.test/v1/' }), fetchFn) + + expect(seenUrl).toBe('https://api.test/v1/devices/allowlist') + }) + + it('drops rows with a null node id (nullable column, never-bound devices)', async () => { + const fetchFn: FetchFn = async () => jsonResponse({ nodeIds: [{ nodeId: 'ok' }, { nodeId: null }] }) + expect(await fetchAccountAllowlist(cred(), fetchFn)).toEqual(['ok']) + }) + + it('throws on a non-2xx response so the caller can decide to soft-fail', async () => { + const fetchFn: FetchFn = async () => jsonResponse({ error: 'unauthorized' }, 401) + await expect(fetchAccountAllowlist(cred(), fetchFn)).rejects.toThrow(/401/) + }) + + it('passes an abort signal so a hung backend cannot block the fetch forever', async () => { + const seen: { signal: AbortSignal | null } = { signal: null } + const fetchFn: FetchFn = async (_url, init) => { + seen.signal = init?.signal ?? null + return jsonResponse({ nodeIds: [] }) + } + + await fetchAccountAllowlist(cred(), fetchFn, 5000) + + expect(seen.signal).toBeInstanceOf(AbortSignal) + }) +}) + +/** The bridge's own NodeId, included in the fetched list so cache tests aren't + * self-revoked (self-revocation is exercised in its own describe below). */ +const SELF = 'self-node' + +describe('createAccountAllowlist — in-memory cache', () => { + it('trusts no peer until the first successful refresh', () => { + const allowlist = createAccountAllowlist(async () => [SELF, 'peer'], SELF) + expect(allowlist.has('peer')).toBe(false) + }) + + it('populates the cache on refresh and trims the queried id', async () => { + const allowlist = createAccountAllowlist(async () => [SELF, 'peer-a', 'peer-b'], SELF) + await allowlist.refresh() + expect(allowlist.has('peer-a')).toBe(true) + expect(allowlist.has(' peer-b ')).toBe(true) + expect(allowlist.has('stranger')).toBe(false) + }) + + it('replaces the cache on each refresh, so a revoked id drops out', async () => { + let ids = [SELF, 'peer-a', 'peer-b'] + const allowlist = createAccountAllowlist(async () => ids, SELF) + await allowlist.refresh() + expect(allowlist.has('peer-b')).toBe(true) + + ids = [SELF, 'peer-a'] // peer-b revoked in the account + await allowlist.refresh() + expect(allowlist.has('peer-b')).toBe(false) + expect(allowlist.has('peer-a')).toBe(true) + }) + + it('soft-fails a transient fetch error: keeps the last-known set and does not throw', async () => { + const stderr = spyOn(process.stderr, 'write').mockImplementation(() => true) + let fail = false + const allowlist = createAccountAllowlist(async () => { + if (fail) throw new Error('network down') + return [SELF, 'peer-a'] + }, SELF) + + await allowlist.refresh() + expect(allowlist.has('peer-a')).toBe(true) + + fail = true + await allowlist.refresh() // must not throw + expect(allowlist.has('peer-a')).toBe(true) // last-known-good preserved + expect(stderr).toHaveBeenCalled() + stderr.mockRestore() + }) + + it('leaves the cache empty (no peer trusted) when the very first refresh fails', async () => { + const stderr = spyOn(process.stderr, 'write').mockImplementation(() => true) + const fetchFn = mock(async () => { + throw new Error('boom') + }) + const allowlist = createAccountAllowlist(fetchFn, SELF) + + await allowlist.refresh() + + expect(allowlist.has('anyone')).toBe(false) + stderr.mockRestore() + }) +}) + +describe('createAccountAllowlist — self-revocation (D8)', () => { + it('trusts no account peer once this bridge is dropped from a populated allowlist', async () => { + // The populated list omits SELF → the account revoked this bridge. + const allowlist = createAccountAllowlist(async () => ['peer-a', 'peer-b'], SELF) + await allowlist.refresh() + + expect(allowlist.isSelfRevoked()).toBe(true) + expect(allowlist.has('peer-a')).toBe(false) // account auto-trust disabled + expect(allowlist.has('peer-b')).toBe(false) + }) + + it('keeps trusting peers while this bridge is still listed', async () => { + const allowlist = createAccountAllowlist(async () => [SELF, 'peer-a'], SELF) + await allowlist.refresh() + + expect(allowlist.isSelfRevoked()).toBe(false) + expect(allowlist.has('peer-a')).toBe(true) + }) + + it('treats an empty allowlist as unknown, never self-revoked (unprimed / fetch failure)', async () => { + const allowlist = createAccountAllowlist(async () => [], SELF) + await allowlist.refresh() + + expect(allowlist.isSelfRevoked()).toBe(false) // empty ≠ revoked, so auto-trust isn't disabled on a blip + expect(allowlist.has('anyone')).toBe(false) + }) + + it('re-trusts once this bridge returns to the allowlist (re-keyed / re-attested)', async () => { + let ids = ['peer-a'] // SELF absent → revoked + const allowlist = createAccountAllowlist(async () => ids, SELF) + await allowlist.refresh() + expect(allowlist.isSelfRevoked()).toBe(true) + + ids = [SELF, 'peer-a'] // this bridge re-added + await allowlist.refresh() + expect(allowlist.isSelfRevoked()).toBe(false) + expect(allowlist.has('peer-a')).toBe(true) + }) +}) diff --git a/cli/src/iroh/account-allowlist.ts b/cli/src/iroh/account-allowlist.ts new file mode 100644 index 000000000..44aa1fac2 --- /dev/null +++ b/cli/src/iroh/account-allowlist.ts @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Account-scoped allowlist for the iroh bridge (design decision D2): the trusted, + * non-revoked NodeIds of the account this CLI logged in to. A logged-in bridge + * fetches this over REST with its stored bearer, caches it in memory, and refreshes + * it on the 45s membership heartbeat — so same-account peers are auto-trusted at the + * gate without embedding PowerSync or ever holding the E2EE Content Key. + * + * This is the auto-trust *layer*, not a replacement: it sits alongside the manual + * `iroh allow` file, which stays mandatory for Standalone / cross-account / CI. When + * the CLI has no account credential (Standalone), this client is simply absent and + * the bridge falls back to the manual file — it must never surface an error there. + */ + +import type { BridgeCredential } from '../auth/token-store.ts' + +/** The subset of `fetch` this client uses; injected so the wire contract is + * unit-testable without a real network (mirrors {@link auth/http-transport}). */ +export type FetchFn = (url: string, init?: RequestInit) => Promise + +/** `GET /devices/allowlist` 200 body: one row per trusted, non-revoked device that + * has bound an iroh identity. `nodeId` is non-null in practice (the query filters + * `node_id IS NOT NULL`); the column is nullable, so the `string | null` type lets + * us narrow to `string[]` with a type guard instead of an unchecked cast. */ +type AllowlistBody = { readonly nodeIds: ReadonlyArray<{ readonly nodeId: string | null }> } + +/** Hard ceiling on the allowlist fetch. Bounds both the startup prime (which the + * bridge awaits before accepting any peer) and every heartbeat refresh, so a hung + * backend can neither block startup nor wedge the revocation loop. */ +export const ALLOWLIST_FETCH_TIMEOUT_MS = 10_000 + +/** Build the auth header for the allowlist fetch from the credential's wire scheme: + * a device-grant session authenticates via `Authorization: Bearer`, while a Better + * Auth api key / PAT authenticates via `x-api-key` (the apiKey plugin reads ONLY + * that header — sending it as a bearer would silently 401). */ +const authHeader = (credential: BridgeCredential): Record => + credential.kind === 'apiKey' + ? { 'x-api-key': credential.token } + : { authorization: `Bearer ${credential.token}` } + +/** + * Fetch the caller account's trusted NodeIds from the backend. Credential-scoped to + * the account, so it only ever returns same-account rows. Aborts after `timeoutMs` + * and throws on abort or a non-2xx response, so the caller's refresh boundary can + * decide whether to soft-fail. + * + * @param credential - the bridge credential (token + backend + wire scheme) + * @param fetchFn - HTTP fetch (defaults to the global `fetch`) + * @param timeoutMs - abort deadline (default {@link ALLOWLIST_FETCH_TIMEOUT_MS}) + * @returns the account's trusted NodeId strings + */ +export const fetchAccountAllowlist = async ( + credential: BridgeCredential, + fetchFn: FetchFn = fetch, + timeoutMs: number = ALLOWLIST_FETCH_TIMEOUT_MS, +): Promise => { + const res = await fetchFn(`${credential.cloudUrl.replace(/\/+$/, '')}/devices/allowlist`, { + headers: authHeader(credential), + signal: AbortSignal.timeout(timeoutMs), + }) + if (!res.ok) { + throw new Error(`account allowlist fetch failed (${res.status} ${res.statusText})`) + } + const body = (await res.json()) as AllowlistBody + return body.nodeIds.map((row) => row.nodeId).filter((id): id is string => Boolean(id)) +} + +/** An in-memory account allowlist: membership check + a re-fetch that swaps the cache. */ +export type AccountAllowlist = { + /** Whether `nodeId` is trusted: present in the last successfully-fetched account + * allowlist AND this bridge is not itself self-revoked ({@link isSelfRevoked}). + * A self-revoked bridge trusts no account peer (auto-trust off); the manual file + * still governs at the gate. */ + readonly has: (nodeId: string) => boolean + /** Re-fetch and replace the cache. Soft-fails: a transient fetch error keeps the + * last-known-good set (a network blip must not tear down every same-account peer) + * and is logged, never thrown — the manual file still governs regardless. */ + readonly refresh: () => Promise + /** Whether this bridge's own NodeId has dropped out of a *populated* account + * allowlist — i.e. the account revoked this device (D8: revoking nulls its + * node_id, so it disappears from the list). An empty set is "unknown" (unprimed / + * fetch failed), never a revocation, so a transient outage can't disable + * auto-trust. While true, {@link has} trusts nobody. */ + readonly isSelfRevoked: () => boolean +} + +/** + * Build an {@link AccountAllowlist} over an injected NodeId fetcher. Starts empty + * (no peer auto-trusted until the first successful {@link AccountAllowlist.refresh}), + * so a failed prime falls back safely to the manual file rather than trusting anyone. + * + * Self-revocation (D8): the bridge's own NodeId is one of the account's trusted + * devices, so it normally appears in the fetched list. If a *populated* refresh omits + * it, the account has revoked this bridge — {@link AccountAllowlist.has} then trusts + * nobody, so both the connection gate and the heartbeat re-check drop every + * same-account peer within one interval. The manual `iroh allow` file is unaffected. + * + * @param fetchNodeIds - fetches the account's trusted NodeIds (the network seam) + * @param selfNodeId - this bridge's own NodeId, checked for self-revocation + */ +export const createAccountAllowlist = ( + fetchNodeIds: () => Promise, + selfNodeId: string, +): AccountAllowlist => { + const self = selfNodeId.trim() + let trusted = new Set() + const isSelfRevoked = (): boolean => trusted.size > 0 && !trusted.has(self) + return { + has: (nodeId) => !isSelfRevoked() && trusted.has(nodeId.trim()), + isSelfRevoked, + refresh: async () => { + try { + const ids = await fetchNodeIds() + trusted = new Set(ids.map((id) => id.trim()).filter(Boolean)) + } catch (err) { + process.stderr.write( + `⚡ iroh bridge: account allowlist refresh failed, keeping last-known set: ${err instanceof Error ? err.message : String(err)}\n`, + ) + } + }, + } +} diff --git a/cli/src/iroh/admin.ts b/cli/src/iroh/admin.ts new file mode 100644 index 000000000..e23e99ebf --- /dev/null +++ b/cli/src/iroh/admin.ts @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `thunderbolt iroh` admin actions: inspect this node's identity, hand out a + * pairing ticket, and manage the peer allowlist. + * + * id print this node's NodeId and a fresh connection ticket + * pair print a ticket as the out-of-band pairing primitive + * allow add a peer NodeId to the allowlist (the authorization gate) + */ + +import { EndpointId } from '@number0/iroh' +import type { IrohAdminAction } from '../agent/types.ts' +import { add } from './allowlist.ts' +import { bindServer } from './endpoint.ts' + +/** Bind briefly to mint a current ticket (needs a live home relay), print it + * alongside the NodeId, then release the endpoint. Binds the `acp` identity, so + * `iroh id|pair` print the ACP bridge's NodeId/ticket only — MCP now has a + * distinct NodeId, and MCP users copy the NodeId/ticket that + * `thunderbolt mcp --transport iroh` prints on startup. */ +const printIdentity = async (headline: string): Promise => { + const { endpoint, nodeId, ticket } = await bindServer('acp') + process.stdout.write(`${headline}\n node id: ${nodeId}\n ticket: ${ticket}\n`) + await endpoint.close() +} + +/** + * Run an `iroh` admin action. + * + * @param action - the parsed sub-action (`id` | `pair` | `allow`) + */ +export const runIrohAdmin = async (action: IrohAdminAction): Promise => { + if (action.kind === 'id') { + await printIdentity('⚡ thunderbolt iroh identity') + return + } + + if (action.kind === 'pair') { + await printIdentity('⚡ thunderbolt iroh pairing ticket — share this out-of-band') + process.stdout.write( + ' the peer connects with: thunderbolt acp connect \n' + + ' then allow them here: thunderbolt iroh allow \n' + + ' (QR-code encoding of the ticket is deferred)\n', + ) + return + } + + // Validate the NodeId is a real ed25519 key before trusting it — parsing + // throws on a malformed id, which surfaces as a clean CLI error. + EndpointId.fromString(action.nodeId) + const added = await add(action.nodeId) + process.stdout.write(added ? `⚡ allowed ${action.nodeId}\n` : `⚡ ${action.nodeId} was already allowed\n`) +} diff --git a/cli/src/iroh/allowlist.test.ts b/cli/src/iroh/allowlist.test.ts new file mode 100644 index 000000000..485dc0ffa --- /dev/null +++ b/cli/src/iroh/allowlist.test.ts @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the peer allowlist — the authorization gate for the iroh transport. + * Uses a real temp `THUNDERBOLT_HOME` (DI-over-mocking: the file store is the + * unit under test, so we exercise it against a real, isolated filesystem rather + * than a mocked fs). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtemp, rm, stat, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { add, isAllowed, list } from './allowlist.ts' +import { allowlistPath, irohDir } from './paths.ts' + +let home: string +const prevHome = process.env.THUNDERBOLT_HOME + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'tb-allowlist-')) + process.env.THUNDERBOLT_HOME = home +}) + +afterEach(async () => { + if (prevHome === undefined) delete process.env.THUNDERBOLT_HOME + else process.env.THUNDERBOLT_HOME = prevHome + await rm(home, { recursive: true, force: true }) +}) + +/** Write a raw allowlist file (bypassing `add`) to test parsing of arbitrary content. */ +const writeRawAllowlist = async (contents: string): Promise => { + await mkdir(irohDir(), { recursive: true }) + await writeFile(allowlistPath(), contents) +} + +describe('list', () => { + it('is empty when no allowlist file exists (no peer trusted by default)', async () => { + expect(await list()).toEqual([]) + }) + + it('trims, drops blank lines, and de-duplicates', async () => { + await writeRawAllowlist(' peerA \n\npeerB\n \npeerA\n') + expect(await list()).toEqual(['peerA', 'peerB']) + }) +}) + +describe('isAllowed — the gate', () => { + it('refuses a non-allowlisted NodeId (empty allowlist => false)', async () => { + expect(await isAllowed('peerX')).toBe(false) + }) + + it('refuses a NodeId not present even when others are', async () => { + await add('peerA') + expect(await isAllowed('peerB')).toBe(false) + }) + + it('permits an allowlisted NodeId', async () => { + await add('peerA') + expect(await isAllowed('peerA')).toBe(true) + }) + + it('trims the queried NodeId before matching', async () => { + await add('peerA') + expect(await isAllowed(' peerA ')).toBe(true) + }) + + it('does not match on a substring / prefix (exact NodeId only)', async () => { + await add('peerAardvark') + expect(await isAllowed('peerA')).toBe(false) + }) +}) + +describe('add', () => { + it('returns true when newly added and false on a duplicate (idempotent)', async () => { + expect(await add('peerA')).toBe(true) + expect(await add('peerA')).toBe(false) + expect(await list()).toEqual(['peerA']) + }) + + it('trims before storing, so a padded duplicate is detected', async () => { + expect(await add(' peerA ')).toBe(true) + expect(await list()).toEqual(['peerA']) + expect(await add('peerA')).toBe(false) + }) + + it('appends additional NodeIds in order', async () => { + await add('peerA') + await add('peerB') + expect(await list()).toEqual(['peerA', 'peerB']) + expect(await isAllowed('peerA')).toBe(true) + expect(await isAllowed('peerB')).toBe(true) + }) + + it('a blank input never becomes a usable (trusted) empty entry', async () => { + await add(' ') + // The blank trims away on read, so no empty NodeId can ever be "allowed". + expect(await list()).toEqual([]) + expect(await isAllowed('')).toBe(false) + expect(await isAllowed(' ')).toBe(false) + }) + + it('persists the allowlist through the secure store (0600 file in a 0700 dir)', async () => { + await add('peerA') + expect((await stat(allowlistPath())).mode & 0o777).toBe(0o600) + expect((await stat(irohDir())).mode & 0o777).toBe(0o700) + }) +}) diff --git a/cli/src/iroh/allowlist.ts b/cli/src/iroh/allowlist.ts new file mode 100644 index 000000000..3a277fd66 --- /dev/null +++ b/cli/src/iroh/allowlist.ts @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Peer allowlist for the iroh bridge — the authorization gate for the transport. + * + * Because an iroh NodeId is an ed25519 public key, the QUIC handshake already + * authenticates *who* a peer is for free; this list decides *whether* that peer + * may drive a local agent. Only NodeIds present here are permitted to open a + * bridged session. Stored one NodeId per line at `~/.thunderbolt/iroh/allowlist`. + */ + +import { irohDir, allowlistPath } from './paths.ts' +import { readFileOrNull, writeSecureFile } from './storage.ts' + +/** + * The current allowlist as an ordered, de-duplicated list of NodeId strings. + * An absent file yields an empty list (no peer is trusted by default). + */ +export const list = async (): Promise => { + const raw = await readFileOrNull(allowlistPath()) + if (raw === null) return [] + const seen = new Set() + for (const line of raw.split('\n')) { + const id = line.trim() + if (id) seen.add(id) + } + return [...seen] +} + +/** Whether `nodeId` is permitted to open a bridged session. */ +export const isAllowed = async (nodeId: string): Promise => { + const ids = await list() + return ids.includes(nodeId.trim()) +} + +/** + * Add a NodeId to the allowlist (idempotent). Returns `true` if it was newly + * added, `false` if it was already present. + * + * @param nodeId - the peer NodeId (base32) to trust + */ +export const add = async (nodeId: string): Promise => { + const id = nodeId.trim() + const ids = await list() + if (ids.includes(id)) return false + await writeSecureFile(irohDir(), allowlistPath(), [...ids, id].join('\n') + '\n') + return true +} diff --git a/cli/src/iroh/bridge.test.ts b/cli/src/iroh/bridge.test.ts new file mode 100644 index 000000000..8ec42b169 --- /dev/null +++ b/cli/src/iroh/bridge.test.ts @@ -0,0 +1,555 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the iroh bridge's trust boundary and DoS backstops: + * - the allowlist gate (a non-allowlisted peer is closed, no agent spawned), + * - the handshake timeout/semaphore (a stalled handshake can't pin a slot), + * - the per-remote sliding-window rate limiter + bounded key map, + * - the rate-limit key derivation. + * Native iroh connection/incoming objects are replaced by minimal fakes; the + * allowlist is the real file store over a temp `THUNDERBOLT_HOME`. + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test' +import type { Connection, Incoming } from '@number0/iroh' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BridgeConfig } from '../agent/types.ts' +import { MAX_ACTIVE_PROCS, redactArgv, type BridgeProc } from '../commands/bridge.ts' +import type { AccountAllowlist } from './account-allowlist.ts' +import { add } from './allowlist.ts' +import { + admitConnection, + CLOSE_REFUSED, + createHandshakeGuard, + createRateLimiter, + handleConnection, + handshake, + HEARTBEAT_INTERVAL_MS, + heartbeatTick, + isConnectionAllowed, + type OpenConnection, + remoteKey, + startMembershipHeartbeat, +} from './bridge.ts' + +/** UTF-8 bytes of a close reason, computed independently of `reasonBytes`. */ +const bytesOf = (s: string): number[] => [...Buffer.from(s, 'utf8')] +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + +describe('createRateLimiter — sliding window', () => { + it('allows up to max within the window, then refuses', () => { + const limiter = createRateLimiter(2, 1000, () => 0) + expect(limiter.allow('peer')).toBe(true) + expect(limiter.allow('peer')).toBe(true) + expect(limiter.allow('peer')).toBe(false) + }) + + it('refills once the window slides past the old hits', () => { + let t = 0 + const limiter = createRateLimiter(2, 1000, () => t) + expect(limiter.allow('peer')).toBe(true) + expect(limiter.allow('peer')).toBe(true) + expect(limiter.allow('peer')).toBe(false) + t = 1000 + expect(limiter.allow('peer')).toBe(true) + }) + + it('budgets each key independently', () => { + const limiter = createRateLimiter(2, 1000, () => 0) + limiter.allow('a') + limiter.allow('a') + expect(limiter.allow('a')).toBe(false) + expect(limiter.allow('b')).toBe(true) + }) + + it('evicts the least-recently-seen key past the cap, resetting its budget', () => { + const limiter = createRateLimiter(1, 1000, () => 0, 2) + expect(limiter.allow('A')).toBe(true) + expect(limiter.allow('A')).toBe(false) // A exhausted + expect(limiter.allow('B')).toBe(true) + expect(limiter.allow('C')).toBe(true) // map now {A,B,C}, size 3 > cap 2 + // Next touch of A evicts the oldest key (A itself) -> A's history is gone. + expect(limiter.allow('A')).toBe(true) + }) + + it('does NOT reset an exhausted key while it stays within the cap', () => { + const limiter = createRateLimiter(1, 1000, () => 0, 10) + expect(limiter.allow('A')).toBe(true) + expect(limiter.allow('A')).toBe(false) + limiter.allow('B') + limiter.allow('C') + expect(limiter.allow('A')).toBe(false) // still exhausted, no eviction + }) +}) + +describe('createHandshakeGuard — concurrency cap', () => { + it('grants up to max slots then refuses, and a release frees one', () => { + const guard = createHandshakeGuard(2) + expect(guard.tryAcquire()).toBe(true) + expect(guard.tryAcquire()).toBe(true) + expect(guard.tryAcquire()).toBe(false) + guard.release() + expect(guard.tryAcquire()).toBe(true) + }) + + it('never lets release push the count below zero (no phantom capacity)', () => { + const guard = createHandshakeGuard(1) + guard.release() // release with nothing in flight + expect(guard.tryAcquire()).toBe(true) + expect(guard.tryAcquire()).toBe(false) + }) +}) + +describe('remoteKey — rate-limit key derivation', () => { + const fakeIncoming = (addr: { kind: string; addr?: string; endpointId?: string }): Incoming => + ({ remoteAddr: async () => addr }) as unknown as Incoming + + it('prefers the endpointId when present', async () => { + expect(await remoteKey(fakeIncoming({ kind: 'relay', endpointId: 'EID', addr: '1.2.3.4:5' }))).toBe('EID') + }) + + it('falls back to the socket addr when no endpointId', async () => { + expect(await remoteKey(fakeIncoming({ kind: 'direct', addr: '1.2.3.4:5' }))).toBe('1.2.3.4:5') + }) + + it('falls back to the transport kind when neither is present', async () => { + expect(await remoteKey(fakeIncoming({ kind: 'mixed' }))).toBe('mixed') + }) +}) + +describe('handshake — timeout & guard release', () => { + it('returns the connection and releases the guard exactly once on success', async () => { + const connection = { close: mock(() => {}) } as unknown as Connection + const incoming = { accept: async () => ({ connect: async () => connection }) } as unknown as Incoming + const release = mock(() => {}) + const result = await handshake(incoming, { release }, 50) + expect(result).toBe(connection) + expect(release).toHaveBeenCalledTimes(1) + expect((connection.close as ReturnType)).not.toHaveBeenCalled() + }) + + it('releases the guard exactly once and propagates when the handshake fails before the deadline', async () => { + const boom = new Error('connect refused') + const incoming = { + accept: async () => ({ + connect: async () => { + throw boom + }, + }), + } as unknown as Incoming + const release = mock(() => {}) + await expect(handshake(incoming, { release }, 1000)).rejects.toBe(boom) + expect(release).toHaveBeenCalledTimes(1) + }) + + it('rejects on timeout, releases the slot, and closes a late-settling connection', async () => { + let resolveConn: (c: Connection) => void = () => {} + const lateConn = { close: mock(() => {}) } as unknown as Connection + const incoming = { + accept: async () => ({ connect: () => new Promise((r) => (resolveConn = r)) }), + } as unknown as Incoming + const release = mock(() => {}) + + await expect(handshake(incoming, { release }, 10)).rejects.toThrow(/exceeded 10ms/) + expect(release).toHaveBeenCalledTimes(1) + + // The handshake settles *after* the deadline -> the orphan must be closed. + resolveConn(lateConn) + await flush() + expect((lateConn.close as ReturnType)).toHaveBeenCalledTimes(1) + expect((lateConn.close as ReturnType).mock.calls[0][0]).toBe(CLOSE_REFUSED) + expect((lateConn.close as ReturnType).mock.calls[0][1]).toEqual(bytesOf('handshake timed out')) + }) +}) + +describe('handleConnection — the allowlist gate', () => { + let home: string + const prevHome = process.env.THUNDERBOLT_HOME + let stdout: ReturnType + let stderr: ReturnType + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'tb-bridge-')) + process.env.THUNDERBOLT_HOME = home + stdout = spyOn(process.stdout, 'write').mockImplementation(() => true) + stderr = spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + afterEach(async () => { + stdout.mockRestore() + stderr.mockRestore() + if (prevHome === undefined) delete process.env.THUNDERBOLT_HOME + else process.env.THUNDERBOLT_HOME = prevHome + await rm(home, { recursive: true, force: true }) + }) + + const config: BridgeConfig = { protocol: 'acp', transport: 'iroh', port: 0, command: ['__nonexistent_binary_xyzzy__'] } + + it('refuses a non-allowlisted peer: closes with CLOSE_REFUSED, never opens a stream or spawns', async () => { + const acceptBi = mock(async () => ({ recv: {}, send: {} })) + const connection = { + remoteId: () => ({ toString: () => 'unknown-peer' }), + acceptBi, + close: mock(() => {}), + } as unknown as Connection + const incoming = { accept: async () => ({ connect: async () => connection }) } as unknown as Incoming + const activeProcs = new Set() + + await handleConnection(incoming, config, activeProcs, { release: () => {} }) + + expect(acceptBi).not.toHaveBeenCalled() + expect(activeProcs.size).toBe(0) + const close = connection.close as ReturnType + expect(close).toHaveBeenCalledTimes(1) + expect(close.mock.calls[0][0]).toBe(CLOSE_REFUSED) + expect(close.mock.calls[0][1]).toEqual(bytesOf('not allowlisted')) + }) + + it('closes an allowlisted-but-idle peer with CLOSE_REFUSED once the accept deadline passes', async () => { + await add('idle-peer') + const acceptBi = mock(() => new Promise(() => {})) // client never opens the stream + const connection = { + remoteId: () => ({ toString: () => 'idle-peer' }), + acceptBi, + close: mock(() => {}), + } as unknown as Connection + const incoming = { accept: async () => ({ connect: async () => connection }) } as unknown as Incoming + const activeProcs = new Set() + + await handleConnection(incoming, config, activeProcs, { release: () => {} }, { acceptTimeoutMs: 10 }) + + expect(acceptBi).toHaveBeenCalledTimes(1) + expect(activeProcs.size).toBe(0) // nothing spawned for an idle peer + const close = connection.close as ReturnType + expect(close).toHaveBeenCalledTimes(1) + expect(close.mock.calls[0][0]).toBe(CLOSE_REFUSED) + expect(close.mock.calls[0][1]).toEqual(bytesOf('idle: no data stream opened')) + }) + + it('closes with a spawn-failure reason when an allowlisted peer opens a stream but the agent cannot spawn', async () => { + await add('known-peer') + const closed = mock(() => new Promise(() => {})) // never resolves + const connection = { + remoteId: () => ({ toString: () => 'known-peer' }), + acceptBi: mock(async () => ({ recv: {}, send: {} })), + closed, + close: mock(() => {}), + } as unknown as Connection + const incoming = { accept: async () => ({ connect: async () => connection }) } as unknown as Incoming + const activeProcs = new Set() + + await handleConnection(incoming, config, activeProcs, { release: () => {} }) + + expect((connection.acceptBi as ReturnType)).toHaveBeenCalledTimes(1) + expect(closed).not.toHaveBeenCalled() // no proc to bind a kill to + expect(activeProcs.size).toBe(0) + const close = connection.close as ReturnType + expect(close).toHaveBeenCalledTimes(1) + expect(close.mock.calls[0][0]).toBe(CLOSE_REFUSED) + expect(close.mock.calls[0][1]).toEqual(bytesOf("failed to spawn '__nonexistent_binary_xyzzy__'")) + }) + + it('refuses an allowlisted peer at the active-proc cap: closes with "bridge at capacity", no spawn', async () => { + await add('busy-peer') + // Fill the registry to the ceiling so the next connection is over-capacity. + const activeProcs = new Set(Array.from({ length: MAX_ACTIVE_PROCS }, () => ({}) as BridgeProc)) + const closed = mock(() => new Promise(() => {})) + const connection = { + remoteId: () => ({ toString: () => 'busy-peer' }), + acceptBi: mock(async () => ({ recv: {}, send: {} })), + closed, + close: mock(() => {}), + } as unknown as Connection + const incoming = { accept: async () => ({ connect: async () => connection }) } as unknown as Incoming + + await handleConnection(incoming, config, activeProcs, { release: () => {} }) + + expect((connection.acceptBi as ReturnType)).toHaveBeenCalledTimes(1) // peer opened its stream + expect(closed).not.toHaveBeenCalled() // nothing spawned, so no kill bound + expect(activeProcs.size).toBe(MAX_ACTIVE_PROCS) // unchanged: refused, not spawned + const close = connection.close as ReturnType + expect(close).toHaveBeenCalledTimes(1) + expect(close.mock.calls[0][0]).toBe(CLOSE_REFUSED) + expect(close.mock.calls[0][1]).toEqual(bytesOf('bridge at capacity')) + }) +}) + +describe('redactArgv — secret redaction in command logs', () => { + it('redacts the value following --api-key', () => { + expect(redactArgv(['openai-agent', '--api-key', 'sk-secret', '--model', 'gpt-4'])).toBe( + 'openai-agent --api-key *** --model gpt-4', + ) + }) + + it('redacts the value following --token', () => { + expect(redactArgv(['agent', '--token', 'bearer-xyz'])).toBe('agent --token ***') + }) + + it('redacts *_KEY env-style assignments while keeping the variable name', () => { + expect(redactArgv(['OPENAI_API_KEY=sk-abc', 'agent', '--flag'])).toBe('OPENAI_API_KEY=*** agent --flag') + }) + + it('redacts *_TOKEN / *_SECRET / *_PASSWORD env-style assignments, keeping the name', () => { + expect(redactArgv(['GITHUB_TOKEN=ghp_abc', 'agent'])).toBe('GITHUB_TOKEN=*** agent') + expect(redactArgv(['DB_SECRET=shh', 'agent'])).toBe('DB_SECRET=*** agent') + expect(redactArgv(['DB_PASSWORD=hunter2', 'agent'])).toBe('DB_PASSWORD=*** agent') + }) + + it('redacts bare uppercase credential names (PASSWORD=, SECRET=, TOKEN=)', () => { + expect(redactArgv(['PASSWORD=hunter2'])).toBe('PASSWORD=***') + expect(redactArgv(['SECRET=shh'])).toBe('SECRET=***') + expect(redactArgv(['TOKEN=ghp_x'])).toBe('TOKEN=***') + }) + + it('leaves benign argv untouched (no false positives like monkey= or --model=)', () => { + expect(redactArgv(['claude', 'mcp', 'serve', '--model=gpt-4', 'monkey=foo'])).toBe( + 'claude mcp serve --model=gpt-4 monkey=foo', + ) + }) + + it('does NOT redact a lowercase credential-looking name (case-sensitive)', () => { + expect(redactArgv(['password=foo', 'token=bar'])).toBe('password=foo token=bar') + }) + + it('handles a secret flag at the very end with no following value', () => { + expect(redactArgv(['agent', '--api-key'])).toBe('agent --api-key') + }) + + it('redacts the tail of a joined --api-key=value', () => { + expect(redactArgv(['openai-agent', '--api-key=sk-live-secret', '--model', 'gpt-4'])).toBe( + 'openai-agent --api-key=*** --model gpt-4', + ) + }) + + it('redacts the tail of a joined --token=value', () => { + expect(redactArgv(['agent', '--token=ghp_secret'])).toBe('agent --token=***') + }) + + it('splits a joined secret on the first = only (hides = inside the value)', () => { + expect(redactArgv(['agent', '--api-key=abc=def'])).toBe('agent --api-key=***') + }) + + it('redacts a joined secret flag with an empty value', () => { + expect(redactArgv(['agent', '--api-key='])).toBe('agent --api-key=***') + }) + + it('leaves a non-secret joined flag intact', () => { + expect(redactArgv(['agent', '--foo=bar'])).toBe('agent --foo=bar') + }) +}) + +describe('admitConnection — pre-handshake DoS gates', () => { + let stderr: ReturnType + beforeEach(() => { + stderr = spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + afterEach(() => { + stderr.mockRestore() + }) + + const config: BridgeConfig = { protocol: 'acp', transport: 'iroh', port: 0, command: ['echo'] } + + const fakeIncoming = (): { incoming: Incoming; ignore: ReturnType; accept: ReturnType } => { + const ignore = mock(async () => {}) + const accept = mock(async () => ({ connect: async () => ({}) })) + const incoming = { + remoteAddr: async () => ({ kind: 'relay', endpointId: 'peerEID' }), + ignore, + accept, + } as unknown as Incoming + return { incoming, ignore, accept } + } + + it('drops a rate-limited connection with ignore() before touching the handshake guard', async () => { + const { incoming, ignore, accept } = fakeIncoming() + const tryAcquire = mock(() => true) + await admitConnection(incoming, config, new Set(), { allow: () => false }, { tryAcquire, release: () => {} }) + expect(ignore).toHaveBeenCalledTimes(1) + expect(tryAcquire).not.toHaveBeenCalled() // never paid for a handshake slot + expect(accept).not.toHaveBeenCalled() + }) + + it('drops a connection with ignore() when the handshake guard is at capacity', async () => { + const { incoming, ignore, accept } = fakeIncoming() + await admitConnection(incoming, config, new Set(), { allow: () => true }, { tryAcquire: () => false, release: () => {} }) + expect(ignore).toHaveBeenCalledTimes(1) + expect(accept).not.toHaveBeenCalled() // dropped before the handshake + }) +}) + +/** A stub {@link AccountAllowlist} over a fixed trusted set, with an injectable + * refresh and self-revocation flag. Mirrors production: when self-revoked, `has` + * trusts nobody so the gate and heartbeat drop every account peer. */ +const fakeAllowlist = ( + trusted: Set, + refresh: () => Promise = async () => {}, + isSelfRevoked: () => boolean = () => false, +): AccountAllowlist => ({ + has: (id) => !isSelfRevoked() && trusted.has(id), + refresh, + isSelfRevoked, +}) + +/** A stub open connection whose `close` is a spy, for heartbeat teardown assertions. */ +const fakeOpen = (remoteId: string): { open: OpenConnection; close: ReturnType } => { + const close = mock(() => {}) + return { open: { remoteId, connection: { close } as unknown as OpenConnection['connection'] }, close } +} + +describe('account trust gate + heartbeat (D6/D7)', () => { + let home: string + const prevHome = process.env.THUNDERBOLT_HOME + let stderr: ReturnType + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'tb-trust-')) + process.env.THUNDERBOLT_HOME = home + stderr = spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + afterEach(async () => { + stderr.mockRestore() + if (prevHome === undefined) delete process.env.THUNDERBOLT_HOME + else process.env.THUNDERBOLT_HOME = prevHome + await rm(home, { recursive: true, force: true }) + }) + + describe('isConnectionAllowed — the D6 gate (account allowlist OR manual file)', () => { + it('admits a peer in the account allowlist even when the manual file is empty (auto-trust)', async () => { + expect(await isConnectionAllowed('acct-peer', fakeAllowlist(new Set(['acct-peer'])))).toBe(true) + }) + + it('admits a peer in the manual file even when it is absent from the account allowlist', async () => { + await add('manual-peer') + expect(await isConnectionAllowed('manual-peer', fakeAllowlist(new Set(['someone-else'])))).toBe(true) + }) + + it('denies a peer in neither the account allowlist nor the manual file', async () => { + await add('other-peer') + expect(await isConnectionAllowed('stranger', fakeAllowlist(new Set(['acct-peer'])))).toBe(false) + }) + + it('Standalone (no account allowlist): manual file only, admits/denies, never throws', async () => { + await add('manual-peer') + expect(await isConnectionAllowed('manual-peer', undefined)).toBe(true) + expect(await isConnectionAllowed('stranger', undefined)).toBe(false) + }) + + it('self-revoked bridge (D8): rejects an account peer at the gate, auto-trust off', async () => { + // The allowlist still lists the peer, but this bridge is self-revoked → has() is + // false → the gate falls through to the (empty) manual file and denies. + const revoked = fakeAllowlist(new Set(['acct-peer']), async () => {}, () => true) + expect(await isConnectionAllowed('acct-peer', revoked)).toBe(false) + }) + + it('self-revoked bridge (D8): a manual-file peer is still admitted (manual trust persists)', async () => { + await add('manual-peer') + const revoked = fakeAllowlist(new Set(['manual-peer']), async () => {}, () => true) + expect(await isConnectionAllowed('manual-peer', revoked)).toBe(true) + }) + }) + + describe('heartbeatTick — D7 live-connection revocation', () => { + it('refreshes the allowlist, tears down a now-revoked peer, and leaves a still-valid one', async () => { + const refresh = mock(async () => {}) + const allowlist = fakeAllowlist(new Set(['still-valid']), refresh) + const valid = fakeOpen('still-valid') + const revoked = fakeOpen('revoked-peer') + + await heartbeatTick(allowlist, new Set([valid.open, revoked.open])) + + expect(refresh).toHaveBeenCalledTimes(1) + expect(valid.close).not.toHaveBeenCalled() + expect(revoked.close).toHaveBeenCalledTimes(1) + expect(revoked.close.mock.calls[0][0]).toBe(CLOSE_REFUSED) + expect(revoked.close.mock.calls[0][1]).toEqual(bytesOf('membership revoked')) + }) + + it('keeps a peer that survives only in the manual file (account-revoked but manually allowed)', async () => { + await add('manual-peer') + const manual = fakeOpen('manual-peer') + + await heartbeatTick(fakeAllowlist(new Set()), new Set([manual.open])) + + expect(manual.close).not.toHaveBeenCalled() + }) + + it('self-revoked bridge (D8): tears down ALL account-auto-trusted sessions and logs once', async () => { + const refresh = mock(async () => {}) + // Non-empty account set, but this bridge is self-revoked → has() trusts nobody, + // so every same-account session is torn down within the interval. + const allowlist = fakeAllowlist(new Set(['acct-a', 'acct-b']), refresh, () => true) + const a = fakeOpen('acct-a') + const b = fakeOpen('acct-b') + + await heartbeatTick(allowlist, new Set([a.open, b.open])) + + expect(refresh).toHaveBeenCalledTimes(1) + expect(a.close).toHaveBeenCalledTimes(1) + expect(b.close).toHaveBeenCalledTimes(1) + const logged = stderr.mock.calls.flat().some((s: unknown) => String(s).includes('no longer in the account allowlist')) + expect(logged).toBe(true) + }) + + it('self-revoked bridge (D8): a manual-file peer survives the teardown (D9)', async () => { + await add('manual-peer') + const allowlist = fakeAllowlist(new Set(['manual-peer']), async () => {}, () => true) + const manual = fakeOpen('manual-peer') + + await heartbeatTick(allowlist, new Set([manual.open])) + + expect(manual.close).not.toHaveBeenCalled() + }) + + it('isolates a connection whose close() throws — the sweep continues to other peers', async () => { + const throwingClose = mock(() => { + throw new Error('NAPI close failed') + }) + const bad: OpenConnection = { + remoteId: 'bad-peer', + connection: { close: throwingClose } as unknown as OpenConnection['connection'], + } + const good = fakeOpen('good-peer') + + // Empty account set + empty manual file → both peers are revoked and get closed. + await heartbeatTick(fakeAllowlist(new Set()), new Set([bad, good.open])) + + expect(throwingClose).toHaveBeenCalledTimes(1) // attempted despite throwing + expect(good.close).toHaveBeenCalledTimes(1) // sweep survived the throw and continued + }) + }) +}) + +describe('startMembershipHeartbeat — 45s cadence', () => { + it('runs on a 45s interval', () => { + expect(HEARTBEAT_INTERVAL_MS).toBe(45_000) + }) + + it('refreshes each interval until stopped, driven by the injected clock (no real timers)', async () => { + const refresh = mock(async () => {}) + const allowlist = fakeAllowlist(new Set(), refresh) + const pending: Array<() => void> = [] + const sleep = mock((_ms: number) => new Promise((resolve) => pending.push(resolve))) + const releaseOne = (): void => pending.shift()?.() + + const stop = startMembershipHeartbeat(allowlist, new Set(), { now: () => 0, sleep }) + + await flush() // loop reaches the first sleep + expect(sleep).toHaveBeenCalledWith(HEARTBEAT_INTERVAL_MS) + + releaseOne() // first 45s elapses + await flush() + expect(refresh).toHaveBeenCalledTimes(1) + + releaseOne() // second 45s elapses + await flush() + expect(refresh).toHaveBeenCalledTimes(2) + + stop() + releaseOne() // wake the loop so it observes running=false and exits + await flush() + expect(refresh).toHaveBeenCalledTimes(2) // no tick after stop + }) +}) diff --git a/cli/src/iroh/bridge.ts b/cli/src/iroh/bridge.ts new file mode 100644 index 000000000..f6d923c17 --- /dev/null +++ b/cli/src/iroh/bridge.ts @@ -0,0 +1,547 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * iroh transport for the ACP/MCP bridge — the authenticated, P2P counterpart to + * the loopback-only WebSocket bridge. + * + * Binds a server endpoint on the protocol's persistent identity and accepts + * incoming connections from the n0 relays. The QUIC handshake authenticates the + * peer's NodeId (an ed25519 key) for free; the allowlist is the authorization + * gate on top of it. For each *allowed* connection the bridge spawns its own + * stdio agent and pumps it over one bidi stream using the same ndjson framing as + * the WebSocket path. Lifecycle is 1:1: a dropped connection kills the agent, + * and an exiting agent ends the session by finishing its stream. + */ + +import type { Connection, Incoming } from '@number0/iroh' +import type { BridgeConfig } from '../agent/types.ts' +import { isSecureCloudUrl } from '../auth/config.ts' +import type { Clock } from '../auth/device-grant.ts' +import { resolveBridgeCredential } from '../auth/token-store.ts' +import { atProcCapacity, redactArgv, spawnAgent, type BridgeProc } from '../commands/bridge.ts' +import { createAccountAllowlist, fetchAccountAllowlist, type AccountAllowlist } from './account-allowlist.ts' +import { isAllowed } from './allowlist.ts' +import { bindServer } from './endpoint.ts' +import { forwardFromRecv, forwardToSend, writeToStdin } from './pump.ts' + +/** QUIC application close code for a connection we actively reject (allowlist + * miss or spawn failure). Normal end-of-session is signalled by finishing the + * stream and letting the client close, never by an active server-side close. */ +export const CLOSE_REFUSED = 1n + +/** Encode a human-readable connection-close reason as the byte array iroh wants. */ +export const reasonBytes = (reason: string): number[] => Array.from(Buffer.from(reason, 'utf8')) + +/** Per-remote handshake budget: how many connections one peer may open within + * {@link RATE_WINDOW_MS} before we drop the excess *before* the TLS handshake. + * Generous enough that no legitimate client reconnecting hits it. */ +const RATE_MAX = 10 +/** Sliding window for {@link RATE_MAX}. */ +const RATE_WINDOW_MS = 10_000 +/** Hard ceiling on distinct rate-limit keys. The map evicts least-recently-seen + * keys past this, so a flood of fresh (rotating) identities can't grow it. */ +const RATE_MAX_KEYS = 4096 +/** Max TLS handshakes allowed to run at once. This is the real CPU backstop: the + * per-remote budget is defeated by an attacker who mints a fresh EndpointId per + * connection, but a global cap bounds concurrent handshake cost regardless of + * identity. Generous enough that legitimate concurrent clients never hit it. */ +const MAX_CONCURRENT_HANDSHAKES = 16 +/** Hard ceiling on a single QUIC/TLS handshake. A peer that grabs a guard slot + * then stalls accept()/connect() would otherwise pin that slot forever; with + * {@link MAX_CONCURRENT_HANDSHAKES} such stalls every slot is held and legit + * clients are locked out at the guard. Past this deadline we abandon the + * handshake, releasing the slot. Generous enough that no real relay-routed + * handshake hits it. */ +const HANDSHAKE_TIMEOUT_MS = 10_000 +/** Hard ceiling on how long an allowlisted peer may take to open its bidi data + * stream after the handshake. The handshake guard slot is already released by + * this point, so this is not a handshake-DoS — but an allowlisted-but-idle peer + * that never opens the stream would pin the {@link Connection} indefinitely + * (QUIC's idle timeout is defeated by keepalives). Past this deadline we close + * the connection so it can't be held open for free. Generous enough that a real + * client opening its stream right after connecting never hits it. */ +const ACCEPT_TIMEOUT_MS = 10_000 + +/** + * A lightweight per-key sliding-window rate limiter. `allow(key)` records the + * call and returns whether the key is still within budget. A key's own stale + * timestamps are pruned on each check; the backing map is hard-capped at + * {@link RATE_MAX_KEYS} via least-recently-seen (insertion-order) eviction, so it + * stays bounded even under a flood of fresh keys that never go stale in-window. + * + * @param max - calls allowed per window + * @param windowMs - the window length in milliseconds + * @param clock - current-time source (ms); injectable so the sliding window can + * be exercised deterministically without real waits + * @param maxKeys - hard ceiling on distinct keys before least-recently-seen + * eviction; injectable so the bound can be tested cheaply + */ +export const createRateLimiter = ( + max: number, + windowMs: number, + clock: () => number = Date.now, + maxKeys: number = RATE_MAX_KEYS, +): { allow: (key: string) => boolean } => { + const hits = new Map() + + const allow = (key: string): boolean => { + const now = clock() + // Map iterates in insertion order, so the first key is the oldest-touched. + while (hits.size > maxKeys) { + const oldest = hits.keys().next().value + if (oldest === undefined) break + hits.delete(oldest) + } + const recent = (hits.get(key) ?? []).filter((at) => now - at < windowMs) + if (recent.length >= max) { + hits.set(key, recent) + return false + } + recent.push(now) + // Re-insert so an active key refreshes to newest, making eviction least-recent. + hits.delete(key) + hits.set(key, recent) + return true + } + return { allow } +} + +/** + * A non-blocking counting semaphore. `tryAcquire` takes a slot if one is free + * (returning whether it did); `release` returns one. Used to cap concurrent TLS + * handshakes so handshake spam can't burn unbounded CPU before the allowlist gate. + * + * @param max - the number of slots + */ +export const createHandshakeGuard = (max: number): { tryAcquire: () => boolean; release: () => void } => { + let inFlight = 0 + return { + tryAcquire: () => { + if (inFlight >= max) return false + inFlight += 1 + return true + }, + release: () => { + if (inFlight > 0) inFlight -= 1 + }, + } +} + +/** Derive a pre-handshake rate-limit key for an incoming connection. Relay-routed + * peers (our P2P model) expose their EndpointId before the handshake; direct IP + * peers expose `ip:port`. Falls back to the transport kind so a key always exists. */ +export const remoteKey = async (incoming: Incoming): Promise => { + const addr = await incoming.remoteAddr() + return addr.endpointId ?? addr.addr ?? addr.kind +} + +/** + * Complete the QUIC/TLS handshake for an incoming connection, releasing the + * handshake guard slot the instant the handshake settles (success, failure, or + * timeout) so the slot covers only the bounded handshake window, never the whole + * session and never an indefinite stall. + * + * The handshake races a {@link HANDSHAKE_TIMEOUT_MS} deadline: a peer that takes a + * guard slot then stalls accept()/connect() can't pin the slot — past the deadline + * we abandon the wait and free the slot. The underlying handshake may still settle + * afterwards; if it resolves late we close the orphaned connection so it can't leak. + */ +export const handshake = async ( + incoming: Incoming, + guard: { release: () => void }, + timeoutMs: number = HANDSHAKE_TIMEOUT_MS, +): Promise => { + let timedOut = false + const connecting = (async () => (await incoming.accept()).connect())() + // Side-channel the late settle: if the deadline already won the race, close a + // connection that completes afterwards. The single trailing `.catch` covers both + // a late rejection (already surfaced by the race) and a throw from `close()` + // itself (the NAPI binding can throw), so the void-discarded chain can never leak + // an unhandled rejection. A peer that stalls *before* `accept()` yields a + // Connection has no JS-visible handle to abort (the binding exposes only + // `Connection.close()`); iroh's own QUIC transport timeout reaps that future, so + // it's bounded, not an unbounded leak — and the guard slot is already freed below. + void connecting + .then((connection) => { + if (timedOut) connection.close(CLOSE_REFUSED, reasonBytes('handshake timed out')) + }) + .catch(() => undefined) + + let timer: ReturnType | undefined + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true + reject(new Error(`handshake exceeded ${timeoutMs}ms`)) + }, timeoutMs) + }) + + try { + return await Promise.race([connecting, deadline]) + } finally { + // Clears the timer on the win path so the deadline never rejects unhandled. + clearTimeout(timer) + guard.release() + } +} + +/** Type of the bidi stream pair iroh hands back from {@link Connection.acceptBi}. */ +type BiStream = Awaited> + +/** + * Wait for the client to open its bidi data stream, bounded by a deadline. An + * allowlisted-but-idle peer that never opens the stream would otherwise leave us + * awaiting `acceptBi()` forever and pin the {@link Connection} (QUIC's idle timeout + * is defeated by keepalives). Past the deadline we close the connection + * ({@link CLOSE_REFUSED}) and resolve `null`. The discarded `acceptBi` promise is + * pre-`.catch`'d so a late rejection (the close tears the stream down) can't leak + * as an unhandled rejection. + */ +const acceptBidiStream = async (connection: Connection, timeoutMs: number): Promise => { + const accepting = connection.acceptBi() + void accepting.catch(() => undefined) + + let timer: ReturnType | undefined + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + connection.close(CLOSE_REFUSED, reasonBytes('idle: no data stream opened')) + resolve(null) + }, timeoutMs) + }) + + try { + return await Promise.race([accepting, deadline]) + } finally { + clearTimeout(timer) + } +} + +/** Cadence of the membership heartbeat (D7): refresh the account allowlist and + * re-check every open connection against it. 45s is the revocation-propagation SLA + * — a revoked device's live session is torn down within one interval. */ +export const HEARTBEAT_INTERVAL_MS = 45_000 + +/** Background time seam for the heartbeat: like the login poll's `systemClock`, but + * its sleep timer is `unref`'d so a pending heartbeat sleep never keeps the process + * alive past a clean shutdown. The accept loop (awaiting the endpoint) holds the + * process up while serving; once it ends, this timer must not block exit. */ +const backgroundClock: Clock = { + now: () => Date.now(), + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms).unref?.() + }), +} + +/** A live bridged session, tracked so the heartbeat can re-check its peer's + * membership and tear it down if the account revokes it mid-session. */ +export type OpenConnection = { + readonly remoteId: string + readonly connection: Pick +} + +/** Optional per-bridge trust context threaded into connection handling: the cached + * account allowlist (absent in Standalone / no-account mode) and the open-connection + * registry the heartbeat re-checks. `acceptTimeoutMs` stays overridable for tests. */ +export type HandleConnectionOptions = { + readonly accountAllowlist?: AccountAllowlist + readonly openConnections?: Set + readonly acceptTimeoutMs?: number +} + +/** + * The bridge trust gate (D6): a peer is admitted if its authenticated NodeId is in + * the cached same-account allowlist (auto-trust) OR the manual `iroh allow` file + * (Standalone / cross-account / CI). The account allowlist is checked first and + * short-circuits, so the manual file is only read when auto-trust doesn't cover the + * peer. The QUIC handshake has already authenticated `remoteId`, so this is pure + * authorization over a proven identity. + * + * @param remoteId - the handshake-authenticated peer NodeId + * @param accountAllowlist - the cached account allowlist, or `undefined` (Standalone) + */ +export const isConnectionAllowed = async ( + remoteId: string, + accountAllowlist?: AccountAllowlist, +): Promise => (accountAllowlist?.has(remoteId) ?? false) || isAllowed(remoteId) + +/** + * One heartbeat cycle (D7): refresh the cached account allowlist, then tear down + * every open connection whose peer is no longer allowed (account-revoked AND absent + * from the manual file). A still-allowed peer is untouched — the heartbeat is a + * no-op for legit sessions. Closing the connection triggers the same lifecycle the + * peer's own disconnect would (agent killed, registry pruned) via the hooks wired in + * {@link handleConnection}. Exported so a single tick is unit-testable directly. + * + * Self-revocation (D8): if the refreshed allowlist no longer lists this bridge's own + * NodeId, the account has revoked *this device*. The allowlist then reports every + * account peer as untrusted, so the per-connection sweep below tears down all + * same-account sessions (manual-file peers survive, per D9); we log it once per tick + * so the compromise-response ("revoke the device") is observable in the bridge logs. + * + * Each peer's re-check is isolated: a thrown manual-file read or a `close()` that + * throws (the NAPI binding can) is logged and skipped, never aborting the sweep or + * killing the loop — the revocation check must survive one bad connection. + * + * @param accountAllowlist - the cached account allowlist to refresh and check against + * @param openConnections - the live sessions to re-check + */ +export const heartbeatTick = async ( + accountAllowlist: AccountAllowlist, + openConnections: Set, +): Promise => { + await accountAllowlist.refresh() + if (accountAllowlist.isSelfRevoked()) { + process.stderr.write( + '⚡ iroh bridge: this device is no longer in the account allowlist — account auto-trust disabled, tearing down same-account sessions (manual allowlist still active)\n', + ) + } + for (const open of openConnections) { + try { + if (await isConnectionAllowed(open.remoteId, accountAllowlist)) continue + process.stderr.write(`⚡ iroh bridge: ${open.remoteId} revoked mid-session — closing\n`) + open.connection.close(CLOSE_REFUSED, reasonBytes('membership revoked')) + } catch (err) { + process.stderr.write( + `⚡ iroh bridge: heartbeat re-check failed for ${open.remoteId}: ${err instanceof Error ? err.message : String(err)}\n`, + ) + } + } +} + +/** + * Run the membership heartbeat on {@link HEARTBEAT_INTERVAL_MS} cadence until + * stopped, sleeping via the injected {@link Clock} so tests drive it without real + * timers. Each cycle {@link heartbeatTick}s; refresh + per-connection re-checks are + * error-isolated, so the loop never dies. Returns a stop function that ends the loop + * after the current sleep (the {@link backgroundClock} sleep is `unref`'d, so a + * pending cycle can't hold the process up meanwhile). + * + * @param accountAllowlist - the allowlist refreshed each cycle + * @param openConnections - the live sessions re-checked each cycle + * @param clock - injected time seam (real: {@link backgroundClock}) + * @param intervalMs - cycle cadence (default {@link HEARTBEAT_INTERVAL_MS}) + */ +export const startMembershipHeartbeat = ( + accountAllowlist: AccountAllowlist, + openConnections: Set, + clock: Clock = backgroundClock, + intervalMs: number = HEARTBEAT_INTERVAL_MS, +): (() => void) => { + let running = true + void (async () => { + while (running) { + await clock.sleep(intervalMs) + if (!running) break + await heartbeatTick(accountAllowlist, openConnections) + } + })() + return () => { + running = false + } +} + +/** + * Handle a single incoming connection (its handshake guard slot already taken): + * complete the handshake to learn the authenticated remote NodeId, enforce the + * trust gate ({@link isConnectionAllowed}), wait (bounded) for the client to open + * the data stream, and only then bridge a freshly-spawned agent over it. + */ +export const handleConnection = async ( + incoming: Incoming, + config: BridgeConfig, + activeProcs: Set, + guard: { release: () => void }, + opts: HandleConnectionOptions = {}, +): Promise => { + const connection = await handshake(incoming, guard) + const remoteId = connection.remoteId().toString() + + if (!(await isConnectionAllowed(remoteId, opts.accountAllowlist))) { + process.stderr.write(`⚡ iroh bridge: refused ${remoteId} (not allowlisted)\n`) + connection.close(CLOSE_REFUSED, reasonBytes('not allowlisted')) + return + } + + // Commit a subprocess only once the client actually opens the data stream, so + // an allowlisted-but-idle peer can't pin a spawned agent, and the agent never + // runs before its data plane exists. The wait is bounded: a peer that completes + // the handshake (and passes the allowlist) but never opens the stream is closed + // rather than left to pin the connection forever. + const bi = await acceptBidiStream(connection, opts.acceptTimeoutMs ?? ACCEPT_TIMEOUT_MS) + if (!bi) { + process.stderr.write(`⚡ iroh bridge: closed ${remoteId} (idle: no data stream)\n`) + return + } + + // Cap concurrently-live agents: the allowlist authorizes a *peer*, not a fixed + // number of sessions, so one allowlisted peer holding many connections open + // would otherwise spawn unbounded agents. At the ceiling we refuse rather than spawn. + if (atProcCapacity(activeProcs)) { + process.stderr.write(`⚡ iroh bridge: refused ${remoteId} (at capacity, ${activeProcs.size} live agents)\n`) + connection.close(CLOSE_REFUSED, reasonBytes('bridge at capacity')) + return + } + + const proc = spawnAgent(config.command) + if (!proc) { + connection.close(CLOSE_REFUSED, reasonBytes(`failed to spawn '${config.command[0]}'`)) + return + } + activeProcs.add(proc) + // Register this live session so the heartbeat can re-check its peer's membership + // (D7) and tear it down on revocation. + const open: OpenConnection = { remoteId, connection } + opts.openConnections?.add(open) + void proc.exited.then(() => activeProcs.delete(proc)) + // A dropped connection (peer disconnect OR a heartbeat teardown) kills the agent + // and prunes the session from the registry. The reverse (agent exit) is signalled + // by finishing the send stream below — never by an active connection close — + // so the final JSON-RPC response can't be truncated mid-flight; the client + // tears the connection down once it has drained that stream. + void connection.closed().then(() => { + proc.kill() + opts.openConnections?.delete(open) + }) + process.stdout.write(`⚡ iroh bridge: accepted ${remoteId} → spawned ${redactArgv(config.command)}\n`) + + // `.finally` (not `.then`) so the agent always gets stdin EOF, even if the + // recv pump errors as the connection tears down. `writeToStdin` awaits the + // flush (backpressure) and logs an EPIPE loudly rather than swallowing it. + const toAgent = forwardFromRecv(bi.recv, (chunk) => writeToStdin(proc.stdin, chunk, 'bridge')).finally(() => + proc.stdin.end(), + ) + const fromAgent = forwardToSend(proc.stdout, bi.send) + // A pump rejecting here is the expected end-of-session (the stream errors as + // the connection closes); the lifecycle hooks above have already cleaned up. + await Promise.allSettled([toAgent, fromAgent]) +} + +/** Live same-account trust for a running bridge: the cached account allowlist and a + * stop for its heartbeat. Absent when the CLI isn't logged in (Standalone). */ +type AccountTrust = { readonly accountAllowlist: AccountAllowlist; readonly stop: () => void } + +/** + * Wire same-account auto-trust (D2/D7/D8) when this bridge has a backend credential: + * resolve it (env PAT via `x-api-key`, else the stored device-grant session bearer), + * build the account allowlist over that credential, prime it with an initial refresh + * before any connection is accepted, and start the 45s membership heartbeat. Returns + * the live allowlist + a heartbeat stop, or `undefined` in Standalone / no-credential + * mode where the bridge stays pure manual-file. Never throws: a failed prime leaves an + * empty-but-live allowlist the heartbeat keeps retrying, and the manual file governs + * meanwhile. + * + * Re-checks the credential's `cloudUrl` against the same secure-transport policy + * `login` enforced (`isSecureCloudUrl`) before sending the credential: a tampered / + * cleartext non-loopback URL disables account trust rather than leaking it. + * + * The bridge's own `selfNodeId` is threaded into the allowlist so a refresh that no + * longer lists it (the account revoked this device, D8) disables account auto-trust. + * + * @param openConnections - the live-session registry the heartbeat re-checks + * @param selfNodeId - this bridge's own NodeId, for D8 self-revocation + */ +const startAccountTrust = async ( + openConnections: Set, + selfNodeId: string, +): Promise => { + const credential = await resolveBridgeCredential() + if (!credential) return undefined + if (!isSecureCloudUrl(credential.cloudUrl)) { + process.stderr.write( + `⚡ iroh bridge: cloud URL is not a secure transport (${credential.cloudUrl}); account auto-trust disabled, using manual allowlist only\n`, + ) + return undefined + } + const accountAllowlist = createAccountAllowlist(() => fetchAccountAllowlist(credential), selfNodeId) + await accountAllowlist.refresh() + const stop = startMembershipHeartbeat(accountAllowlist, openConnections) + return { accountAllowlist, stop } +} + +/** + * Start the iroh bridge: advertise this node's NodeId + ticket, then accept and + * bridge connections until interrupted. Each connection is handled concurrently + * so a slow or stalled session never blocks new peers; a SIGINT/SIGTERM kills + * every spawned agent before exit so none are orphaned. When the CLI is logged in + * to an account, same-account peers are auto-trusted from the backend allowlist and + * re-checked on a 45s heartbeat; otherwise the manual `iroh allow` file is the gate. + */ +export const runIrohBridge = async (config: BridgeConfig): Promise => { + const { endpoint, nodeId, ticket } = await bindServer(config.protocol) + const activeProcs = new Set() + const openConnections = new Set() + // The QUIC handshake authenticates the peer's NodeId, but it runs *before* the + // allowlist check below — so a peer that learns this NodeId could force endless + // TLS handshakes. Two layers drop the excess pre-handshake: a per-remote budget + // (fairness against a fixed peer) and a global concurrent-handshake cap (the CPU + // backstop that holds even against an attacker rotating EndpointIds). + const handshakeBudget = createRateLimiter(RATE_MAX, RATE_WINDOW_MS) + const handshakeGuard = createHandshakeGuard(MAX_CONCURRENT_HANDSHAKES) + const accountTrust = await startAccountTrust(openConnections, nodeId) + + process.stdout.write( + `⚡ thunderbolt ${config.protocol} bridge (iroh) ready\n` + + ` node id: ${nodeId}\n` + + ` ticket: ${ticket}\n` + + ` spawning per connection: ${redactArgv(config.command)}\n` + + (accountTrust + ? ' same-account auto-trust: on (backend allowlist, 45s heartbeat)\n' + : ' allow a peer with: thunderbolt iroh allow \n'), + ) + + const shutdown = (): void => { + accountTrust?.stop() + for (const proc of activeProcs) proc.kill() + void endpoint.close() + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + + // Only track open connections when the heartbeat is live to re-check them; in + // Standalone there is nothing to consume the registry, so we don't populate it. + const opts: HandleConnectionOptions = accountTrust + ? { accountAllowlist: accountTrust.accountAllowlist, openConnections } + : {} + while (true) { + const incoming = await endpoint.acceptNext() + if (!incoming) break + void admitConnection(incoming, config, activeProcs, handshakeBudget, handshakeGuard, opts).catch((err) => { + process.stderr.write(`⚡ iroh bridge: connection error: ${err instanceof Error ? err.message : String(err)}\n`) + }) + } + // The accept loop only ends when the endpoint closes; end the heartbeat loop so no + // further tick runs after the endpoint is gone. + accountTrust?.stop() +} + +/** + * Gate one incoming connection before paying for its TLS handshake: first the + * per-remote budget, then a free global handshake slot. A rejected connection is + * `ignore()`d (dropped with no response — the cheapest rejection) so spam can't + * burn CPU; an admitted one proceeds to {@link handleConnection}, which owns the + * acquired slot and releases it once the handshake settles. + */ +export const admitConnection = async ( + incoming: Incoming, + config: BridgeConfig, + activeProcs: Set, + handshakeBudget: { allow: (key: string) => boolean }, + handshakeGuard: { tryAcquire: () => boolean; release: () => void }, + opts: HandleConnectionOptions = {}, +): Promise => { + const key = await remoteKey(incoming) + if (!handshakeBudget.allow(key)) { + process.stderr.write(`⚡ iroh bridge: rate-limited ${key} (too many handshakes)\n`) + await incoming.ignore() + return + } + if (!handshakeGuard.tryAcquire()) { + process.stderr.write(`⚡ iroh bridge: at handshake capacity, dropped ${key}\n`) + await incoming.ignore() + return + } + await handleConnection(incoming, config, activeProcs, handshakeGuard, opts) +} diff --git a/cli/src/iroh/connect.test.ts b/cli/src/iroh/connect.test.ts new file mode 100644 index 000000000..50ea7ee07 --- /dev/null +++ b/cli/src/iroh/connect.test.ts @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the connect-side refusal decision: distinguishing a remote rejection + * (allowlist miss => closed before any data) from a clean empty round-trip. This + * is what turns a silent "no bytes" into a clear non-zero-exit error. + */ + +import { describe, expect, it } from 'bun:test' +import { refusalError } from './connect.ts' + +describe('refusalError', () => { + it('is null when any bytes came back, regardless of a close reason', () => { + expect(refusalError(5, null, 'not allowlisted')).toBeNull() + expect(refusalError(5, new Error('x'), null)).toBeNull() + }) + + it('is null on a clean empty round-trip (zero bytes, no failure, no reason)', () => { + expect(refusalError(0, null, null)).toBeNull() + }) + + it('reports the peer close reason when zero bytes came back', () => { + const err = refusalError(0, null, 'not allowlisted') + expect(err).toBeInstanceOf(Error) + expect(err?.message).toContain('not allowlisted') + }) + + it('reports a local pump failure message when there is no peer reason', () => { + const err = refusalError(0, new Error('stream reset'), null) + expect(err?.message).toContain('stream reset') + }) + + it('prefers the peer close reason over the local failure message', () => { + const err = refusalError(0, new Error('local detail'), 'remote said no') + expect(err?.message).toContain('remote said no') + expect(err?.message).not.toContain('local detail') + }) + + it('stringifies a non-Error failure', () => { + const err = refusalError(0, 'plain string failure', null) + expect(err?.message).toContain('plain string failure') + }) +}) diff --git a/cli/src/iroh/connect.ts b/cli/src/iroh/connect.ts new file mode 100644 index 000000000..6c1f78129 --- /dev/null +++ b/cli/src/iroh/connect.ts @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Client side of the iroh transport: dial a remote bridge and pump a local + * stdio ACP/MCP client into it. The mirror image of {@link runIrohBridge} — + * the same ndjson byte pump, just sourced from this side's stdio (or a spawned + * local client) instead of a server-spawned agent. + * + * With `-- ` it spawns that local client and bridges its stdio; without a + * command it bridges this process's own stdin/stdout, so a JSON-RPC line can be + * piped straight through to prove the round-trip. A bidi stream erroring as the + * connection closes is the normal end-of-pipe, so the pumps settle rather than + * throw; the caller decides success from whether any bytes came back. + */ + +import type { Connection } from '@number0/iroh' +import type { ConnectConfig } from '../agent/types.ts' +import { spawnAgent } from '../commands/bridge.ts' +import { dial } from './endpoint.ts' +import { forwardFromRecv, forwardToSend, writeToStdin } from './pump.ts' + +/** Write received bytes to this process's stdout, awaiting a `drain` when the + * kernel buffer is full so the iroh read loop respects stdout backpressure. An + * `EPIPE` (the downstream of a pipe like `| head` closed early) is logged + * loudly and rethrown so the pump stops rather than swallowing the failure. */ +const writeToStdout = (chunk: Uint8Array): Promise => + new Promise((resolve, reject) => { + const onError = (err: Error): void => { + process.stderr.write(`⚡ iroh connect: stdout write failed: ${err.message}\n`) + reject(err) + } + if (process.stdout.write(chunk, (err) => err && onError(err))) { + resolve() + } else { + process.stdout.once('drain', resolve) + } + }) + +/** Wait until the pumps settle *or* the connection closes — whichever first — + * so a peer that refuses us mid-pump is observed (and its close reason is + * populated) rather than leaving us blocked on a half that will never end. */ +const settleOrClose = (connection: Connection, pumps: Promise[]): Promise => + Promise.race([Promise.allSettled(pumps), connection.closed()]) + +/** Pump a spawned local client's stdio over the connection's bidi stream, + * returning the number of bytes received back from the remote agent. */ +const bridgeLocalCommand = async (connection: Connection, command: readonly string[]): Promise => { + const proc = spawnAgent(command) + if (!proc) throw new Error(`failed to spawn local client '${command[0]}'`) + void connection.closed().then(() => proc.kill()) + + let received = 0 + const bi = await connection.openBi() + const toRemote = forwardToSend(proc.stdout, bi.send) + // `.finally` so the local client always gets stdin EOF, even on a torn stream. + const fromRemote = forwardFromRecv(bi.recv, (chunk) => { + received += chunk.length + return writeToStdin(proc.stdin, chunk, 'connect') + }).finally(() => proc.stdin.end()) + await settleOrClose(connection, [toRemote, fromRemote]) + return received +} + +/** Pump this process's own stdin/stdout over the connection's bidi stream, + * returning the number of bytes received back from the remote agent. */ +const bridgeProcessStdio = async (connection: Connection): Promise => { + let received = 0 + const bi = await connection.openBi() + const toRemote = forwardToSend(Bun.stdin.stream(), bi.send) + const fromRemote = forwardFromRecv(bi.recv, (chunk) => { + received += chunk.length + return writeToStdout(chunk) + }) + await settleOrClose(connection, [toRemote, fromRemote]) + return received +} + +/** + * Decide whether a finished connect attempt was a refusal / dead end. The remote + * rejects a non-allowlisted peer by closing before any data flows, so "zero bytes + * back *and* an explicit signal (a local pump failure or a peer close reason)" is + * the refusal fingerprint. The peer's close `reason` is preferred over our local + * failure message because it names *why* the remote hung up. Zero bytes with no + * signal at all is not treated as an error (a clean, empty round-trip). + * + * @param received - bytes received back from the remote + * @param failure - a local pump error, or `null` if the pumps settled cleanly + * @param reason - the peer-supplied close reason, or `null` if none + */ +export const refusalError = (received: number, failure: unknown, reason: string | null): Error | null => { + if (received !== 0 || (failure === null && reason === null)) return null + const detail = reason ?? (failure instanceof Error ? failure.message : String(failure)) + return new Error(`iroh connect: refused or no response from remote (${detail})`) +} + +/** + * Dial the remote bridge identified by `config.target` and bridge a local + * client to it. If the remote rejects this node (not allowlisted) it closes the + * connection before any data flows; with no bytes received and a peer-supplied + * close reason, that surfaces here as a clear, non-zero-exit error. + */ +export const runIrohConnect = async (config: ConnectConfig): Promise => { + const { endpoint, connection } = await dial(config.target, config.protocol) + + let received = 0 + let failure: unknown = null + try { + received = + config.command.length > 0 + ? await bridgeLocalCommand(connection, config.command) + : await bridgeProcessStdio(connection) + } catch (err) { + failure = err + } + + // Capture the peer's close reason before tearing down our endpoint, which + // would otherwise overwrite it with our own local close. + const reason = connection.closeReason() + await endpoint.close() + + const refusal = refusalError(received, failure, reason) + if (refusal) throw refusal +} diff --git a/cli/src/iroh/endpoint.test.ts b/cli/src/iroh/endpoint.test.ts new file mode 100644 index 000000000..355ea8630 --- /dev/null +++ b/cli/src/iroh/endpoint.test.ts @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Transport-configuration tests for the iroh endpoint. The relay-override seam is + * exercised with a fake builder + fake configurator, so neither a native iroh + * endpoint nor a relay is bound — only the decision (n0 default vs custom relay) + * is asserted. + */ + +import { afterEach, describe, expect, it, mock } from 'bun:test' +import type { EndpointBuilder, RelayMode } from '@number0/iroh' +import { EndpointAddr, EndpointId, EndpointTicket, SecretKey } from '@number0/iroh' +import { configureTransport, relayUrlOverride, resolveTarget } from './endpoint.ts' + +const RELAY_ENV = 'THUNDERBOLT_IROH_RELAY_URL' + +afterEach(() => { + delete process.env[RELAY_ENV] +}) + +describe('relayUrlOverride', () => { + it('is undefined when the env var is unset', () => { + expect(relayUrlOverride({})).toBeUndefined() + }) + + it('is undefined when the env var is empty or whitespace', () => { + expect(relayUrlOverride({ [RELAY_ENV]: '' })).toBeUndefined() + expect(relayUrlOverride({ [RELAY_ENV]: ' ' })).toBeUndefined() + }) + + it('returns the trimmed url when the env var is set', () => { + expect(relayUrlOverride({ [RELAY_ENV]: ' wss://relay.example ' })).toBe('wss://relay.example') + }) +}) + +type FakeBuilder = EndpointBuilder & { relayMode: ReturnType } + +const makeConfigurator = () => { + const relayMode = { custom: true } as unknown as RelayMode + return { + applyPreset: mock<(builder: EndpointBuilder) => void>(() => {}), + customRelayMode: mock<(urls: string[]) => RelayMode>(() => relayMode), + relayMode, + } +} + +const makeBuilder = (): FakeBuilder => ({ relayMode: mock(() => {}) }) as unknown as FakeBuilder + +describe('configureTransport', () => { + it('applies the n0 preset and leaves the relay untouched when the env var is unset', () => { + const builder = makeBuilder() + const cfg = makeConfigurator() + configureTransport(builder, cfg) + expect(cfg.applyPreset).toHaveBeenCalledTimes(1) + expect(cfg.customRelayMode).not.toHaveBeenCalled() + expect(builder.relayMode).not.toHaveBeenCalled() + }) + + it('threads the custom relay url through to the builder when the env var is set', () => { + process.env[RELAY_ENV] = 'wss://relay.example' + const builder = makeBuilder() + const cfg = makeConfigurator() + configureTransport(builder, cfg) + expect(cfg.applyPreset).toHaveBeenCalledTimes(1) + expect(cfg.customRelayMode).toHaveBeenCalledWith(['wss://relay.example']) + expect(builder.relayMode).toHaveBeenCalledWith(cfg.relayMode) + }) +}) + +describe('resolveTarget — ticket vs bare NodeId', () => { + const nodeId = SecretKey.generate().public().toString() + const relayUrl = 'https://relay.example./' + const ticket = EndpointTicket.fromAddr( + new EndpointAddr(EndpointId.fromString(nodeId), relayUrl, []), + ).toString() + + it('resolves a bare NodeId to an addr for that node with no relay (relies on n0 discovery)', () => { + const addr = resolveTarget(nodeId) + expect(addr.id().toString()).toBe(nodeId) + expect(addr.relayUrl()).toBeNull() + expect(addr.directAddresses()).toEqual([]) + }) + + it('resolves a full ticket via the ticket branch, carrying its relay URL through', () => { + // Proves the ticket path was taken: the bare-NodeId fallback would throw on a + // ticket, and a bare NodeId would have a null relay — this addr carries one. + expect(() => EndpointId.fromString(ticket)).toThrow() + const addr = resolveTarget(ticket) + expect(addr.id().toString()).toBe(nodeId) + expect(addr.relayUrl()).toBe(relayUrl) + }) + + it('throws on a target that is neither a ticket nor a NodeId', () => { + expect(() => resolveTarget('not-a-real-target')).toThrow() + }) +}) diff --git a/cli/src/iroh/endpoint.ts b/cli/src/iroh/endpoint.ts new file mode 100644 index 000000000..f23eb46b2 --- /dev/null +++ b/cli/src/iroh/endpoint.ts @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * iroh endpoint helpers: bind a server (agent host) or dial a peer (controller), + * both pinned to this machine's persistent identity and speaking one ALPN. + * + * Transport shape (verified against the n0 relays): + * builder → configureTransport (n0 preset: relays + discovery + crypto, with + * the relay optionally overridden via THUNDERBOLT_IROH_RELAY_URL) → secretKey + * (stable NodeId) → [server only] alpns → bind → online (home relay ready). + * A connection ticket embeds the NodeId + home-relay URL, so it is the natural + * out-of-band pairing primitive; a bare NodeId also dials via n0 DNS discovery. + */ + +import { Endpoint, EndpointAddr, EndpointId, EndpointTicket, RelayMode, presetN0 } from '@number0/iroh' +import type { Connection, EndpointBuilder } from '@number0/iroh' +import type { BridgeProtocol } from '../agent/types.ts' +import { loadOrCreateIdentity } from './identity.ts' + +/** Env var that overrides the iroh relay. Unset/empty keeps the n0 public relays + * (today's behavior); set it to a self-hosted iroh-relay wss URL to switch with + * no code change. Read at runtime, so the CLI binary needs no rebuild. */ +const RELAY_URL_ENV = 'THUNDERBOLT_IROH_RELAY_URL' + +/** The self-hosted relay override from {@link RELAY_URL_ENV}, or `undefined` to + * keep the n0 public relays. Whitespace-only is treated as unset, so an exported + * but blank var is byte-for-byte the default. */ +export const relayUrlOverride = (env: NodeJS.ProcessEnv = process.env): string | undefined => { + const url = env[RELAY_URL_ENV]?.trim() + return url ? url : undefined +} + +/** The two @number0/iroh calls {@link configureTransport} makes, behind a seam so + * a test can pass a fake builder and assert the relay override is threaded + * through without binding a native endpoint. Defaults to the real SDK. */ +export type TransportConfigurator = { + readonly applyPreset: (builder: EndpointBuilder) => void + readonly customRelayMode: (urls: string[]) => RelayMode +} + +const defaultConfigurator: TransportConfigurator = { + applyPreset: presetN0, + customRelayMode: (urls) => RelayMode.customFromUrls(urls), +} + +/** + * Apply the n0 transport preset (relays + n0 DNS discovery + crypto), then — only + * when {@link RELAY_URL_ENV} is set — swap ONLY the relay for that self-hosted + * URL. n0 DNS discovery + crypto stay intact, so a bare NodeId still resolves and + * tickets still dial; only the relay hop changes. Unset/empty env leaves the n0 + * default untouched (zero regression to the proven flows). + * + * @param builder - the endpoint builder to configure in place + * @param configurator - test seam; production uses the real @number0/iroh SDK + */ +export const configureTransport = ( + builder: EndpointBuilder, + configurator: TransportConfigurator = defaultConfigurator, +): void => { + configurator.applyPreset(builder) + const relayUrl = relayUrlOverride() + if (relayUrl) { + builder.relayMode(configurator.customRelayMode([relayUrl])) + } +} + +/** Per-protocol ALPN: one string per protocol version. Both peers must match or + * the handshake is refused — so an ACP client can't drive an MCP bridge (or + * vice-versa). Each bridge protocol also binds a distinct identity (distinct + * NodeId), so the separation holds even when a stale address resolves the + * wrong-protocol process, not just at the ALPN check. */ +const alpnStringFor = (protocol: BridgeProtocol): string => `thunderbolt/${protocol}/0` + +/** The protocol's ALPN as the byte array the iroh API expects. */ +export const alpnFor = (protocol: BridgeProtocol): number[] => Array.from(Buffer.from(alpnStringFor(protocol), 'utf8')) + +/** A bound server endpoint plus the two identifiers operators share to be + * reached: the bare NodeId and a full connection ticket (NodeId + relay). */ +export type ServerEndpoint = { + readonly endpoint: Endpoint + readonly nodeId: string + readonly ticket: string +} + +/** A dialed peer: the live connection and the local endpoint backing it. */ +export type DialedEndpoint = { + readonly endpoint: Endpoint + readonly connection: Connection +} + +/** + * Bind a server endpoint on this protocol's persistent identity, advertising the + * protocol's ALPN, and wait until a home relay is usable so the returned ticket + * is dialable. Each protocol loads a distinct identity, so the acp and mcp + * bridges publish distinct NodeIds and a ticket can only reach its own bridge. + * + * @param protocol - the protocol whose identity to pin and ALPN to advertise + */ +export const bindServer = async (protocol: BridgeProtocol): Promise => { + const { secretKeyBytes } = await loadOrCreateIdentity(protocol) + const builder = Endpoint.builder() + configureTransport(builder) + builder.secretKey([...secretKeyBytes]) + builder.alpns([alpnFor(protocol)]) + const endpoint = await builder.bind() + await endpoint.online() + const ticket = EndpointTicket.fromAddr(endpoint.addr()).toString() + return { endpoint, nodeId: endpoint.id().toString(), ticket } +} + +/** Resolve a `--connect` argument (ticket *or* bare NodeId) to an address. A + * ticket carries the home-relay URL; a bare NodeId relies on n0 discovery. */ +export const resolveTarget = (target: string): EndpointAddr => { + try { + return EndpointTicket.fromString(target).endpointAddr() + } catch { + return new EndpointAddr(EndpointId.fromString(target), null, []) + } +} + +/** + * Dial a peer by ticket or NodeId over the protocol's ALPN, using this machine's + * stable client identity so the remote can recognize (and allowlist) us. The + * dialer NodeId stays on the legacy `acp` identity regardless of the dialed + * protocol — it is the single client NodeId a remote bridge allowlists, so it + * must not fork per-protocol (only the bridge accept-side is per-protocol). + * + * @param target - a connection ticket or a bare NodeId + * @param protocol - the protocol whose ALPN to request + */ +export const dial = async (target: string, protocol: BridgeProtocol): Promise => { + const addr = resolveTarget(target) + const { secretKeyBytes } = await loadOrCreateIdentity('acp') + const builder = Endpoint.builder() + configureTransport(builder) + builder.secretKey([...secretKeyBytes]) + const endpoint = await builder.bind() + await endpoint.online() + const connection = await endpoint.connect(addr, alpnFor(protocol)) + return { endpoint, connection } +} diff --git a/cli/src/iroh/identity.test.ts b/cli/src/iroh/identity.test.ts new file mode 100644 index 000000000..31b4f05fe --- /dev/null +++ b/cli/src/iroh/identity.test.ts @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for persistent node identity. The security-critical properties are: + * each bridge protocol gets a *distinct* NodeId (so an acp ticket can't + * authenticate the mcp bridge), the NodeId is *stable across runs* (what makes + * an allowlist meaningful), the returned NodeId is genuinely derived from the + * returned secret bytes, and the private-key file self-heals to 0600. The `acp` + * identity keeps the legacy `identity` filename so existing pairings survive. + * Real native key + real temp home. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { SecretKey } from '@number0/iroh' +import { appendFile, chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BridgeProtocol } from '../agent/types.ts' +import { loadOrCreateIdentity } from './identity.ts' +import { identityPath, irohDir } from './paths.ts' + +let home: string +const prevHome = process.env.THUNDERBOLT_HOME + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'tb-identity-')) + process.env.THUNDERBOLT_HOME = home +}) + +afterEach(async () => { + if (prevHome === undefined) delete process.env.THUNDERBOLT_HOME + else process.env.THUNDERBOLT_HOME = prevHome + await rm(home, { recursive: true, force: true }) +}) + +const modeOf = async (path: string): Promise => (await stat(path)).mode & 0o777 + +const protocols: BridgeProtocol[] = ['acp', 'mcp'] + +describe('loadOrCreateIdentity', () => { + it('gives each protocol a distinct NodeId and distinct secret bytes', async () => { + const acp = await loadOrCreateIdentity('acp') + const mcp = await loadOrCreateIdentity('mcp') + expect(mcp.nodeId).not.toBe(acp.nodeId) + expect([...mcp.secretKeyBytes]).not.toEqual([...acp.secretKeyBytes]) + }) + + for (const protocol of protocols) { + it(`derives the returned NodeId from the returned secret bytes (${protocol})`, async () => { + const { secretKeyBytes, nodeId } = await loadOrCreateIdentity(protocol) + const expected = SecretKey.fromBytes([...secretKeyBytes]).public().toString() + expect(nodeId).toBe(expected) + }) + + it(`persists a stable NodeId across calls — the allowlist depends on this (${protocol})`, async () => { + const first = await loadOrCreateIdentity(protocol) + const second = await loadOrCreateIdentity(protocol) + expect(second.nodeId).toBe(first.nodeId) + expect([...second.secretKeyBytes]).toEqual([...first.secretKeyBytes]) + }) + + it(`writes the private-key file owner-only, 0600 (${protocol})`, async () => { + await loadOrCreateIdentity(protocol) + expect(await modeOf(identityPath(protocol))).toBe(0o600) + }) + + it(`self-heals a key restored with lax permissions back to 0600 (${protocol})`, async () => { + const { nodeId } = await loadOrCreateIdentity(protocol) + await chmod(identityPath(protocol), 0o644) + expect(await modeOf(identityPath(protocol))).toBe(0o644) + const reloaded = await loadOrCreateIdentity(protocol) + expect(reloaded.nodeId).toBe(nodeId) + expect(await modeOf(identityPath(protocol))).toBe(0o600) + }) + + it(`tolerates trailing whitespace in the stored hex — trims before decoding (${protocol})`, async () => { + const { nodeId } = await loadOrCreateIdentity(protocol) + await appendFile(identityPath(protocol), '\n') + const reloaded = await loadOrCreateIdentity(protocol) + expect(reloaded.nodeId).toBe(nodeId) + }) + + it(`surfaces a corrupt (wrong-length) identity file loudly instead of inventing a key (${protocol})`, async () => { + await mkdir(irohDir(), { recursive: true }) + await writeFile(identityPath(protocol), 'deadbeef') // 4 bytes, not the required 32 + await expect(loadOrCreateIdentity(protocol)).rejects.toThrow(/32 bytes/) + }) + } + + it('reads the legacy `identity` file for acp so existing pairings survive, and does NOT reuse it for mcp', async () => { + const legacySecret = SecretKey.generate().toBytes() + const legacyNodeId = SecretKey.fromBytes([...legacySecret]).public().toString() + await mkdir(irohDir(), { recursive: true }) + await writeFile(identityPath('acp'), Buffer.from(legacySecret).toString('hex')) + + const acp = await loadOrCreateIdentity('acp') + expect(acp.nodeId).toBe(legacyNodeId) + + const mcp = await loadOrCreateIdentity('mcp') + expect(mcp.nodeId).not.toBe(legacyNodeId) + expect([...mcp.secretKeyBytes]).not.toEqual([...legacySecret]) + }) +}) diff --git a/cli/src/iroh/identity.ts b/cli/src/iroh/identity.ts new file mode 100644 index 000000000..cc1dc23e7 --- /dev/null +++ b/cli/src/iroh/identity.ts @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Persistent node identity for the iroh transport. + * + * An iroh NodeId is the base32 form of a 32-byte ed25519 public key, so the + * identity *is* the cryptographic peer credential: persisting the secret key + * keeps this machine's NodeId stable across runs, which is what makes a peer + * allowlist (and, later, device-binding into the E2E key hierarchy) meaningful. + * Each bridge protocol gets its own identity file (see {@link identityPath}), so + * the acp and mcp bridges publish distinct NodeIds. The secret is stored + * hex-encoded under `~/.thunderbolt/iroh/` with 0600 permissions. + */ + +import { SecretKey } from '@number0/iroh' +import type { BridgeProtocol } from '../agent/types.ts' +import { irohDir, identityPath } from './paths.ts' +import { enforceSecureFile, readFileOrNull, writeSecureFile } from './storage.ts' + +/** A loaded node identity: the raw secret-key bytes to pin onto an endpoint, + * plus the derived public NodeId (base32) to share and allowlist. */ +export type IrohIdentity = { + /** 32-byte ed25519 secret key, ready for `EndpointBuilder.secretKey`. */ + readonly secretKeyBytes: readonly number[] + /** Base32 NodeId (the ed25519 public key) identifying this node to peers. */ + readonly nodeId: string +} + +/** Derive the NodeId (base32-ish key string) from secret-key bytes. */ +const nodeIdOf = (secretKeyBytes: readonly number[]): string => + SecretKey.fromBytes([...secretKeyBytes]) + .public() + .toString() + +/** + * Load this machine's persisted node identity for a bridge protocol, generating + * and saving a fresh one on first use. Each protocol has its own file so the acp + * and mcp bridges publish distinct NodeIds. The secret is stored hex-encoded; + * its file is forced to `0600` on every load so a key restored with lax + * permissions self-heals. + * + * @param protocol - the bridge protocol whose identity to load + * @returns the secret-key bytes and the derived NodeId + */ +export const loadOrCreateIdentity = async (protocol: BridgeProtocol): Promise => { + const path = identityPath(protocol) + const existing = await readFileOrNull(path) + if (existing !== null) { + await enforceSecureFile(path) + const secretKeyBytes = [...Buffer.from(existing.trim(), 'hex')] + return { secretKeyBytes, nodeId: nodeIdOf(secretKeyBytes) } + } + + const secretKeyBytes = SecretKey.generate().toBytes() + await writeSecureFile(irohDir(), path, Buffer.from(secretKeyBytes).toString('hex')) + return { secretKeyBytes, nodeId: nodeIdOf(secretKeyBytes) } +} diff --git a/cli/src/iroh/paths.test.ts b/cli/src/iroh/paths.test.ts new file mode 100644 index 000000000..1d80175db --- /dev/null +++ b/cli/src/iroh/paths.test.ts @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The identity path is per-protocol: each bridge protocol resolves to a distinct + * file (so the acp/mcp bridges get distinct NodeIds), and `acp` keeps the legacy + * `identity` basename so existing pairings survive untouched. + */ + +import { describe, expect, it } from 'bun:test' +import { basename } from 'node:path' +import { identityPath } from './paths.ts' + +describe('identityPath', () => { + it('resolves acp and mcp to distinct files', () => { + expect(identityPath('acp')).not.toBe(identityPath('mcp')) + }) + + it('keeps the legacy `identity` basename for acp (pairing continuity)', () => { + expect(basename(identityPath('acp'))).toBe('identity') + }) + + it('names the mcp identity `identity-mcp`', () => { + expect(basename(identityPath('mcp'))).toBe('identity-mcp') + }) +}) diff --git a/cli/src/iroh/paths.ts b/cli/src/iroh/paths.ts new file mode 100644 index 000000000..a1dc53ea6 --- /dev/null +++ b/cli/src/iroh/paths.ts @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Filesystem locations for the iroh transport's persistent state (identity and + * peer allowlist). Everything lives under a single per-user home directory, + * `~/.thunderbolt/iroh/`, overridable via `THUNDERBOLT_HOME` — the override is + * what lets a second identity be exercised on the same machine (e.g. proving + * the allowlist gate rejects an unknown peer). + */ + +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { BridgeProtocol } from '../agent/types.ts' + +/** Root for all thunderbolt CLI state. `THUNDERBOLT_HOME` overrides the default + * `~/.thunderbolt`, enabling isolated identities for testing/multi-account. */ +const baseDir = (): string => process.env.THUNDERBOLT_HOME ?? join(homedir(), '.thunderbolt') + +/** Directory holding the iroh identity and allowlist. */ +export const irohDir = (): string => join(baseDir(), 'iroh') + +/** Path to a protocol's persisted 32-byte node secret key (hex-encoded, mode + * 0600). Each bridge protocol gets a distinct NodeId by loading a distinct + * file, so an ACP ticket can never authenticate the MCP bridge (or vice-versa) + * when a stale address resolves the wrong process. `acp` keeps the legacy + * `identity` filename so every existing ACP pairing survives untouched. */ +export const identityPath = (protocol: BridgeProtocol): string => + join(irohDir(), protocol === 'acp' ? 'identity' : `identity-${protocol}`) + +/** Path to the newline-delimited list of allowed peer NodeIds. */ +export const allowlistPath = (): string => join(irohDir(), 'allowlist') diff --git a/cli/src/iroh/pump.test.ts b/cli/src/iroh/pump.test.ts new file mode 100644 index 000000000..1495b4058 --- /dev/null +++ b/cli/src/iroh/pump.test.ts @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the byte pumps. The load-bearing behaviors are the teardown + * guarantees: `forwardToSend` MUST finish the stream on the error path too + * (clean-FIN vs error-FIN), `forwardFromRecv` MUST stop on a zero-length read + * and MUST apply backpressure (await the sink before the next read), and + * `writeToStdin` MUST rethrow (and log) a flush failure rather than swallow it. + * Streams/sinks are injected fakes — DI over mocking. + */ + +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test' +import type { FileSink } from 'bun' +import type { RecvStream, SendStream } from '@number0/iroh' +import { forwardFromRecv, forwardToSend, writeToStdin } from './pump.ts' + +let stderr: ReturnType +beforeEach(() => { + stderr = spyOn(process.stderr, 'write').mockImplementation(() => true) +}) +afterEach(() => { + stderr.mockRestore() +}) + +/** A readable stream that yields the given chunks then ends. */ +const streamOf = (chunks: Uint8Array[]): ReadableStream => + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk) + controller.close() + }, + }) + +describe('writeToStdin', () => { + it('writes the chunk and awaits the flush on success', async () => { + const calls: string[] = [] + const sink = { + write: mock(() => { + calls.push('write') + return 1 + }), + flush: mock(async () => { + calls.push('flush') + return 0 + }), + } as unknown as FileSink + const chunk = Uint8Array.from([1, 2, 3]) + await writeToStdin(sink, chunk, 'bridge') + expect((sink.write as ReturnType).mock.calls[0][0]).toBe(chunk) + expect(calls).toEqual(['write', 'flush']) + }) + + it('logs loudly and rethrows when the flush fails (e.g. EPIPE on a dead pipe)', async () => { + const boom = new Error('EPIPE') + const sink = { + write: mock(() => 1), + flush: mock(async () => { + throw boom + }), + } as unknown as FileSink + await expect(writeToStdin(sink, Uint8Array.from([1]), 'connect')).rejects.toBe(boom) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0][0])).toContain('connect') + }) + + it('logs loudly and rethrows when the synchronous write itself throws', async () => { + const boom = new Error('write blew up') + const sink = { + write: mock(() => { + throw boom + }), + flush: mock(async () => 0), + } as unknown as FileSink + await expect(writeToStdin(sink, Uint8Array.from([1]), 'bridge')).rejects.toBe(boom) + expect((sink.flush as ReturnType)).not.toHaveBeenCalled() + expect(stderr).toHaveBeenCalledTimes(1) + }) +}) + +describe('forwardToSend', () => { + it('writes each non-empty chunk verbatim and finishes once on clean EOF', async () => { + const written: number[][] = [] + const send = { + writeAll: mock(async (buf: number[]) => { + written.push(buf) + }), + finish: mock(async () => {}), + } as unknown as SendStream + await forwardToSend(streamOf([Uint8Array.from([1, 2]), Uint8Array.from([3])]), send) + expect(written).toEqual([[1, 2], [3]]) + expect((send.finish as ReturnType)).toHaveBeenCalledTimes(1) + }) + + it('skips zero-length chunks (never writes an empty frame)', async () => { + const send = { + writeAll: mock(async () => {}), + finish: mock(async () => {}), + } as unknown as SendStream + await forwardToSend(streamOf([Uint8Array.from([]), Uint8Array.from([9])]), send) + expect((send.writeAll as ReturnType)).toHaveBeenCalledTimes(1) + expect((send.writeAll as ReturnType).mock.calls[0][0]).toEqual([9]) + }) + + it('still finishes the send half when the source errors mid-stream (error-FIN)', async () => { + const boom = new Error('source exploded') + const source = new ReadableStream({ + pull() { + throw boom + }, + }) + const send = { + writeAll: mock(async () => {}), + finish: mock(async () => {}), + } as unknown as SendStream + await expect(forwardToSend(source, send)).rejects.toBe(boom) + expect((send.finish as ReturnType)).toHaveBeenCalledTimes(1) + }) + + it('still finishes the send half when writeAll itself rejects (error-FIN on the write path)', async () => { + const boom = new Error('stream reset') + const send = { + writeAll: mock(async () => { + throw boom + }), + finish: mock(async () => {}), + } as unknown as SendStream + await expect(forwardToSend(streamOf([Uint8Array.from([1])]), send)).rejects.toBe(boom) + expect((send.finish as ReturnType)).toHaveBeenCalledTimes(1) + }) +}) + +describe('forwardFromRecv', () => { + it('copies chunks (as Uint8Array) until a zero-length read signals EOF', async () => { + const queue = [[1, 2, 3], [4], []] + const recv = { + read: mock(async () => queue.shift() ?? []), + } as unknown as RecvStream + const received: Uint8Array[] = [] + await forwardFromRecv(recv, (chunk) => { + received.push(chunk) + }) + expect(received.map((c) => [...c])).toEqual([[1, 2, 3], [4]]) + // 3 reads: two data chunks + the terminating empty read. + expect((recv.read as ReturnType)).toHaveBeenCalledTimes(3) + // Each read is bounded by READ_CHUNK_LIMIT (1 << 16). + expect((recv.read as ReturnType).mock.calls[0][0]).toBe(1 << 16) + }) + + it('propagates a sink rejection and stops reading (e.g. stdin EPIPE tears the pump down)', async () => { + const boom = new Error('EPIPE') + const recv = { + read: mock(async () => [1, 2]), + } as unknown as RecvStream + await expect( + forwardFromRecv(recv, () => { + throw boom + }), + ).rejects.toBe(boom) + // The loop must not keep draining the recv stream after the sink failed. + expect((recv.read as ReturnType)).toHaveBeenCalledTimes(1) + }) + + it('applies backpressure: does not read again until the sink settles', async () => { + const queue = [[1], []] + const recv = { + read: mock(async () => queue.shift() ?? []), + } as unknown as RecvStream + let releaseSink: () => void = () => {} + const gate = new Promise((resolve) => { + releaseSink = resolve + }) + let sinkCalls = 0 + const done = forwardFromRecv(recv, () => { + sinkCalls += 1 + return gate + }) + // Drain all microtasks (macrotask flush), then assert we're parked on the sink. + await new Promise((r) => setTimeout(r, 0)) + expect(sinkCalls).toBe(1) + expect((recv.read as ReturnType)).toHaveBeenCalledTimes(1) + releaseSink() + await done + expect((recv.read as ReturnType)).toHaveBeenCalledTimes(2) + }) +}) diff --git a/cli/src/iroh/pump.ts b/cli/src/iroh/pump.ts new file mode 100644 index 000000000..fe034c9dd --- /dev/null +++ b/cli/src/iroh/pump.ts @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Byte pumps between a process's stdio and an iroh bidirectional QUIC stream. + * + * Unlike the WebSocket bridge, an iroh bidi stream is a raw byte pipe with no + * frame boundaries — but ACP/MCP stdio is newline-delimited JSON, which is + * self-delimiting, so each direction is a verbatim byte copy. The ndjson + * newlines that the WebSocket path has to re-insert/re-split are carried + * through untouched here, keeping both halves symmetric and framing-agnostic. + */ + +import type { FileSink } from 'bun' +import type { RecvStream, SendStream } from '@number0/iroh' + +/** Max bytes pulled per `recv.read`; a comfortably large ceiling for JSON-RPC. */ +const READ_CHUNK_LIMIT = 1 << 16 + +/** + * Write a chunk into a subprocess's stdin {@link FileSink} and await its flush, + * so the iroh read loop driving this sink respects the subprocess's stdin + * backpressure instead of buffering received bytes without bound. + * + * A write/flush failure — most commonly `EPIPE` once the agent has exited and + * closed its stdin — is logged loudly (per the loud-failure rule) and rethrown + * so the pump stops rather than spinning on a dead pipe. + * + * @param sink - the subprocess stdin to write into + * @param chunk - the bytes to write + * @param label - short context for the error log (e.g. `bridge`, `connect`) + */ +export const writeToStdin = async (sink: FileSink, chunk: Uint8Array, label: string): Promise => { + try { + sink.write(chunk) + await sink.flush() + } catch (err) { + process.stderr.write(`⚡ iroh ${label}: stdin write failed: ${err instanceof Error ? err.message : String(err)}\n`) + throw err + } +} + +/** + * Copy a readable byte source (e.g. a subprocess `stdout` or `Bun.stdin`) into + * an iroh send stream, finishing the stream when the source ends. + * + * @param source - the readable side to drain + * @param send - the iroh send half to write into + */ +export const forwardToSend = async (source: ReadableStream, send: SendStream): Promise => { + const reader = source.getReader() + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + if (value && value.length > 0) await send.writeAll(Array.from(value)) + } + } finally { + // Finish the send half on both the clean-EOF and error paths so the peer + // always observes the stream end (and never blocks on its read side). + reader.releaseLock() + await send.finish() + } +} + +/** + * Copy an iroh recv stream into a byte sink (e.g. a subprocess `stdin` or this + * process's `stdout`), returning when the remote finishes the stream (a zero- + * length read signals EOF). + * + * The sink is awaited so a slow consumer throttles the read loop instead of the + * received bytes piling up unbounded: a sink that returns its write's + * backpressure promise (a `FileSink.flush()` or a stdout `drain`) gates the next + * `recv.read` until the previous chunk has actually drained. + * + * @param recv - the iroh recv half to drain + * @param sink - called with each received chunk; await it to apply backpressure + */ +export const forwardFromRecv = async ( + recv: RecvStream, + sink: (chunk: Uint8Array) => void | Promise, +): Promise => { + while (true) { + const chunk = await recv.read(READ_CHUNK_LIMIT) + if (chunk.length === 0) break + await sink(Uint8Array.from(chunk)) + } +} diff --git a/cli/src/iroh/storage.test.ts b/cli/src/iroh/storage.test.ts new file mode 100644 index 000000000..af811e7a4 --- /dev/null +++ b/cli/src/iroh/storage.test.ts @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the security-sensitive filesystem helpers. The whole point of these + * helpers is the 0600/0700 permission invariant (the files are a private key and + * an auth gate), so the assertions check the actual on-disk mode against a real + * temp dir — not a mocked fs. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { enforceSecureFile, ensureSecureDir, readFileOrNull, writeSecureFile } from './storage.ts' + +let dir: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'tb-storage-')) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +/** The permission bits (`& 0o777`) of a path. */ +const modeOf = async (path: string): Promise => (await stat(path)).mode & 0o777 + +describe('readFileOrNull', () => { + it('returns null for a non-existent file (expected first-run, not a failure)', async () => { + expect(await readFileOrNull(join(dir, 'nope'))).toBeNull() + }) + + it('returns the file contents when present', async () => { + const path = join(dir, 'f') + await writeFile(path, 'hello') + expect(await readFileOrNull(path)).toBe('hello') + }) + + it('rethrows a non-ENOENT error rather than masking it as missing', async () => { + // Reading a directory yields EISDIR, not ENOENT — must surface loudly. + await expect(readFileOrNull(dir)).rejects.toThrow() + }) +}) + +describe('writeSecureFile', () => { + it('creates the dir 0700 and the file 0600', async () => { + const sub = join(dir, 'iroh') + const path = join(sub, 'identity') + await writeSecureFile(sub, path, 'secret') + expect(await readFileOrNull(path)).toBe('secret') + expect(await modeOf(path)).toBe(0o600) + expect(await modeOf(sub)).toBe(0o700) + }) + + it('repairs an existing too-permissive parent dir down to 0700', async () => { + const sub = join(dir, 'iroh') + await mkdir(sub, { mode: 0o755 }) + await chmod(sub, 0o755) + await writeSecureFile(sub, join(sub, 'identity'), 'secret') + expect(await modeOf(sub)).toBe(0o700) + }) + + it('re-chmods an existing too-permissive file back to 0600 (defeats a lax umask)', async () => { + const path = join(dir, 'f') + await writeFile(path, 'old', { mode: 0o644 }) + await chmod(path, 0o644) + expect(await modeOf(path)).toBe(0o644) + await writeSecureFile(dir, path, 'new') + expect(await modeOf(path)).toBe(0o600) + expect(await readFileOrNull(path)).toBe('new') + }) +}) + +describe('ensureSecureDir', () => { + it('forces an existing permissive dir down to 0700', async () => { + const sub = join(dir, 'loose') + await mkdir(sub, { mode: 0o755 }) + await chmod(sub, 0o755) + await ensureSecureDir(sub) + expect(await modeOf(sub)).toBe(0o700) + }) +}) + +describe('enforceSecureFile', () => { + it('chmods a file to owner-only 0600', async () => { + const path = join(dir, 'f') + await writeFile(path, 'x', { mode: 0o666 }) + await chmod(path, 0o666) + await enforceSecureFile(path) + expect(await modeOf(path)).toBe(0o600) + }) +}) diff --git a/cli/src/iroh/storage.ts b/cli/src/iroh/storage.ts new file mode 100644 index 000000000..31eea84dc --- /dev/null +++ b/cli/src/iroh/storage.ts @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Filesystem helpers shared by the iroh identity and allowlist stores. + * + * Both files are security-sensitive — the identity *is* the node's private key, + * and the allowlist *is* the authorization gate — so they are always written and + * re-`chmod`ed to owner-only (`0600` files inside a `0700` dir). Enforcing the + * mode on every read/write means a key restored from a lax backup or synced with + * loose permissions self-heals on next use rather than silently staying exposed. + * + * A not-yet-created file is an expected first-run condition, not a failure — so + * it maps to `null`; any other read error surfaces loudly. + */ + +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' + +/** Owner-only file mode for the secret/allowlist files. */ +const FILE_MODE = 0o600 +/** Owner-only directory mode for the iroh state dir. */ +const DIR_MODE = 0o700 + +/** + * Read a UTF-8 file, returning `null` only when it does not exist. All other + * errors propagate so genuine problems aren't masked as a missing file. + * + * @param path - absolute path to read + */ +export const readFileOrNull = async (path: string): Promise => { + try { + return await readFile(path, 'utf8') + } catch (err) { + if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } +} + +/** Create (if needed) and lock down a directory to owner-only (`0700`). */ +export const ensureSecureDir = async (dir: string): Promise => { + await mkdir(dir, { recursive: true, mode: DIR_MODE }) + await chmod(dir, DIR_MODE) +} + +/** Force a file to owner-only (`0600`). Idempotent; safe to call on every load. */ +export const enforceSecureFile = async (path: string): Promise => { + await chmod(path, FILE_MODE) +} + +/** + * Write a security-sensitive file owner-only: ensure the parent dir is `0700`, + * write `0600`, then re-`chmod` to defeat a permissive umask. + * + * @param dir - the owning directory (created + locked down if absent) + * @param path - absolute file path within `dir` + * @param contents - the bytes/text to write + */ +export const writeSecureFile = async (dir: string, path: string, contents: string): Promise => { + await ensureSecureDir(dir) + await writeFile(path, contents, { mode: FILE_MODE }) + await chmod(path, FILE_MODE) +} diff --git a/cli/src/ui/prompt.ts b/cli/src/ui/prompt.ts new file mode 100644 index 000000000..cd64c03cd --- /dev/null +++ b/cli/src/ui/prompt.ts @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createInterface } from 'node:readline/promises' +import type { PermissionDecision, PermissionRequest, TerminalIO } from '../agent/types.ts' + +/** + * Creates the interactive terminal I/O used by both the REPL input loop and the + * permission gate. A single `node:readline/promises` interface backs every read + * so the two never contend for stdin. + * + * @returns a {@link TerminalIO} bound to process stdin/stdout + */ +export const createTerminalIO = (): TerminalIO => { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + // readline emits 'close' when stdin ends (Ctrl-D / closed pipe). A pending + // `question()` otherwise hangs forever, so abort it on close — that rejects + // the read, which `readLine` turns into a `null` end-of-input signal. + const eof = new AbortController() + rl.on('close', () => eof.abort()) + + const readLine = async (prompt: string): Promise => { + try { + const answer = await rl.question(prompt, { signal: eof.signal }) + return answer.trim() + } catch { + return null + } + } + + const ask = async (request: PermissionRequest): Promise => { + const block = ['', `\x1b[33m⚠ allow ${request.toolName}?\x1b[0m`, ` ${request.summary}`] + if (request.detail) block.push('', request.detail) + process.stdout.write(`${block.join('\n')}\n`) + + const answer = (await readLine('Allow? [y]es / [a]lways / [N]o: '))?.toLowerCase() + if (answer === 'y' || answer === 'yes') return 'allow-once' + if (answer === 'a' || answer === 'always') return 'allow-session' + return 'deny' + } + + const close = (): void => { + rl.close() + } + + return { readLine, ask, close } +} diff --git a/cli/src/ui/render.test.ts b/cli/src/ui/render.test.ts new file mode 100644 index 000000000..8ff78ebcf --- /dev/null +++ b/cli/src/ui/render.test.ts @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Coverage for the pure tool-activity formatters shared by the plain stdout + * renderer and the TUI. Only the branchy string logic is tested (argument + * summarizing, result previewing, truncation, and the error gate) — the ANSI + * styling is environment-dependent, so assertions check for the meaningful + * substrings rather than exact colored output. + */ + +import type { AgentMessage } from '@earendil-works/pi-agent-core' +import { describe, expect, test } from 'bun:test' +import { formatToolEnd, formatToolStart, formatTurnError, sanitizeTerminalText } from './render.ts' + +/** A Pi tool result carrying a single text content block. */ +const textResult = (text: string): unknown => ({ content: [{ type: 'text', text }] }) + +describe('sanitizeTerminalText — control-sequence stripping', () => { + test('strips an OSC 52 clipboard-write sequence', () => { + const result = sanitizeTerminalText('safe\x1b]52;c;aGVsbG8=\x07 after') + expect(result).toBe('safe after') + expect(result).not.toContain('\x1b') + }) + + test('strips a CSI cursor-move sequence', () => { + expect(sanitizeTerminalText('a\x1b[2J\x1b[1;1Hb')).toBe('ab') + }) + + test('strips an OSC window-title-set sequence (BEL-terminated)', () => { + expect(sanitizeTerminalText('\x1b]0;pwned\x07home')).toBe('home') + }) + + test('strips an OSC sequence terminated by ST (ESC backslash)', () => { + expect(sanitizeTerminalText('\x1b]8;;http://evil\x1b\\link')).toBe('link') + }) + + test('strips an SGR color sequence embedded mid-string', () => { + expect(sanitizeTerminalText('red\x1b[31mtext\x1b[0m!')).toBe('redtext!') + }) + + test('defangs a lone/unterminated ESC by dropping its introducer', () => { + // A split or truncated sequence loses its ESC, leaving inert printable text. + expect(sanitizeTerminalText('oops\x1b')).toBe('oops') + expect(sanitizeTerminalText('oops\x1b[2')).toBe('oops2') + }) + + test('strips a raw C1 control byte (single-byte CSI introducer)', () => { + expect(sanitizeTerminalText('a\x9bb')).toBe('ab') + }) + + test('strips lone C0 control bytes but preserves tab and newline', () => { + expect(sanitizeTerminalText('a\x00\x07b\tc\nd')).toBe('ab\tc\nd') + }) + + test('leaves ordinary text untouched', () => { + expect(sanitizeTerminalText('plain text, no escapes 123')).toBe('plain text, no escapes 123') + }) + + test('strips a run of unterminated OSC introducers without leaving an ESC', () => { + // The OSC body is a negated class, so each bare introducer degrades via the + // lone-ESC fallback instead of rescanning to the end (keeps the pass linear). + const result = sanitizeTerminalText('\x1b]'.repeat(500) + 'tail') + expect(result).toBe('tail') + }) + + // These assert the untrusted payload is gone, not the absence of all ESC: the + // app's own color SGR (added by gray() under a TTY) is intentionally kept. + test('flows through formatToolEnd so hostile tool output cannot spoof the terminal', () => { + const line = formatToolEnd(false, textResult('ok\x1b]52;c;cHduZWQ=\x07')) + expect(line).not.toContain('52;') + expect(line).not.toContain('cHduZWQ') + expect(line).toContain('ok') + }) + + test('flows through formatToolStart so a hostile bash command cannot spoof the terminal', () => { + const line = formatToolStart('bash', { command: 'echo\x1b[2Jhi' }) + expect(line).not.toContain('2J') + expect(line).toContain('echohi') + }) +}) + +describe('formatToolStart — argument summary', () => { + test('bash summarizes to its command', () => { + const line = formatToolStart('bash', { command: 'echo hi' }) + expect(line).toContain('bash') + expect(line).toContain('echo hi') + }) + + test('read/write summarize to the target path', () => { + expect(formatToolStart('read', { path: 'src/a.ts' })).toContain('src/a.ts') + }) + + test('an argument object with neither command nor path falls back to JSON', () => { + expect(formatToolStart('weird', { foo: 'bar' })).toContain(JSON.stringify({ foo: 'bar' })) + }) + + test('a non-object argument yields a header with no summary tail', () => { + const line = formatToolStart('read', null) + expect(line).toContain('read') + expect(line).not.toContain('null') + }) + + test('a long command is truncated with an ellipsis', () => { + const line = formatToolStart('bash', { command: 'x'.repeat(500) }) + expect(line).toContain('…') + expect(line).not.toContain('x'.repeat(500)) + }) +}) + +describe('formatToolEnd — result preview', () => { + test('a successful result shows the ok mark and a text preview', () => { + const line = formatToolEnd(false, textResult('all good')) + expect(line).toContain('✓') + expect(line).toContain('all good') + }) + + test('an error result shows the fail mark', () => { + expect(formatToolEnd(true, textResult('boom'))).toContain('✗') + }) + + test('only the first couple of result lines are previewed', () => { + const line = formatToolEnd(false, textResult('line1\nline2\nline3\nline4')) + expect(line).toContain('line1') + expect(line).toContain('line2') + expect(line).not.toContain('line3') + }) + + test('a result with no text content is just the marker', () => { + const line = formatToolEnd(false, { content: [] }) + expect(line).toContain('✓') + expect(line).not.toContain('undefined') + }) +}) + +describe('formatTurnError — error gate', () => { + test('an errored turn returns its detail message', () => { + const message = { stopReason: 'error', errorMessage: 'rate limited' } as unknown as AgentMessage + expect(formatTurnError(message)).toContain('rate limited') + }) + + test('an errored turn with no message uses a generic detail', () => { + const message = { stopReason: 'error' } as unknown as AgentMessage + expect(formatTurnError(message)).toContain('the request failed') + }) + + test('a non-error turn returns undefined', () => { + const message = { stopReason: 'endTurn' } as unknown as AgentMessage + expect(formatTurnError(message)).toBeUndefined() + }) +}) diff --git a/cli/src/ui/render.ts b/cli/src/ui/render.ts new file mode 100644 index 000000000..926f16395 --- /dev/null +++ b/cli/src/ui/render.ts @@ -0,0 +1,179 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Streaming terminal renderer for the thunderbolt CLI. Subscribes to a Pi + * `AgentHarness` and pretty-prints the run as it happens: assistant prose, + * subdued thinking, and colored tool-call activity. + */ + +import type { AgentHarness, AgentMessage } from '@earendil-works/pi-agent-core' +import { cyan, dim, gray, green, red, symbols } from './theme.ts' + +/** Max length of a tool-call argument summary before it's ellipsized. */ +const ARGS_MAX = 100 +/** Max length of a tool-result preview before it's ellipsized. */ +const PREVIEW_MAX = 160 +/** Lines of a tool result shown as a preview. */ +const PREVIEW_LINES = 2 + +/** + * Matches whole ANSI escape sequences: CSI (`ESC[…`), OSC (`ESC]…` up to a BEL + * or ST terminator), and any other `ESC`-introduced form (two-byte escapes down + * to a lone `ESC`). The lone-`ESC` alternative is last so a split or unterminated + * sequence still loses its introducer and degrades to inert printable text. The + * OSC body is a negated class (not `.*?`) so an unterminated introducer stops at + * the next `ESC`/BEL instead of rescanning to the end — keeping the pass linear + * on hostile input like a long run of bare `ESC]`. + */ +const ESCAPE_SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]?/g +/** + * Matches lone C0/C1 control bytes (and DEL) that survive escape-sequence + * removal, deliberately sparing tab (`\x09`) and newline (`\x0a`) — the only + * whitespace the renderer lays out on. + */ +const CONTROL_CHAR = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g + +/** + * Neutralizes terminal control sequences in untrusted text (tool output, + * model-influenced arguments, assistant prose) before it reaches the operator's + * terminal. Strips ANSI escape sequences — OSC 52 clipboard writes, window-title + * and hyperlink spoofs, CSI cursor moves — and lone control bytes, while + * preserving the tab and newline the renderer relies on. Apply this at the trust + * boundary, before wrapping the text in the app's own color SGR. + * + * @param text - the untrusted text to sanitize + * @returns the text with escape sequences and stray control bytes removed + */ +export const sanitizeTerminalText = (text: string): string => + text.replace(ESCAPE_SEQUENCE, '').replace(CONTROL_CHAR, '') + +/** + * Truncates `text` to `max` characters, appending an ellipsis when clipped. + * + * @param text - the text to bound + * @param max - maximum length including the ellipsis + * @returns the original text, or a truncated copy ending in `…` + */ +const truncate = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max - 1)}…` : text) + +/** + * Derives a one-line summary of a tool call's arguments: the shell command for + * `bash`, the target path for `read`/`write`/`edit`, or compact JSON otherwise. + * + * @param args - the tool's arguments (Pi types these loosely, so narrow here) + * @returns a single-line, untruncated summary + */ +const summarizeArgs = (args: unknown): string => { + if (typeof args !== 'object' || args === null) return '' + const record = args as Record + if (typeof record.command === 'string') return sanitizeTerminalText(record.command) + if (typeof record.path === 'string') return sanitizeTerminalText(record.path) + return sanitizeTerminalText(JSON.stringify(record)) +} + +/** Narrows an unknown tool-result content block to one carrying text. */ +const isTextBlock = (block: unknown): block is { text: string } => + typeof block === 'object' && block !== null && typeof (block as { text?: unknown }).text === 'string' + +/** + * Extracts a short preview from a Pi tool result by concatenating its text + * content blocks, then keeping the first couple of lines. + * + * @param result - the tool result (`{ content: [{ type, text }] }`) + * @returns a trimmed, line- and length-bounded preview (empty when none) + */ +const previewResult = (result: unknown): string => { + if (typeof result !== 'object' || result === null) return '' + const content = (result as { content?: unknown }).content + if (!Array.isArray(content)) return '' + const text = sanitizeTerminalText( + content + .filter(isTextBlock) + .map((block) => block.text) + .join(''), + ).trim() + return truncate(text.split('\n').slice(0, PREVIEW_LINES).join('\n'), PREVIEW_MAX) +} + +/** + * Formats the colored header that announces a tool invocation, e.g. + * `⏺ bash npm test`. Returns a single line with no surrounding whitespace so + * callers can frame it for their medium (stdout stream or a TUI component). + * + * @param toolName - the tool being invoked + * @param args - the tool's arguments, summarized to one line + * @returns the styled, single-line header + */ +export const formatToolStart = (toolName: string, args: unknown): string => { + const header = `${symbols.tool} ${cyan(toolName)}` + const summary = truncate(summarizeArgs(args), ARGS_MAX) + return summary ? `${header} ${gray(summary)}` : header +} + +/** + * Formats the success/failure marker for a finished tool call, with a short + * result preview. Returns a single line with no surrounding whitespace. + * + * @param isError - whether the tool result is an error + * @param result - the tool result to preview + * @returns the styled, single-line marker + */ +export const formatToolEnd = (isError: boolean, result: unknown): string => { + const mark = isError ? red(symbols.fail) : green(symbols.ok) + const preview = previewResult(result) + return preview ? `${mark} ${gray(preview)}` : mark +} + +/** + * Formats a turn that ended in a provider error (auth failure, rate limit, a + * bad request). Pi resolves the turn instead of throwing — the failure rides on + * the assistant message's `stopReason`/`errorMessage` — so without surfacing it + * the CLI would print nothing and look like a silent no-op. + * + * @param message - the assistant message attached to a `turn_end` event + * @returns the styled error line, or `undefined` when the turn did not error + */ +export const formatTurnError = (message: AgentMessage): string | undefined => { + if (!('stopReason' in message) || message.stopReason !== 'error') return undefined + const detail = message.errorMessage ?? 'the request failed' + return red(`${symbols.fail} ${detail}`) +} + +/** + * Attaches a streaming renderer to a harness. Subscribes for the harness's + * lifetime and writes assistant text, thinking, and tool activity to stdout as + * events arrive. + * + * @param harness - the Pi harness whose run should be rendered + */ +export const attachRenderer = (harness: AgentHarness): void => { + harness.subscribe((event) => { + switch (event.type) { + case 'message_update': { + const inner = event.assistantMessageEvent + switch (inner.type) { + case 'text_delta': + process.stdout.write(sanitizeTerminalText(inner.delta)) + break + case 'thinking_delta': + process.stdout.write(dim(sanitizeTerminalText(inner.delta))) + break + } + break + } + case 'tool_execution_start': + process.stdout.write(`\n${formatToolStart(event.toolName, event.args)}\n`) + break + case 'tool_execution_end': + process.stdout.write(`${formatToolEnd(event.isError, event.result)}\n`) + break + case 'turn_end': { + const error = formatTurnError(event.message) + if (error) process.stderr.write(`\n${error}\n`) + break + } + } + }) +} diff --git a/cli/src/ui/theme.ts b/cli/src/ui/theme.ts new file mode 100644 index 000000000..9450ecbbd --- /dev/null +++ b/cli/src/ui/theme.ts @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Minimal, dependency-free ANSI styling for the thunderbolt CLI's terminal + * renderer. Color is suppressed when `NO_COLOR` holds a non-empty value or when + * stdout is not a TTY (piped/redirected output stays plain). The decision is + * resolved once at module load — the environment and TTY status don't change + * mid-run. + */ + +const colorEnabled = !process.env.NO_COLOR && Boolean(process.stdout.isTTY) + +/** + * Builds a styling helper that wraps text in an ANSI SGR sequence, returning + * the text unchanged when color is disabled. + * + * @param open - the opening SGR escape (e.g. `\x1b[36m` for cyan) + * @returns a helper that styles a string and resets afterwards + */ +const style = + (open: string) => + (text: string): string => + colorEnabled ? `${open}${text}\x1b[0m` : text + +/** Dims text — used for subdued output like the agent's thinking stream. */ +export const dim = style('\x1b[2m') +/** Colors text cyan — used for tool names and headers. */ +export const cyan = style('\x1b[36m') +/** Colors text green — used for success markers. */ +export const green = style('\x1b[32m') +/** Colors text red — used for failure markers. */ +export const red = style('\x1b[31m') +/** Colors text gray — used for secondary detail and result previews. */ +export const gray = style('\x1b[90m') +/** Colors text yellow — used for inline code in the TUI markdown theme. */ +export const yellow = style('\x1b[33m') +/** Bolds text — used for markdown headings and strong emphasis. */ +export const bold = style('\x1b[1m') +/** Italicizes text — used for markdown emphasis and thinking traces. */ +export const italic = style('\x1b[3m') +/** Underlines text — used for markdown links. */ +export const underline = style('\x1b[4m') +/** Strikes through text — used for markdown deletions. */ +export const strikethrough = style('\x1b[9m') + +/** Glyphs marking tool activity in the streamed output. */ +export const symbols = { + /** Precedes a tool invocation. */ + tool: '⏺', + /** Marks a successful tool result. */ + ok: '✓', + /** Marks a failed tool result. */ + fail: '✗', +} as const diff --git a/cli/src/ui/tui.ts b/cli/src/ui/tui.ts new file mode 100644 index 000000000..52ca87722 --- /dev/null +++ b/cli/src/ui/tui.ts @@ -0,0 +1,279 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Interactive TUI for the thunderbolt REPL, built on `@earendil-works/pi-tui`. + * It replaces the plain readline loop + stdout renderer whenever stdout is a + * real terminal. + * + * Markdown rendering reuses pi-tui's `Markdown` component (the same engine + * pi-coding-agent wraps), themed here with plain ANSI helpers. We deliberately + * avoid pi-coding-agent's theme/component layer: its `initTheme` reads theme + * JSON files from disk, which don't exist next to a `bun build --compile` + * single binary and would crash on startup. + * + * The TUI owns stdin (raw mode) and stdout (differential renderer) for its whole + * lifetime, so nothing else may write to either while it runs — assistant prose, + * tool activity, the banner, and permission prompts all flow through components. + * A single `Editor` drives an async prompt worker: submit runs one turn to idle + * with the editor's submit disabled, so turns never overlap. Raw mode disables + * SIGINT, so Ctrl+C / Ctrl+D are intercepted manually to abort any in-flight + * turn, tear the TUI down, and restore the terminal. + */ + +import type { AgentHarness } from '@earendil-works/pi-agent-core' +import type { AssistantMessage } from '@earendil-works/pi-ai' +import type { EditorTheme, MarkdownTheme, SelectItem, SelectListTheme } from '@earendil-works/pi-tui' +import { + Container, + Editor, + Key, + Markdown, + matchesKey, + ProcessTerminal, + SelectList, + Spacer, + Text, + TUI, +} from '@earendil-works/pi-tui' +import { attachPermissionGate } from '../agent/permissions.ts' +import type { PermissionDecision, PermissionPrompt, PermissionRequest } from '../agent/types.ts' +import { bannerText } from '../banner.ts' +import { formatToolEnd, formatToolStart, formatTurnError, sanitizeTerminalText } from './render.ts' +import { bold, cyan, dim, gray, italic, strikethrough, underline, yellow } from './theme.ts' + +/** Composes two styling helpers, applying `inner` before `outer`. */ +const compose = + (outer: (t: string) => string, inner: (t: string) => string) => + (text: string): string => + outer(inner(text)) + +/** Markdown styling for the TUI, built from plain ANSI helpers so it needs no + * on-disk theme files (unlike pi-coding-agent's loader). */ +const markdownTheme: MarkdownTheme = { + heading: compose(bold, cyan), + link: cyan, + linkUrl: gray, + code: yellow, + codeBlock: (text) => text, + codeBlockBorder: gray, + quote: gray, + quoteBorder: gray, + hr: gray, + listBullet: cyan, + bold, + italic, + strikethrough, + underline, +} + +/** Select-list styling for permission prompts and the editor's autocomplete. */ +const selectListTheme: SelectListTheme = { + selectedPrefix: cyan, + selectedText: compose(bold, cyan), + description: gray, + scrollInfo: gray, + noMatch: gray, +} + +/** Editor theme: a subdued border and the shared select-list styling. */ +const editorTheme: EditorTheme = { borderColor: gray, selectList: selectListTheme } + +/** Narrows a select value back to a {@link PermissionDecision}; anything + * unexpected fails closed to `deny`. */ +const toDecision = (value: string): PermissionDecision => + value === 'allow-once' || value === 'allow-session' ? value : 'deny' + +/** Renders a single assistant content block as markdown, or `undefined` for + * blocks with no prose (tool calls, blank text/thinking). */ +const blockToMarkdown = (block: AssistantMessage['content'][number]): Markdown | undefined => { + if (block.type === 'text' && block.text.trim()) + return new Markdown(sanitizeTerminalText(block.text.trim()), 1, 0, markdownTheme) + if (block.type === 'thinking' && block.thinking.trim()) + return new Markdown(sanitizeTerminalText(block.thinking.trim()), 1, 0, markdownTheme, { color: dim, italic: true }) + return undefined +} + +/** + * Rebuilds `container` to show an assistant message's prose and thinking as + * markdown. Tool calls are rendered separately (as tool-activity lines), so only + * text/thinking blocks are drawn here. + */ +const renderAssistantInto = (container: Container, message: AssistantMessage): void => { + container.clear() + const blocks = message.content.map(blockToMarkdown).filter((block): block is Markdown => block !== undefined) + if (blocks.length === 0) return + // A blank line separates the turn from the prompt above — added only when + // there's prose to show, so a tool-only turn stays flush. + container.addChild(new Spacer(1)) + for (const block of blocks) container.addChild(block) +} + +/** + * Subscribes a component-based renderer to the harness for the TUI's lifetime. + * Assistant prose/thinking renders as markdown; tool activity and turn errors + * reuse the plain-mode formatters wrapped in `Text`. Each new component is + * appended to `scrollback` in event order, so the transcript reads top-to-bottom + * like the plain renderer. + */ +const subscribeRenderer = (harness: AgentHarness, tui: TUI, scrollback: Container): void => { + let streaming: Container | undefined + + harness.subscribe((event) => { + switch (event.type) { + case 'message_start': + if (event.message.role === 'assistant') { + streaming = new Container() + scrollback.addChild(streaming) + } + break + case 'message_update': + case 'message_end': + if (streaming && event.message.role === 'assistant') { + renderAssistantInto(streaming, event.message) + tui.requestRender() + } + if (event.type === 'message_end') streaming = undefined + break + case 'tool_execution_start': + scrollback.addChild(new Text(`\n${formatToolStart(event.toolName, event.args)}`)) + tui.requestRender() + break + case 'tool_execution_end': + scrollback.addChild(new Text(formatToolEnd(event.isError, event.result))) + tui.requestRender() + break + case 'turn_end': { + // A turn that errors while streaming text has no dedicated error line, + // so surface it here (the message body shows the raw prose, not the + // provider error). Tool-call turns get the same treatment. + const error = formatTurnError(event.message) + if (error) { + scrollback.addChild(new Text(`\n${error}`)) + tui.requestRender() + } + break + } + } + }) +} + +/** Warning header shown above the permission choices. */ +const formatPermissionHeader = (request: PermissionRequest): string => { + const lines = [`\n${yellow(`⚠ allow ${request.toolName}?`)}`, ` ${request.summary}`] + if (request.detail) lines.push('', request.detail) + return lines.join('\n') +} + +/** + * Builds a TUI-backed permission prompt: an inline `SelectList` appended to the + * scrollback and given focus, resolving the returned promise on the user's + * choice. It replaces the readline prompt, which can't share stdin with the + * TUI's raw mode. On resolve, focus returns to the editor. + */ +const buildAsk = (tui: TUI, scrollback: Container, editor: Editor): PermissionPrompt => { + return (request) => + new Promise((resolve) => { + const items: SelectItem[] = [ + { value: 'allow-once', label: 'Allow once' }, + { value: 'allow-session', label: `Allow ${request.toolName} for the rest of this session` }, + { value: 'deny', label: 'Deny' }, + ] + scrollback.addChild(new Text(formatPermissionHeader(request))) + const list = new SelectList(items, items.length, selectListTheme) + scrollback.addChild(list) + tui.setFocus(list) + tui.requestRender() + + const finish = (decision: PermissionDecision): void => { + scrollback.removeChild(list) + tui.setFocus(editor) + tui.requestRender() + resolve(decision) + } + list.onSelect = (item) => finish(toDecision(item.value)) + list.onCancel = () => finish('deny') + }) +} + +/** + * Runs the interactive TUI REPL against a built harness until the user exits + * (`exit`/`quit`, Ctrl+C, or Ctrl+D). Always tears the TUI down before + * returning so the terminal never stays in raw mode, even on error. + * + * @param harness - the Pi harness driving the conversation + * @param options.yolo - when true, auto-approve every tool call (no gate) + */ +export const runTuiRepl = async (harness: AgentHarness, options: { yolo: boolean }): Promise => { + const tui = new TUI(new ProcessTerminal()) + const scrollback = new Container() + scrollback.addChild(new Text(bannerText())) + const editor = new Editor(tui, editorTheme) + tui.addChild(scrollback) + tui.addChild(editor) + tui.setFocus(editor) + + subscribeRenderer(harness, tui, scrollback) + attachPermissionGate(harness, { yolo: options.yolo, ask: buildAsk(tui, scrollback, editor) }) + + let settle: (error?: Error) => void = () => {} + const done = new Promise((resolve, reject) => { + settle = (error) => (error ? reject(error) : resolve()) + }) + let exiting = false + const requestExit = (error?: Error): void => { + if (exiting) return + exiting = true + settle(error) + } + + const runPrompt = async (text: string): Promise => { + editor.disableSubmit = true + scrollback.addChild(new Text(`\n${gray(`› ${text}`)}`)) + tui.requestRender() + try { + await harness.prompt(text) + await harness.waitForIdle() + } finally { + editor.disableSubmit = false + tui.requestRender() + } + } + + editor.onSubmit = (text) => { + const trimmed = text.trim() + if (trimmed === '') return + editor.setText('') + editor.addToHistory(trimmed) + if (trimmed === 'exit' || trimmed === 'quit') { + requestExit() + return + } + runPrompt(trimmed).catch((error) => requestExit(error instanceof Error ? error : new Error(String(error)))) + } + + const removeListener = tui.addInputListener((data) => { + if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { + requestExit() + return { consume: true } + } + return undefined + }) + + tui.start() + tui.requestRender() + try { + await done + } finally { + removeListener() + // Cancel any in-flight turn so a running tool (e.g. a bash command) stops + // instead of outliving the torn-down UI — Ctrl+C must interrupt, not detach. + // `abort()` fires the turn's AbortController synchronously (the interrupt + // lands here), but its settling awaits the run going idle; we must not let + // that gate raw-mode restoration, so `tui.stop()` runs immediately and the + // settle (and any teardown-time abort error) is left to resolve on its own. + harness.abort().catch(() => {}) + tui.stop() + } +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 000000000..2315e8a0c --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../shared/tsconfig.base.json", + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/crates/thunderbolt-acp-client/.cargo/config.toml b/crates/thunderbolt-acp-client/.cargo/config.toml new file mode 100644 index 000000000..57904ac6d --- /dev/null +++ b/crates/thunderbolt-acp-client/.cargo/config.toml @@ -0,0 +1,8 @@ +# getrandom 0.3 needs an explicit backend cfg for wasm32 — without it the wasm +# build fails to link a randomness source. Pairs with the `wasm_js` feature in +# Cargo.toml so iroh's key generation / TLS use the browser's crypto.getRandomValues. +# +# build.sh composes CARGO_ENCODED_RUSTFLAGS (adding --remap-path-prefix) which fully +# REPLACES these rustflags, so it repeats this same cfg. Mirror any change there too. +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/crates/thunderbolt-acp-client/Cargo.lock b/crates/thunderbolt-acp-client/Cargo.lock new file mode 100644 index 000000000..005093069 --- /dev/null +++ b/crates/thunderbolt-acp-client/Cargo.lock @@ -0,0 +1,4140 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common 0.2.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "rand", + "reqwest", + "rustc-hash", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c95e4459d9bb828a77084277abd308aa2b58a096652b079bddfd6ef2361f53" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754f7e0c1f67938e1d671007264ffef158f14a9f795a7cc219ea68ea09a9d4c9" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-relay" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c12e48fef252fd04f8e6b6a8802b377baf72548d62ae4838816624cd0e06b79" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "noq" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "thunderbolt-acp-client" +version = "0.1.0" +dependencies = [ + "async-channel", + "async-stream", + "console_error_panic_hook", + "futures-channel", + "futures-core", + "getrandom 0.3.4", + "hex", + "iroh", + "iroh-tickets", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http", + "httparse", + "rand", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/crates/thunderbolt-acp-client/Cargo.toml b/crates/thunderbolt-acp-client/Cargo.toml new file mode 100644 index 000000000..b283dabba --- /dev/null +++ b/crates/thunderbolt-acp-client/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "thunderbolt-acp-client" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +description = "Relay-only iroh client (wasm) that dials a Thunderbolt CLI ACP/MCP bridge over an n0 relay." + +[lib] +# cdylib => the wasm artifact the web app imports; rlib => lets a native Tauri +# build (deferred) reuse the same client, and lets `cargo check` run on the host. +crate-type = ["cdylib", "rlib"] + +[dependencies] +# Browser/relay-only iroh. `default-features = false` drops `metrics` (required +# for wasm). `tls-ring` is the wasm-compatible TLS backend (ring compiles its C +# crypto to wasm — see build.sh for the macOS clang requirement). +iroh = { version = "1", default-features = false, features = ["tls-ring"] } +# Ticket parsing: the CLI bridge prints an `EndpointTicket` (NodeId + relay URL). +iroh-tickets = "1" + +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +# ReadableStream bridge for the receive half (Uint8Array chunks → JS stream). +wasm-streams = "0.5" +# `stream! { ... }` generator that turns the iroh recv half into a Stream. +async-stream = "0.3" +# The `Stream` trait named in `recv_byte_stream`'s return type. +futures-core = { version = "0.3", default-features = false } +# Per-frame completion signal: each queued frame carries a `oneshot` the write +# task fulfils once the bytes are actually written (or fails on a write error), +# so `send()` reflects the real write outcome instead of resolving on enqueue. +futures-channel = { version = "0.3", default-features = false, features = ["alloc"] } +# Outbound byte queue decoupling `send()` from the QUIC write task, so the +# wasm-bindgen object is never borrowed across an await. +async-channel = "2" +# wasm RNG backend — paired with the rustflags cfg in .cargo/config.toml. +getrandom = { version = "0.3", features = ["wasm_js"] } +# Stable-identity persistence: serialize the secret key as hex for localStorage. +hex = "0.4" +console_error_panic_hook = "0.1" + +# Disable wasm-pack's BUNDLED wasm-opt — it's too old to validate the bulk-memory +# ops rustc emits. build.sh runs a current Homebrew `wasm-opt -Oz` instead, which +# reads the target-features section and needs no manual feature flags. +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[profile.release] +opt-level = "z" +lto = true +strip = true +panic = "abort" diff --git a/crates/thunderbolt-acp-client/README.md b/crates/thunderbolt-acp-client/README.md new file mode 100644 index 000000000..b21333089 --- /dev/null +++ b/crates/thunderbolt-acp-client/README.md @@ -0,0 +1,77 @@ +# thunderbolt-acp-client + +Relay-only [iroh](https://www.iroh.computer/) client, compiled to WebAssembly, that +dials a Thunderbolt CLI ACP/MCP bridge over an n0 relay. The web app lazy-imports +the wasm-pack glue from `src/acp/iroh/pkg/`. + +## Prebuilt artifact in tree + +The compiled output (`src/acp/iroh/pkg/`) is **committed to the repo** so the app — +and CI — can import it without a wasm toolchain (`ring` compiles C crypto to +`wasm32`, which needs a wasm-capable clang; see [`build.sh`](./build.sh)). This is a +deliberate binary-in-tree decision for a P2P/QUIC security core, so its provenance +is anchored below and enforced by CI. + +Because the artifact is prebuilt, it is verified three ways: + +- **Staleness gate (CI):** the `wasm-artifact` job also triggers when the crate source + changes (`src/`, `Cargo.toml`, `Cargo.lock`, `rust-toolchain.toml`, `.cargo/`, + `build.sh`). If the crate changed without a matching `src/acp/iroh/pkg/` regeneration + in the same PR, the job fails — so editing the crate can't silently leave the + committed wasm stale. No toolchain needed. +- **Tamper-evidence (CI):** `src/acp/iroh/pkg/CHECKSUMS.txt` lists the sha256 of each + committed file. The `wasm-artifact` CI job runs `shasum -a 256 -c CHECKSUMS.txt` + and fails if any committed artifact drifts from the manifest. No toolchain needed. +- **Reproducibility (local, pinned toolchain):** `./build.sh --verify` rebuilds into a + throwaway dir and fails if the result drifts from `CHECKSUMS.txt`. Two clean builds on + the pinned toolchain are bit-identical (see "Determinism test"). CI still verifies the + committed artifact against `CHECKSUMS.txt` (tamper-evidence + staleness) rather than + rebuilding — a cross-machine rebuild-verify is now plausible (absolute builder paths + are remapped out, see below) but not yet wired up. + +## Rebuilding + +```sh +./build.sh # rebuild + regenerate CHECKSUMS.txt into src/acp/iroh/pkg +./build.sh --verify # rebuild into a temp dir and diff against the committed manifest +``` + +Commit the regenerated `pkg/` (including `CHECKSUMS.txt`) in the same change as any +crate/dependency edit. Bumping the toolchain below also changes the artifact — bump +and regenerate in one commit. + +## Toolchain provenance + +The build is pinned so it is byte-for-byte reproducible on a matching machine: + +| Tool | Version | Pinned by | +| ------------ | ------- | -------------------------------------------------- | +| rustc | 1.94.0 | [`rust-toolchain.toml`](./rust-toolchain.toml) | +| wasm-bindgen | 0.2.126 | `Cargo.lock` (wasm-pack fetches the matching CLI) | +| wasm-pack | 0.13.1 | provenance note (installed via `cargo install`) | +| wasm-opt | 130 | provenance note (Homebrew `binaryen`) | + +macOS additionally needs Homebrew LLVM clang to compile `ring`'s C to `wasm32` +(Apple's system clang has no wasm backend) — `build.sh` points the wasm C toolchain +at `/opt/homebrew/opt/llvm`. + +## Determinism test + +Two clean builds (`cargo clean` between) on the pinned toolchain produce a +bit-identical `thunderbolt_acp_client_bg.wasm` (and identical `.js`/`.d.ts`): + +``` +0265ecaa38125fbe56a0bd52021db77f1a4f1593bf653607c989062841ae5005 (build 1) +0265ecaa38125fbe56a0bd52021db77f1a4f1593bf653607c989062841ae5005 (build 2) +``` + +wasm-opt is deterministic here, so it is **not** a source of drift. The build no longer +embeds absolute builder paths: `build.sh` passes `--remap-path-prefix` to rewrite +`$CARGO_HOME`→`/cargo` and the repo root→`/build` (std is already `/rustc//`), so +the ~750 dependency panic-location strings that used to leak the builder's username are +now machine-independent. That was the one hard blocker to cross-machine reproducibility, +so a Linux-CI rebuild matching this macOS hash is now **plausible** — though still +unproven across the OS/wasm-opt boundary (codegen and wasm-opt output can differ by +platform). Wiring a CI rebuild-verify on the pinned toolchain is possible future work; +today CI verifies the committed artifact against `CHECKSUMS.txt` (tamper-evidence + +staleness) and `./build.sh --verify` reproduces it locally. diff --git a/crates/thunderbolt-acp-client/build.sh b/crates/thunderbolt-acp-client/build.sh new file mode 100755 index 000000000..195b39432 --- /dev/null +++ b/crates/thunderbolt-acp-client/build.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Build the Thunderbolt ACP iroh client to WebAssembly for the web app. +# +# Output: a wasm-pack `pkg/` at `src/acp/iroh/pkg/` (the app lazy-imports the +# glue from there). Re-run after changing the crate. +# +# Toolchain (one-time): +# rustup target add wasm32-unknown-unknown +# cargo install wasm-pack # or the prebuilt installer +# +# macOS caveat: `ring` (TLS backend) compiles C to wasm32, and Apple's system +# clang has NO wasm backend. Install Homebrew LLVM and this script points the +# wasm C toolchain at it. Linux CI usually ships a wasm-capable clang already. +# brew install llvm +# +# Size: this script runs `wasm-opt -Oz` from a current Homebrew binaryen +# (`brew install binaryen`). wasm-pack's bundled wasm-opt is too old to validate +# the bulk-memory ops rustc emits, so it's disabled in Cargo.toml. +set -euo pipefail + +CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$CRATE_DIR/../.." && pwd)" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/src/acp/iroh/pkg}" + +# The committed artifacts, checksummed into CHECKSUMS.txt for tamper-evidence +# (CI verifies the tree against it) and reproducibility (`--verify` below). +ARTIFACTS=( + thunderbolt_acp_client_bg.wasm + thunderbolt_acp_client_bg.wasm.d.ts + thunderbolt_acp_client.d.ts + thunderbolt_acp_client.js + package.json +) + +# `--verify` rebuilds into a throwaway dir and fails if the result drifts from the +# committed CHECKSUMS.txt, instead of overwriting src/acp/iroh/pkg. The build is +# byte-for-byte reproducible only on the pinned toolchain and OS (see README.md). +VERIFY=0 +if [[ "${1:-}" == "--verify" ]]; then + VERIFY=1 + COMMITTED_DIR="$OUT_DIR" + OUT_DIR="$(mktemp -d)" + trap 'rm -rf "$OUT_DIR"' EXIT +fi + +if [[ "$(uname)" == "Darwin" ]]; then + LLVM_PREFIX="${LLVM_PREFIX:-/opt/homebrew/opt/llvm}" + if [[ ! -x "$LLVM_PREFIX/bin/clang" ]]; then + echo "error: Homebrew LLVM clang not found at $LLVM_PREFIX/bin/clang." >&2 + echo " Run 'brew install llvm' (Apple clang can't target wasm32)." >&2 + exit 1 + fi + CLANG_VER="$(ls "$LLVM_PREFIX/lib/clang" | sort -V | tail -1)" + export CC_wasm32_unknown_unknown="$LLVM_PREFIX/bin/clang" + export AR_wasm32_unknown_unknown="$LLVM_PREFIX/bin/llvm-ar" + export CFLAGS_wasm32_unknown_unknown="-I$LLVM_PREFIX/lib/clang/$CLANG_VER/include" +fi + +# Remap the builder's absolute paths out of the artifact. rustc bakes ~750 dependency +# panic-location paths (`~/.cargo/registry/.../ring-*/src/...`, tokio, rustls, …) into +# the wasm; left as-is they leak the builder's username and pin byte-reproducibility to +# one machine's layout. --remap-path-prefix rewrites $CARGO_HOME→/cargo and the repo +# root→/build (std is already remapped to /rustc// by the rust dist). +# +# CARGO_ENCODED_RUSTFLAGS fully REPLACES .cargo/config.toml's [target.wasm32] rustflags, +# so the getrandom backend cfg is repeated here — without it the wasm has no RNG source +# and fails to link (a loud failure, not a silent one). Keep this cfg in sync with +# .cargo/config.toml. Encoded (0x1f-separated) form tolerates spaces in the paths. +CARGO_HOME_DIR="${CARGO_HOME:-$HOME/.cargo}" +rustflags=( + --cfg 'getrandom_backend="wasm_js"' + "--remap-path-prefix=$CARGO_HOME_DIR=/cargo" + "--remap-path-prefix=$REPO_ROOT=/build" +) +encoded="$(printf '%s\x1f' "${rustflags[@]}")" +export CARGO_ENCODED_RUSTFLAGS="${encoded%$'\x1f'}" + +cd "$CRATE_DIR" +echo "building wasm → $OUT_DIR" +wasm-pack build --target web --release --out-dir "$OUT_DIR" + +WASM="$OUT_DIR/thunderbolt_acp_client_bg.wasm" +if command -v wasm-opt >/dev/null 2>&1; then + echo "optimizing with $(wasm-opt --version)" + # Enable the wasm features LLVM emits — the release profile's `strip` drops the + # target-features section, so wasm-opt can't auto-detect them and defaults to MVP. + wasm-opt -Oz \ + --enable-bulk-memory --enable-bulk-memory-opt \ + --enable-nontrapping-float-to-int --enable-sign-ext \ + --enable-mutable-globals --enable-reference-types --enable-multivalue \ + "$WASM" -o "$WASM.opt" + mv "$WASM.opt" "$WASM" +else + echo "warning: wasm-opt not found — shipping the unoptimized artifact. 'brew install binaryen' to shrink it." >&2 +fi + +# wasm-pack writes a '*' .gitignore into the out-dir (drop it so the artifact can +# be committed) and copies the crate README (drop it — redundant, unchecksummed, and +# would otherwise show up untracked after every build). +rm -f "$OUT_DIR/.gitignore" "$OUT_DIR/README.md" + +# Manifest of the committed artifacts (bare filenames, so it's path-independent +# and CI can `shasum -a 256 -c` it from the pkg dir without a wasm toolchain). +( cd "$OUT_DIR" && shasum -a 256 "${ARTIFACTS[@]}" > CHECKSUMS.txt ) + +if [[ "$VERIFY" == 1 ]]; then + if diff -u "$COMMITTED_DIR/CHECKSUMS.txt" "$OUT_DIR/CHECKSUMS.txt"; then + echo "verify: rebuilt artifacts match committed CHECKSUMS.txt" + else + echo "verify: DRIFT — rebuilt artifacts differ from committed CHECKSUMS.txt" >&2 + exit 1 + fi + exit 0 +fi + +echo "done. artifact:" +ls -lh "$WASM" diff --git a/crates/thunderbolt-acp-client/rust-toolchain.toml b/crates/thunderbolt-acp-client/rust-toolchain.toml new file mode 100644 index 000000000..b613dbea1 --- /dev/null +++ b/crates/thunderbolt-acp-client/rust-toolchain.toml @@ -0,0 +1,8 @@ +# Pinned so the committed wasm artifact (src/acp/iroh/pkg) is byte-for-byte +# reproducible: rustc embeds its sysroot commit hash and absolute panic-location +# paths into the binary, so a different rustc yields a different hash. Bump this +# and regenerate the artifact + CHECKSUMS.txt (./build.sh) in the same commit. +[toolchain] +channel = "1.94.0" +components = ["rustfmt", "clippy"] +targets = ["wasm32-unknown-unknown"] diff --git a/crates/thunderbolt-acp-client/src/lib.rs b/crates/thunderbolt-acp-client/src/lib.rs new file mode 100644 index 000000000..8fb80f447 --- /dev/null +++ b/crates/thunderbolt-acp-client/src/lib.rs @@ -0,0 +1,304 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Thunderbolt ACP client — a relay-only [iroh] dialer compiled to WebAssembly. +//! +//! The web app can't open UDP sockets, so this crate binds a **relay-only** +//! iroh endpoint (the n0 preset): every QUIC packet flows over a WebSocket to an +//! n0 relay, still end-to-end encrypted to the dialed peer. That is exactly the +//! topology of dialing a `thunderbolt acp --transport iroh` CLI bridge from the +//! browser — no hole-punching, no direct path. +//! +//! Surface (consumed by `src/acp/transports/iroh.ts`): +//! * [`IrohClient::create`] — bind ONE long-lived endpoint (optionally pinned +//! to a persisted secret key so the bridge operator allowlists a stable +//! NodeId once, and optionally pointed at a self-hosted relay instead of the +//! n0 public ones). +//! * [`IrohClient::connect`] — dial a ticket or bare NodeId over an ALPN and +//! open ONE bidirectional stream, returning an [`IrohConnection`]. +//! * [`IrohConnection::send`] / [`IrohConnection::readable`] — write bytes into +//! and read bytes out of that stream. The JS side carries newline-delimited +//! JSON-RPC (ACP) over this raw byte pipe, matching the CLI bridge's ndjson +//! framing (`cli/src/iroh/pump.ts`). +//! +//! The ALPN is supplied by the caller (`thunderbolt/acp/0` for the ACP bridge) +//! and must match the bridge byte-for-byte or the QUIC handshake is refused. +//! +//! This same crate is intended to be the shared native client for Tauri +//! (desktop/mobile) via its `rlib` target; only the wasm/web target is built +//! here — native embedding is deferred. + +use std::cell::RefCell; + +use async_channel::{Receiver, Sender}; +use futures_channel::oneshot; +use iroh::endpoint::{Connection, RecvStream, SendStream, presets}; +use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, RelayUrl, SecretKey}; +use iroh_tickets::endpoint::EndpointTicket; +use js_sys::{Promise, Uint8Array}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{future_to_promise, spawn_local}; +use wasm_streams::ReadableStream; +use wasm_streams::readable::sys::ReadableStream as JsReadableStream; + +/// Depth of the outbound byte queue. Keeps `send()` decoupled from the QUIC write +/// task — the frame is handed off over the channel rather than written under a +/// borrow of the wasm-bindgen object, so `self` is never held across an await — +/// while bounding how many frames may be in flight. Small because ACP JSON-RPC +/// messages are tiny and frequent. +const OUTBOUND_CAPACITY: usize = 64; + +/// A queued outbound frame: the bytes to write, paired with a `oneshot` the write +/// task fulfils once those bytes are written (`Ok`) or a write fails (`Err(msg)`). +/// This is how `send()`'s promise reflects the ACTUAL write outcome instead of +/// merely that the frame was buffered — no accepted frame is ever silently dropped. +type OutboundFrame = (Vec, oneshot::Sender>); + +/// Max bytes pulled per recv read — a comfortably large ceiling for JSON-RPC. +const READ_CHUNK_LIMIT: usize = 1 << 16; + +/// Installs a panic hook that surfaces Rust panics as readable console errors. +#[wasm_bindgen(start)] +fn start() { + console_error_panic_hook::set_once(); +} + +/// One long-lived relay-only iroh endpoint. Hold a single instance for the app's +/// lifetime and open a connection per bridge — re-binding per dial would churn +/// the relay handshake and the NodeId. +#[wasm_bindgen] +pub struct IrohClient { + endpoint: Endpoint, + secret_key_hex: String, +} + +#[wasm_bindgen] +impl IrohClient { + /// Bind the relay-only endpoint. Returns as soon as the endpoint is bound; + /// the home relay is warmed lazily by the first [`IrohClient::connect`]. + /// + /// We deliberately do NOT pre-warm the relay here (no `endpoint.online()`): + /// that call has no timeout, so on an offline or captive network it pends + /// forever, and the JS side caches this future in an app-wide singleton — a + /// never-resolving bind would poison every later dial. `connect()` resolves + /// the relay path on demand instead, and the JS transport bounds that dial + /// with its `AbortSignal`. `bind()` itself does not block on connectivity. + /// + /// Pass a 32-byte hex secret key to pin a stable NodeId (so the bridge + /// operator runs `thunderbolt iroh allow ` only once); pass `null` + /// to generate a fresh identity, then read it back via + /// [`IrohClient::secret_key_hex`] to persist for next session. + /// + /// `relay_url` overrides the relay: `None`/empty keeps the n0 preset's public + /// relays (today's behavior); a self-hosted iroh-relay URL swaps ONLY the + /// relay, leaving the n0 DNS discovery + crypto from `presets::N0` intact so a + /// bare NodeId still resolves and tickets still dial. The web app threads + /// `VITE_IROH_RELAY_URL` through here. + #[wasm_bindgen(js_name = create)] + pub async fn create( + secret_key_hex: Option, + relay_url: Option, + ) -> Result { + let secret = match secret_key_hex.as_deref() { + Some(hex) if !hex.trim().is_empty() => parse_secret(hex)?, + _ => SecretKey::generate(), + }; + let stored_hex = hex::encode(secret.to_bytes()); + let mut builder = Endpoint::builder(presets::N0).secret_key(secret); + if let Some(url) = relay_url.as_deref().map(str::trim).filter(|u| !u.is_empty()) { + let relay: RelayUrl = url.parse().map_err(to_js)?; + builder = builder.relay_mode(RelayMode::custom([relay])); + } + let endpoint = builder.bind().await.map_err(to_js)?; + Ok(IrohClient { + endpoint, + secret_key_hex: stored_hex, + }) + } + + /// This client's NodeId (base32). The bridge operator allowlists it with + /// `thunderbolt iroh allow `. + #[wasm_bindgen(js_name = nodeId)] + pub fn node_id(&self) -> String { + self.endpoint.id().to_string() + } + + /// This client's secret key as hex, so the app can persist it and re-create + /// the SAME NodeId next session. + #[wasm_bindgen(js_name = secretKeyHex)] + pub fn secret_key_hex(&self) -> String { + self.secret_key_hex.clone() + } + + /// Dial `target` (an `EndpointTicket` or a bare NodeId) over `alpn`, open ONE + /// bidirectional stream, and resolve to an [`IrohConnection`]. + /// + /// Returns a `Promise` (rather than an `async fn` borrowing `&self`) so the + /// endpoint is cloned out synchronously and the future owns it. + #[wasm_bindgen(js_name = connect)] + pub fn connect(&self, target: String, alpn: String) -> Promise { + let endpoint = self.endpoint.clone(); + future_to_promise(async move { + let addr = resolve_target(&target)?; + let connection = endpoint + .connect(addr, alpn.as_bytes()) + .await + .map_err(err_to_jsv)?; + let (send, recv) = connection.open_bi().await.map_err(err_to_jsv)?; + Ok(JsValue::from(IrohConnection::new(connection, send, recv))) + }) + } +} + +/// A live bridge connection: one QUIC bidi stream over the relay. Sending queues +/// bytes for the write task; the receive half is exposed once as a JS +/// `ReadableStream` of `Uint8Array` chunks. +#[wasm_bindgen] +pub struct IrohConnection { + // Held to keep the QUIC connection alive — dropping it tears the stream down. + connection: Connection, + outbound: Sender, + readable: RefCell>, +} + +impl IrohConnection { + fn new(connection: Connection, send: SendStream, recv: RecvStream) -> Self { + let (outbound, rx) = async_channel::bounded::(OUTBOUND_CAPACITY); + spawn_local(drive_send(send, rx)); + let readable = ReadableStream::from_stream(recv_byte_stream(recv)).into_raw(); + IrohConnection { + connection, + outbound, + readable: RefCell::new(Some(readable)), + } + } +} + +#[wasm_bindgen] +impl IrohConnection { + /// Write `data` to the bidi stream, resolving only once the bytes are ACTUALLY + /// written and rejecting if the write fails — the promise reflects the real + /// write outcome, never merely that the frame was buffered. The frame is + /// handed to the write task over the bounded queue (so `self` is never held + /// across an await and backpressure is preserved), carrying a `oneshot` the + /// task fulfils once the write settles. Rejects if the connection is already + /// closed, or if the write task tears down before this frame is written — so + /// an accepted frame is never silently dropped. + #[wasm_bindgen(js_name = send)] + pub fn send(&self, data: Vec) -> Promise { + let outbound = self.outbound.clone(); + future_to_promise(async move { + let (done_tx, done_rx) = oneshot::channel(); + outbound + .send((data, done_tx)) + .await + .map_err(|_| JsValue::from(JsError::new("iroh connection closed")))?; + match done_rx.await { + Ok(Ok(())) => Ok(JsValue::UNDEFINED), + Ok(Err(msg)) => Err(JsValue::from(JsError::new(&msg))), + // The write task dropped this frame's sender without writing it + // (it broke out on an earlier write error) — surface it, don't drop. + Err(_canceled) => Err(JsValue::from(JsError::new("iroh connection closed"))), + } + }) + } + + /// The receive half as a `ReadableStream`. Consumed once — the JS + /// transport reads it for the lifetime of the session. + #[wasm_bindgen(js_name = readable)] + pub fn readable(&self) -> Result { + self.readable + .borrow_mut() + .take() + .ok_or_else(|| JsError::new("iroh receive stream already taken")) + } + + /// Close the connection: stop the outbound queue (finishing the send half) + /// and close the QUIC connection. + #[wasm_bindgen(js_name = close)] + pub fn close(&self) { + self.outbound.close(); + self.connection.close(0u8.into(), b"client closed"); + } +} + +/// Drain the outbound queue into the send half, finishing the stream when the +/// queue closes (on [`IrohConnection::close`] or drop) or a write fails. Each +/// frame's `oneshot` is fulfilled with the write outcome so its `send()` promise +/// settles truthfully; on a write error every still-queued frame is failed too, +/// so no accepted frame's promise is left resolving `Ok` on bytes that never +/// reached the wire. +async fn drive_send(mut send: SendStream, rx: Receiver) { + while let Ok((chunk, done)) = rx.recv().await { + match send.write_all(&chunk).await { + Ok(()) => { + let _ = done.send(Ok(())); + } + Err(err) => { + let msg = err.to_string(); + let _ = done.send(Err(msg.clone())); + // Fail every frame already accepted into the queue rather than + // dropping it silently; frames that race in after this drop their + // sender and reject via the `Canceled` arm in `send()`. + while let Ok((_, queued)) = rx.try_recv() { + let _ = queued.send(Err(msg.clone())); + } + break; + } + } + } + let _ = send.finish(); +} + +/// Turn the iroh recv half into a `Stream` of `Uint8Array` chunks for +/// `ReadableStream::from_stream`. Ends on a clean FIN (`Ok(None)`); a read error +/// is surfaced as a stream error and ends the stream. +fn recv_byte_stream( + mut recv: RecvStream, +) -> impl futures_core::Stream> { + async_stream::stream! { + loop { + match recv.read_chunk(READ_CHUNK_LIMIT).await { + Ok(Some(bytes)) => { + let chunk = Uint8Array::new_with_length(bytes.len() as u32); + chunk.copy_from(bytes.as_ref()); + yield Ok(JsValue::from(chunk)); + } + Ok(None) => break, + Err(err) => { + yield Err(JsValue::from(JsError::new(&err.to_string()))); + break; + } + } + } + } +} + +/// Resolve a dial target: an `EndpointTicket` (NodeId + relay URL) if it parses, +/// else a bare NodeId relying on n0 DNS discovery to find the relay. +fn resolve_target(target: &str) -> Result { + if let Ok(ticket) = target.parse::() { + return Ok(ticket.endpoint_addr().clone()); + } + let id: EndpointId = target.parse().map_err(err_to_jsv)?; + Ok(EndpointAddr::from(id)) +} + +/// Parse a 32-byte hex secret key into a [`SecretKey`]. +fn parse_secret(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str.trim()).map_err(to_js)?; + let arr: [u8; 32] = bytes + .as_slice() + .try_into() + .map_err(|_| JsError::new("secret key must be 32 bytes (64 hex chars)"))?; + Ok(SecretKey::from_bytes(&arr)) +} + +fn to_js(err: impl std::fmt::Display) -> JsError { + JsError::new(&err.to_string()) +} + +fn err_to_jsv(err: impl std::fmt::Display) -> JsValue { + JsValue::from(JsError::new(&err.to_string())) +} diff --git a/docs/architecture/powersync-account-devices.md b/docs/architecture/powersync-account-devices.md index f3b071233..606bae2d3 100644 --- a/docs/architecture/powersync-account-devices.md +++ b/docs/architecture/powersync-account-devices.md @@ -70,6 +70,10 @@ Split the work into two PRs to avoid sync rule mismatches: - Frontend: table in `src/db/tables.ts`, `src/db/powersync/schema.ts`, and any UI/feature code. - Merge after PR 1 is deployed and PowerSync rules are updated. +### Adding Columns to an Existing Synced Table + +A new column on a `SELECT *` table (all current sync rules) carries the same silent-failure risk as a new table: the backend migration must deploy **and** the PowerSync Cloud sync rules must be refreshed before the column replicates. If the frontend schema ships first, the column stays null across devices while local tests pass. When backend and frontend land in the same PR (e.g. `devices.node_id` / `node_id_attested_at`, migration `0021`), splitting is unnecessary if the feature tolerates a null value, but the deployer must still run the migration and refresh the dashboard rules before relying on the column cross-device. + --- ## 3. Local Development (PowerSync Docker) diff --git a/docs/development/testing.md b/docs/development/testing.md index 9bcbd2f18..ad5386ab4 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -6,6 +6,9 @@ # Run frontend tests (src/ + shared/*.test.ts + scripts/create-release.test.ts + .github/scripts/post-pr-metrics.test.js) bun run test +# Run the isolated shared/agent-core module's unit tests (NOT part of `bun run test`; CI runs these only when shared/agent-core/** changes) +bun run test:agent-core + # Run frontend tests in watch mode (src/ only) bun run test:watch @@ -22,6 +25,8 @@ bun run e2e:headed # with a visible browser **Note**: Don't run `bun test` directly from the project root — Bun's positional args are substring filters (not paths), so a filter like `src/` matches `backend/src/...` and pulls in backend tests. The `test` script uses `bun test --cwd=src` to scope discovery to the frontend tree, then runs `shared/*.test.ts`, `scripts/create-release.test.ts`, and `.github/scripts/post-pr-metrics.test.js` by explicit path (`shared/` is outside `--cwd=src`, and Bun skips hidden dirs in discovery, so each path must be explicit). +**`shared/agent-core` is an isolated module** with its own unit tests under `shared/agent-core/**` (nested subdirs the non-recursive `shared/*.test.ts` glob wouldn't reach). Because `src/` app changes can't affect it, those tests are intentionally **not** part of `bun run test`. Run them on their own with `bun run test:agent-core` (or `bun run test:agent-core:5x` for the 5x-stability gate), mirroring the `test:backend` split. In CI, a dedicated `agent-core` job in [`ci.yml`](../../.github/workflows/ci.yml) runs `bun run test:agent-core:5x` and is path-gated via the `detect-changes` `dorny/paths-filter` step (`agent-core: 'shared/agent-core/**'`) — same mechanism that gates the `rust` job — so it executes only when files under `shared/agent-core/**` change. (`cli/` also consumes agent-core; its own tests cover the cli→agent-core integration, so agent-core's unit tests stay gated on agent-core paths only.) + ## Testing Guidelines Please follow these guidelines for unit tests: diff --git a/eslint.config.js b/eslint.config.js index 73034436a..b28a2bac7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,9 @@ import globals from 'globals' import { sharedParserOptions, sharedRules } from './shared/eslint/base.js' export default [ + // Global ignores (object with only `ignores` applies repo-wide). The generated + // wasm-bindgen glue is a build artifact — never lint it. + { ignores: ['src/acp/iroh/pkg/**', 'crates/**'] }, js.configs.recommended, prettier, { diff --git a/package.json b/package.json index 4c634d6de..5eb131e93 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "type": "module", "license": "MPL-2.0", "scripts": { - "test": "bun test --cwd=src --timeout 5000 --randomize && bun test shared/ scripts/create-release.test.ts ./.github/scripts/post-pr-metrics.test.js ./.github/scripts/review-orchestrator.test.mjs --timeout 5000 --randomize", - "test:5x": "bun test --cwd=src --timeout 3600000 --randomize --rerun-each 5 && bun test shared/ scripts/create-release.test.ts ./.github/scripts/post-pr-metrics.test.js ./.github/scripts/review-orchestrator.test.mjs --timeout 3600000 --randomize --rerun-each 5", + "test": "bun test --cwd=src --timeout 5000 --randomize && bun test shared/*.test.ts shared/defaults/ scripts/create-release.test.ts ./.github/scripts/post-pr-metrics.test.js ./.github/scripts/review-orchestrator.test.mjs --timeout 5000 --randomize", + "test:5x": "bun test --cwd=src --timeout 3600000 --randomize --rerun-each 5 && bun test shared/*.test.ts shared/defaults/ scripts/create-release.test.ts ./.github/scripts/post-pr-metrics.test.js ./.github/scripts/review-orchestrator.test.mjs --timeout 3600000 --randomize --rerun-each 5", + "test:agent-core": "bun test --cwd=shared/agent-core --timeout 5000 --randomize", + "test:agent-core:5x": "bun test --cwd=shared/agent-core --timeout 5000 --randomize --rerun-each 5", "test:watch": "bun test --cwd=src --watch", "test:backend": "cd backend && bun test --timeout 5000 --randomize", "test:backend:5x": "cd backend && bun test --timeout 5000 --randomize --rerun-each 5", @@ -61,9 +63,13 @@ "@ai-sdk/openai": "^3.0.11", "@ai-sdk/openai-compatible": "^2.0.12", "@ai-sdk/react": "^3.0.39", + "@anthropic-ai/sdk": "0.91.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@earendil-works/pi-agent-core": "0.80.2", + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2", "@hookform/resolvers": "^5.2.1", "@icons-pack/react-simple-icons": "^13.13.0", "@journeyapps/wa-sqlite": "^1.7.0", @@ -104,23 +110,31 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", "@tauri-apps/plugin-updater": "^2.10.1", + "@zenfs/core": "^2.5.7", + "@zenfs/dom": "^1.2.9", "acorn": "^8.17.0", "ai": "^6.0.37", "better-auth": "^1.6.9", "bson": "^6.10.4", + "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "css-tree": "^3.2.1", "dayjs": "^1.11.13", "drizzle-orm": "^0.45.2", + "events": "^3.3.0", "framer-motion": "^12.23.12", "input-otp": "^1.4.2", + "jsqr": "^1.4.0", + "just-bash": "^3.0.2", "katex": "^0.16.0", "lucide-react": "^1.8.0", "mammoth": "^1.12.0", "maplibre-gl": "^5.24.0", + "path-browserify": "^1.0.1", "posthog-js": "^1.288.1", + "qrcode": "^1.5.4", "react": "^19.2.1", "react-dom": "^19.2.1", "react-hook-form": "^7.62.0", @@ -161,6 +175,7 @@ "@types/bun": "^1.2.20", "@types/css-tree": "^2.3.11", "@types/node": "^25.8.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@types/sinonjs__fake-timers": "^15.0.1", diff --git a/scripts/license-headers.ts b/scripts/license-headers.ts index d53d03bbd..e665ff979 100644 --- a/scripts/license-headers.ts +++ b/scripts/license-headers.ts @@ -57,6 +57,9 @@ const skipPathPatterns: RegExp[] = [ /(^|\/)drizzle\/meta\//, /(^|\/)dist(-[^/]+)?\//, /(^|\/)node_modules\//, + // Generated wasm-bindgen glue for the iroh ACP client (build artifact). + /(^|\/)src\/acp\/iroh\/pkg\//, + /(^|\/)crates\/[^/]+\/target\//, /\.gen\.[a-z]+$/i, ] diff --git a/shared/agent-core/anthropic-model.test.ts b/shared/agent-core/anthropic-model.test.ts new file mode 100644 index 000000000..150c69031 --- /dev/null +++ b/shared/agent-core/anthropic-model.test.ts @@ -0,0 +1,186 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for {@link buildAnthropicModel} / {@link isKnownAnthropicModel}. + * + * The module's whole reason to exist is the simple→full options bridge: Pi's + * `streamSimple` rebuilds options via `buildBaseOptions` and DROPS the pre-built + * `client`, so this module re-implements the bridge to (a) keep the injected + * `fetch`-bearing client alive through `streamSimple`, and (b) shape the request's + * thinking config correctly. These tests pin that behaviour at the wire boundary: + * they drive a real `streamSimple` against an injected `fetch`, capture the JSON + * body the `@anthropic-ai/sdk` actually emits, and assert the thinking/effort/ + * max_tokens fields — plus that the injected fetch (not the global) served it. + */ + +import { afterEach, describe, expect, it } from 'bun:test' +import type { Context, SimpleStreamOptions } from '@earendil-works/pi-ai' +import { buildAnthropicModel, isKnownAnthropicModel, type AgentFetch } from './anthropic-model.ts' + +/** Minimal well-formed Anthropic messages SSE: a start then an immediate stop, so + * the SDK parses a clean stream rather than erroring mid-iteration. */ +const SSE = [ + 'event: message_start', + 'data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"x","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}', + '', + 'event: message_stop', + 'data: {"type":"message_stop"}', + '', + '', +].join('\n') + +const CONTEXT: Context = { messages: [{ role: 'user', content: 'hi', timestamp: 0 }] } + +type CapturedBody = { + max_tokens?: number + thinking?: { type: string; budget_tokens?: number } + output_config?: { effort?: string } +} + +type DriveResult = { body: CapturedBody | null; headers: Headers | null; injectedCalls: number } + +/** Build the model and drive `streamSimple`, capturing the request body the SDK + * emits through the injected fetch. `entry` selects which provider entrypoint to + * exercise (the harness uses `streamSimple`; `stream` is the full path). */ +const drive = async ( + modelId: string, + options: SimpleStreamOptions, + entry: 'streamSimple' | 'stream' = 'streamSimple', +): Promise => { + let body: CapturedBody | null = null + let headers: Headers | null = null + let injectedCalls = 0 + const injectedFetch: AgentFetch = async (_input, init) => { + injectedCalls += 1 + headers = init?.headers ? new Headers(init.headers) : null + body = init?.body ? (JSON.parse(init.body as string) as CapturedBody) : null + return new Response(SSE, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + } + + const { models, model } = buildAnthropicModel({ apiKey: 'test-key', fetch: injectedFetch, modelId }) + const provider = models.getProvider('anthropic') + if (!provider) throw new Error('anthropic provider not registered') + + const stream = provider[entry](model, CONTEXT, options) + try { + for await (const event of stream) void event + } catch { + // Stream-parse hiccups are irrelevant; the captured request body is the contract. + } + return { body, headers, injectedCalls } +} + +describe('isKnownAnthropicModel', () => { + it('returns true for a model the built-in anthropic-messages catalog resolves', () => { + expect(isKnownAnthropicModel('claude-opus-4-8')).toBe(true) + }) + + it('returns false for ids the catalog lacks (so the caller falls back, not crashes)', () => { + expect(isKnownAnthropicModel('claude-3-5-sonnet-latest')).toBe(false) + expect(isKnownAnthropicModel('totally-not-a-real-model')).toBe(false) + }) +}) + +describe('buildAnthropicModel — resolution', () => { + const fetchFn: AgentFetch = async () => new Response('', { status: 200 }) + + it('throws on a model id outside the built-in catalog', () => { + expect(() => buildAnthropicModel({ apiKey: 'k', fetch: fetchFn, modelId: 'bogus-model' })).toThrow( + /Unknown Anthropic model "bogus-model"/, + ) + }) + + it('resolves a known model and registers the anthropic provider', () => { + const { models, model } = buildAnthropicModel({ apiKey: 'k', fetch: fetchFn, modelId: 'claude-opus-4-8' }) + expect(model.id).toBe('claude-opus-4-8') + expect(model.baseUrl).toBe('https://api.anthropic.com') + expect(models.getProvider('anthropic')).toBeTruthy() + }) +}) + +describe('buildAnthropicModel — streamSimple request shaping', () => { + it('disables thinking and keeps the model max_tokens when reasoning is off', async () => { + const { body } = await drive('claude-opus-4-8', {} as SimpleStreamOptions) + expect(body?.thinking).toEqual({ type: 'disabled' }) + // No caller cap → the model cap (128000) flows through with no thinking budget added. + expect(body?.max_tokens).toBe(128000) + }) + + it('uses ADAPTIVE thinking (not a fixed budget) for a forceAdaptiveThinking model', async () => { + const { body } = await drive('claude-opus-4-8', { reasoning: 'high' } as SimpleStreamOptions) + expect(body?.thinking?.type).toBe('adaptive') + expect(body?.thinking?.budget_tokens).toBeUndefined() + expect(body?.output_config?.effort).toBe('high') + }) + + it('maps every Pi thinking level to the right adaptive effort, honoring the catalog override', async () => { + const expectEffort = async (level: string, effort: string) => { + const { body } = await drive('claude-opus-4-8', { reasoning: level } as SimpleStreamOptions) + expect(body?.output_config?.effort).toBe(effort) + } + await expectEffort('minimal', 'low') + await expectEffort('low', 'low') + await expectEffort('medium', 'medium') + await expectEffort('high', 'high') + // opus-4-8's thinkingLevelMap remaps xhigh→xhigh; without the override the + // default branch would collapse it to 'high', so this pins the override path. + await expectEffort('xhigh', 'xhigh') + }) + + it('uses ENABLED thinking with a level-sized budget for a non-adaptive model', async () => { + const high = await drive('claude-opus-4-1', { reasoning: 'high' } as SimpleStreamOptions) + expect(high.body?.thinking).toMatchObject({ type: 'enabled', budget_tokens: 16384 }) + expect(high.body?.thinking?.type).not.toBe('adaptive') + // Budget tracks the reasoning level, not the model. + const low = await drive('claude-opus-4-1', { reasoning: 'low' } as SimpleStreamOptions) + expect(low.body?.thinking).toMatchObject({ type: 'enabled', budget_tokens: 2048 }) + // No caller cap → clamped to the model's own max_tokens. + expect(high.body?.max_tokens).toBe(32000) + }) + + it('forwards a caller maxTokens cap into the thinking-budget math (cap + budget)', async () => { + // Proves the bridge carries the caller's `maxTokens` through buildBaseOptions + // into adjustMaxTokensForThinking — 1000 cap + 16384 high budget = 17384. + const { body } = await drive('claude-opus-4-1', { reasoning: 'high', maxTokens: 1000 } as SimpleStreamOptions) + expect(body?.max_tokens).toBe(17384) + expect(body?.thinking?.budget_tokens).toBe(16384) + }) +}) + +describe('buildAnthropicModel — injected-fetch client survives the bridge', () => { + const originalFetch = globalThis.fetch + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('routes streamSimple HTTP through the injected fetch, never the global', async () => { + let sentinelHits = 0 + globalThis.fetch = (async () => { + sentinelHits += 1 + return new Response('', { status: 500 }) + }) as unknown as typeof globalThis.fetch + + // Regression guard: vanilla Pi streamSimple drops the pre-built client (and thus + // the injected fetch). If the bridge ever stops carrying it, the request falls + // through to the global sentinel and these assertions fail loudly. + const { injectedCalls } = await drive('claude-opus-4-8', {} as SimpleStreamOptions) + expect(injectedCalls).toBe(1) + expect(sentinelHits).toBe(0) + }) + + it('the full `stream` entrypoint also carries the injected client', async () => { + const { injectedCalls, body } = await drive('claude-opus-4-8', {} as SimpleStreamOptions, 'stream') + expect(injectedCalls).toBe(1) + expect(body?.max_tokens).toBeGreaterThan(0) + }) + + it('restores the static browser-direct-access headers Pi would otherwise add', async () => { + // Handing Pi a pre-built client bypasses its per-request header logic; the two + // static headers needed for direct browser access must be restored on the wire. + const { headers } = await drive('claude-opus-4-8', {} as SimpleStreamOptions) + expect(headers?.get('accept')).toBe('application/json') + expect(headers?.get('anthropic-dangerous-direct-browser-access')).toBe('true') + }) +}) diff --git a/shared/agent-core/anthropic-model.ts b/shared/agent-core/anthropic-model.ts new file mode 100644 index 000000000..0e90a7fc0 --- /dev/null +++ b/shared/agent-core/anthropic-model.ts @@ -0,0 +1,206 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Builds a Pi-compatible Anthropic model whose HTTP goes through an injected + * `fetch`. This is the seam the in-browser embed needs: the app must route LLM + * calls through its CORS proxy via a custom fetch, but Pi's built-in anthropic + * provider constructs its `@anthropic-ai/sdk` client without a `fetch` hook. + * + * The `@anthropic-ai/sdk` constructor *does* accept `fetch`, and Pi's + * `anthropic-messages` API exposes a public `client?: Anthropic` option that + * skips internal client construction. So we build the SDK client ourselves with + * the injected fetch and hand it to Pi via that option — no fork of Pi. + * + * The one wrinkle: the harness drives streaming through `Models.streamSimple`, + * and Pi's `streamSimple` rebuilds its options via `buildBaseOptions`, which + * drops the `client` field. Only the full `stream` entry point honors `client`. + * So this module re-implements Pi's thin simple→full options bridge (reusing + * Pi's own exported `buildBaseOptions`/`adjustMaxTokensForThinking`) and injects + * the pre-built client into the full `stream` call. + * + * Header fidelity note: handing Pi a pre-built client bypasses its per-request + * header logic. We restore the two static headers that matter for the browser + * (`accept`, `anthropic-dangerous-direct-browser-access`). The only per-request + * header lost for tool-using runs is the `fine-grained-tool-streaming` beta + * (incremental tool-arg streaming — UX only, not correctness). The interleaved- + * thinking beta is irrelevant here: every adaptive model (e.g. claude-opus-4-8) + * has it built in and Pi skips that header for them anyway. + */ + +import Anthropic from '@anthropic-ai/sdk' +import { + type Api, + type AnthropicEffort, + type AnthropicOptions, + type Model, + type Models, + type ProviderStreams, + type SimpleStreamOptions, + type ThinkingLevel, + createModels, + createProvider, + envApiKeyAuth, + hasApi, +} from '@earendil-works/pi-ai' +import { stream as anthropicStream } from '@earendil-works/pi-ai/api/anthropic-messages' +import { adjustMaxTokensForThinking, buildBaseOptions } from '@earendil-works/pi-ai/api/simple-options' +import { builtinModels } from '@earendil-works/pi-ai/providers/all' + +/** Provider id of the resolved model; matches Pi's built-in anthropic provider. */ +const PROVIDER = 'anthropic' +const API = 'anthropic-messages' + +/** Valid adaptive-thinking effort levels, used to narrow catalog overrides. */ +const EFFORTS: readonly AnthropicEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'] + +/** + * Minimal fetch shape every request is routed through. The app passes its proxy + * fetch (`FetchFn` from `src/lib/proxy-fetch.ts`, which additionally carries a + * `preconnect` method); a bare global fetch also satisfies this. Declared + * structurally — rather than as `typeof globalThis.fetch` — to dodge the + * Bun-vs-DOM `preconnect` signature clash that `typeof fetch` triggers when both + * type roots are loaded (the same reason `proxy-fetch.ts` pins its own `FetchFn` + * alias). Assignable to `@anthropic-ai/sdk`'s `Fetch` option, and every proxy + * fetch is assignable to it. + */ +export type AgentFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise + +/** Inputs for {@link buildAnthropicModel}. */ +export type BuildAnthropicModelOptions = { + /** Anthropic API key (used to build the SDK client; HTTP still flows through `fetch`). */ + readonly apiKey: string + /** The fetch implementation every request is routed through (e.g. the app's proxy fetch). */ + readonly fetch: AgentFetch + /** Anthropic model id to resolve, e.g. `claude-opus-4-8`. */ + readonly modelId: string +} + +/** + * Whether Pi's built-in Anthropic catalog can resolve `modelId` as an + * anthropic-messages model. The adapter's routing gate calls this so a model id + * Pi doesn't know (e.g. a brand-new claude the catalog lacks) falls back to the + * legacy pipeline instead of crashing the chat. Cheap — the catalog is in-memory. + * + * @param modelId - the Anthropic model id to probe, e.g. `claude-opus-4-8` + * @returns true if the catalog has an anthropic-messages model with that id + */ +export const isKnownAnthropicModel = (modelId: string): boolean => { + const resolved = builtinModels().getModel(PROVIDER, modelId) + return Boolean(resolved && hasApi(resolved, API)) +} + +/** + * Narrows a dispatched `Model` to the anthropic-messages model this + * provider exclusively serves, surfacing misuse loudly rather than guessing. + */ +const requireAnthropic = (model: Model): Model => { + if (!hasApi(model, API)) { + throw new Error(`Expected an "${API}" model, got "${model.api}".`) + } + return model +} + +/** + * Maps a Pi thinking level to an Anthropic adaptive-thinking effort, honoring a + * model's `thinkingLevelMap` override (e.g. opus models remap `xhigh`). Mirrors + * Pi's internal mapping, which is not exported. + */ +const mapThinkingLevelToEffort = (model: Model, level: ThinkingLevel): AnthropicEffort => { + const mapped = model.thinkingLevelMap?.[level] + // `EFFORTS.find` validates the catalog override as a real effort (type-safe, + // no cast). Every Anthropic catalog override is valid, so this matches Pi. + const override = typeof mapped === 'string' ? EFFORTS.find((effort) => effort === mapped) : undefined + if (override) return override + if (level === 'minimal' || level === 'low') return 'low' + if (level === 'medium') return 'medium' + return 'high' +} + +/** + * Re-implements Pi's `streamSimple` simple→full bridge so the `client` survives + * into the full `stream` call. Reuses Pi's exported helpers for the parts that + * are exported; only the (unexported) effort mapping is reproduced above. + */ +const toFullAnthropicOptions = (model: Model, options?: SimpleStreamOptions): AnthropicOptions => { + const base = buildBaseOptions(model, options, options?.apiKey) + if (!options?.reasoning) { + return { ...base, thinkingEnabled: false } + } + if (model.compat?.forceAdaptiveThinking === true) { + return { ...base, thinkingEnabled: true, effort: mapThinkingLevelToEffort(model, options.reasoning) } + } + const adjusted = adjustMaxTokensForThinking( + base.maxTokens, + model.maxTokens, + options.reasoning, + options.thinkingBudgets, + ) + return { + ...base, + maxTokens: adjusted.maxTokens, + thinkingEnabled: true, + thinkingBudgetTokens: adjusted.thinkingBudget, + } +} + +/** + * Builds the `@anthropic-ai/sdk` client with the injected fetch, restoring the + * static headers Pi would otherwise add for direct browser access. + */ +const createAnthropicClient = (model: Model, opts: BuildAnthropicModelOptions): Anthropic => + new Anthropic({ + apiKey: opts.apiKey, + baseURL: model.baseUrl, + fetch: opts.fetch, + dangerouslyAllowBrowser: true, + defaultHeaders: { + accept: 'application/json', + 'anthropic-dangerous-direct-browser-access': 'true', + }, + }) + +/** + * Resolves an Anthropic model and wires it through a provider whose HTTP flows + * through `opts.fetch`. Drop-in replacement for `resolveModel`: returns the same + * `{ models, model }` shape the harness consumes. + * + * @param opts - api key, injected fetch, and the model id to resolve + * @returns the wired provider collection and the resolved model + * @throws if `opts.modelId` is not in Pi's built-in Anthropic catalog + */ +export const buildAnthropicModel = (opts: BuildAnthropicModelOptions): { models: Models; model: Model } => { + const catalog = builtinModels() + const resolved = catalog.getModel(PROVIDER, opts.modelId) + if (!resolved || !hasApi(resolved, API)) { + throw new Error(`Unknown Anthropic model "${opts.modelId}".`) + } + + const client = createAnthropicClient(resolved, opts) + const api: ProviderStreams = { + stream: (model, context, options) => anthropicStream(requireAnthropic(model), context, { ...options, client }), + streamSimple: (model, context, options) => { + const narrowed = requireAnthropic(model) + return anthropicStream(narrowed, context, { ...toFullAnthropicOptions(narrowed, options), client }) + }, + } + + const models = createModels() + models.setProvider( + createProvider({ + id: PROVIDER, + name: 'Anthropic', + baseUrl: resolved.baseUrl, + // Advisory only: the pre-built `client` owns the real credential + // (`opts.apiKey`, bound for the session). This descriptor lets Pi's + // status/credential-store reporting recognize the provider; the resolved + // key never reaches the wire because the injected client is used as-is. + auth: { apiKey: envApiKeyAuth('Anthropic API key', ['ANTHROPIC_API_KEY']) }, + models: [resolved], + api, + }), + ) + + return { models, model: resolved } +} diff --git a/shared/agent-core/browser-env/browser-execution-env.test.ts b/shared/agent-core/browser-env/browser-execution-env.test.ts new file mode 100644 index 000000000..79068f2df --- /dev/null +++ b/shared/agent-core/browser-env/browser-execution-env.test.ts @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `BrowserExecutionEnv` jail tests. The shell path (`exec`) already enforces the + * workspace jail; these lock the *filesystem* surface (read/write/list/…) to the + * same boundary, so a Pi tool can't reach a sibling thread's files via an + * absolute or `..` path — and so a future method added without `jailed()` is + * caught here rather than in production. + */ + +import { beforeAll, describe, expect, it } from 'bun:test' +import * as fsp from '@zenfs/core/promises' +import { BrowserExecutionEnv } from './browser-execution-env.ts' +import { mountInMemoryFs } from './mount.ts' + +const JAIL = '/workspace/t1' + +beforeAll(async () => { + await mountInMemoryFs() + await fsp.mkdir('/workspace/t1', { recursive: true }) + await fsp.mkdir('/workspace/t2', { recursive: true }) + await fsp.writeFile('/workspace/t1/mine.txt', 'mine') + await fsp.writeFile('/workspace/t2/secret.txt', 'secret') + await fsp.mkdir('/etc', { recursive: true }) + await fsp.writeFile('/etc/passwd', 'root') +}) + +const env = new BrowserExecutionEnv({ cwd: JAIL }) + +describe('BrowserExecutionEnv filesystem jail', () => { + it('reads files inside the workspace (absolute and relative)', async () => { + const abs = await env.readTextFile('/workspace/t1/mine.txt') + const rel = await env.readTextFile('mine.txt') + expect(abs.ok && abs.value).toBe('mine') + expect(rel.ok && rel.value).toBe('mine') + }) + + it('blocks reading a sibling thread workspace without leaking content', async () => { + const result = await env.readTextFile('/workspace/t2/secret.txt') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.code).toBe('permission_denied') + }) + + it('blocks reading an absolute system path', async () => { + const result = await env.readTextFile('/etc/passwd') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.code).toBe('permission_denied') + }) + + it('blocks `..` traversal into a sibling', async () => { + const result = await env.readTextFile('../t2/secret.txt') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.code).toBe('permission_denied') + }) + + it('blocks readBinaryFile and readTextLines outside the jail', async () => { + const bin = await env.readBinaryFile('/workspace/t2/secret.txt') + const lines = await env.readTextLines('/etc/passwd') + expect(bin.ok).toBe(false) + expect(lines.ok).toBe(false) + }) + + it('blocks writing outside the workspace and does not create the file', async () => { + const result = await env.writeFile('/workspace/t2/pwned.txt', 'x') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.code).toBe('permission_denied') + expect(await fsp.exists('/workspace/t2/pwned.txt')).toBe(false) + }) + + it('blocks appendFile outside the workspace', async () => { + const result = await env.appendFile('/workspace/t2/secret.txt', 'x') + expect(result.ok).toBe(false) + expect(await fsp.readFile('/workspace/t2/secret.txt', { encoding: 'utf8' })).toBe('secret') + }) + + it('blocks removing outside the workspace and leaves the file intact', async () => { + const result = await env.remove('/workspace/t2/secret.txt') + expect(result.ok).toBe(false) + expect(await fsp.exists('/workspace/t2/secret.txt')).toBe(true) + }) + + it('blocks listing the parent workspace root', async () => { + const result = await env.listDir('/workspace') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.code).toBe('permission_denied') + }) + + it('blocks fileInfo, canonicalPath and createDir outside the jail', async () => { + const info = await env.fileInfo('/workspace/t2/secret.txt') + const canon = await env.canonicalPath('/etc/passwd') + const dir = await env.createDir('/workspace/t2/sub') + expect(info.ok).toBe(false) + expect(canon.ok).toBe(false) + expect(dir.ok).toBe(false) + expect(await fsp.exists('/workspace/t2/sub')).toBe(false) + }) + + it('reports exists() as a permission error for out-of-jail paths (no existence oracle)', async () => { + const escape = await env.exists('/etc/passwd') + expect(escape.ok).toBe(false) + if (!escape.ok) expect(escape.error.code).toBe('permission_denied') + + const insideMissing = await env.exists('nope.txt') + expect(insideMissing.ok && insideMissing.value).toBe(false) + const insidePresent = await env.exists('mine.txt') + expect(insidePresent.ok && insidePresent.value).toBe(true) + }) + + it('writes and reads back a legitimate in-jail file', async () => { + const write = await env.writeFile('nested/note.txt', 'hello') + expect(write.ok).toBe(true) + const read = await env.readTextFile('nested/note.txt') + expect(read.ok && read.value).toBe('hello') + }) + + it('canonicalPath resolves an in-jail file (re-jail does not block legit paths)', async () => { + const result = await env.canonicalPath('mine.txt') + expect(result.ok && result.value).toBe('/workspace/t1/mine.txt') + }) + + it('keeps absolutePath pure (un-jailed) so ancestor computation still works', async () => { + // Pure path math grants no access; the FS methods above are the gate. + const result = await env.absolutePath('../t2/secret.txt') + expect(result.ok && result.value).toBe('/workspace/t2/secret.txt') + }) + + it('blocks a shell whose cwd escapes the workspace', async () => { + const result = await env.exec('echo hi', { cwd: '../t2' }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.message).toContain('cwd escapes workspace') + }) +}) diff --git a/shared/agent-core/browser-env/browser-execution-env.ts b/shared/agent-core/browser-env/browser-execution-env.ts new file mode 100644 index 000000000..cb799f9ca --- /dev/null +++ b/shared/agent-core/browser-env/browser-execution-env.ts @@ -0,0 +1,333 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `BrowserExecutionEnv` is Pi's {@link ExecutionEnv} (filesystem + shell) + * implemented entirely on top of a single ZenFS mount, with the shell provided + * by just-bash. It is the in-browser analogue of Pi's `NodeExecutionEnv`: same + * Result-based contract, same four-tool surface, but backed by ZenFS instead of + * `node:fs` and by just-bash instead of a spawned `/bin/bash`. + * + * Both halves target the SAME ZenFS singleton: + * - filesystem methods call `@zenfs/core/promises` directly; + * - `exec()` runs commands through a fresh just-bash `Bash` bound to a + * {@link ZenBashFileSystem} adapter over that same singleton. + * That shared mount is what makes a Pi `writeFile` instantly visible to `cat`, + * and a shell redirect instantly readable via `readTextFile`. + * + * Every operation method honours Pi's invariant of never throwing: ZenFS' and + * just-bash's thrown errors are caught at this adapter boundary and encoded into + * the returned {@link Result}. This is the architectural error-handling layer + * the contract mandates, not defensive wrapping of trusted calls. + * + * ZenFS is a process-global singleton; configure it once via `mountInMemoryFs()` + * (or, in the app, a `@zenfs/dom` OPFS backend) before constructing this env. + */ + +import { + err, + ExecutionError, + FileError, + ok, + toError, + type ExecutionEnv, + type FileInfo, + type Result, + type ShellExecOptions, +} from '@earendil-works/pi-agent-core' +import * as fsp from '@zenfs/core/promises' +import { dirname, join, resolve } from '@zenfs/core/path' +import { Bash } from 'just-bash' +import { abortedResult, fileInfoFrom, splitLines, toFileError } from './fs-helpers.ts' +import { isWithinWorkspace, resolveInWorkspace } from './workspace-jail.ts' +import { ZenBashFileSystem } from './zen-bash-fs.ts' + +/** Per-env subdirectory (relative to the env's root) under which + * {@link BrowserExecutionEnv.createTempDir} carves unique directories. Kept + * INSIDE the env root so temp files stay within the workspace jail (readable by + * the jailed coding tools, e.g. bash's "Full output" file) and are torn down + * with the workspace instead of leaking into a shared `/tmp`. */ +const TEMP_SUBDIR = '.tmp' + +export class BrowserExecutionEnv implements ExecutionEnv { + cwd: string + private readonly env: Record + private readonly bashFs: ZenBashFileSystem + /** Absolute temp root for this env, inside its (jailed) workspace root. */ + private readonly tempRoot: string + + constructor(options: { cwd: string; env?: Record }) { + this.cwd = options.cwd + this.env = options.env ?? {} + this.tempRoot = join(options.cwd, TEMP_SUBDIR) + // The shell is jailed to the env's root (the thread's workspace) so bash + // commands can't read or write a sibling thread's files on the shared mount. + this.bashFs = new ZenBashFileSystem(options.cwd) + } + + /** + * Resolve `path` against this env's workspace root and assert it stays inside + * the jail, returning a `permission_denied` {@link FileError} on escape so the + * never-throw contract holds. This is the same invariant {@link exec} and + * {@link ZenBashFileSystem} enforce; centralising it here means every + * path-accepting filesystem method is jailed by construction rather than by + * each call site remembering to re-check. + * + * This check is purely lexical (it does not follow symlinks). That is sound + * because the shared mount can never hold a symlink whose physical target + * escapes the jail: {@link ZenBashFileSystem.symlink} refuses creation, and the + * {@link ExecutionEnv} surface exposes no symlink primitive — so there is no + * agent-reachable path to plant one. {@link canonicalPath} additionally + * re-validates the resolved real path as defense-in-depth. + */ + private jailed(path: string): Result { + try { + return ok(resolveInWorkspace(this.cwd, path)) + } catch (error) { + return err(new FileError('permission_denied', toError(error).message, path)) + } + } + + async absolutePath(path: string): Promise> { + // Pure path computation, deliberately un-jailed (mirrors ZenBashFileSystem's + // `resolvePath`): it grants no filesystem access — every method that does + // touch the mount routes through `jailed()` — and Pi's tools rely on it to + // compute ancestor paths during traversal. + return ok(resolve(this.cwd, path)) + } + + async joinPath(parts: string[]): Promise> { + return ok(join(...parts)) + } + + async exec( + command: string, + options?: ShellExecOptions, + ): Promise> { + if (options?.abortSignal?.aborted) return err(new ExecutionError('aborted', 'aborted')) + + const cwd = options?.cwd ? resolve(this.cwd, options.cwd) : this.cwd + if (!isWithinWorkspace(this.cwd, cwd)) { + return err(new ExecutionError('unknown', `cwd escapes workspace: ${options?.cwd}`)) + } + const env = { ...this.env, ...options?.env } + const controller = new AbortController() + const state = { timedOut: false } + const onExternalAbort = () => controller.abort() + if (options?.abortSignal) options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }) + const timeoutId = + typeof options?.timeout === 'number' + ? setTimeout(() => { + state.timedOut = true + controller.abort() + }, options.timeout * 1000) + : undefined + + try { + // `defenseInDepth` (default ON) monkey-patches/blocks JS globals (Proxy, + // eval, Function, …) to contain escapes from just-bash's *sandboxed JS* + // surfaces — `js-exec` (QuickJS) and python. We enable neither, so it + // guards nothing here while actively breaking the bash interpreter (it + // trips on just-bash's own internal `Proxy` use). The real sandbox is the + // virtual ZenFS mount with no host-process access, so we disable it. + const bash = new Bash({ fs: this.bashFs, cwd, env, defenseInDepth: false }) + const result = await bash.exec(command, { signal: controller.signal }) + if (state.timedOut) return err(new ExecutionError('timeout', `timeout:${options?.timeout}`)) + if (options?.abortSignal?.aborted) return err(new ExecutionError('aborted', 'aborted')) + if (result.stdout) options?.onStdout?.(result.stdout) + if (result.stderr) options?.onStderr?.(result.stderr) + return ok({ stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }) + } catch (error) { + if (state.timedOut) return err(new ExecutionError('timeout', `timeout:${options?.timeout}`)) + if (controller.signal.aborted) return err(new ExecutionError('aborted', 'aborted')) + const cause = toError(error) + return err(new ExecutionError('unknown', cause.message, cause)) + } finally { + if (timeoutId) clearTimeout(timeoutId) + if (options?.abortSignal) options.abortSignal.removeEventListener('abort', onExternalAbort) + } + } + + async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + const aborted = abortedResult(abortSignal, resolved.value) + if (aborted) return aborted + try { + return ok(await fsp.readFile(resolved.value, { encoding: 'utf8' })) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + const aborted = abortedResult(options?.abortSignal, resolved.value) + if (aborted) return aborted + if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]) + try { + const lines = splitLines(await fsp.readFile(resolved.value, { encoding: 'utf8' })) + return ok(options?.maxLines !== undefined ? lines.slice(0, options.maxLines) : lines) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + const aborted = abortedResult(abortSignal, resolved.value) + if (aborted) return aborted + try { + return ok(new Uint8Array(await fsp.readFile(resolved.value))) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + const aborted = abortedResult(abortSignal, resolved.value) + if (aborted) return aborted + try { + await fsp.mkdir(dirname(resolved.value), { recursive: true }) + const afterMkdir = abortedResult(abortSignal, resolved.value) + if (afterMkdir) return afterMkdir + await fsp.writeFile(resolved.value, content) + return ok(undefined) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async appendFile(path: string, content: string | Uint8Array): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + try { + await fsp.mkdir(dirname(resolved.value), { recursive: true }) + await fsp.appendFile(resolved.value, content) + return ok(undefined) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async fileInfo(path: string): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + try { + return fileInfoFrom(resolved.value, await fsp.lstat(resolved.value)) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async listDir(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + const aborted = abortedResult(abortSignal, resolved.value) + if (aborted) return aborted + try { + const entries = await fsp.readdir(resolved.value, { withFileTypes: true }) + const infos: FileInfo[] = [] + for (const entry of entries) { + const loopAborted = abortedResult(abortSignal, resolved.value) + if (loopAborted) return loopAborted + const childPath = join(resolved.value, entry.name) + const info = fileInfoFrom(childPath, await fsp.lstat(childPath)) + if (info.ok) infos.push(info.value) + } + return ok(infos) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async canonicalPath(path: string): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + try { + const real = await fsp.realpath(resolved.value) + // Defense-in-depth: `realpath` is the one method that follows symlinks, so + // re-assert the canonical target stays in the jail. With symlink creation + // refused this never fires, but it keeps the boundary from resting solely + // on that invariant. + if (!isWithinWorkspace(this.cwd, real)) { + return err(new FileError('permission_denied', `path escapes workspace: ${path}`, path)) + } + return ok(real) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async exists(path: string): Promise> { + const result = await this.fileInfo(path) + if (result.ok) return ok(true) + if (result.error.code === 'not_found') return ok(false) + return err(result.error) + } + + async createDir(path: string, options?: { recursive?: boolean }): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + try { + await fsp.mkdir(resolved.value, { recursive: options?.recursive ?? true }) + return ok(undefined) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise> { + const resolved = this.jailed(path) + if (!resolved.ok) return resolved + try { + await fsp.rm(resolved.value, { recursive: options?.recursive ?? false, force: options?.force ?? false }) + return ok(undefined) + } catch (error) { + return err(toFileError(error, resolved.value)) + } + } + + async createTempDir(prefix = 'tmp-'): Promise> { + try { + // `crypto.randomUUID()` is generated inside the try: outside a secure + // context the browser leaves it undefined, so calling it throws — which + // would break the never-throw contract if it ran before the try. + const dir = join(this.tempRoot, `${prefix}${crypto.randomUUID()}`) + await fsp.mkdir(this.tempRoot, { recursive: true }) + await fsp.mkdir(dir) + return ok(dir) + } catch (error) { + return err(toFileError(error, this.tempRoot)) + } + } + + async createTempFile(options?: { prefix?: string; suffix?: string }): Promise> { + const dir = await this.createTempDir('tmp-') + if (!dir.ok) return dir + try { + const filePath = join(dir.value, `${options?.prefix ?? ''}${crypto.randomUUID()}${options?.suffix ?? ''}`) + await fsp.writeFile(filePath, '') + return ok(filePath) + } catch (error) { + return err(toFileError(error, dir.value)) + } + } + + async cleanup(): Promise { + // ZenFS is a shared, process-global singleton owned by the mount; there is + // nothing per-env to release. Kept as a no-op to satisfy the contract. + } +} diff --git a/shared/agent-core/browser-env/coding-tool-operations.test.ts b/shared/agent-core/browser-env/coding-tool-operations.test.ts new file mode 100644 index 000000000..8d09928bc --- /dev/null +++ b/shared/agent-core/browser-env/coding-tool-operations.test.ts @@ -0,0 +1,151 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for the browser coding-tool operation adapters. + * + * `createBashOperations` is tested with a hand-written fake `BrowserExecutionEnv` + * (dependency injection, not module mocking): it is the boundary translation + * between the env's never-throw `Result` and Pi's bash tool, which decodes + * failures from a *thrown* error. The translation (success -> exitCode, failure + * -> re-throw the typed error, stdout+stderr -> a single `onData` Buffer stream) + * is the only real logic in this file and the part most likely to regress. + * + * The read/write/edit adapters are thin ZenFS wrappers, so they run against a + * real in-memory mount (the shared singleton) to prove the Buffer conversion and + * recursive-mkdir behavior the coding tools rely on. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { ExecutionError, err, ok, type Result } from '@earendil-works/pi-agent-core' +import * as fsp from '@zenfs/core/promises' +import type { BrowserExecutionEnv } from './browser-execution-env.ts' +import { + createBashOperations, + createEditOperations, + createReadOperations, + createWriteOperations, +} from './coding-tool-operations.ts' +import { mountInMemoryFs } from './mount.ts' + +type ExecResult = Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError> + +/** Minimal fake env that records the options it was called with and lets each + * test script the exec outcome + what gets streamed. Only `exec` is exercised. */ +const fakeEnv = (script: { + result: ExecResult + emit?: { stdout?: string; stderr?: string } +}): { env: BrowserExecutionEnv; calls: Array<{ command: string; options: Record }> } => { + const calls: Array<{ command: string; options: Record }> = [] + const env = { + exec: async (command: string, options: Record) => { + calls.push({ command, options }) + if (script.emit?.stdout !== undefined) (options.onStdout as (c: string) => void)(script.emit.stdout) + if (script.emit?.stderr !== undefined) (options.onStderr as (c: string) => void)(script.emit.stderr) + return script.result + }, + } as unknown as BrowserExecutionEnv + return { env, calls } +} + +describe('createBashOperations', () => { + it('returns the env exitCode on success', async () => { + const { env } = fakeEnv({ result: ok({ stdout: '', stderr: '', exitCode: 7 }) }) + const ops = createBashOperations(env) + const result = await ops.exec('echo hi', '/workspace/t1', { onData: () => {} }) + expect(result).toEqual({ exitCode: 7 }) + }) + + it('streams BOTH stdout and stderr through onData as Buffers', async () => { + const { env } = fakeEnv({ + result: ok({ stdout: '', stderr: '', exitCode: 0 }), + emit: { stdout: 'out-chunk', stderr: 'err-chunk' }, + }) + const ops = createBashOperations(env) + const chunks: Buffer[] = [] + await ops.exec('cmd', '/workspace/t1', { onData: (d) => chunks.push(d) }) + expect(chunks.every((c) => Buffer.isBuffer(c))).toBe(true) + expect(chunks.map((c) => c.toString())).toEqual(['out-chunk', 'err-chunk']) + }) + + it('re-throws the env ExecutionError on failure, after still streaming any output emitted before it', async () => { + const timeoutError = new ExecutionError('timeout', 'timeout:5') + // Output produced before the command timed out must reach the tool, and the + // exact same error instance must surface — its `.message` carries the framing + // the bash tool parses; re-wrapping would break timeout detection. + const { env } = fakeEnv({ result: err(timeoutError), emit: { stdout: 'partial-before-timeout' } }) + const ops = createBashOperations(env) + const chunks: Buffer[] = [] + await expect(ops.exec('sleep 10', '/workspace/t1', { onData: (d) => chunks.push(d) })).rejects.toBe(timeoutError) + expect(chunks.map((c) => c.toString())).toEqual(['partial-before-timeout']) + }) + + it('forwards cwd, timeout and the abort signal into env.exec', async () => { + const { env, calls } = fakeEnv({ result: ok({ stdout: '', stderr: '', exitCode: 0 }) }) + const ops = createBashOperations(env) + const signal = new AbortController().signal + await ops.exec('cmd', '/workspace/sub', { onData: () => {}, signal, timeout: 12 }) + expect(calls).toHaveLength(1) + expect(calls[0].command).toBe('cmd') + expect(calls[0].options.cwd).toBe('/workspace/sub') + expect(calls[0].options.timeout).toBe(12) + expect(calls[0].options.abortSignal).toBe(signal) + }) +}) + +const DIR = '/optest' + +describe('file operation adapters (real in-memory ZenFS)', () => { + beforeAll(async () => { + await mountInMemoryFs() + await fsp.mkdir(DIR, { recursive: true }) + await fsp.writeFile(`${DIR}/seed.txt`, 'seed-content') + }) + + afterAll(async () => { + await fsp.rm(DIR, { recursive: true, force: true }) + }) + + it('readFile returns a Node Buffer (not a bare Uint8Array)', async () => { + const ops = createReadOperations() + const buf = await ops.readFile(`${DIR}/seed.txt`) + // The operation contract is typed in terms of Buffer; ZenFS hands back a + // Uint8Array, so the Buffer.from wrap is load-bearing for callers doing + // Buffer-only ops (.toString('utf8'), slicing). + expect(Buffer.isBuffer(buf)).toBe(true) + expect(buf.toString('utf8')).toBe('seed-content') + }) + + it('access resolves for an existing file and rejects (ENOENT) for a missing one', async () => { + const ops = createReadOperations() + await expect(ops.access(`${DIR}/seed.txt`)).resolves.toBeUndefined() + // The edit tool branches on this rejection to distinguish edit vs create. + await expect(ops.access(`${DIR}/missing.txt`)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('write mkdir creates nested dirs recursively; the file is then visible to the read adapter (shared mount)', async () => { + const writeOps = createWriteOperations() + await writeOps.mkdir(`${DIR}/deep/nested`) + await writeOps.writeFile(`${DIR}/deep/nested/out.txt`, 'written') + // Read back through a SEPARATE adapter to prove both target the one ZenFS + // singleton, not via raw fsp (which would prove nothing about the adapters). + const readOps = createReadOperations() + expect((await readOps.readFile(`${DIR}/deep/nested/out.txt`)).toString('utf8')).toBe('written') + }) + + it('edit writeFile overwrites in place and readFile reads the new content back as a Buffer', async () => { + const ops = createEditOperations() + const target = `${DIR}/edit-target.txt` + await ops.writeFile(target, 'v1') + await ops.writeFile(target, 'v2-overwritten') + const buf = await ops.readFile(target) + expect(Buffer.isBuffer(buf)).toBe(true) + expect(buf.toString('utf8')).toBe('v2-overwritten') + }) + + it('edit access rejects for a missing file (the create-vs-edit signal)', async () => { + const ops = createEditOperations() + await expect(ops.access(`${DIR}/never-existed.txt`)).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/shared/agent-core/browser-env/coding-tool-operations.ts b/shared/agent-core/browser-env/coding-tool-operations.ts new file mode 100644 index 000000000..f9880f414 --- /dev/null +++ b/shared/agent-core/browser-env/coding-tool-operations.ts @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * {@link BrowserExecutionEnv}-backed operations for the four browser coding tools + * (bash/read/write/edit, see `../coding-tools`). They redirect each tool's I/O + * onto the same process-global ZenFS mount the harness runs over, replacing the + * Node shell + `node:fs` backend the CLI tools assume. + * + * The bash operation delegates to {@link BrowserExecutionEnv.exec} (just-bash + * over ZenFS); the file operations call `@zenfs/core/promises` directly — the + * very same singleton — so a file the shell writes is instantly visible to the + * read tool and vice versa. All four therefore share one consistent mount. + * + * The env is never-throwing (it encodes failures in a `Result`); the bash tool, + * by contrast, decodes failures from a thrown error (and detects timeouts via a + * `timeout:` message). The bash adapter re-throws the env's + * `ExecutionError` — whose `.message` already carries that exact framing — so the + * tool's error/timeout handling works unchanged. This is the architectural + * boundary translation, not defensive wrapping. + * + * Runtime note: the read/edit operations and the bash output bridge construct + * Node `Buffer`s (the operation contracts are typed in terms of `Buffer`). The + * app entry must ensure a global `Buffer` polyfill exists in the browser (the + * standard `buffer` package shim) before driving the harness. + */ + +import * as fsp from '@zenfs/core/promises' +import type { BrowserExecutionEnv } from './browser-execution-env.ts' + +/** Streamed-execution backend for the bash tool. Throws on failure (aborted / + * `timeout:` / other), matching the tool's error-decoding contract. */ +export type BashOperations = { + exec: ( + command: string, + cwd: string, + options: { onData: (data: Buffer) => void; signal?: AbortSignal; timeout?: number }, + ) => Promise<{ exitCode: number | null }> +} + +/** File-read backend for the read tool. */ +export type ReadOperations = { + readFile: (absolutePath: string) => Promise + access: (absolutePath: string) => Promise +} + +/** File-write backend for the write tool. */ +export type WriteOperations = { + writeFile: (absolutePath: string, content: string) => Promise + mkdir: (dir: string) => Promise +} + +/** File read/write backend for the edit tool. */ +export type EditOperations = { + readFile: (absolutePath: string) => Promise + writeFile: (absolutePath: string, content: string) => Promise + access: (absolutePath: string) => Promise +} + +/** + * Build bash operations that run every command through `env` (just-bash over the + * shared ZenFS mount). Streams stdout and stderr to the tool via `onData` and + * surfaces the env's encoded failures by throwing them. + * + * @param env - the browser execution environment bound to the agent's cwd + */ +export const createBashOperations = (env: BrowserExecutionEnv): BashOperations => ({ + exec: async (command, cwd, { onData, signal, timeout }) => { + const result = await env.exec(command, { + cwd, + timeout, + abortSignal: signal, + onStdout: (chunk) => onData(Buffer.from(chunk)), + onStderr: (chunk) => onData(Buffer.from(chunk)), + }) + if (result.ok) { + return { exitCode: result.value.exitCode } + } + // env.exec never throws; re-throw the typed ExecutionError so Pi's bash tool + // decodes it (its `.message` already carries the `timeout:` framing). + throw result.error + }, +}) + +/** Build read operations over the shared ZenFS mount. */ +export const createReadOperations = (): ReadOperations => ({ + readFile: async (absolutePath) => Buffer.from(await fsp.readFile(absolutePath)), + access: async (absolutePath) => { + await fsp.access(absolutePath) + }, +}) + +/** Build write operations over the shared ZenFS mount. */ +export const createWriteOperations = (): WriteOperations => ({ + writeFile: async (absolutePath, content) => { + await fsp.writeFile(absolutePath, content) + }, + mkdir: async (dir) => { + await fsp.mkdir(dir, { recursive: true }) + }, +}) + +/** Build edit operations over the shared ZenFS mount. */ +export const createEditOperations = (): EditOperations => ({ + readFile: async (absolutePath) => Buffer.from(await fsp.readFile(absolutePath)), + writeFile: async (absolutePath, content) => { + await fsp.writeFile(absolutePath, content) + }, + access: async (absolutePath) => { + await fsp.access(absolutePath) + }, +}) diff --git a/shared/agent-core/browser-env/fs-helpers.test.ts b/shared/agent-core/browser-env/fs-helpers.test.ts new file mode 100644 index 000000000..8a5f7b411 --- /dev/null +++ b/shared/agent-core/browser-env/fs-helpers.test.ts @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for the ZenFS<->Pi interop helpers. These are pure functions, so + * they are tested directly (no mount needed). The error-code mapping is + * security-relevant: it is what turns a ZenFS `EACCES`/`EPERM` (raised when the + * jail or backend denies access) into Pi's `permission_denied`, and a missing + * file into `not_found` — the code the coding tools branch on to decide + * create-vs-edit. A regression here would silently mis-classify denials. + */ + +import { describe, expect, it } from 'bun:test' +import { FileError } from '@earendil-works/pi-agent-core' +import { abortedResult, fileInfoFrom, splitLines, toFileError, type ZenStats } from './fs-helpers.ts' + +const errno = (code: string, message = code): Error & { code: string } => + Object.assign(new Error(message), { code }) + +const statOf = (kind: 'file' | 'dir' | 'symlink' | 'other', over: Partial = {}): ZenStats => ({ + isFile: () => kind === 'file', + isDirectory: () => kind === 'dir', + isSymbolicLink: () => kind === 'symlink', + mode: 0o644, + size: 0, + mtime: new Date(0), + mtimeMs: 0, + ...over, +}) + +describe('toFileError', () => { + it('returns an existing FileError unchanged (passthrough, no double-wrap)', () => { + const original = new FileError('is_directory', 'orig', '/p') + expect(toFileError(original, '/other')).toBe(original) + }) + + it.each([ + ['ABORT_ERR', 'aborted'], + ['ENOENT', 'not_found'], + ['EACCES', 'permission_denied'], + ['EPERM', 'permission_denied'], + ['ENOTDIR', 'not_directory'], + ['EISDIR', 'is_directory'], + ['EINVAL', 'invalid'], + ] as const)('maps errno %s -> %s', (code, expectedCode) => { + const thrown = errno(code, `${code} boom`) + const result = toFileError(thrown, '/x') + expect(result).toBeInstanceOf(FileError) + expect(result.code).toBe(expectedCode) + expect(result.message).toBe(`${code} boom`) + expect(result.path).toBe('/x') + // The exact original error is preserved as the cause (not a rewrapped copy). + expect((result as { cause?: Error }).cause).toBe(thrown) + }) + + it('falls back to "unknown" for an unrecognized errno code', () => { + const result = toFileError(errno('EEXIST', 'already there'), '/x') + expect(result.code).toBe('unknown') + expect(result.message).toBe('already there') + }) + + it('treats an Error whose `code` is non-string as unknown (errno codes are strings)', () => { + // A numeric `code` (some libraries use numbers) must not be matched against + // the string switch — `isErrnoError` requires `typeof code === 'string'`. + const result = toFileError(Object.assign(new Error('numeric code'), { code: 123 }), '/x') + expect(result.code).toBe('unknown') + expect(result.message).toBe('numeric code') + }) + + it('treats a non-Error value carrying a string `code` as unknown (only real errno Errors map)', () => { + // Security: a plain object {code:'ENOENT'} must NOT be mapped to not_found. + // Only genuine thrown Errors get the specific mapping; otherwise an attacker + // controlling a thrown payload could forge a benign code. + const result = toFileError({ code: 'ENOENT', message: 'fake' }, '/x') + expect(result.code).toBe('unknown') + }) + + it('maps a plain Error with no `code` to unknown, carrying its message', () => { + const result = toFileError(new Error('plain failure'), '/x') + expect(result.code).toBe('unknown') + expect(result.message).toBe('plain failure') + }) + + it('coerces a thrown non-Error (string) into an unknown FileError', () => { + const result = toFileError('just a string', '/x') + expect(result.code).toBe('unknown') + expect(result.message).toBe('just a string') + }) +}) + +describe('fileInfoFrom', () => { + it('builds a file info with basename, size and mtimeMs', () => { + const result = fileInfoFrom('/workspace/t1/mine.txt', statOf('file', { size: 42, mtimeMs: 1234 })) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error('unreachable') + expect(result.value).toEqual({ name: 'mine.txt', path: '/workspace/t1/mine.txt', kind: 'file', size: 42, mtimeMs: 1234 }) + }) + + it.each([ + ['dir', 'directory'], + ['symlink', 'symlink'], + ] as const)('classifies %s stats as kind %s', (statKind, expectedKind) => { + const result = fileInfoFrom('/workspace/x', statOf(statKind)) + expect(result.ok && result.value.kind).toBe(expectedKind) + }) + + it('falls back to the path when basename is empty (root "/")', () => { + const result = fileInfoFrom('/', statOf('dir')) + expect(result.ok && result.value.name).toBe('/') + }) + + it('returns an invalid FileError for a kind Pi does not model (e.g. device/fifo)', () => { + const result = fileInfoFrom('/dev/null', statOf('other')) + expect(result.ok).toBe(false) + if (result.ok) throw new Error('unreachable') + expect(result.error.code).toBe('invalid') + expect(result.error.path).toBe('/dev/null') + }) +}) + +describe('abortedResult', () => { + it('returns an aborted FileError when the signal is already aborted', () => { + const controller = new AbortController() + controller.abort() + const result = abortedResult(controller.signal, '/p') + expect(result).toBeDefined() + expect(result && !result.ok && result.error.code).toBe('aborted') + expect(result && !result.ok && result.error.path).toBe('/p') + }) + + it('returns undefined for a live signal (lets the caller proceed)', () => { + expect(abortedResult(new AbortController().signal, '/p')).toBeUndefined() + }) + + it('returns undefined when no signal is provided', () => { + expect(abortedResult(undefined, '/p')).toBeUndefined() + }) +}) + +describe('splitLines', () => { + it('returns an empty array for empty input', () => { + expect(splitLines('')).toEqual([]) + }) + + it('drops exactly one trailing newline ("a\\nb\\n" -> [a, b])', () => { + expect(splitLines('a\nb\n')).toEqual(['a', 'b']) + }) + + it('keeps a line that lacks a trailing newline', () => { + expect(splitLines('a\nb')).toEqual(['a', 'b']) + }) + + it('handles CRLF the same as LF', () => { + expect(splitLines('a\r\nb\r\n')).toEqual(['a', 'b']) + }) + + it('preserves interior blank lines', () => { + expect(splitLines('a\n\nb\n')).toEqual(['a', '', 'b']) + }) + + it('drops only ONE trailing empty line, keeping a second one (two trailing newlines)', () => { + // "a\n\n" -> split = [a, "", ""] -> pop one -> [a, ""]. A double-newline at + // EOF must keep a representative blank line. + expect(splitLines('a\n\n')).toEqual(['a', '']) + }) + + it('a lone newline yields a single empty line', () => { + expect(splitLines('\n')).toEqual(['']) + }) +}) diff --git a/shared/agent-core/browser-env/fs-helpers.ts b/shared/agent-core/browser-env/fs-helpers.ts new file mode 100644 index 000000000..dd272249d --- /dev/null +++ b/shared/agent-core/browser-env/fs-helpers.ts @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Interop helpers that translate ZenFS results and errors into the shapes Pi's + * {@link FileSystem} contract expects. ZenFS throws Node-style `errno` errors + * (`ENOENT`, `EACCES`, …); these helpers map them onto Pi's backend-independent + * {@link FileError} codes so the {@link BrowserExecutionEnv} can stay strictly + * Result-based and never leak a thrown error. + */ + +import { err, FileError, ok, toError, type FileInfo, type FileKind, type Result } from '@earendil-works/pi-agent-core' +import { basename } from '@zenfs/core/path' + +/** + * Structural view of a ZenFS `Stats` object. Declared structurally (rather than + * importing ZenFS' concrete type) so the helpers stay decoupled from ZenFS' + * internal class while remaining fully typed. + */ +export type ZenStats = { + isFile(): boolean + isDirectory(): boolean + isSymbolicLink(): boolean + mode: number + size: number + mtime: Date + mtimeMs: number +} + +const isErrnoError = (error: unknown): error is { code: string; message: string } => + error instanceof Error && 'code' in error && typeof (error as { code: unknown }).code === 'string' + +/** + * Normalize an unknown thrown value (typically a ZenFS `errno` error) into a + * Pi {@link FileError} with a stable, backend-independent code. + * + * @param error - the thrown value to translate + * @param path - the addressed path associated with the failure, when known + * @returns a {@link FileError} carrying the mapped code and the original cause + */ +export const toFileError = (error: unknown, path?: string): FileError => { + if (error instanceof FileError) return error + const cause = toError(error) + if (isErrnoError(error)) { + switch (error.code) { + case 'ABORT_ERR': + return new FileError('aborted', error.message, path, cause) + case 'ENOENT': + return new FileError('not_found', error.message, path, cause) + case 'EACCES': + case 'EPERM': + return new FileError('permission_denied', error.message, path, cause) + case 'ENOTDIR': + return new FileError('not_directory', error.message, path, cause) + case 'EISDIR': + return new FileError('is_directory', error.message, path, cause) + case 'EINVAL': + return new FileError('invalid', error.message, path, cause) + } + } + return new FileError('unknown', cause.message, path, cause) +} + +const fileKindFrom = (stat: ZenStats): FileKind | undefined => { + if (stat.isFile()) return 'file' + if (stat.isDirectory()) return 'directory' + if (stat.isSymbolicLink()) return 'symlink' + return undefined +} + +/** + * Build a Pi {@link FileInfo} from an addressed path and its ZenFS stats, + * without following symlinks. Returns an `invalid` {@link FileError} for + * filesystem object kinds Pi does not model. + * + * @param path - the absolute addressed path the stats describe + * @param stat - the ZenFS stats for that path + */ +export const fileInfoFrom = (path: string, stat: ZenStats): Result => { + const kind = fileKindFrom(stat) + if (!kind) return err(new FileError('invalid', 'Unsupported file type', path)) + return ok({ name: basename(path) || path, path, kind, size: stat.size, mtimeMs: stat.mtimeMs }) +} + +/** + * Short-circuit Result for an already-aborted operation, or `undefined` when the + * signal is still live. Lets callers early-return before touching ZenFS. + * + * @param signal - the caller's abort signal, if any + * @param path - the addressed path, threaded into the {@link FileError} + */ +export const abortedResult = (signal: AbortSignal | undefined, path: string): Result | undefined => + signal?.aborted ? err(new FileError('aborted', 'aborted', path)) : undefined + +/** + * Split UTF-8 text into lines the way Node's `readline` would: tolerate both + * CRLF and LF, and drop the single trailing empty element a final newline + * produces (so `"a\nb\n"` yields `["a", "b"]`, matching the Node env). + * + * @param text - the file contents to split + */ +export const splitLines = (text: string): string[] => { + if (text === '') return [] + const lines = text.split(/\r?\n/) + if (lines[lines.length - 1] === '') lines.pop() + return lines +} diff --git a/shared/agent-core/browser-env/index.ts b/shared/agent-core/browser-env/index.ts new file mode 100644 index 000000000..3b3b15b66 --- /dev/null +++ b/shared/agent-core/browser-env/index.ts @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Browser execution environment for the Pi harness: a single ZenFS mount shared + * between Pi's filesystem API and just-bash's shell, presenting OPFS as a CLI, + * plus the per-tool operation adapters that bind Pi's four coding tools to it. + */ + +export { BrowserExecutionEnv } from './browser-execution-env.ts' +export { ZenBashFileSystem } from './zen-bash-fs.ts' +export { mountAgentFs, mountInMemoryFs, type MountedBackend } from './mount.ts' +export { + createBashOperations, + createEditOperations, + createReadOperations, + createWriteOperations, +} from './coding-tool-operations.ts' diff --git a/shared/agent-core/browser-env/mount.test.ts b/shared/agent-core/browser-env/mount.test.ts new file mode 100644 index 000000000..5d7a6b3d9 --- /dev/null +++ b/shared/agent-core/browser-env/mount.test.ts @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the ZenFS mount glue. The only non-trivial behaviors are + * `mountAgentFs`'s documented invariants: it must NEVER reject (an unusable OPFS + * has to degrade to in-memory, not get the memoised promise stuck on a + * rejection), and it must be memoised so repeated harness builds share one + * global mount instead of reconfiguring the live singleton. + * + * We drive the OPFS-present-but-broken branch by installing a `navigator.storage` + * whose `getDirectory` rejects (the realistic failure shape), assert the code + * actually attempted OPFS, then assert the catch fell through to memory. + * + * Note: `agentFsMount` is module-scoped and memoised for the process lifetime, so + * this file deliberately exercises `mountAgentFs` exactly once — a second + * scenario would observe the cached promise, not a fresh evaluation. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fsp from '@zenfs/core/promises' +import { mountAgentFs, mountInMemoryFs, resetAgentFsMountForTests } from './mount.ts' + +const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + +// `agentFsMount` is a process-global memo. Under `bun test --rerun-each` (the CI +// 5x stability gate) the module is reused across runs, so without this reset the +// first run would settle the memo and every subsequent run would observe the +// cached promise instead of a fresh OPFS attempt. +beforeEach(() => { + resetAgentFsMountForTests() +}) + +afterEach(() => { + if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator) +}) + +describe('mountInMemoryFs', () => { + it('mounts a usable in-memory filesystem at /', async () => { + await mountInMemoryFs() + await fsp.mkdir('/mounttest', { recursive: true }) + await fsp.writeFile('/mounttest/probe.txt', 'ok') + expect((await fsp.readFile('/mounttest/probe.txt')).toString()).toBe('ok') + await fsp.rm('/mounttest', { recursive: true, force: true }) + }) +}) + +describe('mountAgentFs', () => { + it('attempts OPFS, degrades to "memory" without rejecting when it fails, and memoises (in-flight + settled)', async () => { + let getDirectoryCalls = 0 + Object.defineProperty(globalThis, 'navigator', { + value: { + storage: { + // Realistic failure shape: a rejected promise (private browsing / quota). + getDirectory: () => { + getDirectoryCalls += 1 + return Promise.reject(new Error('OPFS unavailable')) + }, + }, + }, + configurable: true, + }) + + const first = mountAgentFs() + const second = mountAgentFs() + // In-flight memoisation: same promise, so getDirectory is attempted at most once. + expect(first).toBe(second) + + expect(await first).toBe('memory') + // The OPFS path was genuinely entered (isOpfsAvailable saw the function), then + // the rejection was caught — proving fallback, not an unconditional skip. + expect(getDirectoryCalls).toBe(1) + + // Settled memoisation: a post-resolution call reuses the cached promise too. + const third = mountAgentFs() + expect(third).toBe(first) + expect(await third).toBe('memory') + expect(getDirectoryCalls).toBe(1) + + // The fallback mount is usable. + await fsp.writeFile('/agent-probe.txt', 'fallback') + expect((await fsp.readFile('/agent-probe.txt')).toString()).toBe('fallback') + await fsp.rm('/agent-probe.txt', { force: true }) + }) +}) diff --git a/shared/agent-core/browser-env/mount.ts b/shared/agent-core/browser-env/mount.ts new file mode 100644 index 000000000..95cad796e --- /dev/null +++ b/shared/agent-core/browser-env/mount.ts @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Configures the process-global ZenFS singleton that backs + * {@link BrowserExecutionEnv}. ZenFS is a singleton (like `node:fs`), so it must + * be mounted exactly once before constructing an env. + * + * Two mounts are provided: + * - {@link mountInMemoryFs} — an ephemeral in-memory mount for Node/tests and + * non-OPFS contexts. + * - {@link mountAgentFs} — the app entry point: prefers an OPFS-backed + * `@zenfs/dom` `WebAccess` mount (persists across reloads) and falls back to + * in-memory when OPFS is unavailable. It is memoised so repeated harness + * builds share the single global mount instead of reconfiguring it. + */ + +import { configureSingle, InMemory } from '@zenfs/core' +import { WebAccess } from '@zenfs/dom' + +/** Backend that ended up mounted, for the caller's diagnostics. */ +export type MountedBackend = 'opfs' | 'memory' + +/** + * Mount an in-memory ZenFS at `/`. Used by tests, by Node spikes, and as the + * fallback when the browser exposes no OPFS. Both Pi's filesystem API and + * just-bash share whatever backend is mounted here. + */ +export const mountInMemoryFs = async (): Promise => { + await configureSingle({ backend: InMemory }) +} + +/** Whether the current context exposes the Origin Private File System. */ +const isOpfsAvailable = (): boolean => + typeof navigator !== 'undefined' && typeof navigator.storage?.getDirectory === 'function' + +/** Performs the actual mount: OPFS when available and usable, in-memory otherwise. + * Always resolves (never rejects) so the memoised mount can't get stuck on a + * rejected promise — an unusable OPFS degrades to the in-memory backend. */ +const doMountAgentFs = async (): Promise => { + if (isOpfsAvailable()) { + try { + // The one-line OPFS swap: wire @zenfs/dom's WebAccess backend over the + // origin's private directory handle. {@link BrowserExecutionEnv} and + // {@link ZenBashFileSystem} both target whatever is mounted here. + const handle = await navigator.storage.getDirectory() + await configureSingle({ backend: WebAccess, handle }) + return 'opfs' + } catch { + // OPFS is exposed but unusable here (quota, private browsing, permission). + // Fall through to the same in-memory mount used when OPFS is absent. + } + } + await mountInMemoryFs() + return 'memory' +} + +/** + * Module-scoped mount promise. ZenFS is a process-global singleton, so the mount + * is a process-global one-time effect; memoising the promise makes repeated + * `mountAgentFs()` calls (one per harness build) idempotent rather than + * reconfiguring the live singleton underneath an in-flight env. + */ +let agentFsMount: Promise | undefined + +/** + * Mount the ZenFS singleton the app harness runs over, exactly once per process. + * Prefers OPFS persistence and falls back to in-memory. Safe to call repeatedly — + * subsequent calls return the same in-flight/settled mount. + * + * @returns the backend that was actually mounted (`opfs` or `memory`) + */ +export const mountAgentFs = (): Promise => { + agentFsMount ??= doMountAgentFs() + return agentFsMount +} + +/** + * Test-only: clear the memoised mount so the next `mountAgentFs()` re-evaluates + * from scratch. Production never resets — the mount is a one-time per-process + * effect — but the memo is process-global, so a test runner reusing the module + * across reruns (e.g. `bun test --rerun-each`) would otherwise observe a stale + * settled promise instead of a fresh OPFS attempt. Not re-exported from the + * package entry point; tests import it directly from this module. + */ +export const resetAgentFsMountForTests = (): void => { + agentFsMount = undefined +} diff --git a/shared/agent-core/browser-env/workspace-jail.test.ts b/shared/agent-core/browser-env/workspace-jail.test.ts new file mode 100644 index 000000000..f3842bed1 --- /dev/null +++ b/shared/agent-core/browser-env/workspace-jail.test.ts @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `workspace-jail` tests — the single guard every browser coding tool uses to keep + * a thread's file/shell access inside its own `/workspace/` subtree. + */ + +import { describe, expect, it } from 'bun:test' +import { isWithinWorkspace, resolveInWorkspace } from './workspace-jail.ts' + +const WS = '/workspace/thread-1' + +describe('resolveInWorkspace', () => { + it('resolves a relative path inside the workspace', () => { + expect(resolveInWorkspace(WS, 'notes/todo.md')).toBe(`${WS}/notes/todo.md`) + }) + + it('allows the workspace root itself', () => { + expect(resolveInWorkspace(WS, '.')).toBe(WS) + }) + + it('throws on an absolute path outside the workspace', () => { + expect(() => resolveInWorkspace(WS, '/etc/passwd')).toThrow('path escapes workspace') + }) + + it('throws on a `..` traversal into a sibling thread', () => { + expect(() => resolveInWorkspace(WS, '../thread-2/secret')).toThrow('path escapes workspace') + }) + + it('throws on a `..` chain that climbs above the workspace root', () => { + expect(() => resolveInWorkspace(WS, 'a/../../thread-2/x')).toThrow('path escapes workspace') + }) + + it('does not treat a sibling whose name shares a prefix as inside', () => { + expect(() => resolveInWorkspace(WS, '/workspace/thread-12/x')).toThrow('path escapes workspace') + }) +}) + +describe('isWithinWorkspace', () => { + it('accepts the root and its descendants', () => { + expect(isWithinWorkspace(WS, WS)).toBe(true) + expect(isWithinWorkspace(WS, `${WS}/a/b`)).toBe(true) + }) + + it('rejects siblings and prefix-aliased siblings', () => { + expect(isWithinWorkspace(WS, '/workspace/thread-2')).toBe(false) + expect(isWithinWorkspace(WS, '/workspace/thread-1-evil')).toBe(false) + }) +}) diff --git a/shared/agent-core/browser-env/workspace-jail.ts b/shared/agent-core/browser-env/workspace-jail.ts new file mode 100644 index 000000000..575fb0206 --- /dev/null +++ b/shared/agent-core/browser-env/workspace-jail.ts @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The single workspace jail shared by every browser coding tool. Each thread's + * tools are rooted at its isolated workspace + * ({@link import('../build-app-harness.ts').workspaceDirFor}); this asserts that a + * resolved path stays inside that subtree, so a model-supplied absolute path + * (`/etc/passwd`) or `..` traversal (`..//secret`) can't reach a + * sibling thread's files on the shared process-global ZenFS mount. + * + * Throwing on an escape is the loud, architectural failure the project's rules + * favour over silently clamping or ignoring the path. + */ + +import { resolve } from '@zenfs/core/path' + +/** True when `resolvedPath` is the workspace root itself or inside its subtree. */ +export const isWithinWorkspace = (workspaceDir: string, resolvedPath: string): boolean => + resolvedPath === workspaceDir || resolvedPath.startsWith(`${workspaceDir}/`) + +/** + * Resolve `path` against `workspaceDir` and assert it stays inside the workspace + * subtree, throwing `path escapes workspace` otherwise. + * + * @param workspaceDir - the thread's absolute workspace root (the jail boundary) + * @param path - a model-supplied relative or absolute path + * @returns the resolved absolute path, guaranteed within `workspaceDir` + */ +export const resolveInWorkspace = (workspaceDir: string, path: string): string => { + const resolved = resolve(workspaceDir, path) + if (!isWithinWorkspace(workspaceDir, resolved)) { + throw new Error(`path escapes workspace: ${path}`) + } + return resolved +} diff --git a/shared/agent-core/browser-env/zen-bash-fs.test.ts b/shared/agent-core/browser-env/zen-bash-fs.test.ts new file mode 100644 index 000000000..46e803e59 --- /dev/null +++ b/shared/agent-core/browser-env/zen-bash-fs.test.ts @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `ZenBashFileSystem` jail tests — the shell's only I/O channel. They lock the + * security boundary: every path-accepting method must reject access outside the + * thread's workspace, so a future method added without the jail is caught here. + */ + +import { beforeAll, describe, expect, it } from 'bun:test' +import * as fsp from '@zenfs/core/promises' +import { Bash } from 'just-bash' +import { mountInMemoryFs } from './mount.ts' +import { ZenBashFileSystem } from './zen-bash-fs.ts' + +const JAIL = '/workspace/t1' + +beforeAll(async () => { + await mountInMemoryFs() + await fsp.mkdir('/workspace/t1', { recursive: true }) + await fsp.mkdir('/workspace/t2', { recursive: true }) + await fsp.writeFile('/workspace/t1/mine.txt', 'mine') + await fsp.writeFile('/workspace/t2/secret.txt', 'secret') + await fsp.mkdir('/etc', { recursive: true }) + await fsp.writeFile('/etc/passwd', 'root') +}) + +const fs = new ZenBashFileSystem(JAIL) + +describe('ZenBashFileSystem jail', () => { + it('reads files inside the workspace (absolute and relative)', async () => { + expect(await fs.readFile('/workspace/t1/mine.txt')).toBe('mine') + expect(await fs.readFile('mine.txt')).toBe('mine') + }) + + it('blocks reading a sibling thread workspace', async () => { + await expect(fs.readFile('/workspace/t2/secret.txt')).rejects.toThrow('path escapes workspace') + }) + + it('blocks reading an absolute system path', async () => { + await expect(fs.readFile('/etc/passwd')).rejects.toThrow('path escapes workspace') + }) + + it('blocks `..` traversal into a sibling', async () => { + await expect(fs.readFile('../t2/secret.txt')).rejects.toThrow('path escapes workspace') + }) + + it('blocks enumerating the parent workspace root', async () => { + await expect(fs.readdir('/workspace')).rejects.toThrow('path escapes workspace') + await expect(fs.readdirWithFileTypes('/workspace')).rejects.toThrow('path escapes workspace') + }) + + it('blocks stat outside the workspace', async () => { + await expect(fs.stat('/workspace/t2/secret.txt')).rejects.toThrow('path escapes workspace') + }) + + it('blocks writing outside the workspace', async () => { + await expect(fs.writeFile('/workspace/t2/pwned.txt', 'x')).rejects.toThrow('path escapes workspace') + }) + + it('blocks removing outside the workspace', async () => { + await expect(fs.rm('/workspace/t2/secret.txt')).rejects.toThrow('path escapes workspace') + }) + + it('blocks copy/move that escape the workspace', async () => { + await expect(fs.cp('mine.txt', '/workspace/t2/copy.txt')).rejects.toThrow('path escapes workspace') + await expect(fs.mv('/workspace/t2/secret.txt', 'stolen.txt')).rejects.toThrow('path escapes workspace') + }) + + it('disallows symlink creation outright (no `ln -s`)', async () => { + // Symlinks are refused even when the target lexically resolves inside the + // jail: validating only at creation is bypassable by relocating the link + // later (see the relocation-escape regression below). + await expect(fs.symlink('../t2/secret.txt', '/workspace/t1/link')).rejects.toThrow( + 'symlinks are not allowed in the workspace jail', + ) + await expect(fs.symlink('inside.txt', '/workspace/t1/link')).rejects.toThrow( + 'symlinks are not allowed in the workspace jail', + ) + }) + + it('blocks the symlink-relocation jail escape: `ln -s ../sibling deep/x && mv deep/x x && cat x/secret`', async () => { + // The classic lexical-validation bypass: `../sibling` resolves INSIDE the + // jail from `deep/x` (-> /workspace/t1/sibling), but once the link is moved + // up to `x` the same stored relative target escapes to /workspace/sibling. + // Disallowing symlink creation kills the chain at `ln -s`. + await fsp.mkdir('/workspace/sibling', { recursive: true }) + await fsp.writeFile('/workspace/sibling/secret', 'TOPSECRET') + await fsp.mkdir('/workspace/t1/deep', { recursive: true }) + + const bash = new Bash({ fs, cwd: JAIL, defenseInDepth: false }) + const result = await bash.exec('ln -s ../sibling deep/x && mv deep/x x && cat x/secret', {}) + + expect(result.stdout).not.toContain('TOPSECRET') + expect(result.exitCode).not.toBe(0) + }) + + it('exists() returns false for out-of-jail paths instead of throwing', async () => { + expect(await fs.exists('/etc/passwd')).toBe(false) + expect(await fs.exists('/workspace/t1/mine.txt')).toBe(true) + }) + + it('keeps resolvePath pure (un-jailed) so ancestor walks still compute', () => { + expect(fs.resolvePath('/workspace/t1', '..')).toBe('/workspace') + }) +}) diff --git a/shared/agent-core/browser-env/zen-bash-fs.ts b/shared/agent-core/browser-env/zen-bash-fs.ts new file mode 100644 index 000000000..87e4a9bce --- /dev/null +++ b/shared/agent-core/browser-env/zen-bash-fs.ts @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `ZenBashFileSystem` is the bridge that lets just-bash run its 80+ coreutils + * over the very same ZenFS mount that Pi's {@link BrowserExecutionEnv} reads and + * writes. It implements just-bash's `IFileSystem` by forwarding every call to + * the process-global ZenFS singleton (`@zenfs/core/promises`). + * + * This is half of the "OPFS-as-CLI" illusion: because both this adapter and the + * execution env target one ZenFS instance, a file Pi writes is instantly + * visible to `cat`, and a file a shell redirect creates is instantly readable + * through Pi's filesystem API — no copying, no sync step. + * + * just-bash expects filesystem methods to THROW on failure (it catches and maps + * them to shell exit codes internally), so this adapter deliberately forwards + * ZenFS' Node-style errors unchanged rather than wrapping them. + * + * Isolation: every path-accepting method is jailed to the thread's workspace + * ({@link jailRoot}) via {@link resolveInWorkspace}, so a shell command using an + * absolute path (`cat /etc/passwd`) or a `..`/sibling path (`cat + * /workspace//secret`, `ls /workspace`) throws and just-bash maps it + * to a non-zero exit — a thread's shell can only touch its own files. + * `resolvePath` stays a pure resolve (NOT jailed) so just-bash's internal + * ancestor walks — e.g. resolving `..` up to `/` while discovering `.gitignore` — + * still compute correctly; the subsequent file access is what enforces the jail. + */ + +import * as fsp from '@zenfs/core/promises' +import { resolve } from '@zenfs/core/path' +import type { BufferEncoding, CpOptions, FileContent, FsStat, IFileSystem, MkdirOptions, RmOptions } from 'just-bash' +import type { ZenStats } from './fs-helpers.ts' +import { isWithinWorkspace, resolveInWorkspace } from './workspace-jail.ts' + +// just-bash declares these structurally in its `IFileSystem` module but does not +// re-export them from the package root; mirror them locally. Structural identity +// keeps `implements IFileSystem` satisfied. +type ReadFileOptions = { encoding?: BufferEncoding | null } +type WriteFileOptions = { encoding?: BufferEncoding } +type DirentEntry = { name: string; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean } + +const encodingOf = ( + options: ReadFileOptions | WriteFileOptions | BufferEncoding | undefined, +): BufferEncoding | undefined => { + const encoding = typeof options === 'string' ? options : options?.encoding + return encoding ?? undefined +} + +const toFsStat = (stat: ZenStats): FsStat => ({ + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + isSymbolicLink: stat.isSymbolicLink(), + mode: stat.mode, + size: stat.size, + mtime: stat.mtime, +}) + +export class ZenBashFileSystem implements IFileSystem { + /** + * @param jailRoot - the thread's absolute workspace dir; every path this + * adapter touches is asserted to stay inside it (see file header). + */ + constructor(private readonly jailRoot: string) {} + + /** Resolve `path` against the jail root and assert it stays in the workspace, + * throwing on escape so just-bash maps it to a non-zero exit. */ + private jailed(path: string): string { + return resolveInWorkspace(this.jailRoot, path) + } + + async readFile(path: string, options?: ReadFileOptions | BufferEncoding): Promise { + return await fsp.readFile(this.jailed(path), { encoding: encodingOf(options) ?? 'utf8' }) + } + + async readFileBuffer(path: string): Promise { + return new Uint8Array(await fsp.readFile(this.jailed(path))) + } + + async writeFile(path: string, content: FileContent, options?: WriteFileOptions | BufferEncoding): Promise { + const encoding = encodingOf(options) + await fsp.writeFile(this.jailed(path), content, encoding ? { encoding } : undefined) + } + + async appendFile(path: string, content: FileContent, options?: WriteFileOptions | BufferEncoding): Promise { + const encoding = encodingOf(options) + await fsp.appendFile(this.jailed(path), content, encoding ? { encoding } : undefined) + } + + async exists(path: string): Promise { + const resolved = resolve(this.jailRoot, path) + // A path outside the jail simply "doesn't exist" for this thread, so `[ -e ]` + // tests resolve to false rather than erroring on the jail throw. + return isWithinWorkspace(this.jailRoot, resolved) ? await fsp.exists(resolved) : false + } + + async stat(path: string): Promise { + return toFsStat(await fsp.stat(this.jailed(path))) + } + + async lstat(path: string): Promise { + return toFsStat(await fsp.lstat(this.jailed(path))) + } + + async mkdir(path: string, options?: MkdirOptions): Promise { + await fsp.mkdir(this.jailed(path), { recursive: options?.recursive ?? false }) + } + + async readdir(path: string): Promise { + return await fsp.readdir(this.jailed(path)) + } + + async readdirWithFileTypes(path: string): Promise { + const entries = await fsp.readdir(this.jailed(path), { withFileTypes: true }) + return entries.map((entry) => ({ + name: entry.name, + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + isSymbolicLink: entry.isSymbolicLink(), + })) + } + + async rm(path: string, options?: RmOptions): Promise { + await fsp.rm(this.jailed(path), { recursive: options?.recursive ?? false, force: options?.force ?? false }) + } + + async cp(src: string, dest: string, options?: CpOptions): Promise { + await fsp.cp(this.jailed(src), this.jailed(dest), { recursive: options?.recursive ?? false }) + } + + async mv(src: string, dest: string): Promise { + await fsp.rename(this.jailed(src), this.jailed(dest)) + } + + resolvePath(base: string, path: string): string { + return resolve(base, path) + } + + getAllPaths(): string[] { + return [] + } + + async chmod(path: string, mode: number): Promise { + await fsp.chmod(this.jailed(path), mode) + } + + async symlink(_target: string, _linkPath: string): Promise { + // Symlinks are disallowed outright. Validating a link's target only at + // creation is a lexical check: it resolves the *stored relative* target + // against the link's current directory, so a later `mv`/`cp` that relocates + // the link to a shallower dir leaves that same relative target pointing + // outside the jail — a cross-thread read/write on the shared OPFS mount. + // Re-validating on every relocation is brittle; the coding agent never needs + // `ln -s`, so refuse it and remove the escape class entirely. just-bash maps + // this throw to a non-zero exit for `ln -s`. + throw new Error('symlinks are not allowed in the workspace jail') + } + + async link(existingPath: string, newPath: string): Promise { + await fsp.link(this.jailed(existingPath), this.jailed(newPath)) + } + + async readlink(path: string): Promise { + return await fsp.readlink(this.jailed(path)) + } + + async realpath(path: string): Promise { + return await fsp.realpath(this.jailed(path)) + } + + async utimes(path: string, atime: Date, mtime: Date): Promise { + await fsp.utimes(this.jailed(path), atime, mtime) + } +} diff --git a/shared/agent-core/browser-stubs/install-process.ts b/shared/agent-core/browser-stubs/install-process.ts new file mode 100644 index 000000000..1d8367e45 --- /dev/null +++ b/shared/agent-core/browser-stubs/install-process.ts @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Installs the Node globals (`process`, `global`) the lazily-loaded Pi engine + * expects but the browser lacks. + * + * Why this exists: Pi's runtime (`@earendil-works/pi-ai`) and the Anthropic SDK + * still read the bare `process` global to detect their environment — + * `process.versions?.node`/`process.env.*` in provider/auth helpers — and some + * dependency code references the bare `global` object, so loading them throws + * `ReferenceError: process is not defined` / `global is not defined` in the + * browser. Because these are bare globals (not `import … from 'process'`), a + * module alias can't reach them; the globals must exist when those modules + * evaluate. (The pi-coding-agent CLI modules that previously needed this — its + * `config.js`/`timings.js`/clipboard helper — are no longer on the app path.) + * + * Why a side-effecting import rather than a runtime shim: ES module imports are + * hoisted and evaluated in source order, so a `globalThis.process = …` assignment + * placed inside a consumer runs *after* the hoisted Pi import has already thrown. + * This module performs the assignment at its own module scope and is imported + * first by `shared/agent-core/index.ts` (the lazy chunk's entry), so the global is + * in place before any Pi module evaluates — and, living only in that chunk, it + * never touches the entry bundle. + * + * The harness never executes OS processes (bash runs through BrowserExecutionEnv), + * so this shim only has to satisfy the module-scope reads above and stay inert. + */ + +/** Minimal browser stand-in for Node's `process` — just the surface Pi reads. */ +const browserProcess = { + /** Pi treats truthy `browser` as "take the browser code path" where it checks. */ + browser: true, + env: {} as Record, + platform: 'browser', + arch: 'wasm', + version: '', + versions: {} as Record, + argv: [] as string[], + execPath: '/pi', + cwd: () => '/', + chdir: () => {}, + nextTick: (callback: (...args: unknown[]) => void, ...args: unknown[]) => { + queueMicrotask(() => callback(...args)) + }, + // Some libraries register lifecycle listeners (`process.on('exit', …)`); accept + // and ignore them so the chainable API stays intact. + on: () => browserProcess, + once: () => browserProcess, + off: () => browserProcess, + removeListener: () => browserProcess, + emit: () => false, +} + +const scope = globalThis as unknown as { process?: unknown; global?: unknown } +scope.process ??= browserProcess +// Some Pi/dependency code references the bare Node `global` object; in the browser +// it is the same as `globalThis`. +scope.global ??= globalThis diff --git a/shared/agent-core/browser-stubs/node-crypto.ts b/shared/agent-core/browser-stubs/node-crypto.ts new file mode 100644 index 000000000..78a9f80b6 --- /dev/null +++ b/shared/agent-core/browser-stubs/node-crypto.ts @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Browser shim for Node's `crypto` / `node:crypto`, aliased in `vite.config.ts` + * for the in-browser Pi harness path. + * + * Why this exists: Pi's runtime engine generates ids at runtime — + * `crypto.randomUUID` (session/message ids) and `crypto.randomBytes` (tool-call + * ids) — which Vite's default `browser-external:crypto` leaves undefined. These + * are backed by the browser's native Web Crypto, so the shim is a thin, correct + * delegation rather than a stub. `createHash` is not on the harness path; it + * throws so an unexpected caller surfaces loudly instead of silently misbehaving. + */ + +import { Buffer } from 'buffer' + +/** Native RFC-4122 v4 UUID via Web Crypto. */ +export const randomUUID = (): string => globalThis.crypto.randomUUID() + +/** Node-compatible `randomBytes`: a `Buffer` of cryptographically-random bytes. */ +export const randomBytes = (size: number): Buffer => { + const bytes = new Uint8Array(size) + globalThis.crypto.getRandomValues(bytes) + return Buffer.from(bytes) +} + +/** Fill an existing typed array with random bytes (Web Crypto, returns the array). */ +export const randomFillSync = (buffer: T): T => { + globalThis.crypto.getRandomValues(buffer as unknown as Uint8Array) + return buffer +} + +/** The Web Crypto implementation, mirroring Node's `crypto.webcrypto`. */ +export const webcrypto = globalThis.crypto + +/** Not available in the browser — surfaces loudly if the harness ever needs it. */ +export const createHash = (): never => { + throw new Error('crypto.createHash is unavailable in the browser Pi harness') +} + +export default { randomUUID, randomBytes, randomFillSync, webcrypto, createHash } diff --git a/shared/agent-core/browser-stubs/node-fs-promises.ts b/shared/agent-core/browser-stubs/node-fs-promises.ts new file mode 100644 index 000000000..89aa0e287 --- /dev/null +++ b/shared/agent-core/browser-stubs/node-fs-promises.ts @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Browser shim for Node's `fs/promises` / `node:fs/promises`, aliased in + * `vite.config.ts` for the in-browser Pi harness path. + * + * The harness performs its real file I/O through the ZenFS-backed operation + * adapters (see `coding-tool-operations.ts`), so Pi's own `fs/promises` calls are + * inert on this path. This presents an empty filesystem: reads reject with + * `ENOENT`, writes resolve as no-ops. It exists so importing Pi doesn't pull in + * Vite's `browser-external` placeholder (whose members throw on access). + */ + +/** Build a Node-style ENOENT error so Pi's error handling reads it as "missing". */ +const enoent = (path: string): Error & { code: string } => { + const error = new Error(`ENOENT: no such file or directory, '${path}'`) as Error & { code: string } + error.code = 'ENOENT' + return error +} + +export const access = (path: string): Promise => Promise.reject(enoent(path)) +export const readFile = (path: string): Promise => Promise.reject(enoent(path)) +export const stat = (path: string): Promise => Promise.reject(enoent(path)) +export const lstat = (path: string): Promise => Promise.reject(enoent(path)) +export const readlink = (path: string): Promise => Promise.reject(enoent(path)) +export const realpath = (path: string): Promise => Promise.resolve(path) +export const readdir = (): Promise => Promise.resolve([]) +export const writeFile = (): Promise => Promise.resolve() +export const appendFile = (): Promise => Promise.resolve() +export const mkdir = (): Promise => Promise.resolve(undefined) +export const mkdtemp = (prefix: string): Promise => Promise.resolve(`${prefix}browser`) +export const rm = (): Promise => Promise.resolve() +export const unlink = (): Promise => Promise.resolve() +export const open = (path: string): Promise => Promise.reject(enoent(path)) + +export default { + access, + readFile, + stat, + lstat, + readlink, + realpath, + readdir, + writeFile, + appendFile, + mkdir, + mkdtemp, + rm, + unlink, + open, +} diff --git a/shared/agent-core/browser-stubs/node-fs.cjs b/shared/agent-core/browser-stubs/node-fs.cjs new file mode 100644 index 000000000..e52b0d426 --- /dev/null +++ b/shared/agent-core/browser-stubs/node-fs.cjs @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Browser shim for Node's `fs` / `node:fs`, aliased in `vite.config.ts` for the + * in-browser Pi harness path. + * + * Why this exists: `@earendil-works/pi-ai`'s `utils/provider-env.js` does a + * `require("node:fs")` for a Bun-sandbox `/proc/self/environ` fallback. The call + * is guarded by `process.versions?.bun` (always falsy in the browser) so it never + * runs here, but rolldown still resolves the specifier statically — without this + * alias it falls back to `browser-external:fs`, a build warning whose members + * throw on access. This shim resolves it cleanly to an *empty* filesystem: + * `existsSync` returns false, reads throw `ENOENT`, writes are inert no-ops. (The + * harness's REAL file I/O goes through the ZenFS adapters, never through these.) + * + * Why CommonJS: the sole consumer reaches it via `require("node:fs")`, so a plain + * `module.exports` object is the natural, interop-free target. + */ + +/** Build a Node-style ENOENT error so Pi's error handling reads it as "missing". */ +const enoent = (path) => { + const error = new Error(`ENOENT: no such file or directory, '${path}'`) + error.code = 'ENOENT' + return error +} + +/** No-op file watcher: callers only ever `.close()` it. */ +const noopWatcher = { close: () => {}, on: () => noopWatcher, unref: () => noopWatcher, ref: () => noopWatcher } + +module.exports = { + constants: { F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1, O_RDONLY: 0, O_WRONLY: 1, O_RDWR: 2 }, + + existsSync: () => false, + readFileSync: (path) => { + throw enoent(path) + }, + readSync: () => { + throw new Error('fs.readSync is unavailable in the browser Pi harness') + }, + openSync: (path) => { + throw enoent(path) + }, + statSync: (path) => { + throw enoent(path) + }, + lstatSync: (path) => { + throw enoent(path) + }, + readlinkSync: (path) => { + throw enoent(path) + }, + /** Callback-style `fs.readdir`: report an empty directory. */ + readdir: (_path, optionsOrCb, maybeCb) => { + const callback = typeof optionsOrCb === 'function' ? optionsOrCb : maybeCb + if (typeof callback === 'function') { + callback(null, []) + } + }, + readdirSync: () => [], + accessSync: (path) => { + throw enoent(path) + }, + createReadStream: (path) => { + throw enoent(path) + }, + createWriteStream: () => { + throw new Error('fs.createWriteStream is unavailable in the browser Pi harness') + }, + /** Identity: with no real symlinks, the resolved path is the input path. */ + realpathSync: (path) => path, + + writeFileSync: () => {}, + appendFileSync: () => {}, + mkdirSync: () => {}, + rmSync: () => {}, + renameSync: () => {}, + copyFileSync: () => {}, + unlinkSync: () => {}, + chmodSync: () => {}, + closeSync: () => {}, + + watch: () => noopWatcher, + watchFile: () => {}, + unwatchFile: () => {}, + + /** Async surface, for code reaching `fs.promises` through the namespace. */ + promises: { + access: (path) => Promise.reject(enoent(path)), + readFile: (path) => Promise.reject(enoent(path)), + stat: (path) => Promise.reject(enoent(path)), + lstat: (path) => Promise.reject(enoent(path)), + readlink: (path) => Promise.reject(enoent(path)), + realpath: (path) => Promise.resolve(path), + readdir: () => Promise.resolve([]), + writeFile: () => Promise.resolve(), + appendFile: () => Promise.resolve(), + mkdir: () => Promise.resolve(undefined), + mkdtemp: (prefix) => Promise.resolve(`${prefix}browser`), + rm: () => Promise.resolve(), + unlink: () => Promise.resolve(), + }, +} diff --git a/shared/agent-core/browser-stubs/node-module.ts b/shared/agent-core/browser-stubs/node-module.ts new file mode 100644 index 000000000..f7e538ba3 --- /dev/null +++ b/shared/agent-core/browser-stubs/node-module.ts @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Browser shim for Node's `module` / `node:module`, aliased in `vite.config.ts` + * for the in-browser Pi harness path. + * + * Why this exists: `just-bash`'s browser bundle evaluates + * `createRequire(import.meta.url)` at MODULE SCOPE. Vite's `browser-external:module` + * leaves `createRequire` undefined, so that top-level call throws on import (the + * production build tree-shakes the unused `require`, but the dev server does not). + * + * The returned `require` resolves the handful of Node builtins still aliased on + * this path (`crypto`/`fs/promises`/`path`) to their browser shims, so a lazy + * `require('crypto')` behaves like the static `import`. Anything else throws — + * loudly, since a real native `require` cannot be satisfied in the browser. + */ + +// Imported via bare builtin specifiers: typed by @types/node for the type-checker, +// redirected to the browser shims by vite.config.ts's resolve.alias at build time. +import nodeCrypto from 'node:crypto' +import nodeFsPromises from 'node:fs/promises' +import nodePath from 'node:path' + +/** Node builtins that may be `require()`-d lazily, mapped to their browser shims. */ +const browserBuiltins: Record = { + crypto: nodeCrypto, + 'node:crypto': nodeCrypto, + 'fs/promises': nodeFsPromises, + 'node:fs/promises': nodeFsPromises, + path: nodePath, + 'node:path': nodePath, +} + +/** A CommonJS `require` over the browser shims; unknown ids fail loudly. */ +const browserRequire = (id: string): unknown => { + const builtin = browserBuiltins[id] + if (builtin) { + return builtin + } + throw new Error(`Cannot require '${id}' in the browser; native modules are unavailable in the Pi harness`) +} + +/** Browser `createRequire`: hand back the shimmed `require`. */ +export const createRequire = (): typeof browserRequire => browserRequire + +export default { createRequire } diff --git a/shared/agent-core/build-app-harness.test.ts b/shared/agent-core/build-app-harness.test.ts new file mode 100644 index 000000000..871eec72f --- /dev/null +++ b/shared/agent-core/build-app-harness.test.ts @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for {@link workspaceDirFor} — the per-thread workspace path that is + * ALSO the coding-tool jail boundary. A threadId carrying `/` or `..` would move + * that boundary and defeat per-thread isolation, so the function must reject any + * non-UUID-shaped id loudly. Only the pure, security-relevant path logic is tested + * here; the rest of `buildAppHarness` is ZenFS/OPFS wiring covered by integration. + */ + +import { describe, expect, it } from 'bun:test' +import { workspaceDirFor } from './build-app-harness.ts' + +describe('workspaceDirFor', () => { + it('roots a UUID-shaped thread under /workspace', () => { + expect(workspaceDirFor('3cc2bf39-afa2-44d1-a89b-a1ecec7bb067')).toBe( + '/workspace/3cc2bf39-afa2-44d1-a89b-a1ecec7bb067', + ) + }) + + it('allows the dot/underscore/hyphen characters that may appear in ids', () => { + expect(workspaceDirFor('a.b_c-D9')).toBe('/workspace/a.b_c-D9') + }) + + it.each([ + ['contains a slash', 'a/b'], + ['is a parent-traversal segment', '..'], + ['is the current-dir segment', '.'], + ['embeds a traversal path', '../../etc'], + ['contains a backslash-style separator attempt', 'a\\b'], + ['contains whitespace', 'a b'], + ['is empty', ''], + ])('throws when the threadId %s', (_why, threadId) => { + expect(() => workspaceDirFor(threadId)).toThrow(/unsafe threadId/) + }) +}) diff --git a/shared/agent-core/build-app-harness.ts b/shared/agent-core/build-app-harness.ts new file mode 100644 index 000000000..5b2645a28 --- /dev/null +++ b/shared/agent-core/build-app-harness.ts @@ -0,0 +1,170 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Assembles a Pi {@link AgentHarness} for the APP — the in-browser analogue of + * the CLI's `buildHarness`. Where the CLI binds a `NodeExecutionEnv` (real bash + + * `node:fs`) and lets Pi's anthropic provider read `ANTHROPIC_API_KEY`, the app + * harness runs entirely inside the browser: + * + * - the model is Pi's anthropic model wired through the caller's injected + * `fetch` (the app's CORS proxy) via {@link buildAnthropicModel}; + * - the execution environment is a {@link BrowserExecutionEnv} over an + * OPFS-backed ZenFS mount ({@link mountAgentFs}), with an in-memory fallback; + * - the four coding tools (bash/read/write/edit) are bound to that same mount + * via {@link createBrowserCodingTools} — plain Pi `AgentTool`s over the + * {@link BrowserExecutionEnv}, with no `@earendil-works/pi-coding-agent` (and + * hence no Node child-process/`undici`/TUI) cascade on the app path. + * + * Extra `tools` (e.g. the app's MCP tools converted via `./mcp-tools.ts`) are + * appended and made active alongside the coding tools. + */ + +import { AgentHarness, InMemorySessionRepo, type AgentTool, type ThinkingLevel } from '@earendil-works/pi-agent-core' +import { buildAnthropicModel, type AgentFetch } from './anthropic-model.ts' +import { buildOpenAiCompatModel } from './openai-compat-model.ts' +import { BrowserExecutionEnv } from './browser-env/browser-execution-env.ts' +import { mountAgentFs } from './browser-env/mount.ts' +import { createBrowserCodingTools } from './coding-tools/index.ts' +import { ensureBufferPolyfill } from './ensure-buffer.ts' +import { buildSeedMessages, type SeedTurn } from './seed-history.ts' + +/** Mount-relative root under which every thread carves its isolated workspace. */ +const WORKSPACE_ROOT = '/workspace' + +/** + * Absolute mount-relative workspace directory for a thread. Each thread gets its + * own subtree under {@link WORKSPACE_ROOT} so concurrent threads never see each + * other's files even though they share the one process-global ZenFS mount. + * + * @param threadId - the chat thread id (an app-generated id, e.g. a UUID) + * @returns the thread's absolute workspace directory, e.g. `/workspace/` + */ +export const workspaceDirFor = (threadId: string): string => { + // The workspace dir is also the jail boundary for every coding tool, so a + // threadId containing `/` or `..` would move the boundary and defeat the jail. + // App thread ids are UUID-shaped; reject anything else loudly rather than + // silently weakening isolation. + if (!/^[A-Za-z0-9._-]+$/.test(threadId) || threadId === '.' || threadId === '..') { + throw new Error(`unsafe threadId for workspace: ${threadId}`) + } + return `${WORKSPACE_ROOT}/${threadId}` +} + +/** + * The model the harness runs, tagged by Pi engine family. Both variants route + * their LLM HTTP through an injected `fetch` (the app's proxy / SSO fetch): + * + * - `anthropic` resolves a model from Pi's built-in catalog and wires the + * `@anthropic-ai/sdk` client through `fetch` ({@link buildAnthropicModel}). + * - `openai-compat` synthesizes an `openai-completions` model for the app's + * OpenAI-wire providers (`openai`/`custom`/`openrouter`/`thunderbolt`) and + * injects `fetch` around client construction ({@link buildOpenAiCompatModel}). + */ +export type PiModelDescriptor = + | { + readonly kind: 'anthropic' + /** Anthropic model id to resolve from Pi's catalog, e.g. `claude-opus-4-8`. */ + readonly modelId: string + /** Anthropic API key (HTTP still flows through `fetch`). */ + readonly apiKey: string + /** Fetch every request is routed through — the app's proxy fetch. */ + readonly fetch: AgentFetch + } + | { + readonly kind: 'openai-compat' + /** App provider name, also used as the Pi provider id. */ + readonly providerId: string + /** Upstream model id sent on the wire. */ + readonly modelId: string + /** OpenAI-compatible base URL. */ + readonly baseURL: string + /** Bearer key for the OpenAI SDK (placeholder when `fetch` supplies auth). */ + readonly apiKey: string + /** Provider-specific app fetch (proxy fetch, or thunderbolt SSO fetch). */ + readonly fetch: AgentFetch + /** Whether to request a reasoning effort (else Pi sends none). */ + readonly reasoning: boolean + /** Optional upstream context window. */ + readonly contextWindow?: number + } + +/** Inputs for {@link buildAppHarness}. */ +export type BuildAppHarnessOptions = { + /** The model to run, tagged by Pi engine family. */ + readonly model: PiModelDescriptor + /** System prompt sent with each model request. */ + readonly systemPrompt: string + /** Reasoning depth for the harness. */ + readonly thinkingLevel: ThinkingLevel + /** Chat thread this harness serves. Its tools are bound to the thread's + * isolated workspace ({@link workspaceDirFor}). */ + readonly threadId: string + /** Extra tools to register and activate alongside the four coding tools. */ + readonly tools?: AgentTool[] + /** Prior conversation turns to seed into the session before the first prompt, + * so the agent has multi-turn context. Omitted/empty starts a blank session. */ + readonly history?: readonly SeedTurn[] +} + +/** + * Build a ready-to-run app harness for a thread. Mounts the ZenFS singleton + * (once), carves the thread's isolated workspace under {@link WORKSPACE_ROOT}, + * binds the coding tools to it, resolves the proxied model (anthropic or + * openai-compatible), and returns the constructed harness. The workspace persists + * with the harness; tear it down by removing {@link workspaceDirFor}`(threadId)` + * when the thread is disposed. + * + * @param options - model descriptor, prompt, thinking level, thread id, extra tools, history + * @returns the constructed {@link AgentHarness} + */ +export const buildAppHarness = async (options: BuildAppHarnessOptions): Promise => { + ensureBufferPolyfill() + await mountAgentFs() + const workspaceDir = workspaceDirFor(options.threadId) + const env = new BrowserExecutionEnv({ cwd: workspaceDir }) + const created = await env.createDir(workspaceDir) + if (!created.ok) { + throw created.error + } + const session = await new InMemorySessionRepo().create({}) + const { models, model } = + options.model.kind === 'anthropic' + ? buildAnthropicModel({ + apiKey: options.model.apiKey, + fetch: options.model.fetch, + modelId: options.model.modelId, + }) + : buildOpenAiCompatModel({ + providerId: options.model.providerId, + modelId: options.model.modelId, + baseURL: options.model.baseURL, + apiKey: options.model.apiKey, + fetch: options.model.fetch, + reasoning: options.model.reasoning, + contextWindow: options.model.contextWindow, + }) + + const tools: AgentTool[] = [...createBrowserCodingTools(env, { cwd: workspaceDir }), ...(options.tools ?? [])] + + const harness = new AgentHarness({ + env, + session, + models, + model, + tools, + activeToolNames: tools.map((tool) => tool.name), + thinkingLevel: options.thinkingLevel, + systemPrompt: options.systemPrompt, + }) + + // Seed prior turns into the (idle) session so the first prompt runs with full + // conversational context. `appendMessage` writes straight to the session while + // idle; `prompt` then reads them back via `session.buildContext()`. + for (const message of buildSeedMessages(options.history)) { + await harness.appendMessage(message) + } + + return harness +} diff --git a/shared/agent-core/coding-tools/edit-apply.test.ts b/shared/agent-core/coding-tools/edit-apply.test.ts new file mode 100644 index 000000000..c52357aa7 --- /dev/null +++ b/shared/agent-core/coding-tools/edit-apply.test.ts @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `edit-apply` tests — focused on the empty/whitespace-only oldText guard. A + * whitespace-only oldText is non-empty after LF normalization but collapses to '' + * under fuzzy normalization, so it must be rejected up front rather than fuzzy- + * matching at index 0. + */ + +import { describe, expect, it } from 'bun:test' +import { applyEditsToNormalizedContent } from './edit-apply.ts' + +describe('applyEditsToNormalizedContent', () => { + it('applies a straightforward exact replacement', () => { + const { newContent } = applyEditsToNormalizedContent('hello world', [{ oldText: 'world', newText: 'pi' }], 'f.txt') + expect(newContent).toBe('hello pi') + }) + + it('rejects an empty oldText', () => { + expect(() => applyEditsToNormalizedContent('abc', [{ oldText: '', newText: 'x' }], 'f.txt')).toThrow( + 'oldText must not be empty', + ) + }) + + it('rejects a whitespace-only oldText (collapses to empty under fuzzy match)', () => { + expect(() => applyEditsToNormalizedContent('a b', [{ oldText: ' ', newText: 'x' }], 'f.txt')).toThrow( + 'oldText must not be empty', + ) + }) + + it('rejects a tab-only oldText', () => { + expect(() => applyEditsToNormalizedContent('a\tb', [{ oldText: '\t', newText: 'x' }], 'f.txt')).toThrow( + 'oldText must not be empty', + ) + }) + + it('still accepts a newline-only oldText (fuzzy keeps the newline)', () => { + const { newContent } = applyEditsToNormalizedContent('a\n\nb', [{ oldText: '\n\n', newText: '\n' }], 'f.txt') + expect(newContent).toBe('a\nb') + }) +}) diff --git a/shared/agent-core/coding-tools/edit-apply.ts b/shared/agent-core/coding-tools/edit-apply.ts new file mode 100644 index 000000000..7ad8d7e31 --- /dev/null +++ b/shared/agent-core/coding-tools/edit-apply.ts @@ -0,0 +1,294 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Exact-text replacement engine for the browser `edit` tool, ported from Pi's + * `core/tools/edit-diff.ts` (the matching/applying half only — the `diff`-based + * preview rendering and the `node:fs` preview helper are dropped because the app + * never renders the edit tool's diff details). + * + * The matcher tries an exact match first, then a fuzzy match (NFKC + trailing- + * whitespace + smart-quote/dash/space normalization) so the model's `oldText` + * still lands when it differs only in invisible/cosmetic characters. All edits in + * a call are matched against the SAME original content and applied in reverse + * offset order so earlier edits cannot shift later offsets. Errors mirror Pi's + * wording verbatim — the model is tuned to recover from these exact messages. + * + * Every function here is pure string manipulation: no Node builtins, no `Buffer`. + */ + +/** A single exact-text replacement requested by the model. */ +export type EditReplacement = { oldText: string; newText: string } + +/** Normalize all line endings to LF. */ +export const normalizeToLF = (text: string): string => text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + +/** Detect whether the original content predominantly used CRLF or LF endings. */ +export const detectLineEnding = (content: string): '\r\n' | '\n' => { + const crlfIdx = content.indexOf('\r\n') + const lfIdx = content.indexOf('\n') + if (lfIdx === -1) { + return '\n' + } + if (crlfIdx === -1) { + return '\n' + } + return crlfIdx < lfIdx ? '\r\n' : '\n' +} + +/** Restore the original line ending after editing in LF space. */ +export const restoreLineEndings = (text: string, ending: '\r\n' | '\n'): string => + ending === '\r\n' ? text.replace(/\n/g, '\r\n') : text + +/** Strip a leading UTF-8 BOM, returning it separately so it can be re-applied. */ +export const stripBom = (content: string): { bom: string; text: string } => + content.startsWith('\uFEFF') ? { bom: '\uFEFF', text: content.slice(1) } : { bom: '', text: content } + +/** + * Normalize text for fuzzy matching: NFKC, strip per-line trailing whitespace, + * and fold smart quotes / Unicode dashes / exotic spaces to their ASCII forms. + */ +export const normalizeForFuzzyMatch = (text: string): string => + text + .normalize('NFKC') + // Strip trailing whitespace per line. + .split('\n') + .map((line) => line.trimEnd()) + .join('\n') + // Smart single quotes -> ' + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") + // Smart double quotes -> " + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') + // Hyphens/dashes/minus -> - + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, '-') + // Exotic spaces -> regular space + .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, ' ') + +type LineSpan = { start: number; end: number } + +type MatchedEdit = { editIndex: number; matchIndex: number; matchLength: number; newText: string } + +/** Split content into lines, preserving each line's trailing newline. */ +const splitLinesWithEndings = (content: string): string[] => content.match(/[^\n]*\n|[^\n]+/g) ?? [] + +/** Compute the character span of each line within `content`. */ +const getLineSpans = (content: string): LineSpan[] => { + let offset = 0 + return splitLinesWithEndings(content).map((line) => { + const span = { start: offset, end: offset + line.length } + offset = span.end + return span + }) +} + +/** Find the inclusive-exclusive line range a replacement touches within `lines`. */ +const getReplacementLineRange = ( + lines: LineSpan[], + replacement: MatchedEdit, +): { startLine: number; endLine: number } => { + const replacementStart = replacement.matchIndex + const replacementEnd = replacement.matchIndex + replacement.matchLength + let startLine = -1 + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (replacementStart >= line.start && replacementStart < line.end) { + startLine = i + break + } + } + if (startLine === -1) { + throw new Error('Replacement range is outside the base content.') + } + let endLine = startLine + while (endLine < lines.length && lines[endLine].end < replacementEnd) { + endLine++ + } + if (endLine >= lines.length) { + throw new Error('Replacement range is outside the base content.') + } + return { startLine, endLine: endLine + 1 } +} + +/** Apply replacements (in reverse offset order) to `content`. */ +const applyReplacements = (content: string, replacements: MatchedEdit[], offset = 0): string => { + let result = content + for (let i = replacements.length - 1; i >= 0; i--) { + const replacement = replacements[i] + const matchIndex = replacement.matchIndex - offset + result = + result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength) + } + return result +} + +/** + * Apply replacements matched against a normalized `baseContent` to the original + * content while preserving unchanged line blocks byte-for-byte. Used when fuzzy + * matching rewrote the matching space. + */ +const applyReplacementsPreservingUnchangedLines = ( + originalContent: string, + baseContent: string, + replacements: MatchedEdit[], +): string => { + const originalLines = splitLinesWithEndings(originalContent) + const baseLines = getLineSpans(baseContent) + if (originalLines.length !== baseLines.length) { + throw new Error('Cannot preserve unchanged lines because the base content has a different line count.') + } + const groups: { startLine: number; endLine: number; replacements: MatchedEdit[] }[] = [] + const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex) + for (const replacement of sortedReplacements) { + const range = getReplacementLineRange(baseLines, replacement) + const current = groups[groups.length - 1] + if (current && range.startLine < current.endLine) { + current.endLine = Math.max(current.endLine, range.endLine) + current.replacements.push(replacement) + continue + } + groups.push({ ...range, replacements: [replacement] }) + } + let originalLineIndex = 0 + let result = '' + for (const group of groups) { + result += originalLines.slice(originalLineIndex, group.startLine).join('') + const groupStartOffset = baseLines[group.startLine].start + const groupEndOffset = baseLines[group.endLine - 1].end + result += applyReplacements( + baseContent.slice(groupStartOffset, groupEndOffset), + group.replacements, + groupStartOffset, + ) + originalLineIndex = group.endLine + } + result += originalLines.slice(originalLineIndex).join('') + return result +} + +type FuzzyMatch = { + found: boolean + index: number + matchLength: number + usedFuzzyMatch: boolean +} + +/** Find `oldText` in `content`, exact first then fuzzy-normalized. */ +const fuzzyFindText = (content: string, oldText: string): FuzzyMatch => { + const exactIndex = content.indexOf(oldText) + if (exactIndex !== -1) { + return { found: true, index: exactIndex, matchLength: oldText.length, usedFuzzyMatch: false } + } + const fuzzyContent = normalizeForFuzzyMatch(content) + const fuzzyOldText = normalizeForFuzzyMatch(oldText) + const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText) + if (fuzzyIndex === -1) { + return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false } + } + return { found: true, index: fuzzyIndex, matchLength: fuzzyOldText.length, usedFuzzyMatch: true } +} + +/** Count fuzzy occurrences of `oldText` in `content`. */ +const countOccurrences = (content: string, oldText: string): number => { + const fuzzyContent = normalizeForFuzzyMatch(content) + const fuzzyOldText = normalizeForFuzzyMatch(oldText) + return fuzzyContent.split(fuzzyOldText).length - 1 +} + +const getNotFoundError = (path: string, editIndex: number, totalEdits: number): Error => + totalEdits === 1 + ? new Error( + `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, + ) + : new Error( + `Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`, + ) + +const getDuplicateError = (path: string, editIndex: number, totalEdits: number, occurrences: number): Error => + totalEdits === 1 + ? new Error( + `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, + ) + : new Error( + `Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`, + ) + +const getEmptyOldTextError = (path: string, editIndex: number, totalEdits: number): Error => + totalEdits === 1 + ? new Error(`oldText must not be empty in ${path}.`) + : new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`) + +const getNoChangeError = (path: string, totalEdits: number): Error => + totalEdits === 1 + ? new Error( + `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, + ) + : new Error(`No changes made to ${path}. The replacements produced identical content.`) + +/** + * Apply one or more exact-text replacements to LF-normalized content. Throws Pi's + * exact error messages on empty/missing/duplicate/overlapping/no-op edits. + * + * @param normalizedContent - the file content, already normalized to LF + * @param edits - the replacements to apply + * @param path - the file path, only used to build human error messages + * @returns the original (`baseContent`) and edited (`newContent`) content + */ +export const applyEditsToNormalizedContent = ( + normalizedContent: string, + edits: readonly EditReplacement[], + path: string, +): { baseContent: string; newContent: string } => { + const normalizedEdits = edits.map((edit) => ({ + oldText: normalizeToLF(edit.oldText), + newText: normalizeToLF(edit.newText), + })) + for (let i = 0; i < normalizedEdits.length; i++) { + // Guard against the FUZZY-normalized length, not the raw LF length: a + // whitespace-only oldText is non-empty after LF normalization but collapses + // to '' under normalizeForFuzzyMatch (which trims trailing whitespace per + // line), and a fuzzy match of '' would otherwise land at index 0. + if (normalizeForFuzzyMatch(normalizedEdits[i].oldText).length === 0) { + throw getEmptyOldTextError(path, i, normalizedEdits.length) + } + } + const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText)) + const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch) + const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent + const matchedEdits: MatchedEdit[] = [] + for (let i = 0; i < normalizedEdits.length; i++) { + const edit = normalizedEdits[i] + const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText) + if (!matchResult.found) { + throw getNotFoundError(path, i, normalizedEdits.length) + } + const occurrences = countOccurrences(replacementBaseContent, edit.oldText) + if (occurrences > 1) { + throw getDuplicateError(path, i, normalizedEdits.length, occurrences) + } + matchedEdits.push({ + editIndex: i, + matchIndex: matchResult.index, + matchLength: matchResult.matchLength, + newText: edit.newText, + }) + } + matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex) + for (let i = 1; i < matchedEdits.length; i++) { + const previous = matchedEdits[i - 1] + const current = matchedEdits[i] + if (previous.matchIndex + previous.matchLength > current.matchIndex) { + throw new Error( + `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`, + ) + } + } + const baseContent = normalizedContent + const newContent = usedFuzzyMatch + ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) + : applyReplacements(replacementBaseContent, matchedEdits) + if (baseContent === newContent) { + throw getNoChangeError(path, normalizedEdits.length) + } + return { baseContent, newContent } +} diff --git a/shared/agent-core/coding-tools/index.test.ts b/shared/agent-core/coding-tools/index.test.ts new file mode 100644 index 000000000..eb158b453 --- /dev/null +++ b/shared/agent-core/coding-tools/index.test.ts @@ -0,0 +1,354 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Tests for the four browser coding tools' wiring: argument preparation/validation, + * error/abort paths, and output shaping (continuation hints, truncation footers, + * line-ending/BOM preservation). Driven through the public `createBrowserCodingTools` + * factory over a real in-memory ZenFS mount (DI of the FS via the mount, not module + * mocking) so the tests exercise the genuine tool → operation → ZenFS path. The pure + * edit MATCHING algorithm is covered separately in `edit-apply.test.ts`; here we only + * cover the edit tool's wiring (prepareArguments, validation, BOM/CRLF round-trip, + * error path) without duplicating it. + */ + +import { beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import * as fsp from '@zenfs/core/promises' +import type { AgentTool } from '@earendil-works/pi-agent-core' +import { mountInMemoryFs } from '../browser-env/mount.ts' +import { BrowserExecutionEnv } from '../browser-env/browser-execution-env.ts' +import { createBrowserCodingTools } from './index.ts' +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from './truncate.ts' + +const WS = '/ws' + +const textOf = (result: { content: { type: string; text?: string }[] }): string => { + const block = result.content[0] + if (block.type !== 'text' || block.text === undefined) throw new Error('expected a text block') + return block.text +} + +let bash: AgentTool +let read: AgentTool +let write: AgentTool +let edit: AgentTool + +beforeAll(async () => { + await mountInMemoryFs() +}) + +beforeEach(async () => { + // Fresh workspace per test so files from one test can't leak into another. + await fsp.rm(WS, { recursive: true, force: true }) + await fsp.mkdir(WS, { recursive: true }) + const env = new BrowserExecutionEnv({ cwd: WS }) + ;[bash, read, write, edit] = createBrowserCodingTools(env, { cwd: WS }) +}) + +const run = (tool: AgentTool, args: unknown) => + // The Pi AgentTool.execute signature: (toolCallId, args, signal). + (tool.execute as (id: string, a: unknown, s?: AbortSignal) => Promise<{ content: { type: string; text?: string }[] }>)( + 'call-1', + args, + undefined, + ) + +describe('createBrowserCodingTools', () => { + it('exposes the four tools in order with the model-visible names', () => { + expect([bash.name, read.name, write.name, edit.name]).toEqual(['bash', 'read', 'write', 'edit']) + // The mutating tools serialize the batch; read does not. + expect(bash.executionMode).toBe('sequential') + expect(write.executionMode).toBe('sequential') + expect(edit.executionMode).toBe('sequential') + expect(read.executionMode).toBeUndefined() + }) +}) + +describe('read tool', () => { + it('returns the full file content when small', async () => { + await fsp.writeFile(`${WS}/a.txt`, 'line one\nline two') + expect(textOf(await run(read, { path: 'a.txt' }))).toBe('line one\nline two') + }) + + it('applies offset + limit and appends a continuation hint with the next offset', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'l1\nl2\nl3\nl4\nl5') + const out = textOf(await run(read, { path: 'f.txt', offset: 2, limit: 2 })) + expect(out).toBe('l2\nl3\n\n[2 more lines in file. Use offset=4 to continue.]') + }) + + it('does not append a continuation hint when the limit reaches end of file', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'l1\nl2\nl3') + expect(textOf(await run(read, { path: 'f.txt', offset: 1, limit: 3 }))).toBe('l1\nl2\nl3') + }) + + it('counts a trailing newline as a line (unlike truncate.ts, which drops it)', async () => { + // 'a\nb\n' splits into ['a','b',''] here, so the empty trailing entry is a 3rd line. + await fsp.writeFile(`${WS}/f.txt`, 'a\nb\n') + expect(textOf(await run(read, { path: 'f.txt', offset: 1, limit: 1 }))).toBe( + 'a\n\n[2 more lines in file. Use offset=2 to continue.]', + ) + }) + + it('clamps a negative offset to the start of the file', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'l1\nl2\nl3') + expect(textOf(await run(read, { path: 'f.txt', offset: -5, limit: 1 }))).toBe( + 'l1\n\n[2 more lines in file. Use offset=2 to continue.]', + ) + }) + + it('handles limit=0 (selects no lines) and still reports the remaining count', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'l1\nl2\nl3') + expect(textOf(await run(read, { path: 'f.txt', offset: 1, limit: 0 }))).toBe( + '\n\n[3 more lines in file. Use offset=1 to continue.]', + ) + }) + + it('throws when the offset is beyond end of file', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'only\ntwo') + await expect(run(read, { path: 'f.txt', offset: 10 })).rejects.toThrow( + 'Offset 10 is beyond end of file (2 lines total)', + ) + }) + + it('head-truncates very long files and emits a line-based continuation footer', async () => { + const content = Array.from({ length: DEFAULT_MAX_LINES + 1 }, (_, i) => `row${i}`).join('\n') + await fsp.writeFile(`${WS}/big.txt`, content) + const out = textOf(await run(read, { path: 'big.txt' })) + expect(out).toContain(`[Showing lines 1-${DEFAULT_MAX_LINES} of ${DEFAULT_MAX_LINES + 1}. Use offset=${DEFAULT_MAX_LINES + 1} to continue.]`) + expect(out).not.toContain('limit)') // line-based truncation has no "(50.0KB limit)" note + }) + + it('byte-truncates a many-line file and appends the "(50.0KB limit)" footer', async () => { + // 100 lines × ~600 bytes = ~60KB: under the 2000-line limit but over the 50KB + // byte limit, so truncation is byte-based and the footer carries the limit note. + const content = Array.from({ length: 100 }, (_, i) => `${i}:${'y'.repeat(600)}`).join('\n') + await fsp.writeFile(`${WS}/wide.txt`, content) + const out = textOf(await run(read, { path: 'wide.txt' })) + expect(out).toContain('(50.0KB limit). Use offset=') + expect(out.startsWith('0:')).toBe(true) // head truncation keeps the FIRST lines + }) + + it('shifts the displayed line numbers and next offset when reading from an offset into a truncated region', async () => { + const content = Array.from({ length: DEFAULT_MAX_LINES + 10 }, (_, i) => `row${i}`).join('\n') + await fsp.writeFile(`${WS}/big.txt`, content) + const out = textOf(await run(read, { path: 'big.txt', offset: 5 })) + // From offset 5, the kept window is DEFAULT_MAX_LINES lines: 5 .. 5+2000-1 = 2004. + expect(out).toContain( + `[Showing lines 5-${5 + DEFAULT_MAX_LINES - 1} of ${DEFAULT_MAX_LINES + 10}. Use offset=${5 + DEFAULT_MAX_LINES} to continue.]`, + ) + }) + + it('returns the sed escape hint (no content) when a single line exceeds the byte limit', async () => { + await fsp.writeFile(`${WS}/wide.txt`, 'x'.repeat(60 * 1024)) + const out = textOf(await run(read, { path: 'wide.txt' })) + expect(out).toContain('[Line 1 is 60.0KB, exceeds 50.0KB limit.') + expect(out).toContain("sed -n '1p' wide.txt | head -c " + DEFAULT_MAX_BYTES) + }) + + it('rejects an absolute path that escapes the workspace jail', async () => { + await expect(run(read, { path: '/etc/passwd' })).rejects.toThrow('path escapes workspace') + }) + + it('rejects a `..` traversal that escapes the workspace jail', async () => { + await expect(run(read, { path: '../secret.txt' })).rejects.toThrow('path escapes workspace') + }) + + it('aborts before touching the filesystem when the signal is already aborted', async () => { + // Target a MISSING file: if access() ran despite the aborted signal we would see + // ENOENT, so the 'Operation aborted' message proves the abort short-circuits first. + const aborted = AbortSignal.abort() + await expect( + (read.execute as (id: string, a: unknown, s?: AbortSignal) => Promise)( + 'c', + { path: 'never-created.txt' }, + aborted, + ), + ).rejects.toThrow('Operation aborted') + }) +}) + +describe('write tool', () => { + it('writes content and reports the byte count (character length) and path', async () => { + const out = textOf(await run(write, { path: 'out.txt', content: 'hello' })) + expect(out).toBe('Successfully wrote 5 bytes to out.txt') + expect(await fsp.readFile(`${WS}/out.txt`, { encoding: 'utf8' })).toBe('hello') + }) + + it('reports the CHARACTER count (not UTF-8 byte length) for multibyte content', async () => { + // '€€' is 2 chars but 6 UTF-8 bytes; the tool's message uses content.length, so it + // says "2 bytes". This pins the (mislabelled) contract — the bytes on disk are 6. + const out = textOf(await run(write, { path: 'mb.txt', content: '€€' })) + expect(out).toBe('Successfully wrote 2 bytes to mb.txt') + expect(Buffer.byteLength(await fsp.readFile(`${WS}/mb.txt`, { encoding: 'utf8' }), 'utf-8')).toBe(6) + }) + + it('creates missing parent directories', async () => { + await run(write, { path: 'nested/deep/file.txt', content: 'x' }) + expect(await fsp.readFile(`${WS}/nested/deep/file.txt`, { encoding: 'utf8' })).toBe('x') + }) + + it('overwrites an existing file', async () => { + await fsp.writeFile(`${WS}/o.txt`, 'old') + await run(write, { path: 'o.txt', content: 'new' }) + expect(await fsp.readFile(`${WS}/o.txt`, { encoding: 'utf8' })).toBe('new') + }) + + it('rejects writing outside the workspace jail', async () => { + await expect(run(write, { path: '../escape.txt', content: 'x' })).rejects.toThrow('path escapes workspace') + }) +}) + +describe('edit tool – argument preparation', () => { + const prepare = (input: unknown): unknown => + (edit.prepareArguments as (i: unknown) => unknown)(input) + + it('parses a JSON-string `edits` into an array (some models stringify it)', () => { + expect(prepare({ path: 'f', edits: '[{"oldText":"a","newText":"b"}]' })).toEqual({ + path: 'f', + edits: [{ oldText: 'a', newText: 'b' }], + }) + }) + + it('folds a top-level oldText/newText pair into edits[] and strips the legacy keys', () => { + expect(prepare({ path: 'f', oldText: 'a', newText: 'b' })).toEqual({ + path: 'f', + edits: [{ oldText: 'a', newText: 'b' }], + }) + }) + + it('appends a top-level oldText/newText pair to an existing edits[] array', () => { + expect( + prepare({ path: 'f', edits: [{ oldText: 'x', newText: 'y' }], oldText: 'a', newText: 'b' }), + ).toEqual({ path: 'f', edits: [{ oldText: 'x', newText: 'y' }, { oldText: 'a', newText: 'b' }] }) + }) + + it('leaves a non-JSON `edits` string untouched so validation surfaces a clear error', () => { + expect(prepare({ path: 'f', edits: 'not json' })).toEqual({ path: 'f', edits: 'not json' }) + }) + + it('passes non-object input through unchanged', () => { + expect(prepare(null)).toBeNull() + expect(prepare('raw')).toBe('raw') + }) +}) + +describe('edit tool – execution', () => { + it('throws when no replacements are provided', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'abc') + await expect(run(edit, { path: 'f.txt', edits: [] })).rejects.toThrow( + 'edits must contain at least one replacement', + ) + }) + + it('reports a friendly error (with the FS error code) when the file does not exist', async () => { + await expect(run(edit, { path: 'missing.txt', edits: [{ oldText: 'a', newText: 'b' }] })).rejects.toThrow( + /Could not edit file: missing\.txt\..*ENOENT/, + ) + }) + + it('rejects editing outside the workspace jail', async () => { + await expect(run(edit, { path: '../escape.txt', edits: [{ oldText: 'a', newText: 'b' }] })).rejects.toThrow( + 'path escapes workspace', + ) + }) + + it('propagates a match failure and leaves the file unchanged when oldText is absent', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'hello world') + await expect(run(edit, { path: 'f.txt', edits: [{ oldText: 'zzz', newText: 'q' }] })).rejects.toThrow() + expect(await fsp.readFile(`${WS}/f.txt`, { encoding: 'utf8' })).toBe('hello world') + }) + + it('composes prepareArguments with execute (stringified edits applied end-to-end)', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'hello world') + const prepared = (edit.prepareArguments as (i: unknown) => unknown)({ + path: 'f.txt', + edits: '[{"oldText":"world","newText":"pi"}]', + }) + expect(textOf(await run(edit, prepared))).toBe('Successfully replaced 1 block(s) in f.txt.') + expect(await fsp.readFile(`${WS}/f.txt`, { encoding: 'utf8' })).toBe('hello pi') + }) + + it('applies a replacement and reports the block count', async () => { + await fsp.writeFile(`${WS}/f.txt`, 'hello world') + const out = textOf(await run(edit, { path: 'f.txt', edits: [{ oldText: 'world', newText: 'pi' }] })) + expect(out).toBe('Successfully replaced 1 block(s) in f.txt.') + expect(await fsp.readFile(`${WS}/f.txt`, { encoding: 'utf8' })).toBe('hello pi') + }) + + it('preserves CRLF line endings across the edit', async () => { + await fsp.writeFile(`${WS}/crlf.txt`, 'a\r\nb\r\nc') + await run(edit, { path: 'crlf.txt', edits: [{ oldText: 'b', newText: 'B' }] }) + expect(await fsp.readFile(`${WS}/crlf.txt`, { encoding: 'utf8' })).toBe('a\r\nB\r\nc') + }) + + it('preserves a leading BOM (U+FEFF) across the edit', async () => { + await fsp.writeFile(`${WS}/bom.txt`, '\uFEFFhello') + await run(edit, { path: 'bom.txt', edits: [{ oldText: 'hello', newText: 'bye' }] }) + expect(await fsp.readFile(`${WS}/bom.txt`, { encoding: 'utf8' })).toBe('\uFEFFbye') + }) + + it('reports the count for multiple blocks', async () => { + await fsp.writeFile(`${WS}/m.txt`, 'a x c') + const out = textOf( + await run(edit, { path: 'm.txt', edits: [{ oldText: 'a', newText: 'A' }, { oldText: 'c', newText: 'C' }] }), + ) + expect(out).toBe('Successfully replaced 2 block(s) in m.txt.') + expect(await fsp.readFile(`${WS}/m.txt`, { encoding: 'utf8' })).toBe('A x C') + }) +}) + +describe('bash tool', () => { + it('returns command stdout', async () => { + expect(textOf(await run(bash, { command: 'echo hello' }))).toBe('hello\n') + }) + + it('reports "(no output)" when a successful command writes nothing', async () => { + expect(textOf(await run(bash, { command: 'true' }))).toBe('(no output)') + }) + + it('captures stderr alongside stdout', async () => { + expect(textOf(await run(bash, { command: 'echo oops >&2' }))).toBe('oops\n') + }) + + it('includes captured output AND the status line when a command prints then exits non-zero', async () => { + // appendStatus joins the captured stdout with the status via a blank line. + await expect(run(bash, { command: 'echo partial; exit 3' })).rejects.toThrow( + /partial[\s\S]*Command exited with code 3/, + ) + }) + + it('throws "Command aborted" (with captured output) when the run is aborted', async () => { + await expect( + (bash.execute as (id: string, a: unknown, s?: AbortSignal) => Promise)( + 'c', + { command: 'echo hi' }, + AbortSignal.abort(), + ), + ).rejects.toThrow('Command aborted') + }) + + it('byte-truncates output and labels the footer with the 50KB limit', async () => { + // 60 lines × ~1001 bytes ≈ 60KB: under the line limit but over the byte limit. + const out = textOf( + await run(bash, { command: 'for i in $(seq 1 60); do printf "%01000d\\n" $i; done' }), + ) + expect(out).toContain('(50.0KB limit). Full output:') + }) + + it('truncates long output (tail) and persists the FULL output to the temp file it names', async () => { + const lineCount = DEFAULT_MAX_LINES + 5 + const out = textOf(await run(bash, { command: `for i in $(seq 1 ${lineCount}); do echo line$i; done` })) + expect(out).toContain('[Showing lines') + // Tail truncation keeps the END: the last line is present, an early line is dropped. + expect(out).toContain(`line${lineCount}`) + expect(out).not.toContain('line1\n') + // Parse the persisted path out of the footer and confirm it holds the UNtruncated output. + const match = out.match(/Full output: (\S+?)\]/) + expect(match).not.toBeNull() + const full = await fsp.readFile(match![1], { encoding: 'utf8' }) + expect(full).toContain('line1\n') + expect(full).toContain(`line${lineCount}\n`) + expect(full.split('\n').filter(Boolean)).toHaveLength(lineCount) + }) +}) diff --git a/shared/agent-core/coding-tools/index.ts b/shared/agent-core/coding-tools/index.ts new file mode 100644 index 000000000..69b54cf6d --- /dev/null +++ b/shared/agent-core/coding-tools/index.ts @@ -0,0 +1,368 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The four coding tools (bash/read/write/edit) as PLAIN Pi {@link AgentTool} + * objects bound directly to a {@link BrowserExecutionEnv}. + * + * This is the in-browser replacement for Pi's `@earendil-works/pi-coding-agent` + * tool factories. Those factories are a Node CLI: importing them transitively + * pulls `cross-spawn`/`which`/`undici`/`graceful-fs` and a TUI renderer, forcing + * a stack of browser stubs and vite aliases. The model, however, only ever sees a + * tool's `name`, `description`, and parameter schema — so we replicate those + * verbatim from Pi (the model's priors depend on the exact wording) while + * implementing `execute` over the same ZenFS-backed operation adapters the CLI + * tools were given via their `operations` option (see + * `../browser-env/coding-tool-operations.ts`). The Node-coupled factories are + * therefore never imported on the app path, and their dependency cascade is gone. + * + * Dropped vs. the CLI tools (all TUI/CLI-only, invisible to the model): + * - `renderCall`/`renderResult` (pi-tui terminal rendering); + * - the edit tool's `diff`/`patch` details (the app never renders them); + * - the per-file mutation queue — replaced by marking the mutating tools + * (`bash`/`write`/`edit`) `executionMode: 'sequential'`. Pi's agent loop runs + * a tool batch in parallel by default, but serializes the whole batch as soon + * as one of its tools is sequential, so a model that emits `write` + `edit` on + * the same file in one turn can't race them over the shared ZenFS mount. + * + * `Buffer` is used for byte-accurate truncation and to decode operation output; + * the app installs the global `Buffer` polyfill before any tool runs. + */ + +import type { AgentTool, AgentToolResult } from '@earendil-works/pi-agent-core' +import type { TextContent } from '@earendil-works/pi-ai' +import { dirname } from '@zenfs/core/path' +import { Type, type Static } from 'typebox' +import type { BrowserExecutionEnv } from '../browser-env/browser-execution-env.ts' +import { resolveInWorkspace } from '../browser-env/workspace-jail.ts' +import { + createBashOperations, + createEditOperations, + createReadOperations, + createWriteOperations, +} from '../browser-env/coding-tool-operations.ts' +import { + applyEditsToNormalizedContent, + detectLineEnding, + normalizeToLF, + restoreLineEndings, + stripBom, + type EditReplacement, +} from './edit-apply.ts' +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, truncateTail } from './truncate.ts' + +/** Wrap plain text as a single-block tool result with no structured details. */ +const textResult = (text: string): AgentToolResult => ({ + content: [{ type: 'text', text } satisfies TextContent], + details: undefined, +}) + +/** Throw Pi's abort error if the run was cancelled. Checked between awaits so an + * in-flight ZenFS operation settles before we bail. */ +const throwIfAborted = (signal?: AbortSignal): void => { + if (signal?.aborted) { + throw new Error('Operation aborted') + } +} + +// --------------------------------------------------------------------------- +// bash +// --------------------------------------------------------------------------- + +const bashSchema = Type.Object({ + command: Type.String({ description: 'Bash command to execute' }), + timeout: Type.Optional(Type.Number({ description: 'Timeout in seconds (optional, no default timeout)' })), +}) + +/** Persist full (untruncated) bash output to a ZenFS temp file so the model can + * `cat` it; returns the path, or undefined if the write failed. */ +const persistFullOutput = async (env: BrowserExecutionEnv, output: string): Promise => { + const tmp = await env.createTempFile({ prefix: 'pi-bash-', suffix: '.log' }) + if (!tmp.ok) { + return undefined + } + const written = await env.writeFile(tmp.value, output) + return written.ok ? tmp.value : undefined +} + +/** Tail-truncate bash output and, when truncated, append Pi's "[Showing lines …]" + * footer pointing at the persisted full-output file. */ +const formatBashOutput = async (env: BrowserExecutionEnv, output: string, emptyText: string): Promise => { + const truncation = truncateTail(output) + if (!truncation.truncated) { + return truncation.content || emptyText + } + const fullOutputPath = await persistFullOutput(env, output) + const fullOutputNote = fullOutputPath ? ` Full output: ${fullOutputPath}` : '' + const startLine = truncation.totalLines - truncation.outputLines + 1 + const endLine = truncation.totalLines + if (truncation.lastLinePartial) { + return `${truncation.content}\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine}.${fullOutputNote}]` + } + if (truncation.truncatedBy === 'lines') { + return `${truncation.content}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}.${fullOutputNote}]` + } + return `${truncation.content}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit).${fullOutputNote}]` +} + +/** `${text}\n\n${status}`, or just `status` when there is no captured output. */ +const appendStatus = (text: string, status: string): string => `${text ? `${text}\n\n` : ''}${status}` + +const buildBashTool = (env: BrowserExecutionEnv, cwd: string): AgentTool => { + const operations = createBashOperations(env) + return { + name: 'bash', + label: 'bash', + description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + parameters: bashSchema, + // Mutates the shared ZenFS mount (redirects, file writes); serialize the batch. + executionMode: 'sequential', + async execute(_toolCallId, { command, timeout }, signal) { + const chunks: string[] = [] + const onData = (data: Buffer): void => { + chunks.push(data.toString('utf-8')) + } + let exitCode: number | null + try { + const result = await operations.exec(command, cwd, { onData, signal, timeout }) + exitCode = result.exitCode + } catch (error) { + // operations.exec re-throws the env's ExecutionError, whose `.message` + // already carries Pi's `aborted` / `timeout:` framing. + const text = await formatBashOutput(env, chunks.join(''), '') + if (error instanceof Error && error.message === 'aborted') { + throw new Error(appendStatus(text, 'Command aborted')) + } + if (error instanceof Error && error.message.startsWith('timeout:')) { + throw new Error(appendStatus(text, `Command timed out after ${error.message.split(':')[1]} seconds`)) + } + throw error + } + const text = await formatBashOutput(env, chunks.join(''), '(no output)') + if (exitCode !== 0 && exitCode !== null) { + throw new Error(appendStatus(text, `Command exited with code ${exitCode}`)) + } + return textResult(text) + }, + } +} + +// --------------------------------------------------------------------------- +// read +// --------------------------------------------------------------------------- + +const readSchema = Type.Object({ + path: Type.String({ description: 'Path to the file to read (relative or absolute)' }), + offset: Type.Optional(Type.Number({ description: 'Line number to start reading from (1-indexed)' })), + limit: Type.Optional(Type.Number({ description: 'Maximum number of lines to read' })), +}) + +/** Build the read tool's text output: applies offset/limit, head-truncates, and + * appends Pi's actionable continuation hints (`Use offset=N to continue`). */ +const formatReadOutput = ( + rawPath: string, + textContent: string, + offset: number | undefined, + limit: number | undefined, +): string => { + const allLines = textContent.split('\n') + const totalFileLines = allLines.length + const startLine = offset ? Math.max(0, offset - 1) : 0 + const startLineDisplay = startLine + 1 + if (startLine >= allLines.length) { + throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`) + } + + const endLine = limit !== undefined ? Math.min(startLine + limit, allLines.length) : allLines.length + const userLimitedLines = limit !== undefined ? endLine - startLine : undefined + const selectedContent = allLines.slice(startLine, endLine).join('\n') + const truncation = truncateHead(selectedContent) + + if (truncation.firstLineExceedsLimit) { + const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], 'utf-8')) + return `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${rawPath} | head -c ${DEFAULT_MAX_BYTES}]` + } + if (truncation.truncated) { + const endLineDisplay = startLineDisplay + truncation.outputLines - 1 + const nextOffset = endLineDisplay + 1 + const limitNote = truncation.truncatedBy === 'lines' ? '' : ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` + return `${truncation.content}\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}${limitNote}. Use offset=${nextOffset} to continue.]` + } + if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { + const remaining = allLines.length - (startLine + userLimitedLines) + const nextOffset = startLine + userLimitedLines + 1 + return `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]` + } + return truncation.content +} + +const buildReadTool = (cwd: string): AgentTool => { + const operations = createReadOperations() + return { + name: 'read', + label: 'read', + description: `Read the contents of a file. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, + parameters: readSchema, + async execute(_toolCallId, { path, offset, limit }, signal) { + const absolutePath = resolveInWorkspace(cwd, path) + throwIfAborted(signal) + await operations.access(absolutePath) + throwIfAborted(signal) + const buffer = await operations.readFile(absolutePath) + return textResult(formatReadOutput(path, buffer.toString('utf-8'), offset, limit)) + }, + } +} + +// --------------------------------------------------------------------------- +// write +// --------------------------------------------------------------------------- + +const writeSchema = Type.Object({ + path: Type.String({ description: 'Path to the file to write (relative or absolute)' }), + content: Type.String({ description: 'Content to write to the file' }), +}) + +const buildWriteTool = (cwd: string): AgentTool => { + const operations = createWriteOperations() + return { + name: 'write', + label: 'write', + description: + "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", + parameters: writeSchema, + // Serialize against other file mutations in the same batch (no per-path queue). + executionMode: 'sequential', + async execute(_toolCallId, { path, content }, signal) { + const absolutePath = resolveInWorkspace(cwd, path) + throwIfAborted(signal) + await operations.mkdir(dirname(absolutePath)) + throwIfAborted(signal) + await operations.writeFile(absolutePath, content) + return textResult(`Successfully wrote ${content.length} bytes to ${path}`) + }, + } +} + +// --------------------------------------------------------------------------- +// edit +// --------------------------------------------------------------------------- + +const replaceEditSchema = Type.Object( + { + oldText: Type.String({ + description: + 'Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.', + }), + newText: Type.String({ description: 'Replacement text for this targeted edit.' }), + }, + { additionalProperties: false }, +) + +const editSchema = Type.Object( + { + path: Type.String({ description: 'Path to the file to edit (relative or absolute)' }), + edits: Type.Array(replaceEditSchema, { + description: + 'One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.', + }), + }, + { additionalProperties: false }, +) + +type EditInput = Static + +/** Drop `oldText`/`newText` from a record, keeping every other key. */ +const withoutLegacyEditKeys = (args: Record): Record => { + const rest: Record = {} + for (const [key, value] of Object.entries(args)) { + if (key !== 'oldText' && key !== 'newText') { + rest[key] = value + } + } + return rest +} + +/** + * Normalize raw model arguments into the edit schema's shape before validation. + * Some models emit `edits` as a JSON string (Opus 4.6, GLM-5.1) or a single + * top-level `oldText`/`newText` pair instead of the `edits[]` array; fold both + * into `edits[]` so the tool's contract stays uniform. + */ +const prepareEditArguments = (input: unknown): EditInput => { + if (!input || typeof input !== 'object') { + return input as EditInput + } + const args: Record = { ...(input as Record) } + if (typeof args.edits === 'string') { + try { + const parsed: unknown = JSON.parse(args.edits) + if (Array.isArray(parsed)) { + args.edits = parsed + } + } catch { + // Not JSON — leave `edits` untouched so validation surfaces a clear error. + } + } + if (typeof args.oldText !== 'string' || typeof args.newText !== 'string') { + return args as EditInput + } + const edits = Array.isArray(args.edits) ? [...args.edits] : [] + edits.push({ oldText: args.oldText, newText: args.newText }) + return { ...withoutLegacyEditKeys(args), edits } as EditInput +} + +/** Validate that at least one replacement was provided. */ +const validateEditInput = (input: EditInput): { path: string; edits: EditReplacement[] } => { + if (!Array.isArray(input.edits) || input.edits.length === 0) { + throw new Error('Edit tool input is invalid. edits must contain at least one replacement.') + } + return { path: input.path, edits: input.edits } +} + +const buildEditTool = (cwd: string): AgentTool => { + const operations = createEditOperations() + return { + name: 'edit', + label: 'edit', + description: + 'Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.', + parameters: editSchema, + // Serialize against other file mutations in the same batch (no per-path queue). + executionMode: 'sequential', + prepareArguments: prepareEditArguments, + async execute(_toolCallId, input, signal) { + const { path, edits } = validateEditInput(input) + const absolutePath = resolveInWorkspace(cwd, path) + throwIfAborted(signal) + try { + await operations.access(absolutePath) + } catch (error) { + const reason = error instanceof Error && 'code' in error ? `Error code: ${error.code}` : String(error) + throw new Error(`Could not edit file: ${path}. ${reason}.`) + } + throwIfAborted(signal) + const buffer = await operations.readFile(absolutePath) + const { bom, text: content } = stripBom(buffer.toString('utf-8')) + const originalEnding = detectLineEnding(content) + const { newContent } = applyEditsToNormalizedContent(normalizeToLF(content), edits, path) + throwIfAborted(signal) + await operations.writeFile(absolutePath, bom + restoreLineEndings(newContent, originalEnding)) + return textResult(`Successfully replaced ${edits.length} block(s) in ${path}.`) + }, + } +} + +/** + * Build the four browser coding tools (bash/read/write/edit) bound to `env` and + * rooted at `options.cwd`. Returned as plain {@link AgentTool}s ready to register + * on an {@link import('@earendil-works/pi-agent-core').AgentHarness}. + * + * @param env - the ZenFS-backed execution environment the tools operate over + * @param options - `cwd` the tools resolve relative paths against + */ +export const createBrowserCodingTools = (env: BrowserExecutionEnv, options: { cwd: string }): AgentTool[] => { + const { cwd } = options + return [buildBashTool(env, cwd), buildReadTool(cwd), buildWriteTool(cwd), buildEditTool(cwd)] +} diff --git a/shared/agent-core/coding-tools/truncate.test.ts b/shared/agent-core/coding-tools/truncate.test.ts new file mode 100644 index 000000000..f7996d0b5 --- /dev/null +++ b/shared/agent-core/coding-tools/truncate.test.ts @@ -0,0 +1,217 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `truncate` tests — the head/tail truncation algorithm is a pure function whose + * exact contract ("first/last N lines or KKB, whichever is hit first") the model's + * priors depend on. These exercise the boundaries exhaustively: empty input, + * exactly-at-limit vs one-over for BOTH the line and byte limits, which-limit-wins, + * UTF-8 byte accounting (multibyte chars), the head-specific `firstLineExceedsLimit` + * escape, and the tail-specific partial-last-line path with UTF-8 boundary safety. + */ + +import { describe, expect, it } from 'bun:test' +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, truncateTail } from './truncate.ts' + +describe('formatSize', () => { + it('renders sub-KB byte counts with a B suffix', () => { + expect(formatSize(0)).toBe('0B') + expect(formatSize(1)).toBe('1B') + expect(formatSize(1023)).toBe('1023B') + }) + + it('switches to KB exactly at 1024 bytes with one decimal', () => { + expect(formatSize(1024)).toBe('1.0KB') + expect(formatSize(1536)).toBe('1.5KB') + expect(formatSize(DEFAULT_MAX_BYTES)).toBe('50.0KB') + }) + + it('switches to MB exactly at 1024*1024 bytes', () => { + expect(formatSize(1024 * 1024 - 1)).toBe('1024.0KB') + expect(formatSize(1024 * 1024)).toBe('1.0MB') + expect(formatSize(3 * 1024 * 1024 + 512 * 1024)).toBe('3.5MB') + }) +}) + +describe('truncateHead', () => { + it('returns empty content untruncated (line count is 0, not 1)', () => { + const r = truncateHead('') + expect(r.truncated).toBe(false) + expect(r.content).toBe('') + expect(r.totalLines).toBe(0) + expect(r.outputLines).toBe(0) + expect(r.truncatedBy).toBeNull() + }) + + it('does not truncate exactly at the line limit', () => { + const content = Array.from({ length: 3 }, (_, i) => `l${i}`).join('\n') + const r = truncateHead(content, { maxLines: 3 }) + expect(r.truncated).toBe(false) + expect(r.content).toBe(content) + expect(r.outputLines).toBe(3) + }) + + it('truncates one line over the line limit, keeping the FIRST maxLines lines', () => { + const r = truncateHead('a\nb\nc', { maxLines: 2 }) + expect(r.truncated).toBe(true) + expect(r.truncatedBy).toBe('lines') + expect(r.content).toBe('a\nb') + expect(r.outputLines).toBe(2) + expect(r.totalLines).toBe(3) + }) + + it('does not truncate when total bytes equal the byte limit exactly', () => { + // 'aaa\nbbb' = 7 bytes. + const r = truncateHead('aaa\nbbb', { maxBytes: 7 }) + expect(r.truncated).toBe(false) + expect(r.totalBytes).toBe(7) + }) + + it('truncates one byte over the byte limit, dropping whole trailing lines', () => { + const r = truncateHead('aaa\nbbb', { maxBytes: 6 }) + expect(r.truncated).toBe(true) + expect(r.truncatedBy).toBe('bytes') + expect(r.content).toBe('aaa') + expect(r.outputLines).toBe(1) + }) + + it('flags firstLineExceedsLimit (empty content) when line 1 alone is over the byte limit', () => { + const r = truncateHead('aaaaa\nb', { maxBytes: 3 }) + expect(r.truncated).toBe(true) + expect(r.firstLineExceedsLimit).toBe(true) + expect(r.content).toBe('') + expect(r.outputLines).toBe(0) + expect(r.truncatedBy).toBe('bytes') + }) + + it('counts bytes (not chars) for multibyte content at the byte boundary', () => { + // '€' is 3 UTF-8 bytes; '€\n€' = 7 bytes. maxBytes 4 fits only the first line. + const r = truncateHead('€\n€', { maxBytes: 4 }) + expect(r.truncated).toBe(true) + expect(r.truncatedBy).toBe('bytes') + expect(r.content).toBe('€') + expect(r.outputBytes).toBe(3) + }) + + it('reports the line limit as the cause when lines run out before bytes do', () => { + const content = Array.from({ length: 5 }, () => 'x').join('\n') + const r = truncateHead(content, { maxLines: 2, maxBytes: 1000 }) + expect(r.truncatedBy).toBe('lines') + expect(r.content).toBe('x\nx') + }) + + it('attributes truncation to lines when BOTH limits are exceeded but the line limit is hit first', () => { + // 5 lines of 'a' = 9 bytes total, over BOTH maxLines:2 and maxBytes:5. The 2 + // emitted lines ('a\na' = 3 bytes) fit under maxBytes, so the post-loop + // reassignment must label the cause 'lines', not 'bytes'. + const content = Array.from({ length: 5 }, () => 'a').join('\n') + const r = truncateHead(content, { maxLines: 2, maxBytes: 5 }) + expect(r.truncatedBy).toBe('lines') + expect(r.content).toBe('a\na') + expect(r.outputLines).toBe(2) + }) + + it('drops the trailing empty line produced by a final newline when counting', () => { + const r = truncateHead('a\nb\n') + expect(r.truncated).toBe(false) + expect(r.totalLines).toBe(2) + expect(r.content).toBe('a\nb\n') // content is returned verbatim, newline preserved + expect(r.outputLines).toBe(2) + }) + + it('uses the default limits when no options are passed', () => { + const content = Array.from({ length: DEFAULT_MAX_LINES + 1 }, (_, i) => `${i}`).join('\n') + const r = truncateHead(content) + expect(r.truncated).toBe(true) + expect(r.truncatedBy).toBe('lines') + expect(r.outputLines).toBe(DEFAULT_MAX_LINES) + expect(r.maxBytes).toBe(DEFAULT_MAX_BYTES) + }) +}) + +describe('truncateTail', () => { + it('returns empty content untruncated', () => { + const r = truncateTail('') + expect(r.truncated).toBe(false) + expect(r.content).toBe('') + expect(r.totalLines).toBe(0) + }) + + it('does not truncate exactly at the line limit', () => { + const r = truncateTail('a\nb\nc', { maxLines: 3 }) + expect(r.truncated).toBe(false) + expect(r.content).toBe('a\nb\nc') + }) + + it('keeps the LAST maxLines lines when over the line limit', () => { + const r = truncateTail('a\nb\nc', { maxLines: 2 }) + expect(r.truncated).toBe(true) + expect(r.truncatedBy).toBe('lines') + expect(r.content).toBe('b\nc') + expect(r.outputLines).toBe(2) + expect(r.lastLinePartial).toBe(false) + }) + + it('keeps the LAST whole lines when over the byte limit', () => { + const r = truncateTail('aaa\nbbb\nccc', { maxBytes: 5 }) + expect(r.truncated).toBe(true) + expect(r.truncatedBy).toBe('bytes') + expect(r.content).toBe('ccc') + expect(r.lastLinePartial).toBe(false) + }) + + it('returns a PARTIAL first line (kept from the end) when the last line alone exceeds the byte limit', () => { + const r = truncateTail('hello', { maxBytes: 3 }) + expect(r.truncated).toBe(true) + expect(r.truncatedBy).toBe('bytes') + expect(r.lastLinePartial).toBe(true) + expect(r.content).toBe('llo') + expect(r.outputBytes).toBe(3) + }) + + it('respects UTF-8 character boundaries when partial-truncating the last line', () => { + // '€€€' = 9 bytes. maxBytes 4 cannot fit two whole chars (6 bytes), so it keeps + // one whole '€' (3 bytes) rather than slicing mid-codepoint. + const r = truncateTail('€€€', { maxBytes: 4 }) + expect(r.lastLinePartial).toBe(true) + expect(r.content).toBe('€') + expect(r.outputBytes).toBe(3) + }) + + it('returns EMPTY partial content when the byte limit is smaller than one multibyte char', () => { + // '€' is 3 bytes; maxBytes 2 can fit none of it without splitting a codepoint, so + // the boundary walk drops the whole char, yielding empty (but still partial) content. + const r = truncateTail('€', { maxBytes: 2 }) + expect(r.truncated).toBe(true) + expect(r.lastLinePartial).toBe(true) + expect(r.content).toBe('') + expect(r.outputBytes).toBe(0) + }) + + it('attributes truncation to lines when BOTH limits are exceeded but the line limit caps the output', () => { + // 5 lines of 'a' = 9 bytes, over both maxLines:2 and maxBytes:5. The kept last + // 2 lines ('a\na' = 3 bytes) fit, so the cause is 'lines', not 'bytes'. + const content = Array.from({ length: 5 }, () => 'a').join('\n') + const r = truncateTail(content, { maxLines: 2, maxBytes: 5 }) + expect(r.truncatedBy).toBe('lines') + expect(r.content).toBe('a\na') + expect(r.lastLinePartial).toBe(false) + }) + + it('does not truncate when total bytes equal the byte limit exactly', () => { + const r = truncateTail('aaa\nbbb', { maxBytes: 7 }) + expect(r.truncated).toBe(false) + }) + + it('uses the default limits when no options are passed', () => { + const content = Array.from({ length: DEFAULT_MAX_LINES + 1 }, (_, i) => `${i}`).join('\n') + const r = truncateTail(content) + expect(r.truncated).toBe(true) + expect(r.truncatedBy).toBe('lines') + expect(r.outputLines).toBe(DEFAULT_MAX_LINES) + // The last line is retained, the first dropped. + expect(r.content.endsWith(`${DEFAULT_MAX_LINES}`)).toBe(true) + expect(r.content.startsWith('0\n')).toBe(false) + }) +}) diff --git a/shared/agent-core/coding-tools/truncate.ts b/shared/agent-core/coding-tools/truncate.ts new file mode 100644 index 000000000..af1fa1bf3 --- /dev/null +++ b/shared/agent-core/coding-tools/truncate.ts @@ -0,0 +1,229 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Output-truncation utilities for the browser coding tools, ported verbatim (in + * behaviour) from Pi's `core/tools/truncate.ts`. The tool descriptions promise a + * specific contract — "last/first N lines or KKB, whichever is hit first" — so the + * model's priors depend on this exact framing; replicating it keeps the in-browser + * tools indistinguishable from the CLI's for the model. + * + * Truncation honours two independent limits (whichever is hit first wins): + * - line limit ({@link DEFAULT_MAX_LINES}) + * - byte limit ({@link DEFAULT_MAX_BYTES}) + * + * `Buffer` is used for byte counting (UTF-8 aware); the app installs the global + * `Buffer` polyfill (`ensureBufferPolyfill`) before any tool runs. + */ + +/** Maximum lines retained before truncation kicks in. */ +export const DEFAULT_MAX_LINES = 2000 +/** Maximum bytes retained before truncation kicks in (50KB). */ +export const DEFAULT_MAX_BYTES = 50 * 1024 + +/** Result of a head/tail truncation pass. */ +export type TruncationResult = { + content: string + truncated: boolean + truncatedBy: 'lines' | 'bytes' | null + totalLines: number + totalBytes: number + outputLines: number + outputBytes: number + lastLinePartial: boolean + firstLineExceedsLimit: boolean + maxLines: number + maxBytes: number +} + +/** Options accepted by the truncation helpers. */ +export type TruncateOptions = { maxLines?: number; maxBytes?: number } + +/** Split content into lines for counting, dropping the trailing empty entry a + * final newline would otherwise produce. */ +const splitLinesForCounting = (content: string): string[] => { + if (content.length === 0) { + return [] + } + const lines = content.split('\n') + if (content.endsWith('\n')) { + lines.pop() + } + return lines +} + +/** Format a byte count as a human-readable size (B/KB/MB). */ +export const formatSize = (bytes: number): string => { + if (bytes < 1024) { + return `${bytes}B` + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB` + } + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} + +/** + * Truncate content from the head (keep the first N lines/bytes) — used by the + * read tool. Never returns partial lines; if the first line alone exceeds the + * byte limit, returns empty content with `firstLineExceedsLimit`. + * + * @param content - the text to truncate + * @param options - optional line/byte limit overrides + */ +export const truncateHead = (content: string, options: TruncateOptions = {}): TruncationResult => { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + const totalBytes = Buffer.byteLength(content, 'utf-8') + const lines = splitLinesForCounting(content) + const totalLines = lines.length + + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + } + } + + const firstLineBytes = Buffer.byteLength(lines[0], 'utf-8') + if (firstLineBytes > maxBytes) { + return { + content: '', + truncated: true, + truncatedBy: 'bytes', + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + } + } + + const outputLinesArr: string[] = [] + let outputBytesCount = 0 + let truncatedBy: 'lines' | 'bytes' = 'lines' + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i] + const lineBytes = Buffer.byteLength(line, 'utf-8') + (i > 0 ? 1 : 0) + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = 'bytes' + break + } + outputLinesArr.push(line) + outputBytesCount += lineBytes + } + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = 'lines' + } + const outputContent = outputLinesArr.join('\n') + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: Buffer.byteLength(outputContent, 'utf-8'), + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + } +} + +/** Truncate a string to fit within a byte limit, keeping the END and respecting + * UTF-8 character boundaries. */ +const truncateStringToBytesFromEnd = (text: string, maxBytes: number): string => { + const buf = Buffer.from(text, 'utf-8') + if (buf.length <= maxBytes) { + return text + } + let start = buf.length - maxBytes + while (start < buf.length && (buf[start] & 0xc0) === 0x80) { + start++ + } + return buf.subarray(start).toString('utf-8') +} + +/** + * Truncate content from the tail (keep the last N lines/bytes) — used by the bash + * tool, where the end (errors, final results) matters most. May return a partial + * first line when the original's last line alone exceeds the byte limit. + * + * @param content - the text to truncate + * @param options - optional line/byte limit overrides + */ +export const truncateTail = (content: string, options: TruncateOptions = {}): TruncationResult => { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + const totalBytes = Buffer.byteLength(content, 'utf-8') + const lines = splitLinesForCounting(content) + const totalLines = lines.length + + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + } + } + + const outputLinesArr: string[] = [] + let outputBytesCount = 0 + let truncatedBy: 'lines' | 'bytes' = 'lines' + let lastLinePartial = false + for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { + const line = lines[i] + const lineBytes = Buffer.byteLength(line, 'utf-8') + (outputLinesArr.length > 0 ? 1 : 0) + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = 'bytes' + if (outputLinesArr.length === 0) { + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes) + outputLinesArr.unshift(truncatedLine) + outputBytesCount = Buffer.byteLength(truncatedLine, 'utf-8') + lastLinePartial = true + } + break + } + outputLinesArr.unshift(line) + outputBytesCount += lineBytes + } + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = 'lines' + } + const outputContent = outputLinesArr.join('\n') + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: Buffer.byteLength(outputContent, 'utf-8'), + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + } +} diff --git a/shared/agent-core/ensure-buffer.ts b/shared/agent-core/ensure-buffer.ts new file mode 100644 index 000000000..617f264b1 --- /dev/null +++ b/shared/agent-core/ensure-buffer.ts @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Installs a global `Buffer` for the in-browser harness. Pi's coding tools and + * the ZenFS operation adapters speak Node `Buffer` (the read/edit operations + * return `Buffer`, bash output is wrapped in `Buffer`, and Pi's coding-agent + * internals call `Buffer` too), but browsers expose no global `Buffer`. This + * shims it from the `buffer` package once, before the harness runs. + */ + +import { Buffer as BufferPolyfill } from 'buffer' + +/** + * Ensure `globalThis.Buffer` exists, idempotently. A no-op in any environment + * that already provides `Buffer` (Node, Bun, or a host that polyfilled it). + */ +export const ensureBufferPolyfill = (): void => { + const scope: { Buffer?: unknown } = globalThis + scope.Buffer ??= BufferPolyfill +} diff --git a/shared/agent-core/index.ts b/shared/agent-core/index.ts new file mode 100644 index 000000000..bbfca604f --- /dev/null +++ b/shared/agent-core/index.ts @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The app's in-browser agent engine: a Pi {@link AgentHarness} assembled over an + * OPFS-backed ZenFS execution environment, with the Anthropic model routed + * through the app's proxy fetch and the app's MCP tools bridged in. + */ + +// Side-effect import — MUST stay first. Installs the Node globals Pi reads at +// module scope (`process`, `global`) before any Pi module evaluates; otherwise the +// browser throws `ReferenceError: process is not defined`. See ./browser-stubs/install-process.ts. +import './browser-stubs/install-process.ts' + +export { + buildAnthropicModel, + isKnownAnthropicModel, + type AgentFetch, + type BuildAnthropicModelOptions, +} from './anthropic-model.ts' +export { buildOpenAiCompatModel, type BuildOpenAiCompatModelOptions } from './openai-compat-model.ts' +export { + buildAppHarness, + workspaceDirFor, + type BuildAppHarnessOptions, + type PiModelDescriptor, +} from './build-app-harness.ts' +export { ensureBufferPolyfill } from './ensure-buffer.ts' +export { toPiAgentTools } from './mcp-tools.ts' +export type { SeedTurn } from './seed-history.ts' +export { piHarnessToUiMessageStream, type AiSdkChunk } from './pi-to-aisdk-stream.ts' +export { BrowserExecutionEnv, mountAgentFs, mountInMemoryFs, type MountedBackend } from './browser-env/index.ts' diff --git a/shared/agent-core/mcp-tools.test.ts b/shared/agent-core/mcp-tools.test.ts new file mode 100644 index 000000000..92bfa4282 --- /dev/null +++ b/shared/agent-core/mcp-tools.test.ts @@ -0,0 +1,284 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Unit tests for {@link toPiAgentTools} — the AI-SDK→Pi tool bridge. + * + * Focus: the two boundaries the module bridges (namespaced-name preservation + + * schema mapping at FFI, and result mapping through `toModelOutput`), plus the + * never-mask error contract (an MCP `{ isError: true }` result must surface as a + * THROW, not a successful tool result) and the streaming-`execute` normalisation. + */ + +import { describe, expect, it } from 'bun:test' +import { jsonSchema, type Tool } from 'ai' +import { z } from 'zod' +import { toPiAgentTools } from './mcp-tools.ts' + +type ToolExecute = NonNullable +type ToModelOutput = NonNullable + +/** Build a minimal AI-SDK `Tool` exposing exactly the fields the bridge reads. */ +const makeTool = (def: { + description?: string + title?: string + inputSchema?: Tool['inputSchema'] + execute?: ToolExecute + toModelOutput?: ToModelOutput +}): Tool => + ({ + inputSchema: def.inputSchema ?? jsonSchema({ type: 'object', properties: {} }), + description: def.description, + title: def.title, + execute: def.execute, + toModelOutput: def.toModelOutput, + }) as unknown as Tool + +/** Drive a single converted tool's `execute` with the Pi calling convention. */ +const runPiTool = ( + tool: { execute: (id: string, params: unknown, signal?: AbortSignal) => unknown }, + params: unknown, +) => tool.execute('call-1', params, undefined) + +describe('toPiAgentTools — schema + name boundary', () => { + it('preserves each namespaced toolset key as the Pi tool name (no re-prefixing) and order', async () => { + const toolset: Record = { + iroh_fs_read: makeTool({ description: 'read a file' }), + github_create_issue: makeTool({ title: 'Create Issue' }), + web_search: makeTool({}), + } + + const pi = await toPiAgentTools(toolset) + + expect(pi.map((t) => t.name)).toEqual(['iroh_fs_read', 'github_create_issue', 'web_search']) + }) + + it('falls back label→name and description→empty-string when absent', async () => { + const pi = await toPiAgentTools({ + iroh_fs_read: makeTool({ description: 'read a file', title: 'Read File' }), + bare_tool: makeTool({}), + }) + + expect(pi[0]).toMatchObject({ name: 'iroh_fs_read', label: 'Read File', description: 'read a file' }) + // No title → label defaults to the (namespaced) name; no description → ''. + expect(pi[1]).toMatchObject({ name: 'bare_tool', label: 'bare_tool', description: '' }) + }) + + it('passes a JSON-Schema tool input through to Pi parameters UNCHANGED', async () => { + const schema = { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } + const [pi] = await toPiAgentTools({ + iroh_fs_read: makeTool({ inputSchema: jsonSchema(schema as Parameters[0]) }), + }) + // Exact equality (not toMatchObject): the bridge must not mutate the schema. + expect(pi.parameters).toEqual(schema) + }) + + it('resolves a Zod (FlexibleSchema) tool input to JSON Schema for Pi parameters', async () => { + const [pi] = await toPiAgentTools({ + iroh_fs_read: makeTool({ inputSchema: z.object({ path: z.string() }) as unknown as Tool['inputSchema'] }), + }) + expect(pi.parameters).toMatchObject({ + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }) + }) +}) + +describe('toPiAgentTools — execute routing + result mapping', () => { + it('throws when the underlying tool has no execute function', async () => { + const [pi] = await toPiAgentTools({ iroh_fs_read: makeTool({}) }) + await expect(runPiTool(pi, {})).rejects.toThrow(/Cannot run tool "iroh_fs_read": it has no execute function/) + }) + + it('passes the namespaced call through to execute and returns content + raw details', async () => { + let seenParams: unknown + let seenOptions: { toolCallId?: string; abortSignal?: AbortSignal } | undefined + const [pi] = await toPiAgentTools({ + github_create_issue: makeTool({ + execute: async (params, options) => { + seenParams = params + seenOptions = options + return { id: 42 } + }, + }), + }) + + const signal = new AbortController().signal + const result = await pi.execute('call-1', { title: 'bug' }, signal) + + expect(seenParams).toEqual({ title: 'bug' }) + // The Pi calling convention is bridged onto the AI-SDK options object. + expect(seenOptions?.toolCallId).toBe('call-1') + expect(seenOptions?.abortSignal).toBe(signal) + // No toModelOutput → JSON dump of the raw output as a single text block. + expect(result).toEqual({ content: [{ type: 'text', text: '{"id":42}' }], details: { id: 42 } }) + }) + + it('coerces an undefined output with no toModelOutput to the string "null"', async () => { + const [pi] = await toPiAgentTools({ t: makeTool({ execute: async () => undefined }) }) + const result = (await runPiTool(pi, {})) as { content: Array<{ type: string; text: string }> } + expect(result.content).toEqual([{ type: 'text', text: 'null' }]) + }) + + it('uses the final chunk of an async-iterable (streaming) execute result', async () => { + async function* stream() { + yield { partial: 1 } + yield { partial: 2 } + yield { final: true } + } + const [pi] = await toPiAgentTools({ t: makeTool({ execute: () => stream() as ReturnType }) }) + + const result = (await runPiTool(pi, {})) as { details: unknown } + expect(result.details).toEqual({ final: true }) + }) + + it('treats an EMPTY async-iterable result as a "null" output (no final chunk)', async () => { + async function* empty() { + // yields nothing + } + const [pi] = await toPiAgentTools({ t: makeTool({ execute: () => empty() as ReturnType }) }) + + const result = (await runPiTool(pi, {})) as { content: Array<{ type: string; text: string }>; details: unknown } + expect(result.details).toBeUndefined() + expect(result.content).toEqual([{ type: 'text', text: 'null' }]) + }) +}) + +describe('toPiAgentTools — toModelOutput content branches', () => { + const convert = async (toModelOutput: ToModelOutput, output: unknown) => { + const [pi] = await toPiAgentTools({ + t: makeTool({ execute: async () => output, toModelOutput }), + }) + return (await runPiTool(pi, {})) as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> + } + } + + it('maps text / error-text to a text block verbatim', async () => { + expect((await convert(() => ({ type: 'text', value: 'hello' }), {})).content).toEqual([ + { type: 'text', text: 'hello' }, + ]) + expect((await convert(() => ({ type: 'error-text', value: 'boom' }), {})).content).toEqual([ + { type: 'text', text: 'boom' }, + ]) + }) + + it('JSON-stringifies json / error-json values into a text block', async () => { + expect((await convert(() => ({ type: 'json', value: { a: 1 } }), {})).content).toEqual([ + { type: 'text', text: '{"a":1}' }, + ]) + expect((await convert(() => ({ type: 'error-json', value: [1, 2] }), {})).content).toEqual([ + { type: 'text', text: '[1,2]' }, + ]) + }) + + it('coerces an unserialisable json value (stringify→undefined) to "null"', async () => { + // JSON.stringify(undefined) === undefined; the ?? 'null' guard must keep it a string. + const denied = await convert(() => ({ type: 'json', value: undefined as unknown as null }), {}) + expect(denied.content).toEqual([{ type: 'text', text: 'null' }]) + }) + + it('maps execution-denied to its reason, falling back ONLY for null/undefined (?? not ||)', async () => { + expect((await convert(() => ({ type: 'execution-denied', reason: 'nope' }), {})).content).toEqual([ + { type: 'text', text: 'nope' }, + ]) + expect((await convert(() => ({ type: 'execution-denied' }), {})).content).toEqual([ + { type: 'text', text: 'Tool execution was denied.' }, + ]) + // `??` (not `||`): an empty-string reason is a real value and must NOT fall back. + expect((await convert(() => ({ type: 'execution-denied', reason: '' }), {})).content).toEqual([ + { type: 'text', text: '' }, + ]) + }) + + it('maps a content array: text→text, image-data/media→image, image file-data→image, non-image file-data→text', async () => { + const result = await convert( + () => ({ + type: 'content', + value: [ + { type: 'text', text: 'caption' }, + { type: 'image-data', data: 'AAA', mediaType: 'image/png' }, + { type: 'media', data: 'BBB', mediaType: 'image/jpeg' }, + { type: 'file-data', data: 'CCC', mediaType: 'image/gif' }, + { type: 'file-data', data: 'DDD', mediaType: 'application/pdf' }, + ], + }), + {}, + ) + + expect(result.content).toEqual([ + { type: 'text', text: 'caption' }, + { type: 'image', data: 'AAA', mimeType: 'image/png' }, + { type: 'image', data: 'BBB', mimeType: 'image/jpeg' }, + { type: 'image', data: 'CCC', mimeType: 'image/gif' }, + { type: 'text', text: '{"type":"file-data","data":"DDD","mediaType":"application/pdf"}' }, + ]) + }) + + it('falls the default content-item branch (e.g. file-url) through to a JSON text dump', async () => { + const result = await convert(() => ({ type: 'content', value: [{ type: 'file-url', url: 'https://x/y.pdf' }] }), {}) + expect(result.content).toEqual([{ type: 'text', text: '{"type":"file-url","url":"https://x/y.pdf"}' }]) + }) +}) + +describe('toPiAgentTools — never-mask MCP error contract', () => { + it('throws (does not return a result) when the raw output is an MCP { isError: true }', async () => { + const [pi] = await toPiAgentTools({ + iroh_fs_read: makeTool({ + // The AI-SDK MCP client RETURNS this rather than throwing; toModelOutput + // drops the isError flag, so the bridge must detect it on the raw output. + execute: async () => ({ isError: true, content: [{ type: 'text', text: 'file not found' }] }), + toModelOutput: () => ({ type: 'error-text', value: 'file not found' }), + }), + }) + + await expect(runPiTool(pi, {})).rejects.toThrow('file not found') + }) + + it('surfaces an image-only error result as a "[image]" placeholder throw', async () => { + const [pi] = await toPiAgentTools({ + iroh_fs_read: makeTool({ + execute: async () => ({ isError: true }), + toModelOutput: () => ({ type: 'content', value: [{ type: 'image-data', data: 'X', mediaType: 'image/png' }] }), + }), + }) + + await expect(runPiTool(pi, {})).rejects.toThrow('[image]') + }) + + it('throws a generic message when the error output yields empty text', async () => { + const [pi] = await toPiAgentTools({ + iroh_fs_read: makeTool({ + execute: async () => ({ isError: true }), + toModelOutput: () => ({ type: 'error-text', value: '' }), + }), + }) + + await expect(runPiTool(pi, {})).rejects.toThrow('Tool "iroh_fs_read" returned an error.') + }) + + it('does NOT throw when isError is falsy — a normal success result flows through', async () => { + const [pi] = await toPiAgentTools({ + t: makeTool({ + execute: async () => ({ isError: false, ok: 1 }), + toModelOutput: () => ({ type: 'text', value: 'done' }), + }), + }) + + await expect(runPiTool(pi, {})).resolves.toMatchObject({ content: [{ type: 'text', text: 'done' }] }) + }) + + it('only treats EXACTLY `isError === true` as an error (truthy non-true does not throw)', async () => { + const [pi] = await toPiAgentTools({ + t: makeTool({ + // A truthy-but-non-boolean isError must NOT be misread as the MCP error flag. + execute: async () => ({ isError: 1, ok: true }), + toModelOutput: () => ({ type: 'text', value: 'fine' }), + }), + }) + + await expect(runPiTool(pi, {})).resolves.toMatchObject({ content: [{ type: 'text', text: 'fine' }] }) + }) +}) diff --git a/shared/agent-core/mcp-tools.ts b/shared/agent-core/mcp-tools.ts new file mode 100644 index 000000000..e7aaa947f --- /dev/null +++ b/shared/agent-core/mcp-tools.ts @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Converts the app's AI-SDK tools — the `Record` produced by + * `src/ai/fetch.ts`'s `mergeMcpTools` (MCP servers' tools, already namespaced + * `_`, plus the app's built-in integration tools) — into Pi + * {@link AgentTool}s so they keep working under the in-browser Pi harness. + * + * Two boundaries are bridged: + * + * - **Schema.** An AI-SDK tool carries its input schema as a `FlexibleSchema` + * (Zod or JSON Schema). Pi types a tool's `parameters` as a TypeBox `TSchema`, + * but its validation layer (`validateToolArguments`) explicitly accepts a + * *plain* JSON Schema object that lacks TypeBox metadata — it compiles such a + * schema directly and applies JSON-Schema coercion — and its Anthropic tool + * serialization reads `.properties`/`.required` straight off the object. So we + * resolve the tool's schema to JSON Schema via `asSchema().jsonSchema` and pass + * it through unchanged; {@link asPiParameters} only re-brands the structural + * type at the FFI boundary. + * + * - **Result.** Pi tools return `{ content, details }` where `content` is a list + * of text/image blocks. We run the AI-SDK tool's `execute` and map its output + * through the tool's own `toModelOutput` (MCP tools define one) into Pi text / + * image content, falling back to a JSON dump when no `toModelOutput` exists. + * + * Errors are intentionally left to throw: Pi's agent loop catches a thrown tool + * error and encodes it as a tool-result error, which is exactly the never-mask + * behaviour we want — no defensive wrapping here. + */ + +import type { AgentTool } from '@earendil-works/pi-agent-core' +import type { ImageContent, TextContent } from '@earendil-works/pi-ai' +import { asSchema, type Tool } from 'ai' + +/** Pi's structural type for a tool's `parameters` (a TypeBox `TSchema`). */ +type PiToolParameters = AgentTool['parameters'] + +/** A text or image block in a Pi tool result. */ +type PiContent = TextContent | ImageContent + +/** + * Re-brand a resolved JSON Schema as Pi's `parameters` type. Pi accepts plain + * JSON Schema objects at runtime (see module docs), so the schema flows through + * unchanged; this only satisfies the structural `TSchema` brand at the boundary. + */ +const asPiParameters = (jsonSchema: object): PiToolParameters => jsonSchema as unknown as PiToolParameters + +/** Render any value as a string for text content (passing strings through). + * `JSON.stringify` returns `undefined` for `undefined`/functions, so coerce that + * to `'null'` (matching the AI SDK) to keep the result a real string. */ +const stringify = (value: unknown): string => (typeof value === 'string' ? value : (JSON.stringify(value) ?? 'null')) + +/** Whether a tool's raw output is an MCP error result (`{ isError: true }`). The + * AI-SDK MCP client returns — rather than throws — such results, and its + * `toModelOutput` drops the flag, so we detect it on the raw output. */ +const isErrorOutput = (output: unknown): boolean => + typeof output === 'object' && output !== null && 'isError' in output && output.isError === true + +/** Whether a value is an async iterable (a streaming tool result). */ +const isAsyncIterable = (value: unknown): value is AsyncIterable => + typeof value === 'object' && value !== null && Symbol.asyncIterator in value + +/** + * Normalise an AI-SDK `execute` return — which may be a value, a promise, or an + * async iterable of progressive outputs — to the single final output. + */ +const resolveExecuteOutput = async (raw: unknown): Promise => { + if (isAsyncIterable(raw)) { + const chunks: unknown[] = [] + for await (const chunk of raw) { + chunks.push(chunk) + } + return chunks.at(-1) + } + return await raw +} + +/** + * Map an AI-SDK tool's output to Pi content. Prefers the tool's `toModelOutput` + * (MCP tools always define one); otherwise dumps the raw output as JSON text. + */ +const toPiContent = async ( + tool: Tool, + args: { toolCallId: string; input: unknown; output: unknown }, +): Promise => { + if (!tool.toModelOutput) { + return [{ type: 'text', text: stringify(args.output) }] + } + const modelOutput = await tool.toModelOutput(args) + switch (modelOutput.type) { + case 'text': + case 'error-text': + return [{ type: 'text', text: modelOutput.value }] + case 'json': + case 'error-json': + return [{ type: 'text', text: stringify(modelOutput.value) }] + case 'execution-denied': + return [{ type: 'text', text: modelOutput.reason ?? 'Tool execution was denied.' }] + case 'content': + return modelOutput.value.map((item): PiContent => { + switch (item.type) { + case 'text': + return { type: 'text', text: item.text } + case 'image-data': + case 'media': + return { type: 'image', data: item.data, mimeType: item.mediaType } + case 'file-data': + return item.mediaType.startsWith('image/') + ? { type: 'image', data: item.data, mimeType: item.mediaType } + : { type: 'text', text: stringify(item) } + default: + return { type: 'text', text: stringify(item) } + } + }) + } +} + +/** + * Convert a single AI-SDK tool into a Pi {@link AgentTool}. The schema is resolved + * eagerly (Pi reads `parameters` synchronously when building the provider request). + */ +const toPiAgentTool = async (name: string, tool: Tool): Promise => { + const jsonSchema = await Promise.resolve(asSchema(tool.inputSchema).jsonSchema) + return { + name, + label: tool.title ?? name, + description: tool.description ?? '', + parameters: asPiParameters(jsonSchema), + execute: async (toolCallId, params, signal) => { + const { execute } = tool + if (!execute) { + throw new Error(`Cannot run tool "${name}": it has no execute function.`) + } + const output = await resolveExecuteOutput(execute(params, { toolCallId, messages: [], abortSignal: signal })) + const content = await toPiContent(tool, { toolCallId, input: params, output }) + if (isErrorOutput(output)) { + // Pi tools signal failure by throwing (its loop encodes that as an error + // tool result, mirroring Anthropic's `is_error`). Surface the MCP error + // text as a throw rather than masking it as a successful result. + const text = content.map((part) => (part.type === 'text' ? part.text : '[image]')).join('\n') + throw new Error(text || `Tool "${name}" returned an error.`) + } + return { content, details: output } + }, + } +} + +/** + * Convert an AI-SDK toolset (e.g. the merged MCP + integration tools) into Pi + * {@link AgentTool}s, preserving each tool's namespaced name as the Pi tool name. + * + * @param toolset - the app's `Record` (keys are namespaced tool names) + * @returns the equivalent Pi agent tools, ready to pass to `buildAppHarness({ tools })` + */ +export const toPiAgentTools = async (toolset: Record): Promise => + Promise.all(Object.entries(toolset).map(([name, tool]) => toPiAgentTool(name, tool))) diff --git a/shared/agent-core/openai-compat-model.test.ts b/shared/agent-core/openai-compat-model.test.ts new file mode 100644 index 000000000..23d981e00 --- /dev/null +++ b/shared/agent-core/openai-compat-model.test.ts @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Regression guard for {@link buildOpenAiCompatModel}'s `withInjectedFetch` swap. + * + * The provider has no `fetch?` seam, so it routes every request through the app's + * proxy fetch by SYNCHRONOUSLY swapping `globalThis.fetch` for the window in which + * Pi's openai-completions API constructs its OpenAI SDK client (which captures the + * global `fetch` at construction). This test pins that contract end to end: + * + * - it sets `globalThis.fetch` to a SENTINEL that must never be hit, and + * - hands `buildOpenAiCompatModel` a distinct injected fetch, + * + * then drives a real `streamSimple` and asserts the HTTP went through the INJECTED + * fetch while the sentinel stayed untouched, and that the global was restored + * synchronously. If a future `openai`/`@earendil-works/pi-ai` bump makes fetch + * resolution lazy (read at request time instead of captured at construction), the + * request would fall through to the sentinel and this test fails loudly — catching + * a silent proxy bypass that would otherwise leak requests past the CORS proxy. + */ + +import { afterEach, describe, expect, it } from 'bun:test' +import type { Context } from '@earendil-works/pi-ai' +import { buildOpenAiCompatModel } from './openai-compat-model.ts' + +/** A minimal, well-formed OpenAI Chat Completions SSE stream: one content delta + * then a stop, so the openai SDK parses a clean response rather than erroring. */ +const sseBody = [ + 'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hi"},"finish_reason":null}]}', + '', + 'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', + '', + 'data: [DONE]', + '', + '', +].join('\n') + +const makeSseResponse = (): Response => + new Response(sseBody, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + +const context: Context = { messages: [{ role: 'user', content: 'hi', timestamp: 0 }] } + +const urlOf = (input: RequestInfo | URL): string => + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + +describe('buildOpenAiCompatModel — withInjectedFetch', () => { + const originalFetch = globalThis.fetch + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('routes HTTP through the synchronously-injected fetch the SDK captured, not the global', async () => { + let injectedCalls = 0 + let injectedUrl = '' + const injectedFetch = async (input: RequestInfo | URL): Promise => { + injectedCalls += 1 + injectedUrl = urlOf(input) + return makeSseResponse() + } + + let sentinelCalls = 0 + const sentinel = (async () => { + sentinelCalls += 1 + return new Response('', { status: 500 }) + }) as unknown as typeof globalThis.fetch + globalThis.fetch = sentinel + + const { models, model } = buildOpenAiCompatModel({ + providerId: 'openai', + modelId: 'gpt-test', + baseURL: 'https://upstream.example/v1', + apiKey: 'test-key', + fetch: injectedFetch, + reasoning: false, + }) + + const provider = models.getProvider('openai') + if (!provider) { + throw new Error('expected the openai provider to be registered') + } + + // The OpenAI client is constructed synchronously inside this call, inside the + // swap window — so the moment it returns, the global must already be restored. + const stream = provider.streamSimple(model, context) + expect(globalThis.fetch).toBe(sentinel) + + // The request itself fires lazily on iteration (global is the sentinel by now); + // the captured injected fetch must still be what serves it. + try { + for await (const event of stream) { + void event + } + } catch { + // A parse hiccup doesn't matter — the fetch-routing assertions below are the contract. + } + + expect(injectedCalls).toBe(1) + expect(injectedUrl).toContain('upstream.example') + expect(sentinelCalls).toBe(0) + }) +}) diff --git a/shared/agent-core/openai-compat-model.ts b/shared/agent-core/openai-compat-model.ts new file mode 100644 index 000000000..3722d2953 --- /dev/null +++ b/shared/agent-core/openai-compat-model.ts @@ -0,0 +1,184 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Builds a Pi-compatible OpenAI-completions model whose HTTP goes through an + * injected `fetch`. This is the OpenAI-family analogue of + * {@link buildAnthropicModel}: it serves every provider the app talks to over the + * OpenAI Chat Completions wire — `openai`, `custom`, `openrouter`, and + * `thunderbolt` (the backend proxy = openai-compatible against `cloudUrl`). + * + * Unlike the anthropic API, Pi's `openai-completions` provider exposes NO public + * `client?`/`fetch?` seam: its `stream`/`streamSimple` construct the `openai` SDK + * client internally via `new OpenAI({ apiKey, baseURL, defaultHeaders })` with no + * `fetch` override, so the SDK resolves its fetch from `getDefaultFetch()` — the + * global `fetch`. We therefore inject the app's proxy fetch by SYNCHRONOUSLY + * swapping `globalThis.fetch` for the exact window in which Pi constructs that + * client (see {@link withInjectedFetch}), then restoring it. + * + * Why the swap is race-free: + * - Pi's `stream()` runs an async IIFE whose SYNCHRONOUS prefix builds the + * OpenAI client (`createClient`) before the first `await`; `streamSimple()` + * calls `stream()` synchronously. The SDK captures `this.fetch` at + * construction, so the client is bound to our fetch inside the synchronous + * body of our wrapper. + * - JS is single-threaded and our swap window contains NO `await`, so no other + * code can observe the swapped global; we restore it in a `finally`. The + * captured fetch is what the SDK uses for the (later, async) HTTP, so the + * request flows through our fetch even after the global is restored. + * + * Documented reliances (re-verify on `@earendil-works/pi-ai` / `openai` bumps): + * 1. openai-completions constructs its SDK client synchronously, before any + * `await`, inside `stream()`/`streamSimple()`. + * 2. The `openai` SDK reads the global `fetch` via `getDefaultFetch()` when no + * `fetch` is passed to its constructor. + * + * Auth: the synthetic model carries `reasoning` (whether to request a reasoning + * effort at all). The provider's advisory `auth` mirrors anthropic; the real + * api key rides on the per-call options ({@link buildOpenAiCompatModel}'s `api` + * injects `opts.apiKey`), since Pi's openai client reads `options.apiKey`. + */ + +import { + type Api, + type Model, + type Models, + type ProviderStreams, + createModels, + createProvider, + envApiKeyAuth, + hasApi, +} from '@earendil-works/pi-ai' +import { + stream as openaiStream, + streamSimple as openaiStreamSimple, +} from '@earendil-works/pi-ai/api/openai-completions' +import type { AgentFetch } from './anthropic-model.ts' + +/** The Pi API this provider exclusively serves. */ +const API = 'openai-completions' + +/** Context window used when the app model carries none. Only consumed by Pi's + * token-budget math (irrelevant to openai-completions, which never caps tokens + * unless the caller passes `maxTokens`), so a generous default is harmless. */ +const DEFAULT_CONTEXT_WINDOW = 128_000 +/** Advisory max-output budget on the synthetic model. openai-completions only + * sends `max_completion_tokens` when the caller sets `options.maxTokens` (the + * harness does not), so this value never reaches the wire — it exists solely to + * satisfy the `Model` shape. */ +const DEFAULT_MAX_TOKENS = 8_192 + +/** Inputs for {@link buildOpenAiCompatModel}. */ +export type BuildOpenAiCompatModelOptions = { + /** Pi provider id; must equal the synthetic model's `provider` so the + * `MutableModels` dispatch resolves this provider. Carries the app provider + * name (`openai` | `custom` | `openrouter` | `thunderbolt`). */ + readonly providerId: string + /** Upstream model id sent on the wire, e.g. `opus-4.8` or `gpt-5`. */ + readonly modelId: string + /** OpenAI-compatible base URL (e.g. `cloudUrl`, `https://openrouter.ai/api/v1`). */ + readonly baseURL: string + /** Bearer key handed to the OpenAI SDK (may be a placeholder when the injected + * fetch supplies auth itself, e.g. thunderbolt SSO). */ + readonly apiKey: string + /** Fetch every request is routed through — the provider-specific app fetch + * (proxy fetch, or the SSO-aware fetch for thunderbolt). */ + readonly fetch: AgentFetch + /** Whether the model should request a reasoning effort. When false, Pi clamps + * any thinking level to `off` and sends no `reasoning_effort`. */ + readonly reasoning: boolean + /** Optional upstream context window for the synthetic model descriptor. */ + readonly contextWindow?: number +} + +/** + * Synchronously route `globalThis.fetch` through `fetchImpl` for the duration of + * `run()` — the window in which Pi constructs the OpenAI SDK client (which + * captures the global fetch). Restores the original fetch unconditionally. + * + * The window spans Pi's synchronous prefix up to its first `await` (which builds + * the client and then evaluates the `onPayload` hook). It contains NO `await`, so + * it is race-free (see the module header). The only app code that can run in it is + * the *synchronous* prefix of a harness `onPayload`/`onResponse` listener; the + * built-in harness registers none that issue a fetch there, so nothing observes + * the swapped global before it is restored. + */ +const withInjectedFetch = (fetchImpl: AgentFetch, run: () => T): T => { + const original = globalThis.fetch + globalThis.fetch = fetchImpl as unknown as typeof globalThis.fetch + try { + return run() + } finally { + globalThis.fetch = original + } +} + +/** + * Narrows a dispatched `Model` to the openai-completions model this provider + * exclusively serves, surfacing misuse loudly rather than guessing. Mirrors + * {@link buildAnthropicModel}'s `requireAnthropic`. + */ +const requireOpenAiCompletions = (model: Model): Model => { + if (!hasApi(model, API)) { + throw new Error(`Expected an "${API}" model, got "${model.api}".`) + } + return model +} + +/** Synthesize the Pi `Model<"openai-completions">` descriptor. The app's models + * live outside Pi's built-in catalog (custom URLs, backend-proxied ids), so we + * build the descriptor directly rather than resolving it. */ +const synthesizeModel = (opts: BuildOpenAiCompatModelOptions): Model => ({ + id: opts.modelId, + name: opts.modelId, + api: API, + provider: opts.providerId, + baseUrl: opts.baseURL, + reasoning: opts.reasoning, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: opts.contextWindow ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, +}) + +/** + * Resolves an OpenAI-compatible model and wires it through a provider whose HTTP + * flows through `opts.fetch`. Drop-in sibling of {@link buildAnthropicModel}: + * returns the same `{ models, model }` shape the harness consumes. + * + * @param opts - provider id, model id, base URL, api key, injected fetch, reasoning flag + * @returns the wired provider collection and the synthetic model + */ +export const buildOpenAiCompatModel = (opts: BuildOpenAiCompatModelOptions): { models: Models; model: Model } => { + const model = synthesizeModel(opts) + + // Inject the api key on every call (Pi's openai client reads `options.apiKey`) + // and bind the fetch only around client construction via the synchronous swap. + const api: ProviderStreams = { + stream: (resolved, context, options) => + withInjectedFetch(opts.fetch, () => + openaiStream(requireOpenAiCompletions(resolved), context, { ...options, apiKey: opts.apiKey }), + ), + streamSimple: (resolved, context, options) => + withInjectedFetch(opts.fetch, () => + openaiStreamSimple(requireOpenAiCompletions(resolved), context, { ...options, apiKey: opts.apiKey }), + ), + } + + const models = createModels() + models.setProvider( + createProvider({ + id: opts.providerId, + name: opts.providerId, + baseUrl: opts.baseURL, + // Advisory only: the real credential rides on the per-call options above. + // An empty env list makes resolution a graceful no-op in the browser. + auth: { apiKey: envApiKeyAuth(`${opts.providerId} API key`, []) }, + models: [model], + api, + }), + ) + + return { models, model } +} diff --git a/shared/agent-core/pi-to-aisdk-stream.ts b/shared/agent-core/pi-to-aisdk-stream.ts new file mode 100644 index 000000000..ce9743440 --- /dev/null +++ b/shared/agent-core/pi-to-aisdk-stream.ts @@ -0,0 +1,371 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Translator: Pi {@link AgentHarness} run → AI SDK v5 UI message stream. + * + * This is the in-browser analogue of `src/acp/translators/acp-to-ai-sdk.ts`, + * which does the same job for an ACP `session/update` stream. The output + * `ReadableStream` matches what `createUIMessageStreamResponse` + * produces — each chunk is a UTF-8 `data: \n\n` line — so the app's chat + * transport consumes it directly from a `Response.body`. + * + * Event mapping (Pi `AgentHarnessEvent` → AI SDK chunk): + * agent_start → start + * turn_start → start-step + * message_update (text_delta) → text-start (once) → text-delta… + * message_update (thinking_delta) → reasoning-start (once) → reasoning-delta… + * message_end → close any open text/reasoning part + * tool_execution_start → tool-input-start + tool-input-available + * tool_execution_end → tool-output-available | tool-output-error + * turn_end (stopReason === 'error') → error (carrying message.errorMessage) + * turn_end → finish-step + * agent_end → finish + * + * Reasoning boundaries are synthesized from deltas, not from Pi's explicit + * `thinking_start`/`text_start` content events: reasoning opens on the first + * `thinking_delta` and closes the moment the first `text_delta` arrives. This + * mirrors the app's "reasoning duration ends when text begins" semantic and + * keeps the translator robust to providers that omit the explicit boundaries. + * + * Durations are emitted as `message-metadata` `reasoningTime` entries keyed to + * match `groupMessageParts`: reasoning by its 0-based ordinal (`reasoning-`) + * and tools by their `toolCallId`. The chat store deep-merges these chunks, so + * one entry per part accumulates rather than overwrites. + */ + +import type { AgentEvent, AgentHarness, AgentHarnessEvent } from '@earendil-works/pi-agent-core' + +const encoder = new TextEncoder() +const now = Date.now + +/** The `assistantMessageEvent` carried by a Pi `message_update` event. */ +type AssistantInnerEvent = Extract['assistantMessageEvent'] + +/** + * Minimal AI SDK v5 UI message stream chunk shapes this translator emits. Mirrors + * `src/acp/types.ts` (which itself mirrors `ai`'s `UIMessageChunk`), declaring + * only the variants we produce so an upstream change surfaces as a compile error. + * Re-declared here because `shared/` must not depend on app (`@/`) code. + */ +export type AiSdkChunk = + | { type: 'start'; messageId?: string } + | { type: 'start-step' } + | { type: 'text-start'; id: string } + | { type: 'text-delta'; id: string; delta: string } + | { type: 'text-end'; id: string } + | { type: 'reasoning-start'; id: string } + | { type: 'reasoning-delta'; id: string; delta: string } + | { type: 'reasoning-end'; id: string } + | { type: 'tool-input-start'; toolCallId: string; toolName: string; title?: string } + | { type: 'tool-input-available'; toolCallId: string; toolName: string; input: unknown; title?: string } + | { type: 'tool-output-available'; toolCallId: string; output: unknown } + | { type: 'tool-output-error'; toolCallId: string; errorText: string } + | { type: 'message-metadata'; messageMetadata: Record } + | { type: 'finish-step' } + | { type: 'finish' } + | { type: 'error'; errorText: string } + +/** Open reasoning part: its stream `id`, the `reasoningTime` key the UI expects, + * and the start timestamp used to compute its duration. */ +type ReasoningPart = { id: string; durationKey: string; startedAt: number } + +const encodeChunk = (chunk: AiSdkChunk): Uint8Array => encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`) +const encodeDone = (): Uint8Array => encoder.encode('data: [DONE]\n\n') + +/** Narrows an unknown tool-result content block to one carrying text. */ +const isTextContent = (block: unknown): block is { text: string } => + typeof block === 'object' && block !== null && typeof (block as { text?: unknown }).text === 'string' + +/** + * Derives a human error message from a Pi tool result by concatenating its text + * content blocks, falling back to compact JSON when none are present. + * + * @param result - the `AgentToolResult` from a `tool_execution_end` event + * @returns the joined error text (never empty) + */ +const toolErrorText = (result: unknown): string => { + if (typeof result === 'object' && result !== null) { + const content = (result as { content?: unknown }).content + if (Array.isArray(content)) { + const text = content + .filter(isTextContent) + .map((block) => block.text) + .join('') + .trim() + if (text.length > 0) { + return text + } + } + } + return JSON.stringify(result ?? {}) +} + +/** Stateful per-run translator. `handle` ingests harness events; `finish` + * guarantees a well-formed terminal sequence even when the run never reaches + * `agent_end` (e.g. the harness rejects before the loop starts). */ +type PiTranslator = { + /** Translate one harness event into zero or more emitted chunks. */ + handle: (event: AgentHarnessEvent) => void + /** Close any open parts and emit the terminal `finish`. With `errorText`, + * an `error` chunk is emitted first. Idempotent once the stream has finished. */ + finish: (errorText?: string) => void +} + +/** + * Builds a stateful translator that writes AI SDK chunks into `emit`. Splitting + * state from sinking lets the stream wrapper enqueue into a controller while a + * test could collect into an array. + * + * @param emit - sink invoked once per produced chunk + * @returns the translator's `handle`/`finish` surface + */ +const createPiTranslator = (emit: (chunk: AiSdkChunk) => void): PiTranslator => { + let started = false + let stepOpen = false + let finished = false + let reasoning: ReasoningPart | null = null + let text: { id: string } | null = null + // Run-global ordinals: part ids must be unique across the whole run, and the + // reasoning ordinal must match `groupMessageParts`' per-message counter. + let reasoningOrdinal = 0 + let textOrdinal = 0 + const toolStartTimes = new Map() + + const emitDuration = (key: string, elapsedMs: number): void => { + emit({ type: 'message-metadata', messageMetadata: { reasoningTime: { [key]: elapsedMs } } }) + } + + const closeReasoning = (): void => { + if (!reasoning) { + return + } + emit({ type: 'reasoning-end', id: reasoning.id }) + emitDuration(reasoning.durationKey, now() - reasoning.startedAt) + reasoning = null + } + + const closeText = (): void => { + if (!text) { + return + } + emit({ type: 'text-end', id: text.id }) + text = null + } + + const closeOpenParts = (): void => { + closeReasoning() + closeText() + } + + const ensureReasoningOpen = (): string => { + if (reasoning) { + return reasoning.id + } + // A new reasoning block means any prior text block has ended; keep parts + // non-overlapping so the chat store assembles them cleanly. + closeText() + const ordinal = reasoningOrdinal++ + reasoning = { id: `pi-reasoning-${ordinal}`, durationKey: `reasoning-${ordinal}`, startedAt: now() } + emit({ type: 'reasoning-start', id: reasoning.id }) + return reasoning.id + } + + const ensureTextOpen = (): string => { + if (text) { + return text.id + } + text = { id: `pi-text-${textOrdinal++}` } + emit({ type: 'text-start', id: text.id }) + return text.id + } + + const handleInner = (inner: AssistantInnerEvent): void => { + if (inner.type === 'thinking_delta') { + emit({ type: 'reasoning-delta', id: ensureReasoningOpen(), delta: inner.delta }) + return + } + if (inner.type === 'text_delta') { + closeReasoning() + emit({ type: 'text-delta', id: ensureTextOpen(), delta: inner.delta }) + } + } + + // Terminal sequence shared by `agent_end`, normal settlement, and the + // reject/abort backstop. Guarded by `finished` so it runs exactly once. + const finish = (errorText?: string): void => { + if (finished) { + return + } + if (!started) { + emit({ type: 'start' }) + started = true + } + closeOpenParts() + if (errorText !== undefined) { + emit({ type: 'error', errorText }) + } + if (stepOpen) { + emit({ type: 'finish-step' }) + stepOpen = false + } + emit({ type: 'finish' }) + finished = true + } + + const handle = (event: AgentHarnessEvent): void => { + switch (event.type) { + case 'agent_start': { + if (!started) { + emit({ type: 'start' }) + started = true + } + return + } + case 'turn_start': { + emit({ type: 'start-step' }) + stepOpen = true + return + } + case 'message_update': { + handleInner(event.assistantMessageEvent) + return + } + case 'message_end': { + closeOpenParts() + return + } + case 'tool_execution_start': { + toolStartTimes.set(event.toolCallId, now()) + const input: unknown = event.args ?? {} + emit({ + type: 'tool-input-start', + toolCallId: event.toolCallId, + toolName: event.toolName, + title: event.toolName, + }) + emit({ + type: 'tool-input-available', + toolCallId: event.toolCallId, + toolName: event.toolName, + input, + title: event.toolName, + }) + return + } + case 'tool_execution_end': { + if (event.isError) { + emit({ type: 'tool-output-error', toolCallId: event.toolCallId, errorText: toolErrorText(event.result) }) + } else { + emit({ type: 'tool-output-available', toolCallId: event.toolCallId, output: event.result as unknown }) + } + const startedAt = toolStartTimes.get(event.toolCallId) + if (startedAt !== undefined) { + toolStartTimes.delete(event.toolCallId) + emitDuration(event.toolCallId, now() - startedAt) + } + return + } + case 'turn_end': { + closeOpenParts() + const { message } = event + if ('stopReason' in message && message.stopReason === 'error') { + emit({ type: 'error', errorText: message.errorMessage ?? 'the request failed' }) + } + if (stepOpen) { + emit({ type: 'finish-step' }) + stepOpen = false + } + return + } + case 'agent_end': { + finish() + return + } + // Every other harness event (queue_update, tool_call/tool_result hooks, + // model/tools/resources updates, etc.) is irrelevant to the UI stream. + default: + return + } + } + + return { handle, finish } +} + +/** + * Subscribes to a Pi {@link AgentHarness} run and produces the AI SDK UI message + * stream the app's chat transport expects. The harness's own lifecycle events + * drive every chunk; `runPrompt` only *initiates* the run (and its settlement is + * the backstop that closes the stream), so the caller stays free to start the + * run however it likes — a plain prompt, a skill, or a template invocation: + * + * ```ts + * new Response( + * piHarnessToUiMessageStream(harness, async () => { + * await harness.prompt(text) + * await harness.waitForIdle() + * }), + * { headers: { 'Content-Type': 'text/event-stream' } }, + * ) + * ``` + * + * The stream is purely streaming — chunks are enqueued as events arrive, never + * buffered for the whole run. Cancelling the consumer (e.g. the user stops + * generation) aborts the harness and unsubscribes. + * + * @param harness - the harness whose run should be streamed + * @param runPrompt - thunk that starts the run and resolves once it settles + * @returns a `ReadableStream` of SSE-encoded AI SDK chunks for the `Response` body + */ +export const piHarnessToUiMessageStream = ( + harness: AgentHarness, + runPrompt: () => Promise, +): ReadableStream => { + let unsubscribe: (() => void) | null = null + let closed = false + + return new ReadableStream({ + start(controller) { + const translator = createPiTranslator((chunk) => { + if (!closed) { + controller.enqueue(encodeChunk(chunk)) + } + }) + unsubscribe = harness.subscribe((event) => translator.handle(event)) + + const finalize = (errorText?: string): void => { + if (closed) { + return + } + // Unsubscribe first so no further harness event races the terminal + // sequence, let `finish` emit its chunks while the gate is still open, + // and only then close the gate against any straggler from `cancel`. + unsubscribe?.() + unsubscribe = null + translator.finish(errorText) + closed = true + controller.enqueue(encodeDone()) + controller.close() + } + + // Drive the run off this start callback. A failed turn resolves with a + // `stopReason: 'error'` message (surfaced via `turn_end`), so a rejection + // here is an exceptional harness failure — surface it as a terminal error. + void (async () => { + try { + await runPrompt() + finalize() + } catch (error) { + finalize(error instanceof Error ? error.message : String(error)) + } + })() + }, + async cancel() { + closed = true + unsubscribe?.() + unsubscribe = null + await harness.abort() + }, + }) +} diff --git a/shared/agent-core/seed-history.ts b/shared/agent-core/seed-history.ts new file mode 100644 index 000000000..3c1f5127b --- /dev/null +++ b/shared/agent-core/seed-history.ts @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Maps a thread's prior conversation turns into Pi {@link AgentMessage}s so a + * freshly built harness starts with that history seeded into its session + * (`harness.appendMessage`). Without it the harness is prompted with only the + * latest user turn and the agent forgets everything said before — every turn + * would start from a blank transcript. + * + * Only the *text* of each prior turn is carried across: tool calls/results and + * images from earlier turns are intentionally dropped. Faithfully rebuilding + * Pi's `tool_use`/`tool_result` pairing (matched ids, schemas) from the AI-SDK UI + * message shape is fragile and unnecessary for conversational context — the agent + * re-runs any tool it still needs. Prior assistant turns are rebuilt via Pi's + * exported `fauxAssistantMessage` helper, the supported way to synthesize a + * well-formed `AssistantMessage` without hand-fabricating its usage/provider + * metadata (none of which is sent on the wire for a historical turn anyway). + */ + +import type { AgentMessage } from '@earendil-works/pi-agent-core' +import { fauxAssistantMessage } from '@earendil-works/pi-ai' + +/** A prior conversation turn reduced to its role and concatenated text. The app + * derives these from its `ThunderboltUIMessage[]` using app types only, so it can + * build the seed list without statically importing (and bundling) the engine. */ +export type SeedTurn = { + readonly role: 'user' | 'assistant' + readonly text: string +} + +/** + * Build the Pi {@link AgentMessage}s for a list of prior turns, ready to seed into + * a harness session in order. Empty-text turns are skipped so the transcript never + * carries a content-less message (e.g. an assistant turn that only ran tools). + * + * @param turns - prior conversation turns (role + text); omitted/all-empty yields `[]` + * @returns the equivalent Pi agent messages, in order + */ +export const buildSeedMessages = (turns: readonly SeedTurn[] = []): AgentMessage[] => + turns + .filter((turn) => turn.text.length > 0) + .map( + (turn): AgentMessage => + turn.role === 'user' + ? { role: 'user', content: turn.text, timestamp: Date.now() } + : fauxAssistantMessage(turn.text), + ) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 24b25cdec..3a8711458 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5420,11 +5420,14 @@ name = "thunderbolt" version = "0.1.106" dependencies = [ "anyhow", + "hex", "objc2-foundation", "objc2-ui-kit", + "reqwest 0.12.28", "rustls", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-deep-link", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0061d0853..597691ad8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,6 +42,13 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.150" anyhow = "1.0.99" tokio = "1.52.3" + +# CLI installer: download the prebuilt `thunderbolt` binary + SHA256SUMS and +# verify the checksum before install. rustls (no OpenSSL) matches the rest of the +# app's TLS stack; all three are already transitive deps so no new versions land. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +sha2 = "0.10" +hex = "0.4" tauri-plugin-devtools = "2.0.1" tauri-plugin-http = "2.5.9" tauri-plugin-process = "2.3.1" diff --git a/src-tauri/src/cli_installer.rs b/src-tauri/src/cli_installer.rs new file mode 100644 index 000000000..0926d2a3c --- /dev/null +++ b/src-tauri/src/cli_installer.rs @@ -0,0 +1,367 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! One-click install of the standalone `thunderbolt` CLI. +//! +//! The desktop app derives per-release download URLs from its own version and the +//! scheme the CLI release pipeline defines (`.github/workflows/cli-release.yml`): +//! +//! ```text +//! https://github.com/thunderbird/thunderbolt/releases/download/v{version}/thunderbolt-{target} +//! https://github.com/thunderbird/thunderbolt/releases/download/v{version}/SHA256SUMS +//! ``` +//! +//! It downloads the binary and the `SHA256SUMS` manifest, checks the binary's +//! digest against the manifest before touching the filesystem (hard-fail on +//! mismatch), then installs it into `~/.local/bin/thunderbolt` via an atomic +//! rename. Every failure is a typed [`CliInstallError`] the UI renders honestly — +//! in particular a release predating the CLI pipeline yields +//! [`CliInstallError::NotPublished`], never a silent fallback. +//! +//! **What the checksum does and does not guarantee.** The binary and `SHA256SUMS` +//! are fetched from the same release host over the same TLS channel, so the digest +//! check only catches transport corruption and tampering *within* the manifest — it +//! adds no integrity against a compromised release host, since whoever can swap the +//! binary can swap its recorded digest too. There is no code signature, and on macOS +//! [`strip_quarantine`] removes the download quarantine so Gatekeeper never assesses +//! the (unsigned) binary. A detached signature (minisign) over the manifest, verified +//! against a pinned public key, is the known follow-up hardening. + +use serde::Serialize; +use std::path::PathBuf; + +/// Where prebuilt CLI binaries and their checksum manifest live. +const RELEASE_BASE: &str = "https://github.com/thunderbird/thunderbolt/releases/download"; + +/// Typed install failure. Each variant maps to a distinct UI message; `Unsupported` +/// and `NotPublished` additionally drive the "build from source instead" fallback. +#[derive(Debug, Serialize)] +#[serde(tag = "kind", content = "message", rename_all = "camelCase")] +pub enum CliInstallError { + /// This platform has no prebuilt binary (Intel macOS, Windows, mobile). + Unsupported(String), + /// The release or its CLI assets don't exist yet — e.g. a build predating the + /// CLI release pipeline. Nothing to retry; it's simply not published. + NotPublished(String), + /// The download failed (network, TLS, or an unexpected HTTP status). + Download(String), + /// The downloaded binary's SHA-256 didn't match the manifest — install aborted. + ChecksumMismatch(String), + /// Writing the verified binary into place failed. + Install(String), +} + +impl From for CliInstallError { + fn from(err: std::io::Error) -> Self { + CliInstallError::Install(err.to_string()) + } +} + +/// A successful install, as rendered by the settings UI. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliInstallResult { + /// Absolute path the binary was installed to (`~/.local/bin/thunderbolt`). + pub path: String, + /// Whether `~/.local/bin` is already on the user's `PATH`. + pub on_path: bool, + /// Shell line that puts the install dir on `PATH`; present only when + /// `on_path` is false so the UI can show the one-liner the user still needs. + pub path_hint: Option, +} + +/// The release asset slug for `(os, arch)`, or `None` on platforms with no +/// prebuilt binary. Mirrors the `cli-release.yml` build matrix exactly: only +/// `darwin-arm64`, `linux-x64` and `linux-arm64` are published — Intel macOS has +/// no iroh addon, and Windows/mobile aren't built. +fn resolve_target(os: &str, arch: &str) -> Option<&'static str> { + match (os, arch) { + ("macos", "aarch64") => Some("darwin-arm64"), + ("linux", "x86_64") => Some("linux-x64"), + ("linux", "aarch64") => Some("linux-arm64"), + _ => None, + } +} + +/// The release asset filename for a target (matches the `SHA256SUMS` entries). +fn asset_name(target: &str) -> String { + format!("thunderbolt-{target}") +} + +fn binary_url(version: &str, target: &str) -> String { + format!("{RELEASE_BASE}/v{version}/thunderbolt-{target}") +} + +fn checksums_url(version: &str) -> String { + format!("{RELEASE_BASE}/v{version}/SHA256SUMS") +} + +/// Extract the expected lowercase hex digest for `asset` from a `sha256sum`-format +/// manifest (` `, two spaces in text mode or ` *` in binary mode). +/// Returns `None` when no line names the asset exactly. +fn expected_sha_for(manifest: &str, asset: &str) -> Option { + manifest.lines().find_map(|line| { + let (hash, name) = line.split_once(char::is_whitespace)?; + let name = name.trim_start_matches([' ', '*']); + (name == asset).then(|| hash.to_ascii_lowercase()) + }) +} + +/// Lowercase hex SHA-256 of `bytes`. +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +/// Whether `dir` appears in a `:`-separated `PATH` string (trailing slashes ignored). +fn is_dir_on_path(path_var: &str, dir: &str) -> bool { + fn normalize(s: &str) -> &str { + s.trim_end_matches('/') + } + let target = normalize(dir); + path_var.split(':').any(|entry| normalize(entry) == target) +} + +/// `~/.local/bin` — the XDG-conventional per-user binary dir. +fn local_bin_dir() -> Result { + let home = std::env::var_os("HOME").ok_or_else(|| { + CliInstallError::Install("HOME environment variable is not set".to_string()) + })?; + Ok(PathBuf::from(home).join(".local").join("bin")) +} + +/// Write `bytes` to `~/.local/bin/thunderbolt` with mode `0755` via a same-dir temp +/// file plus atomic rename, so a partial write never leaves a broken binary in +/// place. Returns the final installed path. +fn install_binary(bytes: &[u8]) -> Result { + let dir = local_bin_dir()?; + std::fs::create_dir_all(&dir)?; + + let final_path = dir.join("thunderbolt"); + // Same-dir temp keeps the rename atomic (both ends on one filesystem) and + // pid-scoped so concurrent installs can't clobber each other's temp. + let tmp_path = dir.join(format!(".thunderbolt.{}.tmp", std::process::id())); + + std::fs::write(&tmp_path, bytes)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?; + } + + std::fs::rename(&tmp_path, &final_path)?; + Ok(final_path) +} + +/// Remove the download-quarantine xattr macOS applies to files fetched by +/// sandboxed apps, so Gatekeeper won't block the unsigned CLI. We write the file +/// ourselves so it usually has no such attribute — a missing attr is not an error. +#[cfg(target_os = "macos")] +fn strip_quarantine(path: &std::path::Path) { + let _ = std::process::Command::new("/usr/bin/xattr") + .args(["-d", "com.apple.quarantine"]) + .arg(path) + .output(); +} + +/// GET `url`, mapping a 404 to [`CliInstallError::NotPublished`] (release/asset +/// absent) and any other non-success status or transport error to +/// [`CliInstallError::Download`]. +async fn fetch(client: &reqwest::Client, url: &str) -> Result { + let response = client + .get(url) + .send() + .await + .map_err(|e| CliInstallError::Download(e.to_string()))?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(CliInstallError::NotPublished( + "This release has no prebuilt Thunderbolt CLI yet. Build it from source instead." + .to_string(), + )); + } + if !response.status().is_success() { + return Err(CliInstallError::Download(format!( + "Unexpected HTTP status {} downloading {url}", + response.status() + ))); + } + Ok(response) +} + +/// Download, verify and install the CLI for the given app `version`. See the +/// module docs for the URL scheme and verification contract. +pub async fn install_cli(version: &str) -> Result { + let target = resolve_target(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| { + CliInstallError::Unsupported( + "No prebuilt Thunderbolt CLI is published for this platform. Build it from source instead." + .to_string(), + ) + })?; + + // reqwest's rustls stack needs a process-default crypto provider. Install the + // same aws-lc-rs provider the rest of the app uses — idempotent, so this is a + // no-op if a provider is already set. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + // `connect_timeout` bounds only the TCP/TLS handshake; `timeout` is the overall + // per-request deadline covering the body transfer, so a host that accepts the + // connection then stalls mid-download surfaces as `Download` instead of hanging + // the one-click-install spinner forever. Sized generously (10 min) since the + // binary is ~90MB and must still complete over a slow-but-progressing link. + let client = reqwest::Client::builder() + .user_agent(concat!("thunderbolt/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(600)) + .build() + .map_err(|e| CliInstallError::Download(e.to_string()))?; + + // Fetch the manifest first: if the release predates the CLI pipeline this 404s + // and we stop before downloading the (also-missing) binary. + let manifest = fetch(&client, &checksums_url(version)) + .await? + .text() + .await + .map_err(|e| CliInstallError::Download(e.to_string()))?; + + let expected = expected_sha_for(&manifest, &asset_name(target)).ok_or_else(|| { + CliInstallError::NotPublished(format!( + "This release publishes no CLI binary for {target}. Build it from source instead." + )) + })?; + + let bytes = fetch(&client, &binary_url(version, target)) + .await? + .bytes() + .await + .map_err(|e| CliInstallError::Download(e.to_string()))?; + + let actual = sha256_hex(&bytes); + if actual != expected { + return Err(CliInstallError::ChecksumMismatch(format!( + "Checksum mismatch for {}: expected {expected}, got {actual}. Install aborted.", + asset_name(target) + ))); + } + + let path = install_binary(&bytes)?; + + #[cfg(target_os = "macos")] + strip_quarantine(&path); + + let dir = local_bin_dir()?; + let dir_str = dir.to_string_lossy(); + let on_path = std::env::var("PATH").is_ok_and(|p| is_dir_on_path(&p, &dir_str)); + let path_hint = (!on_path).then(|| "export PATH=\"$HOME/.local/bin:$PATH\"".to_string()); + + Ok(CliInstallResult { + path: path.to_string_lossy().to_string(), + on_path, + path_hint, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_target_maps_published_platforms() { + assert_eq!(resolve_target("macos", "aarch64"), Some("darwin-arm64")); + assert_eq!(resolve_target("linux", "x86_64"), Some("linux-x64")); + assert_eq!(resolve_target("linux", "aarch64"), Some("linux-arm64")); + } + + #[test] + fn resolve_target_rejects_unpublished_platforms() { + // Intel macOS has no iroh addon; Windows/mobile aren't built. + assert_eq!(resolve_target("macos", "x86_64"), None); + assert_eq!(resolve_target("windows", "x86_64"), None); + assert_eq!(resolve_target("android", "aarch64"), None); + assert_eq!(resolve_target("ios", "aarch64"), None); + } + + #[test] + fn urls_follow_the_release_scheme() { + assert_eq!( + binary_url("0.1.105", "darwin-arm64"), + "https://github.com/thunderbird/thunderbolt/releases/download/v0.1.105/thunderbolt-darwin-arm64" + ); + assert_eq!( + checksums_url("0.1.105"), + "https://github.com/thunderbird/thunderbolt/releases/download/v0.1.105/SHA256SUMS" + ); + } + + #[test] + fn asset_name_matches_the_binary_filename() { + assert_eq!(asset_name("linux-x64"), "thunderbolt-linux-x64"); + } + + #[test] + fn expected_sha_parses_text_mode_manifest() { + let manifest = "\ +aaaa1111 thunderbolt-darwin-arm64 +bbbb2222 thunderbolt-linux-x64 +cccc3333 thunderbolt-linux-arm64 +"; + assert_eq!( + expected_sha_for(manifest, "thunderbolt-linux-x64"), + Some("bbbb2222".to_string()) + ); + } + + #[test] + fn expected_sha_parses_binary_mode_and_lowercases() { + // sha256sum binary mode uses ` *filename`; digests are compared lowercase. + let manifest = "ABCD1234 *thunderbolt-darwin-arm64\n"; + assert_eq!( + expected_sha_for(manifest, "thunderbolt-darwin-arm64"), + Some("abcd1234".to_string()) + ); + } + + #[test] + fn expected_sha_returns_none_for_absent_or_partial_names() { + let manifest = "aaaa1111 thunderbolt-linux-x64-old\n"; + // Must match the asset exactly, not as a prefix of another entry. + assert_eq!(expected_sha_for(manifest, "thunderbolt-linux-x64"), None); + assert_eq!(expected_sha_for("", "thunderbolt-linux-x64"), None); + } + + #[test] + fn sha256_hex_matches_known_vectors() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn is_dir_on_path_detects_membership() { + let path = "/usr/bin:/home/u/.local/bin:/opt/bin"; + assert!(is_dir_on_path(path, "/home/u/.local/bin")); + assert!(!is_dir_on_path(path, "/home/u/bin")); + } + + #[test] + fn is_dir_on_path_ignores_trailing_slashes() { + assert!(is_dir_on_path( + "/usr/bin:/home/u/.local/bin/", + "/home/u/.local/bin" + )); + assert!(is_dir_on_path("/home/u/.local/bin", "/home/u/.local/bin/")); + } + + #[test] + fn is_dir_on_path_handles_empty_path() { + assert!(!is_dir_on_path("", "/home/u/.local/bin")); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 53d6d4861..c68779f49 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -108,3 +108,16 @@ const OAUTH_PORTS: &[u16] = &[17421, 17422, 17423]; pub async fn start_oauth_server(app: tauri::AppHandle) -> Result { crate::oauth_server::start(app, OAUTH_PORTS) } + +// === Thunderbolt CLI install ================================================================= + +/// Download, verify and install the standalone `thunderbolt` CLI into +/// `~/.local/bin`, deriving the release URL from this app's version. Desktop +/// macOS/Linux only — other platforms return `Unsupported` (see [`crate::cli_installer`]). +#[command] +pub async fn install_thunderbolt_cli( + app: tauri::AppHandle, +) -> Result { + let version = app.package_info().version.to_string(); + crate::cli_installer::install_cli(&version).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5197284a1..8a8592ffb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ // These modules are used by Tauri's command system through macros // The compiler can't see the usage, so we allow dead_code warnings +pub mod cli_installer; #[allow(dead_code)] pub mod commands; pub mod oauth_server; @@ -48,6 +49,7 @@ pub fn create_app() -> tauri::Builder { commands::capabilities, commands::set_interface_style, commands::start_oauth_server, + commands::install_thunderbolt_cli, ]); #[cfg(debug_assertions)] diff --git a/src/acp/acp-adapter.test.ts b/src/acp/acp-adapter.test.ts index 4dee8ecef..59d10a477 100644 --- a/src/acp/acp-adapter.test.ts +++ b/src/acp/acp-adapter.test.ts @@ -25,11 +25,15 @@ import '@/testing-library' import { act } from '@testing-library/react' import type { Agent as AcpSdkAgent, + CancelNotification, Client, InitializeRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, + RequestPermissionRequest, + RequestPermissionResponse, + ResumeSessionRequest, SessionNotification, } from '@agentclientprotocol/sdk' import { describe, expect, it } from 'bun:test' @@ -97,12 +101,25 @@ const buildFakeTransport = (): { * or hang). `newSession` hands out distinct ids per call. The `toClient` * factory is captured so a test can push `session/update` notifications through * the real routing path the adapter wires up. */ -const buildFakeConnection = (opts: { hangInitialize?: boolean; loadSession?: boolean } = {}) => { +const buildFakeConnection = ( + opts: { + hangInitialize?: boolean + loadSession?: boolean + resume?: boolean + /** Reject the resume/load call so a test exercises the tier fallthrough. */ + rejectResume?: boolean + rejectLoad?: boolean + /** Reject the fire-and-forget cancel so a test exercises the abort `.catch`. */ + rejectCancel?: boolean + } = {}, +) => { const calls = { initialize: [] as InitializeRequest[], newSession: [] as NewSessionRequest[], loadSession: [] as LoadSessionRequest[], + resumeSession: [] as ResumeSessionRequest[], prompt: [] as PromptRequest[], + cancel: [] as CancelNotification[], } let client: Client | null = null let newSessionCount = 0 @@ -121,7 +138,13 @@ const buildFakeConnection = (opts: { hangInitialize?: boolean; loadSession?: boo if (opts.hangInitialize) { return new Promise(() => {}) } - return Promise.resolve({ protocolVersion: 1, agentCapabilities: { loadSession: opts.loadSession ?? false } }) + return Promise.resolve({ + protocolVersion: 1, + agentCapabilities: { + loadSession: opts.loadSession ?? false, + sessionCapabilities: opts.resume ? { resume: {} } : {}, + }, + }) } newSession = (req: NewSessionRequest) => { calls.newSession.push(req) @@ -130,19 +153,32 @@ const buildFakeConnection = (opts: { hangInitialize?: boolean; loadSession?: boo } loadSession = (req: LoadSessionRequest) => { calls.loadSession.push(req) - return Promise.resolve({}) + return opts.rejectLoad ? Promise.reject(new Error('session unloadable')) : Promise.resolve({}) + } + resumeSession = (req: ResumeSessionRequest) => { + calls.resumeSession.push(req) + return opts.rejectResume ? Promise.reject(new Error('session evicted')) : Promise.resolve({}) } prompt = async (req: PromptRequest) => { calls.prompt.push(req) await promptGatePromise return { stopReason: 'end_turn' as const } } + cancel = (req: CancelNotification) => { + calls.cancel.push(req) + return opts.rejectCancel ? Promise.reject(new Error('transport closing')) : Promise.resolve() + } } return { FakeConnection, calls, pushUpdate: (n: SessionNotification) => client?.sessionUpdate(n), + // Route a permission request through the same client the adapter registers, + // so a test can observe whether a thread's handler is still wired (its own + // outcome) or has been torn down (the adapter's `cancelled` fallback). + pushPermission: (req: RequestPermissionRequest): Promise | undefined => + client?.requestPermission(req), releasePrompts: () => promptGate.release(), } } @@ -172,11 +208,26 @@ const readSse = async (response: Response, max = 50): Promise => { return chunks } -const promptInit = (text: string): RequestInit => ({ +const promptInit = (text: string, signal?: AbortSignal): RequestInit => ({ method: 'POST', body: JSON.stringify({ id: 't', messages: [{ role: 'user', parts: [{ type: 'text', text }] }] }), + signal, }) +/** Build a request body from an explicit turn list so a test can supply prior + * history (everything before the trailing user turn) for the fallback replay. */ +const conversationInit = (turns: { role: 'user' | 'assistant'; text: string }[]): RequestInit => ({ + method: 'POST', + body: JSON.stringify({ + id: 't', + messages: turns.map((t) => ({ role: t.role, parts: [{ type: 'text', text: t.text }] })), + }), +}) + +/** Read the text of the single text block the adapter posted on `session/prompt`. */ +const sentPromptText = (calls: { prompt: PromptRequest[] }, index = 0): string => + (calls.prompt[index]?.prompt?.[0] as { type: string; text: string }).text + describe('connectAcpAdapter — handshake failure modes', () => { it('rejects after handshakeTimeoutMs when initialize never resolves and tears down the transport', async () => { const { transport, closeCalls } = buildFakeTransport() @@ -456,3 +507,379 @@ describe('connectAcpAdapter — agent-level command capture', () => { expect(calls.newSession).toHaveLength(1) }) }) + +describe('connectAcpAdapter — capability-aware continuity (resume / load / new+replay)', () => { + const drive = async ( + adapter: Awaited>, + init: RequestInit, + ctx: AgentAdapterContext, + releasePrompts: () => void, + ) => { + const response = await adapter.fetch(init, ctx) + await act(async () => { + releasePrompts() + await getClock().runAllAsync() + await readSse(response) + }) + } + + it('tier 1: resume-capable agent with a stored id resumes it — no newSession, no re-persist, no replay', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection({ resume: true }) + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + expect(adapter.capabilities).toMatchObject({ resume: true }) + + const persisted: string[] = [] + await drive( + adapter, + conversationInit([ + { role: 'user', text: 'earlier q' }, + { role: 'assistant', text: 'earlier a' }, + { role: 'user', text: 'now' }, + ]), + threadCtx('t-resume', { acpSessionId: 'stored-1', onAcpSessionId: async (s) => void persisted.push(s) }), + releasePrompts, + ) + + expect(calls.resumeSession).toHaveLength(1) + expect(calls.resumeSession[0]?.sessionId).toBe('stored-1') + expect(calls.newSession).toHaveLength(0) + expect(persisted).toEqual([]) // reused id, nothing fresh to persist + // No app-side replay: the live prompt carries only the current user text. + expect(sentPromptText(calls)).toBe('now') + }) + + it('tier 1→3: resume rejects (session evicted) → newSession + persist + transcript replay', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection({ resume: true, rejectResume: true }) + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + const persisted: string[] = [] + await drive( + adapter, + conversationInit([ + { role: 'user', text: 'earlier q' }, + { role: 'assistant', text: 'earlier a' }, + { role: 'user', text: 'now' }, + ]), + threadCtx('t-evicted', { acpSessionId: 'gone-1', onAcpSessionId: async (s) => void persisted.push(s) }), + releasePrompts, + ) + + expect(calls.resumeSession).toHaveLength(1) + expect(calls.newSession).toHaveLength(1) + expect(persisted).toEqual(['sess-1']) + const text = sentPromptText(calls) + expect(text).toContain('Conversation so far:') + expect(text).toContain('user: earlier q') + expect(text).toContain('assistant: earlier a') + expect(text.endsWith('now')).toBe(true) + }) + + it('degrade order: agent advertising BOTH resume and loadSession tries resume first (never loadSession)', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection({ resume: true, loadSession: true }) + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + await drive(adapter, promptInit('hi'), threadCtx('t-both', { acpSessionId: 'stored-9' }), releasePrompts) + + expect(calls.resumeSession).toHaveLength(1) + expect(calls.loadSession).toHaveLength(0) + expect(calls.newSession).toHaveLength(0) + }) + + it('tier 1→2: resume rejects but loadSession succeeds → loadSession, no newSession, no replay', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection({ + resume: true, + rejectResume: true, + loadSession: true, + }) + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + await drive( + adapter, + conversationInit([ + { role: 'user', text: 'earlier' }, + { role: 'assistant', text: 'reply' }, + { role: 'user', text: 'now' }, + ]), + threadCtx('t-load', { acpSessionId: 'stored-2' }), + releasePrompts, + ) + + expect(calls.resumeSession).toHaveLength(1) + expect(calls.loadSession).toHaveLength(1) + expect(calls.newSession).toHaveLength(0) + expect(sentPromptText(calls)).toBe('now') // agent replays its own history + }) + + it('tier 2→3: loadSession rejects → newSession + transcript replay', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection({ loadSession: true, rejectLoad: true }) + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + await drive( + adapter, + conversationInit([ + { role: 'user', text: 'earlier' }, + { role: 'assistant', text: 'reply' }, + { role: 'user', text: 'now' }, + ]), + threadCtx('t-loadfail', { acpSessionId: 'stored-3' }), + releasePrompts, + ) + + expect(calls.loadSession).toHaveLength(1) + expect(calls.newSession).toHaveLength(1) + expect(sentPromptText(calls)).toContain('Conversation so far:') + }) + + it('tier 3 consume-once: existing thread seeds the transcript on the first prompt but NOT the second', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection() // no resume/load + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + const persisted: string[] = [] + const ctx = () => threadCtx('t-3', { acpSessionId: 'stale-x', onAcpSessionId: async (s) => void persisted.push(s) }) + + await drive( + adapter, + conversationInit([ + { role: 'user', text: 'q1' }, + { role: 'assistant', text: 'a1' }, + { role: 'user', text: 'q2' }, + ]), + ctx(), + releasePrompts, + ) + // Second send: the live session now already contains q1/a1/q2, so re-seeding + // would double-inject. Guard is keyed on the fresh session's first send only. + await drive( + adapter, + conversationInit([ + { role: 'user', text: 'q1' }, + { role: 'assistant', text: 'a1' }, + { role: 'user', text: 'q2' }, + { role: 'assistant', text: 'a2' }, + { role: 'user', text: 'q3' }, + ]), + ctx(), + releasePrompts, + ) + + expect(calls.newSession).toHaveLength(1) // one fresh session, cached + expect(persisted).toEqual(['sess-1']) // persisted exactly once + expect(sentPromptText(calls, 0)).toContain('Conversation so far:') + expect(sentPromptText(calls, 1)).toBe('q3') // no re-seed on the second send + }) + + it('brand-new thread (no prior turns) never seeds a transcript on either send', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection() + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + // First ever prompt on a brand-new thread — no stored id, no prior turns. + await drive(adapter, conversationInit([{ role: 'user', text: 'hello' }]), threadCtx('t-new'), releasePrompts) + // Second prompt now carries [hello, hi-back] as "prior", but the live session + // already has them, so nothing must be seeded. + await drive( + adapter, + conversationInit([ + { role: 'user', text: 'hello' }, + { role: 'assistant', text: 'hi back' }, + { role: 'user', text: 'again' }, + ]), + threadCtx('t-new'), + releasePrompts, + ) + + expect(sentPromptText(calls, 0)).toBe('hello') + expect(sentPromptText(calls, 1)).toBe('again') + }) + + it('defers persistence: ensureSession warms a fresh session but does NOT persist until the first real send', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection() + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + const persisted: string[] = [] + const onAcpSessionId = async (s: string) => void persisted.push(s) + + await adapter.ensureSession(threadCtx('t-warm', { onAcpSessionId })) + expect(calls.newSession).toHaveLength(1) + expect(persisted).toEqual([]) // warming must not persist an empty session id + + await drive(adapter, promptInit('first'), threadCtx('t-warm', { onAcpSessionId }), releasePrompts) + expect(calls.newSession).toHaveLength(1) // reused the warmed session + expect(persisted).toEqual(['sess-1']) // persisted only on the real send + }) +}) + +describe('connectAcpAdapter — Stop cancels the remote ACP turn', () => { + /** A minimal permission request for the given session. The adapter only keys + * off `sessionId` when routing, so the rest is filler. */ + const permissionReq = (sessionId: string): RequestPermissionRequest => + ({ + sessionId, + toolCall: { toolCallId: 'tc-1' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }) as RequestPermissionRequest + + it('aborting mid-stream sends session/cancel once and tears down the thread handlers', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, pushPermission, releasePrompts } = buildFakeConnection() + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + const controller = new AbortController() + // A thread-owned permission handler that would auto-allow while connected. + const requestPermission = async (): Promise => ({ + outcome: { outcome: 'selected', optionId: 'allow' }, + }) + const response = await adapter.fetch( + promptInit('do something long', controller.signal), + threadCtx('thread-A', { requestPermission }), + ) + + // The prompt is in flight (gated open); nothing cancelled yet. + expect(calls.prompt).toHaveLength(1) + expect(calls.cancel).toHaveLength(0) + + let sse: string[] = [] + await act(async () => { + controller.abort() + // Even though the agent later finishes the (now-cancelled) turn, cancel + // must have fired exactly once — driven by the abort, not the resolution. + releasePrompts() + await getClock().runAllAsync() + sse = await readSse(response) + }) + + expect(calls.cancel).toHaveLength(1) + expect(calls.cancel[0]?.sessionId).toBe('sess-1') + + // Teardown ran: the body closed with a terminal finish + [DONE]... + const joined = sse.join('') + expect(joined).toContain('"type":"finish"') + expect(joined).toContain('[DONE]') + expect(sse).toContain('[CLOSED]') + + // ...and the thread's permission handler was unregistered, so a late + // permission prompt for that session now hits the `cancelled` fallback. + const outcome = await pushPermission(permissionReq('sess-1')) + expect(outcome?.outcome.outcome).toBe('cancelled') + }) + + it('a rejecting cancel is swallowed — abort still tears the thread down cleanly', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, pushPermission, releasePrompts } = buildFakeConnection({ rejectCancel: true }) + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + const controller = new AbortController() + const response = await adapter.fetch(promptInit('do something long', controller.signal), threadCtx('thread-A')) + + let sse: string[] = [] + await act(async () => { + controller.abort() + releasePrompts() + await getClock().runAllAsync() + sse = await readSse(response) + }) + + // Cancel fired and its rejection was swallowed (no unhandled rejection), so + // teardown still completed: the stream closed and the handler unregistered. + expect(calls.cancel).toHaveLength(1) + expect(sse.join('')).toContain('[DONE]') + const outcome = await pushPermission(permissionReq('sess-1')) + expect(outcome?.outcome.outcome).toBe('cancelled') + }) + + it('a signal already aborted at fetch time cancels immediately', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection() + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + const response = await adapter.fetch(promptInit('hi', AbortSignal.abort()), threadCtx('thread-A')) + + // The turn was issued (can't un-send) but immediately cancelled — once. + expect(calls.prompt).toHaveLength(1) + expect(calls.cancel).toHaveLength(1) + expect(calls.cancel[0]?.sessionId).toBe('sess-1') + + await act(async () => { + releasePrompts() + await getClock().runAllAsync() + await readSse(response) + }) + + // The agent resolving the cancelled turn does not double-cancel. + expect(calls.cancel).toHaveLength(1) + }) + + it('a normal completion never sends session/cancel', async () => { + const { transport } = buildFakeTransport() + const { FakeConnection, calls, releasePrompts } = buildFakeConnection() + + const adapter = await connectAcpAdapter(remoteAgent, baseCtx(), { + openTransport: async () => transport, + ClientSideConnection: FakeConnection as never, + }) + + const controller = new AbortController() + const response = await adapter.fetch(promptInit('hi', controller.signal), threadCtx('thread-A')) + + let sse: string[] = [] + await act(async () => { + releasePrompts() + await getClock().runAllAsync() + sse = await readSse(response) + }) + + expect(sse.join('')).toContain('"type":"finish"') + expect(calls.cancel).toHaveLength(0) + }) +}) diff --git a/src/acp/acp-adapter.ts b/src/acp/acp-adapter.ts index 88998b71f..da8165536 100644 --- a/src/acp/acp-adapter.ts +++ b/src/acp/acp-adapter.ts @@ -18,12 +18,15 @@ * permanently-failed or silent transport rejects loudly instead of hanging * the chat's fetch forever. NO per-thread session is resolved here. * 2. Each `adapter.fetch(init, ctx)` resolves the calling thread's ACP - * session lazily and once: if `ctx.acpSessionId` is set AND the agent - * advertises `loadSession` it calls `loadSession`, otherwise it calls - * `newSession`, captures the fresh id, and persists it via - * `ctx.onAcpSessionId`. The resolved id is cached per thread so repeated - * sends on the same thread never re-resolve. Both calls go through the - * same handshake guard as `initialize`. + * session lazily and once. With a stored `ctx.acpSessionId` it tries, by + * advertised capability, `session/resume` → `session/load` (each degrading + * to the next on a runtime rejection); otherwise — or if both fall through + * — it mints `session/new`. A fresh session's id is persisted via + * `ctx.onAcpSessionId` on the FIRST real send (not at resolution), and for + * a non-resumable agent that first send also seeds the prior transcript as + * context so the fresh agent isn't blind. The resolved id is cached per + * thread so repeated sends never re-resolve. All calls go through the same + * handshake guard as `initialize`. * 3. `session/update` notifications are routed by their `sessionId` to the * owning thread's translator via a `Map`, so two threads * streaming at once never bleed into each other. @@ -99,6 +102,10 @@ export const adaptCapabilities = (response: InitializeResponse): AgentCapabiliti const caps = response.agentCapabilities return { loadSession: caps?.loadSession ?? false, + // `sessionCapabilities.resume` is an empty `SessionResumeCapabilities` + // object (`{}`) when supported, `null`/absent otherwise — so presence, not + // truthiness, is the signal. + resume: caps?.sessionCapabilities?.resume != null, promptCapabilities: { image: caps?.promptCapabilities?.image ?? false, audio: caps?.promptCapabilities?.audio ?? false, @@ -107,6 +114,22 @@ export const adaptCapabilities = (response: InitializeResponse): AgentCapabiliti } } +/** Parse the AI SDK request body `{ messages: ThunderboltUIMessage[], id }`. */ +const parseRequestMessages = (init: RequestInit): ThunderboltUIMessage[] => { + if (typeof init.body !== 'string') { + throw new Error('ACP adapter expects string body on init') + } + return (JSON.parse(init.body) as { messages: ThunderboltUIMessage[] }).messages +} + +/** Concatenate a UI message's text parts. Non-text parts are dropped — the MVP + * `promptCapabilities` are all false, so images/files/tool parts never travel. */ +const messageText = (message: ThunderboltUIMessage): string => + message.parts + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text) + .join('\n') + /** * Build the ACP prompt content blocks from the AI SDK request body. The * built-in transport posts `{ messages: ThunderboltUIMessage[], id }`; we @@ -139,27 +162,22 @@ export const buildPromptBlocks = async ( skillInstructions: string[] | undefined, embeddedContext: boolean, deps: PromptBlockDeps = defaultPromptBlockDeps, + priorTranscript?: string, ): Promise => { - if (typeof init.body !== 'string') { - throw new Error('ACP adapter expects string body on init') - } - const parsed = JSON.parse(init.body) as { messages: ThunderboltUIMessage[] } - const lastUser = [...parsed.messages].reverse().find((m) => m.role === 'user') + const lastUser = [...parseRequestMessages(init)].reverse().find((m) => m.role === 'user') if (!lastUser) { throw new Error('ACP adapter: no user message in request body') } - const replyText = lastUser.parts - .filter((p): p is { type: 'text'; text: string } => p.type === 'text') - .map((p) => p.text) - .join('\n') + const replyText = messageText(lastUser) // Quotes flatten to a leading Markdown blockquote — ACP builds a plain-text // prompt, so unlike the AI SDK path we fold them in here rather than passing a // structured data-quote part through hydrateQuotesAsText. Ordered ahead of the // reply, mirroring the composer (quote chip above the typed text). const quotesText = renderMessageQuotesAsText(lastUser) const userText = [quotesText, replyText].filter(Boolean).join('\n\n') - // Skill instructions ride the text block (ACP has no system channel) — see composeAcpPrompt. - const text = composeAcpPrompt(skillInstructions, userText) + // Skill instructions + (fallback) prior transcript ride the text block (ACP + // has no system channel) — see composeAcpPrompt. + const text = composeAcpPrompt(skillInstructions, userText, priorTranscript) const attachments = getAttachments(lastUser) if (attachments.length === 0) { @@ -247,13 +265,45 @@ const resolveTextDelivery = async ( return { kind: 'undeliverable', filename: attachment.filename } } -/** Fold resolved user-skill instructions into the prompt text. ACP has no - * separate system channel (the prompt is content blocks), so we prepend the - * instructions ahead of the user's message — mirroring how the built-in - * pipeline prepends them as system messages, just over the only channel ACP - * gives us. No instructions → the user text is sent unchanged. */ -const composeAcpPrompt = (skillInstructions: string[] | undefined, userText: string): string => - skillInstructions && skillInstructions.length > 0 ? `${skillInstructions.join('\n\n')}\n\n${userText}` : userText +/** Render the conversation *before* the trailing user turn as a plain-text + * context block (mirroring the built-in adapter's transcript shape). Used ONLY + * on a fallback `session/new` for an existing thread — i.e. an agent that + * advertises neither `resume` nor `loadSession`, whose fresh session would + * otherwise start blind. This restores conversation CONTEXT only (what was + * said), NOT execution state (tool calls/results, file edits, failed attempts, + * compaction summaries) — that is recovered solely for our own agent via + * `resume`. No prior text turns → `undefined` (nothing to seed). */ +const extractPriorTranscript = (init: RequestInit): string | undefined => { + const messages = parseRequestMessages(init) + const lastUserIndex = messages.findLastIndex((m) => m.role === 'user') + const transcript = messages + .slice(0, Math.max(lastUserIndex, 0)) + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ role: m.role, text: messageText(m) })) + .filter((turn) => turn.text.length > 0) + .map((turn) => `${turn.role}: ${turn.text}`) + .join('\n\n') + return transcript.length > 0 ? transcript : undefined +} + +/** Fold resolved user-skill instructions + (fallback) prior transcript into the + * single prompt-text channel ACP gives us. Order: skill instructions first + * (behavioral, system-like), the prior-conversation context block next, the + * live user text last — mirroring how the built-in pipeline layers system → + * history → prompt. Absent blocks are omitted; with none, the user text is + * sent unchanged. */ +const composeAcpPrompt = ( + skillInstructions: string[] | undefined, + userText: string, + priorTranscript?: string, +): string => + [ + skillInstructions && skillInstructions.length > 0 ? skillInstructions.join('\n\n') : undefined, + priorTranscript ? `Conversation so far:\n\n${priorTranscript}` : undefined, + userText, + ] + .filter((block): block is string => block !== undefined) + .join('\n\n') export type AcpAdapterDeps = { /** Override transport opening for tests. Production omits and the factory @@ -319,7 +369,7 @@ export const connectAcpAdapter = async ( if (!agent.url) { throw new Error(`ACP agent ${agent.id} has no url`) } - if (agent.transport !== 'websocket') { + if (agent.transport !== 'websocket' && agent.transport !== 'iroh') { throw new Error(`ACP agent ${agent.id} has unsupported transport ${agent.transport}`) } @@ -398,33 +448,66 @@ export const connectAcpAdapter = async ( // Resolved ACP session id per chat thread. `null` while a thread's first // resolution is in flight is impossible — we store the in-flight promise so - // concurrent sends on the same thread dedupe to one load/new call. + // concurrent sends on the same thread dedupe to one resume/load/new call. const sessionByThread = new Map>() + // Threads whose current session was freshly minted via `session/new`. Drained + // exactly once by the first real `fetch` (via `Set.delete`) to BOTH persist + // the id and seed the prior transcript — deferring both out of resolution so a + // warm-then-reload before any prompt never persists an empty session id, and + // a resume/load-capable agent never resumes an empty session it thinks is warm. + const freshPending = new Set() + + const guardHandshake = (step: Promise): Promise => + withHandshakeGuard(step, transport.closed, handshakeTimeoutMs) /** Resolve (and cache) the ACP session id for the calling thread. First send - * on a thread runs `loadSession` (when supported + a prior id exists) or - * `newSession`; subsequent sends reuse the cached id. */ + * on a thread with a stored id + a capable agent tries, in order, + * `session/resume` → `session/load`; a runtime rejection from either (the + * session was evicted on the agent, wire still alive) degrades to the next + * tier. With no stored id, no capability, or both tiers falling through, it + * mints a fresh `session/new` (tier-3, app-side transcript replay in `fetch`). + * A genuinely dead transport is not silently downgraded: `resolveNew`'s own + * guarded handshake rejects there too, surfacing loudly. Subsequent sends + * reuse the cached id. */ const resolveThreadSession = (context: EnsureSessionContext): Promise => { const existing = sessionByThread.get(context.threadId) if (existing) { return existing } + const resolveNew = async (): Promise => { + const newSession = await guardHandshake(connection.newSession({ cwd: sessionCwd, mcpServers: [] })) + // Defer persistence + transcript seeding to the first real send. + freshPending.add(context.threadId) + return newSession.sessionId + } + // Try a stored-session restore, swallowing a runtime rejection (session + // evicted on the agent, wire still alive) into `false` so the caller degrades + // to the next tier. A genuinely dead transport also lands here as `false`, + // then surfaces loudly at `resolveNew`'s own guarded handshake. + const tryRestore = (restore: Promise): Promise => + guardHandshake(restore).then( + () => true, + () => false, + ) const resolve = (async (): Promise => { - if (context.acpSessionId && capabilities.loadSession) { - await withHandshakeGuard( - connection.loadSession({ sessionId: context.acpSessionId, cwd: sessionCwd, mcpServers: [] }), - transport.closed, - handshakeTimeoutMs, - ) - return context.acpSessionId + const stored = context.acpSessionId + // `resume` restores execution state with no replay; `load` has the agent + // replay its own transcript over `session/update`. Resume is tried first. + if ( + stored && + capabilities.resume && + (await tryRestore(connection.resumeSession({ sessionId: stored, cwd: sessionCwd, mcpServers: [] }))) + ) { + return stored } - const newSession = await withHandshakeGuard( - connection.newSession({ cwd: sessionCwd, mcpServers: [] }), - transport.closed, - handshakeTimeoutMs, - ) - await context.onAcpSessionId(newSession.sessionId) - return newSession.sessionId + if ( + stored && + capabilities.loadSession && + (await tryRestore(connection.loadSession({ sessionId: stored, cwd: sessionCwd, mcpServers: [] }))) + ) { + return stored + } + return resolveNew() })() // Evict on failure so a transient handshake error doesn't poison the thread // — the next send retries a fresh resolution. @@ -434,12 +517,26 @@ export const connectAcpAdapter = async ( } const fetch = async (init: RequestInit, context: AgentAdapterContext): Promise => { + const sessionId = await resolveThreadSession(context) + + // First real send of a freshly-minted session: persist the id we actually + // used, and (tier-3 only) seed the prior transcript as context. `Set.delete` + // returns true iff the entry existed and removes it synchronously — a + // race-free consume-once, and keyed on "fresh session's first prompt" (not + // "we prepended a transcript") so a brand-new thread's second prompt never + // re-injects its first exchange. + const isFirstSendOfFreshSession = freshPending.delete(context.threadId) + if (isFirstSendOfFreshSession) { + await context.onAcpSessionId(sessionId) + } + const priorTranscript = isFirstSendOfFreshSession ? extractPriorTranscript(init) : undefined const prompt = await buildPromptBlocks( init, context.skillInstructions, capabilities.promptCapabilities.embeddedContext, + defaultPromptBlockDeps, + priorTranscript, ) - const sessionId = await resolveThreadSession(context) const { body, translator, close } = createTranslatorStream({ textDeltaThrottleMs: deps.textDeltaThrottleMs, @@ -453,6 +550,37 @@ export const connectAcpAdapter = async ( translator.start() + // One idempotent teardown shared by every exit path — the prompt + // resolving, the prompt erroring, and the user hitting Stop (signal abort). + // It drops this turn's routing handlers, closes the translator stream, and + // detaches the abort listener so nothing leaks. + const { signal } = init + let tornDown = false + const teardown = (): void => { + if (tornDown) { + return + } + tornDown = true + signal?.removeEventListener('abort', onAbort) + sessionUpdateSinks.delete(sessionId) + permissionHandlers.delete(sessionId) + translator.finish() + close() + } + + // Stop: the AI SDK aborts the *local* stream, but the remote ACP turn keeps + // running (burning tokens, still executing tool calls) until we tell the + // agent. Send `session/cancel` — fire-and-forget, since teardown must not + // block on the wire — then run the same teardown. + const onAbort = (): void => { + // Fire-and-forget: teardown must not block on the wire. A rejection means + // the transport is already tearing down (the turn is ending anyway), so + // swallow it to avoid an unhandled rejection — matching the sibling + // `sessionUpdate` write above. + void connection.cancel({ sessionId }).catch(() => {}) + teardown() + } + // Drive the prompt off the request thread — the response stream is the // synchronous return value so the AI SDK can attach immediately. void (async () => { @@ -468,13 +596,17 @@ export const connectAcpAdapter = async ( } catch (err) { translator.error(err instanceof Error ? err.message : String(err)) } finally { - sessionUpdateSinks.delete(sessionId) - permissionHandlers.delete(sessionId) - translator.finish() - close() + teardown() } })() + // A signal already aborted before we attached the listener still cancels. + if (signal?.aborted) { + onAbort() + } else { + signal?.addEventListener('abort', onAbort, { once: true }) + } + return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' }, @@ -484,7 +616,10 @@ export const connectAcpAdapter = async ( // Warm the thread's session ahead of the first prompt so the agent emits its // `available_commands_update` (captured by `routeSessionUpdate`) before the // user sends anything. The session is cached per thread, so the subsequent - // first `fetch` reuses it — no extra `session/new`. + // first `fetch` reuses it — no extra `session/new`. Warming deliberately does + // NOT persist a freshly-minted id (that is deferred to the first real send): + // a reload after warming but before any prompt must leave the thread on its + // old/`null` id so it re-resolves correctly instead of resuming an empty one. const ensureSession = async (context: EnsureSessionContext): Promise => { await resolveThreadSession(context) } diff --git a/src/acp/built-in-adapter.test.ts b/src/acp/built-in-adapter.test.ts new file mode 100644 index 000000000..7e50653d8 --- /dev/null +++ b/src/acp/built-in-adapter.test.ts @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `harnessSignature` tests — the fingerprint that drives the per-thread harness + * cache. A stable signature for unchanged config keeps the live harness; any + * change to model / provider / key / base url / reasoning / thinking level / + * system prompt must produce a different signature so a mid-thread config switch + * rebuilds the harness instead of silently reusing the first turn's config. + */ + +import '@/testing-library' + +import { describe, expect, it } from 'bun:test' +import { harnessSignature, type ResolvedPiModel } from './built-in-adapter' +import type { PiModelDescriptor } from '@shared/agent-core' + +const noopFetch = (async () => new Response('')) as PiModelDescriptor['fetch'] + +const anthropic = (overrides: Partial> = {}): ResolvedPiModel => ({ + descriptor: { kind: 'anthropic', modelId: 'claude-opus-4-8', apiKey: 'sk-a', fetch: noopFetch, ...overrides }, + thinkingLevel: 'medium', +}) + +const openaiCompat = ( + overrides: Partial> = {}, +): ResolvedPiModel => ({ + descriptor: { + kind: 'openai-compat', + providerId: 'openai', + modelId: 'gpt-5', + baseURL: 'https://api.openai.com/v1', + apiKey: 'sk-o', + fetch: noopFetch, + reasoning: false, + ...overrides, + }, + thinkingLevel: 'medium', +}) + +const noMcp = '' + +describe('harnessSignature', () => { + it('is stable for identical config', () => { + expect(harnessSignature(anthropic(), 'sys', noMcp)).toBe(harnessSignature(anthropic(), 'sys', noMcp)) + }) + + it('changes when the model id changes', () => { + expect(harnessSignature(anthropic(), 'sys', noMcp)).not.toBe( + harnessSignature(anthropic({ modelId: 'claude-sonnet-4-8' }), 'sys', noMcp), + ) + }) + + it('changes when the api key changes', () => { + expect(harnessSignature(anthropic(), 'sys', noMcp)).not.toBe( + harnessSignature(anthropic({ apiKey: 'sk-b' }), 'sys', noMcp), + ) + }) + + it('changes when the system prompt changes', () => { + expect(harnessSignature(anthropic(), 'sys', noMcp)).not.toBe(harnessSignature(anthropic(), 'other', noMcp)) + }) + + it('changes when the thinking level changes', () => { + const high: ResolvedPiModel = { ...anthropic(), thinkingLevel: 'high' } + expect(harnessSignature(anthropic(), 'sys', noMcp)).not.toBe(harnessSignature(high, 'sys', noMcp)) + }) + + it('changes when the set of MCP servers changes', () => { + expect(harnessSignature(anthropic(), 'sys', noMcp)).not.toBe( + harnessSignature(anthropic(), 'sys', 'srv-1@https://a'), + ) + }) + + it('does not collide across provider families', () => { + expect(harnessSignature(anthropic(), 'sys', noMcp)).not.toBe(harnessSignature(openaiCompat(), 'sys', noMcp)) + }) + + it('changes when the openai-compat base url changes', () => { + expect(harnessSignature(openaiCompat(), 'sys', noMcp)).not.toBe( + harnessSignature(openaiCompat({ baseURL: 'https://other/v1' }), 'sys', noMcp), + ) + }) + + it('changes when the openai-compat reasoning flag changes', () => { + expect(harnessSignature(openaiCompat(), 'sys', noMcp)).not.toBe( + harnessSignature(openaiCompat({ reasoning: true }), 'sys', noMcp), + ) + }) + + it('changes when the openai-compat context window changes', () => { + expect(harnessSignature(openaiCompat(), 'sys', noMcp)).not.toBe( + harnessSignature(openaiCompat({ contextWindow: 200000 }), 'sys', noMcp), + ) + }) + + it('does not embed the plaintext api key', () => { + expect(harnessSignature(anthropic({ apiKey: 'super-secret-key' }), 'sys', noMcp)).not.toContain('super-secret-key') + }) +}) diff --git a/src/acp/built-in-adapter.ts b/src/acp/built-in-adapter.ts index 82cb32028..aa0e0d02a 100644 --- a/src/acp/built-in-adapter.ts +++ b/src/acp/built-in-adapter.ts @@ -3,27 +3,606 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Built-in adapter — wraps the existing `aiFetchStreamingResponse` pipeline so - * the chat layer can route every agent (built-in or ACP) through one - * `AgentAdapter` seam. No ACP handshake; `capabilities` is null; - * `disconnect()` is a no-op (the underlying pipeline is stateless per call). + * Built-in adapter — the app's first-party agent, exposed through the same + * `AgentAdapter` seam as every ACP agent. Selecting it must look identical to + * the user: the chat layer calls `adapter.fetch(init, ctx)` and streams the + * returned `Response` body unchanged. + * + * Engine routing (behind the seam, invisible to the chat layer): + * + * - **Anthropic + OpenAI-wire models** (`anthropic`, plus the OpenAI-compatible + * family `openai`/`custom`/`openrouter`/`thunderbolt`) run on the in-browser + * Pi {@link AgentHarness} (`shared/agent-core`): a real coding agent + * (bash/read/write/edit over an OPFS-backed ZenFS sandbox) whose LLM HTTP + * flows through the app's per-provider fetch (proxy fetch, or the thunderbolt + * SSO fetch). Its Pi event stream is translated to the AI SDK v5 UI message + * stream by `piHarnessToUiMessageStream`. The engine is `import()`-ed lazily + * (see `fetchViaHarness`) so its weight stays off the chat entry chunk. + * - **tinfoil** (confidential enclave) and any model id the chosen Pi provider + * can't resolve fall back to the legacy `aiFetchStreamingResponse` pipeline. + * tinfoil is deferred: its `SecureClient` does attestation/HPKE through a + * bespoke async-acquired fetch that doesn't fit Pi's synchronous fetch-swap + * cheaply, so routing it to Pi would risk the confidential path. + * + * Each thread keeps a PERSISTENT harness (cached per `threadId` for the life of + * the adapter), mirroring the ACP path's per-thread session model: the first turn + * builds the harness (seeding any prior turns as history so a resumed conversation + * has context — `buildAppHarness({ history })`), and every later turn prompts that + * same live harness, whose session already holds the running transcript — no + * re-seeding. The cache is tagged with a config SIGNATURE (model / provider / api + * key / system prompt / thinking level / reasoning / active MCP servers): switching + * any of these mid-thread rebuilds the harness from the request-body history (the workspace, + * keyed by `threadId`, is kept so its files survive the rebuild). Each thread's + * harness is bound to its own isolated OPFS workspace (`/workspace/`), + * jailed so a thread's coding tools and shell can't reach another thread's files. + * Side-effecting tool calls (`bash`/`write`/`edit`/MCP) are gated on the chat + * layer's permission dialog; read-only `read` auto-allows. + * + * No ACP handshake either way; `capabilities` is null and `ensureSession` is a + * no-op (no wire to warm). `disconnect` is real: it disposes every cached harness + * and removes its workspace, so no thread's session or files leak past the + * adapter's teardown (agent delete / config edit / sign-out). */ -import { aiFetchStreamingResponse } from '@/ai/fetch' +import { aiFetchStreamingResponse, mergeMcpTools, resolveOpenAiCompatConnection } from '@/ai/fetch' +import { getModelProfile } from '@/dal' +import { getDb } from '@/db/database' import type { Agent, AgentAdapter, AgentAdapterContext } from '@/types/acp' +import type { Model, ModelProfile, ThunderboltUIMessage } from '@/types' +import type { PiModelDescriptor, SeedTurn } from '@shared/agent-core' +import type { PermissionOption, RequestPermissionResponse, ToolKind } from '@agentclientprotocol/sdk' +import type { AgentHarness, ThinkingLevel, ToolCallEvent, ToolCallResult } from '@earendil-works/pi-agent-core' + +/** The type of the lazily-imported Pi engine module. A pure type reference — it + * resolves the module's shape for the compiler without emitting a runtime + * import, so the ~8MB engine stays in the async chunk loaded inside + * {@link fetchViaHarness}, never on the chat entry bundle. */ +type AgentCoreModule = typeof import('@shared/agent-core') + +/** A thread's live harness plus the workspace it is bound to. Kept in the + * per-adapter cache so the conversation (which lives in the harness session) and + * its isolated OPFS workspace persist across the thread's turns. */ +type HarnessRecord = { + readonly harness: AgentHarness + /** The thread's isolated workspace dir ({@link workspaceDirFor}); removed on dispose. */ + readonly workspaceDir: string +} + +/** A thread's cached build, tagged with the config {@link harnessSignature} it was + * built for so a mid-thread config switch is detected and rebuilt. */ +type CachedHarness = { + readonly signature: string + /** The build PROMISE (see {@link HarnessCache}). */ + readonly record: Promise +} + +/** Per-thread harness cache: one persistent harness per chat thread, reused across + * that thread's turns while its config signature is unchanged. Stores the build + * PROMISE (not the resolved record) so concurrent first-turns dedupe to a single + * build; a failed build is evicted so the next turn retries against a fresh + * harness. */ +type HarnessCache = Map /** Production injection point — production binds to `aiFetchStreamingResponse`. */ export type AiFetchStreamingResponseFn = typeof aiFetchStreamingResponse export type BuiltInAdapterOptions = { - /** Inject for tests so we don't touch the AI SDK / DB / settings stack. */ + /** Inject for tests so we don't touch the AI SDK / DB / settings stack. Also + * the engine for every non-Pi provider (tinfoil/thunderbolt-proxy/openai/…). */ aiFetch?: AiFetchStreamingResponseFn } +/** Providers the in-browser Pi harness can serve. Everything else (tinfoil, plus + * any future provider) stays on the legacy pipeline. */ +const piProviders = new Set(['anthropic', 'openai', 'custom', 'openrouter', 'thunderbolt']) + +/** Valid Pi thinking levels, used to validate a profile-supplied effort string. */ +const piThinkingLevels = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']) + +/** Reasoning depth used when a model carries no explicit profile config. Mirrors + * the adaptive default the anthropic path has always used, so deriving the level + * never regresses a model that didn't configure one. */ +const fallbackThinkingLevel: ThinkingLevel = 'medium' + +/** Maps an Anthropic-style thinking budget (tokens) to a Pi level by upper bound: + * ≤0 → off, ≤1024 → minimal, ≤4096 → low, ≤12288 → medium, else high. */ +const budgetToThinkingLevel = (budget: number): ThinkingLevel => { + if (budget <= 0) { + return 'off' + } + if (budget <= 1024) { + return 'minimal' + } + if (budget <= 4096) { + return 'low' + } + if (budget <= 12288) { + return 'medium' + } + return 'high' +} + +/** Coerce a profile effort string to a Pi level. Maps the explicit "off" signals + * ('off'/'none') to `off`, accepts the Pi levels verbatim, and rejects anything + * else (returning null so the caller can keep looking / fall back). */ +const effortToThinkingLevel = (value: unknown): ThinkingLevel | null => { + if (typeof value !== 'string') { + return null + } + if (value === 'none') { + return 'off' + } + return piThinkingLevels.has(value as ThinkingLevel) ? (value as ThinkingLevel) : null +} + +/** Pull a Pi thinking level out of a profile's `providerOptions`, the only + * per-model reasoning signal in the data model (there is no thinking-level + * column). Recognizes the OpenAI `reasoningEffort`/`reasoning_effort` strings, + * a nested `reasoning.effort`, and the Anthropic-style `thinking` object + * (`{ type: 'disabled' }` → off; `{ budgetTokens }` → bucketed level). Returns + * null when no reasoning config is present. */ +const readProfileThinkingLevel = ( + providerOptions: Record | null | undefined, +): ThinkingLevel | null => { + if (!providerOptions) { + return null + } + const direct = + effortToThinkingLevel(providerOptions.reasoningEffort) ?? effortToThinkingLevel(providerOptions.reasoning_effort) + if (direct) { + return direct + } + const reasoning = providerOptions.reasoning + if (reasoning && typeof reasoning === 'object') { + const nested = effortToThinkingLevel((reasoning as { effort?: unknown }).effort) + if (nested) { + return nested + } + } + const thinking = providerOptions.thinking + if (thinking && typeof thinking === 'object') { + const { type, budgetTokens } = thinking as { type?: unknown; budgetTokens?: unknown } + if (type === 'disabled') { + return 'off' + } + if (typeof budgetTokens === 'number') { + return budgetToThinkingLevel(budgetTokens) + } + } + return null +} + +/** The Pi thinking level for a model: its explicit profile reasoning config, else + * the adaptive fallback. Used for the anthropic path (whose catalog model is + * natively adaptive) and as the effort for OpenAI-wire reasoning models. */ +const deriveThinkingLevel = (profile: ModelProfile | null): ThinkingLevel => + readProfileThinkingLevel(profile?.providerOptions) ?? fallbackThinkingLevel + +/** Whether an OpenAI-wire model should request reasoning at all. Only models + * whose profile configures a non-`off` effort opt in; without config (or with an + * explicit `off`/`disabled`) the synthetic Pi model stays non-reasoning (Pi then + * sends no `reasoning_effort`, matching the legacy pipeline, which only forwards + * configured providerOptions). */ +const hasExplicitReasoning = (profile: ModelProfile | null): boolean => { + const level = readProfileThinkingLevel(profile?.providerOptions) + return level !== null && level !== 'off' +} + +/** The two choices we surface for a built-in tool-call permission prompt. + * Stable ids so the response can be mapped back to allow/deny by kind. */ +const permissionOptions: readonly PermissionOption[] = [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { optionId: 'reject', name: 'Reject', kind: 'reject_once' }, +] + +/** Pi tools that run unguarded — pure reads with no side effects. Mirrors the + * CLI's gate (`cli/src/agent/permissions.ts`): only `read` auto-allows; + * `bash`/`write`/`edit` and every MCP tool still prompt. Prompting on reads is + * noise, and the legacy built-in path never prompted at all. */ +const readOnlyTools = new Set(['read']) + +/** Map a Pi coding-tool name to the closest ACP {@link ToolKind} so the + * permission dialog renders a sensible label. Unknown (e.g. MCP) tools fall + * back to `other`. */ +const toToolKind = (toolName: string): ToolKind => { + switch (toolName) { + case 'bash': + return 'execute' + case 'read': + return 'read' + case 'write': + case 'edit': + return 'edit' + default: + return 'other' + } +} + +/** Translate the user's permission response into a Pi `tool_call` hook result. + * A cancelled prompt or a reject-kind selection blocks the tool (Pi encodes a + * blocked call as an error tool result); anything else allows it. */ +const toToolCallResult = (response: RequestPermissionResponse): ToolCallResult | undefined => { + const { outcome } = response + if (outcome.outcome === 'cancelled') { + return { block: true, reason: 'Tool call was not approved.' } + } + const selected = permissionOptions.find((option) => option.optionId === outcome.optionId) + if (selected?.kind.startsWith('reject')) { + return { block: true, reason: 'Tool call was rejected.' } + } + return undefined +} + +/** The latest user turn to prompt with, plus the prior turns to seed as history. */ +type PreparedConversation = { + /** Prior conversation turns, oldest first, seeded into the harness session. */ + readonly history: SeedTurn[] + /** The latest user turn's text, used to start the run (skill-prefixed). */ + readonly prompt: string +} + +/** Concatenate a UI message's text parts (dropping tool/reasoning/file parts). */ +const messageText = (message: ThunderboltUIMessage): string => + message.parts + .filter((part): part is { type: 'text'; text: string } => part.type === 'text') + .map((part) => part.text) + .join('\n') + +/** Reduce a UI message to a seed turn, dropping non-conversational roles + * (`system`) and content-less turns (e.g. an assistant turn that only ran tools). */ +const toTurn = (message: ThunderboltUIMessage): SeedTurn[] => { + if (message.role !== 'user' && message.role !== 'assistant') { + return [] + } + const text = messageText(message) + return text.length > 0 ? [{ role: message.role, text }] : [] +} + +/** Collapse consecutive same-role turns into one (joining their text). The seeded + * transcript must strictly alternate: Anthropic rejects two same-role messages in + * a row and Pi's `convertToLlm` does not guard it, so dropping a content-less + * assistant turn (or a thread that already has same-role runs) must not leak two + * user/two assistant messages back-to-back. */ +const coalesceTurns = (turns: readonly SeedTurn[]): SeedTurn[] => + turns.reduce((acc, turn) => { + const prev = acc.at(-1) + if (prev && prev.role === turn.role) { + acc[acc.length - 1] = { role: turn.role, text: `${prev.text}\n\n${turn.text}` } + return acc + } + acc.push(turn) + return acc + }, []) + +/** Split the AI SDK request body into the latest user prompt and the prior turns + * to seed as multi-turn history. The chat transport posts the full + * `{ messages: ThunderboltUIMessage[], id }`; the transcript is reduced to + * alternating text turns whose trailing entry is always the latest user turn — + * that becomes the prompt (optionally skill-prefixed), and everything before it is + * seeded as history so the agent remembers the conversation. */ +const prepareConversation = (init: RequestInit, skillInstructions: string[] | undefined): PreparedConversation => { + if (typeof init.body !== 'string') { + throw new Error('Built-in adapter expects a string body on init') + } + const { messages } = JSON.parse(init.body) as { messages: ThunderboltUIMessage[] } + const lastUserIndex = messages.findLastIndex((message) => message.role === 'user') + if (lastUserIndex === -1) { + throw new Error('Built-in adapter: no user message in request body') + } + const turns = coalesceTurns([ + ...messages.slice(0, lastUserIndex).flatMap(toTurn), + { role: 'user', text: messageText(messages[lastUserIndex]) }, + ]) + const promptTurn = turns[turns.length - 1] + const history = turns.slice(0, -1) + const prompt = + skillInstructions && skillInstructions.length > 0 + ? `${skillInstructions.join('\n\n')}\n\n${promptTurn.text}` + : promptTurn.text + return { history, prompt } +} + +/** Resolve to a cancelled outcome when `signal` aborts. Raced against the + * permission dialog so stopping generation mid-prompt settles the harness's + * pending tool-call hook instead of leaving it awaiting a dialog that will + * never be answered (which would dangle the aborted run). */ +const cancelledOnAbort = (signal: AbortSignal): Promise => + new Promise((resolve) => { + if (signal.aborted) { + resolve({ outcome: { outcome: 'cancelled' } }) + return + } + signal.addEventListener('abort', () => resolve({ outcome: { outcome: 'cancelled' } }), { once: true }) + }) + +/** No-op unsubscribe, returned when no permission sink is wired (tools auto-run). */ +const noop = (): void => {} + +/** + * Register the tool-call permission hook for ONE turn and return its unsubscribe. + * The hook closes over this turn's `requestPermission` sink and abort `signal`, so + * on a persistent (reused) harness it MUST be removed when the turn settles — + * leaving stacked hooks would fire one stale dialog per past turn. Read-only tools + * auto-allow; with no `requestPermission` sink nothing is registered (tools auto-run). + */ +const registerToolCallPermission = ( + harness: AgentHarness, + context: AgentAdapterContext, + signal: AbortSignal | null | undefined, +): (() => void) => { + const { requestPermission } = context + if (!requestPermission) { + return noop + } + return harness.on('tool_call', async (event: ToolCallEvent) => { + if (readOnlyTools.has(event.toolName)) { + return undefined + } + const ask = requestPermission({ + sessionId: context.threadId, + toolCall: { + toolCallId: event.toolCallId, + title: event.toolName, + kind: toToolKind(event.toolName), + rawInput: event.input, + status: 'pending', + }, + options: [...permissionOptions], + }) + const response = signal ? await Promise.race([ask, cancelledOnAbort(signal)]) : await ask + return toToolCallResult(response) + }) +} + +/** A resolved Pi model descriptor plus the thinking level derived from its + * profile. A null result at the call site means the model isn't Pi-serviceable + * (an anthropic id Pi's catalog lacks, or an OpenAI-wire provider missing its + * api key / url) and the request falls back to the legacy pipeline. */ +export type ResolvedPiModel = { + readonly descriptor: PiModelDescriptor + readonly thinkingLevel: ThinkingLevel +} + +/** Resolve the selected model to a Pi descriptor + thinking level, or null to + * fall back to legacy. Anthropic ids must exist in Pi's built-in catalog; + * OpenAI-wire providers must resolve a connection (api key / url present). The + * thinking level is derived from the model's profile for both families. */ +const resolvePiModel = async ( + agentCore: AgentCoreModule, + context: AgentAdapterContext, +): Promise => { + const model = context.selectedModel + const profile = await getModelProfile(getDb(), model.id) + const thinkingLevel = deriveThinkingLevel(profile) + if (model.provider === 'anthropic') { + if (!agentCore.isKnownAnthropicModel(model.model)) { + return null + } + return { + descriptor: { + kind: 'anthropic', + modelId: model.model, + apiKey: model.apiKey ?? '', + fetch: context.getProxyFetch(), + }, + thinkingLevel, + } + } + const connection = resolveOpenAiCompatConnection(model, context.getProxyFetch) + // Pi's openai-completions client requires a bearer key (it throws on an empty + // one with no auth header). A `custom` model pointing at a no-auth local + // endpoint (ollama / llama.cpp) has no key, so it stays on the legacy pipeline + // (which omits the Authorization header) rather than crashing the run. + if (!connection || !connection.apiKey) { + return null + } + return { + descriptor: { + kind: 'openai-compat', + providerId: model.provider, + modelId: model.model, + baseURL: connection.baseURL, + apiKey: connection.apiKey, + fetch: connection.fetch, + reasoning: hasExplicitReasoning(profile), + contextWindow: model.contextWindow ?? undefined, + }, + thinkingLevel, + } +} + +/** Compact non-cryptographic fingerprint (FNV-1a) of a secret, so the harness + * signature can detect an api-key change without embedding the plaintext key. */ +const hashSecret = (value: string): string => { + let hash = 0x811c9dc5 + for (let i = 0; i < value.length; i++) { + hash = Math.imul(hash ^ value.charCodeAt(i), 0x01000193) + } + return (hash >>> 0).toString(36) +} + +/** Fingerprint every input baked into a thread's harness at build time — the + * descriptor (provider / model id / api key / base url / reasoning / context + * window), the thinking level, the system prompt, and the active MCP servers. + * When it changes mid-thread (a model, provider, key, mode/system-prompt, + * thinking, or MCP-server switch) the cached harness is stale and + * {@link getOrBuildHarness} rebuilds it; an unchanged signature reuses the live + * harness. `mcpFingerprint` is supplied by the caller (the harness bakes the MCP + * toolset in, so it's part of the build config). */ +export const harnessSignature = (resolved: ResolvedPiModel, systemPrompt: string, mcpFingerprint: string): string => { + const d = resolved.descriptor + const model = + d.kind === 'anthropic' + ? `anthropic|${d.modelId}|${hashSecret(d.apiKey)}` + : `openai-compat|${d.providerId}|${d.modelId}|${d.baseURL}|${hashSecret(d.apiKey)}|${d.reasoning}|${d.contextWindow ?? ''}` + return `${model}|${resolved.thinkingLevel}|${systemPrompt}|mcp:${mcpFingerprint}` +} + +/** Stable fingerprint of the thread's active MCP servers (sorted ids + urls). The + * harness bakes their tools in at build time, so a mid-thread add/remove/retarget + * must rebuild it. Reconnecting the same server (same id+url) keeps the + * fingerprint stable, so it doesn't thrash the cache. */ +const mcpFingerprintOf = (context: AgentAdapterContext): string => + context.mcpClients + .map((client) => `${client.id}@${client.url}`) + .sort() + .join(',') + +/** Build a thread's harness from the lazily-loaded engine: convert the thread's + * MCP servers to Pi tools and bind the harness to the thread's isolated workspace + * with the resolved model descriptor + thinking level. `history` is seeded only + * HERE — on the first turn (a resumed conversation's prior turns) and on a + * config-drift rebuild (re-seeding the transcript into the fresh harness); an + * unchanged-config later turn reuses this harness, whose session already holds it. */ +const buildHarnessRecord = async ( + agentCore: AgentCoreModule, + context: AgentAdapterContext, + resolved: ResolvedPiModel, + history: readonly SeedTurn[], +): Promise => { + // The thread's MCP servers become Pi tools (namespaced `_`), reusing + // the legacy pipeline's merge/prefix logic. Only tool-capable models reach here — + // the harness always adds the four coding tools, so no-tools models route to legacy. + const { toolset } = await mergeMcpTools({}, context.mcpClients, context.reconnectClient) + const tools = await agentCore.toPiAgentTools(toolset) + const harness = await agentCore.buildAppHarness({ + model: resolved.descriptor, + systemPrompt: context.selectedMode.systemPrompt ?? '', + thinkingLevel: resolved.thinkingLevel, + threadId: context.threadId, + tools, + history, + }) + return { harness, workspaceDir: agentCore.workspaceDirFor(context.threadId) } +} + +/** Return the thread's cached harness, building it on first use and REBUILDING it + * when the config {@link harnessSignature} drifts (a mid-thread model / provider / + * key / mode / thinking switch). On drift the stale harness is evicted and its run + * aborted, but its workspace is KEPT — the rebuild re-seeds history from the + * request body and reuses the same `threadId`-keyed workspace, so the conversation + * context and the agent's files both survive. Concurrent first-turns share one + * in-flight build; a rejected build is evicted so a later turn retries fresh + * instead of replaying the poisoned promise. */ +const getOrBuildHarness = ( + cache: HarnessCache, + threadId: string, + signature: string, + build: () => Promise, +): Promise => { + const cached = cache.get(threadId) + if (cached && cached.signature === signature) { + return cached.record + } + // Config drift (or first turn). On drift, abort the stale harness's run and + // WAIT for that to settle before building the replacement, so the old and new + // harness never write the shared (threadId-keyed) workspace concurrently. The + // workspace dir is kept — the rebuild reuses the thread's files; a rejected + // prior build is swallowed so the rebuild still proceeds. + const previous = cached?.record + const record = previous ? previous.then(abortHarness, () => {}).then(build) : build() + record.catch(() => { + if (cache.get(threadId)?.record === record) { + cache.delete(threadId) + } + }) + cache.set(threadId, { signature, record }) + return record +} + +/** Abort a harness's in-flight run WITHOUT removing its workspace. Used on a + * config-drift eviction, where the rebuilt harness reuses the same workspace. */ +const abortHarness = async (record: HarnessRecord): Promise => { + await record.harness.abort().catch(() => {}) +} + +/** Tear down one thread's harness: abort any in-flight run, then remove its + * isolated workspace so no files leak. Optimistic — `remove` can't throw + * (`force`), and a benign idle-abort error is swallowed. */ +const disposeHarness = async (record: HarnessRecord): Promise => { + await abortHarness(record) + await record.harness.env.remove(record.workspaceDir, { recursive: true, force: true }) +} + +/** Dispose every cached harness and clear the cache. Fire-and-forget so the + * adapter's synchronous `disconnect` doesn't await teardown; a never-resolved or + * rejected build is swallowed so no straggler escapes as an unhandled rejection. */ +const disposeAllHarnesses = (cache: HarnessCache): void => { + const cached = [...cache.values()] + cache.clear() + void Promise.all(cached.map(({ record }) => record.then(disposeHarness).catch(() => {}))) +} + +/** Run the built-in request on the thread's persistent in-browser Pi harness and + * return its stream as the AI SDK UI message stream `Response`. Falls back to the + * legacy pipeline when the model isn't Pi-serviceable (unresolvable id/config). */ +const fetchViaHarness = async ( + init: RequestInit, + context: AgentAdapterContext, + cache: HarnessCache, + fallback: () => Promise, +): Promise => { + // Sanctioned route-splitting exception (CLAUDE.md "Route-level Code Splitting"). + // The Pi engine (`pi-*`, `zenfs`, `just-bash`, `@anthropic-ai/sdk`, `openai` — + // several MB) must NOT sit in the chat entry chunk on the critical landing path. + // This dynamic import keeps it in a separate async chunk that loads only when a + // built-in Pi agent actually runs; the legacy path's imports stay static. + const agentCore = await import('@shared/agent-core') + + // Resolve the model to a Pi descriptor; an unknown anthropic id or an + // unconfigured OpenAI-wire provider falls back to the legacy pipeline so the + // chat never crashes on a model Pi can't run. + const resolved = await resolvePiModel(agentCore, context) + if (!resolved) { + return fallback() + } + + const { history, prompt } = prepareConversation(init, context.skillInstructions) + + // Build the thread's harness on its first turn (seeding `history`); reuse it on + // every later turn whose config signature is unchanged, and rebuild it when the + // signature drifts (a mid-thread model / provider / key / mode / thinking / MCP switch). + const signature = harnessSignature(resolved, context.selectedMode.systemPrompt ?? '', mcpFingerprintOf(context)) + const { harness } = await getOrBuildHarness(cache, context.threadId, signature, () => + buildHarnessRecord(agentCore, context, resolved, history), + ) + + // Gate side-effecting tool calls on the chat layer's permission dialog for THIS + // turn, then unregister so the reused harness never carries a stale turn's hook. + const unregisterPermission = registerToolCallPermission(harness, context, init.signal) + + return new Response( + agentCore.piHarnessToUiMessageStream(harness, async () => { + try { + await harness.prompt(prompt) + await harness.waitForIdle() + } finally { + unregisterPermission() + } + }), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ) +} + +/** + * Build the built-in agent's {@link AgentAdapter}. Its `fetch` routes Anthropic + * models to the in-browser Pi harness and every other provider to the legacy + * `aiFetchStreamingResponse` pipeline (overridable via `options.aiFetch`). + * + * @param agent - the built-in `Agent` row this adapter represents + * @param options - test/override seam for the legacy fetch engine + * @returns an adapter with `capabilities: null` and no-op session lifecycle + */ export const createBuiltInAdapter = (agent: Agent, options: BuiltInAdapterOptions = {}): AgentAdapter => { const aiFetch = options.aiFetch ?? aiFetchStreamingResponse - const fetch = (init: RequestInit, context: AgentAdapterContext): Promise => + // Per-thread harness cache, scoped to this adapter instance. The adapter is + // itself cached per-agent (`adapter-cache.ts`), so a thread's harness survives + // across all of that thread's turns; `disconnect` disposes them all. + const harnessCache: HarnessCache = new Map() + + /** Legacy engine — every provider the Pi harness doesn't (yet) serve. */ + const fetchViaLegacyPipeline = (init: RequestInit, context: AgentAdapterContext): Promise => aiFetch({ init, modelId: context.selectedModel.id, @@ -35,12 +614,24 @@ export const createBuiltInAdapter = (agent: Agent, options: BuiltInAdapterOption getProxyFetch: context.getProxyFetch, }) + // Route tool-capable Pi-serviceable models (anthropic + the OpenAI-wire family) + // to the in-browser Pi harness; everything else (tinfoil, or a no-tools model + // the harness can't honor since it always activates coding tools) stays on the + // legacy pipeline. fetchViaHarness itself falls back when a candidate model + // turns out to be unresolvable (unknown id / missing api key or url). + const isPiCandidate = (model: Model): boolean => piProviders.has(model.provider) && model.toolUsage !== 0 + const fetch = (init: RequestInit, context: AgentAdapterContext): Promise => + isPiCandidate(context.selectedModel) + ? fetchViaHarness(init, context, harnessCache, () => fetchViaLegacyPipeline(init, context)) + : fetchViaLegacyPipeline(init, context) + return { agent, capabilities: null, fetch, - // No ACP session to warm — the built-in pipeline advertises no commands. + // No ACP wire to warm. Each thread's harness IS persistent, so disconnect + // disposes every cached harness and removes its isolated workspace. ensureSession: async () => {}, - disconnect: () => {}, + disconnect: () => disposeAllHarnesses(harnessCache), } } diff --git a/src/acp/connect.test.ts b/src/acp/connect.test.ts index 8ff24ed2a..a6c6cc8bb 100644 --- a/src/acp/connect.test.ts +++ b/src/acp/connect.test.ts @@ -20,6 +20,7 @@ import type { NewSessionRequest, LoadSessionRequest, PromptRequest, + ResumeSessionRequest, } from '@agentclientprotocol/sdk' import type { Agent, AgentAdapterContext } from '@/types/acp' import type { HttpClient } from '@/lib/http' @@ -109,11 +110,15 @@ type ConnCalls = { initialize: InitializeRequest[] newSession: NewSessionRequest[] loadSession: LoadSessionRequest[] + resumeSession: ResumeSessionRequest[] prompt: PromptRequest[] } -const buildFakeAcpDeps = (opts: { capabilities?: { loadSession?: boolean }; newSessionId?: string }) => { - const calls: ConnCalls = { initialize: [], newSession: [], loadSession: [], prompt: [] } +const buildFakeAcpDeps = (opts: { + capabilities?: { loadSession?: boolean; resume?: boolean } + newSessionId?: string +}) => { + const calls: ConnCalls = { initialize: [], newSession: [], loadSession: [], resumeSession: [], prompt: [] } const newId = opts.newSessionId ?? 'sess-new-1' class FakeConnection { @@ -125,7 +130,10 @@ const buildFakeAcpDeps = (opts: { capabilities?: { loadSession?: boolean }; newS calls.initialize.push(req) return { protocolVersion: 1, - agentCapabilities: { loadSession: opts.capabilities?.loadSession ?? false }, + agentCapabilities: { + loadSession: opts.capabilities?.loadSession ?? false, + sessionCapabilities: opts.capabilities?.resume ? { resume: {} } : {}, + }, } } newSession = async (req: NewSessionRequest) => { @@ -136,6 +144,10 @@ const buildFakeAcpDeps = (opts: { capabilities?: { loadSession?: boolean }; newS calls.loadSession.push(req) return {} } + resumeSession = async (req: ResumeSessionRequest) => { + calls.resumeSession.push(req) + return {} + } prompt = async (req: PromptRequest) => { calls.prompt.push(req) return { stopReason: 'end_turn' as const } @@ -200,6 +212,29 @@ describe('connectToAgent — remote-acp dispatch', () => { expect(calls.loadSession[0]).toMatchObject({ sessionId: 'existing-sess' }) }) + it('sends resumeSession on fetch when acpSessionId present AND capabilities.resume is advertised', async () => { + const { calls, FakeConnection, openTransport } = buildFakeAcpDeps({ + capabilities: { resume: true }, + }) + const onAcpSessionId = mock(async (_id: string) => {}) + + const adapter = await connectToAgent( + remoteAgent, + { httpClient, getProxyFetch }, + { openTransport, ClientSideConnection: FakeConnection as never }, + ) + expect(adapter.capabilities).toMatchObject({ resume: true }) + + await adapter.fetch(promptInit('hi'), baseAdapterContext({ acpSessionId: 'existing-sess', onAcpSessionId })) + + expect(calls.resumeSession).toHaveLength(1) + expect(calls.resumeSession[0]).toMatchObject({ sessionId: 'existing-sess' }) + expect(calls.newSession).toHaveLength(0) + expect(calls.loadSession).toHaveLength(0) + // resume reuses the existing id — nothing fresh to persist. + expect(onAcpSessionId).not.toHaveBeenCalled() + }) + it('falls back to newSession + onAcpSessionId callback when loadSession capability is false even if acpSessionId is set', async () => { const { calls, FakeConnection, openTransport } = buildFakeAcpDeps({ capabilities: { loadSession: false }, @@ -220,7 +255,7 @@ describe('connectToAgent — remote-acp dispatch', () => { expect(onAcpSessionId).toHaveBeenCalledWith('fresh-1') }) - it('fetch posts session/prompt with the last user-message text and returns a streaming Response', async () => { + it('fetch on a fresh session carrying prior history seeds the transcript ahead of the last user message', async () => { const { calls, FakeConnection, openTransport } = buildFakeAcpDeps({}) const adapter = await connectToAgent( @@ -240,6 +275,8 @@ describe('connectToAgent — remote-acp dispatch', () => { id: 't1', }), } + // acpSessionId is null → tier-3 newSession. The prior turns are seeded once so + // the fresh agent isn't blind; the live prompt is still the last user message. const ctx = baseAdapterContext() const response = await adapter.fetch(init, ctx) @@ -250,9 +287,11 @@ describe('connectToAgent — remote-acp dispatch', () => { await getClock().runAllAsync() }) expect(calls.prompt).toHaveLength(1) - expect(calls.prompt[0]).toMatchObject({ - sessionId: 'sess-new-1', - prompt: [{ type: 'text', text: 'newest question' }], - }) + expect(calls.prompt[0]?.sessionId).toBe('sess-new-1') + const sent = (calls.prompt[0]?.prompt?.[0] as { type: string; text: string }).text + expect(sent).toContain('Conversation so far:') + expect(sent).toContain('user: older') + expect(sent).toContain('assistant: reply') + expect(sent.endsWith('newest question')).toBe(true) }) }) diff --git a/src/acp/connection-test.test.ts b/src/acp/connection-test.test.ts index a0be808f9..9cbbe9f86 100644 --- a/src/acp/connection-test.test.ts +++ b/src/acp/connection-test.test.ts @@ -64,6 +64,7 @@ describe('testAcpConnection', () => { success: true, capabilities: { loadSession: true, + resume: false, promptCapabilities: { image: true, audio: false, embeddedContext: true }, }, }) @@ -84,11 +85,26 @@ describe('testAcpConnection', () => { success: true, capabilities: { loadSession: false, + resume: false, promptCapabilities: { image: false, audio: false, embeddedContext: false }, }, }) }) + it('maps sessionCapabilities.resume presence to resume: true', async () => { + const { openTransport, FakeConnection } = buildFakeDeps({ + capabilities: { loadSession: false, sessionCapabilities: { resume: {} } }, + }) + + const result = await testAcpConnection({ + url: 'wss://example.test/ws', + openTransport: openTransport as never, + ClientSideConnection: FakeConnection as never, + }) + + expect(result).toMatchObject({ success: true, capabilities: { resume: true, loadSession: false } }) + }) + it('returns the error message when initialize rejects', async () => { const { close, openTransport, FakeConnection } = buildFakeDeps({ initialize: async () => { diff --git a/src/acp/iroh/iroh-transport.test.ts b/src/acp/iroh/iroh-transport.test.ts new file mode 100644 index 000000000..41276c3a5 --- /dev/null +++ b/src/acp/iroh/iroh-transport.test.ts @@ -0,0 +1,449 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * iroh transport tests. A fake `IrohClientLike` stands in for the wasm client — + * no relay, no wasm — so the ndjson framing and `Stream` adaptation are exercised + * directly. The shared client is reset between cases. + */ + +import type { AnyMessage } from '@agentclientprotocol/sdk' +import { afterEach, describe, expect, it, spyOn } from 'bun:test' +import { + bindAndPersistForTests, + bindIrohClient, + clearIrohClientSecret, + irohRelayUrl, + resetSharedIrohClientForTests, + acpIrohAlpn, + openIrohTransport, +} from './iroh-transport' +import type { IrohClientLike, IrohConnectionLike } from './types' + +type FakeConnection = { + connection: IrohConnectionLike + pushBytes: (bytes: Uint8Array) => void + endReceive: () => void + errorReceive: (err: Error) => void + sent: () => Uint8Array[] + closed: () => boolean + closeCount: () => number +} + +const makeFakeConnection = (): FakeConnection => { + let controller: ReadableStreamDefaultController | null = null + const readable = new ReadableStream({ + start(c) { + controller = c + }, + }) + const sent: Uint8Array[] = [] + let closeCalls = 0 + return { + connection: { + send: async (data) => { + sent.push(data) + }, + readable: () => readable, + close: () => { + closeCalls += 1 + }, + }, + pushBytes: (bytes) => controller?.enqueue(bytes), + endReceive: () => controller?.close(), + errorReceive: (err) => controller?.error(err), + sent: () => sent, + closed: () => closeCalls > 0, + closeCount: () => closeCalls, + } +} + +type CapturedConnect = { target: string; alpn: string } + +const makeFakeClient = (connection: IrohConnectionLike, captured: CapturedConnect[]): IrohClientLike => ({ + nodeId: () => 'fake-node-id', + connect: async (target, alpn) => { + captured.push({ target, alpn }) + return connection + }, +}) + +const enc = (s: string): Uint8Array => new TextEncoder().encode(s) + +afterEach(() => { + resetSharedIrohClientForTests() +}) + +describe('openIrohTransport', () => { + it('dials the target over the ACP ALPN', async () => { + const fake = makeFakeConnection() + const captured: CapturedConnect[] = [] + await openIrohTransport({ + target: 'ticket-or-nodeid', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(fake.connection, captured), + }) + expect(captured).toEqual([{ target: 'ticket-or-nodeid', alpn: acpIrohAlpn }]) + }) + + it('frames an outbound message as ndjson on the send half', async () => { + const fake = makeFakeConnection() + const transport = await openIrohTransport({ + target: 't', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + const writer = transport.stream.writable.getWriter() + await writer.write({ jsonrpc: '2.0', id: 1, method: 'initialize' } as unknown as AnyMessage) + expect(fake.sent().map((b) => new TextDecoder().decode(b))).toEqual([ + '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n', + ]) + }) + + it('propagates a send failure to the writable write (no silently swallowed error)', async () => { + const fake = makeFakeConnection() + // The wasm `send()` now rejects when the underlying QUIC write fails, instead + // of resolving on enqueue — the writable must surface that rejection. + const failing: IrohConnectionLike = { + ...fake.connection, + send: async () => { + throw new Error('iroh connection closed') + }, + } + const transport = await openIrohTransport({ + target: 't', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(failing, []), + }) + const writer = transport.stream.writable.getWriter() + await expect( + writer.write({ jsonrpc: '2.0', id: 1, method: 'initialize' } as unknown as AnyMessage), + ).rejects.toThrow('iroh connection closed') + }) + + it('decodes inbound ndjson bytes into ACP messages on the readable', async () => { + const fake = makeFakeConnection() + const transport = await openIrohTransport({ + target: 't', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + const reader = transport.stream.readable.getReader() + fake.pushBytes(enc('{"jsonrpc":"2.0","id":1,"result":{}}\n')) + const { value } = await reader.read() + expect(value).toEqual({ jsonrpc: '2.0', id: 1, result: {} } as unknown as AnyMessage) + }) + + it('reassembles a message split across two receive chunks', async () => { + const fake = makeFakeConnection() + const transport = await openIrohTransport({ + target: 't', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + const reader = transport.stream.readable.getReader() + fake.pushBytes(enc('{"jsonrpc":"2.0",')) + fake.pushBytes(enc('"id":7}\n')) + const { value } = await reader.read() + expect(value).toEqual({ jsonrpc: '2.0', id: 7 } as unknown as AnyMessage) + }) + + it('resolves `closed` cleanly when the receive stream ends', async () => { + const fake = makeFakeConnection() + const transport = await openIrohTransport({ + target: 't', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + fake.endReceive() + await expect(transport.closed).resolves.toBeUndefined() + }) + + it('rejects `closed` when the receive stream errors', async () => { + const fake = makeFakeConnection() + const transport = await openIrohTransport({ + target: 't', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + fake.errorReceive(new Error('relay dropped')) + await expect(transport.closed).rejects.toThrow('relay dropped') + }) + + it('routes teardown through close() on a receive error: closes the connection and detaches the abort listener', async () => { + const fake = makeFakeConnection() + const controller = new AbortController() + const removeSpy = spyOn(controller.signal, 'removeEventListener') + const transport = await openIrohTransport({ + target: 't', + signal: controller.signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + fake.errorReceive(new Error('recv blew up')) + await expect(transport.closed).rejects.toThrow('recv blew up') + // The error path must close the QUIC connection (no leaked connection)... + expect(fake.closeCount()).toBe(1) + // ...and detach the abort listener from the long-lived signal (no pinned leak). + expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)) + // A later abort is now a no-op — the detached listener can't re-close. + controller.abort() + expect(fake.closeCount()).toBe(1) + }) + + it('closes the readable on close() and ignores inbound bytes that arrive after', async () => { + const fake = makeFakeConnection() + const transport = await openIrohTransport({ + target: 't', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + const reader = transport.stream.readable.getReader() + transport.close() + // A late frame from the wire must not throw (the pump is guarded). + fake.pushBytes(enc('{"late":true}\n')) + const { done } = await reader.read() + expect(done).toBe(true) + }) + + it('close() closes the underlying connection and settles `closed`', async () => { + const fake = makeFakeConnection() + const transport = await openIrohTransport({ + target: 't', + signal: new AbortController().signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + transport.close() + expect(fake.closed()).toBe(true) + await expect(transport.closed).resolves.toBeUndefined() + }) + + it('aborting the signal closes the connection', async () => { + const fake = makeFakeConnection() + const controller = new AbortController() + await openIrohTransport({ + target: 't', + signal: controller.signal, + loadClient: async () => makeFakeClient(fake.connection, []), + }) + controller.abort() + expect(fake.closed()).toBe(true) + }) + + it('rejects with AbortError without dialing when the signal is already aborted', async () => { + const fake = makeFakeConnection() + const captured: CapturedConnect[] = [] + const controller = new AbortController() + controller.abort() + await expect( + openIrohTransport({ + target: 't', + signal: controller.signal, + loadClient: async () => makeFakeClient(fake.connection, captured), + }), + ).rejects.toThrow(/abort/i) + // A pre-aborted dial never reaches the wire — nothing to dial or close. + expect(captured).toEqual([]) + expect(fake.closed()).toBe(false) + }) + + it('closes a connection that resolves after an abort during the dial', async () => { + const fake = makeFakeConnection() + const controller = new AbortController() + let resolveConnect: (connection: IrohConnectionLike) => void = () => {} + let signalDialing: () => void = () => {} + const dialing = new Promise((resolve) => { + signalDialing = resolve + }) + const slowClient: IrohClientLike = { + nodeId: () => 'slow', + connect: () => { + signalDialing() + return new Promise((resolve) => { + resolveConnect = resolve + }) + }, + } + const open = openIrohTransport({ target: 't', signal: controller.signal, loadClient: async () => slowClient }) + // Wait until the dial is actually in flight (the never-resolving connect was + // called), then abort — no fake-timer dependency. + await dialing + controller.abort() + await expect(open).rejects.toThrow(/abort/i) + // The dial wins the race a tick too late; the orphaned connection must close. + resolveConnect(fake.connection) + await Promise.resolve() + await Promise.resolve() + expect(fake.closed()).toBe(true) + }) + + it('evicts the shared client when the bind fails so a later dial rebinds', async () => { + let binds = 0 + const loadClient = async (): Promise => { + binds += 1 + if (binds === 1) { + throw new Error('bind failed') + } + return makeFakeClient(makeFakeConnection().connection, []) + } + await expect(openIrohTransport({ target: 'a', signal: new AbortController().signal, loadClient })).rejects.toThrow( + 'bind failed', + ) + // The failed bind was evicted, so this rebinds instead of replaying the rejection. + const transport = await openIrohTransport({ target: 'b', signal: new AbortController().signal, loadClient }) + expect(binds).toBe(2) + expect(transport.stream).toBeDefined() + }) + + it('evicts the shared client when aborted during the bind so a later dial rebinds', async () => { + let binds = 0 + let resolveBind: (client: IrohClientLike) => void = () => {} + const loadClient = (): Promise => { + binds += 1 + if (binds === 1) { + return new Promise((resolve) => { + resolveBind = resolve + }) + } + return Promise.resolve(makeFakeClient(makeFakeConnection().connection, [])) + } + const controller = new AbortController() + // `load()` runs synchronously inside the call and `raceAbort` attaches its + // abort listener before suspending, so aborting right away lands during the + // in-flight bind. + const open = openIrohTransport({ target: 'a', signal: controller.signal, loadClient }) + controller.abort() + await expect(open).rejects.toThrow(/abort/i) + // The hung bind was evicted; a later dial rebinds rather than awaiting it. + const transport = await openIrohTransport({ target: 'b', signal: new AbortController().signal, loadClient }) + expect(binds).toBe(2) + expect(transport.stream).toBeDefined() + // Let the orphaned first bind settle so it leaves no dangling pending promise. + resolveBind(makeFakeClient(makeFakeConnection().connection, [])) + }) + + it('binds ONE shared client across transports, opening a connection each', async () => { + let binds = 0 + let connects = 0 + const loadClient = async (): Promise => { + binds += 1 + return { + nodeId: () => 'shared', + // A fresh connection per dial — each transport owns its own bidi stream. + connect: async () => { + connects += 1 + return makeFakeConnection().connection + }, + } + } + await openIrohTransport({ target: 'a', signal: new AbortController().signal, loadClient }) + await openIrohTransport({ target: 'b', signal: new AbortController().signal, loadClient }) + expect(binds).toBe(1) + expect(connects).toBe(2) + }) +}) + +describe('clearIrohClientSecret', () => { + const secretStorageKey = 'iroh_acp_client_secret' + + it('removes the persisted client secret from localStorage', () => { + localStorage.setItem(secretStorageKey, 'deadbeef') + clearIrohClientSecret() + expect(localStorage.getItem(secretStorageKey)).toBeNull() + }) + + it('drops the in-memory shared client so the next dial re-binds a fresh identity', async () => { + let binds = 0 + const loadClient = async (): Promise => { + binds += 1 + return { nodeId: () => 'shared', connect: async () => makeFakeConnection().connection } + } + await openIrohTransport({ target: 'a', signal: new AbortController().signal, loadClient }) + expect(binds).toBe(1) + // A wiped credential must not leave the old identity bound in memory to re-persist. + clearIrohClientSecret() + await openIrohTransport({ target: 'b', signal: new AbortController().signal, loadClient }) + expect(binds).toBe(2) + }) + + it('persists the bound secret when no wipe races the bind', async () => { + localStorage.removeItem(secretStorageKey) + const client: IrohClientLike = { nodeId: () => 'n', connect: async () => makeFakeConnection().connection } + await bindAndPersistForTests(async () => ({ client, secretHex: 'fresh-secret' })) + expect(localStorage.getItem(secretStorageKey)).toBe('fresh-secret') + }) + + it('does NOT re-persist a secret when a wipe (sign-out) races the in-flight bind', async () => { + localStorage.removeItem(secretStorageKey) + const client: IrohClientLike = { nodeId: () => 'n', connect: async () => makeFakeConnection().connection } + let resolveBind: (value: { client: IrohClientLike; secretHex: string }) => void = () => {} + const bindPending = bindAndPersistForTests(() => new Promise((resolve) => (resolveBind = resolve))) + // Sign-out wipes the secret while the wasm bind is still in flight. + clearIrohClientSecret() + // The bind now resolves with the freshly generated secret — it must not be written back. + resolveBind({ client, secretHex: 'resurrected-secret' }) + await bindPending + expect(localStorage.getItem(secretStorageKey)).toBeNull() + }) +}) + +// `irohRelayUrl` reads `import.meta.env` on every call, so tests mutate the env +// directly (the same pattern auth-mode tests use) and clear it afterwards. +const viteEnv = import.meta.env as Record + +describe('irohRelayUrl', () => { + afterEach(() => { + delete viteEnv.VITE_IROH_RELAY_URL + }) + + it('is undefined when VITE_IROH_RELAY_URL is unset', () => { + delete viteEnv.VITE_IROH_RELAY_URL + expect(irohRelayUrl()).toBeUndefined() + }) + + it('is undefined when VITE_IROH_RELAY_URL is empty or whitespace', () => { + viteEnv.VITE_IROH_RELAY_URL = ' ' + expect(irohRelayUrl()).toBeUndefined() + }) + + it('returns the trimmed url when VITE_IROH_RELAY_URL is set', () => { + viteEnv.VITE_IROH_RELAY_URL = ' wss://relay.example ' + expect(irohRelayUrl()).toBe('wss://relay.example') + }) +}) + +describe('bindIrohClient', () => { + afterEach(() => { + delete viteEnv.VITE_IROH_RELAY_URL + }) + + it('forwards VITE_IROH_RELAY_URL into create and surfaces the bound secret', async () => { + viteEnv.VITE_IROH_RELAY_URL = 'wss://relay.example' + const relays: Array = [] + const { client, secretHex } = await bindIrohClient(async (relay) => { + relays.push(relay) + return { + nodeId: () => 'node', + connect: async () => makeFakeConnection().connection, + secretKeyHex: () => 'bound-secret', + } + }) + expect(relays).toEqual(['wss://relay.example']) + expect(secretHex).toBe('bound-secret') + expect(client.nodeId()).toBe('node') + }) + + it('passes undefined relay (n0 default) when VITE_IROH_RELAY_URL is unset', async () => { + delete viteEnv.VITE_IROH_RELAY_URL + const relays: Array = [] + await bindIrohClient(async (relay) => { + relays.push(relay) + return { + nodeId: () => 'node', + connect: async () => makeFakeConnection().connection, + secretKeyHex: () => 'bound-secret', + } + }) + expect(relays).toEqual([undefined]) + }) +}) diff --git a/src/acp/iroh/iroh-transport.ts b/src/acp/iroh/iroh-transport.ts new file mode 100644 index 000000000..a60a8d1fc --- /dev/null +++ b/src/acp/iroh/iroh-transport.ts @@ -0,0 +1,387 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * iroh transport for ACP — the relay-only, peer-to-peer counterpart to the + * WebSocket transport. Dials a `thunderbolt acp --transport iroh` CLI bridge by + * its NodeId/ticket (carried in `agent.url`) over an n0 relay (or a self-hosted + * one via `VITE_IROH_RELAY_URL`), end-to-end encrypted to the bridge. + * + * The dial happens in a Rust→wasm client (`crates/thunderbolt-acp-client`) that + * the browser can't replace with JS — iroh has no browser TS SDK. The wasm chunk + * is lazy-loaded on first use (never in the entry bundle) and ONE relay endpoint + * is shared across every iroh agent; each agent opens its own bidi stream. + * + * The bidi stream is a raw byte pipe, so this transport adds the same ndjson + * framing the bridge uses (`./ndjson`) to carry ACP JSON-RPC objects, and adapts + * it to the SDK's `{ readable, writable }` message `Stream`. + */ + +import type { AnyMessage, Stream } from '@agentclientprotocol/sdk' +import type { AcpTransport } from '../types' +import { createNdjsonDecoder, encodeNdjsonFrame } from './ndjson' +import type { IrohClientLike, IrohClientLoader, IrohConnectionLike } from './types' + +/** ALPN of the CLI ACP bridge (`cli/src/iroh/endpoint.ts`, + * `thunderbolt/${protocol}/0`). Must match byte-for-byte or the QUIC handshake + * is refused — an ACP client can't drive an MCP bridge. */ +export const acpIrohAlpn = 'thunderbolt/acp/0' + +/** localStorage key for the persisted client secret key (hex). Persisting it + * pins a stable NodeId, so the bridge operator allowlists this app once. */ +const secretStorageKey = 'iroh_acp_client_secret' + +// ONE long-lived relay endpoint for the whole app — binding per dial would churn +// the relay handshake and the NodeId. All iroh transports share this client and +// open their own connection on it. +let sharedClient: Promise | null = null + +// Bumped by every wipe (sign-out / account-deletion / device-revocation). A bind +// samples this before its async work and only persists if it still matches, so a +// wipe that races an in-flight bind can't be silently undone by the bind re-writing +// the just-cleared secret. +let secretGeneration = 0 + +const readPersistedSecret = (): string | undefined => { + if (typeof localStorage === 'undefined') { + return undefined + } + return localStorage.getItem(secretStorageKey) ?? undefined +} + +const persistSecret = (hex: string): void => { + if (typeof localStorage === 'undefined') { + return + } + localStorage.setItem(secretStorageKey, hex) +} + +/** + * Wipe the persisted iroh client secret and drop the in-memory binding. + * + * The secret IS the bridge access credential — it pins this app's NodeId, which a + * bridge operator allowlists once. It currently lives in plaintext localStorage + * (XSS-exfiltratable, and it outlives any patched XSS), so sign-out and account + * deletion must clear it alongside the auth token and device id — `clearLocalData` + * funnels all three teardowns (sign-out, account deletion, device revocation) here. + * Resetting the shared client drops the in-memory identity, and bumping the + * generation fences any bind that is still in flight so it can't re-persist the + * wiped secret — the next dial re-binds a fresh NodeId instead. + * + * TODO: store the secret behind the encryption middleware so it never sits in + * plaintext — see docs/architecture/e2e-encryption.md (same path the auth-token + * TODO tracks). + */ +export const clearIrohClientSecret = (): void => { + secretGeneration += 1 + sharedClient = null + if (typeof localStorage === 'undefined') { + return + } + localStorage.removeItem(secretStorageKey) +} + +/** Bind via `bind`, then persist the resulting secret — but only if no wipe raced + * the bind. Sampling the generation before the async work and re-checking it after + * closes the sign-out TOCTOU: an in-flight bind that resolves after + * `clearIrohClientSecret` must not resurrect the cleared credential. */ +const bindAndPersist = async ( + bind: () => Promise<{ client: IrohClientLike; secretHex: string }>, +): Promise => { + const boundAtGeneration = secretGeneration + const { client, secretHex } = await bind() + if (boundAtGeneration === secretGeneration) { + persistSecret(secretHex) + } + return client +} + +/** Build-time relay override (`VITE_IROH_RELAY_URL`). Unset/empty → `undefined`, + * so the wasm client keeps `presets::N0` (the n0 public relays — today's + * behavior); set it to a self-hosted iroh-relay wss URL to route every dial + * through it (n0 DNS discovery + crypto are kept, only the relay hop changes). + * Vite inlines this at build time, so switching relays is an env change + redeploy + * — no code edit. */ +export const irohRelayUrl = (): string | undefined => { + const url = import.meta.env.VITE_IROH_RELAY_URL?.trim() + return url ? url : undefined +} + +/** The wasm `IrohClient.create` factory, narrowed to the build-time relay + * override; the persisted secret is read INSIDE the factory (after wasm init) so + * its wipe-race timing is unchanged. Returns a client that also exposes its + * secret as hex. */ +type IrohClientFactory = (relayUrl: string | undefined) => Promise string }> + +/** Bind the wasm client, threading the build-time relay override into `create`. + * Split from {@link defaultLoadClient} so a fake `create` can assert the relay + * URL is forwarded without loading the multi-MB wasm chunk. The relay URL is a + * static build-time value (no wipe race), so reading it here — unlike the secret — + * is safe. */ +export const bindIrohClient = async ( + create: IrohClientFactory, +): Promise<{ client: IrohClientLike; secretHex: string }> => { + const client = await create(irohRelayUrl()) + return { client, secretHex: client.secretKeyHex() } +} + +/** Lazy-load + bind the wasm iroh client, pinning a persisted identity and an + * optional self-hosted relay. The dynamic import is the route-split point — the + * multi-MB wasm chunk never lands in the entry bundle. `readPersistedSecret()` + * stays AFTER `wasm.default()` so the sign-out/wipe race window is byte-for-byte + * the pre-relay behavior. */ +const defaultLoadClient: IrohClientLoader = () => + bindAndPersist(() => + bindIrohClient(async (relayUrl) => { + const wasm = await import('./pkg/thunderbolt_acp_client.js') + await wasm.default() + const client = await wasm.IrohClient.create(readPersistedSecret(), relayUrl) + return client as unknown as IrohClientLike & { secretKeyHex: () => string } + }), + ) + +/** Get (binding once) the shared iroh client. A failed bind clears the cache so + * a later attempt can retry rather than replaying a rejected promise forever. + * The eviction is identity-guarded: it clears the cache only if this exact bind + * is still the cached one, so a concurrent rebind (e.g. after an aborted dial + * already evicted this entry) is never clobbered by the old bind's late + * rejection. */ +const getSharedClient = (load: IrohClientLoader): Promise => { + if (!sharedClient) { + const pending: Promise = load().catch((err: unknown) => { + if (sharedClient === pending) { + sharedClient = null + } + throw err + }) + sharedClient = pending + } + return sharedClient +} + +/** Evict the shared client iff `pending` is still the cached bind. Called when a + * dial is aborted while the shared bind is still in flight, so the next dial + * rebinds rather than awaiting a possibly-stuck endpoint. Never evicts a newer + * bind that has already replaced this one. */ +const clearSharedClientIfPending = (pending: Promise): void => { + if (sharedClient === pending) { + sharedClient = null + } +} + +/** Settle with `promise`, but reject with an `AbortError` the instant `signal` + * aborts — so a slow bind/dial over an offline or captive relay can't hang the + * caller (the wasm client warms the relay lazily on the first dial, so this is + * the only place that bounds it). `onAbort` runs only when the abort wins the + * race — to evict a stuck bind, or to close a connection that resolves too late + * to be used. The listener is detached as soon as `promise` settles, so it never + * leaks onto the adapter's long-lived signal. */ +const raceAbort = (promise: Promise, signal: AbortSignal, onAbort?: () => void): Promise => { + if (signal.aborted) { + onAbort?.() + return Promise.reject(new DOMException('aborted', 'AbortError')) + } + return new Promise((resolve, reject) => { + const abortListener = (): void => { + onAbort?.() + reject(new DOMException('aborted', 'AbortError')) + } + signal.addEventListener('abort', abortListener, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', abortListener) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', abortListener) + reject(error) + }, + ) + }) +} + +/** This app's iroh NodeId — what a bridge operator runs + * `thunderbolt iroh allow ` against. Binds the shared client on first + * call. Surfaced for the agent-settings UI (wired in a later slice). */ +export const irohClientNodeId = async (load: IrohClientLoader = defaultLoadClient): Promise => + (await getSharedClient(load)).nodeId() + +export type DialIrohBridgeOptions = { + /** EndpointTicket or bare NodeId printed by the CLI bridge. */ + target: string + /** ALPN of the bridge protocol — must match the CLI byte-for-byte or the QUIC + * handshake is refused (e.g. {@link acpIrohAlpn} vs the MCP bridge's ALPN). */ + alpn: string + /** Aborting cancels an in-flight bind/dial (and closes a connection that + * resolves too late to be used). */ + signal: AbortSignal + /** Test seam — production omits and lazy-loads the wasm client. */ + loadClient?: IrohClientLoader +} + +/** + * Dial an iroh bridge `target` over the shared relay endpoint, opening one bidi + * byte stream under `alpn`. The protocol-agnostic core both the ACP transport + * (this file) and the MCP transport (`src/lib/mcp-iroh-transport.ts`) build their + * framing on — one place owns the bind/dial abort-race handling. + * + * Races the bind AND the dial against the signal: the wasm client warms the relay + * lazily on the first dial, so on an offline/captive network either step can take + * a while — without racing, an abort would be a no-op until a listener is attached + * downstream, hanging the caller. An abort during the bind evicts the shared + * client so the next dial rebinds rather than awaiting a possibly-stuck endpoint; + * an abort during the dial closes a connection that resolves too late, so a dial + * that wins the race a tick after the abort can't leak a live QUIC stream nothing + * will read or close. + */ +export const dialIrohBridge = async (options: DialIrohBridgeOptions): Promise => { + const { signal } = options + // Bail before binding when already aborted, matching the WebSocket path (which + // rejects with `AbortError`). + if (signal.aborted) { + throw new DOMException('aborted', 'AbortError') + } + + const clientPromise = getSharedClient(options.loadClient ?? defaultLoadClient) + const client = await raceAbort(clientPromise, signal, () => clearSharedClientIfPending(clientPromise)) + + const connectPromise = client.connect(options.target, options.alpn) + return raceAbort(connectPromise, signal, () => { + void connectPromise.then((conn) => conn.close()).catch(() => {}) + }) +} + +export type OpenIrohTransportOptions = { + /** EndpointTicket or bare NodeId printed by the CLI bridge (held in + * `agent.url` for an iroh agent). */ + target: string + /** Aborting tears the transport (and its bidi stream) down. */ + signal: AbortSignal + /** Override the ALPN (e.g. an MCP bridge). Defaults to {@link acpIrohAlpn}. */ + alpn?: string + /** Test seam — production omits and lazy-loads the wasm client. */ + loadClient?: IrohClientLoader +} + +/** Open an ACP transport against an iroh bridge `target`. Dials over the shared + * relay endpoint, opens one bidi stream, and frames ACP JSON-RPC as ndjson. */ +export const openIrohTransport = async (options: OpenIrohTransportOptions): Promise => { + const connection = await dialIrohBridge({ + target: options.target, + alpn: options.alpn ?? acpIrohAlpn, + signal: options.signal, + loadClient: options.loadClient, + }) + + // `closed` rejects on a transport-level read error and resolves on clean EOF / + // caller close, so the adapter's handshake can race it and fail loudly instead + // of hanging on a pending `initialize` (mirrors the WebSocket transport). + let settleClosed: ((reason: Error | null) => void) | null = null + const closed = new Promise((resolve, reject) => { + settleClosed = (reason) => (reason ? reject(reason) : resolve()) + }) + closed.catch(() => {}) + const settleClosedOnce = (reason: Error | null): void => { + if (!settleClosed) { + return + } + const settle = settleClosed + settleClosed = null + settle(reason) + } + + const decoder = createNdjsonDecoder() + let readableController: ReadableStreamDefaultController | null = null + let readableClosed = false + const readable = new ReadableStream({ + start(controller) { + readableController = controller + }, + }) + + // Guard every controller op behind one flag so a normal close, a read error, + // and a consumer cancel can't double-close/enqueue-after-close (the same + // discipline the WebSocket transport uses). + const closeReadable = (): void => { + if (readableClosed) { + return + } + readableClosed = true + readableController?.close() + } + + // `close` is also the abort listener, so a normal close detaches it (rather + // than leaving it attached to the adapter's long-lived signal). Idempotent: + // `closeReadable`, `settleClosedOnce`, and the wasm `connection.close()` all + // no-op on re-entry. Defined before `writable`/`pumpReceive` so both route + // their teardown through this single path. + const close = (): void => { + options.signal.removeEventListener('abort', close) + closeReadable() + connection.close() + settleClosedOnce(null) + } + + // Pump iroh recv bytes → ndjson decode → ACP messages, for the session's life. + const pumpReceive = async (): Promise => { + const reader = connection.readable().getReader() + try { + while (true) { + const { value, done } = await reader.read() + if (done || readableClosed) { + break + } + for (const message of decoder.push(value)) { + readableController?.enqueue(message as AnyMessage) + } + } + closeReadable() + settleClosedOnce(null) + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + if (!readableClosed) { + readableClosed = true + readableController?.error(error) + } + // Settle `closed` with the error FIRST (so `close()`'s `settleClosedOnce(null)` + // no-ops), then route the rest of teardown through the same `close()` the + // normal path uses — detaching the abort listener from the adapter's + // long-lived signal and closing the QUIC connection. Without this, a recv + // read error (or an oversized ndjson frame) would leak both. All of + // `close()`'s steps are idempotent, so the double-call is safe. + settleClosedOnce(error) + close() + } + } + void pumpReceive() + + const writable = new WritableStream({ + async write(message) { + await connection.send(encodeNdjsonFrame(message)) + }, + close() { + close() + }, + abort() { + close() + }, + }) + + const stream: Stream = { readable, writable } + + options.signal.addEventListener('abort', close, { once: true }) + + return { stream, close, closed } +} + +/** Reset the shared client and secret generation — tests only, so module state + * doesn't leak between cases. */ +export const resetSharedIrohClientForTests = (): void => { + sharedClient = null + secretGeneration = 0 +} + +/** Test seam — drives {@link bindAndPersist}'s wipe-race guard with a fake bind, + * so the sign-out TOCTOU is covered without instantiating the real wasm client. */ +export const bindAndPersistForTests = bindAndPersist diff --git a/src/acp/iroh/ndjson.test.ts b/src/acp/iroh/ndjson.test.ts new file mode 100644 index 000000000..64da020e9 --- /dev/null +++ b/src/acp/iroh/ndjson.test.ts @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { createNdjsonDecoder, encodeNdjsonFrame } from './ndjson' + +const decode = (bytes: Uint8Array): string => new TextDecoder().decode(bytes) + +describe('encodeNdjsonFrame', () => { + it('serializes a message as a single newline-terminated JSON line', () => { + const frame = encodeNdjsonFrame({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + expect(decode(frame)).toBe('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n') + }) +}) + +describe('createNdjsonDecoder', () => { + const enc = (s: string): Uint8Array => new TextEncoder().encode(s) + + it('decodes a single complete line', () => { + const decoder = createNdjsonDecoder() + expect(decoder.push(enc('{"a":1}\n'))).toEqual([{ a: 1 }]) + }) + + it('decodes multiple messages in one chunk', () => { + const decoder = createNdjsonDecoder() + expect(decoder.push(enc('{"a":1}\n{"b":2}\n'))).toEqual([{ a: 1 }, { b: 2 }]) + }) + + it('buffers a line split across chunks until its newline arrives', () => { + const decoder = createNdjsonDecoder() + expect(decoder.push(enc('{"a":'))).toEqual([]) + expect(decoder.push(enc('1}\n'))).toEqual([{ a: 1 }]) + }) + + it('holds an unterminated trailing line and completes it on the next chunk', () => { + const decoder = createNdjsonDecoder() + expect(decoder.push(enc('{"a":1}\n{"b":'))).toEqual([{ a: 1 }]) + expect(decoder.push(enc('2}\n'))).toEqual([{ b: 2 }]) + }) + + it('skips blank lines', () => { + const decoder = createNdjsonDecoder() + expect(decoder.push(enc('\n{"a":1}\n\n'))).toEqual([{ a: 1 }]) + }) + + it('reassembles a multi-byte UTF-8 character split across chunks', () => { + const decoder = createNdjsonDecoder() + // '✅' is 3 bytes (0xE2 0x9C 0x85); split after the first byte. + const full = enc('{"s":"✅"}\n') + const splitAt = 7 // mid the multi-byte sequence + expect(decoder.push(full.slice(0, splitAt))).toEqual([]) + expect(decoder.push(full.slice(splitAt))).toEqual([{ s: '✅' }]) + }) + + it('fails loud instead of buffering an unbounded newline-less stream (no tab OOM)', () => { + const decoder = createNdjsonDecoder() + // A peer streaming bytes that never contain a newline must not grow the + // pending-line buffer forever. Feed > 16MB across chunks with no '\n'. + const chunk = enc('a'.repeat(4 * 1024 * 1024)) // 4MB, no newline + expect(decoder.push(chunk)).toEqual([]) + expect(decoder.push(chunk)).toEqual([]) + expect(decoder.push(chunk)).toEqual([]) + expect(decoder.push(chunk)).toEqual([]) + expect(() => decoder.push(chunk)).toThrow('ndjson frame exceeded') + }) + + it('rejects a single oversized newline-terminated frame before parsing it', () => { + const decoder = createNdjsonDecoder() + // A completed frame larger than the cap must not reach JSON.parse (the + // allocation alone can OOM the tab), even though it carries its own newline. + const oversized = enc(`${'a'.repeat(17 * 1024 * 1024)}\n`) + expect(() => decoder.push(oversized)).toThrow('ndjson frame exceeded') + }) + + it('still decodes normally after a long-but-bounded partial line', () => { + const decoder = createNdjsonDecoder() + const big = `{"big":"${'x'.repeat(1024 * 1024)}"}` // ~1MB, well under the cap + expect(decoder.push(enc(big))).toEqual([]) // buffered, no newline yet + expect(decoder.push(enc('\n'))).toEqual([{ big: 'x'.repeat(1024 * 1024) }]) + }) +}) diff --git a/src/acp/iroh/ndjson.ts b/src/acp/iroh/ndjson.ts new file mode 100644 index 000000000..6773e6287 --- /dev/null +++ b/src/acp/iroh/ndjson.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Newline-delimited JSON framing for the iroh byte pipe. + * + * An iroh bidi stream is a raw byte pipe with no message boundaries, so the ACP + * JSON-RPC objects are framed exactly as the CLI bridge frames them + * (`cli/src/iroh/pump.ts`): one JSON value per line, `\n`-terminated. The + * WebSocket transport gets discrete frames for free; here we add/strip the + * newline and re-assemble lines split across QUIC reads. + */ + +/** + * Hard ceiling on a single ndjson frame — whether it is still buffering across + * chunks (no newline yet) or arrived already newline-terminated. A bridge that + * streams bytes without a newline would grow the pending buffer unbounded, and a + * single oversized terminated frame would hand `JSON.parse` a huge allocation; + * either OOMs the tab. Past this size we fail loud instead. Measured in UTF-16 + * code units (`string.length`) — for the ASCII JSON-RPC traffic this frames that + * tracks bytes closely, and for multi-byte content it still bounds memory within + * a small constant factor. + */ +const maxFrameLength = 16 * 1024 * 1024 + +/** Encode a JSON-RPC message as a single `\n`-terminated UTF-8 frame. */ +export const encodeNdjsonFrame = (message: unknown): Uint8Array => + new TextEncoder().encode(`${JSON.stringify(message)}\n`) + +/** A stateful decoder that re-assembles complete JSON values from byte chunks, + * buffering a partial trailing line until its newline arrives. */ +export type NdjsonDecoder = { + /** Feed a received byte chunk; returns every complete message it completes. */ + push: (chunk: Uint8Array) => unknown[] +} + +/** + * Create an {@link NdjsonDecoder}. A streaming `TextDecoder` handles multi-byte + * UTF-8 sequences split across chunk boundaries; line buffering handles JSON + * values split across chunks. Blank lines are skipped. + */ +export const createNdjsonDecoder = (): NdjsonDecoder => { + const textDecoder = new TextDecoder() + let buffer = '' + return { + push: (chunk) => { + buffer += textDecoder.decode(chunk, { stream: true }) + const lines = buffer.split('\n') + // The final element is the (possibly empty) incomplete trailing line. + buffer = lines.pop() ?? '' + // No single frame may exceed the cap — neither the still-pending trailing + // line (which grows across newline-less chunks) nor an already-completed + // line (a lone oversized terminated frame would still hit `JSON.parse`). + if (buffer.length > maxFrameLength || lines.some((line) => line.length > maxFrameLength)) { + // Reset so a caught error can't be retried into the same overflow, then + // fail loud — a frame this large is a broken/abusive peer, not a value + // we should keep buffering or parse toward an OOM. + buffer = '' + throw new Error(`ndjson frame exceeded ${maxFrameLength} chars`) + } + return lines.filter((line) => line.trim().length > 0).map((line) => JSON.parse(line) as unknown) + }, + } +} diff --git a/src/acp/iroh/pkg/CHECKSUMS.txt b/src/acp/iroh/pkg/CHECKSUMS.txt new file mode 100644 index 000000000..c13aff511 --- /dev/null +++ b/src/acp/iroh/pkg/CHECKSUMS.txt @@ -0,0 +1,5 @@ +cd1d27ddd0fc4530c6ed5bbe268df67f08bbe06228a44aab9e5b116a478f0c81 thunderbolt_acp_client_bg.wasm +8ec2f181b845b5f6b3a4bfbe78c17d46bdf0e7cc330867228c33287ea39ae62c thunderbolt_acp_client_bg.wasm.d.ts +75370cf3e7062c0ebc2344c7c0d61a3cc6abcc2c1b49679e9c93cb955d13914c thunderbolt_acp_client.d.ts +a36f6afcbd76d3e68712d90f528b1d4d8c1c4e884298fb8cc5d656056d350c47 thunderbolt_acp_client.js +cb31d9f2d9956ab520067916a6cc48ac5ac99d841c165e465d42db193defea39 package.json diff --git a/src/acp/iroh/pkg/package.json b/src/acp/iroh/pkg/package.json new file mode 100644 index 000000000..88dba6d8d --- /dev/null +++ b/src/acp/iroh/pkg/package.json @@ -0,0 +1,17 @@ +{ + "name": "thunderbolt-acp-client", + "type": "module", + "description": "Relay-only iroh client (wasm) that dials a Thunderbolt CLI ACP/MCP bridge over an n0 relay.", + "version": "0.1.0", + "license": "MPL-2.0", + "files": [ + "thunderbolt_acp_client_bg.wasm", + "thunderbolt_acp_client.js", + "thunderbolt_acp_client.d.ts" + ], + "main": "thunderbolt_acp_client.js", + "types": "thunderbolt_acp_client.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} \ No newline at end of file diff --git a/src/acp/iroh/pkg/thunderbolt_acp_client.d.ts b/src/acp/iroh/pkg/thunderbolt_acp_client.d.ts new file mode 100644 index 000000000..7cf28388c --- /dev/null +++ b/src/acp/iroh/pkg/thunderbolt_acp_client.d.ts @@ -0,0 +1,194 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * The `ReadableStreamType` enum. + * + * *This API requires the following crate features to be activated: `ReadableStreamType`* + */ + +export type ReadableStreamType = "bytes"; + +export class IntoUnderlyingByteSource { + private constructor(); + free(): void; + [Symbol.dispose](): void; + cancel(): void; + pull(controller: ReadableByteStreamController): Promise; + start(controller: ReadableByteStreamController): void; + readonly autoAllocateChunkSize: number; + readonly type: ReadableStreamType; +} + +export class IntoUnderlyingSink { + private constructor(); + free(): void; + [Symbol.dispose](): void; + abort(reason: any): Promise; + close(): Promise; + write(chunk: any): Promise; +} + +export class IntoUnderlyingSource { + private constructor(); + free(): void; + [Symbol.dispose](): void; + cancel(): void; + pull(controller: ReadableStreamDefaultController): Promise; +} + +/** + * One long-lived relay-only iroh endpoint. Hold a single instance for the app's + * lifetime and open a connection per bridge — re-binding per dial would churn + * the relay handshake and the NodeId. + */ +export class IrohClient { + private constructor(); + free(): void; + [Symbol.dispose](): void; + /** + * Dial `target` (an `EndpointTicket` or a bare NodeId) over `alpn`, open ONE + * bidirectional stream, and resolve to an [`IrohConnection`]. + * + * Returns a `Promise` (rather than an `async fn` borrowing `&self`) so the + * endpoint is cloned out synchronously and the future owns it. + */ + connect(target: string, alpn: string): Promise; + /** + * Bind the relay-only endpoint. Returns as soon as the endpoint is bound; + * the home relay is warmed lazily by the first [`IrohClient::connect`]. + * + * We deliberately do NOT pre-warm the relay here (no `endpoint.online()`): + * that call has no timeout, so on an offline or captive network it pends + * forever, and the JS side caches this future in an app-wide singleton — a + * never-resolving bind would poison every later dial. `connect()` resolves + * the relay path on demand instead, and the JS transport bounds that dial + * with its `AbortSignal`. `bind()` itself does not block on connectivity. + * + * Pass a 32-byte hex secret key to pin a stable NodeId (so the bridge + * operator runs `thunderbolt iroh allow ` only once); pass `null` + * to generate a fresh identity, then read it back via + * [`IrohClient::secret_key_hex`] to persist for next session. + * + * `relay_url` overrides the relay: `None`/empty keeps the n0 preset's public + * relays (today's behavior); a self-hosted iroh-relay URL swaps ONLY the + * relay, leaving the n0 DNS discovery + crypto from `presets::N0` intact so a + * bare NodeId still resolves and tickets still dial. The web app threads + * `VITE_IROH_RELAY_URL` through here. + */ + static create(secret_key_hex?: string | null, relay_url?: string | null): Promise; + /** + * This client's NodeId (base32). The bridge operator allowlists it with + * `thunderbolt iroh allow `. + */ + nodeId(): string; + /** + * This client's secret key as hex, so the app can persist it and re-create + * the SAME NodeId next session. + */ + secretKeyHex(): string; +} + +/** + * A live bridge connection: one QUIC bidi stream over the relay. Sending queues + * bytes for the write task; the receive half is exposed once as a JS + * `ReadableStream` of `Uint8Array` chunks. + */ +export class IrohConnection { + private constructor(); + free(): void; + [Symbol.dispose](): void; + /** + * Close the connection: stop the outbound queue (finishing the send half) + * and close the QUIC connection. + */ + close(): void; + /** + * The receive half as a `ReadableStream`. Consumed once — the JS + * transport reads it for the lifetime of the session. + */ + readable(): ReadableStream; + /** + * Write `data` to the bidi stream, resolving only once the bytes are ACTUALLY + * written and rejecting if the write fails — the promise reflects the real + * write outcome, never merely that the frame was buffered. The frame is + * handed to the write task over the bounded queue (so `self` is never held + * across an await and backpressure is preserved), carrying a `oneshot` the + * task fulfils once the write settles. Rejects if the connection is already + * closed, or if the write task tears down before this frame is written — so + * an accepted frame is never silently dropped. + */ + send(data: Uint8Array): Promise; +} + +/** + * Installs a panic hook that surfaces Rust panics as readable console errors. + */ +export function start(): void; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_irohclient_free: (a: number, b: number) => void; + readonly __wbg_irohconnection_free: (a: number, b: number) => void; + readonly irohclient_connect: (a: number, b: number, c: number, d: number, e: number) => number; + readonly irohclient_create: (a: number, b: number, c: number, d: number) => number; + readonly irohclient_nodeId: (a: number, b: number) => void; + readonly irohclient_secretKeyHex: (a: number, b: number) => void; + readonly irohconnection_close: (a: number) => void; + readonly irohconnection_readable: (a: number, b: number) => void; + readonly irohconnection_send: (a: number, b: number, c: number) => number; + readonly start: () => void; + readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void; + readonly intounderlyingsource_cancel: (a: number) => void; + readonly intounderlyingsource_pull: (a: number, b: number) => number; + readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void; + readonly intounderlyingsink_abort: (a: number, b: number) => number; + readonly intounderlyingsink_close: (a: number) => number; + readonly intounderlyingsink_write: (a: number, b: number) => number; + readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void; + readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number; + readonly intounderlyingbytesource_cancel: (a: number) => void; + readonly intounderlyingbytesource_pull: (a: number, b: number) => number; + readonly intounderlyingbytesource_start: (a: number, b: number) => void; + readonly intounderlyingbytesource_type: (a: number) => number; + readonly ring_core_0_17_14__bn_mul_mont: (a: number, b: number, c: number, d: number, e: number, f: number) => void; + readonly __wasm_bindgen_func_elem_16171: (a: number, b: number, c: number, d: number) => void; + readonly __wasm_bindgen_func_elem_16155: (a: number, b: number, c: number, d: number) => void; + readonly __wasm_bindgen_func_elem_5329: (a: number, b: number, c: number) => void; + readonly __wasm_bindgen_func_elem_3006: (a: number, b: number, c: number) => void; + readonly __wasm_bindgen_func_elem_7193: (a: number, b: number, c: number) => void; + readonly __wasm_bindgen_func_elem_5160: (a: number, b: number) => void; + readonly __wasm_bindgen_func_elem_6313: (a: number, b: number) => void; + readonly __wasm_bindgen_func_elem_6447: (a: number, b: number) => void; + readonly __wasm_bindgen_func_elem_14839: (a: number, b: number) => void; + readonly __wbindgen_export: (a: number, b: number) => number; + readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_export3: (a: number) => void; + readonly __wbindgen_export4: (a: number, b: number, c: number) => void; + readonly __wbindgen_export5: (a: number, b: number) => void; + readonly __wbindgen_add_to_stack_pointer: (a: number) => number; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/src/acp/iroh/pkg/thunderbolt_acp_client.js b/src/acp/iroh/pkg/thunderbolt_acp_client.js new file mode 100644 index 000000000..6891dc1f9 --- /dev/null +++ b/src/acp/iroh/pkg/thunderbolt_acp_client.js @@ -0,0 +1,1343 @@ +/* @ts-self-types="./thunderbolt_acp_client.d.ts" */ + +export class IntoUnderlyingByteSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingByteSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingbytesource_free(ptr, 0); + } + /** + * @returns {number} + */ + get autoAllocateChunkSize() { + const ret = wasm.intounderlyingbytesource_autoAllocateChunkSize(this.__wbg_ptr); + return ret >>> 0; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingbytesource_cancel(ptr); + } + /** + * @param {ReadableByteStreamController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingbytesource_pull(this.__wbg_ptr, addHeapObject(controller)); + return takeObject(ret); + } + /** + * @param {ReadableByteStreamController} controller + */ + start(controller) { + wasm.intounderlyingbytesource_start(this.__wbg_ptr, addHeapObject(controller)); + } + /** + * @returns {ReadableStreamType} + */ + get type() { + const ret = wasm.intounderlyingbytesource_type(this.__wbg_ptr); + return __wbindgen_enum_ReadableStreamType[ret]; + } +} +if (Symbol.dispose) IntoUnderlyingByteSource.prototype[Symbol.dispose] = IntoUnderlyingByteSource.prototype.free; + +export class IntoUnderlyingSink { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSinkFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsink_free(ptr, 0); + } + /** + * @param {any} reason + * @returns {Promise} + */ + abort(reason) { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_abort(ptr, addHeapObject(reason)); + return takeObject(ret); + } + /** + * @returns {Promise} + */ + close() { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_close(ptr); + return takeObject(ret); + } + /** + * @param {any} chunk + * @returns {Promise} + */ + write(chunk) { + const ret = wasm.intounderlyingsink_write(this.__wbg_ptr, addHeapObject(chunk)); + return takeObject(ret); + } +} +if (Symbol.dispose) IntoUnderlyingSink.prototype[Symbol.dispose] = IntoUnderlyingSink.prototype.free; + +export class IntoUnderlyingSource { + static __wrap(ptr) { + const obj = Object.create(IntoUnderlyingSource.prototype); + obj.__wbg_ptr = ptr; + IntoUnderlyingSourceFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsource_free(ptr, 0); + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingsource_cancel(ptr); + } + /** + * @param {ReadableStreamDefaultController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingsource_pull(this.__wbg_ptr, addHeapObject(controller)); + return takeObject(ret); + } +} +if (Symbol.dispose) IntoUnderlyingSource.prototype[Symbol.dispose] = IntoUnderlyingSource.prototype.free; + +/** + * One long-lived relay-only iroh endpoint. Hold a single instance for the app's + * lifetime and open a connection per bridge — re-binding per dial would churn + * the relay handshake and the NodeId. + */ +export class IrohClient { + static __wrap(ptr) { + const obj = Object.create(IrohClient.prototype); + obj.__wbg_ptr = ptr; + IrohClientFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IrohClientFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_irohclient_free(ptr, 0); + } + /** + * Dial `target` (an `EndpointTicket` or a bare NodeId) over `alpn`, open ONE + * bidirectional stream, and resolve to an [`IrohConnection`]. + * + * Returns a `Promise` (rather than an `async fn` borrowing `&self`) so the + * endpoint is cloned out synchronously and the future owns it. + * @param {string} target + * @param {string} alpn + * @returns {Promise} + */ + connect(target, alpn) { + const ptr0 = passStringToWasm0(target, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(alpn, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.irohclient_connect(this.__wbg_ptr, ptr0, len0, ptr1, len1); + return takeObject(ret); + } + /** + * Bind the relay-only endpoint. Returns as soon as the endpoint is bound; + * the home relay is warmed lazily by the first [`IrohClient::connect`]. + * + * We deliberately do NOT pre-warm the relay here (no `endpoint.online()`): + * that call has no timeout, so on an offline or captive network it pends + * forever, and the JS side caches this future in an app-wide singleton — a + * never-resolving bind would poison every later dial. `connect()` resolves + * the relay path on demand instead, and the JS transport bounds that dial + * with its `AbortSignal`. `bind()` itself does not block on connectivity. + * + * Pass a 32-byte hex secret key to pin a stable NodeId (so the bridge + * operator runs `thunderbolt iroh allow ` only once); pass `null` + * to generate a fresh identity, then read it back via + * [`IrohClient::secret_key_hex`] to persist for next session. + * + * `relay_url` overrides the relay: `None`/empty keeps the n0 preset's public + * relays (today's behavior); a self-hosted iroh-relay URL swaps ONLY the + * relay, leaving the n0 DNS discovery + crypto from `presets::N0` intact so a + * bare NodeId still resolves and tickets still dial. The web app threads + * `VITE_IROH_RELAY_URL` through here. + * @param {string | null} [secret_key_hex] + * @param {string | null} [relay_url] + * @returns {Promise} + */ + static create(secret_key_hex, relay_url) { + var ptr0 = isLikeNone(secret_key_hex) ? 0 : passStringToWasm0(secret_key_hex, wasm.__wbindgen_export, wasm.__wbindgen_export2); + var len0 = WASM_VECTOR_LEN; + var ptr1 = isLikeNone(relay_url) ? 0 : passStringToWasm0(relay_url, wasm.__wbindgen_export, wasm.__wbindgen_export2); + var len1 = WASM_VECTOR_LEN; + const ret = wasm.irohclient_create(ptr0, len0, ptr1, len1); + return takeObject(ret); + } + /** + * This client's NodeId (base32). The bridge operator allowlists it with + * `thunderbolt iroh allow `. + * @returns {string} + */ + nodeId() { + let deferred1_0; + let deferred1_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.irohclient_nodeId(retptr, this.__wbg_ptr); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + deferred1_0 = r0; + deferred1_1 = r1; + return getStringFromWasm0(r0, r1); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export4(deferred1_0, deferred1_1, 1); + } + } + /** + * This client's secret key as hex, so the app can persist it and re-create + * the SAME NodeId next session. + * @returns {string} + */ + secretKeyHex() { + let deferred1_0; + let deferred1_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.irohclient_secretKeyHex(retptr, this.__wbg_ptr); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + deferred1_0 = r0; + deferred1_1 = r1; + return getStringFromWasm0(r0, r1); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export4(deferred1_0, deferred1_1, 1); + } + } +} +if (Symbol.dispose) IrohClient.prototype[Symbol.dispose] = IrohClient.prototype.free; + +/** + * A live bridge connection: one QUIC bidi stream over the relay. Sending queues + * bytes for the write task; the receive half is exposed once as a JS + * `ReadableStream` of `Uint8Array` chunks. + */ +export class IrohConnection { + static __wrap(ptr) { + const obj = Object.create(IrohConnection.prototype); + obj.__wbg_ptr = ptr; + IrohConnectionFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IrohConnectionFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_irohconnection_free(ptr, 0); + } + /** + * Close the connection: stop the outbound queue (finishing the send half) + * and close the QUIC connection. + */ + close() { + wasm.irohconnection_close(this.__wbg_ptr); + } + /** + * The receive half as a `ReadableStream`. Consumed once — the JS + * transport reads it for the lifetime of the session. + * @returns {ReadableStream} + */ + readable() { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.irohconnection_readable(retptr, this.__wbg_ptr); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + if (r2) { + throw takeObject(r1); + } + return takeObject(r0); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Write `data` to the bidi stream, resolving only once the bytes are ACTUALLY + * written and rejecting if the write fails — the promise reflects the real + * write outcome, never merely that the frame was buffered. The frame is + * handed to the write task over the bounded queue (so `self` is never held + * across an await and backpressure is preserved), carrying a `oneshot` the + * task fulfils once the write settles. Rejects if the connection is already + * closed, or if the write task tears down before this frame is written — so + * an accepted frame is never silently dropped. + * @param {Uint8Array} data + * @returns {Promise} + */ + send(data) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.irohconnection_send(this.__wbg_ptr, ptr0, len0); + return takeObject(ret); + } +} +if (Symbol.dispose) IrohConnection.prototype[Symbol.dispose] = IrohConnection.prototype.free; + +/** + * Installs a panic hook that surfaces Rust panics as readable console errors. + */ +export function start() { + wasm.start(); +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg_Error_92b29b0548f8b746: function(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return addHeapObject(ret); + }, + __wbg___wbindgen_boolean_get_fa956cfa2d1bd751: function(arg0) { + const v = getObject(arg0); + const ret = typeof(v) === 'boolean' ? v : undefined; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; + }, + __wbg___wbindgen_debug_string_c25d447a39f5578f: function(arg0, arg1) { + const ret = debugString(getObject(arg1)); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_is_function_1ff95bcc5517c252: function(arg0) { + const ret = typeof(getObject(arg0)) === 'function'; + return ret; + }, + __wbg___wbindgen_is_object_a27215656b807791: function(arg0) { + const val = getObject(arg0); + const ret = typeof(val) === 'object' && val !== null; + return ret; + }, + __wbg___wbindgen_is_string_ea5e6cc2e4141dfe: function(arg0) { + const ret = typeof(getObject(arg0)) === 'string'; + return ret; + }, + __wbg___wbindgen_is_undefined_c05833b95a3cf397: function(arg0) { + const ret = getObject(arg0) === undefined; + return ret; + }, + __wbg___wbindgen_string_get_b0ca35b86a603356: function(arg0, arg1) { + const obj = getObject(arg1); + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg__wbg_cb_unref_fffb441def202758: function(arg0) { + getObject(arg0)._wbg_cb_unref(); + }, + __wbg_abort_8bae0f33e7833997: function(arg0) { + getObject(arg0).abort(); + }, + __wbg_abort_eee9248a6d680839: function(arg0, arg1) { + getObject(arg0).abort(getObject(arg1)); + }, + __wbg_addEventListener_c33b246adf950d7c: function() { return handleError(function (arg0, arg1, arg2, arg3) { + getObject(arg0).addEventListener(getStringFromWasm0(arg1, arg2), getObject(arg3)); + }, arguments); }, + __wbg_append_01c74e5c6b58aa64: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + getObject(arg0).append(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_arrayBuffer_3b637f0fa65c5351: function() { return handleError(function (arg0) { + const ret = getObject(arg0).arrayBuffer(); + return addHeapObject(ret); + }, arguments); }, + __wbg_body_18c9f2ac15ead4b2: function(arg0) { + const ret = getObject(arg0).body; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_buffer_54b87055582c8a81: function(arg0) { + const ret = getObject(arg0).buffer; + return addHeapObject(ret); + }, + __wbg_byobRequest_06b654bb15590436: function(arg0) { + const ret = getObject(arg0).byobRequest; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_byteLength_41862ca4020b9c43: function(arg0) { + const ret = getObject(arg0).byteLength; + return ret; + }, + __wbg_byteOffset_d42e18c4441f628b: function(arg0) { + const ret = getObject(arg0).byteOffset; + return ret; + }, + __wbg_call_a6e5c5dce5018821: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); }, + __wbg_cancel_3983a93e24cc66b3: function(arg0) { + const ret = getObject(arg0).cancel(); + return addHeapObject(ret); + }, + __wbg_catch_c1a60df4c30d76d3: function(arg0, arg1) { + const ret = getObject(arg0).catch(getObject(arg1)); + return addHeapObject(ret); + }, + __wbg_clearTimeout_333bba87532ab9d3: function(arg0) { + const ret = clearTimeout(takeObject(arg0)); + return addHeapObject(ret); + }, + __wbg_clearTimeout_47a40e3be01ed7a3: function() { return handleError(function (arg0, arg1) { + getObject(arg0).clearTimeout(takeObject(arg1)); + }, arguments); }, + __wbg_close_249a23304523681b: function() { return handleError(function (arg0) { + getObject(arg0).close(); + }, arguments); }, + __wbg_close_72d318d9c16e83ef: function() { return handleError(function (arg0) { + getObject(arg0).close(); + }, arguments); }, + __wbg_close_c65ca0257e895318: function() { return handleError(function (arg0) { + getObject(arg0).close(); + }, arguments); }, + __wbg_code_1fc52b4142a112ac: function(arg0) { + const ret = getObject(arg0).code; + return ret; + }, + __wbg_code_cb4327cfc515673b: function(arg0) { + const ret = getObject(arg0).code; + return ret; + }, + __wbg_crypto_38df2bab126b63dc: function(arg0) { + const ret = getObject(arg0).crypto; + return addHeapObject(ret); + }, + __wbg_data_328de4280640da92: function(arg0) { + const ret = getObject(arg0).data; + return addHeapObject(ret); + }, + __wbg_done_89b2b13e91a60321: function(arg0) { + const ret = getObject(arg0).done; + return ret; + }, + __wbg_enqueue_6d83b4c6281bafd6: function() { return handleError(function (arg0, arg1) { + getObject(arg0).enqueue(getObject(arg1)); + }, arguments); }, + __wbg_entries_900cefd6f70eb290: function(arg0) { + const ret = getObject(arg0).entries(); + return addHeapObject(ret); + }, + __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_export4(deferred0_0, deferred0_1, 1); + } + }, + __wbg_fetch_074561c3e313c86f: function(arg0) { + const ret = fetch(getObject(arg0)); + return addHeapObject(ret); + }, + __wbg_fetch_b5951fc96f52f786: function(arg0, arg1) { + const ret = getObject(arg0).fetch(getObject(arg1)); + return addHeapObject(ret); + }, + __wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) { + getObject(arg0).getRandomValues(getObject(arg1)); + }, arguments); }, + __wbg_getRandomValues_cc7f052a444bb2ce: function() { return handleError(function (arg0, arg1) { + globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); + }, arguments); }, + __wbg_getReader_9facd4f899beac89: function() { return handleError(function (arg0) { + const ret = getObject(arg0).getReader(); + return addHeapObject(ret); + }, arguments); }, + __wbg_get_507a50627bffa49b: function(arg0, arg1) { + const ret = getObject(arg0)[arg1 >>> 0]; + return addHeapObject(ret); + }, + __wbg_get_78f252d074a84d0b: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(getObject(arg0), getObject(arg1)); + return addHeapObject(ret); + }, arguments); }, + __wbg_get_done_670108eb06ecbe46: function(arg0) { + const ret = getObject(arg0).done; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; + }, + __wbg_get_value_f465f5be30aa0963: function(arg0) { + const ret = getObject(arg0).value; + return addHeapObject(ret); + }, + __wbg_has_8374cf06984d8bfc: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.has(getObject(arg0), getObject(arg1)); + return ret; + }, arguments); }, + __wbg_headers_cf9c80f30e2a4eff: function(arg0) { + const ret = getObject(arg0).headers; + return addHeapObject(ret); + }, + __wbg_instanceof_ArrayBuffer_4480b9e0068a8adb: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Blob_c6523f92a32c8695: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof Blob; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Response_c8b64b2256f01bec: function(arg0) { + let result; + try { + result = getObject(arg0) instanceof Response; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_irohclient_new: function(arg0) { + const ret = IrohClient.__wrap(arg0); + return addHeapObject(ret); + }, + __wbg_irohconnection_new: function(arg0) { + const ret = IrohConnection.__wrap(arg0); + return addHeapObject(ret); + }, + __wbg_isArray_0677c962b281d01a: function(arg0) { + const ret = Array.isArray(getObject(arg0)); + return ret; + }, + __wbg_length_1f0964f4a5e2c6d8: function(arg0) { + const ret = getObject(arg0).length; + return ret; + }, + __wbg_message_fb0e6e7854e6ea7a: function(arg0, arg1) { + const ret = getObject(arg1).message; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_msCrypto_bd5a034af96bcba6: function(arg0) { + const ret = getObject(arg0).msCrypto; + return addHeapObject(ret); + }, + __wbg_new_0d809930cd1354c6: function() { return handleError(function () { + const ret = new Headers(); + return addHeapObject(ret); + }, arguments); }, + __wbg_new_227d7c05414eb861: function() { + const ret = new Error(); + return addHeapObject(ret); + }, + __wbg_new_32b398fb48b6d94a: function() { + const ret = new Array(); + return addHeapObject(ret); + }, + __wbg_new_4339b2a2675a03e3: function() { return handleError(function () { + const ret = new AbortController(); + return addHeapObject(ret); + }, arguments); }, + __wbg_new_b667d279fd5aa943: function(arg0, arg1) { + const ret = new Error(getStringFromWasm0(arg0, arg1)); + return addHeapObject(ret); + }, + __wbg_new_bf8729ffe10e9ee7: function() { return handleError(function (arg0, arg1) { + const ret = new WebSocket(getStringFromWasm0(arg0, arg1)); + return addHeapObject(ret); + }, arguments); }, + __wbg_new_cd45aabdf6073e84: function(arg0) { + const ret = new Uint8Array(getObject(arg0)); + return addHeapObject(ret); + }, + __wbg_new_da52cf8fe3429cb2: function() { + const ret = new Object(); + return addHeapObject(ret); + }, + __wbg_new_from_slice_77cdfb7977362f3c: function(arg0, arg1) { + const ret = new Uint8Array(getArrayU8FromWasm0(arg0, arg1)); + return addHeapObject(ret); + }, + __wbg_new_typed_1824d93f294193e5: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return __wasm_bindgen_func_elem_16155(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return addHeapObject(ret); + } finally { + state0.a = 0; + } + }, + __wbg_new_with_byte_offset_and_length_54c7724ee3ec7d82: function(arg0, arg1, arg2) { + const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); + }, + __wbg_new_with_into_underlying_source_fd904252f385f59c: function(arg0, arg1) { + const ret = new ReadableStream(IntoUnderlyingSource.__wrap(arg0), takeObject(arg1)); + return addHeapObject(ret); + }, + __wbg_new_with_length_e6785c33c8e4cce8: function(arg0) { + const ret = new Uint8Array(arg0 >>> 0); + return addHeapObject(ret); + }, + __wbg_new_with_str_and_init_d95cbe11ce28e65e: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new Request(getStringFromWasm0(arg0, arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); }, + __wbg_new_with_str_sequence_2de2f569c29910ad: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new WebSocket(getStringFromWasm0(arg0, arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments); }, + __wbg_next_71f2aa1cb3d1e37e: function() { return handleError(function (arg0) { + const ret = getObject(arg0).next(); + return addHeapObject(ret); + }, arguments); }, + __wbg_node_84ea875411254db1: function(arg0) { + const ret = getObject(arg0).node; + return addHeapObject(ret); + }, + __wbg_now_86c0d4ba3fa605b8: function() { + const ret = Date.now(); + return ret; + }, + __wbg_now_e7c6795a7f81e10f: function(arg0) { + const ret = getObject(arg0).now(); + return ret; + }, + __wbg_performance_3fcf6e32a7e1ed0a: function(arg0) { + const ret = getObject(arg0).performance; + return addHeapObject(ret); + }, + __wbg_process_44c7a14e11e9f69e: function(arg0) { + const ret = getObject(arg0).process; + return addHeapObject(ret); + }, + __wbg_protocol_14b3b1c4bf71cd4a: function(arg0, arg1) { + const ret = getObject(arg1).protocol; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_prototypesetcall_4770620bbe4688a0: function(arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), getObject(arg2)); + }, + __wbg_push_d2ae3af0c1217ae6: function(arg0, arg1) { + const ret = getObject(arg0).push(getObject(arg1)); + return ret; + }, + __wbg_queueMicrotask_0ab5b2d2393e99b9: function(arg0) { + const ret = getObject(arg0).queueMicrotask; + return addHeapObject(ret); + }, + __wbg_queueMicrotask_6a09b7bc46549209: function(arg0) { + queueMicrotask(getObject(arg0)); + }, + __wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) { + getObject(arg0).randomFillSync(takeObject(arg1)); + }, arguments); }, + __wbg_read_8afa15f12a160ef8: function(arg0) { + const ret = getObject(arg0).read(); + return addHeapObject(ret); + }, + __wbg_readyState_50bc38c2a9e83db6: function(arg0) { + const ret = getObject(arg0).readyState; + return ret; + }, + __wbg_reason_5dc8e429d537d6a9: function(arg0, arg1) { + const ret = getObject(arg1).reason; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_releaseLock_5b92874cad775644: function(arg0) { + getObject(arg0).releaseLock(); + }, + __wbg_removeEventListener_eb8291c80ca9056d: function() { return handleError(function (arg0, arg1, arg2, arg3) { + getObject(arg0).removeEventListener(getStringFromWasm0(arg1, arg2), getObject(arg3)); + }, arguments); }, + __wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () { + const ret = module.require; + return addHeapObject(ret); + }, arguments); }, + __wbg_resolve_2191a4dfe481c25b: function(arg0) { + const ret = Promise.resolve(getObject(arg0)); + return addHeapObject(ret); + }, + __wbg_respond_510e32df8aeb6817: function() { return handleError(function (arg0, arg1) { + getObject(arg0).respond(arg1 >>> 0); + }, arguments); }, + __wbg_send_a321b376d40ec867: function() { return handleError(function (arg0, arg1, arg2) { + getObject(arg0).send(getArrayU8FromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_send_df98dd5ede9b3f4d: function() { return handleError(function (arg0, arg1, arg2) { + getObject(arg0).send(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_setTimeout_3a808dd861dd3c12: function(arg0, arg1) { + const ret = setTimeout(getObject(arg0), arg1); + return addHeapObject(ret); + }, + __wbg_setTimeout_6613a51400c1bf9f: function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).setTimeout(takeObject(arg1), arg2); + return addHeapObject(ret); + }, arguments); }, + __wbg_set_4d7dd76f3dae2926: function(arg0, arg1, arg2) { + getObject(arg0).set(getArrayU8FromWasm0(arg1, arg2)); + }, + __wbg_set_binaryType_a37b086c78ca7c29: function(arg0, arg1) { + getObject(arg0).binaryType = __wbindgen_enum_BinaryType[arg1]; + }, + __wbg_set_body_029f2d171e0a005f: function(arg0, arg1) { + getObject(arg0).body = getObject(arg1); + }, + __wbg_set_cache_b4a740b195c051f4: function(arg0, arg1) { + getObject(arg0).cache = __wbindgen_enum_RequestCache[arg1]; + }, + __wbg_set_credentials_bb34a40189e3b43b: function(arg0, arg1) { + getObject(arg0).credentials = __wbindgen_enum_RequestCredentials[arg1]; + }, + __wbg_set_handle_event_dd6bc370a8cb4486: function(arg0, arg1) { + getObject(arg0).handleEvent = getObject(arg1); + }, + __wbg_set_headers_9c61d123c3ee1f10: function(arg0, arg1) { + getObject(arg0).headers = getObject(arg1); + }, + __wbg_set_high_water_mark_44d043cd607dd13a: function(arg0, arg1) { + getObject(arg0).highWaterMark = arg1; + }, + __wbg_set_method_5532d59b92d76467: function(arg0, arg1, arg2) { + getObject(arg0).method = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_mode_66c79886ad78fc05: function(arg0, arg1) { + getObject(arg0).mode = __wbindgen_enum_RequestMode[arg1]; + }, + __wbg_set_onclose_f706475385ecce07: function(arg0, arg1) { + getObject(arg0).onclose = getObject(arg1); + }, + __wbg_set_onerror_9f5773fd31512333: function(arg0, arg1) { + getObject(arg0).onerror = getObject(arg1); + }, + __wbg_set_onmessage_836d2f72130b4706: function(arg0, arg1) { + getObject(arg0).onmessage = getObject(arg1); + }, + __wbg_set_onopen_4f65470ae522a61a: function(arg0, arg1) { + getObject(arg0).onopen = getObject(arg1); + }, + __wbg_set_signal_c4ef8faddb4c1446: function(arg0, arg1) { + getObject(arg0).signal = getObject(arg1); + }, + __wbg_signal_dad7cb35193abd31: function(arg0) { + const ret = getObject(arg0).signal; + return addHeapObject(ret); + }, + __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { + const ret = getObject(arg1).stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_static_accessor_GLOBAL_4ef717fb391d88b7: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_static_accessor_GLOBAL_THIS_8d1badc68b5a74f4: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_static_accessor_SELF_146583524fe1469b: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_static_accessor_WINDOW_f2829a2234d7819e: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_status_c45b3b9b3033184a: function(arg0) { + const ret = getObject(arg0).status; + return ret; + }, + __wbg_subarray_3ed232c8a6baee09: function(arg0, arg1, arg2) { + const ret = getObject(arg0).subarray(arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); + }, + __wbg_then_16d107c451e9905d: function(arg0, arg1, arg2) { + const ret = getObject(arg0).then(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, + __wbg_then_6ec10ae38b3e92f7: function(arg0, arg1) { + const ret = getObject(arg0).then(getObject(arg1)); + return addHeapObject(ret); + }, + __wbg_url_a410c0bec2fb1b2c: function(arg0, arg1) { + const ret = getObject(arg1).url; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_url_abdb8fb08377f8c0: function(arg0, arg1) { + const ret = getObject(arg1).url; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_value_a5d5488a9589444a: function(arg0) { + const ret = getObject(arg0).value; + return addHeapObject(ret); + }, + __wbg_versions_276b2795b1c6a219: function(arg0) { + const ret = getObject(arg0).versions; + return addHeapObject(ret); + }, + __wbg_view_21f1d4a4f175dfa9: function(arg0) { + const ret = getObject(arg0).view; + return isLikeNone(ret) ? 0 : addHeapObject(ret); + }, + __wbg_wasClean_3c7aa2335da09e74: function(arg0) { + const ret = getObject(arg0).wasClean; + return ret; + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1584, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_5329); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 3368, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_16171); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 1412, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_3006); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000004: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 2098, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_7193); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000005: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1513, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_5160); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000006: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1731, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_6313); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000007: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1775, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, __wasm_bindgen_func_elem_6447); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000008: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 3344, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_14839); + return addHeapObject(ret); + }, + __wbindgen_cast_0000000000000009: function(arg0, arg1) { + // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. + const ret = getArrayU8FromWasm0(arg0, arg1); + return addHeapObject(ret); + }, + __wbindgen_cast_000000000000000a: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); + }, + __wbindgen_object_clone_ref: function(arg0) { + const ret = getObject(arg0); + return addHeapObject(ret); + }, + __wbindgen_object_drop_ref: function(arg0) { + takeObject(arg0); + }, + }; + return { + __proto__: null, + "./thunderbolt_acp_client_bg.js": import0, + }; +} + +function __wasm_bindgen_func_elem_5160(arg0, arg1) { + wasm.__wasm_bindgen_func_elem_5160(arg0, arg1); +} + +function __wasm_bindgen_func_elem_6313(arg0, arg1) { + wasm.__wasm_bindgen_func_elem_6313(arg0, arg1); +} + +function __wasm_bindgen_func_elem_6447(arg0, arg1) { + wasm.__wasm_bindgen_func_elem_6447(arg0, arg1); +} + +function __wasm_bindgen_func_elem_14839(arg0, arg1) { + wasm.__wasm_bindgen_func_elem_14839(arg0, arg1); +} + +function __wasm_bindgen_func_elem_5329(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_5329(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_3006(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_3006(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_7193(arg0, arg1, arg2) { + wasm.__wasm_bindgen_func_elem_7193(arg0, arg1, addHeapObject(arg2)); +} + +function __wasm_bindgen_func_elem_16171(arg0, arg1, arg2) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.__wasm_bindgen_func_elem_16171(retptr, arg0, arg1, addHeapObject(arg2)); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + if (r1) { + throw takeObject(r0); + } + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } +} + +function __wasm_bindgen_func_elem_16155(arg0, arg1, arg2, arg3) { + wasm.__wasm_bindgen_func_elem_16155(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); +} + + +const __wbindgen_enum_BinaryType = ["blob", "arraybuffer"]; + + +const __wbindgen_enum_ReadableStreamType = ["bytes"]; + + +const __wbindgen_enum_RequestCache = ["default", "no-store", "reload", "no-cache", "force-cache", "only-if-cached"]; + + +const __wbindgen_enum_RequestCredentials = ["omit", "same-origin", "include"]; + + +const __wbindgen_enum_RequestMode = ["same-origin", "no-cors", "cors", "navigate"]; +const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr, 1)); +const IntoUnderlyingSinkFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsink_free(ptr, 1)); +const IntoUnderlyingSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsource_free(ptr, 1)); +const IrohClientFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_irohclient_free(ptr, 1)); +const IrohConnectionFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_irohconnection_free(ptr, 1)); + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => wasm.__wbindgen_export5(state.a, state.b)); + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function dropObject(idx) { + if (idx < 1028) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function getObject(idx) { return heap[idx]; } + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + wasm.__wbindgen_export3(addHeapObject(e)); + } +} + +let heap = new Array(1024).fill(undefined); +heap.push(undefined, null, true, false); + +let heap_next = heap.length; + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeClosure(arg0, arg1, f) { + const state = { a: arg0, b: arg1, cnt: 1 }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + try { + return f(state.a, state.b, ...args); + } finally { + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + wasm.__wbindgen_export5(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function makeMutClosure(arg0, arg1, f) { + const state = { a: arg0, b: arg1, cnt: 1 }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + wasm.__wbindgen_export5(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('thunderbolt_acp_client_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/src/acp/iroh/pkg/thunderbolt_acp_client_bg.wasm b/src/acp/iroh/pkg/thunderbolt_acp_client_bg.wasm new file mode 100644 index 000000000..78809c00d Binary files /dev/null and b/src/acp/iroh/pkg/thunderbolt_acp_client_bg.wasm differ diff --git a/src/acp/iroh/pkg/thunderbolt_acp_client_bg.wasm.d.ts b/src/acp/iroh/pkg/thunderbolt_acp_client_bg.wasm.d.ts new file mode 100644 index 000000000..e1b60489e --- /dev/null +++ b/src/acp/iroh/pkg/thunderbolt_acp_client_bg.wasm.d.ts @@ -0,0 +1,43 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const __wbg_irohclient_free: (a: number, b: number) => void; +export const __wbg_irohconnection_free: (a: number, b: number) => void; +export const irohclient_connect: (a: number, b: number, c: number, d: number, e: number) => number; +export const irohclient_create: (a: number, b: number, c: number, d: number) => number; +export const irohclient_nodeId: (a: number, b: number) => void; +export const irohclient_secretKeyHex: (a: number, b: number) => void; +export const irohconnection_close: (a: number) => void; +export const irohconnection_readable: (a: number, b: number) => void; +export const irohconnection_send: (a: number, b: number, c: number) => number; +export const start: () => void; +export const __wbg_intounderlyingsource_free: (a: number, b: number) => void; +export const intounderlyingsource_cancel: (a: number) => void; +export const intounderlyingsource_pull: (a: number, b: number) => number; +export const __wbg_intounderlyingsink_free: (a: number, b: number) => void; +export const intounderlyingsink_abort: (a: number, b: number) => number; +export const intounderlyingsink_close: (a: number) => number; +export const intounderlyingsink_write: (a: number, b: number) => number; +export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void; +export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number; +export const intounderlyingbytesource_cancel: (a: number) => void; +export const intounderlyingbytesource_pull: (a: number, b: number) => number; +export const intounderlyingbytesource_start: (a: number, b: number) => void; +export const intounderlyingbytesource_type: (a: number) => number; +export const ring_core_0_17_14__bn_mul_mont: (a: number, b: number, c: number, d: number, e: number, f: number) => void; +export const __wasm_bindgen_func_elem_16171: (a: number, b: number, c: number, d: number) => void; +export const __wasm_bindgen_func_elem_16155: (a: number, b: number, c: number, d: number) => void; +export const __wasm_bindgen_func_elem_5329: (a: number, b: number, c: number) => void; +export const __wasm_bindgen_func_elem_3006: (a: number, b: number, c: number) => void; +export const __wasm_bindgen_func_elem_7193: (a: number, b: number, c: number) => void; +export const __wasm_bindgen_func_elem_5160: (a: number, b: number) => void; +export const __wasm_bindgen_func_elem_6313: (a: number, b: number) => void; +export const __wasm_bindgen_func_elem_6447: (a: number, b: number) => void; +export const __wasm_bindgen_func_elem_14839: (a: number, b: number) => void; +export const __wbindgen_export: (a: number, b: number) => number; +export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_export3: (a: number) => void; +export const __wbindgen_export4: (a: number, b: number, c: number) => void; +export const __wbindgen_export5: (a: number, b: number) => void; +export const __wbindgen_add_to_stack_pointer: (a: number) => number; +export const __wbindgen_start: () => void; diff --git a/src/acp/iroh/types.ts b/src/acp/iroh/types.ts new file mode 100644 index 000000000..b68e6eaf9 --- /dev/null +++ b/src/acp/iroh/types.ts @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Structural seams for the wasm iroh client (`crates/thunderbolt-acp-client`). + * + * The generated `pkg/*.d.ts` types are wasm-bindgen flavoured (`Promise`, + * raw `ReadableStream`), so the transport speaks to these narrow structural + * shapes instead. That keeps the transport's framing logic unit-testable with a + * fake client — without instantiating the multi-MB wasm or binding a real + * relay endpoint. + */ + +/** One open bridge connection: a single QUIC bidi stream over the relay. */ +export type IrohConnectionLike = { + /** Write bytes to the send half; resolves once they are actually written and + * rejects if the write fails (or the connection is closed). */ + send: (data: Uint8Array) => Promise + /** The receive half as a byte stream — consumed once. */ + readable: () => ReadableStream + /** Close the connection (finishes the send half, closes QUIC). */ + close: () => void +} + +/** The long-lived relay endpoint. One instance backs every iroh transport. */ +export type IrohClientLike = { + /** This client's NodeId (base32) — what a bridge operator allowlists. */ + nodeId: () => string + /** Dial a ticket or bare NodeId over `alpn`, opening one bidi stream. */ + connect: (target: string, alpn: string) => Promise +} + +/** Loads (and binds) the shared iroh client. Production dynamic-imports the wasm + * chunk; tests inject a fake. */ +export type IrohClientLoader = () => Promise diff --git a/src/acp/translators/acp-to-ai-sdk.ts b/src/acp/translators/acp-to-ai-sdk.ts index a57fa3c8b..5da9316be 100644 --- a/src/acp/translators/acp-to-ai-sdk.ts +++ b/src/acp/translators/acp-to-ai-sdk.ts @@ -408,6 +408,13 @@ export const createTranslatorStream = ( start(c) { controller = c }, + // The consumer (AI SDK) stopped reading — e.g. the user hit Stop. Mark the + // stream closed so any in-flight translator emit or a late `close()` becomes + // a safe no-op regardless of teardown ordering. The adapter wires the actual + // remote `session/cancel` off the request's abort signal. + cancel() { + closed = true + }, }) const emit = (chunk: AiSdkChunk): void => { diff --git a/src/acp/transports/index.ts b/src/acp/transports/index.ts index 782c22b12..fa379f311 100644 --- a/src/acp/transports/index.ts +++ b/src/acp/transports/index.ts @@ -35,12 +35,13 @@ import { computeEffectiveProxyEnabled, createProxyWebSocket } from '@/lib/proxy- import { useLocalSettingsStore } from '@/stores/local-settings-store' import type { AgentType } from '@shared/acp-types' import { encodeWsBearer, wsBearerSubprotocolPrefix, wsCarrierSubprotocol } from '@shared/ws-bearer' +import { openIrohTransport } from '../iroh/iroh-transport' import type { AcpTransport } from '../types' import { openWebSocketTransport, type WebSocketFactory, type WebSocketLike } from './websocket' export type OpenTransportInputs = { url: string - transport: 'websocket' + transport: 'websocket' | 'iroh' /** Agent type drives proxy routing — see file header. `built-in` never * reaches the transport, but the union stays full for type-safety. */ agentType: AgentType @@ -88,6 +89,11 @@ export const isStandaloneTransport = ( * `new WebSocket()` — and unlike the URL/Referer, the subprotocol header is * not logged by default. */ export const openTransport = async (inputs: OpenTransportInputs): Promise => { + // iroh dials a peer bridge by NodeId/ticket over an n0 relay — no URL, proxy, + // or bearer routing applies. `inputs.url` carries the NodeId/ticket. + if (inputs.transport === 'iroh') { + return openIrohTransport({ target: inputs.url, signal: inputs.signal }) + } const webSocketFactory = inputs.webSocketFactory ?? resolveWebSocketFactory(inputs) return openWebSocketTransport({ url: inputs.url, diff --git a/src/acp/types.ts b/src/acp/types.ts index 39db30e99..3120f79b7 100644 --- a/src/acp/types.ts +++ b/src/acp/types.ts @@ -27,11 +27,11 @@ export type AcpTransport = { closed?: Promise } -/** Inputs to `openTransport(...)`. WebSocket is the only remote transport; - * the factory honours the proxy toggle (native socket vs subprotocol tunnel). */ +/** Inputs to `openTransport(...)`. WebSocket honours the proxy toggle (native + * socket vs subprotocol tunnel); `iroh` dials a peer bridge over an n0 relay. */ export type OpenTransportOptions = { url: string - transport: 'websocket' + transport: 'websocket' | 'iroh' /** AbortSignal that, when aborted, must close the transport and cancel any * in-flight retries. The adapter owns this controller and aborts on * `disconnect()`. */ diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index f0bf79466..26de988d3 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -296,6 +296,72 @@ export const mergeMcpTools = async ( } } +/** Raw OpenAI-compatible connection for a model: the three knobs every + * OpenAI-wire provider construction needs ({@link createModel} for the legacy + * Vercel SDK, the in-browser Pi harness for the built-in agent). `fetch` is the + * provider-specific app fetch — the universal proxy fetch for + * `openai`/`custom`/`openrouter`, or the SSO-aware fetch for `thunderbolt`. */ +export type OpenAiCompatConnection = { + baseURL: string + apiKey: string + fetch: FetchFn +} + +/** + * Resolve the raw OpenAI-compatible connection for a model, mirroring the + * per-provider construction in {@link createModel}. Returns `null` for providers + * the OpenAI wire doesn't serve (`anthropic` has its own SDK; `tinfoil` needs the + * enclave client) or when required config is missing (no api key / url) — callers + * fall back to the legacy pipeline rather than crash. + * + * Centralizes the intricate `thunderbolt` SSO-fetch logic so the legacy and Pi + * paths can't drift. + * + * @param modelConfig - the model whose connection to resolve + * @param getProxyFetch - lazily resolved universal proxy fetch + * @returns the connection, or `null` when unsupported/unconfigured + */ +export const resolveOpenAiCompatConnection = ( + modelConfig: Model, + getProxyFetch: () => FetchFn, +): OpenAiCompatConnection | null => { + switch (modelConfig.provider) { + case 'thunderbolt': { + const cloudUrl = getLocalSetting('cloudUrl') + const token = getAuthToken() || 'thunderbolt' + // See the `thunderbolt` case in createModel for the SSO/token rationale: + // SSO web has no bearer token (cookie auth), so strip the placeholder + // Authorization and send credentials; Tauri SSO keeps its real bearer. + const sso = isSsoMode() + const hasRealToken = Boolean(getAuthToken()) + const ssoFetch: typeof fetch = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers) + headers.delete('authorization') + return fetch(input, { ...init, headers, credentials: 'include' }) + }, + { preconnect: fetch.preconnect }, + ) + const providerFetch: FetchFn = sso && !hasRealToken ? ssoFetch : fetch + return { baseURL: cloudUrl, apiKey: token, fetch: providerFetch } + } + case 'openai': + return modelConfig.apiKey + ? { baseURL: 'https://api.openai.com/v1', apiKey: modelConfig.apiKey, fetch: getProxyFetch() } + : null + case 'custom': + return modelConfig.url + ? { baseURL: modelConfig.url, apiKey: modelConfig.apiKey ?? '', fetch: getProxyFetch() } + : null + case 'openrouter': + return modelConfig.apiKey + ? { baseURL: 'https://openrouter.ai/api/v1', apiKey: modelConfig.apiKey, fetch: getProxyFetch() } + : null + default: + return null + } +} + export const createModel = async (modelConfig: Model, getProxyFetch: () => FetchFn) => { // The thunderbolt provider goes through its own SSO-aware fetch below; all // other providers route through the universal proxy. We resolve the proxy @@ -303,8 +369,6 @@ export const createModel = async (modelConfig: Model, getProxyFetch: () => Fetch // (e.g. cloudUrl, proxy_enabled toggle) is picked up. switch (modelConfig.provider) { case 'thunderbolt': { - const cloudUrl = getLocalSetting('cloudUrl') - const token = getAuthToken() || 'thunderbolt' // SSO web flow authenticates via session cookies — the SSO callback is a // browser redirect, not an XHR, so `set-auth-token` never reaches the // client and getAuthToken() returns null. The AI SDKs require an apiKey @@ -316,25 +380,23 @@ export const createModel = async (modelConfig: Model, getProxyFetch: () => Fetch // Tauri desktop SSO uses a loopback server that returns a real bearer // token (stored via setAuthToken). In that case we must keep the // Authorization header because WKWebView can't send cross-origin cookies. - const sso = isSsoMode() - const hasRealToken = Boolean(getAuthToken()) - const ssoFetch = (input: RequestInfo | URL, init?: RequestInit) => { - const headers = new Headers(init?.headers) - headers.delete('authorization') - return fetch(input, { ...init, headers, credentials: 'include' }) + // The connection (baseURL/apiKey/SSO-fetch) lives in + // resolveOpenAiCompatConnection so the Pi harness reuses the same logic. + const conn = resolveOpenAiCompatConnection(modelConfig, getProxyFetch) + if (!conn) { + throw new Error('No connection resolved for thunderbolt provider') } - ssoFetch.preconnect = fetch.preconnect - const providerFetch: typeof fetch = sso && !hasRealToken ? ssoFetch : fetch + const { baseURL, apiKey, fetch: providerFetch } = conn // OpenAI-vendor thunderbolt models use createOpenAI with .chat() to force Chat Completions API // (AI SDK 5 defaults createOpenAI to Responses API which our backend doesn't support) if (modelConfig.vendor === 'openai') { - const provider = createOpenAI({ baseURL: cloudUrl, apiKey: token, fetch: providerFetch }) + const provider = createOpenAI({ baseURL, apiKey, fetch: providerFetch }) return provider.chat(modelConfig.model) } const provider = createOpenAICompatible({ name: 'thunderbolt', - baseURL: cloudUrl, - apiKey: token, + baseURL, + apiKey, fetch: providerFetch, }) return provider(modelConfig.model) @@ -352,38 +414,41 @@ export const createModel = async (modelConfig: Model, getProxyFetch: () => Fetch return anthropic(modelConfig.model) } case 'openai': { - if (!modelConfig.apiKey) { + const conn = resolveOpenAiCompatConnection(modelConfig, getProxyFetch) + if (!conn) { throw new Error('No API key provided') } const openai = createOpenAI({ - apiKey: modelConfig.apiKey, - fetch: getProxyFetch(), + apiKey: conn.apiKey, + fetch: conn.fetch, }) return openai(modelConfig.model) } case 'custom': { - if (!modelConfig.url) { + const conn = resolveOpenAiCompatConnection(modelConfig, getProxyFetch) + if (!conn) { throw new Error('No URL provided for custom provider') } const openaiCompatible = createOpenAICompatible({ name: 'custom', - baseURL: modelConfig.url, - apiKey: modelConfig.apiKey || undefined, - fetch: getProxyFetch(), + baseURL: conn.baseURL, + apiKey: conn.apiKey || undefined, + fetch: conn.fetch, }) return openaiCompatible(modelConfig.model) } case 'openrouter': { - if (!modelConfig.apiKey) { + const conn = resolveOpenAiCompatConnection(modelConfig, getProxyFetch) + if (!conn) { throw new Error('No API key provided') } // Using OpenAI-compatible approach until @openrouter/ai-sdk-provider supports Vercel AI SDK v5 // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/77 const openrouter = createOpenAICompatible({ name: 'openrouter', - baseURL: 'https://openrouter.ai/api/v1', - apiKey: modelConfig.apiKey, - fetch: getProxyFetch(), + baseURL: conn.baseURL, + apiKey: conn.apiKey, + fetch: conn.fetch, }) return openrouter(modelConfig.model) } diff --git a/src/api/encryption.ts b/src/api/encryption.ts index 382675311..4e99873ba 100644 --- a/src/api/encryption.ts +++ b/src/api/encryption.ts @@ -57,6 +57,19 @@ export const revokeDevice = async (httpClient: HttpClient, deviceId: string, can }) } +/** + * Bind a device row to an iroh P2P endpoint identity (node_id). Requires canary + * proof-of-CK-possession — only a trusted device holding the Content Key may attest it. + */ +export const setDeviceNodeId = async ( + httpClient: HttpClient, + deviceId: string, + nodeId: string, + canarySecret: string, +): Promise => { + await httpClient.post(`devices/${encodeURIComponent(deviceId)}/node-id`, { json: { nodeId, canarySecret } }) +} + /** Cancel this device's pending approval state (called by the pending device itself). */ export const cancelPending = async (httpClient: HttpClient): Promise => { await httpClient.post('devices/me/cancel-pending') diff --git a/src/app.tsx b/src/app.tsx index f082cd2c5..7b7f94f3e 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -65,6 +65,7 @@ import { refreshSystemAgents } from '@/db/seeding/seed-agents' import { useLocalSettingsStore } from '@/stores/local-settings-store' import { type ComponentProps, Suspense, lazy, useEffect, useState } from 'react' import { markAppMounted } from '@/lib/init-timing' +import { takeDeviceApprovalReturn } from '@/lib/device-approval-return' import { LazyMotion } from 'framer-motion' // Loaded after first paint so framer-motion feature code lives in an @@ -88,6 +89,10 @@ const IntegrationsPage = lazy(() => import('@/settings/integrations')) // for the extra bundle size and attack surface. const SsoRedirect = lazy(() => import('@/components/sso-redirect')) +// The CLI device-authorization approval page is off the chat/landing critical +// path (only reached via a QR/link), so it ships in its own async chunk. +const DeviceApproval = lazy(() => import('@/components/device-approval')) + // Dev-only routes: guarded by import.meta.env.DEV so Vite eliminates // both the lazy() call and the dynamic import() from production builds. const DevSettingsPage = import.meta.env.DEV ? lazy(() => import('@/settings/dev-settings')) : () => null @@ -138,6 +143,26 @@ const AppContent = ({ initData }: { initData: InitData }) => { ) } +/** + * Home shell for the authenticated `/` tree. When the user lands here right after + * completing login for a CLI device-approval request, replay the stashed `/device` + * URL (see `device-approval-return.ts`) so the approval page reopens pre-filled. + * Read once on mount via a lazy initializer, so normal landings are a no-op. + */ +const HomeShell = () => { + const [deviceReturn] = useState(takeDeviceApprovalReturn) + if (deviceReturn) { + return + } + return ( + <> + + + + + ) +} + const AppRoutes = ({ initData }: { initData: InitData }) => { usePageTracking() useDeepLinkListener() @@ -155,6 +180,7 @@ const AppRoutes = ({ initData }: { initData: InitData }) => { {/* Auth flow routes - NO guards (must work during auth) */} } /> } /> + } /> {/* SSO redirect route — no guard, only in OIDC/SAML mode */} {ssoMode && } />} @@ -171,16 +197,7 @@ const AppRoutes = ({ initData }: { initData: InitData }) => { {/* Main app routes - authenticated only. The gate decides redirect targets internally from VITE_AUTH_MODE + VITE_AUTH_ENABLE_ANONYMOUS. */} }> - - - - - - } - > + }> {/* Home routes with HomeLayout */} }> } /> diff --git a/src/chats/agent-routing.test.ts b/src/chats/agent-routing.test.ts index 60f8137b6..0c2786a34 100644 --- a/src/chats/agent-routing.test.ts +++ b/src/chats/agent-routing.test.ts @@ -311,7 +311,7 @@ describe('createAgentRoutingFetch', () => { expect(saveMessagesSpy.mock.calls[0]?.[0]).toEqual({ id: 't-save-remote', messages: [userMessage] }) }) - it('does NOT persist acpSessionId when the session has no chatThread (new chat)', async () => { + it('persists acpSessionId by thread id even on a new chat whose in-memory chatThread snapshot is null', async () => { resetStore() const disconnect = mock(() => {}) const fakeDb = { __id: 'fake-db' } as never @@ -334,6 +334,9 @@ describe('createAgentRoutingFetch', () => { const updateChatThread = mock(async () => {}) + // A brand-new chat: the store's `chatThread` snapshot is still null, but + // `saveMessages` creates the row, so the fresh ACP id must still persist — + // otherwise resume/load could never fire on the next reconnect. hydrateSessionWith('t-no-thread', remoteAgent, null) const customFetch = createAgentRoutingFetch('t-no-thread', saveMessages, httpClient, getProxyFetch, { @@ -345,7 +348,8 @@ describe('createAgentRoutingFetch', () => { await customFetch('/chat', { method: 'POST', body: '{}' }) await capturedOnAcpSessionId!('any-sess') - expect(updateChatThread).not.toHaveBeenCalled() + expect(updateChatThread).toHaveBeenCalledTimes(1) + expect(updateChatThread).toHaveBeenCalledWith(fakeDb, 't-no-thread', { acpSessionId: 'any-sess' }) }) it('resolves user-skill instructions into the fetch context for a remote-acp agent', async () => { diff --git a/src/chats/chat-instance.ts b/src/chats/chat-instance.ts index d048b5c07..a3e606c73 100644 --- a/src/chats/chat-instance.ts +++ b/src/chats/chat-instance.ts @@ -170,11 +170,13 @@ export const createAgentRoutingFetch = ( const requestBody = JSON.parse(init.body as string) as { messages: ThunderboltUIMessage[] } await saveMessages({ id, messages: requestBody.messages }) + // Persist by `id`, not `chatThread.id`: on a brand-new chat the session's + // `chatThread` snapshot is still `null` here (PowerSync hasn't re-hydrated + // it yet), but `saveMessages` above just created the `chat_threads` row — + // so keying off `chatThread` would silently drop the fresh ACP id and + // break resume/load on the next reconnect. `id` is that same row's id. const persistAcpSessionId = async (newSessionId: string): Promise => { - if (!chatThread) { - return - } - await updateChatThread(getDb(), chatThread.id, { acpSessionId: newSessionId }) + await updateChatThread(getDb(), id, { acpSessionId: newSessionId }) } // Surface `connecting` only when routing to a different agent than this diff --git a/src/components/device-approval.test.tsx b/src/components/device-approval.test.tsx new file mode 100644 index 000000000..d28875d8f --- /dev/null +++ b/src/components/device-approval.test.tsx @@ -0,0 +1,211 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { resetTestDatabase, setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' +import { createMockAuthClient } from '@/test-utils/auth-client' +import { createTestProvider } from '@/test-utils/test-provider' +import { getClock } from '@/testing-library' +import '@testing-library/jest-dom' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, mock } from 'bun:test' +import type { ReactNode } from 'react' +import { MemoryRouter, useLocation } from 'react-router' +import { takeDeviceApprovalReturn } from '@/lib/device-approval-return' +import { DeviceApproval } from './device-approval' + +type FetchResult = { data: unknown; error: unknown } +type Handlers = Partial FetchResult>> + +const NavigationSpy = ({ onLocationChange }: { onLocationChange: (pathname: string) => void }) => { + const location = useLocation() + onLocationChange(location.pathname) + return null +} + +const authedSession = { user: { id: 'user-1', email: 'user@example.com' } } + +const makeFetch = (handlers: Handlers) => + mock(async (path: string) => (handlers[path] ?? (() => ({ data: null, error: null })))()) + +const pendingVerify: Handlers = { + '/device': () => ({ data: { user_code: 'ABCD1234', status: 'pending' }, error: null }), +} + +describe('DeviceApproval', () => { + beforeAll(async () => { + await setupTestDatabase() + }) + + afterAll(async () => { + await teardownTestDatabase() + }) + + afterEach(async () => { + await resetTestDatabase() + cleanup() + localStorage.clear() + }) + + const renderPage = ({ + code, + session = authedSession, + handlers = {}, + }: { + code?: string + session?: typeof authedSession | null + handlers?: Handlers + }) => { + const url = code ? `/device?user_code=${code}` : '/device' + const fetch = makeFetch(handlers) + const authClient = createMockAuthClient({ session, fetch }) + + let lastPathname = '/device' + const TestProvider = createTestProvider({ authClient }) + + render( + <> + + (lastPathname = p)} /> + , + { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }, + ) + + return { fetch, getLastPathname: () => lastPathname } + } + + const flush = async () => { + await act(async () => { + await getClock().runAllAsync() + }) + } + + describe('authentication gate', () => { + it('redirects unauthenticated visitors into the auth flow', () => { + const { getLastPathname } = renderPage({ code: 'ABCD1234', session: null }) + + expect(getLastPathname()).toBe('/') + }) + + it('does not call any device endpoint when unauthenticated', () => { + const { fetch } = renderPage({ code: 'ABCD1234', session: null }) + + expect(fetch).not.toHaveBeenCalled() + }) + + it('stashes the return URL so the code survives the login redirect', () => { + renderPage({ code: 'ABCD1234', session: null }) + + expect(takeDeviceApprovalReturn()).toBe('/device?user_code=ABCD1234') + }) + + it('stashes nothing when there is no code to preserve', () => { + renderPage({ session: null }) + + expect(takeDeviceApprovalReturn()).toBeNull() + }) + }) + + describe('verify + confirm', () => { + it('claims the code on mount and shows the approval prompt with the code', async () => { + const { fetch } = renderPage({ code: 'ABCD1234', handlers: pendingVerify }) + await flush() + + expect(screen.getByText('Approve CLI sign-in?')).toBeInTheDocument() + expect(screen.getByText('ABCD1234')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith('/device', { method: 'GET', query: { user_code: 'ABCD1234' } }) + }) + + it('shows a manual entry form when no code is in the URL', async () => { + renderPage({}) + await flush() + + expect(screen.getByText('Sign in to the CLI')).toBeInTheDocument() + expect(screen.getByLabelText('Code')).toBeInTheDocument() + }) + + it('normalizes a typed code through verify AND approve', async () => { + const { fetch } = renderPage({ + handlers: { ...pendingVerify, '/device/approve': () => ({ data: { success: true }, error: null }) }, + }) + await flush() + + fireEvent.change(screen.getByLabelText('Code'), { target: { value: ' abcd-1234 ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + await flush() + + fireEvent.click(screen.getByRole('button', { name: 'Approve' })) + await flush() + + // Both the verify (query) and approve (body) must carry the normalized code, + // not the raw " abcd-1234 " the user typed. + expect(fetch).toHaveBeenCalledWith('/device', { method: 'GET', query: { user_code: 'ABCD-1234' } }) + expect(fetch).toHaveBeenCalledWith('/device/approve', { method: 'POST', body: { userCode: 'ABCD-1234' } }) + }) + }) + + describe('approve', () => { + it('approves and tells the user to return to their terminal', async () => { + const { fetch } = renderPage({ + code: 'ABCD1234', + handlers: { ...pendingVerify, '/device/approve': () => ({ data: { success: true }, error: null }) }, + }) + await flush() + + fireEvent.click(screen.getByRole('button', { name: 'Approve' })) + await flush() + + expect(screen.getByText('Sign-in approved')).toBeInTheDocument() + expect(screen.getByText('You can return to your terminal.')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith('/device/approve', { method: 'POST', body: { userCode: 'ABCD1234' } }) + }) + }) + + describe('deny', () => { + it('denies and shows the denied state', async () => { + const { fetch } = renderPage({ + code: 'ABCD1234', + handlers: { ...pendingVerify, '/device/deny': () => ({ data: { success: true }, error: null }) }, + }) + await flush() + + fireEvent.click(screen.getByRole('button', { name: 'Deny' })) + await flush() + + expect(screen.getByText('Sign-in denied')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith('/device/deny', { method: 'POST', body: { userCode: 'ABCD1234' } }) + }) + }) + + describe('error states', () => { + it('shows an expired state when the code has expired', async () => { + renderPage({ + code: 'ABCD1234', + handlers: { + '/device': () => ({ data: null, error: { error: 'expired_token', status: 400 } }), + }, + }) + await flush() + + expect(screen.getByText('Request expired')).toBeInTheDocument() + }) + + it('shows an invalid state for an unknown code', async () => { + renderPage({ + code: 'ABCD1234', + handlers: { + '/device': () => ({ data: null, error: { error: 'invalid_request', status: 400 } }), + }, + }) + await flush() + + expect(screen.getByText("Code didn't work")).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/device-approval.tsx b/src/components/device-approval.tsx new file mode 100644 index 000000000..a74a29aba --- /dev/null +++ b/src/components/device-approval.tsx @@ -0,0 +1,326 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AlertCircle, CheckCircle2, Loader2, ShieldQuestion, Terminal } from 'lucide-react' +import { type FormEvent, type ReactNode, useEffect, useReducer, useRef } from 'react' +import { Navigate, useLocation, useNavigate, useSearchParams } from 'react-router' + +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useAuth, type AuthClient } from '@/contexts' +import { saveDeviceApprovalReturn } from '@/lib/device-approval-return' +import { + approveDeviceCode, + denyDeviceCode, + normalizeUserCode, + verifyDeviceCode, + type DeviceGrantFailure, +} from '@/lib/device-grant' + +type Status = 'enteringCode' | 'verifying' | 'confirming' | 'submitting' | 'approved' | 'denied' | 'failed' + +type State = { + status: Status + userCode: string + pendingAction: 'approve' | 'deny' | null + error: DeviceGrantFailure | null +} + +type Action = + | { type: 'setCode'; userCode: string } + | { type: 'verifyStart'; userCode: string } + | { type: 'settled'; status: 'confirming' | 'approved' | 'denied' } + | { type: 'submitStart'; action: 'approve' | 'deny' } + | { type: 'fail'; error: DeviceGrantFailure } + | { type: 'reset' } + +const init = (initialCode: string): State => ({ + status: initialCode ? 'verifying' : 'enteringCode', + userCode: initialCode, + pendingAction: null, + error: null, +}) + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'setCode': + return { ...state, userCode: action.userCode } + case 'verifyStart': + // Persist the canonical (normalized) code so approve/deny post exactly what was verified. + return { ...state, status: 'verifying', userCode: action.userCode, error: null } + case 'settled': + return { ...state, status: action.status, pendingAction: null } + case 'submitStart': + return { ...state, status: 'submitting', pendingAction: action.action, error: null } + case 'fail': + return { ...state, status: 'failed', pendingAction: null, error: action.error } + case 'reset': + return { status: 'enteringCode', userCode: '', pendingAction: null, error: null } + } +} + +/** + * Drives the RFC 8628 approval flow: claim/verify the user code (which binds it to the + * signed-in account), then approve or deny. Only mounts once the caller is authenticated, + * so the verify-on-mount effect always runs with a session. + */ +const useDeviceApproval = (authClient: AuthClient, initialCode: string) => { + const [state, dispatch] = useReducer(reducer, initialCode, init) + + const verify = async (code: string) => { + dispatch({ type: 'verifyStart', userCode: code }) + const result = await verifyDeviceCode(authClient, code) + if (!result.ok) { + dispatch({ type: 'fail', error: result }) + return + } + dispatch({ type: 'settled', status: result.status === 'pending' ? 'confirming' : result.status }) + } + + // Verify-on-mount when the code arrived via the QR/link. Ref-guarded so Strict Mode's + // double invocation issues a single claim. The typed-code path verifies from its submit + // handler instead, so no effect covers it. + const verifiedRef = useRef(false) + useEffect(() => { + if (!initialCode || verifiedRef.current) { + return + } + verifiedRef.current = true + void verify(initialCode) + // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot claim keyed by the URL code + }, [initialCode]) + + const submitCode = (event: FormEvent) => { + event.preventDefault() + const code = normalizeUserCode(state.userCode) + if (code) { + void verify(code) + } + } + + const runAction = async (action: 'approve' | 'deny') => { + dispatch({ type: 'submitStart', action }) + const call = action === 'approve' ? approveDeviceCode : denyDeviceCode + const result = await call(authClient, state.userCode) + if (!result.ok) { + dispatch({ type: 'fail', error: result }) + return + } + dispatch({ type: 'settled', status: action === 'approve' ? 'approved' : 'denied' }) + } + + return { + state, + setCode: (userCode: string) => dispatch({ type: 'setCode', userCode }), + submitCode, + approve: () => runAction('approve'), + deny: () => runAction('deny'), + reset: () => dispatch({ type: 'reset' }), + } +} + +const iconWrapper = 'mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full' + +/** Non-dismissable modal shell shared by every approval-page state. */ +const ApprovalShell = ({ children }: { children: ReactNode }) => ( +

{}}> + e.preventDefault()}> + {children} + + +) + +const DeviceApprovalContent = ({ initialCode }: { initialCode: string }) => { + const authClient = useAuth() + const navigate = useNavigate() + const { state, setCode, submitCode, approve, deny, reset } = useDeviceApproval(authClient, initialCode) + + const goHome = () => navigate('/', { replace: true }) + const isSubmitting = state.status === 'submitting' + + return ( + + {state.status === 'verifying' && ( + +
+ +
+ Checking sign-in request… + One moment while we look up the code. +
+ )} + + {state.status === 'enteringCode' && ( +
+ +
+ +
+ Sign in to the CLI + + Enter the code shown in your terminal to continue. + +
+
+ + setCode(e.target.value)} + className="text-center font-mono tracking-[0.3em] uppercase" + /> + +
+
+ )} + + {(state.status === 'confirming' || isSubmitting) && ( + <> + +
+ +
+ Approve CLI sign-in? + + A device wants to sign in to your account as the Thunderbolt CLI. Only approve if you just started this + from your own terminal. + +
+
+
+ {state.userCode} +
+

+ Confirm this code matches the one in your terminal. +

+
+ + +
+
+ + )} + + {state.status === 'approved' && ( + <> + +
+ +
+ Sign-in approved + You can return to your terminal. +
+
+ +
+ + )} + + {state.status === 'denied' && ( + <> + +
+ +
+ Sign-in denied + + The request was denied. You can safely close this page. + +
+
+ +
+ + )} + + {state.status === 'failed' && state.error && ( + <> + +
+ +
+ + {state.error.reason === 'expired' ? 'Request expired' : "Code didn't work"} + + {state.error.message} +
+
+ + +
+ + )} +
+ ) +} + +/** + * `/device` — device-authorization approval page (RFC 8628). The user lands here from the + * CLI's verification link/QR (which embeds the `user_code`). Approval requires a signed-in + * session; unauthenticated visitors are sent into the normal auth flow with their return URL + * stashed, so the page replays pre-filled once they land back authenticated (see + * `device-approval-return.ts`) — no link re-open needed. Lazy-loaded (off the landing path). + */ +export const DeviceApproval = () => { + const authClient = useAuth() + const { data: session, isPending } = authClient.useSession() + const [searchParams] = useSearchParams() + const location = useLocation() + + if (isPending) { + return ( + + +
+ +
+ Loading… + Checking your session. +
+
+ ) + } + + if (!session?.user) { + // Preserve the code across the login redirect so the approval page comes back pre-filled. + if (searchParams.get('user_code')) { + saveDeviceApprovalReturn(`${location.pathname}${location.search}`) + } + return + } + + // `key` remounts (fresh reducer + re-verify) if the URL's user_code changes while mounted, + // so a new code can never be approved against state initialized from the previous one. + const code = normalizeUserCode(searchParams.get('user_code') ?? '') + return +} + +export default DeviceApproval diff --git a/src/components/device-qr-code.tsx b/src/components/device-qr-code.tsx new file mode 100644 index 000000000..bededa410 --- /dev/null +++ b/src/components/device-qr-code.tsx @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import QRCode from 'qrcode' +import { useEffect, useState } from 'react' + +type DeviceQrCodeProps = { + /** The pairing string to encode (see `encodePairingTicket`). */ + value: string + /** Rendered width/height in pixels. */ + size?: number +} + +/** + * Renders a QR code for a device pairing string. Default export so it can be + * lazily loaded — the `qrcode` dependency is only pulled in when pairing UI is shown. + */ +const DeviceQrCode = ({ value, size = 160 }: DeviceQrCodeProps) => { + const [dataUrl, setDataUrl] = useState(null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + let cancelled = false + QRCode.toDataURL(value, { margin: 1, width: size, errorCorrectionLevel: 'M' }) + .then((url) => { + if (!cancelled) { + setFailed(false) + setDataUrl(url) + } + }) + .catch(() => { + if (!cancelled) { + setFailed(true) + } + }) + return () => { + cancelled = true + } + }, [value, size]) + + if (failed) { + return

Could not render pairing code.

+ } + + return ( + Device pairing QR code + ) +} + +export default DeviceQrCode diff --git a/src/components/set-node-id-dialog.tsx b/src/components/set-node-id-dialog.tsx new file mode 100644 index 000000000..85ad35019 --- /dev/null +++ b/src/components/set-node-id-dialog.tsx @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Textarea } from '@/components/ui/textarea' +import { decodePairingTicket } from '@/lib/pairing-ticket' +import { decodeQrFromFile } from '@/lib/qr-scan' +import { Loader2, Upload } from 'lucide-react' +import { useRef, useState } from 'react' + +type SetNodeIdDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + deviceName: string + onConfirm: (nodeId: string) => Promise + isPending: boolean +} + +type Status = { kind: 'idle' } | { kind: 'scanning' } | { kind: 'error'; message: string } + +/** + * Lets a trusted device bind a P2P pairing identity onto a device row by pasting a + * pairing code or scanning one from an uploaded QR image. Default export for lazy loading. + */ +const SetNodeIdDialog = ({ open, onOpenChange, deviceName, onConfirm, isPending }: SetNodeIdDialogProps) => { + const [text, setText] = useState('') + const [status, setStatus] = useState({ kind: 'idle' }) + const fileInputRef = useRef(null) + + const handleFile = async (file: File) => { + setStatus({ kind: 'scanning' }) + try { + const decoded = await decodeQrFromFile(file) + setText(decoded) + setStatus({ kind: 'idle' }) + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Could not read QR code' }) + } + } + + const handleSave = async () => { + try { + const { nodeId } = decodePairingTicket(text) + try { + await onConfirm(nodeId) + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Could not bind the pairing code' }) + } + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Invalid pairing code' }) + } + } + + const scanning = status.kind === 'scanning' + + return ( + + + + Pair {deviceName} + + Paste a pairing code or upload its QR image to bind this device to its peer-to-peer identity. + + + +
+