diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000000..7599c771cb --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,441 @@ +name: Native + +# Phase 5 of the desktop migration contract (see the desktop migration contract §5.1). +# Gates the Rust/Tauri desktop app (apps/native/) — a separate cargo +# workspace, independent of the Bun workspaces the other workflows cover. +# +# Path-filtered (not the `changes`-job PR-diff pattern test.yml/e2e.yml/ +# sandbox-daemon.yml use) because this workflow is NOT yet a required +# branch-protection check — apps/native is still behind the migration flag +# (the desktop migration contract's `desktopAppAvailable`, default off) and this branch hasn't +# merged to main. A never-scheduled REQUIRED check blocks merges forever if +# skipped via workflow-level `paths:` (see test.yml's comment on its +# `changes` job) — but an opt-in, non-required check like this one is exactly +# what daemon-e2e-windows.yml already does with plain `paths:`, so this +# mirrors that convention. If/when this becomes a required check, switch to +# the `changes`-job pattern first. +on: + push: + branches: + - main + paths: + - "apps/native/**" + - "apps/web/**" + - "packages/sandbox/daemon/**" + - "packages/shared/**" + - ".github/workflows/native.yml" + pull_request: + branches: + - main + paths: + - "apps/native/**" + - "apps/web/**" + - "packages/sandbox/daemon/**" + - "packages/shared/**" + - ".github/workflows/native.yml" + # stub-seam-nightly keeps the DAEMON_E2E_CMD swap seam (packages/sandbox/ + # daemon/e2e-stub/) from rotting even on weeks with no apps/native PRs — + # see the desktop migration contract §5.1's "nightly job running the daemon-e2e stub-seam + # check". `schedule` + `workflow_dispatch` fire regardless of `paths:` + # (path filters only apply to push/pull_request), which is intentional + # here — every job below is gated by `if:` on `github.event_name` instead + # (see each job). + schedule: + - cron: "17 3 * * *" # nightly, arbitrary off-hour offset to dodge the top-of-hour stampede + workflow_dispatch: {} + +concurrency: + group: native-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + BUN_VERSION: "1.3.14" + +jobs: + # --------------------------------------------------------------------- + # 1. rust-checks — fmt / clippy / cargo test across the whole cargo + # workspace (crates/local-api, crates/harness, crates/upstream, + # src-tauri). macOS-only (matches the other jobs and the app's actual + # v1 target — the desktop migration contract's "v1 is macOS-only"); no ubuntu leg because + # src-tauri's deps (tao/wry/muda/objc2/...) are macOS-framework-linked + # and won't compile on Linux without a large extra apt-get surface + # this phase doesn't need to carry. + # --------------------------------------------------------------------- + rust-checks: + if: github.event_name != 'schedule' + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/native + + - name: cargo fmt --check + working-directory: apps/native + run: cargo fmt --all -- --check + + - name: Placeholder frontendDist for generate_context! + # `tauri::generate_context!` (compiled by clippy + test below) panics if + # the configured `frontendDist` (../../web/dist/native) is absent. This + # job intentionally does NOT build the frontend, so drop a minimal + # placeholder — the real bundle is produced in the `tauri-build` job. + run: | + mkdir -p apps/web/dist/native + printf '' > apps/web/dist/native/index.html + + # `tauri-build` resolves `externalBin` in `src-tauri/build.rs`, so ANY + # cargo command touching the workspace needs the sidecar on disk — not + # just `tauri build`. It is git-ignored and fetched at build time, so + # without this every cargo job fails with "resource path + # binaries/rclone- doesn't exist". + - name: Cache bundled rclone + uses: actions/cache@v4 + with: + path: apps/native/src-tauri/binaries + key: rclone-${{ runner.os }}-${{ hashFiles('apps/native/scripts/fetch-rclone.sh') }} + + - name: Fetch bundled rclone + working-directory: apps/native + run: ./scripts/fetch-rclone.sh + + - name: cargo clippy -D warnings + working-directory: apps/native + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: cargo test --workspace + working-directory: apps/native + run: cargo test --workspace + + # --------------------------------------------------------------------- + # 2. daemon-e2e-vs-rust — the parity oracle: build the release local-api + # binary, point packages/sandbox's curated 5-file daemon e2e suite at + # it via DAEMON_E2E_CMD, and assert the EXACT documented 133 pass / 7 + # fail outcome — not just the counts. apps/native/docs/ + # the native parity contract pins the 7 failing test names as intentional + # auth/CORS tightening (+ one harness self-check artifact); a NEW + # failure (regression) or a VANISHED one (doc gone stale) both fail + # this job via apps/native/scripts/ci/check-junit-allowlist.mjs. + # + # `daemon.proxy.e2e.test.ts` + `daemon.ws-proxy.e2e.test.ts` (12 tests, + # formerly "all pass") are DELIBERATELY excluded from this curated set + # as of the port-router split (`local_api::ServerHandle`'s dedicated + # PREVIEW listener) — this harness (`daemon.e2e.helpers.ts`, owned by + # `packages/sandbox`, out of bounds to edit here) hard-codes a SINGLE + # port for the whole daemon and has no way to discover/target a SECOND + # one, so it structurally cannot drive local-api's now-separate preview + # listener. See `the native parity contract`'s "Reverse proxy + + # WS proxy" exclusion section for the full rationale and this crate's + # own substitute oracle coverage (`apps/native/e2e/git-sandbox.e2e.test.ts`, + # `real-ui-passthrough.e2e.test.ts`). + # --------------------------------------------------------------------- + daemon-e2e-vs-rust: + if: github.event_name != 'schedule' + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/native + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install + + # The fs `grep` route (in the curated suite's daemon.e2e.test.ts) + # shells out to ripgrep; install it so the test asserts results + # strictly instead of tolerating "grep unavailable" — same reasoning + # as sandbox-daemon.yml / daemon-e2e-windows.yml's ripgrep installs. + - name: Install ripgrep + run: brew install ripgrep + + - name: Build release local-api binary + working-directory: apps/native + run: cargo build --release --bin local-api + + - name: Run curated daemon e2e subset against local-api + id: daemon-parity + # 7 documented failures are EXPECTED here (see the native parity contract) — + # `|| true` keeps this step green so the next step (the actual + # gate) can inspect the junit output and fail precisely on a SET + # mismatch, not merely on bun test's own non-zero exit. + # + # `daemon.proxy.e2e.test.ts` / `daemon.ws-proxy.e2e.test.ts` are NOT + # in this list — see the job header comment above and + # the native parity contract for why (the port-router split moved that + # traffic to a second listener this single-port harness can't + # reach). + run: | + LOCAL_API_BIN="$GITHUB_WORKSPACE/apps/native/target/release/local-api" + export DAEMON_E2E_CMD="[\"$LOCAL_API_BIN\"]" + cd packages/sandbox + bun test daemon/daemon.e2e.test.ts daemon/daemon.sse-shapes.e2e.test.ts \ + daemon/daemon.git.e2e.test.ts daemon/daemon.tools.e2e.test.ts \ + daemon/daemon.dispatch.e2e.test.ts \ + --reporter=junit --reporter-outfile="$RUNNER_TEMP/daemon-parity.xml" || true + + - name: Assert exact fail-set match against the pinned allowlist + run: | + bun run --cwd apps/native check:junit-allowlist -- \ + "$RUNNER_TEMP/daemon-parity.xml" \ + "$GITHUB_WORKSPACE/apps/native/scripts/ci/daemon-parity-allowlist.json" + + - name: Upload junit report (debugging aid) + if: always() + uses: actions/upload-artifact@v4 + with: + name: daemon-parity-junit + path: ${{ runner.temp }}/daemon-parity.xml + if-no-files-found: warn + retention-days: 7 + + # --------------------------------------------------------------------- + # 3. contract-suite — apps/native/e2e's own black-box contract suite + # (LOCAL_API_E2E_CMD) against the release local-api binary. Unlike the + # parity oracle above, this suite is OWNED by this implementation + # (the native local-API contract is the spec it pins), so + # the bar is simply "all green" — no allowlist needed. + # --------------------------------------------------------------------- + contract-suite: + if: github.event_name != 'schedule' + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/native + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install + + - name: Build release local-api binary + working-directory: apps/native + run: cargo build --release --bin local-api + + - name: Run local-api contract suite + working-directory: apps/native + run: | + export LOCAL_API_E2E_CMD="[\"$GITHUB_WORKSPACE/apps/native/target/release/local-api\"]" + bun test e2e + + # --------------------------------------------------------------------- + # 4. tauri-build — bundles the debug .app, uploads it as an artifact, and + # boot-smokes it. macOS code-signing / notarization / updater-artifact + # signing are handled ENTIRELY by the Tauri CLI itself once the + # matching env vars are non-empty (https://v2.tauri.app/distribute/ + # sign/macos/) — this job does not hand-roll `security`/`notarytool` + # calls, it just wires the documented secret names through as env and + # lets `tauri build` no-op into an unsigned/ad-hoc build when they're + # absent (the fork-safe default: every secret below is empty on a + # fork PR, so signing/notarization are skipped, never failed). + # --------------------------------------------------------------------- + tauri-build: + if: github.event_name != 'schedule' + runs-on: macos-latest + timeout-minutes: 45 + env: + # --- macOS code signing + notarization (Apple Developer ID) --------- + # All optional. Human-required setup (cert enrollment, notarization + # credentials) — see the native release contract (§5.3, + # dormant scaffolding). Required secret names, each consumed only by + # the `tauri build` step below via env: + # APPLE_CERTIFICATE - base64-encoded .p12 Developer ID cert + # APPLE_CERTIFICATE_PASSWORD - password for the .p12 above + # APPLE_SIGNING_IDENTITY - cert Common Name, e.g. "Developer ID + # Application: ()" — + # not in the brief's original list but + # REQUIRED by Tauri's signing flow + # alongside the cert itself; documented + # here so it isn't missed when the + # other 5 Apple secrets are configured + # APPLE_ID - Apple ID used for notarization + # APPLE_PASSWORD - app-specific password for APPLE_ID + # APPLE_TEAM_ID - Developer Team ID + # DELIBERATELY NOT WIRED HERE. This job only ever produces a DEBUG + # bundle for boot-smoke, which runs the binary directly and gains + # nothing from a Developer ID. Exporting the secrets made the Tauri CLI + # attempt `security import` on every build in this job — including the + # one boot-smoke launches itself — and it failed with "failed codesign + # application: failed to import keychain certificate", from a cert that + # is present but not importable. Wiring them cost a green CI and bought + # nothing. Signing belongs to the separate, secret-gated release job. + # --- Tauri updater artifact signing (separate keypair; unrelated to + # Apple code signing above) — only takes effect once + # tauri.conf.json5 configures the `updater` bundle target, which + # is still dormant (the native release contract + # §"Updater"). Wired here now so no further workflow edit is + # needed once that config lands. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/native + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install + + # rclone is bundled as an `externalBin` (the org filesystem's NFS + # mount) and deliberately NOT committed — 78 MB per architecture. + # Keyed on the fetch script's hash, so bumping the pinned version in + # that script busts the cache automatically. + - name: Cache bundled rclone + uses: actions/cache@v4 + with: + path: apps/native/src-tauri/binaries + key: rclone-${{ runner.os }}-${{ hashFiles('apps/native/scripts/fetch-rclone.sh') }} + + # Explicit for the same reason as the frontend bundle below: + # `beforeBuildCommand` also runs it, but a dedicated step attributes a + # download or checksum failure to itself instead of to `tauri build`. + - name: Fetch bundled rclone + working-directory: apps/native + run: ./scripts/fetch-rclone.sh + + # Explicit step (not relying solely on tauri.conf.json5's own + # `beforeBuildCommand: bun run build:native`) for a clearer CI step + # boundary/log — slightly redundant with that hook, harmless (the + # second invocation is a fast no-op rebuild of already-fresh output). + - name: Build desktop frontend bundle + run: bun run --cwd apps/web build:native + + - name: tauri build --debug + working-directory: apps/native + # This build is NEVER signed, whether or not the secrets exist. + # boot-smoke runs the binary directly and a Developer ID buys it + # nothing, while attempting the import couples this job to the health + # of six repo secrets — which is exactly how it has been failing: + # "failed codesign application: failed to import keychain certificate", + # from a cert that is present but not importable. Signing belongs to + # the separate, secret-gated release job (see the native release + # contract). + run: bunx @tauri-apps/cli@latest build --debug + + - name: Upload .app artifact + uses: actions/upload-artifact@v4 + with: + name: decocms-desktop-debug-app + path: apps/native/target/debug/bundle/macos/decocms-desktop.app + if-no-files-found: error + retention-days: 14 + + # Boot-smoke consideration (the desktop migration contract §5.1): CI macOS runners + # are full logged-in GUI VMs (not headless like Linux — no Xvfb + # needed), and DESKTOP_SELFTEST=1 builds the "main" window + # `.visible(false)` specifically so this never pops a real window + # (see the native Tauri integration's "Self-test mode" section) — confirmed + # locally: `bun run --cwd apps/native smoke:boot` passes end-to-end + # (all 7 self-test checks + the orphan-process check) without ever + # showing a window. Included here rather than documented as + # local-only. Residual risk not fully provable from a dev machine: + # `authStatusInvoke`'s status check touches macOS Keychain via the + # `keyring` crate (crates/upstream) — GH's macos-latest runners + # provide an unlocked default login keychain so a read-only + # "no stored session" status check is expected to work the same way + # it does locally, but if this step ever starts failing ONLY in CI + # with a Keychain-access error, add a + # `security unlock-keychain -p runner ~/Library/Keychains/login.keychain-db` + # step immediately before this one rather than reverting it to + # local-only. + - name: Boot smoke (DESKTOP_SELFTEST=1, hidden window) + run: bun run --cwd apps/native smoke:boot + + # --------------------------------------------------------------------- + # 5. stub-seam-nightly — keeps the DAEMON_E2E_CMD swap-seam mechanics + # (spawn / env contract / health-poll / SIGKILL-teardown) exercised + # even on weeks with no apps/native PRs, per the desktop migration contract §5.1. + # Runs the plain Node stub (packages/sandbox/daemon/e2e-stub/ + # stub-daemon.mjs), not the Rust binary — ubuntu-latest is fine since + # nothing here is macOS-specific (no cargo build at all). Asserts the + # documented 76 pass / 27 fail table from the daemon parity contract via the + # same exact-set allowlist checker as job 2 — this table already + # documents (and this job tolerates, by pinning it into the allowlist + # like every other entry) the one env-leakage self-check failure + # (`swappable spawn target > defaults to ... when DAEMON_E2E_CMD is + # unset`) the README's "Friction found in the seam" section explains. + # --------------------------------------------------------------------- + stub-seam-nightly: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install + + - name: Run daemon.e2e.test.ts against the e2e-stub seam probe + # 27 documented failures are EXPECTED (this stub implements only a + # slice of the daemon's route surface — see the daemon parity contract's + # "What stub-daemon.mjs implements"). `|| true` for the same reason + # as job 2's parity step — the next step is the real gate. + run: | + STUB="$GITHUB_WORKSPACE/packages/sandbox/daemon/e2e-stub/stub-daemon.mjs" + export DAEMON_E2E_CMD="[\"node\", \"$STUB\"]" + cd packages/sandbox + bun test daemon/daemon.e2e.test.ts \ + --reporter=junit --reporter-outfile="$RUNNER_TEMP/stub-seam.xml" || true + + - name: Assert exact fail-set match against the pinned allowlist + run: | + bun run --cwd apps/native check:junit-allowlist -- \ + "$RUNNER_TEMP/stub-seam.xml" \ + "$GITHUB_WORKSPACE/apps/native/scripts/ci/stub-seam-allowlist.json" + + - name: Upload junit report (debugging aid) + if: always() + uses: actions/upload-artifact@v4 + with: + name: stub-seam-junit + path: ${{ runner.temp }}/stub-seam.xml + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97a1a687b8..0f4142b01b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -179,12 +179,18 @@ jobs: test -f apps/api/dist/server/migrate.js test -f apps/api/dist/server/cli.js + # `build:web` rather than `build` so VITE_TAURI_APP=0 is stated, not + # inherited from the variable happening to be unset — the desktop-free + # assertion below is only meaningful against a known browser build. - name: Build web app - run: bun run --cwd=apps/web build + run: bun run --cwd=apps/web build:web - name: Verify web output run: test -f apps/web/dist/index.html + - name: Assert no Tauri desktop code in the browser bundle + run: bun run check:web-bundle + # Playwright component tests for the sections-editor field widgets # (apps/web/ct). Self-contained — real chromium, no server/DB — so it lives # here rather than e2e.yml, gated to web changes only. diff --git a/.gitignore b/.gitignore index e8cb09f66f..cc7ac42f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,8 @@ packages/e2e/playwright-report/ # so we can un-ignore specific subdirs below — git won't traverse into an # ignored directory. /docs/* +# Committed engineering plans (desktop migration etc.) +!/docs/plans/ # Superpowers workspace artifacts (brainstorming + writing-plans output) # are kept local to the working tree, not committed. diff --git a/AGENTS.md b/AGENTS.md index 6a9cf0d031..3a4249b2ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,24 @@ bun run --cwd=apps/api dev:server # Run documentation site locally bun run docs:dev + +# Native desktop app (Tauri) dev loop — HMR via Vite on port 4420 +bun run --cwd=apps/native dev ``` +**One-time macOS setup for `apps/native` devs**: run +`bun run --cwd=apps/native dev:signing:setup` once. It creates or reuses the +local self-signed `decocms-dev` code-signing identity; no Apple Developer +account is required. It also builds, signs, and installs one fixed +`decocms-keychain-helper` under the user's Application Support directory. +Debug app rebuilds talk to that unchanged helper over JSON stdin/stdout, so +Keychain sees one stable executable; tokens never use argv, logs, or a +filesystem fallback. The native dev runner still signs the app itself and +fails closed if signing drifts, but the fixed helper—not the app's self-signed +designated requirement—is what makes debug Keychain access stable. Debug +sessions stay in the Keychain-only `com.decocms.studio.dev` namespace; release +sessions stay in `com.decocms.studio`. + ### Testing & Quality ```bash # Run all tests (Bun test runner) diff --git a/README.md b/README.md index 68ffdd2be7..3b1840721b 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ Every tool call gets input/output validation, access control, audit logging, and | --- | --- | | [`apps/api`](./apps/api/README.md) | Hono API, authentication, tools, storage, migrations, and the `deco` CLI | | [`apps/docs`](./apps/docs/README.md) | Astro documentation site | +| [`apps/native`](./apps/native/README.md) | Tauri desktop app and local Rust runtime | | [`apps/web`](./apps/web/README.md) | Vite and React 19 administration interface | ### Packages diff --git a/apps/api/package.json b/apps/api/package.json index bdb7e3b75d..960d3ec916 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -113,7 +113,7 @@ "typescript": "^7.0.2", "typescript5": "npm:typescript@^5.9.3", "vite": "^7.2.1", - "zod": "^4.0.0" + "zod": "4.3.6" }, "homepage": "https://github.com/decocms/studio", "keywords": [ diff --git a/apps/api/src/api/routes/desktop-session-bridge.ts b/apps/api/src/api/routes/desktop-session-bridge.ts index 8ca824a86a..efeda6d9d2 100644 --- a/apps/api/src/api/routes/desktop-session-bridge.ts +++ b/apps/api/src/api/routes/desktop-session-bridge.ts @@ -2,7 +2,7 @@ * Desktop system-browser session bridge. * * `POST /api/auth/desktop/session-from-oauth` — the one Studio-side change - * `apps/desktop/docs/real-ui-auth-recon.md` concluded was unavoidable to fix + * the native authentication contract concluded was unavoidable to fix * the Google/GitHub/SAML system-browser desktop login path: an MCP OAuth * access token (the `oauthAccessToken` row both desktop login paths * ultimately hold) satisfies org-scoped `/api/:org/*` routes but is REJECTED @@ -20,7 +20,7 @@ * `Set-Cookie` header — this caller is a native app process, not a browser * that would capture one) so it can forward that value itself, byte for * byte, as `Cookie: better-auth.session_token=` — see - * `apps/desktop/crates/upstream/src/login.rs`'s + * `apps/native/crates/upstream/src/login.rs`'s * `mint_session_from_access_token`, the Rust-side caller. * * ## Auth guard diff --git a/apps/native/.cargo/config.toml b/apps/native/.cargo/config.toml new file mode 100644 index 0000000000..8c7f34e3ca --- /dev/null +++ b/apps/native/.cargo/config.toml @@ -0,0 +1,11 @@ +# macOS dev-build signing hook. Cargo routes each locally-executed binary +# through the runner, but the script signs only `decocms-desktop`; test binaries +# pass through unchanged. The app uses the local self-signed `decocms-dev` +# identity with an explicit certificate-hash + bundle-identifier requirement so +# Keychain ACLs remain stable across rebuilds. Signing fails closed instead of +# launching an ad-hoc binary. +[target.aarch64-apple-darwin] +runner = "scripts/dev-runner.sh" + +[target.x86_64-apple-darwin] +runner = "scripts/dev-runner.sh" diff --git a/apps/native/.gitignore b/apps/native/.gitignore new file mode 100644 index 0000000000..124e199b8d --- /dev/null +++ b/apps/native/.gitignore @@ -0,0 +1,4 @@ +/target/ +**/target/ +/.dev-signing-identity +/src-tauri/binaries/ diff --git a/apps/native/Cargo.lock b/apps/native/Cargo.lock new file mode 100644 index 0000000000..c179215575 --- /dev/null +++ b/apps/native/Cargo.lock @@ -0,0 +1,6960 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[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 = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[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 = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[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-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[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 2.0.119", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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 = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.24.0", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +dependencies = [ + "arc-swap", + "bytes", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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 0.10.1", +] + +[[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 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[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 = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[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 = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[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 = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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 = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "decocms-desktop" +version = "0.1.0" +dependencies = [ + "async-trait", + "local-api", + "rand 0.8.7", + "rcgen", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-mcp-bridge", + "tauri-plugin-opener", + "tempfile", + "thiserror 1.0.69", + "time", + "tokio", + "tracing", + "tracing-subscriber", + "upstream", + "urlencoding", + "uuid", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[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 = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[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 = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[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-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 2.0.119", +] + +[[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 = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[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", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "harness" +version = "0.1.0" +dependencies = [ + "bytes", + "futures", + "futures-util", + "portable-pty", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[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 = "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", + "h2", + "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", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[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 0.62.2", +] + +[[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 = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[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 = "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 = "ignore" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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-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 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[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 = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-api" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "axum-server", + "bytes", + "futures", + "futures-util", + "globset", + "harness", + "hyper", + "hyper-util", + "ignore", + "notify", + "rand 0.8.7", + "regex", + "reqwest 0.12.28", + "rusqlite", + "rustls", + "serde", + "serde_json", + "sha2", + "subtle", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.23.1", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "upstream", + "urlencoding", + "uuid", + "walkdir", +] + +[[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 = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[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" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[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 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-location 0.3.2", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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 0.2.1", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[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 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[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 = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[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 = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[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.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[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 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +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.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower 0.5.3", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[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 = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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 = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "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 = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[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 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "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 = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[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 = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +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 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "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 = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-mcp-bridge" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee9c726f2293f54dc9a61739886967a75c9f1701d17a3dcb8a4544f7353d4861" +dependencies = [ + "base64 0.22.1", + "block2 0.5.1", + "futures-util", + "image", + "jni", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "objc2-web-kit 0.2.2", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.28.0", + "uuid", + "webview2-com", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.4", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "json5", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.3+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio 1.2.2", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.23.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + +[[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", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[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.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[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 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower 0.5.3", + "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 2.0.119", +] + +[[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 = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "upstream" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "dirs", + "keyring", + "rand 0.8.7", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "urlencoding", + "uuid", + "wait-timeout", +] + +[[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 = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[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.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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 2.0.119", + "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.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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 = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[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 = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[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 = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[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 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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 0.2.1", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[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 = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[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 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[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 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/apps/native/Cargo.toml b/apps/native/Cargo.toml new file mode 100644 index 0000000000..4fc7fbcd21 --- /dev/null +++ b/apps/native/Cargo.toml @@ -0,0 +1,90 @@ +[workspace] +resolver = "2" +members = ["crates/local-api", "crates/harness", "crates/upstream", "src-tauri"] + +[workspace.package] +edition = "2021" +version = "0.1.0" +license = "MIT" +publish = false + +# Warn locally (fast inner-loop feedback); CI runs +# `cargo clippy -- -D warnings` from apps/native/, which promotes every +# warning below to a hard failure. Do not add `deny` here — a `deny` in the +# manifest breaks `cargo build`/`check` too, not just `clippy`, which is +# stricter than intended for local iteration. +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } + +# --- Dependency versions, pinned once here and inherited via `.workspace = true` +# in crates/local-api/Cargo.toml. Over-provisioned on purpose (bootstrap +# directive): every family implementer building on this skeleton pulls from +# this table instead of editing it — Cargo.toml is a shared file family +# owners must not touch (see the native module-ownership contract). +[workspace.dependencies] +axum = { version = "0.7", features = ["ws", "macros"] } +# Local TLS: the app terminates HTTPS on the user's own machine so its origin +# is a secure context (Web Crypto) AND a real domain (per-sandbox cookie +# jars). `rcgen` mints the CA/leaf on first run; nothing is shipped. +rcgen = { version = "0.13", default-features = false, features = ["pem", "crypto", "ring"] } +axum-server = { version = "0.7", features = ["tls-rustls"] } +time = { version = "0.3", default-features = false, features = ["std"] } +tokio = { version = "1", features = ["full"] } +tower = { version = "0.4", features = ["util"] } +tower-http = { version = "0.5", features = ["trace", "cors", "fs", "timeout"] } +hyper = { version = "1", features = ["full"] } +hyper-util = { version = "0.1", features = ["full", "client-legacy", "http1"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +rusqlite = { version = "0.31", features = ["bundled"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "stream", + "rustls-tls", +] } +globset = "0.4" +walkdir = "2" +ignore = "0.4" +notify = "6" +regex = "1" +subtle = "2" +rand = "0.8" +futures = "0.3" +futures-util = "0.3" +bytes = "1" +urlencoding = "2" +uuid = { version = "1", features = ["v4"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +async-trait = "0.1" +# Inbound WS upgrades go through axum's own `ws` feature (above); this is for +# a family that needs a WS *client* role (e.g. proxying an outbound upgrade). +tokio-tungstenite = "0.23" +tempfile = "3" +# Phase 3 (`src-tauri/`, the Tauri shell) additions — distinct lines to +# minimize merge friction with `crates/upstream` (built in parallel), which +# is expected to add its own (`keyring`, etc.) below these rather than +# interleaving. +tauri = { version = "2", features = ["config-json5"] } +# Dev-only MCP bridge for @hypothesi/tauri-mcp-server (registered under +# `#[cfg(debug_assertions)]` in src-tauri/src/lib.rs — never in release). +tauri-plugin-mcp-bridge = "0.12" +tauri-plugin-opener = "2" +tauri-build = { version = "2", features = ["config-json5"] } +# `crates/upstream`'s OAuth/Keychain-specific deps (`keyring`, `sha2`, +# `base64`, `dirs`) are declared directly in `crates/upstream/Cargo.toml` +# instead of here, following the same "single-consumer dep stays local" +# precedent `crates/harness/Cargo.toml` set for `portable-pty` — see that +# crate's Cargo.toml doc comment. + +# Phase 2's `crates/harness` PTY spawn (see +# the desktop migration contract) declares `portable-pty` (and +# `libc`, for process-group signaling) directly in its OWN +# `crates/harness/Cargo.toml` rather than here — this table stays the +# family-agnostic dependency set every OTHER crate inherits via +# `.workspace = true`; local-api itself never spawns a PTY directly and has +# no reason to pull either dependency in. diff --git a/apps/native/README.md b/apps/native/README.md new file mode 100644 index 0000000000..dae78873fb --- /dev/null +++ b/apps/native/README.md @@ -0,0 +1,153 @@ +# Studio Native + +Packages the existing Studio web application as a Tauri desktop app with a +local Rust runtime. + +| Attribute | Value | +| --- | --- | +| Workspace | `@decocms/native` (`apps/native`) | +| Kind | Tauri desktop application and local runtime | +| Runtime | Rust, WebKit, and the bundled React application | +| Distribution | Private workspace package; signed macOS application | + +## Overview + +Studio Native embeds the production `apps/web` UI without forking its product +shell. An in-process Axum server serves the bundled UI and native API from one +stable loopback origin, proxies upstream Studio requests, and intercepts the +local-only thread, harness, sandbox, filesystem, Git, task, and preview +surfaces. + +The current release targets macOS. The Rust crates and browser contracts are +kept portable so Windows can be added without redesigning the application +boundary. + +## Responsibilities + +- Start and supervise the local Rust API inside the Tauri process. +- Serve the native Vite bundle and API from one `localhost` origin. +- Authenticate local control requests with an HttpOnly `SameSite=Strict` + cookie. +- Store upstream Studio sessions in the operating-system credential store. +- Detect and run the user's Claude Code and Codex installations. +- Persist native threads and provider session identifiers in SQLite. +- Create, recover, start, stop, and restart local Git-backed sandboxes. +- Retain subprocess output in bounded files and replay their tails to xterm.js. +- Proxy sandbox previews without stripping the sandbox application's cookies or + authorization headers. + +## Usage + +From the repository root, install dependencies and start the native HMR loop: + +```bash +bun install +bun run --cwd=apps/native dev +``` + +The command starts native-mode Vite from `apps/web` on +`http://localhost:4420`, builds and launches the Rust shell, and proxies native +control routes to the embedded API. + +Build the native web bundle independently with: + +```bash +bun run --cwd=apps/web build:native +``` + +Build the Tauri application with: + +```bash +bunx tauri build --config apps/native/src-tauri/tauri.conf.json5 +``` + +## Architecture + +```text +Tauri window + | + v +http://localhost: + | + +-- bundled apps/web UI + +-- local control API + +-- upstream Studio proxy ----> studio.decocms.com + +-- sandbox preview proxy ----> user dev server + | + +-- SQLite threads and sandbox registry + +-- Keychain session + +-- Claude Code / Codex processes +``` + +Key paths: + +| Path | Purpose | +| --- | --- | +| `src-tauri/` | Tauri shell, stable control origin, bundled assets, CSP, and native commands | +| `crates/local-api/` | Axum control API, proxy, thread store, sandbox manager, and setup pipeline | +| `crates/harness/` | Claude Code and Codex detection, process execution, and stream translation | +| `crates/upstream/` | OAuth, token refresh, Keychain access, and upstream session handling | +| `e2e/` | Black-box native API, sandbox, provider-resume, and recovery tests | +| `scripts/` | Dev signing, boot smoke, and release helpers | + +The packaged UI is built by `apps/web`; the hosted Hono backend remains in +`apps/api`. Browser-safe contracts shared by both live in `@decocms/shared`. + +## Development + +Run the focused verification gates from the repository root: + +```bash +bun run --cwd=apps/web check +bun run --cwd=apps/api check +bun run --cwd=apps/native check +cd apps/native && cargo fmt --check +cd apps/native && cargo clippy --workspace --all-targets -- -D warnings +cd apps/native && cargo test --workspace +bun run --cwd=apps/native smoke:boot +``` + +### Dev sessions & the Keychain + +On each macOS development machine, create the stable self-signed development +identity once: + +```bash +bun run --cwd=apps/native dev:signing:setup +``` + +The Cargo runner signs each development binary with that identity. This gives +Keychain entries a stable designated requirement across Rust rebuilds, so a +saved Studio session remains readable. The shipped app continues to use its +release signing identity; there is no filesystem token-store fallback. + +### Verification matrix + +The native E2E suite can run against the built `local-api` binary through +`LOCAL_API_E2E_CMD`. Provider-resume tests use deterministic Claude Code and +Codex fixtures to prove that the persisted provider session ID is reused and +only the newest user message is sent when a run resumes. + +Run `bun run fmt` after changes. + +## Boundaries + +- Keep product UI and user-facing behavior in `apps/web`; native code should + intercept transport and local capabilities rather than create a second UI. +- Keep hosted Hono routes and server persistence in `apps/api`. +- Keep browser-safe wire contracts in `@decocms/shared`; Rust mirrors those + contracts at the process boundary. +- Never expose upstream access or refresh tokens to webview JavaScript. +- Do not authorize sandbox preview traffic with the control cookie or rewrite + its application cookies. Preview requests are a transparent, dedicated + proxy surface. +- Sandboxes may persist identity and logs, but child processes are always + re-established after an application restart rather than orphaned. +- Subprocess logs are file-backed and bounded; memory holds only live fan-out. + +## Related documentation + +- [Studio Web](../web/README.md) +- [Studio API](../api/README.md) +- [Repository guidelines](../../AGENTS.md) +- [Testing strategy](../../TESTING.md) diff --git a/apps/native/crates/harness/Cargo.toml b/apps/native/crates/harness/Cargo.toml new file mode 100644 index 0000000000..8b9dcf2220 --- /dev/null +++ b/apps/native/crates/harness/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "harness" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +# Shared workspace-pinned deps (see apps/native/Cargo.toml's +# `[workspace.dependencies]` table) — consumed via `.workspace = true`, +# never edited here. +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +futures = { workspace = true } +futures-util = { workspace = true } +bytes = { workspace = true } + +# Crate-local dependency, NOT in the shared workspace table (see that +# table's trailing comment) — declared directly here since this crate is +# its only consumer. +# +# `portable-pty` — wezterm's PTY crate (see the native implementation contract +# section 4 for the full "why portable-pty over node-pty" writeup): spawns +# the child via `std::process::Command` from inside our own signed binary +# (no separate `spawn-helper` executable to lose its execute bit or fail +# Gatekeeper), used for the PTY-backed half of `spawn::spawn` — see that +# module's doc comment for why it is NOT the path harness dispatch +# actually takes (plain pipes are; PTY is implemented for completeness / +# any future TTY-requiring CLI, per the Phase 2 TODO's explicit ask). +# +# No `libc`/`nix` dependency: the workspace denies `unsafe_code` (see +# `apps/native/Cargo.toml`'s `[workspace.lints.rust]`), and every raw +# `kill(2)`/`setpgid(2)` FFI call requires `unsafe`. Process-GROUP +# signaling (`spawn.rs::kill_process_group`) instead shells out to the +# `kill(1)` binary (`kill -TERM -`) — POSIX-portable, zero `unsafe`, +# no extra dependency. Process-group CREATION uses +# `std::os::unix::process::CommandExt::process_group`, which is a safe, +# stable std API (since Rust 1.64) — no FFI needed for that half either. +portable-pty = "0.9" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/apps/native/crates/harness/src/detect.rs b/apps/native/crates/harness/src/detect.rs new file mode 100644 index 0000000000..c855ae3fca --- /dev/null +++ b/apps/native/crates/harness/src/detect.rs @@ -0,0 +1,418 @@ +//! CLI availability detection — GROW-ONLY cache mirroring +//! `apps/api/src/link-daemon/capabilities.ts`'s semantics EXACTLY: +//! `mergeProbedCapabilities` there never un-advertises a capability once +//! seen ("a transient probe failure (CLI busy, SDK hiccup) must never +//! un-advertise a capability mid-run — dispatch routing would start +//! bouncing threads that are actively streaming"). This module ports that +//! same invariant to Rust: once a harness flips to `true`, no later probe +//! — however it fails — can flip it back. +//! +//! "Detected" means the CLI is installed AND has a confirmed logged-in +//! session — FAIL-CLOSED: an installed-but-not-logged-in CLI is not usable, +//! so it does NOT count as detected. `probe()` requires BOTH a successful +//! `--version` probe (installed) AND a passing auth probe (logged in): +//! - claude via ` auth status --json`'s `"loggedIn":true` (mirrors +//! `capabilities.ts::detectClaudeCode`'s account-email check, adapted +//! since this crate drives the CLI's own surface rather than the +//! `@anthropic-ai/claude-agent-sdk`), +//! - codex via ` login status`'s "Logged in" text — which the codex +//! CLI prints to STDERR with an empty stdout, so the auth probe merges +//! stdout+stderr before parsing (see `run_probe_capture`). +//! +//! A failed/unrecognized/unparseable auth check keeps `detected` at +//! `false` (logged as a diagnostic). The grow-only cache invariant above +//! still holds: once a confirmed-logged-in probe flips a harness to +//! `true`, no later probe flips it back — but a CLI never reaches `true` +//! in the first place until login is confirmed. +//! +//! Because detection is now auth-gated, the SHARED CONTRACT surface +//! `apps/native/e2e/fixtures/stub-harness.mjs` answers the auth probe as +//! logged-in too (claude: `{"loggedIn":true}` on stdout for `auth status +//! --json`; codex: "Logged in using ChatGPT" on stderr for `login status`) +//! in ADDITION to `--version`, so the differential/parity tests still see +//! it as detected. Both probes are independently swappable via +//! [`resolve::resolve_argv`] (the SHARED CONTRACT env overrides). + +use std::sync::OnceLock; +use std::time::Duration; + +use tokio::sync::RwLock; + +use crate::resolve::{self, HarnessId}; + +/// Wall-clock cap on a single probe subprocess (version OR auth check). +/// Generous for a binary that's actually installed and responsive, bounded +/// so a hung/misbehaving binary on someone's PATH can't wedge detection. +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +/// How often the background task re-probes while at least one harness is +/// still undetected. Matches the order of magnitude of +/// `capabilities.ts::startCapabilityReprobe`'s default (60s). +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Detection { + pub claude_code: bool, + pub codex: bool, +} + +impl Detection { + pub fn get(self, harness: HarnessId) -> bool { + match harness { + HarnessId::ClaudeCode => self.claude_code, + HarnessId::Codex => self.codex, + } + } + + fn set(&mut self, harness: HarnessId, value: bool) { + match harness { + HarnessId::ClaudeCode => self.claude_code = value, + HarnessId::Codex => self.codex = value, + } + } + + fn all_detected(self) -> bool { + self.claude_code && self.codex + } + + /// Grow-only merge: `self ||= probed`, per-field. Never flips a `true` + /// back to `false`. Returns whether anything NEW became detected. + fn merge(&mut self, probed: Detection) -> bool { + let mut grew = false; + for harness in HarnessId::ALL { + if !self.get(harness) && probed.get(harness) { + self.set(harness, true); + grew = true; + } + } + grew + } +} + +static CACHE: OnceLock> = OnceLock::new(); +static REFRESH_STARTED: OnceLock<()> = OnceLock::new(); + +fn cache_lock() -> &'static RwLock { + CACHE.get_or_init(|| RwLock::new(Detection::default())) +} + +/// Read the cache without probing. `Detection::default()` (nothing +/// detected) if nothing has probed yet. +pub async fn cached() -> Detection { + *cache_lock().read().await +} + +/// Ensures the cache has been populated at least once, paying for a +/// bounded probe pass on the FIRST call (cold start) and starting the +/// background re-probe loop as a side effect. Every call after the first +/// reads the warm cache instantly — a request never blocks on a probe it +/// didn't cause. Concurrent cold-start callers all pay for their own probe +/// (idempotent — the grow-only merge makes redundant probing harmless, +/// just slightly wasteful), same tradeoff the Phase 1 `routes/models.rs` +/// bootstrap already made. +pub async fn ensure_detected() -> Detection { + let current = cached().await; + if current.all_detected() { + start_background_reprobe(); + return current; + } + let probed = probe_missing(current).await; + let merged = { + let mut guard = cache_lock().write().await; + guard.merge(probed); + *guard + }; + start_background_reprobe(); + merged +} + +/// Starts the periodic re-probe loop exactly once per process. A no-op +/// once every harness is detected (matches `capabilities.ts`'s "probing +/// stops being useful once every CLI capability is present" comment) — +/// each tick still fires, but `probe_missing` skips a harness already +/// `true`, so a fully-detected process settles into a near-free timer. +fn start_background_reprobe() { + if REFRESH_STARTED.set(()).is_err() { + return; + } + tokio::spawn(async move { + loop { + tokio::time::sleep(REFRESH_INTERVAL).await; + let current = cached().await; + if current.all_detected() { + continue; + } + let probed = probe_missing(current).await; + let mut guard = cache_lock().write().await; + guard.merge(probed); + } + }); +} + +/// Probes only the harnesses NOT already `true` in `current` — matches +/// `capabilities.ts::missingCliCapabilities` gating re-probes to what's +/// actually missing, so a confirmed-detected CLI never pays for another +/// probe subprocess. +async fn probe_missing(current: Detection) -> Detection { + let (claude_code, codex) = tokio::join!( + async { + if current.claude_code { + true + } else { + probe(HarnessId::ClaudeCode).await + } + }, + async { + if current.codex { + true + } else { + probe(HarnessId::Codex).await + } + }, + ); + Detection { claude_code, codex } +} + +async fn probe(harness: HarnessId) -> bool { + // Fail-CLOSED: detected == installed AND logged in. Short-circuit the + // auth probe when `--version` already failed (nothing to be logged in + // to). + if !version_ok(harness).await { + return false; + } + if !auth_ok(harness).await { + tracing::debug!( + harness = harness.wire_id(), + "CLI answered --version but its auth-status probe did not confirm a logged-in session \ + (not logged in, or the auth surface is unrecognized/unsupported) — NOT counted as \ + detected (fail-closed: an installed-but-not-logged-in CLI is not usable)" + ); + return false; + } + true +} + +async fn version_ok(harness: HarnessId) -> bool { + let Ok(argv) = resolve::resolve_argv(harness) else { + return false; + }; + run_probe_capture(&argv, &["--version"], PROBE_TIMEOUT) + .await + .is_some() +} + +async fn auth_ok(harness: HarnessId) -> bool { + let Ok(argv) = resolve::resolve_argv(harness) else { + return false; + }; + match harness { + HarnessId::ClaudeCode => { + let Some(captured) = + run_probe_capture(&argv, &["auth", "status", "--json"], PROBE_TIMEOUT).await + else { + return false; + }; + claude_auth_status_indicates_logged_in(&captured) + } + HarnessId::Codex => { + let Some(captured) = + run_probe_capture(&argv, &["login", "status"], PROBE_TIMEOUT).await + else { + return false; + }; + codex_login_status_indicates_logged_in(&captured) + } + } +} + +/// Parses `claude auth status --json`'s output. Pure (no subprocess) so it +/// is unit-tested directly against captured output rather than requiring +/// a real login/logout cycle in CI. `text` is the MERGED stdout+stderr the +/// probe captured, so rather than parsing the whole capture (which fails +/// the moment any stderr noise precedes/follows the JSON), extract the +/// JSON object — first `{` through last `}` — and parse `loggedIn` from +/// that substring. A clean stdout-only JSON still parses (the whole string +/// is the object). +fn claude_auth_status_indicates_logged_in(text: &str) -> bool { + let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) else { + return false; + }; + if end < start { + return false; + } + serde_json::from_str::(&text[start..=end]) + .ok() + .and_then(|v| v.get("loggedIn").and_then(serde_json::Value::as_bool)) + .unwrap_or(false) +} + +/// Parses `codex login status`'s plain-text output ("Logged in using +/// ChatGPT" / "Logged in using an API key - ..." vs "Not logged in" — the +/// codex CLI has no `--json` flag for this subcommand, and prints this line +/// to STDERR). Pure for the same reason as the claude parser above. `text` +/// is the MERGED stdout+stderr, so check whether ANY line/segment is +/// "Logged in" (capital L) — true for "Logged in using ChatGPT" even when +/// it arrives via stderr after (empty) stdout, false for "Not logged in". +fn codex_login_status_indicates_logged_in(text: &str) -> bool { + text.lines() + .any(|line| line.trim_start().starts_with("Logged in")) +} + +/// Spawn ` ` with stdin closed (never let a probe hang reading +/// stdin — see `run.rs`'s module doc for why every harness spawn does +/// this), capture stdout AND stderr merged (stdout first, then stderr), +/// bounded by `timeout`. The auth probe needs the merge because codex +/// `login status` prints "Logged in ..." to stderr with an empty stdout; +/// merging is harmless for the `--version` probe (which only checks +/// success). `None` on spawn failure, non-success exit, or timeout; +/// non-UTF8-safe-decode issues are tolerated via lossy conversion, and the +/// child is best-effort killed on timeout so it doesn't leak as a zombie. +async fn run_probe_capture(argv: &[String], args: &[&str], timeout: Duration) -> Option { + let (program, rest) = argv.split_first()?; + let mut cmd = tokio::process::Command::new(program); + cmd.args(rest); + cmd.args(args); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().ok()?; + let mut stdout = child.stdout.take()?; + let mut stderr = child.stderr.take()?; + + let read_and_wait = async { + use tokio::io::AsyncReadExt; + let mut out = Vec::new(); + let mut err = Vec::new(); + // Drain BOTH pipes CONCURRENTLY: a child that fills its stderr + // pipe buffer while we're blocked reading stdout to EOF (or the + // reverse) would deadlock if we drained them sequentially. + let _ = tokio::join!(stdout.read_to_end(&mut out), stderr.read_to_end(&mut err)); + let status = child.wait().await; + (out, err, status) + }; + + match tokio::time::timeout(timeout, read_and_wait).await { + Ok((mut out, err, Ok(status))) if status.success() => { + out.extend_from_slice(&err); + Some(String::from_utf8_lossy(&out).into_owned()) + } + Ok(_) => None, + Err(_) => { + // Timed out — best-effort reap so we don't leak a zombie. The + // owning `Command`/`Child` was moved into `read_and_wait`'s + // future, which was dropped by the timeout; nothing left to + // kill from here on most platforms (drop already sends + // SIGKILL for a tokio::process::Child with kill_on_drop, the + // default). Nothing further to do. + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detection_merge_is_grow_only() { + let mut current = Detection { + claude_code: true, + codex: false, + }; + let probed_a_failure = Detection { + claude_code: false, + codex: false, + }; + let grew = current.merge(probed_a_failure); + assert!(!grew); + assert!( + current.claude_code, + "a transient probe failure must never un-list a previously-detected CLI" + ); + } + + #[test] + fn detection_merge_reports_growth_when_something_new_is_detected() { + let mut current = Detection::default(); + let probed = Detection { + claude_code: true, + codex: false, + }; + let grew = current.merge(probed); + assert!(grew); + assert!(current.claude_code); + assert!(!current.codex); + } + + #[test] + fn detection_merge_of_fully_detected_is_a_no_op() { + let mut current = Detection { + claude_code: true, + codex: true, + }; + let grew = current.merge(Detection { + claude_code: true, + codex: true, + }); + assert!(!grew); + assert!(current.all_detected()); + } + + #[test] + fn claude_auth_status_parses_logged_in_true() { + let sample = r#"{"loggedIn":true,"authMethod":"claude.ai","email":"a@b.com"}"#; + assert!(claude_auth_status_indicates_logged_in(sample)); + } + + #[test] + fn claude_auth_status_parses_logged_in_false() { + let sample = r#"{"loggedIn":false}"#; + assert!(!claude_auth_status_indicates_logged_in(sample)); + } + + #[test] + fn claude_auth_status_treats_garbage_as_not_logged_in() { + assert!(!claude_auth_status_indicates_logged_in("not json at all")); + assert!(!claude_auth_status_indicates_logged_in("")); + } + + #[test] + fn claude_auth_status_parses_json_embedded_in_merged_stderr_noise() { + // Merged stdout+stderr: a stderr warning line before the JSON must + // not break parsing (the whole-capture `from_str` would have). + let merged = "warning: config deprecated\n{\"loggedIn\":true,\"email\":\"a@b.com\"}\n"; + assert!(claude_auth_status_indicates_logged_in(merged)); + let merged_false = "some noise\n{\"loggedIn\":false}\ntrailing noise\n"; + assert!(!claude_auth_status_indicates_logged_in(merged_false)); + } + + #[test] + fn codex_login_status_recognizes_logged_in_variants() { + assert!(codex_login_status_indicates_logged_in( + "Logged in using ChatGPT" + )); + assert!(codex_login_status_indicates_logged_in( + "Logged in using an API key - sk-...\n" + )); + assert!(codex_login_status_indicates_logged_in( + " Logged in using ChatGPT" + )); + } + + #[test] + fn codex_login_status_recognizes_logged_in_delivered_via_stderr() { + // codex prints "Logged in ..." to STDERR with an empty stdout; the + // probe merges stdout (empty) + stderr, so the parser sees the line + // after a leading empty-stdout segment (and possibly other stderr + // lines). This is the regression this whole change fixes. + let merged = "\nLogged in using ChatGPT\n"; + assert!(codex_login_status_indicates_logged_in(merged)); + let merged_with_noise = "codex-cli 1.2.3\nLogged in using ChatGPT\n"; + assert!(codex_login_status_indicates_logged_in(merged_with_noise)); + } + + #[test] + fn codex_login_status_recognizes_not_logged_in() { + assert!(!codex_login_status_indicates_logged_in("Not logged in")); + assert!(!codex_login_status_indicates_logged_in("")); + } +} diff --git a/apps/native/crates/harness/src/events/claude.rs b/apps/native/crates/harness/src/events/claude.rs new file mode 100644 index 0000000000..69d3e79a50 --- /dev/null +++ b/apps/native/crates/harness/src/events/claude.rs @@ -0,0 +1,1125 @@ +//! `claude -p --output-format stream-json --include-partial-messages --verbose` ndjson → +//! `UIMessageChunk` mapping. +//! +//! Claude emits each partial content block twice: incremental +//! `stream_event.event.content_block_*` frames, then a complete +//! `assistant.message.content[]` snapshot immediately before that block's +//! `content_block_stop`. The mapper streams the partial frames and uses +//! the snapshot only to append a missing suffix or finalize tool input. +//! It retains the complete-frame path as a fallback for older CLIs and +//! the deterministic stub, which emit no partial frames. +//! This ordering was re-verified live with Claude Code 2.1.218 on +//! 2026-07-23; `fixtures/claude_partial.jsonl` is a trimmed deterministic +//! representation of that capture. +//! +//! Event mapping: +//! - `system.init` → [`crate::events::MappedEvent::SessionId`] (captured, +//! no chunk) + one `{"type":"start"}` chunk. +//! - `stream_event.content_block_*` → incremental text, reasoning, and +//! tool-input chunks with stable per-block ids. +//! - `assistant.message.content[]` → suffix/finalization for a matching +//! partial block, or a complete one-shot block when no partial exists. +//! - `user.message.content[]` (`tool_result` items) → +//! `tool-output-available` / `tool-output-error`. +//! - `result`, `is_error:true` → `MappedEvent::FatalError` (message from +//! `result.result` if present, else the first `result.errors[]` entry). +//! - `result`, `is_error:false` → one `finish` chunk carrying +//! usage/cost/session metadata. +//! - unknown events and unparseable lines → ignored. + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; + +use super::{chunk, EventMapper, FlushReason, MappedEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PartialBlockKind { + Text, + Reasoning, + Tool, +} + +#[derive(Debug, Default)] +struct BlockStatus { + stopped: bool, + snapshot_seen: bool, + finalized: bool, +} + +#[derive(Debug)] +enum PartialBlock { + Text { + id: String, + emitted: String, + status: BlockStatus, + }, + Reasoning { + id: String, + emitted: String, + status: BlockStatus, + }, + Tool { + tool_call_id: String, + tool_name: String, + input_text: String, + final_input: Option, + status: BlockStatus, + }, +} + +impl PartialBlock { + fn kind(&self) -> PartialBlockKind { + match self { + Self::Text { .. } => PartialBlockKind::Text, + Self::Reasoning { .. } => PartialBlockKind::Reasoning, + Self::Tool { .. } => PartialBlockKind::Tool, + } + } + + fn status(&self) -> &BlockStatus { + match self { + Self::Text { status, .. } + | Self::Reasoning { status, .. } + | Self::Tool { status, .. } => status, + } + } + + fn status_mut(&mut self) -> &mut BlockStatus { + match self { + Self::Text { status, .. } + | Self::Reasoning { status, .. } + | Self::Tool { status, .. } => status, + } + } + + fn matches_snapshot(&self, block: &Value) -> bool { + match self { + Self::Text { emitted, .. } => { + let Some(text) = block.get("text").and_then(Value::as_str) else { + return false; + }; + block.get("type").and_then(Value::as_str) == Some("text") + && (text.starts_with(emitted) || emitted.starts_with(text)) + } + Self::Reasoning { emitted, .. } => { + let Some(thinking) = block.get("thinking").and_then(Value::as_str) else { + return false; + }; + block.get("type").and_then(Value::as_str) == Some("thinking") + && (thinking.starts_with(emitted) || emitted.starts_with(thinking)) + } + Self::Tool { + tool_call_id, + tool_name, + .. + } => { + if block.get("type").and_then(Value::as_str) != Some("tool_use") { + return false; + } + let snapshot_id = block.get("id").and_then(Value::as_str).unwrap_or(""); + let snapshot_name = block.get("name").and_then(Value::as_str).unwrap_or(""); + (!snapshot_id.is_empty() && snapshot_id == tool_call_id) + || (snapshot_id.is_empty() + && !snapshot_name.is_empty() + && snapshot_name == tool_name) + } + } + } + + fn apply_delta(&mut self, delta: &Value) -> Vec { + match self { + Self::Text { id, emitted, .. } + if delta.get("type").and_then(Value::as_str) == Some("text_delta") => + { + let text = delta.get("text").and_then(Value::as_str).unwrap_or(""); + emitted.push_str(text); + if text.is_empty() { + vec![] + } else { + vec![chunk( + json!({"type": "text-delta", "id": id, "delta": text}), + )] + } + } + Self::Reasoning { id, emitted, .. } + if delta.get("type").and_then(Value::as_str) == Some("thinking_delta") => + { + let thinking = delta.get("thinking").and_then(Value::as_str).unwrap_or(""); + emitted.push_str(thinking); + if thinking.is_empty() { + vec![] + } else { + vec![chunk( + json!({"type": "reasoning-delta", "id": id, "delta": thinking}), + )] + } + } + Self::Tool { + tool_call_id, + input_text, + .. + } if delta.get("type").and_then(Value::as_str) == Some("input_json_delta") => { + let partial = delta + .get("partial_json") + .and_then(Value::as_str) + .unwrap_or(""); + input_text.push_str(partial); + if partial.is_empty() { + vec![] + } else { + vec![chunk(json!({ + "type": "tool-input-delta", + "toolCallId": tool_call_id, + "inputTextDelta": partial, + }))] + } + } + _ => vec![], + } + } + + fn apply_snapshot(&mut self, block: &Value) -> Vec { + let mut out = Vec::new(); + match self { + Self::Text { + id, + emitted, + status, + } => { + let snapshot = block.get("text").and_then(Value::as_str).unwrap_or(""); + append_missing_suffix(&mut out, "text-delta", id, emitted, snapshot); + status.snapshot_seen = true; + } + Self::Reasoning { + id, + emitted, + status, + } => { + let snapshot = block.get("thinking").and_then(Value::as_str).unwrap_or(""); + append_missing_suffix(&mut out, "reasoning-delta", id, emitted, snapshot); + status.snapshot_seen = true; + } + Self::Tool { + tool_call_id, + input_text, + final_input, + status, + .. + } => { + let input = block.get("input").cloned().unwrap_or_else(|| json!({})); + let snapshot_text = serde_json::to_string(&input).unwrap_or_default(); + if let Some(suffix) = snapshot_text.strip_prefix(input_text.as_str()) { + if !suffix.is_empty() { + input_text.push_str(suffix); + out.push(chunk(json!({ + "type": "tool-input-delta", + "toolCallId": tool_call_id, + "inputTextDelta": suffix, + }))); + } + } + *final_input = Some(input); + status.snapshot_seen = true; + } + } + out + } + + fn mark_stopped(&mut self) { + self.status_mut().stopped = true; + } + + fn should_finalize(&self) -> bool { + let status = self.status(); + status.stopped && status.snapshot_seen + } + + fn finalize(&mut self) -> Vec { + if self.status().finalized { + return vec![]; + } + self.status_mut().finalized = true; + match self { + Self::Text { id, .. } => vec![chunk(json!({"type": "text-end", "id": id}))], + Self::Reasoning { id, .. } => { + vec![chunk(json!({"type": "reasoning-end", "id": id}))] + } + Self::Tool { + tool_call_id, + tool_name, + input_text, + final_input, + .. + } => { + if let Some(input) = final_input.clone() { + vec![chunk(json!({ + "type": "tool-input-available", + "toolCallId": tool_call_id, + "toolName": tool_name, + "input": input, + }))] + } else { + let input = serde_json::from_str(input_text) + .unwrap_or_else(|_| Value::String(input_text.clone())); + vec![chunk(json!({ + "type": "tool-input-error", + "toolCallId": tool_call_id, + "toolName": tool_name, + "input": input, + "errorText": "Claude's stream ended before the tool input was complete", + }))] + } + } + } + } +} + +fn append_missing_suffix( + out: &mut Vec, + chunk_type: &str, + id: &str, + emitted: &mut String, + snapshot: &str, +) { + let Some(suffix) = snapshot.strip_prefix(emitted.as_str()) else { + return; + }; + if suffix.is_empty() { + return; + } + emitted.push_str(suffix); + out.push(chunk(json!({ + "type": chunk_type, + "id": id, + "delta": suffix, + }))); +} + +#[derive(Debug, Default)] +pub struct ClaudeEventMapper { + session_id: Option, + started: bool, + next_id: u64, + partial_blocks: BTreeMap, + current_block_index: Option, +} + +impl ClaudeEventMapper { + pub fn new() -> Self { + Self::default() + } + + fn next_part_id(&mut self) -> String { + let id = self.next_id; + self.next_id += 1; + id.to_string() + } + + fn handle_top_level(&mut self, value: &Value) -> Vec { + match value.get("type").and_then(Value::as_str) { + Some("system") => self.handle_system(value), + Some("assistant") => self.handle_assistant(value), + Some("user") => self.handle_user(value), + Some("result") => self.handle_result(value), + Some("stream_event") => self.handle_stream_event(value), + _ => vec![], + } + } + + fn handle_system(&mut self, value: &Value) -> Vec { + if value.get("subtype").and_then(Value::as_str) != Some("init") { + return vec![]; + } + let mut out = Vec::new(); + if !self.started { + self.started = true; + out.push(chunk(json!({"type": "start"}))); + } + if let Some(sid) = value.get("session_id").and_then(Value::as_str) { + self.session_id = Some(sid.to_string()); + out.push(MappedEvent::SessionId(sid.to_string())); + } + out + } + + fn handle_assistant(&mut self, value: &Value) -> Vec { + let Some(content) = value.pointer("/message/content").and_then(Value::as_array) else { + return vec![]; + }; + let mut out = Vec::new(); + for block in content { + let Some(index) = self.matching_partial_index(block) else { + out.extend(self.emit_complete_block(block)); + continue; + }; + let Some(partial) = self.partial_blocks.get_mut(&index) else { + continue; + }; + out.extend(partial.apply_snapshot(block)); + if partial.should_finalize() { + out.extend(partial.finalize()); + } + } + out + } + + fn emit_complete_block(&mut self, block: &Value) -> Vec { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + let text = block.get("text").and_then(Value::as_str).unwrap_or(""); + let id = self.next_part_id(); + vec![ + chunk(json!({"type": "text-start", "id": id})), + chunk(json!({"type": "text-delta", "id": id, "delta": text})), + chunk(json!({"type": "text-end", "id": id})), + ] + } + Some("thinking") => { + let text = block.get("thinking").and_then(Value::as_str).unwrap_or(""); + let id = self.next_part_id(); + vec![ + chunk(json!({"type": "reasoning-start", "id": id})), + chunk(json!({"type": "reasoning-delta", "id": id, "delta": text})), + chunk(json!({"type": "reasoning-end", "id": id})), + ] + } + Some("tool_use") => { + let tool_call_id = block + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let tool_name = block + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let input = block.get("input").cloned().unwrap_or_else(|| json!({})); + let input_text = serde_json::to_string(&input).unwrap_or_default(); + vec![ + chunk(json!({ + "type": "tool-input-start", + "toolCallId": tool_call_id, + "toolName": tool_name, + })), + chunk(json!({ + "type": "tool-input-delta", + "toolCallId": tool_call_id, + "inputTextDelta": input_text, + })), + chunk(json!({ + "type": "tool-input-available", + "toolCallId": tool_call_id, + "toolName": tool_name, + "input": input, + })), + ] + } + // `redacted_thinking`/any future block type: dropped rather + // than guessed at. + _ => vec![], + } + } + + fn matching_partial_index(&self, block: &Value) -> Option { + let kind = match block.get("type").and_then(Value::as_str) { + Some("text") => PartialBlockKind::Text, + Some("thinking") => PartialBlockKind::Reasoning, + Some("tool_use") => PartialBlockKind::Tool, + _ => return None, + }; + + if let Some(index) = self.current_block_index { + if self + .partial_blocks + .get(&index) + .is_some_and(|partial| partial.kind() == kind && partial.matches_snapshot(block)) + { + return Some(index); + } + } + + if let Some((index, _)) = self + .partial_blocks + .iter() + .rev() + .find(|(_, partial)| partial.kind() == kind && partial.matches_snapshot(block)) + { + return Some(*index); + } + + let mut unmatched = self + .partial_blocks + .iter() + .filter(|(_, partial)| partial.kind() == kind && !partial.status().snapshot_seen); + let (index, _) = unmatched.next()?; + if unmatched.next().is_none() { + Some(*index) + } else { + None + } + } + + fn handle_stream_event(&mut self, value: &Value) -> Vec { + let Some(event) = value.get("event") else { + return vec![]; + }; + match event.get("type").and_then(Value::as_str) { + Some("message_start") => self.finish_partial_message(), + Some("content_block_start") => self.handle_content_block_start(event), + Some("content_block_delta") => self.handle_content_block_delta(event), + Some("content_block_stop") => self.handle_content_block_stop(event), + Some("message_stop") => self.finish_partial_message(), + _ => vec![], + } + } + + fn handle_content_block_start(&mut self, event: &Value) -> Vec { + let Some(index) = event.get("index").and_then(Value::as_u64) else { + return vec![]; + }; + let Some(block) = event.get("content_block") else { + return vec![]; + }; + + let mut out = Vec::new(); + if let Some(mut prior) = self.partial_blocks.remove(&index) { + out.extend(prior.finalize()); + } + + let partial = match block.get("type").and_then(Value::as_str) { + Some("text") => { + let id = self.next_part_id(); + let initial = block + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + out.push(chunk(json!({"type": "text-start", "id": id}))); + if !initial.is_empty() { + out.push(chunk( + json!({"type": "text-delta", "id": id, "delta": initial}), + )); + } + PartialBlock::Text { + id, + emitted: initial, + status: BlockStatus::default(), + } + } + Some("thinking") => { + let id = self.next_part_id(); + let initial = block + .get("thinking") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + out.push(chunk(json!({"type": "reasoning-start", "id": id}))); + if !initial.is_empty() { + out.push(chunk( + json!({"type": "reasoning-delta", "id": id, "delta": initial}), + )); + } + PartialBlock::Reasoning { + id, + emitted: initial, + status: BlockStatus::default(), + } + } + Some("tool_use") => { + let tool_call_id = block + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let tool_name = block + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + out.push(chunk(json!({ + "type": "tool-input-start", + "toolCallId": tool_call_id, + "toolName": tool_name, + }))); + PartialBlock::Tool { + tool_call_id, + tool_name, + input_text: String::new(), + final_input: None, + status: BlockStatus::default(), + } + } + _ => return out, + }; + + self.partial_blocks.insert(index, partial); + self.current_block_index = Some(index); + out + } + + fn handle_content_block_delta(&mut self, event: &Value) -> Vec { + let Some(index) = event.get("index").and_then(Value::as_u64) else { + return vec![]; + }; + let Some(delta) = event.get("delta") else { + return vec![]; + }; + self.partial_blocks + .get_mut(&index) + .map_or_else(Vec::new, |partial| partial.apply_delta(delta)) + } + + fn handle_content_block_stop(&mut self, event: &Value) -> Vec { + let Some(index) = event.get("index").and_then(Value::as_u64) else { + return vec![]; + }; + if self.current_block_index == Some(index) { + self.current_block_index = None; + } + let Some(partial) = self.partial_blocks.get_mut(&index) else { + return vec![]; + }; + partial.mark_stopped(); + if partial.should_finalize() { + partial.finalize() + } else { + vec![] + } + } + + fn finish_partial_message(&mut self) -> Vec { + let mut out = Vec::new(); + for partial in self.partial_blocks.values_mut() { + out.extend(partial.finalize()); + } + self.partial_blocks.clear(); + self.current_block_index = None; + out + } + + fn handle_user(&mut self, value: &Value) -> Vec { + let mut out = self.finish_partial_message(); + let Some(content) = value.pointer("/message/content").and_then(Value::as_array) else { + return out; + }; + out.extend( + content + .iter() + .filter_map(|item| { + if item.get("type").and_then(Value::as_str) != Some("tool_result") { + return None; + } + let tool_call_id = item.get("tool_use_id").and_then(Value::as_str)?.to_string(); + let is_error = item + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + let content_val = item.get("content").cloned().unwrap_or(Value::Null); + Some(if is_error { + chunk(json!({ + "type": "tool-output-error", + "toolCallId": tool_call_id, + "errorText": stringify_tool_content(&content_val), + })) + } else { + chunk(json!({ + "type": "tool-output-available", + "toolCallId": tool_call_id, + "output": content_val, + })) + }) + }) + .collect::>(), + ); + out + } + + fn handle_result(&mut self, value: &Value) -> Vec { + let mut out = self.finish_partial_message(); + // Error results can be the only frame carrying the session id (for + // example, an early resume failure with no preceding init). Capture it + // before branching on `is_error` so the durable assistant result keeps + // the resume anchor even though this turn itself failed. + if let Some(sid) = value + .get("session_id") + .and_then(Value::as_str) + .filter(|sid| !sid.trim().is_empty()) + { + self.session_id = Some(sid.to_string()); + out.push(MappedEvent::SessionId(sid.to_string())); + } + let is_error = value + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + if is_error { + let message = value + .get("result") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| { + // The stub's `fail` scenario (and, per the module doc, + // possibly the real CLI) carries `errors: [string]` + // instead of a `result` string on an error frame. + value + .get("errors") + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .or_else(|| { + value + .get("subtype") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "claude CLI reported an error".to_string()); + out.push(MappedEvent::FatalError { message }); + return out; + } + + let usage = value.get("usage").cloned().unwrap_or(Value::Null); + let total_cost_usd = value.get("total_cost_usd").cloned().unwrap_or(Value::Null); + out.push(chunk(json!({ + "type": "finish", + "finishReason": "stop", + "messageMetadata": { + "usage": usage, + "totalCostUsd": total_cost_usd, + "codingAgentProvider": "claude-code", + "codingAgentSessionId": self.session_id, + }, + }))); + out + } +} + +/// Best-effort text extraction from a `tool_result` content value for the +/// `tool-output-error` chunk's `errorText` (a plain string per the AI +/// SDK's `UIMessageChunk` shape) — the CLI's `content` field itself may be +/// a string OR an array of content blocks. +fn stringify_tool_content(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Array(items) => items + .iter() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"), + other => other.to_string(), + } +} + +impl EventMapper for ClaudeEventMapper { + fn feed_line(&mut self, line: &str) -> Vec { + let trimmed = line.trim(); + if trimmed.is_empty() { + return vec![]; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + // A stray non-JSON line (banners, warnings) must not abort an + // otherwise-healthy run. + return vec![]; + }; + self.handle_top_level(&value) + } + + fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + fn flush(&mut self, _reason: FlushReason) -> Vec { + self.finish_partial_message() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIMPLE_FIXTURE: &str = include_str!("fixtures/claude_simple.jsonl"); + const ERROR_FIXTURE: &str = include_str!("fixtures/claude_error.jsonl"); + const PARTIAL_FIXTURE: &str = include_str!("fixtures/claude_partial.jsonl"); + const TOOL_USE_FIXTURE: &str = include_str!("fixtures/claude_tool_use.jsonl"); + + fn feed_all(mapper: &mut ClaudeEventMapper, fixture: &str) -> Vec { + fixture + .lines() + .flat_map(|line| mapper.feed_line(line)) + .collect() + } + + #[test] + fn simple_fixture_yields_start_session_text_and_finish() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, SIMPLE_FIXTURE); + + assert_eq!(events.first(), Some(&chunk(json!({"type": "start"})))); + assert!(events + .iter() + .any(|e| matches!(e, MappedEvent::SessionId(_)))); + assert!(mapper.session_id().is_some()); + + let text_deltas: Vec<&str> = events + .iter() + .filter_map(|e| match e { + MappedEvent::Chunk(v) if v["type"] == "text-delta" => v["delta"].as_str(), + _ => None, + }) + .collect(); + assert_eq!(text_deltas.join(""), "pong"); + + let finish = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "finish")) + .expect("a finish chunk"); + let MappedEvent::Chunk(finish) = finish else { + unreachable!() + }; + assert_eq!(finish["finishReason"], "stop"); + assert_eq!( + finish["messageMetadata"]["codingAgentProvider"], + "claude-code" + ); + + assert!(!events + .iter() + .any(|e| matches!(e, MappedEvent::FatalError { .. }))); + } + + #[test] + fn start_chunk_is_emitted_exactly_once() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, SIMPLE_FIXTURE); + let starts = events + .iter() + .filter(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "start")) + .count(); + assert_eq!(starts, 1); + } + + #[test] + fn tool_use_fixture_yields_tool_input_and_output_chunks() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, TOOL_USE_FIXTURE); + + let available = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "tool-input-available")) + .expect("a tool-input-available chunk"); + let MappedEvent::Chunk(available) = available else { + unreachable!() + }; + assert_eq!(available["toolCallId"], "toolu_01ABC"); + assert_eq!(available["toolName"], "Bash"); + assert_eq!(available["input"]["command"], "echo hi"); + + let output = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "tool-output-available")) + .expect("a tool-output-available chunk"); + let MappedEvent::Chunk(output) = output else { + unreachable!() + }; + assert_eq!(output["toolCallId"], "toolu_01ABC"); + } + + #[test] + fn result_is_error_true_with_result_field_yields_a_fatal_error() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, ERROR_FIXTURE); + assert_eq!( + events.last(), + Some(&MappedEvent::FatalError { + message: "API Error: 529 overloaded".to_string() + }) + ); + } + + #[test] + fn result_is_error_true_with_errors_array_falls_back_to_the_first_entry() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["stub-harness: induced failure"]}"#, + ); + assert_eq!( + events, + vec![MappedEvent::FatalError { + message: "stub-harness: induced failure".to_string() + }] + ); + } + + #[test] + fn error_result_captures_session_before_reporting_the_failure() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"failed","session_id":"error-session"}"#, + ); + assert_eq!( + events, + vec![ + MappedEvent::SessionId("error-session".to_string()), + MappedEvent::FatalError { + message: "failed".to_string() + } + ] + ); + assert_eq!(mapper.session_id(), Some("error-session")); + } + + #[test] + fn malformed_json_line_is_ignored_not_fatal() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line("Reading additional input from stdin..."); + assert_eq!(events, vec![]); + } + + #[test] + fn blank_line_is_ignored() { + let mut mapper = ClaudeEventMapper::new(); + assert_eq!(mapper.feed_line(""), vec![]); + assert_eq!(mapper.feed_line(" "), vec![]); + } + + #[test] + fn unknown_top_level_type_is_ignored() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line(r#"{"type":"rate_limit_event","rate_limit_info":{}}"#); + assert_eq!(events, vec![]); + } + + #[test] + fn partial_text_is_streamed_and_the_identical_snapshot_is_deduplicated() { + let mut mapper = ClaudeEventMapper::new(); + let fixture = [ + r#"{"type":"stream_event","event":{"type":"message_start","message":{"content":[]}}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}}"#, + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"pong"}]}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_stop","index":0}}"#, + r#"{"type":"stream_event","event":{"type":"message_stop"}}"#, + ] + .join("\n"); + let events = feed_all(&mut mapper, &fixture); + + let text_deltas: Vec<&str> = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "text-delta" => { + value["delta"].as_str() + } + _ => None, + }) + .collect(); + assert_eq!(text_deltas, vec!["pong"]); + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + MappedEvent::Chunk(value) if value["type"] == "text-start" + )) + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + MappedEvent::Chunk(value) if value["type"] == "text-end" + )) + .count(), + 1 + ); + } + + #[test] + fn eof_closes_a_partial_text_block_exactly_once() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":"hel"}}}"#, + ); + assert!(events.iter().any( + |event| matches!(event, MappedEvent::Chunk(value) if value["type"] == "text-start") + )); + + let flushed = mapper.flush(FlushReason::EndOfStream); + assert_eq!(flushed, vec![chunk(json!({"type": "text-end", "id": "0"}))]); + assert!(mapper.flush(FlushReason::EndOfStream).is_empty()); + } + + #[test] + fn cancellation_closes_a_partial_reasoning_block() { + let mut mapper = ClaudeEventMapper::new(); + mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}}"#, + ); + mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"still thinking"}}}"#, + ); + + assert_eq!( + mapper.flush(FlushReason::Cancelled), + vec![chunk(json!({"type": "reasoning-end", "id": "0"}))] + ); + } + + #[test] + fn failed_stream_turns_partial_tool_input_into_tool_input_error() { + let mut mapper = ClaudeEventMapper::new(); + mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_cut","name":"Bash","input":{}}}}"#, + ); + mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\":"}}}"#, + ); + + let flushed = mapper.flush(FlushReason::Failed); + assert_eq!( + flushed, + vec![chunk(json!({ + "type": "tool-input-error", + "toolCallId": "toolu_cut", + "toolName": "Bash", + "input": "{\"command\":", + "errorText": "Claude's stream ended before the tool input was complete", + }))] + ); + assert!(!flushed.iter().any( + |event| matches!(event, MappedEvent::Chunk(value) if value["type"] == "tool-input-available") + )); + } + + #[test] + fn partial_fixture_appends_snapshot_suffixes_and_finalizes_reasoning_and_tools() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, PARTIAL_FIXTURE); + + let text = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "text-delta" => { + value["delta"].as_str() + } + _ => None, + }) + .collect::(); + assert_eq!(text, "hello"); + + let reasoning = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "reasoning-delta" => { + value["delta"].as_str() + } + _ => None, + }) + .collect::(); + assert_eq!(reasoning, "Checking the task"); + + let tool_input_text = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "tool-input-delta" => { + value["inputTextDelta"].as_str() + } + _ => None, + }) + .collect::(); + assert_eq!(tool_input_text, r#"{"command":"echo hi"}"#); + + let tool_available: Vec<&Value> = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "tool-input-available" => Some(value), + _ => None, + }) + .collect(); + assert_eq!(tool_available.len(), 1); + assert_eq!(tool_available[0]["toolCallId"], "toolu_partial"); + assert_eq!(tool_available[0]["input"]["command"], "echo hi"); + assert!(events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "tool-input-start" => { + value["toolCallId"].as_str() + } + _ => None, + }) + .all(|id| id == "toolu_partial")); + + for (start, end) in [ + ("text-start", "text-end"), + ("reasoning-start", "reasoning-end"), + ] { + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + MappedEvent::Chunk(value) if value["type"] == start + )) + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + MappedEvent::Chunk(value) if value["type"] == end + )) + .count(), + 1 + ); + let start_id = events.iter().find_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == start => value["id"].as_str(), + _ => None, + }); + let end_id = events.iter().find_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == end => value["id"].as_str(), + _ => None, + }); + assert_eq!(start_id, end_id); + } + } + + #[test] + fn multiple_assistant_frames_each_contribute_their_own_text_block() { + // Mirrors stub-harness.mjs's SCENARIO:simple: 3 separate + // `assistant` frames, each with ONE new text block — not an + // accumulating snapshot. + let mut mapper = ClaudeEventMapper::new(); + mapper.feed_line(r#"{"type":"system","subtype":"init","session_id":"s1"}"#); + let e1 = mapper.feed_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}"#, + ); + let e2 = mapper.feed_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":" from"}]}}"#, + ); + let e3 = mapper.feed_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":" stub"}]}}"#, + ); + let all: Vec = [e1, e2, e3].concat(); + let deltas: Vec<&str> = all + .iter() + .filter_map(|e| match e { + MappedEvent::Chunk(v) if v["type"] == "text-delta" => v["delta"].as_str(), + _ => None, + }) + .collect(); + assert_eq!(deltas, vec!["Hello", " from", " stub"]); + // Each block gets its OWN part id (start/end pair), not shared. + let starts = all + .iter() + .filter(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "text-start")) + .count(); + assert_eq!(starts, 3); + } +} diff --git a/apps/native/crates/harness/src/events/codex.rs b/apps/native/crates/harness/src/events/codex.rs new file mode 100644 index 0000000000..7c5f97c712 --- /dev/null +++ b/apps/native/crates/harness/src/events/codex.rs @@ -0,0 +1,425 @@ +//! `codex exec --json --skip-git-repo-check ` ndjson → +//! `UIMessageChunk` mapping. +//! +//! Wire shapes for `thread.started`/`turn.started`/`item.completed` +//! (`agent_message` item)/`turn.completed` were captured live on this +//! machine (codex-cli 0.144.5, 2026-07-18) — see +//! `events/fixtures/codex_exec.jsonl` (real capture, verbatim). Codex's +//! `--json` protocol also documents `item.started`/`item.updated` (for +//! items that stream incrementally) and richer item types +//! (`reasoning`, `command_execution`, `file_change`, `mcp_tool_call`, +//! `web_search`, `todo_list`) plus `turn.failed`/top-level `error` — none +//! of those were exercised by the trivial "reply with one word" smoke +//! prompt (a real capture would need to spend real API credits driving an +//! actual tool call, which this crate doesn't need to do twice to build a +//! reasonable mapping). `events/fixtures/codex_tool_use.jsonl` and +//! `events/fixtures/codex_error.jsonl` are HAND-CONSTRUCTED to the +//! documented item/turn shapes; unrecognized item types fall through a +//! generic best-effort mapping (see `handle_item` below) rather than being +//! silently dropped, so a future item type this mapper hasn't been taught +//! about yet still surfaces SOMETHING instead of vanishing. +//! +//! Unlike claude's Anthropic-Messages-API deltas, codex's `--json` items +//! arrive essentially as a WHOLE (`item.completed`) rather than as +//! incremental token deltas — so each `agent_message`/`reasoning` item +//! synthesizes a start+delta(full text)+end triplet in one line rather +//! than genuinely streaming word-by-word. `item.started`/`item.updated` +//! ARE handled (for forward-compat with a codex version that does stream +//! them): the mapper tracks each item's last-seen text and emits only the +//! GROWN suffix as the delta, so a future incrementally-updated item +//! degrades gracefully into real deltas instead of repeating the whole +//! text on every update. +//! +//! Event mapping: +//! - `thread.started` → [`crate::events::MappedEvent::SessionId`] +//! (`thread_id` is codex's resume token — `codex exec resume +//! `). +//! - `turn.started` → one `{"type":"start"}` chunk. +//! - `item.started` / `item.updated` / `item.completed` → see +//! [`handle_item`]. +//! - `turn.completed` → one `finish` chunk carrying usage metadata. +//! - `turn.failed` / top-level `error` → `MappedEvent::FatalError`. +//! - anything else / an unparseable line → `Ignored`. + +use std::collections::HashMap; + +use serde_json::{json, Value}; + +use super::{chunk, EventMapper, MappedEvent}; + +#[derive(Debug, Default)] +pub struct CodexEventMapper { + /// Per-item last-seen text, keyed by item id — lets `item.updated` + /// (if a future codex version streams it) emit only the grown suffix. + item_text: HashMap, + /// Item ids whose start chunk has already been emitted (so a repeated + /// `item.updated` for the same id doesn't re-open it). + item_started: std::collections::HashSet, + session_id: Option, + turn_started: bool, +} + +impl CodexEventMapper { + pub fn new() -> Self { + Self::default() + } + + fn handle_top_level(&mut self, value: &Value) -> Vec { + match value.get("type").and_then(Value::as_str) { + Some("thread.started") => self.handle_thread_started(value), + Some("turn.started") => { + if self.turn_started { + return vec![]; + } + self.turn_started = true; + vec![chunk(json!({"type": "start"}))] + } + Some("item.started") | Some("item.updated") => value + .get("item") + .map(|item| self.handle_item(item, false)) + .unwrap_or_default(), + Some("item.completed") => value + .get("item") + .map(|item| self.handle_item(item, true)) + .unwrap_or_default(), + Some("turn.completed") => Self::handle_turn_completed(value), + Some("turn.failed") => Self::handle_turn_failed(value), + Some("error") => Some(MappedEvent::FatalError { + message: value + .get("message") + .and_then(Value::as_str) + .unwrap_or("codex CLI reported an error") + .to_string(), + }) + .into_iter() + .collect(), + _ => vec![], + } + } + + fn handle_thread_started(&mut self, value: &Value) -> Vec { + let Some(id) = value.get("thread_id").and_then(Value::as_str) else { + return vec![]; + }; + self.session_id = Some(id.to_string()); + vec![MappedEvent::SessionId(id.to_string())] + } + + /// Maps ONE item (from `item.started`/`item.updated`/`item.completed`) + /// to zero or more chunks. `agent_message`/`reasoning` map to + /// text/reasoning start+delta(+end on completion); every other item + /// type (`command_execution`, `file_change`, `mcp_tool_call`, + /// `web_search`, `todo_list`, and anything not yet known) maps to a + /// generic tool-call shape: `toolName` is the item's own `type`, + /// `input` is the item object minus `id`/`type`/`status` (the + /// "control" fields), `output` populated from the item's own status + /// once it's `completed`. + fn handle_item(&mut self, item: &Value, is_completed_event: bool) -> Vec { + let Some(id) = item.get("id").and_then(Value::as_str) else { + return vec![]; + }; + let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); + // "Completed" if either the wrapping event says so (the + // `item.completed` variant) OR the item carries its own terminal + // `status` field — defensive coverage for whichever convention a + // given codex version actually uses; the real capture this + // mapper was built against carries no `status` field at all on + // its `item.completed` item, so `is_completed_event` alone must + // be sufficient. + let completed = + is_completed_event || item.get("status").and_then(Value::as_str) == Some("completed"); + + match item_type { + "agent_message" => self.handle_text_like_item(id, item, "text", completed), + "reasoning" => self.handle_text_like_item(id, item, "reasoning", completed), + // Generic (tool-shaped) items only surface once complete — an + // `item.started`/`item.updated` for e.g. a `command_execution` + // rarely carries a full `input`/`output` yet, and emitting the + // same object twice (once on started, again on completed) + // would duplicate the chunk. + other if completed => self.handle_generic_tool_item(id, other, item), + _ => vec![], + } + } + + /// `agent_message`/`reasoning` items: emit a `-start` the first time + /// this item id is seen, a `-delta` for whatever text GREW since the + /// last update (the whole text on first sight), and a `-end` only once + /// the item reaches a terminal status (or unconditionally for + /// `item.completed`, since that event has no further updates coming). + fn handle_text_like_item( + &mut self, + id: &str, + item: &Value, + kind: &str, + completed: bool, + ) -> Vec { + let text = item + .get("text") + .or_else(|| item.get("summary")) + .and_then(Value::as_str) + .unwrap_or_default(); + let mut out = Vec::new(); + + let first_sight = self.item_started.insert(id.to_string()); + if first_sight { + out.push(chunk(json!({"type": format!("{kind}-start"), "id": id}))); + } + + let previous = self.item_text.get(id).cloned().unwrap_or_default(); + if let Some(grown) = text.strip_prefix(&previous) { + if !grown.is_empty() { + out.push(chunk( + json!({"type": format!("{kind}-delta"), "id": id, "delta": grown}), + )); + } + } else if text != previous { + // Text didn't grow as a simple suffix (a rewrite, not an + // append) — emit the new text wholesale rather than a + // misleading diff. + out.push(chunk( + json!({"type": format!("{kind}-delta"), "id": id, "delta": text}), + )); + } + self.item_text.insert(id.to_string(), text.to_string()); + + if completed { + out.push(chunk(json!({"type": format!("{kind}-end"), "id": id}))); + } + out + } + + /// Any item type that isn't `agent_message`/`reasoning` — a generic, + /// best-effort tool-call mapping so a not-yet-specifically-handled + /// item (or a genuinely new item type a future codex version adds) + /// still surfaces as SOMETHING instead of silently vanishing (see + /// module doc). + fn handle_generic_tool_item( + &self, + id: &str, + tool_name: &str, + item: &Value, + ) -> Vec { + let mut input = item.clone(); + if let Value::Object(map) = &mut input { + map.remove("id"); + map.remove("type"); + } + vec![chunk(json!({ + "type": "tool-input-available", + "toolCallId": id, + "toolName": tool_name, + "input": input, + }))] + } + + fn handle_turn_completed(value: &Value) -> Vec { + let usage = value.get("usage").cloned().unwrap_or(Value::Null); + vec![chunk(json!({ + "type": "finish", + "finishReason": "stop", + "messageMetadata": { + "usage": usage, + "codingAgentProvider": "codex", + }, + }))] + } + + fn handle_turn_failed(value: &Value) -> Vec { + let message = value + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("codex CLI reported a turn failure") + .to_string(); + vec![MappedEvent::FatalError { message }] + } +} + +impl EventMapper for CodexEventMapper { + fn feed_line(&mut self, line: &str) -> Vec { + let trimmed = line.trim(); + if trimmed.is_empty() { + return vec![]; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + // Real captured behavior: codex prints a human + // "Reading additional input from stdin..." banner to stdout + // BEFORE its JSON stream when stdin isn't closed — see + // `run.rs`'s module doc for why every harness spawn closes + // stdin, and this fallback for defense in depth regardless. + return vec![]; + }; + self.handle_top_level(&value) + } + + fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXEC_FIXTURE: &str = include_str!("fixtures/codex_exec.jsonl"); + const TOOL_USE_FIXTURE: &str = include_str!("fixtures/codex_tool_use.jsonl"); + const ERROR_FIXTURE: &str = include_str!("fixtures/codex_error.jsonl"); + + fn feed_all(mapper: &mut CodexEventMapper, fixture: &str) -> Vec { + fixture + .lines() + .flat_map(|line| mapper.feed_line(line)) + .collect() + } + + #[test] + fn exec_fixture_yields_session_start_text_and_finish() { + let mut mapper = CodexEventMapper::new(); + let events = feed_all(&mut mapper, EXEC_FIXTURE); + + assert_eq!( + events.first(), + Some(&MappedEvent::SessionId( + "019f7325-3e34-7b30-863a-861396b02def".to_string() + )) + ); + assert_eq!( + mapper.session_id(), + Some("019f7325-3e34-7b30-863a-861396b02def") + ); + + assert!(events.contains(&chunk(json!({"type": "start"})))); + assert!(events.contains(&chunk(json!({"type": "text-start", "id": "item_0"})))); + assert!(events.contains(&chunk( + json!({"type": "text-delta", "id": "item_0", "delta": "pong"}) + ))); + assert!(events.contains(&chunk(json!({"type": "text-end", "id": "item_0"})))); + + let finish = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "finish")) + .expect("a finish chunk"); + let MappedEvent::Chunk(finish) = finish else { + unreachable!() + }; + assert_eq!(finish["messageMetadata"]["codingAgentProvider"], "codex"); + assert_eq!(finish["messageMetadata"]["usage"]["output_tokens"], 106); + + assert!(!events + .iter() + .any(|e| matches!(e, MappedEvent::FatalError { .. }))); + } + + #[test] + fn agent_message_start_is_emitted_only_once() { + let mut mapper = CodexEventMapper::new(); + let events = feed_all(&mut mapper, EXEC_FIXTURE); + let starts = events + .iter() + .filter(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "text-start")) + .count(); + assert_eq!(starts, 1); + } + + #[test] + fn tool_use_fixture_maps_reasoning_and_generic_command_execution() { + let mut mapper = CodexEventMapper::new(); + let events = feed_all(&mut mapper, TOOL_USE_FIXTURE); + + assert!(events.contains(&chunk(json!({"type": "reasoning-start", "id": "item_0"})))); + assert!(events.contains(&chunk( + json!({"type": "reasoning-delta", "id": "item_0", "delta": "Figuring out what to run."}) + ))); + assert!(events.contains(&chunk(json!({"type": "reasoning-end", "id": "item_0"})))); + + let tool_call = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["toolCallId"] == "item_1")) + .expect("a generic tool-input-available chunk for command_execution"); + let MappedEvent::Chunk(tool_call) = tool_call else { + unreachable!() + }; + assert_eq!(tool_call["type"], "tool-input-available"); + assert_eq!(tool_call["toolName"], "command_execution"); + assert_eq!(tool_call["input"]["command"], "echo hi"); + assert_eq!(tool_call["input"]["exit_code"], 0); + // Control fields id/type are stripped from `input`. + assert!(tool_call["input"].get("id").is_none()); + assert!(tool_call["input"].get("type").is_none()); + } + + #[test] + fn turn_failed_yields_a_fatal_error_with_the_reported_message() { + let mut mapper = CodexEventMapper::new(); + let events = feed_all(&mut mapper, ERROR_FIXTURE); + assert_eq!( + events.last(), + Some(&MappedEvent::FatalError { + message: "stream disconnected before completion".to_string() + }) + ); + } + + #[test] + fn top_level_error_type_is_also_fatal() { + let mut mapper = CodexEventMapper::new(); + let events = mapper.feed_line(r#"{"type":"error","message":"boom"}"#); + assert_eq!( + events, + vec![MappedEvent::FatalError { + message: "boom".to_string() + }] + ); + } + + #[test] + fn malformed_json_line_is_ignored_not_fatal() { + let mut mapper = CodexEventMapper::new(); + let events = mapper.feed_line("Reading additional input from stdin..."); + assert_eq!(events, vec![]); + } + + #[test] + fn incremental_item_updated_emits_only_the_grown_suffix() { + let mut mapper = CodexEventMapper::new(); + mapper.feed_line(r#"{"type":"thread.started","thread_id":"t1"}"#); + mapper.feed_line(r#"{"type":"turn.started"}"#); + let first = mapper.feed_line( + r#"{"type":"item.updated","item":{"id":"item_0","type":"agent_message","text":"pon","status":"in_progress"}}"#, + ); + assert!(first.contains(&chunk(json!({"type":"text-start","id":"item_0"})))); + assert!(first.contains(&chunk( + json!({"type":"text-delta","id":"item_0","delta":"pon"}) + ))); + + let second = mapper.feed_line( + r#"{"type":"item.updated","item":{"id":"item_0","type":"agent_message","text":"pong","status":"in_progress"}}"#, + ); + // Only the grown suffix "g", no repeated start. + assert_eq!( + second, + vec![chunk( + json!({"type":"text-delta","id":"item_0","delta":"g"}) + )] + ); + + let third = mapper.feed_line( + r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"pong"}}"#, + ); + assert_eq!(third, vec![chunk(json!({"type":"text-end","id":"item_0"}))]); + } + + #[test] + fn unknown_item_type_still_surfaces_generically() { + let mut mapper = CodexEventMapper::new(); + let events = mapper.feed_line( + r#"{"type":"item.completed","item":{"id":"item_9","type":"some_future_item","payload":42}}"#, + ); + let MappedEvent::Chunk(c) = &events[0] else { + panic!("expected a chunk") + }; + assert_eq!(c["type"], "tool-input-available"); + assert_eq!(c["toolName"], "some_future_item"); + assert_eq!(c["input"]["payload"], 42); + } +} diff --git a/apps/native/crates/harness/src/events/fixtures/claude_error.jsonl b/apps/native/crates/harness/src/events/fixtures/claude_error.jsonl new file mode 100644 index 0000000000..0f2fd588d3 --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/claude_error.jsonl @@ -0,0 +1,2 @@ +{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"error-session","tools":["Bash"],"model":"claude-fable-5"} +{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: 529 overloaded","session_id":"error-session"} diff --git a/apps/native/crates/harness/src/events/fixtures/claude_partial.jsonl b/apps/native/crates/harness/src/events/fixtures/claude_partial.jsonl new file mode 100644 index 0000000000..6ca1cc00cf --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/claude_partial.jsonl @@ -0,0 +1,17 @@ +{"type":"system","subtype":"init","session_id":"partial-session"} +{"type":"stream_event","event":{"type":"message_start","message":{"content":[]}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Checking"}},"session_id":"partial-session"} +{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"Checking the task","signature":"fixture"}]},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_partial","name":"Bash","input":{}}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":"}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"echo hi\"}"}},"session_id":"partial-session"} +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_partial","name":"Bash","input":{"command":"echo hi"}}]},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_stop","index":1},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"hel"}},"session_id":"partial-session"} +{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_stop","index":2},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"partial-session"} +{"type":"result","subtype":"success","is_error":false,"result":"hello","session_id":"partial-session","usage":{"input_tokens":1,"output_tokens":2}} diff --git a/apps/native/crates/harness/src/events/fixtures/claude_simple.jsonl b/apps/native/crates/harness/src/events/fixtures/claude_simple.jsonl new file mode 100644 index 0000000000..03aa323835 --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/claude_simple.jsonl @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"770886ee-0c27-444d-b6b1-85fa370466e7","tools":["Bash","Read","Write"],"model":"claude-fable-5","permissionMode":"default","apiKeySource":"none","claude_code_version":"2.1.214"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1784359800,"rateLimitType":"five_hour"},"uuid":"013f1974-40e0-4fa7-9cfe-60b3a46043e4","session_id":"770886ee-0c27-444d-b6b1-85fa370466e7"} +{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011Cd8mPPW3FXy5S3H8FmUHF","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISiQIKiAEIDxgCKkD0"}],"stop_reason":null,"usage":{"input_tokens":2,"output_tokens":2}},"parent_tool_use_id":null,"session_id":"770886ee-0c27-444d-b6b1-85fa370466e7","uuid":"7c58a441-0e8c-48c4-b132-f7253cfe701a","timestamp":"2026-07-18T02:52:44.744Z"} +{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011Cd8mPPW3FXy5S3H8FmUHF","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}],"stop_reason":null,"usage":{"input_tokens":2,"output_tokens":17}},"parent_tool_use_id":null,"session_id":"770886ee-0c27-444d-b6b1-85fa370466e7","uuid":"5bc95ffd-c667-4c74-90aa-7bdabae3c79c","timestamp":"2026-07-18T02:52:44.753Z"} +{"type":"result","subtype":"success","is_error":false,"duration_ms":2862,"num_turns":1,"result":"pong","stop_reason":"end_turn","session_id":"770886ee-0c27-444d-b6b1-85fa370466e7","total_cost_usd":0.170644,"usage":{"input_tokens":2,"output_tokens":17},"permission_denials":[],"terminal_reason":"completed"} diff --git a/apps/native/crates/harness/src/events/fixtures/claude_tool_use.jsonl b/apps/native/crates/harness/src/events/fixtures/claude_tool_use.jsonl new file mode 100644 index 0000000000..402074bd60 --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/claude_tool_use.jsonl @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"tool-use-session","tools":["Bash"],"model":"claude-fable-5"} +{"type":"assistant","message":{"id":"msg_tool1","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ABC","name":"Bash","input":{"command":"echo hi"}}],"usage":{"input_tokens":1,"output_tokens":1}},"session_id":"tool-use-session"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01ABC","content":[{"type":"text","text":"hi\n"}],"is_error":false}]},"session_id":"tool-use-session"} +{"type":"assistant","message":{"id":"msg_tool1","role":"assistant","content":[{"type":"text","text":"Ran `echo hi`. Output: hi"}],"usage":{"input_tokens":1,"output_tokens":10}},"session_id":"tool-use-session"} +{"type":"result","subtype":"success","is_error":false,"result":"Ran `echo hi`. Output: hi","session_id":"tool-use-session","total_cost_usd":0.01,"usage":{"input_tokens":1,"output_tokens":10}} diff --git a/apps/native/crates/harness/src/events/fixtures/codex_error.jsonl b/apps/native/crates/harness/src/events/fixtures/codex_error.jsonl new file mode 100644 index 0000000000..df8732e1ba --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/codex_error.jsonl @@ -0,0 +1,3 @@ +{"type":"thread.started","thread_id":"error-thread"} +{"type":"turn.started"} +{"type":"turn.failed","error":{"message":"stream disconnected before completion"}} diff --git a/apps/native/crates/harness/src/events/fixtures/codex_exec.jsonl b/apps/native/crates/harness/src/events/fixtures/codex_exec.jsonl new file mode 100644 index 0000000000..705cdaae2f --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/codex_exec.jsonl @@ -0,0 +1,4 @@ +{"type":"thread.started","thread_id":"019f7325-3e34-7b30-863a-861396b02def"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"pong"}} +{"type":"turn.completed","usage":{"input_tokens":10522,"cached_input_tokens":5120,"output_tokens":106,"reasoning_output_tokens":99}} diff --git a/apps/native/crates/harness/src/events/fixtures/codex_tool_and_error.jsonl b/apps/native/crates/harness/src/events/fixtures/codex_tool_and_error.jsonl new file mode 100644 index 0000000000..f12a40eacd --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/codex_tool_and_error.jsonl @@ -0,0 +1,9 @@ +{"type":"thread.started","thread_id":"tool-use-thread"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Figuring out what to run."}} +{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"echo hi","aggregated_output":"hi\n","exit_code":0,"status":"completed"}} +{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}} +{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":5}} +{"type":"thread.started","thread_id":"error-thread"} +{"type":"turn.started"} +{"type":"turn.failed","error":{"message":"stream disconnected before completion"}} diff --git a/apps/native/crates/harness/src/events/fixtures/codex_tool_use.jsonl b/apps/native/crates/harness/src/events/fixtures/codex_tool_use.jsonl new file mode 100644 index 0000000000..998122ea86 --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/codex_tool_use.jsonl @@ -0,0 +1,6 @@ +{"type":"thread.started","thread_id":"tool-use-thread"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Figuring out what to run."}} +{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"echo hi","aggregated_output":"hi\n","exit_code":0,"status":"completed"}} +{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}} +{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":5}} diff --git a/apps/native/crates/harness/src/events/mod.rs b/apps/native/crates/harness/src/events/mod.rs new file mode 100644 index 0000000000..fd6702566d --- /dev/null +++ b/apps/native/crates/harness/src/events/mod.rs @@ -0,0 +1,86 @@ +//! ndjson → `UIMessageChunk`-shaped-JSON mapping, one submodule per CLI's +//! wire format. See `crate` module doc's "Unknown-event policy" for what +//! happens to a line neither mapper recognizes. +//! +//! Both mappers are STATEFUL (`content_block_start`/`_delta`/`_stop` for +//! claude, `item.started`/`item.completed` for codex all need to remember +//! what's "open" across lines) but PURE otherwise — `feed_line` takes a +//! `&str`, returns `Vec`, does no I/O. That's what makes them +//! unit-testable by feeding captured fixture lines (`events/fixtures/*`) +//! without spawning any process; `run.rs` is the only place that wires a +//! mapper to a real child process's stdout. + +pub mod claude; +pub mod codex; + +use serde_json::Value; + +/// One decoded outcome from a single ndjson line of harness output. +#[derive(Debug, Clone, PartialEq)] +pub enum MappedEvent { + /// → dispatch's `{"type":"ui-message-chunk","chunk":}`. + Chunk(Value), + /// The harness itself reported a terminal fatal error (Claude's + /// `result.is_error`, Codex's `turn.failed`/top-level `error`) — NOT a + /// JSON-parse failure on our side. Maps to dispatch's + /// `{"type":"error",...}` immediately followed by `done`; the run + /// stops reading further lines once this is returned. + FatalError { message: String }, + /// A session/thread id became known — the resume token for the NEXT + /// turn (`input.harness.sessionId` on a future dispatch). Not itself a + /// chunk; `run.rs` threads it into the terminal `finish` chunk's + /// metadata and (in local-api) the persisted `Run` row. + SessionId(String), + /// Nothing worth surfacing — see the crate doc's unknown-event policy. + Ignored, +} + +/// Builds a `MappedEvent::Chunk` from an already-shaped `UIMessageChunk` +/// JSON value. Trivial, but centralizes the "one chunk in, one event out" +/// idiom both mappers use dozens of times. +pub(crate) fn chunk(value: Value) -> MappedEvent { + MappedEvent::Chunk(value) +} + +/// Why the subprocess-backed event stream is being closed. +/// +/// Mappers use this only to close protocol parts that were opened by an +/// incremental event but never received their normal terminator. The reason is +/// deliberately coarse: callers must not infer process policy from a mapper; +/// it exists so a malformed/truncated tool input can be surfaced as an error +/// instead of being promoted to an available tool call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlushReason { + EndOfStream, + Cancelled, + Failed, +} + +/// Feeds one line at a time (as a real spawned process's stdout would +/// arrive, split on `\n`) into a mapper. Implemented by both +/// [`claude::ClaudeEventMapper`] and [`codex::CodexEventMapper`]. +pub trait EventMapper { + /// `line` has its trailing newline already stripped. A line that + /// isn't valid JSON (has been observed in the wild — see + /// `codex_error.jsonl`'s sibling fixture's real capture printing a + /// human `"Reading additional input from stdin..."` banner before its + /// first JSON line when stdin isn't closed) is treated as + /// `MappedEvent::Ignored`, never a fatal error — a stray banner line + /// must not abort an otherwise-healthy run. + fn feed_line(&mut self, line: &str) -> Vec; + + /// The session/thread id captured so far, if any — convenience + /// accessor so `run.rs` can read it without re-deriving it from every + /// `MappedEvent::SessionId` it already forwarded. + fn session_id(&self) -> Option<&str>; + + /// Closes any incremental UI-message parts still open when the + /// subprocess stream ends. Called for clean EOF, cancellation, and every + /// fatal/nonzero path. Most mappers have nothing to flush; Claude's + /// partial-message protocol needs this to pair every `*-start` with an + /// `*-end`, and to turn a truncated tool input into + /// `tool-input-error`. + fn flush(&mut self, _reason: FlushReason) -> Vec { + Vec::new() + } +} diff --git a/apps/native/crates/harness/src/lib.rs b/apps/native/crates/harness/src/lib.rs new file mode 100644 index 0000000000..3319813301 --- /dev/null +++ b/apps/native/crates/harness/src/lib.rs @@ -0,0 +1,63 @@ +//! `harness` — CLI detection, model tiers, process spawn, and ndjson→SSE +//! stream translation for the two harness CLIs desktop v1 supports +//! (`claude`, `codex` — see the desktop migration contract decision +//! 1: no Decopilot in the desktop app). Consumed by +//! `crates/local-api`'s `routes/dispatch.rs` (real streaming) and +//! `routes/models.rs` (`GET /models` detection + tiers). +//! +//! ## Module map +//! +//! - [`resolve`] — binary resolution. Implements the SHARED CONTRACT env +//! overrides (`LOCAL_API_CLAUDE_BIN` / `LOCAL_API_CODEX_BIN`, absolute +//! path or argv JSON array) with a PATH fallback to `claude`/`codex`. +//! - [`tiers`] — the model-tier tables ported verbatim (same ids/labels) +//! from `packages/harness/src/{claude-code,codex}/model/agent-tiers.ts`, +//! plus the composite-id → CLI `--model` flag resolvers ported from +//! `packages/harness/src/{claude-code,codex}/model/index.ts`. +//! - [`detect`] — CLI availability probing with GROW-ONLY caching, mirroring +//! `apps/api/src/link-daemon/capabilities.ts`'s semantics: a transient +//! probe failure never un-lists a CLI that was previously detected. +//! - [`spawn`] — the one process-spawn abstraction (PTY via `portable-pty` +//! OR plain pipes), 128+signal exit-code convention, process-group kill. +//! See that module's doc comment for why harness dispatch defaults to +//! plain pipes (investigated + documented, not just asserted). +//! - [`watchdog`] — the parent-liveness watchdog script and anchored +//! process-group helpers, shared with `crates/local-api`'s +//! `ProcessGroupChild` so crash-recovery semantics cannot drift between +//! the two spawn families (see that module's doc comment). +//! - [`events`] — incremental ndjson→`UIMessageChunk`-shaped-JSON mapping +//! for each CLI's wire format (`events::claude`, `events::codex`). +//! - [`run`] — ties the above together: resolve → spawn → parse → yield, +//! with cancellation support. The type `local-api`'s dispatch route +//! actually drives. +//! - [`parts`] — reconstructs a UIMessage-shaped `parts` array from a +//! run's chunk stream, for persisting the assistant `Message` row (see +//! that module's doc for the resume-token convention it introduces). +//! +//! ## Unknown-event policy +//! +//! the native local-API contract's Dispatch Lifecycle section +//! pins exactly three SSE event shapes for `POST /_sandbox/dispatch`: +//! `ui-message-chunk`, `error`, and `done` — it defines no "escape hatch" +//! wire shape for an event this crate doesn't recognize. So: every ndjson +//! line this crate can't confidently map to one of those three lands as +//! [`events::MappedEvent::Ignored`] and is silently dropped, never +//! forwarded raw. Forwarding an unrecognized shape under the +//! `ui-message-chunk` envelope would violate its "opaque `UIMessageChunk`" +//! contract (the desktop frontend, Phase 3, expects genuine AI-SDK-shaped +//! chunks) — dropping is the conservative reading of "follow the contract +//! doc exactly" when the doc simply doesn't define that hatch. +#![forbid(unsafe_code)] + +pub mod detect; +pub mod events; +pub mod parts; +pub mod resolve; +pub mod run; +pub mod spawn; +pub mod tiers; +pub mod title; +pub mod watchdog; + +pub use resolve::{resolve_argv, resolve_checked, HarnessId, ResolveError}; +pub use tiers::{ModelTier, CLAUDE_CODE_TIERS, CODEX_TIERS}; diff --git a/apps/native/crates/harness/src/parts.rs b/apps/native/crates/harness/src/parts.rs new file mode 100644 index 0000000000..ef3d7436c3 --- /dev/null +++ b/apps/native/crates/harness/src/parts.rs @@ -0,0 +1,453 @@ +//! Reconstructs a UIMessage-shaped `parts` array — the same storage shape +//! the native local-API contract's Threads-store section pins +//! for `Message.parts` (`unknown[]`, "opaque to local-api, ... the same +//! 'UIMessage parts' shape the mesh chat wire already uses") — from the +//! sequence of `UIMessageChunk`-shaped JSON values a [`crate::run::RunHandle`] +//! yields as [`crate::run::RunEvent::Chunk`]. Pure/stateful, no I/O: +//! `local-api`'s dispatch route feeds it every chunk it relays over SSE +//! and persists the final [`PartsAccumulator::finish`] array as the +//! assistant `Message` row once the run ends. +//! +//! Not itself part of the pinned contract (that doc only pins the STORAGE +//! shape and explicitly treats `parts`' contents as opaque) — the exact +//! part shapes below (`text`, `reasoning`, `tool-`) are this crate's +//! own reasonable-effort UIMessage-part reconstruction, introduced in +//! Phase 2 as the bridge between dispatch's ephemeral SSE stream and the +//! durable thread store. No test in either e2e suite pins this exact +//! shape (dispatch's real-harness streaming persistence has no e2e +//! coverage yet — see the Phase 2 report); this module's own unit tests +//! are the correctness gate for now. +//! +//! ## Resume token convention +//! +//! The contract's `Run` entity has no field for the harness CLI's own +//! session/thread id (needed to `--resume`/`resume ` on the NEXT +//! turn) — a Phase 0/1 schema gap; widening the pinned `Run`/`Message` +//! shape is a contract-doc change, out of this Phase 2 module's scope. +//! Instead, when a session id is known, [`PartsAccumulator::finish`] +//! appends ONE final pseudo-part: +//! `{"type":"data-harness-session","harnessId":...,"sessionId":...}` — +//! fits the EXISTING `parts: unknown[]` column with no schema change; a +//! future turn recovers it by scanning assistant messages newest-first for +//! the latest valid token matching the thread's pinned harness. Native chat +//! also checkpoints a newly observed token on its active SQLite queue claim, +//! so crash recovery can append this same part before retiring an interrupted +//! turn. + +use std::collections::HashMap; + +use serde_json::{json, Value}; + +#[derive(Debug, Clone)] +enum OpenPart { + Text(String), + Reasoning(String), + Tool { + tool_name: String, + input: Option, + raw_input: Option, + output: Option, + error_text: Option, + state: &'static str, + }, +} + +#[derive(Debug, Default)] +pub struct PartsAccumulator { + /// First-seen order of part/tool-call ids — preserves the sequence + /// the harness actually produced them in, independent of `HashMap`'s + /// unordered iteration. + order: Vec, + parts: HashMap, +} + +impl PartsAccumulator { + pub fn new() -> Self { + Self::default() + } + + /// Feed one `UIMessageChunk`-shaped chunk (a [`crate::run::RunEvent::Chunk`] + /// payload). Unrecognized `type`s (`start`, `finish`, `finish-step`, + /// any future chunk type this accumulator hasn't been taught about) + /// are ignored — they don't contribute a `parts` entry, matching how + /// AI SDK's own `UIMessage.parts` never includes lifecycle-only + /// chunks either. + pub fn feed(&mut self, chunk: &Value) { + match chunk.get("type").and_then(Value::as_str) { + Some("text-start") => self.open_text_like(chunk, false), + Some("text-delta") => self.append_text(chunk, false), + Some("reasoning-start") => self.open_text_like(chunk, true), + Some("reasoning-delta") => self.append_text(chunk, true), + Some("tool-input-available") => self.set_tool_input(chunk), + Some("tool-input-error") => self.set_tool_input_error(chunk), + Some("tool-output-available") => self.set_tool_output(chunk, false), + Some("tool-output-error") => self.set_tool_output(chunk, true), + _ => {} + } + } + + fn open_text_like(&mut self, chunk: &Value, reasoning: bool) { + let Some(id) = chunk.get("id").and_then(Value::as_str) else { + return; + }; + if !self.parts.contains_key(id) { + self.order.push(id.to_string()); + self.parts.insert( + id.to_string(), + if reasoning { + OpenPart::Reasoning(String::new()) + } else { + OpenPart::Text(String::new()) + }, + ); + } + } + + fn append_text(&mut self, chunk: &Value, reasoning: bool) { + let Some(id) = chunk.get("id").and_then(Value::as_str) else { + return; + }; + let delta = chunk.get("delta").and_then(Value::as_str).unwrap_or(""); + if !self.parts.contains_key(id) { + // A delta with no prior `-start` (shouldn't happen from + // either mapper, but never lose data) — synthesize the slot. + self.order.push(id.to_string()); + self.parts.insert( + id.to_string(), + if reasoning { + OpenPart::Reasoning(String::new()) + } else { + OpenPart::Text(String::new()) + }, + ); + } + match self.parts.get_mut(id) { + Some(OpenPart::Text(s)) if !reasoning => s.push_str(delta), + Some(OpenPart::Reasoning(s)) if reasoning => s.push_str(delta), + // A delta arrived for an id that's open as the OTHER kind + // (text vs reasoning) — shouldn't happen (ids are namespaced + // by content-block index per mapper), dropped defensively. + _ => {} + } + } + + fn set_tool_input(&mut self, chunk: &Value) { + let Some(id) = chunk.get("toolCallId").and_then(Value::as_str) else { + return; + }; + let tool_name = chunk + .get("toolName") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let input = chunk.get("input").cloned().unwrap_or(Value::Null); + if !self.parts.contains_key(id) { + self.order.push(id.to_string()); + } + self.parts.insert( + id.to_string(), + OpenPart::Tool { + tool_name, + input: Some(input), + raw_input: None, + output: None, + error_text: None, + state: "input-available", + }, + ); + } + + fn set_tool_input_error(&mut self, chunk: &Value) { + let Some(id) = chunk.get("toolCallId").and_then(Value::as_str) else { + return; + }; + let tool_name = chunk + .get("toolName") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let raw_input = chunk.get("input").cloned().unwrap_or(Value::Null); + let error_text = chunk + .get("errorText") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if !self.parts.contains_key(id) { + self.order.push(id.to_string()); + } + self.parts.insert( + id.to_string(), + OpenPart::Tool { + tool_name, + // AI SDK v6 represents a static tool-input parse failure + // with no typed `input` and preserves the rejected value in + // `rawInput`. Keeping the same shape makes a reloaded message + // indistinguishable from its live streamed state. + input: None, + raw_input: Some(raw_input), + output: None, + error_text: Some(error_text), + state: "output-error", + }, + ); + } + + fn set_tool_output(&mut self, chunk: &Value, is_error: bool) { + let Some(id) = chunk.get("toolCallId").and_then(Value::as_str) else { + return; + }; + let Some(OpenPart::Tool { + output, + error_text, + state, + .. + }) = self.parts.get_mut(id) + else { + // An output for a tool call we never saw the input for — + // shouldn't happen (both mappers always emit input-available + // before any output), dropped defensively rather than + // panicking or fabricating a tool part with no `toolName`. + return; + }; + if is_error { + *error_text = chunk + .get("errorText") + .and_then(Value::as_str) + .map(str::to_string); + *state = "output-error"; + } else { + *output = chunk.get("output").cloned(); + *state = "output-available"; + } + } + + /// The final reconstructed parts array, in first-seen order. + /// `session`, when `Some((harness_id, session_id))`, appends the + /// resume-token pseudo-part described in the module doc. + pub fn finish(self, session: Option<(&str, &str)>) -> Vec { + let PartsAccumulator { order, parts } = self; + let mut out: Vec = order + .into_iter() + .filter_map(|id| parts.get(&id).map(|p| part_to_json(&id, p))) + .collect(); + if let Some((harness_id, session_id)) = session { + out.push(json!({ + "type": "data-harness-session", + "harnessId": harness_id, + "sessionId": session_id, + })); + } + out + } +} + +fn part_to_json(id: &str, part: &OpenPart) -> Value { + match part { + OpenPart::Text(s) => json!({"type": "text", "text": s}), + OpenPart::Reasoning(s) => json!({"type": "reasoning", "text": s}), + OpenPart::Tool { + tool_name, + input, + raw_input, + output, + error_text, + state, + } => { + let mut v = json!({ + "type": format!("tool-{tool_name}"), + "toolCallId": id, + "state": state, + }); + if let Some(input) = input { + v["input"] = input.clone(); + } + if let Some(raw_input) = raw_input { + v["rawInput"] = raw_input.clone(); + } + if let Some(o) = output { + v["output"] = o.clone(); + } + if let Some(e) = error_text { + v["errorText"] = json!(e); + } + v + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accumulates_a_text_part_across_deltas() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "text-start", "id": "0"})); + acc.feed(&json!({"type": "text-delta", "id": "0", "delta": "hel"})); + acc.feed(&json!({"type": "text-delta", "id": "0", "delta": "lo"})); + acc.feed(&json!({"type": "text-end", "id": "0"})); + let parts = acc.finish(None); + assert_eq!(parts, vec![json!({"type": "text", "text": "hello"})]); + } + + #[test] + fn accumulates_a_reasoning_part_separately_from_text() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "reasoning-start", "id": "0"})); + acc.feed(&json!({"type": "reasoning-delta", "id": "0", "delta": "thinking..."})); + acc.feed(&json!({"type": "text-start", "id": "1"})); + acc.feed(&json!({"type": "text-delta", "id": "1", "delta": "answer"})); + let parts = acc.finish(None); + assert_eq!( + parts, + vec![ + json!({"type": "reasoning", "text": "thinking..."}), + json!({"type": "text", "text": "answer"}), + ] + ); + } + + #[test] + fn tool_call_transitions_from_input_available_to_output_available() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({ + "type": "tool-input-available", + "toolCallId": "t1", + "toolName": "Bash", + "input": {"command": "echo hi"}, + })); + acc.feed(&json!({ + "type": "tool-output-available", + "toolCallId": "t1", + "output": "hi\n", + })); + let parts = acc.finish(None); + assert_eq!( + parts, + vec![json!({ + "type": "tool-Bash", + "toolCallId": "t1", + "state": "output-available", + "input": {"command": "echo hi"}, + "output": "hi\n", + })] + ); + } + + #[test] + fn tool_call_output_error_sets_error_state_and_text() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({ + "type": "tool-input-available", + "toolCallId": "t1", + "toolName": "Bash", + "input": {}, + })); + acc.feed(&json!({ + "type": "tool-output-error", + "toolCallId": "t1", + "errorText": "boom", + })); + let parts = acc.finish(None); + assert_eq!(parts[0]["state"], "output-error"); + assert_eq!(parts[0]["errorText"], "boom"); + } + + #[test] + fn tool_input_error_updates_an_existing_tool_to_a_terminal_error() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({ + "type": "tool-input-available", + "toolCallId": "t1", + "toolName": "Bash", + "input": {"command": "echo hi"}, + })); + acc.feed(&json!({ + "type": "tool-input-error", + "toolCallId": "t1", + "toolName": "Bash", + "input": "{\"command\":", + "errorText": "input was truncated", + })); + + assert_eq!( + acc.finish(None), + vec![json!({ + "type": "tool-Bash", + "toolCallId": "t1", + "state": "output-error", + "rawInput": "{\"command\":", + "errorText": "input was truncated", + })] + ); + } + + #[test] + fn direct_tool_input_error_creates_a_persistable_tool_part() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({ + "type": "tool-input-error", + "toolCallId": "t-direct", + "toolName": "Read", + "input": {"file_path": "/partial"}, + "errorText": "stream ended", + })); + + assert_eq!( + acc.finish(None), + vec![json!({ + "type": "tool-Read", + "toolCallId": "t-direct", + "state": "output-error", + "rawInput": {"file_path": "/partial"}, + "errorText": "stream ended", + })] + ); + } + + #[test] + fn output_for_an_unseen_tool_call_is_dropped_not_fabricated() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "tool-output-available", "toolCallId": "ghost", "output": 1})); + assert_eq!(acc.finish(None), Vec::::new()); + } + + #[test] + fn unknown_chunk_types_do_not_produce_a_part() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "start"})); + acc.feed(&json!({"type": "finish", "finishReason": "stop"})); + assert_eq!(acc.finish(None), Vec::::new()); + } + + #[test] + fn finish_appends_the_session_pseudo_part_when_present() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "text-start", "id": "0"})); + acc.feed(&json!({"type": "text-delta", "id": "0", "delta": "hi"})); + let parts = acc.finish(Some(("claude-code", "sess-1"))); + assert_eq!(parts.len(), 2); + assert_eq!( + parts[1], + json!({"type": "data-harness-session", "harnessId": "claude-code", "sessionId": "sess-1"}) + ); + } + + #[test] + fn finish_omits_the_session_part_when_none() { + let acc = PartsAccumulator::new(); + assert_eq!(acc.finish(None), Vec::::new()); + } + + #[test] + fn order_is_first_seen_not_hashmap_order() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "text-start", "id": "z"})); + acc.feed(&json!({"type": "text-delta", "id": "z", "delta": "Z"})); + acc.feed(&json!({"type": "text-start", "id": "a"})); + acc.feed(&json!({"type": "text-delta", "id": "a", "delta": "A"})); + let parts = acc.finish(None); + assert_eq!(parts[0]["text"], "Z"); + assert_eq!(parts[1]["text"], "A"); + } +} diff --git a/apps/native/crates/harness/src/resolve.rs b/apps/native/crates/harness/src/resolve.rs new file mode 100644 index 0000000000..3b1a67d25c --- /dev/null +++ b/apps/native/crates/harness/src/resolve.rs @@ -0,0 +1,329 @@ +//! Binary resolution — the SHARED CONTRACT between the two Phase 2 agents +//! working this branch: `local-api` resolves harness binaries via env +//! overrides `LOCAL_API_CLAUDE_BIN` / `LOCAL_API_CODEX_BIN` (an absolute +//! path, a bare PATH-relative name, or a JSON array of argv strings — +//! `'["node", "/path/to/stub-harness.mjs"]'`), falling back to a PATH +//! lookup of `claude`/`codex` when unset. This module is that +//! implementation; the differential test suite's stub-harness binary is +//! the other side of the contract, wired in via these exact env vars. +//! +//! The JSON-array-or-plain-string parsing mirrors the convention already +//! used twice elsewhere in this migration — +//! `apps/native/e2e/helpers.ts::resolveLocalApiCmd` and +//! `packages/sandbox/daemon/daemon.e2e.helpers.ts::resolveDaemonCmd` — so a +//! test author who already knows one of those knows this one. + +use std::path::{Path, PathBuf}; + +/// The two harness CLIs desktop v1 supports (the desktop migration contract decision 1: no +/// `"decopilot"` in the desktop app). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HarnessId { + ClaudeCode, + Codex, +} + +impl HarnessId { + /// Stable order pinned by the contract doc's `GET /models` example: + /// `claude-code` then `codex`. + pub const ALL: [HarnessId; 2] = [HarnessId::ClaudeCode, HarnessId::Codex]; + + /// The wire id used in `harnessId` (dispatch) and `harness` (models). + pub fn wire_id(self) -> &'static str { + match self { + HarnessId::ClaudeCode => "claude-code", + HarnessId::Codex => "codex", + } + } + + pub fn from_wire_id(id: &str) -> Option { + match id { + "claude-code" => Some(HarnessId::ClaudeCode), + "codex" => Some(HarnessId::Codex), + _ => None, + } + } + + /// SHARED CONTRACT env var name. + pub fn env_override_var(self) -> &'static str { + match self { + HarnessId::ClaudeCode => "LOCAL_API_CLAUDE_BIN", + HarnessId::Codex => "LOCAL_API_CODEX_BIN", + } + } + + /// PATH-lookup fallback name when no override is set. + pub fn default_binary_name(self) -> &'static str { + match self { + HarnessId::ClaudeCode => "claude", + HarnessId::Codex => "codex", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveError { + /// The env override was set (non-empty) but couldn't be parsed as + /// either a JSON string array or used as a plain string. + InvalidOverride(String), + /// A caller supplied a resume token containing no non-whitespace + /// characters. Passing it to either CLI would look like a resume request + /// while actually selecting no durable conversation. + InvalidSessionId, + /// Resolved argv[0] is neither an executable path nor found on PATH. + NotFound(String), +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResolveError::InvalidOverride(msg) => write!(f, "{msg}"), + ResolveError::InvalidSessionId => { + write!(f, "harness session id must not be empty") + } + ResolveError::NotFound(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for ResolveError {} + +/// Resolve the argv PREFIX to spawn for `harness`, reading the SHARED +/// CONTRACT env override from the process environment. Never returns an +/// empty vec. +pub fn resolve_argv(harness: HarnessId) -> Result, ResolveError> { + let raw = std::env::var(harness.env_override_var()).ok(); + resolve_argv_from(harness, raw.as_deref()) +} + +/// Pure variant of [`resolve_argv`] taking the override value explicitly +/// instead of reading the environment — this is the one every unit test in +/// this module calls, so tests never race each other over a shared +/// process-global env var (`std::env::set_var` is famously not +/// parallel-test-safe). +pub fn resolve_argv_from( + harness: HarnessId, + override_raw: Option<&str>, +) -> Result, ResolveError> { + if let Some(raw) = override_raw { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return parse_override(harness.env_override_var(), trimmed); + } + } + Ok(vec![harness.default_binary_name().to_string()]) +} + +fn parse_override(var: &str, trimmed: &str) -> Result, ResolveError> { + if trimmed.starts_with('[') { + let parsed: Vec = serde_json::from_str(trimmed).map_err(|e| { + ResolveError::InvalidOverride(format!("{var} is not a valid JSON string array: {e}")) + })?; + if parsed.is_empty() { + return Err(ResolveError::InvalidOverride(format!( + "{var} is an empty argv array" + ))); + } + return Ok(parsed); + } + Ok(vec![trimmed.to_string()]) +} + +/// Resolve AND verify argv[0] actually exists/is executable — a fast, +/// synchronous, no-subprocess check suitable for the dispatch route's +/// pre-stream "unknown_harness" gate (contract order #7: `lookupHarness` +/// throws → 400 before any process is spawned). A bare name (no path +/// separator) is searched against `PATH` by hand, mirroring POSIX +/// `execvp` search semantics, so a missing CLI surfaces as this crate's +/// own [`ResolveError::NotFound`] instead of an opaque spawn-time I/O +/// error the caller would otherwise have to sniff. +pub fn resolve_checked(harness: HarnessId) -> Result, ResolveError> { + let raw = std::env::var(harness.env_override_var()).ok(); + resolve_checked_from(harness, raw.as_deref()) +} + +/// Pure variant of [`resolve_checked`] — see [`resolve_argv_from`] for why. +pub fn resolve_checked_from( + harness: HarnessId, + override_raw: Option<&str>, +) -> Result, ResolveError> { + let argv = resolve_argv_from(harness, override_raw)?; + let program = Path::new(&argv[0]); + if is_executable_path(program) { + return Ok(argv); + } + if !argv[0].contains(std::path::MAIN_SEPARATOR) && which(&argv[0]).is_some() { + return Ok(argv); + } + Err(ResolveError::NotFound(format!( + "{} CLI not found: {:?} is neither an executable path nor on PATH", + harness.wire_id(), + argv[0], + ))) +} + +fn is_executable_path(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.is_file() && (m.permissions().mode() & 0o111 != 0)) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + path.is_file() + } +} + +/// Manual `PATH` search — POSIX `execvp`-style: for each `PATH` entry, +/// check `/` is an executable file. Returns the first hit. +/// Module-public (not just crate-private) since `detect.rs` and tests both +/// use it directly. +pub fn which(name: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(name); + if is_executable_path(&candidate) { + return Some(candidate); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn make_executable(path: &Path) { + std::fs::File::create(path) + .unwrap() + .write_all(b"#!/bin/sh\nexit 0\n") + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + #[test] + fn no_override_falls_back_to_default_binary_name() { + assert_eq!( + resolve_argv_from(HarnessId::ClaudeCode, None).unwrap(), + vec!["claude".to_string()] + ); + assert_eq!( + resolve_argv_from(HarnessId::Codex, None).unwrap(), + vec!["codex".to_string()] + ); + } + + #[test] + fn empty_override_falls_back_to_default_binary_name() { + assert_eq!( + resolve_argv_from(HarnessId::ClaudeCode, Some(" ")).unwrap(), + vec!["claude".to_string()] + ); + } + + #[test] + fn absolute_path_override_is_used_verbatim() { + assert_eq!( + resolve_argv_from(HarnessId::ClaudeCode, Some("/opt/claude/bin/claude")).unwrap(), + vec!["/opt/claude/bin/claude".to_string()] + ); + } + + #[test] + fn json_array_override_becomes_full_argv() { + assert_eq!( + resolve_argv_from( + HarnessId::Codex, + Some(r#"["node", "/path/to/stub-harness.mjs", "--codex"]"#) + ) + .unwrap(), + vec![ + "node".to_string(), + "/path/to/stub-harness.mjs".to_string(), + "--codex".to_string() + ] + ); + } + + #[test] + fn invalid_json_array_override_errors() { + let err = resolve_argv_from(HarnessId::ClaudeCode, Some("[not json")).unwrap_err(); + assert!(matches!(err, ResolveError::InvalidOverride(_))); + } + + #[test] + fn empty_json_array_override_errors() { + let err = resolve_argv_from(HarnessId::ClaudeCode, Some("[]")).unwrap_err(); + assert!(matches!(err, ResolveError::InvalidOverride(_))); + } + + #[test] + fn resolve_checked_accepts_an_existing_executable_override() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("fake-claude"); + make_executable(&bin); + let argv = + resolve_checked_from(HarnessId::ClaudeCode, Some(bin.to_str().unwrap())).unwrap(); + assert_eq!(argv, vec![bin.to_str().unwrap().to_string()]); + } + + #[test] + fn resolve_checked_rejects_a_nonexistent_override_path() { + let err = resolve_checked_from( + HarnessId::ClaudeCode, + Some("/definitely/not/a/real/path/claude"), + ) + .unwrap_err(); + assert!(matches!(err, ResolveError::NotFound(_))); + } + + #[test] + fn resolve_checked_rejects_a_non_executable_file() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("not-executable"); + std::fs::write(&bin, b"nope").unwrap(); + let err = + resolve_checked_from(HarnessId::ClaudeCode, Some(bin.to_str().unwrap())).unwrap_err(); + assert!(matches!(err, ResolveError::NotFound(_))); + } + + #[test] + fn which_finds_a_binary_placed_on_a_synthetic_path() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("totally-fake-cli-12345"); + make_executable(&bin); + let joined = std::env::join_paths([dir.path()]).unwrap(); + let found = which_with_path("totally-fake-cli-12345", &joined); + assert_eq!(found, Some(bin)); + } + + #[test] + fn which_returns_none_for_a_name_absent_from_path() { + let dir = tempfile::tempdir().unwrap(); + let joined = std::env::join_paths([dir.path()]).unwrap(); + assert_eq!( + which_with_path("nonexistent-cli-98765", &joined), + None:: + ); + } + + /// Test-only helper: `which()`'s search logic against an explicit + /// `PATH` value rather than the process environment (see the module + /// doc's parallel-test-safety rationale). + fn which_with_path(name: &str, path_var: &std::ffi::OsStr) -> Option { + for dir in std::env::split_paths(path_var) { + let candidate = dir.join(name); + if is_executable_path(&candidate) { + return Some(candidate); + } + } + None + } +} diff --git a/apps/native/crates/harness/src/run.rs b/apps/native/crates/harness/src/run.rs new file mode 100644 index 0000000000..25a6c6bc3f --- /dev/null +++ b/apps/native/crates/harness/src/run.rs @@ -0,0 +1,1981 @@ +//! Ties [`crate::resolve`] + [`crate::tiers`] + [`crate::spawn`] + +//! [`crate::events`] together into the one thing `local-api`'s dispatch +//! route actually drives: resolve → build argv → spawn (always plain +//! pipes — see `spawn.rs`'s module doc) → parse ndjson line-by-line → +//! yield [`RunEvent`]s, with cancellation support. +//! +//! ## Two-phase API (mirrors the dispatch contract's pre-stream gate) +//! +//! the native local-API contract's Dispatch Lifecycle pins +//! `lookupHarness(harnessId) throws` as a PRE-stream gate (#7): a 400 +//! `unknown_harness` response BEFORE the SSE response even starts, so it +//! must be resolved BEFORE any `200`/streaming headers are written. Once +//! that gate passes, the caller is committed to a `200` SSE stream — any +//! LATER failure (the resolved binary vanishes between the gate and the +//! actual spawn, a permission error, the CLI itself crashing) has to +//! surface as an in-stream `error` event, not a different HTTP status. +//! +//! [`build_argv`] is that gate: pure, synchronous (no process spawned), +//! calls [`resolve::resolve_checked`] and fails exactly the way an +//! `unknown_harness` gate should. [`start`] takes the now-resolved argv +//! and is effectively infallible from the CALLER's point of view — any +//! spawn-time failure after this point becomes the first (and only) +//! [`RunEvent::FatalError`] on the returned handle's stream instead of a +//! `Result::Err`. +//! +//! ## Stdin is always closed +//! +//! Every harness spawn closes stdin (`Stdio::null()`, set in +//! `spawn::plain::spawn_plain`). This is not incidental: `codex exec` +//! reads stdin as additional prompt content when it's a pipe and hangs +//! waiting for EOF if left open — observed empirically on this machine +//! (`codex exec --json ""` printed a `"Reading additional input +//! from stdin..."` banner and read after the prompt argument was already +//! provided, purely because stdin wasn't closed). Neither CLI needs stdin +//! for the single-turn, non-interactive invocations this crate makes. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +use serde_json::Value; +use tokio::sync::{mpsc, Mutex}; + +use crate::events::claude::ClaudeEventMapper; +use crate::events::codex::CodexEventMapper; +use crate::events::{EventMapper, FlushReason, MappedEvent}; +use crate::resolve::{self, HarnessId, ResolveError}; +use crate::spawn::{self, ExitInfo, ProcessLease, SpawnRequest}; +use crate::tiers; + +/// Mirrors `packages/harness/src/types.ts::ToolApprovalLevel`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolApproval { + Auto, + Readonly, +} + +impl ToolApproval { + /// `"auto"` / `"readonly"` — the two values dispatch's pre-stream gate + /// (`validate_harness_input`, `routes/dispatch.rs`) already restricts + /// `input.toolApprovalLevel` to. + pub fn from_wire(level: &str) -> Self { + if level == "readonly" { + ToolApproval::Readonly + } else { + ToolApproval::Auto + } + } +} + +#[derive(Debug, Clone)] +pub struct RunSpec { + pub harness: HarnessId, + pub cwd: Option, + /// Extracted user-visible text (see [`extract_user_text`]) — the + /// CLI's positional prompt argument. + pub prompt: String, + /// `input.harness.sessionId` from a prior turn, if resuming. + pub resume_session_id: Option, + /// Composite model id (`input.models.thinking.id`, e.g. + /// `"claude-code:sonnet"`). + pub model_id: String, + pub tool_approval: ToolApproval, + /// `input.mode == "plan"` — same tool restrictions as `readonly` in + /// headless CLI mode, mirrors `isPlanMode` in + /// `packages/harness/src/claude-code/index.ts`. + pub plan_mode: bool, + /// Extra system-prompt text to APPEND to whatever the CLI already uses — + /// today the organization-filesystem block (see + /// `local-api::sandbox::org_prompt`). + /// + /// The two CLIs take this differently and the difference is load-bearing: + /// `claude` has a dedicated `--append-system-prompt` that adds to its own + /// default, while `codex` has no system-prompt flag at all and instead + /// takes `-c developer_instructions=…`, which SETS that layer rather than + /// appending. Nothing else populates codex's developer instructions today, + /// so this is additive in practice — but a second block would have to be + /// composed into one string rather than passed separately. + pub append_system_prompt: Option, + /// The agent's own MCP endpoint, so the CLI can call the agent's tools. + /// + /// Cluster runs get a per-run minted key; the desktop instead points the + /// CLI at THIS process, which proxies `/mcp/*` upstream with the + /// Keychain-backed token. That means the credential is local-api's own + /// control cookie, and local-api's embedded guard also demands the exact + /// `Origin` — hence both travel together. + pub mcp: Option, +} + +/// Where the agent's MCP lives and what it takes to be let in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpEndpoint { + pub url: String, + pub cookie: String, + pub origin: String, + /// Root certificate `url`'s TLS chains to, when it is not a public one. + /// claude is a Bun/BoringSSL binary that consults neither the macOS + /// keychain (where this CA is trusted) nor anything beyond its bundled + /// Mozilla roots — without being handed this file it cannot open `url` + /// at all and the agent silently loses every MCP tool. + pub ca_cert: Option, +} + +/// Env var names codex is pointed at, so no secret appears in its argv. +const MCP_URL_ENV: &str = "DECOCMS_MCP_URL"; +const MCP_COOKIE_ENV: &str = "DECOCMS_MCP_COOKIE"; +const MCP_ORIGIN_ENV: &str = "DECOCMS_MCP_ORIGIN"; + +/// How the CA in [`McpEndpoint::ca_cert`] reaches the claude child. Node's +/// (and Bun's) documented "additional CAs" variable — it EXTENDS the runtime's +/// bundled roots, so the CLI's own `api.anthropic.com` connection is +/// untouched. Never `SSL_CERT_FILE`, which REPLACES the root store for +/// OpenSSL/rustls-native-certs consumers and would cut a child off from the +/// public internet. codex needs neither: rustls-platform-verifier consults +/// the keychain, where this CA is already trusted. +const NODE_EXTRA_CA_CERTS_ENV: &str = "NODE_EXTRA_CA_CERTS"; + +/// The environment the CLI child runs with. Secrets reach it here (codex) or +/// via a 0600 file (claude), never argv — `ps` shows argv to every local +/// process. Merged over the inherited environment by the spawn layer. +fn mcp_child_env(spec: &RunSpec) -> Vec<(String, String)> { + let mut env = Vec::new(); + if let Some(mcp) = spec.mcp.as_ref() { + env.push((MCP_URL_ENV.to_string(), mcp.url.clone())); + env.push((MCP_COOKIE_ENV.to_string(), mcp.cookie.clone())); + env.push((MCP_ORIGIN_ENV.to_string(), mcp.origin.clone())); + if let Some(ca_cert) = mcp.ca_cert.as_ref() { + env.push(( + NODE_EXTRA_CA_CERTS_ENV.to_string(), + ca_cert.display().to_string(), + )); + } + } + env +} + +/// The one MCP server name both CLIs see. Matches the cluster harness's +/// `cms` key, so a prompt that refers to it means the same thing either side. +const MCP_SERVER_NAME: &str = "cms"; + +/// claude's `--mcp-config` document. +/// +/// Every value is a `${VAR}` reference, so this string is safe to pass on the +/// command line: the environment supplies the url, cookie and origin. +pub fn claude_mcp_config() -> String { + serde_json::json!({ + "mcpServers": { + MCP_SERVER_NAME: { + "type": "http", + "url": format!("${{{MCP_URL_ENV}}}"), + "headers": { + "Cookie": format!("${{{MCP_COOKIE_ENV}}}"), + "Origin": format!("${{{MCP_ORIGIN_ENV}}}"), + }, + } + } + }) + .to_string() +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RunEvent { + /// Internal continuation checkpoint. Consumers must persist this before + /// acknowledging any later output from the same process; it is not an + /// AI-SDK wire chunk and must never be forwarded to the webview. + SessionId { session_id: String }, + /// → dispatch's `{"type":"ui-message-chunk","chunk":}`. + Chunk(Value), + /// → dispatch's `{"type":"error","code":"harness_crashed",...}`. At + /// most one per run, always the LAST event before the channel closes. + FatalError { message: String }, +} + +/// Extracts the visible text from `input.userMessage` — a UIMessage-shaped +/// `{ parts: [...] }` object opaque to the dispatch route (see the +/// contract doc's Threads-store section: "the same 'UIMessage parts' +/// shape the mesh chat wire already uses"). Joins every +/// `{"type":"text","text":...}` part with newlines; non-text parts +/// (images, tool calls) are ignored. Mirrors +/// `packages/harness/src/cli-message-prep.ts::extractUserText`'s +/// text-only extraction, simplified for dispatch's single-message (not +/// full-history) input shape. +pub fn extract_user_text(user_message: &Value) -> String { + let Some(parts) = user_message.get("parts").and_then(Value::as_array) else { + // Tolerate a bare string as the whole message too — defensive, + // since `userMessage` is opaque to the dispatch route's own + // schema (`chatMessageSchema = z.record(...)` on the TS side). + return user_message.as_str().unwrap_or_default().to_string(); + }; + parts + .iter() + .filter(|p| p.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") +} + +/// The pre-stream gate: resolves `spec.harness`'s binary (SHARED CONTRACT +/// env overrides, PATH fallback) and builds the full argv, WITHOUT +/// spawning anything. `Err` here is dispatch's `unknown_harness` 400 gate +/// (contract order #7) — an unresolvable/missing CLI is treated the same +/// as an unrecognized `harnessId`, since from the caller's point of view +/// both mean "this run cannot start." +pub fn build_argv(spec: &RunSpec) -> Result, ResolveError> { + let resume_session_id = normalized_resume_session_id(spec)?; + let mut argv = resolve::resolve_checked(spec.harness)?; + match spec.harness { + HarnessId::ClaudeCode => append_claude_args(&mut argv, spec, resume_session_id), + HarnessId::Codex => append_codex_args(&mut argv, spec, resume_session_id), + } + Ok(argv) +} + +fn normalized_resume_session_id(spec: &RunSpec) -> Result, ResolveError> { + let Some(raw) = spec.resume_session_id.as_deref() else { + return Ok(None); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ResolveError::InvalidSessionId); + } + Ok(Some(trimmed)) +} + +fn append_claude_args(argv: &mut Vec, spec: &RunSpec, resume_session_id: Option<&str>) { + // `-p ` stays FIRST. The contract suite pins the prompt at + // `args[1]`, and every optional flag below is order-independent to the + // CLI — so anything added here goes after, never before. + argv.push("-p".to_string()); + argv.push(spec.prompt.clone()); + if let Some(extra) = spec.append_system_prompt.as_deref() { + // Appends to the CLI's own default prompt. `--system-prompt` would + // REPLACE it, discarding everything claude-code relies on. + argv.push("--append-system-prompt".to_string()); + argv.push(extra.to_string()); + } + if spec.mcp.is_some() { + // Inline JSON, but the SECRET is not in it: claude expands `${VAR}` + // from the environment (verified — a `${...}` cookie arrives at the + // server fully expanded), so argv carries only variable NAMES. `ps` + // shows argv to every local process, which is why this is not the + // literal cookie and why no file is needed either. + argv.push("--mcp-config".to_string()); + argv.push(claude_mcp_config()); + // Ignore the user's own `~/.claude` servers: an agent run must get the + // agent's tools, not whatever this machine happens to have configured. + argv.push("--strict-mcp-config".to_string()); + } + argv.push("--output-format".to_string()); + argv.push("stream-json".to_string()); + argv.push("--include-partial-messages".to_string()); + argv.push("--verbose".to_string()); + argv.push("--model".to_string()); + argv.push(tiers::resolve_claude_model_id(&spec.model_id)); + if let Some(sid) = resume_session_id { + argv.push("--resume".to_string()); + argv.push(sid.to_string()); + } + // Headless-safe permission handling, mirrors + // `packages/harness/src/claude-code/model/index.ts::createClaudeCodeModel`: + // always bypass the (impossible-in-headless) interactive permission + // prompt, and disallow tools that require a TTY / manage local + // session state regardless of approval level; ALSO disallow + // write-capable tools when the run is read-only or plan mode. + argv.push("--permission-mode".to_string()); + argv.push("bypassPermissions".to_string()); + let mut disallowed: Vec<&str> = vec![ + "AskUserQuestion", + "ExitPlanMode", + "EnterWorktree", + "ExitWorktree", + "Config", + ]; + if spec.plan_mode || matches!(spec.tool_approval, ToolApproval::Readonly) { + disallowed.extend(["Write", "Edit", "Bash", "NotebookEdit"]); + } + argv.push("--disallowedTools".to_string()); + argv.push(disallowed.join(",")); +} + +fn append_codex_args(argv: &mut Vec, spec: &RunSpec, resume_session_id: Option<&str>) { + argv.push("exec".to_string()); + if let Some(extra) = spec.append_system_prompt.as_deref() { + // codex has no system-prompt flag; `developer_instructions` is the + // config key that reaches the model (verified end to end — an + // instruction passed this way overrides the model's default answer). + // There is no file-based variant: `experimental_instructions_file`, + // `instructions_file` and `base_instructions` are all rejected by + // `--strict-config`. + argv.push("-c".to_string()); + argv.push(format!("developer_instructions={extra}")); + } + if let Some(mcp) = spec.mcp.as_ref() { + // `env_http_headers` maps a header NAME to the env var holding its + // value, so the cookie never appears in argv. Verified against the + // config `codex mcp add --url` writes. + argv.push("-c".to_string()); + argv.push(format!("mcp_servers.{MCP_SERVER_NAME}.url={}", mcp.url)); + argv.push("-c".to_string()); + argv.push(format!( + "mcp_servers.{MCP_SERVER_NAME}.env_http_headers.Cookie={MCP_COOKIE_ENV}" + )); + argv.push("-c".to_string()); + argv.push(format!( + "mcp_servers.{MCP_SERVER_NAME}.env_http_headers.Origin={MCP_ORIGIN_ENV}" + )); + } + argv.push("--json".to_string()); + argv.push("--skip-git-repo-check".to_string()); + argv.push("--model".to_string()); + argv.push(tiers::resolve_codex_model_id(&spec.model_id)); + let sandbox = if spec.plan_mode || matches!(spec.tool_approval, ToolApproval::Readonly) { + "read-only" + } else { + "workspace-write" + }; + argv.push("--sandbox".to_string()); + argv.push(sandbox.to_string()); + // `codex exec resume [PROMPT]` — a subcommand of `exec`, placed + // after the flags above and before the trailing positional prompt. + // NOTE: exact flag/subcommand interleaving is best-effort — verified + // manually via `codex exec --help`/`codex exec resume --help` on this + // machine (codex-cli 0.144.5) but NOT exercised end-to-end with a real + // resumed run (that needs a live multi-turn smoke test — see + // the native harness smoke procedure). If this ordering turns out + // to be wrong, the failure surfaces as a normal `harness_crashed` + // stream error (the CLI rejects its own argv), not a silent + // misbehavior. + if let Some(sid) = resume_session_id { + argv.push("resume".to_string()); + argv.push(sid.to_string()); + } + argv.push(spec.prompt.clone()); +} + +/// A running harness. `recv()` yields [`RunEvent`]s until the underlying +/// process's output is fully drained and the process has exited, at which +/// point the channel closes (`recv()` returns `None`) — mirroring the TS +/// dispatch route's `for await (const chunk of harness.stream())` +/// finishing naturally. A `FatalError`, if any, is always the LAST event +/// before closing. +pub struct RunHandle { + events_rx: mpsc::Receiver, + cancelled: Arc, + process_slot: Arc>>, + session_id: Arc>>, + drive_task: Option>, +} + +impl Drop for RunHandle { + fn drop(&mut self) { + // The drive task owns `SpawnedProcess::owner`. Aborting it drops that + // ownership edge, which wakes the detached child waiter and runs its + // bounded TERM→KILL + reap path. This is the panic/abort backstop for + // callers that cannot reach the async `cancel()` method. + self.cancelled.store(true, Ordering::SeqCst); + if let Some(task) = self.drive_task.take() { + task.abort(); + } + } +} + +impl RunHandle { + pub async fn recv(&mut self) -> Option { + self.events_rx.recv().await + } + + /// The harness's session/thread id, once known (populated as soon as + /// the underlying CLI reports it — claude's `system.init`/`result`, + /// codex's `thread.started`) — the resume token for the run's next + /// turn. + pub async fn session_id(&self) -> Option { + self.session_id.lock().await.clone() + } + + /// Cancels the run: marks it cancelled (suppressing the synthetic + /// "exited abnormally" `FatalError` a SIGTERM would otherwise + /// produce — a cancel is not a crash) and signals the process + /// group, escalating TERM→KILL when the CLI resists. Idempotent — safe + /// to call more than once, or after the run has already finished. The + /// process-group lease becomes inert when its waiter reaps the group, so a + /// stale handle never targets a later process that reused the numeric pid. + /// + /// RACE, CLOSED: a `cancel()` landing in the brief async gap between + /// `start()` beginning and the spawned process's pid becoming known + /// finds nothing to signal HERE (`process_slot` still `None`) — but it + /// still sets `cancelled`, and `drive()` re-checks that flag itself + /// the INSTANT it learns the pid (see `drive()`'s own comment at that + /// check), killing the just-spawned process immediately instead of + /// letting it run unsupervised. Found by + /// `apps/native/e2e/dispatch-stream.e2e.test.ts`'s `hang` scenario — + /// a client that cancels the moment it observes ANY output (before + /// the child process has even finished `fork`/`exec`) reliably raced + /// this window; a `cancel()` that fires before the pid exists is the + /// COMMON case for that scenario, not a rare corner. + pub async fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + if let Some(process) = self.process_slot.lock().await.clone() { + process.terminate().await; + } + } + + /// A `Clone`-able, `Send + Sync` handle carrying JUST the cancel + /// capability, sharing the SAME underlying state as this `RunHandle` + /// (`Arc`-backed — cancelling through either one is observable + /// through the other). Exists because `recv()`/`&mut self` and a + /// registry of "runs any request can cancel by id" (`local-api`'s + /// `routes/dispatch.rs`) want different ownership shapes: the SSE + /// response body owns/drains `RunHandle` by value in one task, while + /// `DELETE /_sandbox/runs/:id` needs a `Clone`-able reference it can + /// stash in a shared map and call from a completely different + /// request's handler. + pub fn cancel_handle(&self) -> CancelHandle { + CancelHandle { + cancelled: self.cancelled.clone(), + process_slot: self.process_slot.clone(), + } + } +} + +/// See [`RunHandle::cancel_handle`]. Identical cancel semantics to +/// [`RunHandle::cancel`] — same fields, same method — just detached from +/// the event-receiving half. +#[derive(Clone)] +pub struct CancelHandle { + cancelled: Arc, + process_slot: Arc>>, +} + +impl CancelHandle { + pub async fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + if let Some(process) = self.process_slot.lock().await.clone() { + process.terminate().await; + } + } +} + +/// Spawns `argv` (already resolved by [`build_argv`]) and starts driving +/// its output. Never fails from the caller's perspective (see module +/// doc's "two-phase API" section) — a spawn-time I/O error becomes the +/// handle's first and only [`RunEvent::FatalError`]. A caller that bypasses +/// [`build_argv`] still cannot bypass resume-token validation: invalid input +/// produces the same closed fatal stream without spawning `argv`. +/// Convenience entrypoint for harness-only tests/consumers that do not own a +/// local-api app-root recovery fence. Native local-api must use +/// [`start_with_child_lifetime_lock`] instead. +pub async fn start(argv: Vec, spec: &RunSpec) -> RunHandle { + start_inner(argv, spec, None).await +} + +pub async fn start_with_child_lifetime_lock( + argv: Vec, + spec: &RunSpec, + child_lifetime_lock_path: PathBuf, +) -> RunHandle { + start_inner(argv, spec, Some(child_lifetime_lock_path)).await +} + +async fn start_inner( + argv: Vec, + spec: &RunSpec, + child_lifetime_lock_path: Option, +) -> RunHandle { + let (tx, rx) = mpsc::channel::(64); + let cancelled = Arc::new(AtomicBool::new(false)); + let process_slot = Arc::new(Mutex::new(None)); + // A resumed turn already has a durable session identity before the child + // starts. Keep that identity even when the CLI is cancelled, crashes, or + // rejects its argv before emitting its normal init event; otherwise the + // failed assistant row can mask the only resume anchor and the next turn + // silently starts a fresh conversation. + let session_id = match normalized_resume_session_id(spec) { + Ok(session_id) => Arc::new(Mutex::new(session_id.map(str::to_string))), + Err(error) => { + let _ = tx + .send(RunEvent::FatalError { + message: error.to_string(), + }) + .await; + drop(tx); + return RunHandle { + events_rx: rx, + cancelled, + process_slot, + session_id: Arc::new(Mutex::new(None)), + drive_task: None, + }; + } + }; + + let env = mcp_child_env(spec); + + let req = SpawnRequest { + argv, + cwd: spec.cwd.clone(), + env, + lifetime_lock_path: child_lifetime_lock_path, + // Always plain pipes — see `spawn.rs`'s "why not PTY" doc comment. + pty: false, + }; + + let harness = spec.harness; + + let drive_task = tokio::spawn(drive( + req, + harness, + tx, + cancelled.clone(), + process_slot.clone(), + session_id.clone(), + )); + + RunHandle { + events_rx: rx, + cancelled, + process_slot, + session_id, + drive_task: Some(drive_task), + } +} + +/// Bound on how much trailing stderr is retained for a nonzero-exit +/// error message — enough for a useful diagnostic line, not enough for a +/// misbehaving CLI's stderr firehose to grow this unbounded. +const STDERR_TAIL_CAP: usize = 4096; +const MISSING_SESSION_ID_ERROR: &str = + "local harness completed without a session id; the chat cannot be resumed safely"; +const CHANGED_SESSION_ID_ERROR: &str = + "local harness changed its session id while the thread was running; start a new chat instead of continuing with split history"; +const EMPTY_SESSION_ID_ERROR: &str = + "local harness emitted an empty session id; the chat cannot be resumed safely"; + +async fn drive( + req: SpawnRequest, + harness: HarnessId, + tx: mpsc::Sender, + cancelled: Arc, + process_slot: Arc>>, + session_id: Arc>>, +) { + let fallback_req = (harness == HarnessId::ClaudeCode) + .then(|| without_arg(&req, "--include-partial-messages")) + .flatten(); + let outcome = drive_attempt( + req, + harness, + &tx, + &cancelled, + &process_slot, + &session_id, + fallback_req.is_some(), + ) + .await; + + if outcome == AttemptOutcome::RetryWithoutPartialMessages && !cancelled.load(Ordering::SeqCst) { + let Some(fallback_req) = fallback_req else { + return; + }; + tracing::warn!( + target: "decocms.native.chat.latency", + harness = harness.wire_id(), + "Claude CLI does not support partial-message streaming; retrying after pre-inference argument rejection" + ); + let _ = drive_attempt( + fallback_req, + harness, + &tx, + &cancelled, + &process_slot, + &session_id, + false, + ) + .await; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AttemptOutcome { + Done, + RetryWithoutPartialMessages, +} + +#[allow(clippy::too_many_arguments)] +async fn drive_attempt( + req: SpawnRequest, + harness: HarnessId, + tx: &mpsc::Sender, + cancelled: &AtomicBool, + process_slot: &Mutex>, + session_id: &Mutex>, + allow_partial_messages_fallback: bool, +) -> AttemptOutcome { + let spawned_at = Instant::now(); + let proc = match spawn::spawn(req).await { + Ok(p) => p, + Err(err) => { + let _ = tx + .send(RunEvent::FatalError { + message: format!("failed to start {}: {err}", harness.wire_id()), + }) + .await; + return AttemptOutcome::Done; + } + }; + *process_slot.lock().await = Some(proc.lease.clone()); + // Closes the race documented on `RunHandle::cancel`: a `cancel()` call + // that landed BEFORE this line (the child was still spawning, so + // `process_slot` was `None` and there was nothing to signal yet) sets + // `cancelled` but can't kill anything. Checking `cancelled` again + // HERE, now that the pid is known, catches exactly that window — + // this is what makes cancelling a request that returns near-instantly + // (e.g. a harness that emits one line then hangs, cancelled the + // moment that line is observed) reliable instead of racy. Every + // `cancel()`/`CancelHandle::cancel()` call kills unconditionally (not + // gated on "is anyone listening"), so re-signaling here even if the + // ORIGINAL call already found and killed the pid is a harmless + // no-op — `kill(1)` on an already-dead process just fails silently. + if cancelled.load(Ordering::SeqCst) { + proc.lease.terminate().await; + } + let spawn::SpawnedProcess { + pid: _, + mut stdout, + mut stderr, + exit, + lease: _lease, + owner: _owner, + } = proc; + + let stderr_tail = Arc::new(Mutex::new(String::new())); + let stderr_task = tokio::spawn({ + let stderr_tail = stderr_tail.clone(); + async move { + while let Some(chunk) = stderr.recv().await { + let mut tail = stderr_tail.lock().await; + tail.push_str(&String::from_utf8_lossy(&chunk)); + let len = tail.len(); + if len > STDERR_TAIL_CAP { + let excess = len - STDERR_TAIL_CAP; + tail.drain(0..excess); + } + } + } + }); + + let mut mapper: Box = match harness { + HarnessId::ClaudeCode => Box::new(ClaudeEventMapper::new()), + HarnessId::Codex => Box::new(CodexEventMapper::new()), + }; + + let mut linebuf: Vec = Vec::new(); + let mut stopped_early = false; + let mut mapper_reported_fatal = false; + let mut stdout_non_whitespace = false; + let mut first_meaningful_chunk_logged = false; + 'outer: while let Some(bytes) = stdout.recv().await { + stdout_non_whitespace |= bytes.iter().any(|byte| !byte.is_ascii_whitespace()); + linebuf.extend_from_slice(&bytes); + while let Some(pos) = linebuf.iter().position(|&b| b == b'\n') { + let line_bytes: Vec = linebuf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line_bytes[..line_bytes.len().saturating_sub(1)]) + .into_owned(); + let mut events = mapper.feed_line(&line); + mapper_reported_fatal |= flush_before_mapped_fatal(&mut *mapper, &mut events); + trace_first_meaningful_chunk( + &events, + harness, + spawned_at, + &mut first_meaningful_chunk_logged, + ); + if dispatch_mapped(events, tx, session_id).await { + stopped_early = true; + break 'outer; + } + } + } + if !stopped_early && !linebuf.is_empty() { + let line = String::from_utf8_lossy(&linebuf).into_owned(); + let mut events = mapper.feed_line(&line); + mapper_reported_fatal |= flush_before_mapped_fatal(&mut *mapper, &mut events); + trace_first_meaningful_chunk( + &events, + harness, + spawned_at, + &mut first_meaningful_chunk_logged, + ); + if dispatch_mapped(events, tx, session_id).await { + stopped_early = true; + } + } + + let exit_info = exit.await.unwrap_or(ExitInfo { code: 1 }); + let _ = stderr_task.await; + + let tail = stderr_tail.lock().await.clone(); + if allow_partial_messages_fallback + && is_pre_inference_partial_flag_rejection(harness, exit_info, stdout_non_whitespace, &tail) + { + // The mapper is empty for a CLI argument-parser rejection, but invoke + // the lifecycle hook on this failed attempt just like every other + // process exit. Never retry after any stdout: that could duplicate a + // paid model call or a side effect. + let _ = mapper.flush(FlushReason::Failed); + return AttemptOutcome::RetryWithoutPartialMessages; + } + + if !mapper_reported_fatal && !stopped_early { + let flush_reason = if cancelled.load(Ordering::SeqCst) { + FlushReason::Cancelled + } else if exit_info.code == 0 { + FlushReason::EndOfStream + } else { + FlushReason::Failed + }; + let flushed = mapper.flush(flush_reason); + if dispatch_mapped(flushed, tx, session_id).await { + stopped_early = true; + } + } + + if !stopped_early + && !cancelled.load(Ordering::SeqCst) + && exit_info.code == 0 + && session_id + .lock() + .await + .as_deref() + .is_none_or(|sid| sid.trim().is_empty()) + { + let _ = tx + .send(RunEvent::FatalError { + message: MISSING_SESSION_ID_ERROR.to_string(), + }) + .await; + } + + if !stopped_early && !cancelled.load(Ordering::SeqCst) && exit_info.code != 0 { + let suffix = if tail.trim().is_empty() { + String::new() + } else { + format!(": {}", tail.trim()) + }; + let _ = tx + .send(RunEvent::FatalError { + message: format!( + "{} exited with code {}{suffix}", + harness.wire_id(), + exit_info.code + ), + }) + .await; + } + // The wrapper drops its owning `tx` after this attempt (or after the + // compatibility retry), closing the channel so `RunHandle::recv()` + // returns `None`. + AttemptOutcome::Done +} + +fn without_arg(req: &SpawnRequest, arg: &str) -> Option { + let index = req.argv.iter().position(|candidate| candidate == arg)?; + let mut fallback = req.clone(); + fallback.argv.remove(index); + Some(fallback) +} + +fn is_pre_inference_partial_flag_rejection( + harness: HarnessId, + exit_info: ExitInfo, + stdout_non_whitespace: bool, + stderr: &str, +) -> bool { + if harness != HarnessId::ClaudeCode || exit_info.code == 0 || stdout_non_whitespace { + return false; + } + let stderr = stderr.to_ascii_lowercase(); + stderr.contains("--include-partial-messages") + && [ + "unknown option", + "unknown argument", + "unrecognized option", + "unrecognized argument", + "unexpected argument", + "wasn't expected", + "was not expected", + ] + .iter() + .any(|marker| stderr.contains(marker)) +} + +/// A mapper-level fatal event must remain the final wire event. Insert the +/// mapper's flush output immediately before it, pairing any open incremental +/// parts without violating that ordering. +fn flush_before_mapped_fatal(mapper: &mut dyn EventMapper, events: &mut Vec) -> bool { + let Some(fatal_index) = events + .iter() + .position(|event| matches!(event, MappedEvent::FatalError { .. })) + else { + return false; + }; + let flushed = mapper.flush(FlushReason::Failed); + events.splice(fatal_index..fatal_index, flushed); + true +} + +fn trace_first_meaningful_chunk( + events: &[MappedEvent], + harness: HarnessId, + spawned_at: Instant, + already_logged: &mut bool, +) { + if *already_logged { + return; + } + let Some(chunk_type) = events.iter().find_map(|event| { + let MappedEvent::Chunk(value) = event else { + return None; + }; + let chunk_type = value.get("type").and_then(Value::as_str)?; + matches!( + chunk_type, + "text-start" | "reasoning-start" | "tool-input-start" + ) + .then_some(chunk_type) + }) else { + return; + }; + *already_logged = true; + let spawn_to_first_chunk_ms = + u64::try_from(spawned_at.elapsed().as_millis()).unwrap_or(u64::MAX); + tracing::info!( + target: "decocms.native.chat.latency", + harness = harness.wire_id(), + chunk_type, + spawn_to_first_chunk_ms, + "harness produced its first meaningful chunk" + ); +} + +/// Forwards every [`MappedEvent`] onto `tx`, updating `session_id` as a +/// side effect. Returns `true` when the caller should stop reading +/// further stdout (a `FatalError` was sent, OR the receiver was dropped). +async fn dispatch_mapped( + events: Vec, + tx: &mpsc::Sender, + session_id: &Mutex>, +) -> bool { + for ev in events { + match ev { + MappedEvent::Chunk(v) => { + if v.get("type").and_then(Value::as_str) == Some("finish") + && v.get("finishReason").and_then(Value::as_str) != Some("error") + && session_id + .lock() + .await + .as_deref() + .is_none_or(|sid| sid.trim().is_empty()) + { + let _ = tx + .send(RunEvent::FatalError { + message: MISSING_SESSION_ID_ERROR.to_string(), + }) + .await; + return true; + } + if tx.send(RunEvent::Chunk(v)).await.is_err() { + return true; + } + } + MappedEvent::SessionId(sid) => { + let sid = sid.trim(); + if sid.is_empty() { + let _ = tx + .send(RunEvent::FatalError { + message: EMPTY_SESSION_ID_ERROR.to_string(), + }) + .await; + return true; + } + let changed = { + let mut current = session_id.lock().await; + match current.as_deref() { + Some(existing) if existing != sid => true, + Some(_) => false, + None => { + *current = Some(sid.to_string()); + false + } + } + }; + if changed { + let _ = tx + .send(RunEvent::FatalError { + message: CHANGED_SESSION_ID_ERROR.to_string(), + }) + .await; + return true; + } + if tx + .send(RunEvent::SessionId { + session_id: sid.to_string(), + }) + .await + .is_err() + { + return true; + } + } + MappedEvent::FatalError { message } => { + let _ = tx.send(RunEvent::FatalError { message }).await; + return true; + } + MappedEvent::Ignored => {} + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// The harness-specific argv WITHOUT `build_argv`'s binary resolution: + /// CI has neither CLI installed, and the env override that would fake one + /// is documented as not parallel-test-safe (see `resolve.rs`). These tests + /// are about argv COMPOSITION, which is exactly what these two build. + fn argv_for(spec: &RunSpec) -> Vec { + let mut argv = Vec::new(); + match spec.harness { + HarnessId::ClaudeCode => append_claude_args(&mut argv, spec, None), + HarnessId::Codex => append_codex_args(&mut argv, spec, None), + } + argv + } + + fn mcp_fixture() -> McpEndpoint { + McpEndpoint { + url: "http://localhost:4420/mcp/virtual-mcp/vir_1".to_string(), + cookie: "decocms-local-session=SECRET".to_string(), + origin: "http://localhost:4420".to_string(), + ca_cert: Some(std::path::PathBuf::from("/app-root/tls/ca-cert.pem")), + } + } + + /// The listener's private CA must reach the child, and through the one + /// variable that EXTENDS the runtime's roots rather than replacing them. + /// Without it claude (Bun/BoringSSL — no keychain, no extra files) fails + /// TLS against this process's own listener and the agent silently loses + /// every MCP tool; with `SSL_CERT_FILE` instead, a child would lose the + /// public internet. + #[test] + fn the_private_ca_reaches_the_child_additively_and_only_when_real() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.mcp = Some(mcp_fixture()); + let env = mcp_child_env(&spec); + assert!(env + .iter() + .any(|(name, value)| name == "NODE_EXTRA_CA_CERTS" + && value == "/app-root/tls/ca-cert.pem")); + assert!(!env.iter().any(|(name, _)| name == "SSL_CERT_FILE")); + + // A plain-HTTP listener (selftest, standalone) has no CA to hand + // over; pointing the var at nothing would be Node startup noise. + let mut plain = mcp_fixture(); + plain.ca_cert = None; + spec.mcp = Some(plain); + assert!(!mcp_child_env(&spec) + .iter() + .any(|(name, _)| name == "NODE_EXTRA_CA_CERTS")); + + spec.mcp = None; + assert!(mcp_child_env(&spec).is_empty()); + } + + /// The credential must never be visible to `ps`. claude gets a file path, + /// codex gets ENV VAR NAMES — neither gets the secret itself. + #[test] + fn neither_cli_receives_the_mcp_secret_in_argv() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let mut spec = base_spec(harness); + spec.mcp = Some(mcp_fixture()); + let joined = argv_for(&spec).join(" "); + + assert!(!joined.contains("SECRET"), "{harness:?} leaked the cookie"); + assert!( + !joined.contains("decocms-local-session"), + "{harness:?} leaked the cookie name/value" + ); + } + } + + /// The contract suite pins the prompt at `args[1]`; an optional flag + /// inserted before it silently shifted every positional assertion. + #[test] + fn the_prompt_stays_first_whatever_else_is_added() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.mcp = Some(mcp_fixture()); + spec.append_system_prompt = Some("extra".to_string()); + let argv = argv_for(&spec); + + assert_eq!(argv[0], "-p"); + assert_eq!(argv[1], spec.prompt); + } + + #[test] + fn claude_gets_inline_config_and_ignores_other_mcp_sources() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.mcp = Some(mcp_fixture()); + let argv = argv_for(&spec); + + assert!(argv.iter().any(|a| a == "--mcp-config")); + // Without this the user's own ~/.claude servers would join the run. + assert!(argv.iter().any(|a| a == "--strict-mcp-config")); + } + + #[test] + fn codex_points_at_env_vars_rather_than_values() { + let mut spec = base_spec(HarnessId::Codex); + spec.mcp = Some(mcp_fixture()); + let joined = argv_for(&spec).join(" "); + + assert!(joined.contains("mcp_servers.cms.url=http://localhost:4420/mcp/virtual-mcp/vir_1")); + assert!(joined.contains("mcp_servers.cms.env_http_headers.Cookie=DECOCMS_MCP_COOKIE")); + assert!(joined.contains("mcp_servers.cms.env_http_headers.Origin=DECOCMS_MCP_ORIGIN")); + } + + #[test] + fn no_mcp_means_no_flags_at_all() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let joined = argv_for(&base_spec(harness)).join(" "); + assert!( + !joined.contains("mcp"), + "{harness:?} added MCP flags anyway" + ); + } + } + + /// Every value is a `${VAR}` reference, never a literal — that is what + /// makes passing this document on the command line safe. claude expands + /// them from the environment at startup (verified against the CLI). + #[test] + fn the_claude_config_document_references_env_vars_only() { + let value: serde_json::Value = + serde_json::from_str(&claude_mcp_config()).expect("valid json"); + let server = &value["mcpServers"]["cms"]; + assert_eq!(server["type"], "http"); + assert_eq!(server["url"], "${DECOCMS_MCP_URL}"); + assert_eq!(server["headers"]["Cookie"], "${DECOCMS_MCP_COOKIE}"); + assert_eq!(server["headers"]["Origin"], "${DECOCMS_MCP_ORIGIN}"); + } + + fn base_spec(harness: HarnessId) -> RunSpec { + RunSpec { + append_system_prompt: None, + mcp: None, + harness, + cwd: None, + prompt: "hello".to_string(), + resume_session_id: None, + model_id: match harness { + HarnessId::ClaudeCode => "claude-code:sonnet".to_string(), + HarnessId::Codex => "codex:gpt-5.6-terra".to_string(), + }, + tool_approval: ToolApproval::Auto, + plan_mode: false, + } + } + + #[cfg(unix)] + async fn wait_for_file(path: &std::path::Path) -> String { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Ok(value) = std::fs::read_to_string(path) { + if !value.trim().is_empty() { + return value; + } + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("child must publish its lifecycle oracle") + } + + #[cfg(unix)] + async fn wait_for_pid_exit(pid: u32) { + tokio::time::timeout(std::time::Duration::from_secs(5), async move { + loop { + let alive = tokio::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map(|status| status.success()) + .unwrap_or(false); + if !alive { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("process {pid} survived harness teardown")); + } + + #[test] + fn extract_user_text_joins_text_parts() { + let msg = json!({"parts": [ + {"type": "text", "text": "hello"}, + {"type": "image", "url": "http://x"}, + {"type": "text", "text": "world"}, + ]}); + assert_eq!(extract_user_text(&msg), "hello\nworld"); + } + + #[test] + fn extract_user_text_tolerates_a_bare_string() { + assert_eq!(extract_user_text(&json!("hi")), "hi"); + } + + #[test] + fn extract_user_text_empty_when_no_text_parts() { + let msg = json!({"parts": [{"type": "image", "url": "http://x"}]}); + assert_eq!(extract_user_text(&msg), ""); + } + + #[test] + fn tool_approval_from_wire() { + assert_eq!(ToolApproval::from_wire("readonly"), ToolApproval::Readonly); + assert_eq!(ToolApproval::from_wire("auto"), ToolApproval::Auto); + // Byte-parity gate already restricts this to auto|readonly; an + // unexpected value degrades to the more-permissive `Auto` rather + // than panicking. + assert_eq!(ToolApproval::from_wire("whatever"), ToolApproval::Auto); + } + + #[test] + fn resume_session_id_is_trimmed_and_empty_values_are_rejected() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.resume_session_id = Some(" session-123 ".to_string()); + assert_eq!( + normalized_resume_session_id(&spec).unwrap(), + Some("session-123") + ); + + spec.resume_session_id = Some(" \n\t ".to_string()); + assert_eq!( + normalized_resume_session_id(&spec), + Err(ResolveError::InvalidSessionId) + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn public_start_entrypoints_reject_invalid_resume_without_spawning() { + for use_lifetime_lock in [false, true] { + let temp = tempfile::tempdir().expect("tempdir"); + let marker = temp.path().join("spawned"); + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.resume_session_id = Some(" \n\t ".to_string()); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf spawned > \"$1\"".to_string(), + "harness".to_string(), + marker.display().to_string(), + ]; + + let mut handle = if use_lifetime_lock { + start_with_child_lifetime_lock(argv, &spec, temp.path().join("child.lock")).await + } else { + start(argv, &spec).await + }; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert_eq!( + events, + vec![RunEvent::FatalError { + message: ResolveError::InvalidSessionId.to_string(), + }] + ); + assert!( + !marker.exists(), + "invalid resume input must be rejected before caller-supplied argv is spawned" + ); + assert_eq!(handle.session_id().await, None); + } + } + + #[test] + fn build_argv_fails_closed_for_an_unresolvable_claude_binary() { + // No LOCAL_API_CLAUDE_BIN override is set for this test process, + // and "claude" is very unlikely to be validated as present via + // this pure function in a bare CI runner — but to make the + // assertion deterministic regardless of the CI machine's PATH, + // point the override at a definitely-nonexistent path via the + // ENV directly is what `build_argv`/`resolve_checked` reads; since + // that's not parallel-test-safe (see `resolve.rs`'s doc comment), + // this test instead exercises the pure argv-shape assertions + // below and leaves the resolve-failure path to `resolve.rs`'s own + // (env-free) unit tests. + let spec = base_spec(HarnessId::ClaudeCode); + // Can't assert Ok/Err deterministically without an env override + // (which isn't parallel-test-safe here) — just confirm this + // doesn't panic either way. + let _ = build_argv(&spec); + } + + #[test] + fn claude_argv_shape_includes_expected_flags() { + let mut argv = vec!["claude".to_string()]; + let spec = base_spec(HarnessId::ClaudeCode); + append_claude_args(&mut argv, &spec, None); + assert!(argv.contains(&"-p".to_string())); + assert!(argv.contains(&"hello".to_string())); + assert!(argv.contains(&"stream-json".to_string())); + assert!(argv.contains(&"--include-partial-messages".to_string())); + assert!(argv.contains(&"claude-sonnet-5".to_string())); + assert!(!argv.contains(&"--resume".to_string())); + } + + #[test] + fn claude_argv_includes_resume_when_session_id_present() { + let mut argv = vec!["claude".to_string()]; + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.resume_session_id = Some("sess-123".to_string()); + append_claude_args(&mut argv, &spec, Some("sess-123")); + let idx = argv.iter().position(|a| a == "--resume").unwrap(); + assert_eq!(argv[idx + 1], "sess-123"); + } + + #[test] + fn claude_argv_disallows_write_tools_when_readonly() { + let mut argv = vec!["claude".to_string()]; + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.tool_approval = ToolApproval::Readonly; + append_claude_args(&mut argv, &spec, None); + let idx = argv.iter().position(|a| a == "--disallowedTools").unwrap(); + assert!(argv[idx + 1].contains("Write")); + assert!(argv[idx + 1].contains("Bash")); + } + + #[test] + fn claude_argv_allows_bash_when_auto_approval() { + let mut argv = vec!["claude".to_string()]; + let spec = base_spec(HarnessId::ClaudeCode); + append_claude_args(&mut argv, &spec, None); + let idx = argv.iter().position(|a| a == "--disallowedTools").unwrap(); + assert!(!argv[idx + 1].contains("Bash")); + } + + #[test] + fn codex_argv_shape_includes_expected_flags() { + let mut argv = vec!["codex".to_string()]; + let spec = base_spec(HarnessId::Codex); + append_codex_args(&mut argv, &spec, None); + assert_eq!(argv[1], "exec"); + assert!(argv.contains(&"--json".to_string())); + assert!(argv.contains(&"gpt-5.6-terra".to_string())); + assert_eq!(argv.last(), Some(&"hello".to_string())); + assert!(!argv.contains(&"resume".to_string())); + } + + #[test] + fn codex_argv_includes_resume_subcommand_when_session_id_present() { + let mut argv = vec!["codex".to_string()]; + let mut spec = base_spec(HarnessId::Codex); + spec.resume_session_id = Some("thread-abc".to_string()); + append_codex_args(&mut argv, &spec, Some("thread-abc")); + let idx = argv.iter().position(|a| a == "resume").unwrap(); + assert_eq!(argv[idx + 1], "thread-abc"); + // Prompt still trails everything. + assert_eq!(argv.last(), Some(&"hello".to_string())); + } + + #[test] + fn codex_argv_sandbox_is_read_only_for_plan_mode() { + let mut argv = vec!["codex".to_string()]; + let mut spec = base_spec(HarnessId::Codex); + spec.plan_mode = true; + append_codex_args(&mut argv, &spec, None); + let idx = argv.iter().position(|a| a == "--sandbox").unwrap(); + assert_eq!(argv[idx + 1], "read-only"); + } + + // --- End-to-end drive() tests using a fake "harness" script (/bin/sh) + // instead of the real CLIs — deterministic, free, and exercises the + // exact same drive()/spawn()/EventMapper wiring `local-api`'s dispatch + // route depends on, just against synthetic ndjson instead of a real + // model call. This is the differential/stub-harness style contract + // the Phase 2 TODO's "deterministic stub harness" item calls for, at + // the harness-crate level (the desktop-level black-box differential + // suite is a separate, higher-layer concern this crate's tests don't + // replace). + + fn claude_success_script() -> String { + // Mirrors the fallback shape from + // `apps/native/e2e/fixtures/stub-harness.mjs`: plain `assistant` + // frames only. The mapper retains this path for older CLIs and + // test doubles that accept but do not emit partial messages. + "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}' \ + '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}' \ + '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hi\",\"session_id\":\"s1\"}'" + .to_string() + } + + fn success_without_session_script(harness: HarnessId) -> String { + match harness { + HarnessId::ClaudeCode => { + "printf '%s\\n' \ + '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}' \ + '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hi\"}'" + .to_string() + } + HarnessId::Codex => { + "printf '%s\\n' \ + '{\"type\":\"turn.started\"}' \ + '{\"type\":\"item.completed\",\"item\":{\"id\":\"item-1\",\"type\":\"agent_message\",\"text\":\"hi\"}}' \ + '{\"type\":\"turn.completed\",\"usage\":{}}'" + .to_string() + } + } + } + + fn changed_session_script(harness: HarnessId) -> String { + match harness { + HarnessId::ClaudeCode => "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"changed-session\"}'" + .to_string(), + HarnessId::Codex => "printf '%s\\n' \ + '{\"type\":\"thread.started\",\"thread_id\":\"changed-session\"}'" + .to_string(), + } + } + + #[tokio::test] + async fn successful_first_run_without_session_is_fatal_for_both_harnesses() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let spec = base_spec(harness); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + success_without_session_script(harness), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!( + events.iter().any(|event| matches!( + event, + RunEvent::FatalError { message } if message == MISSING_SESSION_ID_ERROR + )), + "{harness:?} must fail instead of creating non-resumable history: {events:?}" + ); + assert!( + !events.iter().any( + |event| matches!(event, RunEvent::Chunk(value) if value["type"] == "finish") + ), + "{harness:?} must not publish a successful finish without a session" + ); + assert_eq!(handle.session_id().await, None); + } + } + + #[tokio::test] + async fn resumed_runs_retain_the_supplied_session_when_cli_does_not_repeat_it() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let mut spec = base_spec(harness); + spec.resume_session_id = Some(" durable-session ".to_string()); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + success_without_session_script(harness), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!( + events.iter().any( + |event| matches!(event, RunEvent::Chunk(value) if value["type"] == "finish") + ), + "{harness:?} resumed run should complete: {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. })), + "{harness:?} resumed run should not require the CLI to repeat its id: {events:?}" + ); + assert_eq!( + handle.session_id().await.as_deref(), + Some("durable-session") + ); + } + } + + #[tokio::test] + async fn resumed_runs_fail_if_either_cli_changes_the_session_id() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let mut spec = base_spec(harness); + spec.resume_session_id = Some("durable-session".to_string()); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + changed_session_script(harness), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!( + events.iter().any(|event| matches!( + event, + RunEvent::FatalError { message } if message == CHANGED_SESSION_ID_ERROR + )), + "{harness:?} must reject a split session: {events:?}" + ); + assert_eq!( + handle.session_id().await.as_deref(), + Some("durable-session"), + "the durable resume id must not be replaced by the fork" + ); + } + } + + #[tokio::test] + async fn mapper_emitted_whitespace_session_id_is_fatal() { + let spec = base_spec(HarnessId::ClaudeCode); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\" \"}'" + .to_string(), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!(events.iter().any(|event| matches!( + event, + RunEvent::FatalError { message } if message == EMPTY_SESSION_ID_ERROR + ))); + assert_eq!(handle.session_id().await, None); + } + + #[tokio::test] + async fn mapper_session_id_is_trimmed_before_resume_consistency_check() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.resume_session_id = Some(" durable-session ".to_string()); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\" durable-session \"}' \ + '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"session_id\":\" durable-session \"}'" + .to_string(), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!(!events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, RunEvent::Chunk(value) if value["type"] == "finish"))); + assert_eq!( + handle.session_id().await.as_deref(), + Some("durable-session") + ); + } + + #[tokio::test] + async fn drive_end_to_end_yields_chunks_then_closes_with_no_fatal_error() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + claude_success_script(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + let cancelled = Arc::new(AtomicBool::new(false)); + let process_slot = Arc::new(Mutex::new(None)); + let session_id = Arc::new(Mutex::new(None)); + drive( + req, + HarnessId::ClaudeCode, + tx, + cancelled, + process_slot, + session_id.clone(), + ) + .await; + + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { + events.push(ev); + } + assert!(events + .iter() + .any(|e| matches!(e, RunEvent::Chunk(v) if v["type"] == "text-delta"))); + assert!(events + .iter() + .any(|e| matches!(e, RunEvent::Chunk(v) if v["type"] == "finish"))); + assert!(!events + .iter() + .any(|e| matches!(e, RunEvent::FatalError { .. }))); + assert_eq!(session_id.lock().await.as_deref(), Some("s1")); + } + + #[tokio::test] + async fn drive_synthesizes_a_fatal_error_on_unexpected_nonzero_exit() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo 'not json' 1>&2; exit 3".to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + let cancelled = Arc::new(AtomicBool::new(false)); + let process_slot = Arc::new(Mutex::new(None)); + let session_id = Arc::new(Mutex::new(None)); + drive( + req, + HarnessId::Codex, + tx, + cancelled, + process_slot, + session_id, + ) + .await; + + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { + events.push(ev); + } + assert_eq!(events.len(), 1); + let RunEvent::FatalError { message } = &events[0] else { + panic!("expected a FatalError") + }; + assert!(message.contains("codex")); + assert!(message.contains('3')); + assert!(message.contains("not json")); + } + + #[tokio::test] + async fn drive_suppresses_the_synthetic_error_when_cancelled() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 0.2; exit 1".to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + let cancelled = Arc::new(AtomicBool::new(true)); // pre-cancelled + let process_slot = Arc::new(Mutex::new(None)); + let session_id = Arc::new(Mutex::new(None)); + drive( + req, + HarnessId::Codex, + tx, + cancelled, + process_slot, + session_id, + ) + .await; + + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { + events.push(ev); + } + assert!( + events.is_empty(), + "a cancelled run's nonzero exit must not synthesize a FatalError" + ); + } + + #[tokio::test] + async fn drive_flushes_partial_text_on_clean_eof() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"partial-session\"}' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}}'" + .to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + drive( + req, + HarnessId::ClaudeCode, + tx, + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + ) + .await; + + let mut chunk_types = Vec::new(); + while let Some(event) = rx.recv().await { + match event { + RunEvent::SessionId { .. } => {} + RunEvent::Chunk(value) => { + chunk_types.push(value["type"].as_str().unwrap_or("").to_string()) + } + RunEvent::FatalError { message } => panic!("unexpected fatal error: {message}"), + } + } + assert_eq!( + chunk_types, + vec!["start", "text-start", "text-delta", "text-end"] + ); + } + + #[tokio::test] + async fn drive_flushes_partial_tool_as_error_before_nonzero_exit_error() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_cut\",\"name\":\"Bash\",\"input\":{}}}}' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"command\\\":\"}}}'; \ + printf 'process failed\\n' >&2; exit 3" + .to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + drive( + req, + HarnessId::ClaudeCode, + tx, + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + ) + .await; + + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + assert!(matches!( + events.get(events.len().saturating_sub(2)), + Some(RunEvent::Chunk(value)) if value["type"] == "tool-input-error" + && value["toolCallId"] == "toolu_cut" + )); + assert!(matches!( + events.last(), + Some(RunEvent::FatalError { message }) if message.contains("process failed") + )); + assert!(!events.iter().any( + |event| matches!(event, RunEvent::Chunk(value) if value["type"] == "tool-input-available") + )); + } + + #[tokio::test] + async fn claude_retries_without_partial_flag_only_after_argument_parser_rejection() { + let temp = tempfile::tempdir().expect("tempdir"); + let attempts = temp.path().join("attempts"); + let script = format!( + "count=$(cat '{}' 2>/dev/null || printf 0); count=$((count + 1)); printf '%s' \"$count\" > '{}'; \ + case \" $* \" in \ + *' --include-partial-messages '*) printf \"error: unknown option '--include-partial-messages'\\n\" >&2; exit 1 ;; \ + esac; \ + {}", + attempts.display(), + attempts.display(), + claude_success_script() + ); + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + script, + "stub-claude".to_string(), + "--include-partial-messages".to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + drive( + req, + HarnessId::ClaudeCode, + tx, + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + ) + .await; + + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + assert_eq!(std::fs::read_to_string(&attempts).unwrap(), "2"); + assert!(events + .iter() + .any(|event| matches!(event, RunEvent::Chunk(value) if value["type"] == "finish"))); + assert!(!events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. }))); + } + + #[tokio::test] + async fn claude_never_retries_unknown_flag_text_after_any_stdout() { + let temp = tempfile::tempdir().expect("tempdir"); + let attempts = temp.path().join("attempts"); + let script = format!( + "count=$(cat '{}' 2>/dev/null || printf 0); count=$((count + 1)); printf '%s' \"$count\" > '{}'; \ + printf '%s\\n' '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"already-started\"}}'; \ + printf \"error: unknown option '--include-partial-messages'\\n\" >&2; exit 1", + attempts.display(), + attempts.display(), + ); + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + script, + "stub-claude".to_string(), + "--include-partial-messages".to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + drive( + req, + HarnessId::ClaudeCode, + tx, + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + ) + .await; + + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + assert_eq!(std::fs::read_to_string(&attempts).unwrap(), "1"); + assert!(events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. }))); + } + + #[tokio::test] + async fn cancelling_a_partial_reasoning_stream_emits_reasoning_end() { + let spec = base_spec(HarnessId::ClaudeCode); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}}' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"working\"}}}'; \ + sleep 30" + .to_string(), + ]; + let mut handle = start(argv, &spec).await; + let first = tokio::time::timeout(std::time::Duration::from_secs(5), handle.recv()) + .await + .expect("partial reasoning must start") + .expect("stream must remain open"); + assert!(matches!( + first, + RunEvent::Chunk(value) if value["type"] == "reasoning-start" + )); + + handle.cancel().await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + assert!(events.iter().any( + |event| matches!(event, RunEvent::Chunk(value) if value["type"] == "reasoning-end") + )); + assert!(!events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. }))); + } + + #[tokio::test] + async fn run_handle_cancel_terminates_the_process_and_closes_the_stream() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.prompt = "irrelevant".to_string(); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]; + let mut handle = start(argv, &spec).await; + // Give the process a moment to actually spawn and register its pid. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + handle.cancel().await; + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(_ev) = handle.recv().await {} + }) + .await; + assert!( + outcome.is_ok(), + "cancel() must terminate the process so the event stream closes promptly" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_handle_cancel_escalates_to_kill_for_a_term_resistant_group() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.prompt = "irrelevant".to_string(); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + // Install the handler before announcing readiness so the test + // cannot accidentally signal the default disposition window. + "trap '' TERM; printf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"term-resistant\"}'; while :; do sleep 30; done".to_string(), + ]; + let mut handle = start(argv, &spec).await; + tokio::time::timeout(std::time::Duration::from_secs(5), handle.recv()) + .await + .expect("TERM-resistant child must announce readiness") + .expect("TERM-resistant child exited before cancellation"); + + handle.cancel().await; + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(_event) = handle.recv().await {} + }) + .await; + assert!( + outcome.is_ok(), + "cancel() must hard-kill a process group that ignores SIGTERM" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn dropping_run_handle_reaps_term_resistant_child_and_descendant() { + let temp = tempfile::tempdir().expect("tempdir"); + let pids_path = temp.path().join("pids"); + let script = format!( + "trap '' TERM; /bin/sh -c 'trap \"\" TERM; while :; do sleep 30; done' & descendant=$!; printf '%s %s\\n' \"$$\" \"$descendant\" > '{}'; printf '%s\\n' '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"drop-owner\"}}'; while :; do sleep 30; done", + pids_path.display() + ); + let spec = base_spec(HarnessId::ClaudeCode); + let mut handle = start(vec!["/bin/sh".to_string(), "-c".to_string(), script], &spec).await; + tokio::time::timeout(std::time::Duration::from_secs(5), handle.recv()) + .await + .expect("child must announce readiness") + .expect("child exited before drop"); + let pids = wait_for_file(&pids_path).await; + let mut pids = pids.split_whitespace().map(|pid| { + pid.parse::() + .unwrap_or_else(|_| panic!("invalid child pid {pid:?}")) + }); + let child_pid = pids.next().expect("direct child pid"); + let descendant_pid = pids.next().expect("descendant pid"); + + drop(handle); + + wait_for_pid_exit(child_pid).await; + wait_for_pid_exit(descendant_pid).await; + } + + #[cfg(unix)] + #[tokio::test] + async fn aborting_cancel_future_does_not_abort_hard_kill_escalation() { + let temp = tempfile::tempdir().expect("tempdir"); + let term_path = temp.path().join("term-received"); + let script = format!( + "trap 'printf term > \"{}\"' TERM; printf '%s\\n' '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"cancel-abort\"}}'; while :; do sleep 30; done", + term_path.display() + ); + let spec = base_spec(HarnessId::ClaudeCode); + let mut handle = start(vec!["/bin/sh".to_string(), "-c".to_string(), script], &spec).await; + tokio::time::timeout(std::time::Duration::from_secs(5), handle.recv()) + .await + .expect("child must announce readiness") + .expect("child exited before cancellation"); + + let cancel = handle.cancel_handle(); + let cancel_task = tokio::spawn(async move { cancel.cancel().await }); + wait_for_file(&term_path).await; + cancel_task.abort(); + + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while handle.recv().await.is_some() {} + }) + .await + .expect("detached escalation must kill the TERM-resistant child"); + } + + #[tokio::test] + async fn cancel_handle_reaches_the_same_run_as_the_owning_run_handle() { + let spec = base_spec(HarnessId::ClaudeCode); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]; + let mut handle = start(argv, &spec).await; + let cancel_handle = handle.cancel_handle(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + // Cancel via the DETACHED handle, not `handle` itself — mirrors + // `local-api`'s dispatch route storing a `CancelHandle` in a + // shared registry while a separate task drains `handle.recv()`. + cancel_handle.cancel().await; + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(_ev) = handle.recv().await {} + }) + .await; + assert!( + outcome.is_ok(), + "a cancel via the detached CancelHandle must still terminate the run" + ); + } + + /// Regression test for the exact bug + /// `apps/native/e2e/dispatch-stream.e2e.test.ts`'s `hang` scenario + /// caught: `cancel()` called with NO grace period at all — i.e. in + /// the window where `drive()`'s own `spawn::spawn(req).await` may not + /// have completed yet, so `process_slot` can still be `None` when + /// `cancel()` runs. Before the `drive()`-side re-check this test + /// exercises, this was flaky-to-reliably-failing (the process would + /// spawn AFTER `cancel()` already gave up, then run forever); it must + /// now deterministically terminate regardless of scheduling luck. + #[tokio::test] + async fn cancel_called_with_zero_grace_period_still_kills_the_process() { + let spec = base_spec(HarnessId::ClaudeCode); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]; + let mut handle = start(argv, &spec).await; + // NO sleep here — cancel as fast as this task can possibly run, + // maximizing the odds of landing before `drive()`'s spawn + // completes (exactly what a client cancelling the instant it + // observes the harness's very first output line does in + // practice). + handle.cancel().await; + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(_ev) = handle.recv().await {} + }) + .await; + assert!( + outcome.is_ok(), + "a zero-grace-period cancel must still terminate the process once it spawns" + ); + } +} diff --git a/apps/native/crates/harness/src/spawn.rs b/apps/native/crates/harness/src/spawn.rs new file mode 100644 index 0000000000..f7336abc2b --- /dev/null +++ b/apps/native/crates/harness/src/spawn.rs @@ -0,0 +1,1004 @@ +//! The one process-spawn abstraction — PTY (via `portable-pty`) OR plain +//! pipes behind a single [`spawn`] entry point, mirroring +//! `packages/sandbox/daemon/process/pty-spawn.ts`'s two-tier contract +//! (`spawnPty` + its `spawnFallback`) in intent: `TERM=xterm-256color`, +//! 128+signal shell-convention exit codes, 3× `openpty`/`forkpty` retry +//! with backoff before falling back to plain pipes on PTY-allocation +//! failure (keyed off the failure itself, not an OS-version-specific error +//! string — see the native implementation contract section 4's "Fallback design +//! that must be preserved"). +//! +//! ## Why harness dispatch does NOT use the PTY path (investigated + decided) +//! +//! The Phase 2 TODO explicitly asks this to be investigated and documented, +//! not just asserted. Evidence, gathered by actually running both CLIs on +//! this machine (claude 2.1.214, codex-cli 0.144.5, 2026-07-18): +//! +//! - `claude -p --output-format stream-json --verbose` and +//! `codex exec --json` were both run with stdout/stderr redirected to a +//! plain file (i.e. `isatty(stdout) == false`, no PTY at all) and BOTH +//! produced clean, complete, well-formed ndjson with no missing frames, +//! no ANSI escape codes, and no behavioral difference from a TTY-backed +//! run. `-p`/`--print` (claude) and `exec` (codex) are each CLI's +//! explicit non-interactive/headless mode — by design, not by accident, +//! these do not require a controlling terminal. +//! - A PTY, if used, would actively HURT this use case: `events::claude` +//! and `events::codex` parse each line of stdout as one JSON object. +//! PTY line-discipline (canonical mode line editing, `\r\n` translation, +//! OS-level 4096-byte-line buffering under some ptys) and any +//! TTY-triggered color/progress-bar ANSI output the CLI might emit ARE +//! TTY-mode side effects specifically — no evidence either CLI emits +//! them in `-p`/`exec` mode, but a PTY would risk introducing exactly +//! the corruption this crate's ndjson parser has no reason to defend +//! against if plain pipes are used. +//! - A concrete failure mode observed empirically: `codex exec` reads +//! stdin as an "append to the prompt" channel when stdin is a pipe +//! (`Reading additional input from stdin...`) and hangs waiting for EOF +//! if it never arrives — this crate's plain-pipe path closes stdin +//! (`Stdio::null()`) specifically to prevent that hang. A PTY's stdin +//! is inherently a terminal-like fd (never naturally EOF-closed by the +//! spawning side the way a piped `Stdio::null()` is), which would make +//! this specific, already-observed hazard WORSE, not better. +//! +//! **Decision**: `run.rs` always spawns with `pty: false` (plain pipes). +//! The PTY half of this module exists, is exercised by this module's own +//! tests, and remains available for any FUTURE harness/CLI that genuinely +//! requires a controlling terminal (the Phase 2 TODO's explicit ask: "one +//! spawn abstraction" covering both, not a PTY-only or plain-only module). + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use tokio::sync::{mpsc, oneshot, Mutex, Notify}; + +/// One request to spawn a child process. `Clone` so a caller can retain a +/// copy across a PTY→plain-pipe fallback without re-building it. +#[derive(Debug, Clone)] +pub struct SpawnRequest { + /// `argv[0]` is the program; the rest are its arguments. + pub argv: Vec, + pub cwd: Option, + /// Extra/override environment variables, merged over the inherited + /// process environment (and `TERM=xterm-256color`, always set). + pub env: Vec<(String, String)>, + /// Path to local-api's crash/relaunch ownership fence. The production + /// plain-pipe path opens this file with a shared advisory lock and passes + /// that locked file into the independent watchdog as an exec-inherited + /// descriptor before the CLI is spawned. A replacement local-api takes an + /// exclusive lock before recovering durable queue state, so it cannot + /// promote a successor while an old crash watchdog still owns descendants. + /// + /// `None` is reserved for harness-crate unit tests and non-local-api users; + /// both native dispatch call sites always provide the app-root path. + pub lifetime_lock_path: Option, + /// `true` requests the PTY path (with plain-pipe fallback on + /// allocation failure); `false` goes straight to plain pipes. See this + /// module's doc comment for why harness dispatch always passes + /// `false`. + pub pty: bool, +} + +impl SpawnRequest { + pub fn new(argv: Vec) -> Self { + Self { + argv, + cwd: None, + env: Vec::new(), + lifetime_lock_path: None, + pty: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Signal { + Term, + Kill, +} + +impl Signal { + /// The `kill(1)` flag spelling — the one place this mapping lives. + pub(crate) fn flag(self) -> &'static str { + match self { + Signal::Term => "-TERM", + Signal::Kill => "-KILL", + } + } +} + +/// Shell-convention exit info: `code` is the raw exit code for a normal +/// exit, or `128 + signal` for a signal-terminated process (POSIX shell +/// convention — byte-parity in SPIRIT with `pty-spawn.ts::shellExitCode`, +/// though Rust's `std::os::unix::process::ExitStatusExt::signal()` makes +/// the actual mapping far simpler than the TS side's signal-NAME-to-number +/// table: `ExitStatus::signal()` already returns the numeric signal, no +/// name/number lookup table needed). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExitInfo { + pub code: i32, +} + +#[derive(Debug)] +pub enum SpawnError { + Io(std::io::Error), + /// PTY allocation/spawn failed (after retries, if the PTY path was + /// requested) — carries the last error's message. + Pty(String), + /// The spawned child never reported a pid (should not happen in + /// practice; guarded defensively rather than unwrapped). + NoPid, +} + +impl std::fmt::Display for SpawnError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpawnError::Io(e) => write!(f, "spawn failed: {e}"), + SpawnError::Pty(msg) => write!(f, "PTY spawn failed: {msg}"), + SpawnError::NoPid => write!(f, "spawned child reported no pid"), + } + } +} + +impl std::error::Error for SpawnError {} + +/// A running (or just-exited) child process. `stdout`/`stderr` are +/// SEPARATE channels always (even in PTY mode, where `stderr` simply never +/// produces anything — the OS merges both into the one PTY stream, routed +/// here as `stdout`) — unlike `pty-spawn.ts`'s merged single stream, this +/// crate's consumer (`run.rs`) needs stdout to carry ONLY the harness's +/// ndjson, uncontaminated by stderr diagnostics. +pub struct SpawnedProcess { + pub pid: u32, + pub stdout: mpsc::Receiver, + pub stderr: mpsc::Receiver, + /// Resolves exactly once, when the child exits. + pub exit: oneshot::Receiver, + /// Live ownership of the process group. On the plain-pipe path used by + /// harness dispatch, the independent group anchor stays alive until this + /// becomes inert, so stale cancellation cannot target a reused pgid. + pub(crate) lease: ProcessLease, + /// Ownership signal for the detached plain-pipe waiter that holds the + /// actual child. Dropping the consumer closes this sender; the waiter then + /// performs the same bounded process-group teardown as explicit cancel. + pub(crate) owner: ProcessOwner, +} + +impl SpawnedProcess { + /// Signals the child's WHOLE PROCESS GROUP (not just the direct + /// child) — see [`kill_pid`]'s doc comment for the mechanism and why. + pub async fn kill(&self, signal: Signal) { + self.lease.signal(signal).await; + } +} + +/// A process-group id paired with its in-process ownership lifetime. +/// +/// Unix's `kill(2)` API only accepts a numeric pid/pgid, so retaining that +/// number after the child has been reaped risks signaling an unrelated process +/// after pid reuse. Runtime cancellation therefore carries this lease instead. +/// The production plain-pipe path couples it to an independent group anchor; +/// PTY is a completeness-only utility and does not claim that stronger anchor +/// guarantee (harness dispatch always sets `pty: false`). +#[derive(Clone)] +pub(crate) struct ProcessLease { + inner: Arc, +} + +struct ProcessLeaseInner { + group_id: u32, + /// The independent watchdog is the group leader on the plain path. It + /// must never receive SIGKILL with the rest of the group: it owns the + /// inherited shared lifetime fence and releases it only after proving + /// every non-anchor group member is gone. PTY has no such anchor. + anchor_id: Option, + alive: AtomicBool, + exited: Notify, + /// Serializes every numeric-pgid signal with the final group-anchor reap. + /// Without this gate, a signal task could observe `alive`, get descheduled, + /// then resume after the watchdog was reaped and target a reused pgid. + signal_gate: Mutex<()>, + termination_started: AtomicBool, + termination_finished: AtomicBool, + termination_done: Notify, +} + +impl ProcessLease { + fn new(group_id: u32, anchor_id: Option) -> Self { + Self { + inner: Arc::new(ProcessLeaseInner { + group_id, + anchor_id, + alive: AtomicBool::new(true), + exited: Notify::new(), + signal_gate: Mutex::new(()), + termination_started: AtomicBool::new(false), + termination_finished: AtomicBool::new(false), + termination_done: Notify::new(), + }), + } + } + + pub(crate) fn is_alive(&self) -> bool { + self.inner.alive.load(Ordering::SeqCst) + } + + fn mark_exited(&self) { + if self.inner.alive.swap(false, Ordering::SeqCst) { + self.inner.exited.notify_waiters(); + } + } + + /// PTY's waiter is a blocking OS thread. It uses the same signal gate so a + /// signal already in flight finishes before the lease becomes inert. + fn mark_exited_blocking(&self) { + let _signal_guard = self.inner.signal_gate.blocking_lock(); + self.mark_exited(); + } + + pub(crate) async fn signal(&self, signal: Signal) { + let _signal_guard = self.inner.signal_gate.lock().await; + if self.is_alive() { + if let Some(anchor_id) = self.inner.anchor_id { + // Shared enumerate-then-signal helper (blocking `pgrep` + + // `kill`) — see `crate::watchdog` for why `-` is + // forbidden while the anchor owns the lifetime fence. + let group_id = self.inner.group_id; + let _ = tokio::task::spawn_blocking(move || { + crate::watchdog::signal_non_anchor_members(group_id, anchor_id, signal) + }) + .await; + } else { + kill_pid(self.inner.group_id, signal).await; + } + } + } + + /// Cooperative cancellation with a bounded hard-kill fallback. The + /// watchdog remains the process-group anchor while the CLI is alive, so + /// the pgid cannot disappear/rebind during this lease. A stale clone is + /// inert after `mark_exited`. Escalation runs in its own once-only task: + /// dropping the caller's future after TERM cannot strand a resistant CLI. + pub(crate) async fn terminate(&self) { + if !self.is_alive() { + return; + } + + let done = self.inner.termination_done.notified(); + tokio::pin!(done); + done.as_mut().enable(); + + if !self.inner.termination_started.swap(true, Ordering::SeqCst) { + let lease = self.clone(); + tokio::spawn(async move { + lease.signal(Signal::Term).await; + if lease.is_alive() { + let exited = lease.inner.exited.notified(); + tokio::pin!(exited); + exited.as_mut().enable(); + if lease.is_alive() { + tokio::select! { + _ = &mut exited => {}, + _ = tokio::time::sleep(Duration::from_secs(1)) => { + lease.signal(Signal::Kill).await; + } + } + } + } + lease + .inner + .termination_finished + .store(true, Ordering::SeqCst); + lease.inner.termination_done.notify_waiters(); + }); + } + + if !self.inner.termination_finished.load(Ordering::SeqCst) { + done.await; + } + } + + /// Final cleanup for the plain-pipe path. Closing the liveness writer asks + /// the watchdog to TERM→KILL every NON-anchor member and keep its shared + /// lifetime fence until `pgrep` proves the group empty. Only then does the + /// watchdog exit and become reapable. Killing the anchor ourselves would + /// release the restart fence before descendants were known gone. + #[cfg(unix)] + async fn finish_plain_group( + &self, + watchdog: &mut tokio::process::Child, + parent_liveness: tokio::process::ChildStdin, + ) { + let _signal_guard = self.inner.signal_gate.lock().await; + drop(parent_liveness); + match watchdog.wait().await { + Ok(_) => self.mark_exited(), + Err(error) => { + // Fail closed. The watchdog may still own the shared lifetime + // fence; pretending cleanup finished would let queue recovery + // race an indeterminate old process tree. + tracing::error!(%error, "could not prove harness watchdog exit"); + std::future::pending::<()>().await; + } + } + } +} + +/// The waiter owns the OS child; the stream driver owns this sender. That +/// explicit edge makes dropping/aborting a driver a teardown event instead of +/// silently detaching the waiter and its child process. +pub(crate) struct ProcessOwner { + teardown: Option>, +} + +impl ProcessOwner { + fn new(teardown: oneshot::Sender<()>) -> Self { + Self { + teardown: Some(teardown), + } + } + + fn inert() -> Self { + Self { teardown: None } + } +} + +impl Drop for ProcessOwner { + fn drop(&mut self) { + if let Some(teardown) = self.teardown.take() { + let _ = teardown.send(()); + } + } +} + +/// Signals process GROUP `pid` (not just the single process) — see +/// `Cargo.toml`'s doc comment on why this shells out to `kill(1)` instead +/// of an `unsafe` `libc::kill` FFI call. The caller passes an owned process +/// GROUP id: plain harnesses use the parent-liveness watchdog's pid, while +/// PTY children use `portable-pty`'s session/group-leading pid. `-` +/// reaches the harness CLI's own child processes too (e.g. a Bash tool call), +/// not just the direct CLI. Plain-pipe runtime callers retain a +/// watchdog-anchored [`ProcessLease`], never this bare number, so pid reuse +/// cannot turn a stale cancel into a signal for an unrelated process. +#[cfg(unix)] +pub async fn kill_pid(pid: u32, signal: Signal) { + let pgid = format!("-{pid}"); + let _ = tokio::process::Command::new("kill") + .arg(signal.flag()) + .arg(pgid) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; +} + +#[cfg(not(unix))] +pub async fn kill_pid(_pid: u32, _signal: Signal) { + // Process-group signaling is a POSIX concept; Windows job objects are + // a different mechanism entirely. Studio ships as a macOS desktop app + // (the desktop migration contract) — out of scope for v1, not silently pretended to work. + tracing::warn!("process-group kill is not implemented on this platform"); +} + +/// Spawn per `req`. Tries PTY first (with retry+backoff) when +/// `req.pty == true`, falling back to plain pipes on allocation failure; +/// otherwise goes straight to plain pipes. +pub async fn spawn(req: SpawnRequest) -> Result { + if req.pty { + let pty_req = req.clone(); + match tokio::task::spawn_blocking(move || pty::spawn_pty_with_retry(&pty_req)).await { + Ok(Ok(built)) => return Ok(pty::finish_async(built)), + Ok(Err(err)) => { + tracing::warn!( + error = %err, + "PTY spawn failed after retries, falling back to plain pipes" + ); + } + Err(join_err) => { + tracing::warn!( + error = %join_err, + "PTY spawn task panicked, falling back to plain pipes" + ); + } + } + } + plain::spawn_plain(req).await +} + +// --------------------------------------------------------------------------- +// Plain-pipe path (the one harness dispatch actually uses) +// --------------------------------------------------------------------------- + +mod plain { + use super::*; + use std::process::Stdio; + + pub async fn spawn_plain(req: SpawnRequest) -> Result { + let SpawnRequest { + argv, + cwd, + env, + lifetime_lock_path, + pty: _, + } = req; + let (program, args) = argv.split_first().ok_or(SpawnError::NoPid)?; + + // Unix-only process-group anchor (`crate::watchdog` — the one shared + // copy of the parent-liveness script). Its stdin is a liveness pipe + // whose writer exists only inside Studio: if Studio is uncatchably + // terminated (`SIGKILL`, crash), EOF wakes the independent watchdog + // and it TERM→KILLs every surviving group member, releasing the + // inherited shared lifetime fence only after `pgrep` proves the group + // empty. Normal completion explicitly KILLs and reaps it below. + #[cfg(unix)] + let (mut watchdog, parent_liveness, process_group_id) = { + // Acquire before the watchdog exists and before the CLI spawn can + // happen. Moving this locked File into stdout makes it an + // exec-inherited descriptor owned by the watchdog; Studio keeps + // no duplicate whose close could accidentally release the fence. + let lifetime_lock = lifetime_lock_path + .as_deref() + .map(crate::watchdog::open_shared_lifetime_lock) + .transpose() + .map_err(SpawnError::Io)?; + let anchor = crate::watchdog::spawn_anchor("decocms-harness-watchdog", lifetime_lock) + .map_err(SpawnError::Io)?; + (anchor.child, anchor.parent_liveness, anchor.group_id) + }; + + let mut std_cmd = std::process::Command::new(program); + std_cmd.args(args); + if let Some(cwd) = &cwd { + std_cmd.current_dir(cwd); + } + std_cmd.env("TERM", "xterm-256color"); + for (k, v) in &env { + std_cmd.env(k, v); + } + // This is the REAL CLI's stdin, and remains /dev/null even on Unix. + // The separate watchdog owns the parent-liveness pipe; codex can never + // mistake that pipe for additional prompt input. + std_cmd.stdin(Stdio::null()); + std_cmd.stdout(Stdio::piped()); + std_cmd.stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Join the watchdog's already-live process group. The watchdog is + // the group anchor, so the pgid remains owned and cannot be reused + // until the waiter below reaps it. + let process_group = i32::try_from(process_group_id).map_err(|_| { + SpawnError::Io(std::io::Error::other( + "watchdog pid does not fit a Unix process-group id", + )) + })?; + std_cmd.process_group(process_group); + } + + let mut tokio_cmd = tokio::process::Command::from(std_cmd); + // Last-resort teardown for runtime/app shutdown: if the waiter task + // owning this `Child` is dropped before it can reap the process, + // dropping the child must not leave the harness CLI running detached + // from Studio. Normal cancellation still signals the whole process + // group through `kill_pid`; this guard covers abrupt Tokio-runtime + // teardown of the direct child. + tokio_cmd.kill_on_drop(true); + let mut child = match tokio_cmd.spawn() { + Ok(child) => child, + Err(error) => { + #[cfg(unix)] + { + // The CLI never started. Close the liveness pipe and let + // the watchdog prove its group has no non-anchor members + // before it releases the shared lifetime fence. + drop(parent_liveness); + let _ = watchdog.wait().await; + } + return Err(SpawnError::Io(error)); + } + }; + let pid = child.id().ok_or(SpawnError::NoPid)?; + let stdout = child.stdout.take().expect("stdout was piped"); + let stderr = child.stderr.take().expect("stderr was piped"); + + let (stdout_tx, stdout_rx) = mpsc::channel(256); + let (stderr_tx, stderr_rx) = mpsc::channel(256); + let (exit_tx, exit_rx) = oneshot::channel(); + let (teardown_tx, mut teardown_rx) = oneshot::channel(); + + tokio::spawn(pump(stdout, stdout_tx)); + tokio::spawn(pump(stderr, stderr_tx)); + #[cfg(unix)] + let lease = ProcessLease::new(process_group_id, Some(process_group_id)); + #[cfg(not(unix))] + let lease = ProcessLease::new(pid, None); + let waiter_lease = lease.clone(); + + tokio::spawn(async move { + let status = tokio::select! { + status = child.wait() => status, + _ = &mut teardown_rx => { + #[cfg(unix)] + waiter_lease.terminate().await; + #[cfg(not(unix))] + let _ = child.start_kill(); + child.wait().await + } + }; + #[cfg(unix)] + { + // The direct CLI is reaped, but tools/grandchildren could + // still occupy its group. The watchdog anchor proves the pgid + // is still ours, so a final group KILL is safe and leaves no + // detached descendants. Keep the liveness writer open until + // AFTER the watchdog is gone so it cannot race this cleanup. + waiter_lease + .finish_plain_group(&mut watchdog, parent_liveness) + .await; + } + #[cfg(not(unix))] + waiter_lease.mark_exited(); + let _ = exit_tx.send(exit_info_from(status)); + }); + + Ok(SpawnedProcess { + pid, + stdout: stdout_rx, + stderr: stderr_rx, + exit: exit_rx, + lease, + owner: ProcessOwner::new(teardown_tx), + }) + } + + async fn pump(mut reader: R, tx: mpsc::Sender) { + use tokio::io::AsyncReadExt; + let mut buf = vec![0u8; 8192]; + loop { + match reader.read(&mut buf).await { + Ok(0) => return, + Ok(n) => { + if tx.send(Bytes::copy_from_slice(&buf[..n])).await.is_err() { + return; + } + } + Err(_) => return, + } + } + } + + fn exit_info_from(status: std::io::Result) -> ExitInfo { + match status { + Ok(s) => shell_exit_code(s), + Err(_) => ExitInfo { code: 1 }, + } + } + + #[cfg(unix)] + fn shell_exit_code(status: std::process::ExitStatus) -> ExitInfo { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + return ExitInfo { code: 128 + sig }; + } + ExitInfo { + code: status.code().unwrap_or(1), + } + } + + #[cfg(not(unix))] + fn shell_exit_code(status: std::process::ExitStatus) -> ExitInfo { + ExitInfo { + code: status.code().unwrap_or(1), + } + } +} + +// --------------------------------------------------------------------------- +// PTY path (implemented for completeness per the Phase 2 TODO's "one spawn +// abstraction" ask; NOT the path harness dispatch takes — see module doc) +// --------------------------------------------------------------------------- + +mod pty { + use super::*; + use portable_pty::{native_pty_system, Child, CommandBuilder, ExitStatus, PtySize}; + use std::io::Read; + + /// 3 attempts, 50ms × attempt-number backoff between them — same + /// numbers as `pty-spawn.ts::spawnPty`'s retry loop. + const MAX_ATTEMPTS: u32 = 3; + + pub struct Built { + pub pid: u32, + pub child: Box, + pub reader: Box, + /// Kept alive for the process's lifetime — dropping the PTY + /// pair's master half can tear down the slave side prematurely on + /// some platforms. + pub _master: Box, + } + + /// BLOCKING — must be called from `tokio::task::spawn_blocking` (both + /// `openpty`/`spawn_command` and the retry loop's backoff sleep are + /// synchronous). + pub fn spawn_pty_with_retry(req: &SpawnRequest) -> Result { + let mut last_err = None; + for attempt in 0..MAX_ATTEMPTS { + match spawn_pty_once(req) { + Ok(built) => return Ok(built), + Err(e) => { + last_err = Some(e); + std::thread::sleep(Duration::from_millis(50 * u64::from(attempt + 1))); + } + } + } + Err(last_err.unwrap_or_else(|| SpawnError::Pty("unknown PTY failure".to_string()))) + } + + fn spawn_pty_once(req: &SpawnRequest) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 30, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| SpawnError::Pty(e.to_string()))?; + + let (program, args) = req + .argv + .split_first() + .ok_or_else(|| SpawnError::Pty("empty argv".to_string()))?; + let mut cmd = CommandBuilder::new(program); + for a in args { + cmd.arg(a); + } + if let Some(cwd) = &req.cwd { + cmd.cwd(cwd); + } + cmd.env("TERM", "xterm-256color"); + for (k, v) in &req.env { + cmd.env(k, v); + } + + let child = pair + .slave + .spawn_command(cmd) + .map_err(|e| SpawnError::Pty(e.to_string()))?; + let pid = child.process_id().unwrap_or(0); + // The slave fd isn't needed once the child has it open. + drop(pair.slave); + let reader = pair + .master + .try_clone_reader() + .map_err(|e| SpawnError::Pty(e.to_string()))?; + + Ok(Built { + pid, + child, + reader, + _master: pair.master, + }) + } + + /// Bridges the blocking `portable_pty::Child`/`Read` API to the same + /// async `SpawnedProcess` shape the plain path returns — a dedicated + /// OS thread per stream (portable-pty's reader/wait calls are + /// blocking; there is no async portable-pty API to await instead). + pub fn finish_async(built: Built) -> SpawnedProcess { + let Built { + pid, + mut child, + mut reader, + _master, + } = built; + + let (stdout_tx, stdout_rx) = mpsc::channel::(256); + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + if stdout_tx + .blocking_send(Bytes::copy_from_slice(&buf[..n])) + .is_err() + { + return; + } + } + Err(_) => return, + } + } + }); + + // stderr is never populated in PTY mode — the OS already merged + // it into the master fd read above. An immediately-closed channel + // gives `run.rs` a uniform `SpawnedProcess` shape regardless of + // which path was taken. + let (_stderr_tx, stderr_rx) = mpsc::channel::(1); + + let lease = ProcessLease::new(pid, None); + let waiter_lease = lease.clone(); + let (exit_tx, exit_rx) = oneshot::channel(); + std::thread::spawn(move || { + let status = child.wait(); + waiter_lease.mark_exited_blocking(); + let _ = exit_tx.send(exit_info_from_pty(status)); + }); + + SpawnedProcess { + pid, + stdout: stdout_rx, + stderr: stderr_rx, + exit: exit_rx, + lease, + // Harness dispatch always uses plain pipes. PTY remains a fallback + // utility and its blocking portable-pty waiter has no async owner + // channel; explicit `kill` remains available through the lease. + owner: ProcessOwner::inert(), + } + } + + /// Maps `portable_pty::ExitStatus` to the shell-convention + /// `128 + signal` for a signal-terminated child. UNLIKE the plain-pipe + /// path (`std::os::unix::process::ExitStatusExt::signal()`, a clean + /// numeric accessor), `portable_pty::ExitStatus::exit_code()` on a + /// signal-killed child is always just `1` (see 0.9.0's + /// `From` — it discards the real code once + /// a signal is present) — the crate only exposes the signal via + /// `signal() -> Option<&str>`, a human `strsignal(3)`-derived + /// description, NOT a bare number. Verified empirically on THIS + /// platform (macOS 26 / Darwin 25, the v1 ship target per the desktop migration contract): + /// `strsignal` here always suffixes the description with `: ` + /// (`"Terminated: 15"`, `"Killed: 9"`, `"Interrupt: 2"`) — see this + /// module's `pty_spawn_captures_output_and_signal_exit_code` test, + /// which exercises this exact parse against a real spawned+signaled + /// child rather than a canned string. Falls back to `exit_code()` + /// (never panics/errors) if a future platform's `strsignal` output + /// doesn't end in a parseable number — the PTY path isn't + /// harness-dispatch's default anyway (see module doc), so a + /// less-precise code on a hypothetical odd platform is an acceptable + /// degradation, not a correctness blocker. + fn exit_info_from_pty(status: std::io::Result) -> ExitInfo { + match status { + Ok(s) => ExitInfo { + code: signal_number_from_description(s.signal()) + .map(|sig| 128 + sig) + .unwrap_or(s.exit_code() as i32), + }, + Err(_) => ExitInfo { code: 1 }, + } + } + + /// Parses the trailing `: ` off a `strsignal`-derived description + /// (see `exit_info_from_pty`'s doc comment). `None` for `None` input + /// or a description that doesn't end in `: `. + fn signal_number_from_description(description: Option<&str>) -> Option { + let (_, tail) = description?.rsplit_once(':')?; + tail.trim().parse::().ok() + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parses_macos_strsignal_style_descriptions() { + assert_eq!( + signal_number_from_description(Some("Terminated: 15")), + Some(15) + ); + assert_eq!(signal_number_from_description(Some("Killed: 9")), Some(9)); + assert_eq!( + signal_number_from_description(Some("Interrupt: 2")), + Some(2) + ); + } + + #[test] + fn none_and_unparseable_descriptions_yield_none() { + assert_eq!(signal_number_from_description(None), None); + assert_eq!(signal_number_from_description(Some("Terminated")), None); + assert_eq!(signal_number_from_description(Some("")), None); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn collect_stdout(mut rx: mpsc::Receiver) -> String { + let mut out = Vec::new(); + while let Some(chunk) = rx.recv().await { + out.extend_from_slice(&chunk); + } + String::from_utf8_lossy(&out).into_owned() + } + + #[tokio::test] + async fn plain_spawn_captures_stdout_and_clean_exit() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hello-plain".to_string(), + ]); + let mut proc = spawn(req).await.expect("spawn should succeed"); + let out = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + assert_eq!(out.trim(), "hello-plain"); + let exit = proc.exit.await.expect("exit info"); + assert_eq!(exit.code, 0); + } + + #[tokio::test] + async fn plain_spawn_keeps_stdout_and_stderr_separate() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo out-line; echo err-line 1>&2".to_string(), + ]); + let mut proc = spawn(req).await.expect("spawn should succeed"); + let stdout = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + let stderr = collect_stdout(std::mem::replace(&mut proc.stderr, mpsc::channel(1).1)).await; + assert_eq!(stdout.trim(), "out-line"); + assert_eq!(stderr.trim(), "err-line"); + } + + #[tokio::test] + async fn plain_spawn_maps_nonzero_exit_code() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "exit 7".to_string(), + ]); + let proc = spawn(req).await.expect("spawn should succeed"); + let exit = proc.exit.await.expect("exit info"); + assert_eq!(exit.code, 7); + } + + #[tokio::test] + async fn plain_spawn_maps_signal_termination_to_128_plus_signal() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "kill -TERM $$; sleep 5".to_string(), + ]); + let proc = spawn(req).await.expect("spawn should succeed"); + let exit = proc.exit.await.expect("exit info"); + assert_eq!(exit.code, 128 + 15 /* SIGTERM */); + } + + #[tokio::test] + async fn plain_spawn_stdin_is_closed_not_inherited() { + // A `cat` with no args reads stdin until EOF; if stdin were + // inherited (a live pipe/tty) this would hang forever instead of + // exiting immediately — the very hazard this module's doc comment + // documents for codex's `exec` subcommand. + let req = SpawnRequest::new(vec!["/bin/cat".to_string()]); + let proc = spawn(req).await.expect("spawn should succeed"); + let exit = tokio::time::timeout(Duration::from_secs(5), proc.exit) + .await + .expect("must not hang waiting on stdin") + .expect("exit info"); + assert_eq!(exit.code, 0); + } + + #[tokio::test] + async fn kill_reaches_a_process_group_grandchild() { + // The direct child backgrounds a `sleep`, then blocks forever + // itself — killing only the PGID (not just the direct pid) is + // what reaps the grandchild too. We assert on the direct child's + // own exit here (simpler to observe from this process); the + // process-group targeting itself is exercised structurally by + // `process_group(0)` in `plain::spawn_plain` plus this kill call + // both operating on the same value. + let dir = tempfile::tempdir().unwrap(); + let lifetime_lock_path = dir.path().join("child-lifetime.lock"); + let mut req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30 & wait".to_string(), + ]); + req.lifetime_lock_path = Some(lifetime_lock_path.clone()); + let proc = spawn(req).await.expect("spawn should succeed"); + // Give the shell a moment to background the sleep. + tokio::time::sleep(Duration::from_millis(100)).await; + let contender = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&lifetime_lock_path) + .expect("open child lifetime fence"); + assert!(matches!( + contender.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + proc.kill(Signal::Kill).await; + let exit = tokio::time::timeout(Duration::from_secs(5), proc.exit) + .await + .expect("kill must terminate the group promptly") + .expect("exit info"); + assert_eq!(exit.code, 128 + 9 /* SIGKILL */); + let successor = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&lifetime_lock_path) + .expect("open post-reap child lifetime fence"); + successor + .try_lock() + .expect("watchdog releases shared fence only after group reap"); + } + + #[tokio::test] + async fn pty_spawn_captures_output_and_signal_exit_code() { + let mut req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hello-pty; kill -TERM $$; sleep 5".to_string(), + ]); + req.pty = true; + let mut proc = spawn(req).await.expect("spawn should succeed"); + let out = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + assert!( + out.contains("hello-pty"), + "expected PTY output to contain the echoed line, got {out:?}" + ); + let exit = proc.exit.await.expect("exit info"); + assert_eq!( + exit.code, + 128 + 15, + "portable-pty's exit_code() should already encode 128+signal" + ); + } + + #[tokio::test] + async fn pty_spawn_falls_back_to_plain_pipes_on_an_unresolvable_program() { + // `native_pty_system().openpty()` itself will succeed (opening the + // pty pair doesn't depend on the child program); it's + // `spawn_command` that fails for a program that can't be found — + // exercising the SAME fallback-on-spawn-failure path a genuine + // `openpty` exhaustion would take, without needing to actually + // exhaust the system's PTY table in a unit test. + let mut req = SpawnRequest::new(vec!["/definitely/not/a/real/binary/xyz".to_string()]); + req.pty = true; + // Either path (PTY retried-then-failed-then-plain-fallback, or + // plain directly) surfaces the SAME observable outcome: a spawn + // error, because the plain fallback also can't find the program. + // This asserts the fallback doesn't panic/hang and DOES surface + // an error rather than silently succeeding. + let result = spawn(req).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn env_vars_are_honored() { + let mut req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo \"$MY_VAR\"".to_string(), + ]); + req.env + .push(("MY_VAR".to_string(), "hello-env".to_string())); + let mut proc = spawn(req).await.expect("spawn should succeed"); + let out = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + assert_eq!(out.trim(), "hello-env"); + } + + #[tokio::test] + async fn cwd_is_honored() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("marker.txt"), b"x").unwrap(); + let mut req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "ls".to_string(), + ]); + req.cwd = Some(dir.path().to_path_buf()); + let mut proc = spawn(req).await.expect("spawn should succeed"); + let out = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + assert_eq!(out.trim(), "marker.txt"); + } +} diff --git a/apps/native/crates/harness/src/tiers.rs b/apps/native/crates/harness/src/tiers.rs new file mode 100644 index 0000000000..5e19d5a5df --- /dev/null +++ b/apps/native/crates/harness/src/tiers.rs @@ -0,0 +1,209 @@ +//! Model-tier tables — ported VERBATIM (same ids/labels) from +//! `packages/harness/src/{claude-code,codex}/model/agent-tiers.ts` +//! (`CLAUDE_CODE_TIERS` / `CODEX_TIERS`, keyed by `ChatTier = +//! "fast"|"smart"|"thinking"`), plus the composite-id → CLI `--model` flag +//! resolvers ported from +//! `packages/harness/src/{claude-code,codex}/model/index.ts` +//! (`resolveClaudeCodeModelId` / `resolveCodexModelId`). +//! +//! This is the canonical Rust copy — `crates/local-api/src/routes/models.rs` +//! (`GET /models`) and `crates/harness/src/run.rs` (building the CLI's +//! `--model` argument) both read through here so the two can never drift +//! independently the way two hand-duplicated copies eventually would. +//! +//! Cross-language duplication point: this table is hand-transcribed from +//! the TS source above, not generated. If a build-time codegen step +//! (TS → Rust literal) is ever wanted, that's a `Cargo.toml`/build-script +//! change — an interface request against the workspace owner, not +//! something this crate's implementer should add unilaterally (see +//! the native module-ownership contract's "Models / Tiers" section). + +use serde::Serialize; + +use crate::resolve::HarnessId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct ModelTier { + pub id: &'static str, + pub label: &'static str, +} + +/// Order matches `ChatTier` fast → smart → thinking, which is also the +/// order the contract doc's `GET /models` example JSON uses. +pub const CLAUDE_CODE_TIERS: [ModelTier; 3] = [ + ModelTier { + id: "claude-code:haiku", + label: "Haiku 4.5", + }, + ModelTier { + id: "claude-code:sonnet", + label: "Sonnet 5", + }, + ModelTier { + id: "claude-code:opus-1m", + label: "Opus 5 1M", + }, +]; + +pub const CODEX_TIERS: [ModelTier; 3] = [ + ModelTier { + id: "codex:gpt-5.6-luna", + label: "GPT-5.6 Luna", + }, + ModelTier { + id: "codex:gpt-5.6-terra", + label: "GPT-5.6 Terra", + }, + ModelTier { + id: "codex:gpt-5.6-sol", + label: "GPT-5.6 Sol", + }, +]; + +/// The static tier catalog for `harness` (fast, smart, thinking order). +pub fn tiers_for(harness: HarnessId) -> &'static [ModelTier; 3] { + match harness { + HarnessId::ClaudeCode => &CLAUDE_CODE_TIERS, + HarnessId::Codex => &CODEX_TIERS, + } +} + +/// Composite id (`"claude-code:sonnet"`) → the claude CLI's `--model` flag +/// value (`"claude-sonnet-5"`). Mirrors `resolveClaudeCodeModelId` +/// (`packages/harness/src/claude-code/model/index.ts`) EXACTLY, including +/// its passthrough-on-unknown fallback: the ai-sdk-provider-claude-code +/// SDK only recognizes the short aliases "opus"/"sonnet"/"haiku"; models +/// without a short alias use the CLI's full model id. +pub fn resolve_claude_model_id(model_id: &str) -> String { + match model_id { + "claude-code:opus" => "opus", + "claude-code:opus-1m" => "opus[1m]", + "claude-code:sonnet" => "claude-sonnet-5", + "claude-code:haiku" => "haiku", + "claude-code:fable" => "claude-fable-5", + other => return other.to_string(), + } + .to_string() +} + +/// Composite id → the codex CLI's `--model` flag value. Mirrors +/// `resolveCodexModelId` (`packages/harness/src/codex/model/index.ts`) +/// EXCEPT that function throws on an unrecognized id; this returns a +/// best-effort fallback (strip the `codex:` prefix) instead. Dispatch's +/// pre-stream gates already validated `models.thinking.id` is *a* string, +/// not that it's a KNOWN one — an unresolvable id here should reach the +/// CLI and let ITS `--model` validation reject it (surfacing as a +/// `harness_crashed` stream error with the CLI's own message), not crash +/// this resolver into a local-api 500. +pub fn resolve_codex_model_id(model_id: &str) -> String { + match model_id { + "codex:gpt-5.6-sol" => "gpt-5.6-sol", + "codex:gpt-5.6-terra" => "gpt-5.6-terra", + "codex:gpt-5.6-luna" => "gpt-5.6-luna", + "codex:gpt-5.5" => "gpt-5.5", + "codex:gpt-5.4" => "gpt-5.4", + "codex:gpt-5.4-mini" => "gpt-5.4-mini", + "codex:gpt-5.3-codex-spark" => "gpt-5.3-codex-spark", + other => return other.strip_prefix("codex:").unwrap_or(other).to_string(), + } + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn harness_order_is_claude_code_then_codex() { + assert_eq!(HarnessId::ALL[0].wire_id(), "claude-code"); + assert_eq!(HarnessId::ALL[1].wire_id(), "codex"); + } + + #[test] + fn every_tier_id_is_prefixed_with_its_harness() { + for harness in HarnessId::ALL { + for tier in tiers_for(harness) { + assert!( + tier.id.starts_with(&format!("{}:", harness.wire_id())), + "{} does not start with {}:", + tier.id, + harness.wire_id() + ); + assert!(!tier.label.is_empty()); + } + } + } + + #[test] + fn claude_model_ids_match_agent_tiers_ts_verbatim() { + assert_eq!(CLAUDE_CODE_TIERS[0].id, "claude-code:haiku"); + assert_eq!(CLAUDE_CODE_TIERS[0].label, "Haiku 4.5"); + assert_eq!(CLAUDE_CODE_TIERS[1].id, "claude-code:sonnet"); + assert_eq!(CLAUDE_CODE_TIERS[1].label, "Sonnet 5"); + assert_eq!(CLAUDE_CODE_TIERS[2].id, "claude-code:opus-1m"); + assert_eq!(CLAUDE_CODE_TIERS[2].label, "Opus 5 1M"); + } + + #[test] + fn codex_model_ids_match_agent_tiers_ts_verbatim() { + assert_eq!(CODEX_TIERS[0].id, "codex:gpt-5.6-luna"); + assert_eq!(CODEX_TIERS[0].label, "GPT-5.6 Luna"); + assert_eq!(CODEX_TIERS[1].id, "codex:gpt-5.6-terra"); + assert_eq!(CODEX_TIERS[1].label, "GPT-5.6 Terra"); + assert_eq!(CODEX_TIERS[2].id, "codex:gpt-5.6-sol"); + assert_eq!(CODEX_TIERS[2].label, "GPT-5.6 Sol"); + } + + #[test] + fn resolve_claude_model_id_maps_known_composites() { + assert_eq!(resolve_claude_model_id("claude-code:opus"), "opus"); + assert_eq!(resolve_claude_model_id("claude-code:opus-1m"), "opus[1m]"); + assert_eq!( + resolve_claude_model_id("claude-code:sonnet"), + "claude-sonnet-5" + ); + assert_eq!(resolve_claude_model_id("claude-code:haiku"), "haiku"); + assert_eq!( + resolve_claude_model_id("claude-code:fable"), + "claude-fable-5" + ); + } + + #[test] + fn resolve_claude_model_id_passes_through_unknown_ids() { + assert_eq!( + resolve_claude_model_id("claude-code:some-future-model"), + "claude-code:some-future-model" + ); + } + + #[test] + fn resolve_codex_model_id_maps_known_composites() { + assert_eq!(resolve_codex_model_id("codex:gpt-5.6-sol"), "gpt-5.6-sol"); + assert_eq!( + resolve_codex_model_id("codex:gpt-5.6-terra"), + "gpt-5.6-terra" + ); + assert_eq!(resolve_codex_model_id("codex:gpt-5.6-luna"), "gpt-5.6-luna"); + } + + #[test] + fn resolve_codex_model_id_strips_prefix_for_unknown_ids() { + assert_eq!( + resolve_codex_model_id("codex:some-future-model"), + "some-future-model" + ); + // No prefix at all — passes through verbatim. + assert_eq!(resolve_codex_model_id("bare-model-name"), "bare-model-name"); + } + + #[test] + fn every_tier_composite_id_resolves_to_a_non_empty_cli_model() { + for tier in CLAUDE_CODE_TIERS { + assert!(!resolve_claude_model_id(tier.id).is_empty()); + } + for tier in CODEX_TIERS { + assert!(!resolve_codex_model_id(tier.id).is_empty()); + } + } +} diff --git a/apps/native/crates/harness/src/title.rs b/apps/native/crates/harness/src/title.rs new file mode 100644 index 0000000000..124f11ffac --- /dev/null +++ b/apps/native/crates/harness/src/title.rs @@ -0,0 +1,297 @@ +//! Harness-owned thread titles, generated by the CLI's own fast model. +//! +//! Port of `packages/harness/src/title-generator.ts`, which the cluster runs +//! and the desktop previously had no counterpart for. Both sides share the +//! design that matters: the title is produced by the SAME CLI (and therefore +//! the same account and credentials) that runs the turn, using that CLI's +//! cheapest model — `claude-code:haiku`, `codex:gpt-5.6-luna`, the exact ids +//! `claude-code/index.ts` and `codex/index.ts` pass to `genTitle`. Nothing +//! here spends Studio credits or reaches the cluster. +//! +//! The differences from the TypeScript are forced by the boundary, not chosen: +//! it calls the model through the AI SDK's `generateObject` with a Zod schema, +//! while this can only exec the CLI and read stdout. So the schema becomes an +//! instruction to answer in JSON plus a tolerant parse, and `retry` becomes a +//! single attempt — the caller already has a deterministic title on screen, so +//! a failure here costs a nicer name, never a name. + +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use serde_json::Value; +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +use crate::resolve::{resolve_argv, HarnessId}; + +/// Verbatim from `TITLE_GENERATOR_PROMPT`, with the final instruction kept — +/// the TypeScript gets its JSON from `generateObject`'s schema, this asks for +/// it in words, and [`extract_title`] tolerates a model that answers in prose +/// anyway. +const TITLE_PROMPT: &str = r#"Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this session. Use sentence case: capitalize only the first word and proper nouns. + +Good examples: +- Fix login button on mobile +- Add OAuth authentication +- Query product catalog data +- Set up event subscriptions + +Bad (too vague): Help with task +Bad (too long): Investigate and fix the issue where the login button does not respond on mobile devices +Bad (wrong case): Fix Login Button On Mobile + +Respond with a JSON object containing the title. + +Session: +"#; + +/// `TITLE_GEN_TIMEOUT_MS`. A slow title model must never keep the thread on +/// the fallback name for the whole run. +pub const TITLE_TIMEOUT: Duration = Duration::from_secs(20); + +/// Longest title kept, matching the TypeScript's `.slice(0, 60)`. +const MAX_TITLE_CHARS: usize = 60; + +/// How much of the message the title model is shown. The CLI takes its prompt +/// on the command line, and a whole pasted file there is both a fork-time cost +/// and useless to a 3-7 word title. +const MAX_PROMPT_CHARS: usize = 2000; + +/// The cheapest model each CLI offers, by the same wire ids the TypeScript +/// harnesses pass to `genTitle`. +fn title_model(harness: HarnessId) -> &'static str { + match harness { + HarnessId::ClaudeCode => "haiku", + HarnessId::Codex => "gpt-5.6-luna", + } +} + +/// A title for `user_message`, or `None` when the CLI is unavailable, fails, +/// answers with nothing usable, or runs past `timeout`. +/// +/// `None` is not an error worth surfacing — the caller keeps the deterministic +/// title it already showed. +pub async fn generate_title( + harness: HarnessId, + cwd: &Path, + user_message: &str, + timeout: Duration, +) -> Option { + let mut argv = resolve_argv(harness).ok()?; + let binary = argv.remove(0); + let prompt = format!( + "{TITLE_PROMPT}{}", + user_message + .chars() + .take(MAX_PROMPT_CHARS) + .collect::() + ); + append_title_args(harness, &mut argv, &prompt); + + let mut command = Command::new(&binary); + command + .args(&argv) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = command.spawn().ok()?; + let mut stdout = child.stdout.take()?; + + let collected = tokio::time::timeout(timeout, async { + let mut buffer = String::new(); + stdout.read_to_string(&mut buffer).await.ok()?; + let _ = child.wait().await; + Some(buffer) + }) + .await + .ok() + .flatten()?; + + extract_title(harness, &collected) +} + +/// One-shot, read-only, cheapest model. Mirrors the `toolApprovalLevel: +/// "readonly", isPlanMode: true` the TypeScript builds its title model with — +/// a title call must never be able to touch the worktree it is named after. +fn append_title_args(harness: HarnessId, argv: &mut Vec, prompt: &str) { + let model = title_model(harness).to_string(); + match harness { + HarnessId::ClaudeCode => { + argv.push("-p".to_string()); + argv.push(prompt.to_string()); + argv.push("--output-format".to_string()); + argv.push("json".to_string()); + argv.push("--model".to_string()); + argv.push(model); + // No MCP config, and the user's own servers stay out: a title call + // needs no tools at all. + argv.push("--strict-mcp-config".to_string()); + argv.push("--permission-mode".to_string()); + argv.push("bypassPermissions".to_string()); + argv.push("--disallowedTools".to_string()); + argv.push( + [ + "Write", + "Edit", + "Bash", + "NotebookEdit", + "AskUserQuestion", + "ExitPlanMode", + "EnterWorktree", + "ExitWorktree", + "Config", + ] + .join(","), + ); + } + HarnessId::Codex => { + argv.push("exec".to_string()); + argv.push("--json".to_string()); + argv.push("--skip-git-repo-check".to_string()); + argv.push("--model".to_string()); + argv.push(model); + argv.push("--sandbox".to_string()); + argv.push("read-only".to_string()); + argv.push(prompt.to_string()); + } + } +} + +/// The assistant's answer, dug out of whichever envelope the CLI used. +fn assistant_text(harness: HarnessId, stdout: &str) -> Option { + match harness { + // `--output-format json` is ONE object whose `result` is the text. + HarnessId::ClaudeCode => serde_json::from_str::(stdout.trim()) + .ok()? + .get("result")? + .as_str() + .map(str::to_string), + // `--json` is a JSONL event stream; the answer is the last completed + // `agent_message` item. + HarnessId::Codex => stdout + .lines() + .filter_map(|line| serde_json::from_str::(line.trim()).ok()) + .filter_map(|event| { + let item = event.get("item")?; + if item.get("type").and_then(Value::as_str) != Some("agent_message") { + return None; + } + item.get("text").and_then(Value::as_str).map(str::to_string) + }) + .next_back(), + } +} + +/// Sanitizer, ported from `genTitle`'s post-processing: drop trailing +/// punctuation, clamp to 60 characters, trim, and keep it only if it holds a +/// letter or a digit in any script. +fn sanitize_title(raw: &str) -> Option { + let title = raw + .trim() + .trim_matches(|c| c == '"' || c == '\'' || c == '`') + .trim_end_matches(['.', '!', '?']) + .chars() + .take(MAX_TITLE_CHARS) + .collect::() + .trim() + .to_string(); + title.chars().any(char::is_alphanumeric).then_some(title) +} + +/// The title inside the assistant's answer. +/// +/// Prefers the requested `{"title": …}` — possibly inside a ```json fence, and +/// possibly with prose around it — and otherwise treats the whole answer as +/// the title, which is what a model that ignored the JSON instruction gives. +fn extract_title(harness: HarnessId, stdout: &str) -> Option { + let text = assistant_text(harness, stdout)?; + let from_json = text + .find('{') + .zip(text.rfind('}')) + .filter(|(start, end)| start < end) + .and_then(|(start, end)| serde_json::from_str::(&text[start..=end]).ok()) + .and_then(|value| { + value + .get("title") + .and_then(Value::as_str) + .map(str::to_string) + }); + sanitize_title(&from_json.unwrap_or(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_claude_json_envelope_yields_the_title_inside_it() { + let stdout = serde_json::json!({ + "type": "result", + "subtype": "success", + "result": "{\"title\": \"Fix login button on mobile\"}" + }) + .to_string(); + assert_eq!( + extract_title(HarnessId::ClaudeCode, &stdout).as_deref(), + Some("Fix login button on mobile") + ); + } + + #[test] + fn a_codex_event_stream_yields_its_last_agent_message() { + let stdout = [ + r#"{"type":"thread.started","thread_id":"t"}"#, + r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"thinking"}}"#, + r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"{\"title\":\"Add OAuth authentication\"}"}}"#, + ] + .join("\n"); + assert_eq!( + extract_title(HarnessId::Codex, &stdout).as_deref(), + Some("Add OAuth authentication") + ); + } + + /// A model that ignores the JSON instruction still gets used — the answer + /// itself is the title. + #[test] + fn a_bare_or_fenced_answer_is_still_a_title() { + let bare = serde_json::json!({"result": "Query product catalog data."}).to_string(); + assert_eq!( + extract_title(HarnessId::ClaudeCode, &bare).as_deref(), + Some("Query product catalog data") + ); + let fenced = serde_json::json!({ + "result": "Here you go:\n```json\n{\"title\": \"Set up event subscriptions\"}\n```" + }) + .to_string(); + assert_eq!( + extract_title(HarnessId::ClaudeCode, &fenced).as_deref(), + Some("Set up event subscriptions") + ); + } + + #[test] + fn unusable_answers_and_broken_output_produce_no_title() { + for answer in ["", " ", "...", "!!!", "🙂"] { + let stdout = serde_json::json!({ "result": answer }).to_string(); + assert_eq!( + extract_title(HarnessId::ClaudeCode, &stdout), + None, + "{answer:?}" + ); + } + assert_eq!(extract_title(HarnessId::ClaudeCode, "not json"), None); + assert_eq!(extract_title(HarnessId::Codex, "not json"), None); + } + + #[test] + fn a_long_title_is_clamped_and_quotes_are_stripped() { + let long = "a".repeat(200); + let stdout = serde_json::json!({ "result": format!("\"{long}\"") }).to_string(); + let title = extract_title(HarnessId::ClaudeCode, &stdout).unwrap(); + assert_eq!(title.chars().count(), MAX_TITLE_CHARS); + } +} diff --git a/apps/native/crates/harness/src/watchdog.rs b/apps/native/crates/harness/src/watchdog.rs new file mode 100644 index 0000000000..13524b5bf5 --- /dev/null +++ b/apps/native/crates/harness/src/watchdog.rs @@ -0,0 +1,318 @@ +//! The ONE home for the parent-liveness watchdog and its anchored +//! process-group machinery, shared by this crate's plain-pipe spawn path +//! and `local-api`'s `ProcessGroupChild`. +//! +//! Why this module exists: both crates anchor every spawned workload to an +//! independent `/bin/sh` watchdog that (a) pins the PGID until the group is +//! proven empty, (b) owns the shared child-lifetime lock as an exec-inherited +//! descriptor, and (c) TERM→KILLs survivors when the parent's liveness pipe +//! hits EOF. The shell script encoding those guarantees and the +//! enumerate-then-signal helper that must never touch the anchor used to be +//! duplicated per crate — a one-sided edit to either copy would silently skew +//! crash-recovery semantics between harness CLI runs and local-api tasks. +//! Keeping one copy here (the crate both consumers already depend on) closes +//! that drift channel; only the argv0 label differs per caller so `ps` output +//! still attributes each anchor to its family. +//! +//! Script contract (pinned by this module's tests): +//! - ignores TERM itself, so graceful TERM reaches the workload while the +//! ownership anchor survives for a later KILL/reap; +//! - blocks on its stdin liveness pipe; EOF starts TERM rounds, then KILL +//! rounds, against every non-anchor group member; +//! - exits 0 only once `pgrep` proves no non-anchor member remains — which is +//! also when its inherited shared lifetime lock is finally released; +//! - an enumeration error parks it forever: an indeterminate cleanup fails +//! closed rather than unblocking durable recovery. +//! +//! macOS note: `pgrep` excludes its own ancestors, so from inside the +//! watchdog `pgrep -g $$ .` enumerates only the sibling workload and its +//! descendants, never the anchor. The explicit `.` pattern is required by BSD +//! `pgrep`. + +use crate::spawn::Signal; + +#[cfg(unix)] +const PARENT_LIVENESS_WATCHDOG: &str = r#" +trap '' TERM +while IFS= read -r _; do :; done + +term_round=0 +while [ "$term_round" -lt 20 ]; do + members="$(pgrep -g "$$" . 2>/dev/null)" + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + if [ "$status" -ne 0 ]; then + while :; do sleep 60; done + fi + found=0 + for pid in $members; do + if [ "$pid" -eq "$$" ]; then continue; fi + found=1 + kill -TERM "$pid" 2>/dev/null || true + done + if [ "$found" -eq 0 ]; then exit 0; fi + term_round=$((term_round + 1)) + sleep 0.05 +done + +while :; do + members="$(pgrep -g "$$" . 2>/dev/null)" + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + if [ "$status" -ne 0 ]; then + while :; do sleep 60; done + fi + found=0 + for pid in $members; do + if [ "$pid" -eq "$$" ]; then continue; fi + found=1 + kill -KILL "$pid" 2>/dev/null || true + done + if [ "$found" -eq 0 ]; then exit 0; fi + sleep 0.05 +done +"#; + +/// A freshly spawned group anchor: the watchdog child (the process-group +/// leader — its pid IS the group id), the liveness-pipe writer whose drop/EOF +/// triggers the escalating teardown, and the group id workloads must join via +/// `process_group(group_id)`. +#[cfg(unix)] +pub struct SpawnedAnchor { + pub child: tokio::process::Child, + pub parent_liveness: tokio::process::ChildStdin, + pub group_id: u32, +} + +/// Opens the shared child-lifetime lock file (creating it `0o600`) and takes +/// a shared advisory lock. The caller hands the locked `File` to +/// [`spawn_anchor`], making the anchor its sole owner as an exec-inherited +/// stdout descriptor — a replacement server taking an exclusive lock is then +/// provably serialized behind every old anchor's group reap. +#[cfg(unix)] +pub fn open_shared_lifetime_lock(path: &std::path::Path) -> std::io::Result { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create(true).mode(0o600); + let file = options.open(path)?; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.try_lock_shared().map_err(|error| match error { + std::fs::TryLockError::Error(error) => error, + std::fs::TryLockError::WouldBlock => std::io::ErrorKind::WouldBlock.into(), + })?; + Ok(file) +} + +/// Spawns the watchdog as an independent process-group leader. `argv0` labels +/// the anchor in `ps` output so each consuming family stays attributable +/// (e.g. `decocms-harness-watchdog` vs `decocms-local-api-watchdog`). +/// `kill_on_drop` is intentionally false: on abrupt runtime death the +/// liveness pipe, not Tokio's child drop path, lets the watchdog reap the +/// whole group. +#[cfg(unix)] +pub fn spawn_anchor( + argv0: &str, + lifetime_lock: Option, +) -> std::io::Result { + use std::process::Stdio; + + let mut command = tokio::process::Command::new("/bin/sh"); + command + .arg("-c") + .arg(PARENT_LIVENESS_WATCHDOG) + .arg(argv0) + .stdin(Stdio::piped()) + // The locked File (when given) moves into an exec-open descriptor; + // the anchor is now the fence's sole owner, including after the + // parent is SIGKILLed. + .stdout(lifetime_lock.map(Stdio::from).unwrap_or_else(Stdio::null)) + .stderr(Stdio::null()) + .process_group(0) + .kill_on_drop(false); + let mut child = command.spawn()?; + let group_id = child + .id() + .ok_or_else(|| std::io::Error::other("group-anchor watchdog reported no pid"))?; + let parent_liveness = child + .stdin + .take() + .ok_or_else(|| std::io::Error::other("group-anchor watchdog stdin was not piped"))?; + Ok(SpawnedAnchor { + child, + parent_liveness, + group_id, + }) +} + +/// Signals every current member of the anchored group except the anchor +/// itself. `kill -SIG -` is deliberately forbidden here: it would hit +/// the watchdog (the group leader), close its inherited shared lifetime-lock +/// descriptor, and release the restart fence before resistant descendants +/// were proven gone. Enumeration is immediate and never persisted; the +/// still-live anchor keeps the group id owned, so a stale caller cannot +/// later target a recycled process group. +/// +/// Returns `true` when every enumerated member was signaled (or the group was +/// already empty — `pgrep` exit 1). Any indeterminate enumeration sends +/// nothing and returns `false`, leaving the watchdog's EOF path to fail +/// closed. Blocking: callers on an async runtime wrap this in +/// `spawn_blocking`. +#[cfg(unix)] +pub fn signal_non_anchor_members(group_id: u32, anchor_id: u32, signal: Signal) -> bool { + use std::process::Stdio; + + let output = match std::process::Command::new("pgrep") + .args(["-g", &group_id.to_string(), "."]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + { + Ok(output) => output, + Err(error) => { + tracing::error!(%error, group_id, "cannot enumerate anchored process group"); + return false; + } + }; + if !output.status.success() { + // `pgrep` exits 1 when it matched nothing: a valid empty-group + // observation. All other nonzero statuses are indeterminate. + if output.status.code() != Some(1) { + tracing::error!( + status = ?output.status.code(), + group_id, + "indeterminate anchored process-group enumeration" + ); + } + return output.status.code() == Some(1); + } + + let mut all_signaled = true; + for pid in String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .filter_map(|raw| raw.parse::().ok()) + .filter(|pid| *pid != anchor_id) + { + let signaled = std::process::Command::new("kill") + .arg(signal.flag()) + .arg(pid.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + all_signaled &= signaled; + } + all_signaled +} + +/// The anchored watchdog exists only on Unix. Kept as a compile-time +/// counterpart because async callers branch on an `Option` anchor id that is +/// always `None` off-Unix. +#[cfg(not(unix))] +pub fn signal_non_anchor_members(_group_id: u32, _anchor_id: u32, _signal: Signal) -> bool { + false +} + +#[cfg(all(test, unix))] +mod tests { + use std::time::Duration; + + use super::*; + + /// Pins the script's core contract: the anchor holds the shared lifetime + /// fence while alive, ignores TERM, and after liveness-pipe EOF exits 0 + /// (releasing the fence) only once its group has no non-anchor member. + #[tokio::test] + async fn anchor_holds_the_fence_and_exits_only_after_liveness_eof() { + let dir = tempfile::tempdir().unwrap(); + let lock_path = dir.path().join("child-lifetime.lock"); + let lock = open_shared_lifetime_lock(&lock_path).expect("open shared fence"); + let SpawnedAnchor { + mut child, + parent_liveness, + group_id, + } = spawn_anchor("decocms-watchdog-pin-test", Some(lock)).expect("spawn anchor"); + + let contender = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&lock_path) + .expect("open fence contender"); + assert!( + matches!(contender.try_lock(), Err(std::fs::TryLockError::WouldBlock)), + "a live anchor must hold the shared lifetime fence" + ); + + // TERM is ignored: the anchor must survive to perform escalation. + // Give the freshly spawned `/bin/sh` time to reach its `trap '' + // TERM` line first — the guarantee starts once the script runs, and + // TERMing the pid before that would only race shell startup. + tokio::time::sleep(Duration::from_millis(300)).await; + let termed = std::process::Command::new("kill") + .args(["-TERM", &group_id.to_string()]) + .status() + .is_ok_and(|status| status.success()); + assert!(termed, "TERM the anchor"); + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + child.try_wait().expect("try_wait anchor").is_none(), + "the anchor must ignore TERM" + ); + + // EOF with an otherwise-empty group: exit 0 and release the fence. + drop(parent_liveness); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("anchor exited after liveness EOF") + .expect("anchor wait succeeded"); + assert!(status.success()); + contender + .try_lock() + .expect("fence becomes claimable only after the anchor exits"); + } + + /// Pins the enumerate-then-signal helper: it reaps a workload joined to + /// the anchored group but never signals the anchor itself. + #[tokio::test] + async fn signal_non_anchor_members_spares_the_anchor() { + let SpawnedAnchor { + mut child, + parent_liveness, + group_id, + } = spawn_anchor("decocms-watchdog-signal-test", None).expect("spawn anchor"); + + let mut workload_cmd = tokio::process::Command::new("sleep"); + workload_cmd + .arg("30") + .process_group(i32::try_from(group_id).expect("group id fits i32")) + .kill_on_drop(true); + let mut workload = workload_cmd.spawn().expect("join workload to the group"); + + let group_id_for_signal = group_id; + let all_signaled = tokio::task::spawn_blocking(move || { + signal_non_anchor_members(group_id_for_signal, group_id_for_signal, Signal::Kill) + }) + .await + .expect("signal task completed"); + assert!(all_signaled, "the workload must be signaled"); + + let status = tokio::time::timeout(Duration::from_secs(5), workload.wait()) + .await + .expect("workload reaped after group signal") + .expect("workload wait succeeded"); + assert!(!status.success(), "the workload died to the signal"); + assert!( + child.try_wait().expect("try_wait anchor").is_none(), + "the anchor must never be signaled with its group" + ); + + drop(parent_liveness); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; + } +} diff --git a/apps/native/crates/local-api/Cargo.toml b/apps/native/crates/local-api/Cargo.toml new file mode 100644 index 0000000000..a4af4e6711 --- /dev/null +++ b/apps/native/crates/local-api/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "local-api" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +# Phase 3 addition: a thin `start()`/`boot_from_env()` lib surface so the +# Tauri shell (`apps/native/src-tauri/`) can embed this server in-process +# (per the desktop migration contract's in-process decision) instead +# of spawning it as a subprocess. Mechanical extraction from `main.rs` — see +# `src/lib.rs`'s module doc. The `local-api` BINARY (below) is unchanged in +# behavior; it now just calls into this lib for its boot sequence. +name = "local_api" +path = "src/lib.rs" + +[[bin]] +name = "local-api" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +axum-server = { workspace = true } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +# Phase 2: harness detection/tiers/spawn/stream-translation — see +# `crates/harness`'s own Cargo.toml doc comment for why its PTY dependency +# lives there instead of the shared `[workspace.dependencies]` table. This +# is a path dep (in-workspace crate), not a version to keep in sync. +harness = { path = "../harness" } +upstream = { path = "../upstream" } +axum = { workspace = true } +tokio = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +rusqlite = { workspace = true } +reqwest = { workspace = true } +globset = { workspace = true } +walkdir = { workspace = true } +ignore = { workspace = true } +notify = { workspace = true } +regex = { workspace = true } +subtle = { workspace = true } +rand = { workspace = true } +futures = { workspace = true } +futures-util = { workspace = true } +bytes = { workspace = true } +urlencoding = { workspace = true } +uuid = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +async-trait = { workspace = true } +tokio-tungstenite = { workspace = true } +# Crate-local, NOT in the shared workspace table — same "single-consumer dep +# stays local" precedent `crates/upstream/Cargo.toml`'s own `sha2` entry +# documents. Used by `sandbox/manager.rs` to derive the per-handle +# `-` sandbox directory name (prod-parity shape). +sha2 = "0.10" + +[dev-dependencies] +tempfile = { workspace = true } +# Same path dep as the `[dependencies]` entry above, with its `test-support` +# feature enabled — Cargo unifies these into one compiled `upstream` +# instance for test builds, giving `routes/upstream.rs`'s tests access to +# `upstream::tokens::test_support::MemoryTokenStore` (a `#[cfg(test)]`-only +# module in `upstream` otherwise invisible to a downstream crate's own +# tests — see that crate's Cargo.toml `[features] test-support` doc). +upstream = { path = "../upstream", features = ["test-support"] } diff --git a/apps/native/crates/local-api/build.rs b/apps/native/crates/local-api/build.rs new file mode 100644 index 0000000000..52e71f4854 --- /dev/null +++ b/apps/native/crates/local-api/build.rs @@ -0,0 +1,48 @@ +//! Bakes the web bundle's version into the binary as `STUDIO_WEB_VERSION`. +//! +//! The webview compares `GET /api/config`'s `config.version` against its own +//! build-time `__STUDIO_VERSION__` and nags "A new version is ready" when they +//! drift (`apps/web/src/components/version-check-dialog.tsx`). Vite injects +//! that constant from `apps/api/package.json` (see `apps/web/vite.config.ts`'s +//! `define`), and the desktop bundles that same Vite output — so reading the +//! very same file here is what makes the two agree, and lets +//! `routes/upstream.rs` answer the poll with the version actually running +//! inside this app instead of whatever the upstream deployment happens to be +//! serving. +//! +//! Parsed by hand rather than with `serde_json` to keep this crate free of +//! build-dependencies; the field is a plain string in a generated file. + +use std::path::Path; + +fn main() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + // apps/native/crates/local-api -> apps/api/package.json + let package_json = Path::new(&manifest_dir).join("../../../api/package.json"); + + println!("cargo:rerun-if-changed={}", package_json.display()); + + let version = std::fs::read_to_string(&package_json) + .ok() + .and_then(|contents| parse_version(&contents)); + + // Fail open: an unreadable/renamed manifest must not break the build. The + // proxy treats an empty value as "don't rewrite" and passes upstream's + // version through unchanged (today's behavior). + println!( + "cargo:rustc-env=STUDIO_WEB_VERSION={}", + version.unwrap_or_default() + ); +} + +/// Pulls the top-level `"version": "…"` string out of a package.json. +fn parse_version(contents: &str) -> Option { + let start = contents.find("\"version\"")?; + let rest = &contents[start + "\"version\"".len()..]; + let colon = rest.find(':')?; + let after_colon = &rest[colon + 1..]; + let open = after_colon.find('"')?; + let value = &after_colon[open + 1..]; + let close = value.find('"')?; + Some(value[..close].to_string()) +} diff --git a/apps/native/crates/local-api/src/auth.rs b/apps/native/crates/local-api/src/auth.rs new file mode 100644 index 0000000000..1bc2dc17e2 --- /dev/null +++ b/apps/native/crates/local-api/src/auth.rs @@ -0,0 +1,101 @@ +//! Bearer-token auth — byte-parity with +//! `packages/sandbox/daemon/auth.ts::requireToken` / +//! `constantTimeEqual` (see `auth.test.ts` for the exact matrix pinned +//! here: matching token accepts, wrong token / no header / non-`Bearer` +//! scheme / empty configured token all reject). +//! +//! Route exemption: unlike the daemon (which leaves `/_sandbox/idle`, +//! `/_sandbox/scripts`, `/_sandbox/events` GETs, and OPTIONS preflight +//! unauthenticated), local-api's contract +//! (the native local-API contract) tightens this to +//! "every route requires the bearer except `GET /health`". `router.rs` +//! enforces that exemption structurally (by never applying this module's +//! middleware to the `/health` route) rather than this module special- +//! casing paths — keeps the auth primitive itself path-agnostic and +//! testable in isolation. + +use axum::http::HeaderMap; +use subtle::ConstantTimeEq; + +use crate::error::ApiError; + +const BEARER_PREFIX: &str = "Bearer "; + +/// Validate `Authorization: Bearer ` against `expected` in constant +/// time. `Ok(())` on match; `Err(ApiError::unauthorized())` otherwise. An +/// empty `expected` NEVER matches — fail closed, never treat an unset +/// server-side token as "auth disabled". +pub fn require_bearer(headers: &HeaderMap, expected: &str) -> Result<(), ApiError> { + let header = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let Some(provided) = header.strip_prefix(BEARER_PREFIX) else { + return Err(ApiError::unauthorized()); + }; + if expected.is_empty() || !constant_time_eq(provided.as_bytes(), expected.as_bytes()) { + return Err(ApiError::unauthorized()); + } + Ok(()) +} + +pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + // `subtle::ConstantTimeEq` already short-circuits safely on length + // mismatch (it compares up to the shorter length and folds the length + // check itself into the constant-time result), but being explicit here + // documents the invariant without depending on that implementation + // detail staying true across a `subtle` upgrade. + if a.len() != b.len() { + return false; + } + a.ct_eq(b).into() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::{HeaderMap, HeaderValue}; + + const TOKEN: &str = "test-token-32-chars-min-aaaaaaaa"; + + fn headers_with_auth(value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_str(value).unwrap(), + ); + h + } + + #[test] + fn accepts_matching_bearer() { + let headers = headers_with_auth(&format!("Bearer {TOKEN}")); + assert!(require_bearer(&headers, TOKEN).is_ok()); + } + + #[test] + fn rejects_wrong_token() { + let headers = headers_with_auth("Bearer wrong-token"); + let err = require_bearer(&headers, TOKEN).unwrap_err(); + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); + assert_eq!(err.body["error"], "unauthorized"); + } + + #[test] + fn rejects_missing_header() { + let headers = HeaderMap::new(); + assert!(require_bearer(&headers, TOKEN).is_err()); + } + + #[test] + fn rejects_non_bearer_scheme() { + let headers = headers_with_auth(TOKEN); + assert!(require_bearer(&headers, TOKEN).is_err()); + } + + #[test] + fn empty_expected_token_rejects_everything() { + let headers = headers_with_auth("Bearer "); + assert!(require_bearer(&headers, "").is_err()); + } +} diff --git a/apps/native/crates/local-api/src/client_auth.rs b/apps/native/crates/local-api/src/client_auth.rs new file mode 100644 index 0000000000..a101e5306d --- /dev/null +++ b/apps/native/crates/local-api/src/client_auth.rs @@ -0,0 +1,535 @@ +use std::sync::{Arc, Mutex}; + +use axum::http::{header, HeaderMap, Method}; + +use crate::auth::{constant_time_eq, require_bearer}; +use crate::error::ApiError; + +pub(crate) const LOCAL_SESSION_COOKIE_NAME: &str = "decocms-local-session"; + +/// The header carrying the MOUNT-scoped credential — see +/// [`ClientAuth::mount_token_matches`]. A header, not a cookie: the only +/// presenter is `rclone`, which has no jar, and keeping it out of the jar means +/// no browser can ever be tricked into attaching it. +pub(crate) const MOUNT_TOKEN_HEADER: &str = "x-decocms-mount-token"; + +#[derive(Clone)] +pub(crate) enum ClientAuth { + Bearer { token: Arc }, + Embedded { session: Arc }, +} + +/// Removes only local-api's control credential, preserving every unrelated +/// application cookie. Call this at the Studio upstream-proxy boundary, never +/// in the shared guard: sandbox routes and the preview proxy carry application +/// Cookie/Authorization end-to-end. +pub(crate) fn remove_local_session_cookie(headers: &mut HeaderMap) { + let retained = headers + .get_all(header::COOKIE) + .iter() + .filter_map(|header| header.to_str().ok()) + .flat_map(|header| header.split(';')) + .map(str::trim) + .filter(|pair| { + pair.split_once('=') + .is_none_or(|(name, _)| name != LOCAL_SESSION_COOKIE_NAME) + }) + .filter(|pair| !pair.is_empty()) + .map(str::to_string) + .collect::>(); + headers.remove(header::COOKIE); + if !retained.is_empty() { + if let Ok(value) = retained.join("; ").parse() { + headers.insert(header::COOKIE, value); + } + } +} + +pub(crate) struct EmbeddedSession { + /// Every `Host` authority this process answers on. The FIRST is canonical — + /// the browser-facing origin, the one `control_origin` must agree with. + /// The rest are additional authorities for the SAME server, which exist + /// because the browser may reach it through a dev proxy on another port + /// while non-browser clients in this machine talk to the listener directly. + expected_hosts: Vec>, + control_origin: Arc, + session_token: Arc, + mount_token: Arc, + bootstrap_secrets: Mutex>>, +} + +impl ClientAuth { + pub(crate) fn bearer(token: impl Into>) -> Self { + Self::Bearer { + token: token.into(), + } + } + + /// `session_token`: the value the control cookie carries. Supplied by the + /// caller (rather than minted here) so it can be PERSISTED across + /// launches — see `stable_session_token` in `lib.rs`. Minting it per + /// launch invalidated every cookie the webview already held, so after any + /// backend restart a live page 401'd on every request, forever, with no + /// path back: the frontend's bootstrap runs once at module init, so + /// nothing re-established the session until the user manually reloaded. + /// + /// `expected_hosts`: the browser-facing authority FIRST (it is the one + /// checked against `control_origin`), then any additional authority the + /// same server answers on — see [`EmbeddedSession::expected_hosts`]. + /// + /// `mount_token`: the narrow credential for the org-filesystem WebDAV + /// surface — see [`Self::mount_token_matches`]. + pub(crate) fn embedded( + expected_hosts: Vec, + control_origin: String, + bootstrap_secrets: Vec, + session_token: String, + mount_token: String, + ) -> Result { + let Some(expected_host) = expected_hosts.first() else { + return Err("embedded auth requires at least one expected host".into()); + }; + validate_control_identity(expected_host, &control_origin)?; + if expected_hosts.iter().any(String::is_empty) { + return Err("embedded auth expected_host cannot be empty".into()); + } + if mount_token.is_empty() { + return Err("embedded auth requires a non-empty mount token".into()); + } + let mut unique_secrets = Vec::::new(); + for secret in bootstrap_secrets + .into_iter() + .filter(|secret| !secret.is_empty()) + { + if !unique_secrets.iter().any(|existing| existing == &secret) { + unique_secrets.push(secret); + } + } + let bootstrap_secrets = unique_secrets + .into_iter() + .map(String::into_bytes) + .collect::>(); + if bootstrap_secrets.is_empty() { + return Err("embedded auth requires at least one non-empty bootstrap secret".into()); + } + + Ok(Self::Embedded { + session: Arc::new(EmbeddedSession { + expected_hosts: expected_hosts.into_iter().map(Arc::from).collect(), + control_origin: control_origin.into(), + session_token: session_token.into(), + mount_token: mount_token.into(), + bootstrap_secrets: Mutex::new(bootstrap_secrets), + }), + }) + } + + pub(crate) fn is_embedded(&self) -> bool { + matches!(self, Self::Embedded { .. }) + } + + /// The control credential, for the one non-browser caller that genuinely + /// needs the whole API: the agent harness' MCP endpoint, which exists so + /// the CLI can call the agent's own tools. It must satisfy the same guard + /// as the webview and has no cookie jar to be handed one through. + /// + /// Anything narrower gets [`Self::mount_token_matches`] instead. + /// + /// `None` in bearer mode, where callers authenticate with the bearer + /// instead. + pub(crate) fn session_token(&self) -> Option<&str> { + match self { + Self::Embedded { session } => Some(&session.session_token), + _ => None, + } + } + + /// Whether the request presents the mount-scoped credential — the one for + /// the caller that is neither a browser nor entitled to the whole API: the + /// `rclone` child serving the org filesystem. + /// + /// Never a substitute for [`Self::require_private`] on its own. The caller + /// pairs it with a path check so this is honoured on `/_sandbox/orgfs/*` + /// and NOWHERE else (see `router::authorize_private`) — a process that + /// reads it out of rclone's config gains the WebDAV view the user could + /// already browse, not `/_sandbox/bash`, and not the cluster session. + pub(crate) fn mount_token_matches(&self, headers: &HeaderMap) -> bool { + let Self::Embedded { session } = self else { + return false; + }; + headers + .get(MOUNT_TOKEN_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| constant_time_eq(value.as_bytes(), session.mount_token.as_bytes())) + } + + /// Host is a DNS-rebinding boundary in embedded mode. It deliberately + /// includes the port: debug requests can arrive through Vite while the + /// Rust listener itself is bound to a different loopback port — which is + /// exactly why this matches a SET rather than one value (see + /// [`EmbeddedSession::expected_hosts`]). + pub(crate) fn require_expected_host(&self, headers: &HeaderMap) -> Result<(), ApiError> { + let Self::Embedded { session } = self else { + return Ok(()); + }; + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + // Every candidate is compared, with no early exit, so the number of + // comparisons never depends on which one matched. + let matched = session + .expected_hosts + .iter() + .fold(false, |matched, expected| { + constant_time_eq(host.as_bytes(), expected.as_bytes()) | matched + }); + if matched { + Ok(()) + } else { + Err(ApiError::forbidden_origin()) + } + } + + /// SameSite is site-based, not origin-based. Unsafe embedded requests + /// therefore require the exact browser origin in addition to the session + /// cookie. Safe requests may omit Origin, but a present Origin is never + /// allowed to disagree (notably, WebSocket upgrades are GET requests and + /// are not protected by browser CORS). + pub(crate) fn require_unsafe_origin( + &self, + method: &Method, + headers: &HeaderMap, + ) -> Result<(), ApiError> { + let Self::Embedded { session } = self else { + return Ok(()); + }; + let safe = matches!( + *method, + Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE + ); + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()); + if (safe && origin.is_none()) + || origin.is_some_and(|origin| { + constant_time_eq(origin.as_bytes(), session.control_origin.as_bytes()) + }) + { + Ok(()) + } else { + Err(ApiError::forbidden_origin()) + } + } + + pub(crate) fn require_exact_origin(&self, headers: &HeaderMap) -> Result<(), ApiError> { + let Self::Embedded { session } = self else { + return Ok(()); + }; + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if constant_time_eq(origin.as_bytes(), session.control_origin.as_bytes()) { + Ok(()) + } else { + Err(ApiError::forbidden_origin()) + } + } + + pub(crate) fn require_private(&self, headers: &HeaderMap) -> Result<(), ApiError> { + match self { + Self::Bearer { token } => require_bearer(headers, token), + Self::Embedded { session } => { + let matches = cookie_values(headers, LOCAL_SESSION_COOKIE_NAME).any(|value| { + constant_time_eq(value.as_bytes(), session.session_token.as_bytes()) + }); + if matches { + Ok(()) + } else { + Err(ApiError::unauthorized()) + } + } + } + } + + /// Atomically consumes one matching bootstrap bearer. A failed attempt + /// never burns another window's capability. + pub(crate) fn consume_bootstrap(&self, headers: &HeaderMap) -> Result { + let Self::Embedded { session } = self else { + return Err(ApiError::not_found("Not found")); + }; + let provided = bearer_value(headers).ok_or_else(ApiError::unauthorized)?; + let mut secrets = session + .bootstrap_secrets + .lock() + .map_err(|_| ApiError::unauthorized())?; + let matching_index = secrets + .iter() + .position(|expected| constant_time_eq(provided.as_bytes(), expected)); + let Some(index) = matching_index else { + return Err(ApiError::unauthorized()); + }; + secrets.swap_remove(index); + + Ok(format!( + "{LOCAL_SESSION_COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Strict", + session.session_token + )) + } +} + +fn validate_control_identity(expected_host: &str, control_origin: &str) -> Result<(), String> { + if expected_host.is_empty() { + return Err("embedded auth expected_host cannot be empty".into()); + } + let uri = control_origin + .parse::() + .map_err(|error| format!("invalid embedded control_origin: {error}"))?; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || uri.authority().is_none() + || uri + .path_and_query() + .is_some_and(|path| path.as_str() != "/") + { + return Err("embedded control_origin must be an http(s) origin without a path".into()); + } + if uri.authority().map(|authority| authority.as_str()) != Some(expected_host) { + return Err("embedded control_origin authority must equal expected_host".into()); + } + Ok(()) +} + +fn bearer_value(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|value| !value.is_empty()) +} + +fn cookie_values<'a>( + headers: &'a HeaderMap, + expected_name: &'a str, +) -> impl Iterator { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|header| header.to_str().ok()) + .flat_map(|header| header.split(';')) + .filter_map(move |pair| { + let (name, value) = pair.trim().split_once('=')?; + (name == expected_name).then_some(value) + }) +} + +#[cfg(test)] +mod tests { + use axum::http::HeaderValue; + + use super::*; + + fn embedded() -> ClientAuth { + ClientAuth::embedded( + vec!["studio.localhost:43120".into()], + "http://studio.localhost:43120".into(), + vec!["bootstrap-a".into(), "bootstrap-b".into()], + "test-session-token".into(), + "test-mount-token".into(), + ) + .unwrap() + } + + fn bootstrap_headers(secret: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {secret}")).unwrap(), + ); + headers + } + + fn cookie_from_set_cookie(set_cookie: &str) -> String { + set_cookie.split(';').next().unwrap().to_string() + } + + #[test] + fn embedded_identity_requires_matching_host_and_origin_authority() { + assert!(ClientAuth::embedded( + vec!["studio.localhost:43120".into()], + "http://studio.localhost:43120".into(), + vec!["secret".into()], + "test-session-token".into(), + "test-mount-token".into(), + ) + .is_ok()); + assert!(ClientAuth::embedded( + vec!["studio.localhost:43121".into()], + "http://studio.localhost:4420".into(), + vec!["secret".into()], + "test-session-token".into(), + "test-mount-token".into(), + ) + .is_err()); + assert!(ClientAuth::embedded( + vec!["studio.localhost:43120".into()], + "http://studio.localhost:43120/path".into(), + vec!["secret".into()], + "test-session-token".into(), + "test-mount-token".into(), + ) + .is_err()); + } + + #[test] + fn bootstrap_secret_is_one_time_and_other_secrets_survive_failed_attempts() { + let auth = embedded(); + assert!(auth.consume_bootstrap(&bootstrap_headers("wrong")).is_err()); + let first = auth + .consume_bootstrap(&bootstrap_headers("bootstrap-a")) + .unwrap(); + assert!(first.contains("HttpOnly")); + assert!(first.contains("SameSite=Strict")); + assert!(!first.contains("Domain=")); + assert!(auth + .consume_bootstrap(&bootstrap_headers("bootstrap-a")) + .is_err()); + assert!(auth + .consume_bootstrap(&bootstrap_headers("bootstrap-b")) + .is_ok()); + } + + #[test] + fn session_cookie_auth_accepts_only_the_server_generated_value() { + let auth = embedded(); + let set_cookie = auth + .consume_bootstrap(&bootstrap_headers("bootstrap-a")) + .unwrap(); + let cookie = cookie_from_set_cookie(&set_cookie); + + let mut good = HeaderMap::new(); + good.insert(header::COOKIE, HeaderValue::from_str(&cookie).unwrap()); + assert!(auth.require_private(&good).is_ok()); + + let mut wrong = HeaderMap::new(); + wrong.insert( + header::COOKIE, + HeaderValue::from_static("decocms-local-session=attacker-selected"), + ); + assert!(auth.require_private(&wrong).is_err()); + } + + #[test] + fn embedded_host_and_unsafe_origin_are_exact() { + let auth = embedded(); + let mut headers = HeaderMap::new(); + headers.insert( + header::HOST, + HeaderValue::from_static("studio.localhost:43120"), + ); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("http://studio.localhost:43120"), + ); + assert!(auth.require_expected_host(&headers).is_ok()); + assert!(auth.require_unsafe_origin(&Method::POST, &headers).is_ok()); + + headers.insert( + header::HOST, + HeaderValue::from_static("studio.localhost:43121"), + ); + assert!(auth.require_expected_host(&headers).is_err()); + + headers.insert( + header::ORIGIN, + HeaderValue::from_static("http://attacker.localhost:43120"), + ); + assert!(auth.require_unsafe_origin(&Method::GET, &headers).is_err()); + assert!(auth.require_unsafe_origin(&Method::POST, &headers).is_err()); + + headers.remove(header::ORIGIN); + assert!(auth.require_unsafe_origin(&Method::GET, &headers).is_ok()); + assert!(auth.require_unsafe_origin(&Method::POST, &headers).is_err()); + } + + /// One server, two authorities: the browser reaches it through the dev + /// proxy, loopback subprocesses reach the listener directly. Both are the + /// same server, so both are accepted — and nothing else is. Only the FIRST + /// has to agree with `control_origin`, which is why the listener authority + /// can carry a different port. + #[test] + fn every_configured_authority_is_accepted_and_no_other() { + let auth = ClientAuth::embedded( + vec![ + "studio.localhost:4420".into(), + "studio.localhost:43120".into(), + ], + "http://studio.localhost:4420".into(), + vec!["secret".into()], + "test-session-token".into(), + "test-mount-token".into(), + ) + .unwrap(); + let host = |value: &'static str| { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, HeaderValue::from_static(value)); + headers + }; + assert!(auth + .require_expected_host(&host("studio.localhost:4420")) + .is_ok()); + assert!(auth + .require_expected_host(&host("studio.localhost:43120")) + .is_ok()); + assert!(auth + .require_expected_host(&host("studio.localhost:43121")) + .is_err()); + assert!(auth + .require_expected_host(&host("studio.localhost")) + .is_err()); + assert!(auth.require_expected_host(&HeaderMap::new()).is_err()); + } + + #[test] + fn the_mount_token_is_matched_exactly_and_only_from_its_own_header() { + let auth = embedded(); + let mut headers = HeaderMap::new(); + assert!(!auth.mount_token_matches(&headers)); + headers.insert( + MOUNT_TOKEN_HEADER, + HeaderValue::from_static("test-mount-token"), + ); + assert!(auth.mount_token_matches(&headers)); + headers.insert(MOUNT_TOKEN_HEADER, HeaderValue::from_static("nope")); + assert!(!auth.mount_token_matches(&headers)); + // The session cookie is NOT a mount credential, and vice versa. + let mut cookie_only = HeaderMap::new(); + cookie_only.insert( + header::COOKIE, + HeaderValue::from_static("decocms-local-session=test-session-token"), + ); + assert!(!auth.mount_token_matches(&cookie_only)); + let mut mount_only = HeaderMap::new(); + mount_only.insert( + MOUNT_TOKEN_HEADER, + HeaderValue::from_static("test-mount-token"), + ); + assert!(auth.require_private(&mount_only).is_err()); + } + + #[test] + fn removing_control_cookie_preserves_unrelated_application_cookies() { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_static( + "sandbox-session=abc; decocms-local-session=secret; theme=dark", + ), + ); + remove_local_session_cookie(&mut headers); + assert_eq!( + headers.get(header::COOKIE).unwrap(), + "sandbox-session=abc; theme=dark" + ); + } +} diff --git a/apps/native/crates/local-api/src/config/classify.rs b/apps/native/crates/local-api/src/config/classify.rs new file mode 100644 index 0000000000..157442e86c --- /dev/null +++ b/apps/native/crates/local-api/src/config/classify.rs @@ -0,0 +1,365 @@ +//! Pure: derive the single highest-impact transition between two configs. +//! Byte-parity port of `packages/sandbox/daemon/config-store/classify.ts`. +//! +//! Precedence (highest first): identity-conflict, then bootstrap, +//! branch-change, runtime-change, pm-change, port-change, env-change, +//! git-credential-refresh, and finally no-op. +//! +//! Callers MUST run [`crate::config::validate::validate_tenant_config`] on +//! `after` before calling this (same ordering as the TS store's `runOne`) — +//! classify assumes `after`'s fields are already well-typed (e.g. `branch` +//! is a string when present) and degrades a malformed field to "absent" +//! rather than panicking, which is a deliberate, harmless divergence from +//! the TS source (which would throw on some malformed inputs — see the +//! module doc on `validate.rs`). + +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Transition { + Bootstrap, + BranchChange { + from: Option, + to: String, + }, + PmChange { + from: Option, + to: Value, + }, + RuntimeChange { + from: Option, + to: String, + }, + PortChange { + from: Option, + to: Option, + }, + EnvChange { + set: Vec, + deleted: Vec, + }, + GitCredentialRefresh { + clone_url: String, + }, + IdentityConflict { + field: &'static str, + }, + NoOp, +} + +impl Transition { + /// The wire-shape `transition` string (`result.transition.kind` on the + /// TS side) — byte-parity with the daemon's `Transition["kind"]` union. + pub fn kind(&self) -> &'static str { + match self { + Transition::Bootstrap => "bootstrap", + Transition::BranchChange { .. } => "branch-change", + Transition::PmChange { .. } => "pm-change", + Transition::RuntimeChange { .. } => "runtime-change", + Transition::PortChange { .. } => "port-change", + Transition::EnvChange { .. } => "env-change", + Transition::GitCredentialRefresh { .. } => "git-credential-refresh", + Transition::IdentityConflict { .. } => "identity-conflict", + Transition::NoOp => "no-op", + } + } +} + +pub fn classify(before: Option<&Value>, after: &Value) -> Transition { + let before_url = before.and_then(|b| get_str_path(b, &["git", "repository", "cloneUrl"])); + let after_url = get_str_path(after, &["git", "repository", "cloneUrl"]); + + if let (Some(bu), Some(au)) = (&before_url, &after_url) { + if strip_credentials(bu) != strip_credentials(au) { + return Transition::IdentityConflict { field: "cloneUrl" }; + } + } + + // Byte-parity with the TS `after.application !== undefined` check: key + // PRESENCE, not truthiness — an explicit `application: null` patch (an + // edge case no schema forbids pre-merge) still counts as "meaningful". + let is_meaningful = after_url.is_some() || after.get("application").is_some(); + + let Some(before) = before else { + if is_meaningful { + return Transition::Bootstrap; + } + return match diff_env(None, after.get("env")) { + Some((set, deleted)) => Transition::EnvChange { set, deleted }, + None => Transition::NoOp, + }; + }; + + let before_branch = get_str_path(before, &["git", "repository", "branch"]); + if let Some(after_branch) = get_present_str(after, &["git", "repository", "branch"]) { + if Some(&after_branch) != before_branch.as_ref() { + return Transition::BranchChange { + from: before_branch, + to: after_branch, + }; + } + } + + let before_runtime = get_str_path(before, &["application", "runtime"]); + if let Some(after_runtime) = get_present_str(after, &["application", "runtime"]) { + if Some(&after_runtime) != before_runtime.as_ref() { + return Transition::RuntimeChange { + from: before_runtime, + to: after_runtime, + }; + } + } + + let before_pm = get_path(before, &["application", "packageManager"]); + if let Some(after_pm) = get_path(after, &["application", "packageManager"]) { + if !after_pm.is_null() { + let before_name = before_pm + .and_then(|p| p.get("name")) + .and_then(Value::as_str); + let after_name = after_pm.get("name").and_then(Value::as_str); + let before_path = before_pm + .and_then(|p| p.get("path")) + .and_then(Value::as_str); + let after_path = after_pm.get("path").and_then(Value::as_str); + if before_name != after_name || before_path != after_path { + return Transition::PmChange { + from: before_pm.cloned(), + to: after_pm.clone(), + }; + } + } + } + + let before_port = get_path(before, &["application", "port"]).and_then(Value::as_i64); + let after_port = get_path(after, &["application", "port"]).and_then(Value::as_i64); + if before_port != after_port { + return Transition::PortChange { + from: before_port, + to: after_port, + }; + } + + if let Some((set, deleted)) = diff_env(before.get("env"), after.get("env")) { + return Transition::EnvChange { set, deleted }; + } + + if let (Some(bu), Some(au)) = (&before_url, &after_url) { + if bu != au && strip_credentials(bu) == strip_credentials(au) { + return Transition::GitCredentialRefresh { + clone_url: au.clone(), + }; + } + } + + Transition::NoOp +} + +fn diff_env(before: Option<&Value>, after: Option<&Value>) -> Option<(Vec, Vec)> { + let b = before.and_then(Value::as_object); + let a = after.and_then(Value::as_object); + + let mut set = Vec::new(); + if let Some(a_obj) = a { + for (k, v) in a_obj { + let differs = match b.and_then(|bo| bo.get(k)) { + Some(bv) => bv != v, + None => true, + }; + if differs { + set.push(k.clone()); + } + } + } + let mut deleted = Vec::new(); + if let Some(b_obj) = b { + for k in b_obj.keys() { + if a.is_none_or(|ao| !ao.contains_key(k)) { + deleted.push(k.clone()); + } + } + } + if set.is_empty() && deleted.is_empty() { + None + } else { + Some((set, deleted)) + } +} + +/// The `cloneUrl` embeds an OAuth token (e.g. +/// `x-access-token:TOKEN@github.com/…`) that gets refreshed independently of +/// the repo identity — strip username/password before comparing so only the +/// actual repo path is guarded (byte-parity with `classify.ts::stripCredentials`). +fn strip_credentials(raw_url: &str) -> String { + match reqwest::Url::parse(raw_url) { + Ok(mut u) => { + let _ = u.set_username(""); + let _ = u.set_password(None); + u.to_string() + } + Err(_) => raw_url.to_string(), + } +} + +fn get_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut cur = v; + for k in path { + cur = cur.as_object()?.get(*k)?; + } + Some(cur) +} + +fn get_str_path(v: &Value, path: &[&str]) -> Option { + super::get_str(v, path).map(str::to_string) +} + +/// A field the TS side reads as `after.foo?.bar !== undefined` — present +/// (any non-null JSON value) AND actually a string. A present-but-wrong-type +/// value (which `validate_tenant_config` should already have rejected) is +/// treated as absent rather than causing a spurious transition. +fn get_present_str(v: &Value, path: &[&str]) -> Option { + let field = get_path(v, path)?; + if field.is_null() { + return None; + } + field.as_str().map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn null_to_null_is_no_op() { + assert_eq!(classify(None, &json!({})).kind(), "no-op"); + } + + #[test] + fn null_to_meaningful_clone_url_is_bootstrap() { + let after = json!({"git": {"repository": {"cloneUrl": "https://x.git"}}}); + assert_eq!(classify(None, &after).kind(), "bootstrap"); + } + + #[test] + fn null_to_meaningful_application_only_is_bootstrap() { + let after = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + assert_eq!(classify(None, &after).kind(), "bootstrap"); + } + + #[test] + fn clone_url_mismatch_is_identity_conflict() { + let before = + json!({"git": {"repository": {"cloneUrl": "https://github.com/org/repo-a.git"}}}); + let after = + json!({"git": {"repository": {"cloneUrl": "https://github.com/org/repo-b.git"}}}); + assert_eq!(classify(Some(&before), &after).kind(), "identity-conflict"); + } + + #[test] + fn clone_url_credential_only_change_is_not_identity_conflict() { + let before = json!({"git": {"repository": {"cloneUrl": "https://x-access-token:OLD@github.com/org/repo.git"}}}); + let after = json!({"git": {"repository": {"cloneUrl": "https://x-access-token:NEW@github.com/org/repo.git"}}}); + assert_ne!(classify(Some(&before), &after).kind(), "identity-conflict"); + } + + #[test] + fn clone_url_credential_only_change_is_git_credential_refresh() { + let before = json!({"git": {"repository": {"cloneUrl": "https://x-access-token:OLD@github.com/org/repo.git"}}}); + let after = json!({"git": {"repository": {"cloneUrl": "https://x-access-token:NEW@github.com/org/repo.git"}}}); + let t = classify(Some(&before), &after); + assert_eq!(t.kind(), "git-credential-refresh"); + if let Transition::GitCredentialRefresh { clone_url } = t { + assert_eq!( + clone_url, + "https://x-access-token:NEW@github.com/org/repo.git" + ); + } else { + panic!("expected git-credential-refresh"); + } + } + + #[test] + fn branch_change() { + let before = json!({"git": {"repository": {"cloneUrl": "x", "branch": "main"}}}); + let after = json!({"git": {"repository": {"cloneUrl": "x", "branch": "feature"}}}); + let t = classify(Some(&before), &after); + assert_eq!(t.kind(), "branch-change"); + if let Transition::BranchChange { from, to } = t { + assert_eq!(from.as_deref(), Some("main")); + assert_eq!(to, "feature"); + } else { + panic!("expected branch-change"); + } + } + + #[test] + fn runtime_change_without_pm() { + let before = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let after = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "bun"}}); + assert_eq!(classify(Some(&before), &after).kind(), "runtime-change"); + } + + #[test] + fn pm_name_change() { + let before = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let after = json!({"application": {"packageManager": {"name": "pnpm"}, "runtime": "node"}}); + assert_eq!(classify(Some(&before), &after).kind(), "pm-change"); + } + + #[test] + fn pm_path_change() { + let before = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let after = json!({"application": {"packageManager": {"name": "npm", "path": "apps/web"}, "runtime": "node"}}); + assert_eq!(classify(Some(&before), &after).kind(), "pm-change"); + } + + #[test] + fn port_change() { + let before = json!({"application": {"port": 3000}}); + let after = json!({"application": {"port": 5173}}); + assert_eq!(classify(Some(&before), &after).kind(), "port-change"); + } + + #[test] + fn identical_configs_is_no_op() { + let config = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + assert_eq!(classify(Some(&config), &config).kind(), "no-op"); + } + + #[test] + fn branch_and_pm_change_emits_the_higher_impact_one() { + let before = json!({ + "git": {"repository": {"cloneUrl": "x", "branch": "main"}}, + "application": {"packageManager": {"name": "npm"}, "runtime": "node"}, + }); + let after = json!({ + "git": {"repository": {"cloneUrl": "x", "branch": "feature"}}, + "application": {"packageManager": {"name": "pnpm"}, "runtime": "node"}, + }); + assert_eq!(classify(Some(&before), &after).kind(), "branch-change"); + } + + #[test] + fn env_added_is_env_change_with_key_names_only() { + let before = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let after = json!({ + "application": {"packageManager": {"name": "npm"}, "runtime": "node"}, + "env": {"STRIPE_KEY": "sk_test_secret"}, + }); + let t = classify(Some(&before), &after); + assert_eq!(t.kind(), "env-change"); + if let Transition::EnvChange { set, deleted } = t { + assert_eq!(set, vec!["STRIPE_KEY".to_string()]); + assert!(deleted.is_empty()); + } else { + panic!("expected env-change"); + } + } + + #[test] + fn port_change_beats_env_change() { + let before = json!({"application": {"port": 3000}, "env": {"FOO": "1"}}); + let after = json!({"application": {"port": 4000}, "env": {"FOO": "2"}}); + assert_eq!(classify(Some(&before), &after).kind(), "port-change"); + } +} diff --git a/apps/native/crates/local-api/src/config/merge.rs b/apps/native/crates/local-api/src/config/merge.rs new file mode 100644 index 0000000000..bda52b425a --- /dev/null +++ b/apps/native/crates/local-api/src/config/merge.rs @@ -0,0 +1,193 @@ +//! Deep-merge a `ConfigPatch`-shaped JSON value into the current +//! `TenantConfig`. Byte-parity port of +//! `packages/sandbox/daemon/config-store/merge.ts`. +//! +//! Semantics (unchanged from the TS source): +//! - field absent (key missing) -> leave existing +//! - field present -> set (nested objects merge field-by-field, arrays and +//! primitives replace wholesale) +//! - `env` is per-key: string -> upsert, `null` -> delete that key only + +use serde_json::{Map, Value}; + +/// `deepMerge(current, patch)` — always returns an object with only the +/// top-level keys that ended up with a value (mirrors `JSON.stringify` +/// silently dropping `undefined`-valued keys on the TS side). +pub fn deep_merge(current: Option<&Value>, patch: &Value) -> Value { + let base = current.and_then(Value::as_object); + let patch_obj = patch.as_object(); + + let mut out = Map::new(); + for field in ["git", "operator", "application"] { + if let Some(v) = merge_optional_field( + base.and_then(|b| b.get(field)), + patch_obj.and_then(|p| p.get(field)), + ) { + out.insert(field.to_string(), v); + } + } + if let Some(env) = merge_env( + base.and_then(|b| b.get("env")), + patch_obj.and_then(|p| p.get("env")), + ) { + out.insert("env".to_string(), env); + } + Value::Object(out) +} + +fn merge_env(current: Option<&Value>, patch: Option<&Value>) -> Option { + let Some(patch) = patch else { + return current.cloned(); + }; + let mut out: Map = current + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if let Some(patch_obj) = patch.as_object() { + for (k, v) in patch_obj { + if v.is_null() { + out.remove(k); + } else { + // Byte-parity with the TS `else (out as any)[k] = v` fallback + // for a non-string, non-null value — passed through verbatim + // so validation (not merge) is the layer that rejects it. + out.insert(k.clone(), v.clone()); + } + } + } + if out.is_empty() { + None + } else { + Some(Value::Object(out)) + } +} + +fn merge_optional_field(current: Option<&Value>, patch: Option<&Value>) -> Option { + let Some(patch) = patch else { + return current.cloned(); + }; + match current { + None => Some(patch.clone()), + Some(current) => Some(merge_objects(current, patch)), + } +} + +fn merge_objects(current: &Value, patch: &Value) -> Value { + let (Some(cur_obj), Some(patch_obj)) = (current.as_object(), patch.as_object()) else { + // Non-object patch (array/primitive) replaces wholesale, byte-parity + // with the TS `mergeOptional`'s `isPlainObject` guard. + return patch.clone(); + }; + let mut out = cur_obj.clone(); + for (k, v) in patch_obj { + let existing = cur_obj.get(k); + // `Value::as_object()` only matches the `Object` variant (never + // `Array`), so this already mirrors the TS `isPlainObject` guard + // (plain object on both sides, not an array) without a separate + // array check. + match (existing.and_then(Value::as_object), v.as_object()) { + (Some(existing_obj), Some(_)) => { + out.insert( + k.clone(), + merge_objects(&Value::Object(existing_obj.clone()), v), + ); + } + _ => { + out.insert(k.clone(), v.clone()); + } + } + } + Value::Object(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn returns_patch_when_current_is_none() { + let patch = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let merged = deep_merge(None, &patch); + assert_eq!(merged["application"]["packageManager"]["name"], "npm"); + } + + #[test] + fn preserves_fields_not_in_patch() { + let current = json!({ + "git": {"repository": {"cloneUrl": "x", "branch": "main"}}, + "application": {"packageManager": {"name": "npm"}, "runtime": "node"}, + }); + let patch = json!({"application": {"packageManager": {"name": "pnpm"}, "runtime": "node"}}); + let merged = deep_merge(Some(¤t), &patch); + assert_eq!(merged["git"]["repository"]["cloneUrl"], "x"); + assert_eq!(merged["application"]["packageManager"]["name"], "pnpm"); + } + + #[test] + fn preserves_operator_when_git_patch_omits_it() { + let current = json!({ + "operator": {"userName": "Jane Doe", "userEmail": "jane@example.com"}, + "git": {"repository": {"cloneUrl": "old"}}, + }); + let patch = json!({ + "git": { + "repository": {"cloneUrl": "new"}, + "identity": {"userName": "Bot", "userEmail": "bot@example.com"}, + }, + }); + let merged = deep_merge(Some(¤t), &patch); + assert_eq!( + merged["operator"], + json!({"userName": "Jane Doe", "userEmail": "jane@example.com"}) + ); + assert_eq!(merged["git"]["repository"]["cloneUrl"], "new"); + } + + #[test] + fn absent_fields_dont_overwrite_existing_ones() { + let current = json!({ + "application": {"packageManager": {"name": "npm"}, "runtime": "node", "port": 3000}, + }); + let patch = json!({"application": {"packageManager": {"name": "pnpm"}, "runtime": "node"}}); + let merged = deep_merge(Some(¤t), &patch); + assert_eq!(merged["application"]["port"], 3000); + assert_eq!(merged["application"]["packageManager"]["name"], "pnpm"); + } + + #[test] + fn env_upserts_keys_without_touching_siblings() { + let merged = deep_merge( + Some(&json!({"env": {"FOO": "1", "BAR": "2"}})), + &json!({"env": {"FOO": "x"}}), + ); + assert_eq!(merged["env"], json!({"FOO": "x", "BAR": "2"})); + } + + #[test] + fn env_deletes_a_key_when_its_value_is_null() { + let merged = deep_merge( + Some(&json!({"env": {"FOO": "1", "BAR": "2"}})), + &json!({"env": {"FOO": null}}), + ); + assert_eq!(merged["env"], json!({"BAR": "2"})); + } + + #[test] + fn env_collapses_to_absent_when_all_keys_are_deleted() { + let merged = deep_merge( + Some(&json!({"env": {"FOO": "1"}})), + &json!({"env": {"FOO": null}}), + ); + assert!(merged.get("env").is_none()); + } + + #[test] + fn env_preserved_when_patch_omits_it() { + let merged = deep_merge( + Some(&json!({"env": {"FOO": "1"}})), + &json!({"application": {"runtime": "node"}}), + ); + assert_eq!(merged["env"], json!({"FOO": "1"})); + } +} diff --git a/apps/native/crates/local-api/src/config/mod.rs b/apps/native/crates/local-api/src/config/mod.rs new file mode 100644 index 0000000000..d3931ecffb --- /dev/null +++ b/apps/native/crates/local-api/src/config/mod.rs @@ -0,0 +1,50 @@ +pub mod classify; +pub mod merge; +pub mod store; +pub mod validate; + +// `ConfigSnapshot`/`TenantConfig` aren't referenced outside `config::store` +// yet — `state.rs` (a shared file) only imports `ConfigStore`. Kept as a +// re-export (not dead in the "delete it" sense) so a future consumer of the +// documented `snapshot()` shape (see the native module-ownership contract) +// doesn't have to reach into `config::store` directly. +#[allow(unused_imports)] +pub use store::{ConfigSnapshot, ConfigStore, TenantConfig}; + +/// Borrows a nested string out of a tenant-config `Value` by key path — +/// `get_str(config, &["git", "repository", "branch"])`. `None` for a missing +/// key, a non-object on the way down, or a non-string leaf. +/// +/// The ONE copy for every consumer of the tenant-config JSON (setup's +/// clone/install/dev steps, `classify`); each step used to carry its own +/// verbatim walker, which is exactly how one step's idea of a config path +/// drifts from its siblings'. +pub(crate) fn get_str<'a>(config: &'a serde_json::Value, path: &[&str]) -> Option<&'a str> { + let mut cur = config; + for key in path { + cur = cur.get(key)?; + } + cur.as_str() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::get_str; + + #[test] + fn get_str_walks_objects_and_rejects_non_string_leaves() { + let config = json!({ + "git": { "repository": { "cloneUrl": "https://example.com/r.git" } }, + "application": { "port": 4000 }, + }); + assert_eq!( + get_str(&config, &["git", "repository", "cloneUrl"]), + Some("https://example.com/r.git") + ); + assert_eq!(get_str(&config, &["git", "repository", "branch"]), None); + assert_eq!(get_str(&config, &["application", "port"]), None); + assert_eq!(get_str(&config, &["application", "port", "deep"]), None); + } +} diff --git a/apps/native/crates/local-api/src/config/store.rs b/apps/native/crates/local-api/src/config/store.rs new file mode 100644 index 0000000000..eb14adf35b --- /dev/null +++ b/apps/native/crates/local-api/src/config/store.rs @@ -0,0 +1,290 @@ +//! `ConfigStore` — the shared handle behind `GET/POST /_sandbox/config`. +//! +//! Single-writer serial actor: `patch()` holds the lock across the whole +//! merge -> validate -> classify -> apply sequence, so two concurrent +//! `POST`/`PUT /_sandbox/config` calls compose deterministically (last +//! write wins on the same field) — the same guarantee the TS daemon gets +//! from `TenantConfigStore`'s FIFO apply queue (`config-store/store.ts`), +//! achieved here with a `Mutex` instead of an explicit queue since axum +//! handlers already run on a thread pool rather than a single event loop. +//! +//! Every OTHER family only ever calls the read-only accessors +//! (`snapshot()`, `is_configured()`) — for `/health`'s `configured` field, +//! the exec/scripts family's "409 when no package manager is configured +//! yet" gate, and git's "409 not-ready" gate. Only `routes/config.rs` calls +//! `patch()`. +//! +//! Byte-parity target: `packages/sandbox/daemon/config-store/` (`classify.ts` +//! precedence, `merge.ts` deep-merge, `validate.ts` field validation, +//! `store.ts`'s reject/apply/no-op branching). See `config/classify.rs`, +//! `config/merge.rs`, `config/validate.rs` for the ported pieces. + +use std::sync::Mutex; + +use serde_json::{Map, Value}; + +use crate::error::ApiError; + +use super::classify::{classify, Transition}; +use super::merge::deep_merge; +use super::validate::validate_tenant_config; + +/// Opaque JSON `TenantConfig` (`git`/`operator`/`application`/`env` keys) — +/// see the module doc on why this stays JSON rather than a typed struct. +pub type TenantConfig = Value; + +/// `GET /_sandbox/config`'s read model. `config` deliberately EXCLUDES `env` +/// (byte-parity with `routes/config.ts::stripDerived`, which strips it too) +/// — env is only ever exposed as key names via `env_keys`, never values. +/// +/// `configured` mirrors `is_configured()` and is part of the documented +/// bootstrap shape (the native module-ownership contract's `ConfigStore` +/// section: `{ config, env_keys, ready, configured }`) for any future +/// consumer of `snapshot()` as a whole — `routes/config.rs`'s `GET` handler +/// itself reads `is_configured()`'s twin `ready` field but doesn't echo +/// `configured` on the wire (not in the contract doc's GET response field +/// list), hence the allow. +#[allow(dead_code)] +#[derive(Debug, Clone, Default)] +pub struct ConfigSnapshot { + pub config: Option, + pub env_keys: Vec, + pub ready: bool, + pub configured: bool, +} + +/// A successful `patch()` — the shape `routes/config.rs` needs to build the +/// `{ bootId, transition, config }` response and decide whether to emit a +/// `"lifecycle"` broadcaster event (only on a non-`"no-op"` transition, +/// byte-parity with the TS store only notifying subscribers on a real +/// state change). +/// +/// Deliberate signature change from the bootstrap stub's +/// `patch(&self, Value) -> Result`: `ConfigSnapshot` +/// has no room for `transition`, and nothing outside this family calls +/// `patch()` (see the native module-ownership contract's config section), +/// so widening it here doesn't break another family's build. +#[derive(Debug, Clone)] +pub struct ApplyOutcome { + pub transition: &'static str, + /// The merged `TenantConfig` INCLUDING `env` values — byte-parity with + /// `routes/config.ts::makeApplyResponse`, which returns `result.after` + /// (the raw merged object) unstripped, unlike the `GET` handler. This + /// is the TS daemon's actual behavior, not a Rust-side embellishment; + /// see the module doc on `routes/config.rs` for the full reasoning. + pub config: Value, +} + +pub struct ConfigStore { + /// `None` until the first non-no-op `patch()` — mirrors + /// `TenantConfigStore.current` staying `null` through a `{}` patch on a + /// fresh store (see `classify`'s "null -> null = no-op" case). + current: Mutex>, +} + +impl ConfigStore { + pub fn new() -> Self { + Self { + current: Mutex::new(None), + } + } + + /// Current snapshot. Never errors — safe to call from any route, + /// including `/health` (no-auth) and other families' gating checks. + pub fn snapshot(&self) -> ConfigSnapshot { + let current = self.read(); + let config = current.as_ref().map(strip_env); + let env_keys = current + .as_ref() + .and_then(|c| c.get("env")) + .and_then(Value::as_object) + .map(sorted_keys) + .unwrap_or_default(); + let configured = current.is_some(); + ConfigSnapshot { + config, + env_keys, + // No setup/lifecycle pipeline to wait on (dropped family, see + // the contract doc's §C) — "ready" collapses onto "configured", + // the closest local-api has to the TS daemon's + // `lifecycle.current().phase === "running"`. A judgment call: + // no oracle test asserts `ready` becomes `true` post-configure, + // only that it starts `false` on a fresh store (satisfied + // either way). + ready: configured, + configured, + } + } + + pub fn is_configured(&self) -> bool { + self.read().is_some() + } + + /// Apply a `ConfigPatch`-shaped JSON value (already stripped of the + /// wire-only `auth` field by the caller). Byte-parity outcomes: + /// + /// - invalid merged config -> `400 {"error":"invalid"}` (the TS store + /// discards the detailed validation reason on the wire too — see + /// `REJECTION_REASONS.INVALID` in `config-store/types.ts`). + /// - identity conflict (immutable `cloneUrl` change) -> `409 + /// {"error":"immutable: cloneUrl"}`. + /// - no-op transition -> `200`, but internal state is NOT overwritten + /// (byte-parity with `store.ts::runOne`'s early return before + /// `this.current = enrich(merged)`). + /// - otherwise -> `200`, state updated to the merged config. + pub fn patch(&self, patch: Value) -> Result { + let mut guard = self.lock(); + let before = guard.clone(); + let merged = deep_merge(before.as_ref(), &patch); + + if validate_tenant_config(&merged).is_err() { + return Err(ApiError::bad_request("invalid")); + } + + let transition = classify(before.as_ref(), &merged); + if let Transition::IdentityConflict { field } = &transition { + return Err(ApiError::conflict(format!("immutable: {field}"))); + } + + if !matches!(transition, Transition::NoOp) { + *guard = Some(merged.clone()); + } + + Ok(ApplyOutcome { + transition: transition.kind(), + config: merged, + }) + } + + fn read(&self) -> std::sync::MutexGuard<'_, Option> { + self.lock() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + match self.current.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } + } +} + +impl Default for ConfigStore { + fn default() -> Self { + Self::new() + } +} + +/// `{ git, operator, application }` — byte-parity with +/// `routes/config.ts::stripDerived` (env is deliberately omitted; see +/// `ConfigSnapshot`'s doc comment). +fn strip_env(config: &TenantConfig) -> Value { + let mut out = Map::new(); + for key in ["git", "operator", "application"] { + if let Some(v) = config.get(key) { + out.insert(key.to_string(), v.clone()); + } + } + Value::Object(out) +} + +fn sorted_keys(obj: &Map) -> Vec { + let mut keys: Vec = obj.keys().cloned().collect(); + keys.sort(); + keys +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn fresh_store_is_unconfigured() { + let store = ConfigStore::new(); + assert!(!store.is_configured()); + let snap = store.snapshot(); + assert!(snap.config.is_none()); + assert!(snap.env_keys.is_empty()); + assert!(!snap.ready); + } + + #[test] + fn bootstrap_patch_configures_the_store() { + let store = ConfigStore::new(); + let outcome = store + .patch(json!({"application": {"packageManager": {"name": "npm"}}})) + .expect("apply ok"); + assert_eq!(outcome.transition, "bootstrap"); + assert!(store.is_configured()); + let snap = store.snapshot(); + assert_eq!( + snap.config.unwrap()["application"]["packageManager"]["name"], + "npm" + ); + } + + #[test] + fn empty_patch_on_fresh_store_stays_unconfigured() { + let store = ConfigStore::new(); + let outcome = store.patch(json!({})).expect("apply ok"); + assert_eq!(outcome.transition, "no-op"); + assert!(!store.is_configured()); + } + + #[test] + fn immutable_clone_url_change_is_rejected_with_409() { + let store = ConfigStore::new(); + store + .patch(json!({"git": {"repository": {"cloneUrl": "https://example.com/a.git"}}})) + .expect("first apply ok"); + let err = store + .patch(json!({"git": {"repository": {"cloneUrl": "https://example.com/b.git"}}})) + .expect_err("second apply should be rejected"); + assert_eq!(err.status, axum::http::StatusCode::CONFLICT); + assert_eq!(err.body["error"], "immutable: cloneUrl"); + } + + #[test] + fn invalid_patch_is_rejected_with_400_and_no_detail_leak() { + let store = ConfigStore::new(); + let err = store + .patch(json!({"application": {"runtime": "python"}})) + .expect_err("invalid runtime rejected"); + assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!(err.body["error"], "invalid"); + } + + #[test] + fn env_keys_round_trip_without_values() { + let store = ConfigStore::new(); + store + .patch(json!({"env": {"FOO": "bar", "BAZ": "qux"}})) + .expect("apply ok"); + let snap = store.snapshot(); + assert_eq!(snap.env_keys, vec!["BAZ".to_string(), "FOO".to_string()]); + // `config` (the GET-shaped view) never carries env values. + assert!(snap.config.unwrap().get("env").is_none()); + } + + #[test] + fn git_credential_refresh_updates_state_and_notifies_via_non_no_op_transition() { + let store = ConfigStore::new(); + store + .patch(json!({ + "git": {"repository": {"cloneUrl": "https://x-access-token:OLD@github.com/org/repo.git"}}, + "application": {"packageManager": {"name": "npm"}, "runtime": "node"}, + })) + .expect("first apply ok"); + let outcome = store + .patch(json!({ + "git": {"repository": {"cloneUrl": "https://x-access-token:NEW@github.com/org/repo.git"}}, + })) + .expect("refresh apply ok"); + assert_eq!(outcome.transition, "git-credential-refresh"); + let snap = store.snapshot(); + assert_eq!( + snap.config.unwrap()["git"]["repository"]["cloneUrl"], + "https://x-access-token:NEW@github.com/org/repo.git" + ); + } +} diff --git a/apps/native/crates/local-api/src/config/validate.rs b/apps/native/crates/local-api/src/config/validate.rs new file mode 100644 index 0000000000..8daf01693d --- /dev/null +++ b/apps/native/crates/local-api/src/config/validate.rs @@ -0,0 +1,354 @@ +//! Validate a fully-merged `TenantConfig` (post-merge, pre-persist). Byte-parity +//! port of `packages/sandbox/daemon/validate.ts`'s `validateTenantConfig` + +//! `packages/sandbox/git-co-author.ts`'s `normalizeCoAuthorIdentity` (needed +//! by `operator` validation). +//! +//! All fields are optional — partial configs are valid so callers can patch +//! one field at a time; only PRESENT field *values* are validated. +//! +//! Divergence from the TS source (deliberate, matches this repo's "no +//! `unwrap()`/panic on a request path" convention): a present-but-wrong-type +//! value that the TS source would reach via an unchecked property access +//! (e.g. a numeric `git.repository.branch`, which TS's `isSyntheticBranch` +//! would call `.startsWith` on and throw) is instead classified as invalid +//! here — same 400 outcome, no crash. No test in either e2e suite pins the +//! TS crash path, so this doesn't cost byte-parity on anything exercised. + +use regex::Regex; +use serde_json::Value; +use std::path::{Component, Path}; +use std::sync::LazyLock; + +static BRANCH_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[A-Za-z0-9._/-]+$").unwrap()); +static ENV_KEY_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap()); +static EMAIL_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$").unwrap()); + +const ENV_VALUE_MAX_BYTES: usize = 32 * 1024; +const VALID_RUNTIMES: &[&str] = &["node", "bun", "deno"]; +const VALID_PMS: &[&str] = &["npm", "pnpm", "yarn", "bun", "deno"]; + +/// `Ok(())` mirrors `{kind:"ok"}`; `Err(reason)` mirrors `{kind:"invalid", +/// reason}` — note the wire response only ever surfaces the constant +/// `"invalid"` (see `config-store/store.ts`'s `REJECTION_REASONS.INVALID`, +/// which discards `reason`), so `reason` here is for local diagnostics/tests +/// only, never echoed to the caller. +pub fn validate_tenant_config(config: &Value) -> Result<(), String> { + if let Some(git) = present(config, "git") { + validate_git(git)?; + } + if let Some(app) = present(config, "application") { + validate_application(app)?; + } + if let Some(env) = present(config, "env") { + validate_env(env)?; + } + if let Some(operator) = present(config, "operator") { + validate_operator(operator)?; + } + Ok(()) +} + +fn present<'a>(config: &'a Value, key: &str) -> Option<&'a Value> { + config.get(key).filter(|v| !v.is_null()) +} + +fn validate_env(env: &Value) -> Result<(), String> { + let obj = env + .as_object() + .ok_or_else(|| "env must be an object".to_string())?; + for (k, v) in obj { + if !ENV_KEY_RE.is_match(k) { + return Err(format!("env key invalid: {k}")); + } + let s = v + .as_str() + .ok_or_else(|| format!("env value for {k} must be a string"))?; + if s.contains('\0') { + return Err(format!("env value for {k} contains NUL")); + } + if s.len() > ENV_VALUE_MAX_BYTES { + return Err(format!( + "env value for {k} exceeds {ENV_VALUE_MAX_BYTES} bytes" + )); + } + } + Ok(()) +} + +fn validate_git(git: &Value) -> Result<(), String> { + let repository = git.get("repository"); + let clone_url = repository + .and_then(|r| r.get("cloneUrl")) + .and_then(Value::as_str); + let clone_url = clone_url.ok_or_else(|| "git.repository.cloneUrl is required".to_string())?; + if clone_url.is_empty() { + return Err("git.repository.cloneUrl is empty".to_string()); + } + if let Some(branch) = repository.and_then(|r| present(r, "branch")) { + let is_synthetic = branch + .as_str() + .is_some_and(crate::sandbox::is_synthetic_branch); + if !is_synthetic { + let valid = branch + .as_str() + .is_some_and(|b| BRANCH_RE.is_match(b) && !b.starts_with('-')); + if !valid { + return Err(format!( + "git.repository.branch invalid: {}", + display_value(branch) + )); + } + } + } + if let Some(identity) = present(git, "identity") { + let user_name = identity.get("userName").and_then(Value::as_str); + if user_name.is_none_or(str::is_empty) { + return Err("git.identity.userName is required".to_string()); + } + let user_email = identity.get("userEmail").and_then(Value::as_str); + if user_email.is_none_or(str::is_empty) { + return Err("git.identity.userEmail is required".to_string()); + } + } + Ok(()) +} + +fn validate_application(app: &Value) -> Result<(), String> { + if let Some(runtime) = present(app, "runtime") { + let valid = runtime + .as_str() + .is_some_and(|r| VALID_RUNTIMES.contains(&r)); + if !valid { + return Err(format!("runtime invalid: {}", display_value(runtime))); + } + } + if let Some(pm) = present(app, "packageManager") { + if let Some(name) = present(pm, "name") { + let name_str = name + .as_str() + .ok_or_else(|| "application.packageManager.name must be a string".to_string())?; + if !VALID_PMS.contains(&name_str) { + return Err(format!("packageManager invalid: {name_str}")); + } + } + if let Some(path) = present(pm, "path") { + let path = path + .as_str() + .filter(|path| !path.is_empty()) + .ok_or_else(|| "packageManager.path must be non-empty".to_string())?; + validate_package_manager_path(path)?; + } + } + if let Some(port) = present(app, "port") { + if !is_valid_port(port) { + return Err(format!("port invalid: {}", display_value(port))); + } + } + Ok(()) +} + +pub(crate) fn validate_package_manager_path(path: &str) -> Result<(), String> { + let path = Path::new(path); + if path.is_absolute() { + return Err("packageManager.path must be relative to the repository".to_string()); + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err("packageManager.path must stay inside the repository".to_string()); + } + Ok(()) +} + +fn validate_operator(operator: &Value) -> Result<(), String> { + let user_name = operator + .get("userName") + .and_then(Value::as_str) + .ok_or_else(|| "operator.userName is required".to_string())?; + let user_email = operator.get("userEmail").and_then(Value::as_str); + let normalized = normalize_co_author_identity(user_name, user_email); + let normalized = normalized.ok_or_else(|| "operator.userName is required".to_string())?; + + if let Some(email_field) = present(operator, "userEmail") { + let email_str = email_field + .as_str() + .ok_or_else(|| "operator.userEmail must be a string".to_string())?; + if !email_str.trim().is_empty() && normalized.1.is_none() { + return Err("operator.userEmail is invalid".to_string()); + } + } + Ok(()) +} + +fn is_valid_port(v: &Value) -> bool { + v.as_f64() + .is_some_and(|p| p.fract() == 0.0 && p > 0.0 && p <= 65535.0) +} + +/// Byte-parity port of `packages/sandbox/git-co-author.ts::normalizeCoAuthorIdentity`. +/// Returns `(userName, userEmail)` — `userEmail` is `None` when absent or +/// invalid (matching the TS source's "degrade, don't reject" email handling +/// once the name itself is valid). +fn normalize_co_author_identity( + user_name: &str, + user_email: Option<&str>, +) -> Option<(String, Option)> { + let name = user_name.trim(); + if name.is_empty() || has_invalid_co_author_chars(name) { + return None; + } + let Some(email) = user_email.map(str::trim).filter(|e| !e.is_empty()) else { + return Some((name.to_string(), None)); + }; + if has_invalid_co_author_chars(email) || !EMAIL_RE.is_match(email) { + return Some((name.to_string(), None)); + } + Some((name.to_string(), Some(email.to_string()))) +} + +fn has_invalid_co_author_chars(s: &str) -> bool { + s.chars().any(|c| matches!(c, '\r' | '\n' | '<' | '>')) +} + +fn display_value(v: &Value) -> String { + v.as_str() + .map(str::to_string) + .unwrap_or_else(|| v.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn empty_config_is_valid() { + assert!(validate_tenant_config(&json!({})).is_ok()); + } + + #[test] + fn valid_full_config() { + let cfg = json!({ + "git": {"repository": {"cloneUrl": "https://x.git", "branch": "main"}}, + "application": {"packageManager": {"name": "npm"}, "runtime": "node", "port": 3000}, + "env": {"FOO": "bar"}, + "operator": {"userName": "Jane Doe", "userEmail": "jane@example.com"}, + }); + assert!(validate_tenant_config(&cfg).is_ok()); + } + + #[test] + fn empty_clone_url_is_invalid() { + let cfg = json!({"git": {"repository": {"cloneUrl": ""}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn missing_clone_url_is_invalid() { + let cfg = json!({"git": {"repository": {}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn synthetic_branch_skips_format_check() { + let cfg = json!({"git": {"repository": {"cloneUrl": "x", "branch": "thread:abc-123"}}}); + assert!(validate_tenant_config(&cfg).is_ok()); + let cfg2 = json!({"git": {"repository": {"cloneUrl": "x", "branch": "ephemeral"}}}); + assert!(validate_tenant_config(&cfg2).is_ok()); + } + + #[test] + fn branch_starting_with_dash_is_invalid() { + let cfg = json!({"git": {"repository": {"cloneUrl": "x", "branch": "-evil"}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn invalid_runtime_is_rejected() { + let cfg = json!({"application": {"runtime": "python"}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn invalid_package_manager_is_rejected() { + let cfg = json!({"application": {"packageManager": {"name": "cargo"}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn repo_relative_package_manager_path_is_valid() { + let cfg = json!({"application": {"packageManager": {"name": "bun", "path": "apps/web"}}}); + assert!(validate_tenant_config(&cfg).is_ok()); + } + + #[test] + fn absolute_package_manager_path_is_rejected() { + let cfg = + json!({"application": {"packageManager": {"name": "bun", "path": "/tmp/outside"}}}); + let error = validate_tenant_config(&cfg).unwrap_err(); + assert!(error.contains("relative to the repository")); + } + + #[test] + fn traversing_package_manager_path_is_rejected() { + for path in ["..", "../outside", "apps/../../outside"] { + let cfg = json!({"application": {"packageManager": {"name": "bun", "path": path}}}); + let error = validate_tenant_config(&cfg).unwrap_err(); + assert!( + error.contains("stay inside the repository"), + "{path}: {error}" + ); + } + } + + #[test] + fn out_of_range_port_is_rejected() { + assert!(validate_tenant_config(&json!({"application": {"port": 0}})).is_err()); + assert!(validate_tenant_config(&json!({"application": {"port": 70000}})).is_err()); + assert!(validate_tenant_config(&json!({"application": {"port": 3000.5}})).is_err()); + } + + #[test] + fn env_key_must_be_identifier_shaped() { + let cfg = json!({"env": {"1FOO": "bar"}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn env_value_must_be_string() { + let cfg = json!({"env": {"FOO": 1}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn operator_without_username_is_invalid() { + assert!(validate_tenant_config(&json!({"operator": {}})).is_err()); + } + + #[test] + fn operator_with_invalid_email_degrades_gracefully() { + // Byte-parity: an invalid email doesn't reject the whole patch when + // it round-trips through `userEmail is invalid` -- here it's a + // non-empty string that fails EMAIL_RE, which normalize degrades to + // `None`, and validate_operator's own check then rejects it. + let cfg = json!({"operator": {"userName": "Jane", "userEmail": "not-an-email"}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn operator_without_email_is_valid() { + let cfg = json!({"operator": {"userName": "Jane"}}); + assert!(validate_tenant_config(&cfg).is_ok()); + } + + #[test] + fn non_string_branch_is_rejected_without_panicking() { + let cfg = json!({"git": {"repository": {"cloneUrl": "x", "branch": 42}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } +} diff --git a/apps/native/crates/local-api/src/cors.rs b/apps/native/crates/local-api/src/cors.rs new file mode 100644 index 0000000000..a882944af1 --- /dev/null +++ b/apps/native/crates/local-api/src/cors.rs @@ -0,0 +1,175 @@ +//! Origin validation + CORS header shaping. +//! +//! the native local-API contract calls +//! this surface "the crown jewels" (chat + org data + process spawn) and is +//! explicit that local-api MUST NOT reproduce the daemon's unconditional +//! `Access-Control-Allow-Origin: *` (`daemon/routes/body-parser.ts`'s +//! `jsonResponse()`) or its `CORS_HEADERS` blanket preflight constant +//! (`entry.ts`). The rule implemented here is the contract's, not the +//! daemon's: +//! - No `Origin` header at all → allowed (same-machine CLI/test harness; +//! `apps/native/e2e` and the daemon-parity oracle both drive requests +//! this way, via Node's `fetch`, which never sets `Origin`). +//! - `Origin` present and on the allowlist → allowed, ECHO that exact +//! origin back (never `*`). +//! - `Origin` present and NOT on the allowlist → `403 forbidden_origin`, +//! checked before auth. +//! +//! `ApiMode::DaemonCompat` (opt-in via `LOCAL_API_MODE=daemon-compat`, see +//! `state.rs`) exists ONLY to widen the origin allowlist check for the +//! parity-oracle CI job (so a wider slice of `daemon.e2e.test.ts`'s CORS +//! assertions can be pointed at this binary for measurement) — it never +//! widens auth, and it still never emits a wildcard +//! `Access-Control-Allow-Origin`. `ApiMode::Strict` (the default, and the +//! only mode the shipped Tauri app ever runs in) is what's described above. +//! See the native module-ownership contract for why the two suites, not +//! this comment, are the actual arbiters of this behavior. + +use axum::http::{HeaderMap, HeaderValue}; + +use crate::state::ApiMode; + +/// Fixed allowlist for the app's own Tauri origin(s). Sandbox previews use +/// `http://localhost:` on a separate unauthenticated listener; +/// that origin is intentionally never accepted by the main API listener. +pub fn allowed_origins() -> &'static [&'static str] { + &["tauri://localhost", "https://tauri.localhost"] +} + +/// In DEBUG builds only, `tauri dev` (`bun run dev:native`) serves the frontend +/// from its built-in dev server (`http://127.0.0.1:`, e.g. 1430) instead +/// of the packaged app's `tauri://localhost`, so the webview's `Origin` is a +/// loopback http URL that isn't on [`allowed_origins`]. Allow those in dev so +/// the dev loop works end-to-end; the release build +/// (`cfg(not(debug_assertions))`) keeps the strict `tauri://` allowlist, so a +/// distributed app never accepts an arbitrary loopback origin. +fn is_dev_loopback_origin(origin: &str) -> bool { + origin.starts_with("http://127.0.0.1:") || origin.starts_with("http://localhost:") +} + +pub struct OriginDecision { + pub allowed: bool, + /// The exact origin to echo in `Access-Control-Allow-Origin`, if any. + /// `None` when there was no `Origin` header to begin with (same-origin + /// / non-browser caller — no CORS header is needed at all). + pub echo_origin: Option, +} + +pub fn validate(headers: &HeaderMap, mode: ApiMode) -> OriginDecision { + let origin = headers + .get(axum::http::header::ORIGIN) + .and_then(|v| v.to_str().ok()); + match origin { + None => OriginDecision { + allowed: true, + echo_origin: None, + }, + Some(o) => { + let on_allowlist = allowed_origins().contains(&o); + // `daemon-compat` widens the ALLOWLIST CHECK ONLY (for the + // parity-oracle job's convenience) — it never affects whether a + // wildcard is emitted; see the module doc comment. + let allowed = on_allowlist + || mode == ApiMode::DaemonCompat + || (cfg!(debug_assertions) && is_dev_loopback_origin(o)); + OriginDecision { + allowed, + echo_origin: if allowed { Some(o.to_string()) } else { None }, + } + } + } +} + +/// Apply the resolved decision's headers to an outgoing response. Never +/// inserts a wildcard. No-ops when `echo_origin` is `None` (nothing to +/// add). Callers should still explicitly NOT call this at all for the +/// reverse-proxy family's responses (byte-parity: the daemon doesn't gate +/// or CORS-decorate proxied responses either — that traffic is the app +/// under test being previewed, not the control API). +pub fn apply_headers(headers: &mut HeaderMap, decision: &OriginDecision) { + if let Some(origin) = &decision.echo_origin { + if let Ok(v) = HeaderValue::from_str(origin) { + headers.insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, v); + headers.insert(axum::http::header::VARY, HeaderValue::from_static("Origin")); + // Cross-origin JS cannot READ response headers unless exposed. + // The webview -> local-api hop is always cross-origin (unlike + // prod web, where these calls are same-origin and never hit + // this rule): the MCP streamable-HTTP client must read + // `Mcp-Session-Id`, and OAuth discovery reads + // `WWW-Authenticate` (mesh exposes the latter itself — + // apps/api/src/api/app.ts's exposeHeaders). + headers.insert( + axum::http::header::ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_static("Mcp-Session-Id, WWW-Authenticate"), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::{HeaderMap, HeaderValue}; + + #[test] + fn no_origin_header_is_allowed_with_no_echo() { + let decision = validate(&HeaderMap::new(), ApiMode::Strict); + assert!(decision.allowed); + assert!(decision.echo_origin.is_none()); + } + + #[test] + fn allowlisted_origin_is_echoed_verbatim() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::ORIGIN, + HeaderValue::from_static("tauri://localhost"), + ); + let decision = validate(&headers, ApiMode::Strict); + assert!(decision.allowed); + assert_eq!(decision.echo_origin.as_deref(), Some("tauri://localhost")); + } + + #[test] + fn unknown_origin_is_rejected_in_strict_mode() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::ORIGIN, + HeaderValue::from_static("https://evil.example"), + ); + let decision = validate(&headers, ApiMode::Strict); + assert!(!decision.allowed); + assert!(decision.echo_origin.is_none()); + } + + #[test] + fn dev_loopback_origin_allowed_only_in_debug_builds() { + // `tauri dev` (`bun run dev:native`) serves the frontend from + // `http://127.0.0.1:` (e.g. 1430), unlike the packaged app's + // `tauri://localhost` — so it must be accepted in DEBUG builds (where + // this test runs) but rejected in a release build (strict `tauri://`). + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::ORIGIN, + HeaderValue::from_static("http://127.0.0.1:1430"), + ); + assert_eq!( + validate(&headers, ApiMode::Strict).allowed, + cfg!(debug_assertions), + ); + } + + #[test] + fn apply_headers_never_emits_a_wildcard() { + let mut headers = HeaderMap::new(); + let decision = OriginDecision { + allowed: true, + echo_origin: Some("tauri://localhost".to_string()), + }; + apply_headers(&mut headers, &decision); + assert_eq!( + headers.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN), + Some(&HeaderValue::from_static("tauri://localhost")) + ); + } +} diff --git a/apps/native/crates/local-api/src/error.rs b/apps/native/crates/local-api/src/error.rs new file mode 100644 index 0000000000..6ebe3431ca --- /dev/null +++ b/apps/native/crates/local-api/src/error.rs @@ -0,0 +1,156 @@ +//! The daemon-parity JSON error envelope. +//! +//! Every route in `packages/sandbox/daemon/routes/*.ts` responds to a +//! rejected request with `{ "error": "" }`, sometimes with extra +//! fields (`detail`, `notReady`, `available`) — see +//! the native local-API contract. `ApiError` is +//! the ONE type every handler in this crate returns on the error path so +//! that shape never drifts between families. Route handlers should return +//! `Result, ApiError>` (or `Result` for +//! non-JSON success bodies like SSE/204/empty) and use the constructors +//! below rather than building `(StatusCode, Json(..))` tuples by hand. + +// Most constructors below (and `ApiResult`) aren't called yet — every route +// handler in `routes/*.rs` is still a bare `ApiError::not_implemented(..)` +// stub. They're reserved for the families that replace those stubs (see +// the native module-ownership contract), not dead in the "should be +// deleted" sense. +#![allow(dead_code)] + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::{json, Value}; + +#[derive(Debug)] +pub struct ApiError { + pub status: StatusCode, + pub body: Value, +} + +impl ApiError { + /// `{ "error": "" }` at an arbitrary status. The building + /// block every other constructor is sugar over. + pub fn new(status: StatusCode, error: impl Into) -> Self { + Self { + status, + body: json!({ "error": error.into() }), + } + } + + /// `{ "error": "", ...extra }` — `extra` must be a JSON + /// object; its keys are merged alongside `error` (contract: "Some + /// routes add fields alongside `error`", e.g. `detail`/`notReady`/ + /// `available`). + pub fn with_extra(status: StatusCode, error: impl Into, extra: Value) -> Self { + let mut body = json!({ "error": error.into() }); + if let (Some(base), Value::Object(more)) = (body.as_object_mut(), extra) { + base.extend(more); + } + Self { status, body } + } + + // --- Shapes pinned by name across the contract + both e2e suites ------- + + /// 401 — byte-parity with `auth.ts::requireToken`. Missing header, + /// wrong scheme, or mismatched token all resolve here (see `auth.rs`). + pub fn unauthorized() -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized") + } + + /// 403 — Origin header present but not on the allowlist. Checked + /// BEFORE auth (contract: "fail fast, don't waste a timing + /// side-channel on the bearer compare"). + pub fn forbidden_origin() -> Self { + Self::new(StatusCode::FORBIDDEN, "forbidden_origin") + } + + /// 404 — generic "no such route" for both the `/_sandbox/*` catch-all + /// (`Not found: `, byte-parity with the daemon's `vmRouteH` + /// default branch) and any family's own "no such id" case. + pub fn not_found(error: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, error) + } + + pub fn bad_request(error: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, error) + } + + /// 400 `{"error":"bad_input","detail":""}` — the + /// dispatch route's schema-validation-failure shape specifically + /// (contract's error-envelope table). + pub fn bad_input(detail: impl Into) -> Self { + Self::with_extra( + StatusCode::BAD_REQUEST, + "bad_input", + json!({ "detail": detail.into() }), + ) + } + + /// 400 with a `detail` field, for the other "unknown_*"/"missing_*" + /// dispatch-gate errors that also carry extra context. + pub fn bad_request_with_detail(error: impl Into, detail: impl Into) -> Self { + Self::with_extra( + StatusCode::BAD_REQUEST, + error, + json!({ "detail": detail.into() }), + ) + } + + /// 409 `{"error":..., "notReady": true}` — git's "repository not + /// initialized" gate and analogous "not ready yet, retry" cases. + pub fn not_ready(error: impl Into) -> Self { + Self::with_extra(StatusCode::CONFLICT, error, json!({ "notReady": true })) + } + + /// 409 without the `notReady` marker — e.g. config's immutable-field + /// rejection, exec's "no package manager configured yet". + pub fn conflict(error: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, error) + } + + /// 410 — dispatch's post-cancel tombstone window. + pub fn gone(error: impl Into) -> Self { + Self::new(StatusCode::GONE, error) + } + + pub fn payload_too_large(error: impl Into) -> Self { + Self::new(StatusCode::PAYLOAD_TOO_LARGE, error) + } + + /// 502 — upstream/dev-server unreachable (reverse proxy, the app-API + /// proxy, tools/sync). + pub fn bad_gateway(error: impl Into) -> Self { + Self::new(StatusCode::BAD_GATEWAY, error) + } + + /// 500 — reserved for local-api's own bugs (contract: never used for + /// "the caller did something wrong"). + pub fn internal(error: impl Into) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, error) + } + + /// 501 — the bootstrap skeleton's placeholder for every route a family + /// hasn't implemented yet. `route` should be `"METHOD /path"` so a 501 + /// response is self-describing during Phase 1 development. A family + /// MUST replace every `not_implemented` call in the files it owns + /// before that family's e2e subset is expected to go green. + pub fn not_implemented(route: impl Into) -> Self { + Self::new( + StatusCode::NOT_IMPLEMENTED, + format!("not implemented: {}", route.into()), + ) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(self.body)).into_response() + } +} + +/// Convenience alias family handlers use in signatures: +/// `async fn handler(..) -> ApiResult`. +pub type ApiResult = Result; diff --git a/apps/native/crates/local-api/src/events/broadcaster.rs b/apps/native/crates/local-api/src/events/broadcaster.rs new file mode 100644 index 0000000000..f1fcef7c26 --- /dev/null +++ b/apps/native/crates/local-api/src/events/broadcaster.rs @@ -0,0 +1,95 @@ +//! `Broadcaster` — the SSE fan-out hub, byte-parity in spirit with +//! `packages/sandbox/daemon/events/broadcast.ts`. +//! +//! Ownership split (see the native module-ownership contract): EVERY +//! family may call `emit()` to publish a named event (bash background -> +//! `"tasks"`, git branch changes -> `"branch"`, config transitions -> +//! `"lifecycle"`, file writes -> `"file-changed"`/`"reload"`, scripts +//! discovery -> `"scripts"`). Only the `events` family +//! (`routes/events.rs`) calls `subscribe()` — it owns turning the broadcast +//! stream into the actual `/_sandbox/events` SSE response (framing via +//! `event: \ndata: \n\n`, replay-on-connect, the 15s heartbeat). +//! No other module should hold a `Receiver`. +//! +//! Nothing calls `emit()`/`subscribe()` yet (no route handler is +//! implemented past a 501 stub) — hence the crate-unusual `dead_code` +//! allow below. Remove it naturally the moment the first family wires a +//! call site; don't carry it forward once that happens. +#![allow(dead_code)] + +use serde_json::Value; +use tokio::sync::broadcast; + +/// Generous enough that a burst of task/log events between two SSE +/// `poll()`s on a slow consumer doesn't lag-drop before the events route +/// has a chance to read them. The `events` family should treat a +/// `RecvError::Lagged` from its receiver as "resync via a fresh replay", +/// not a fatal stream error — see `tokio::sync::broadcast`'s docs. +const DEFAULT_CAPACITY: usize = 1024; + +#[derive(Debug, Clone)] +pub struct Event { + pub name: String, + pub data: Value, +} + +pub struct Broadcaster { + tx: broadcast::Sender, +} + +impl Broadcaster { + pub fn new() -> Self { + let (tx, _rx) = broadcast::channel(DEFAULT_CAPACITY); + Self { tx } + } + + /// Subscribe to the live event stream from this point forward. Reserved + /// for the `events` family's SSE handler — see the module doc comment. + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } + + /// Fan `data` out to every currently-connected `/_sandbox/events` + /// client under event name `name`. A no-op when nobody is subscribed + /// (mirrors the daemon's `Broadcaster.emit`, which silently drops + /// events with no listeners rather than buffering them) — callers + /// never need to check "is anyone listening" first. + pub fn emit(&self, name: &str, data: Value) { + let _ = self.tx.send(Event { + name: name.to_string(), + data, + }); + } + + /// Current subscriber count — useful for tests/health introspection. + pub fn subscriber_count(&self) -> usize { + self.tx.receiver_count() + } +} + +impl Default for Broadcaster { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn emit_reaches_a_live_subscriber() { + let b = Broadcaster::new(); + let mut rx = b.subscribe(); + b.emit("status", serde_json::json!({"state": "running"})); + let evt = rx.recv().await.expect("event delivered"); + assert_eq!(evt.name, "status"); + assert_eq!(evt.data["state"], "running"); + } + + #[test] + fn emit_with_no_subscribers_does_not_panic() { + let b = Broadcaster::new(); + b.emit("status", serde_json::json!({})); + } +} diff --git a/apps/native/crates/local-api/src/events/mod.rs b/apps/native/crates/local-api/src/events/mod.rs new file mode 100644 index 0000000000..1612499bb1 --- /dev/null +++ b/apps/native/crates/local-api/src/events/mod.rs @@ -0,0 +1,8 @@ +pub mod broadcaster; +pub mod snapshot; + +// `Broadcaster` is consumed via `AppState::broadcaster` (see `state.rs`). +// `Event` itself (the `subscribe()` receiver's item type) is read structurally +// (`ev.name`/`ev.data`) by `routes/events.rs` without ever naming the type, +// so it stays unexported here rather than re-exported-but-unused. +pub use broadcaster::Broadcaster; diff --git a/apps/native/crates/local-api/src/events/snapshot.rs b/apps/native/crates/local-api/src/events/snapshot.rs new file mode 100644 index 0000000000..b9efba9b4a --- /dev/null +++ b/apps/native/crates/local-api/src/events/snapshot.rs @@ -0,0 +1,151 @@ +//! Pure SSE snapshot/frame-format logic for the `events` family, kept +//! separate from `routes/events.rs`'s async plumbing so it's covered by +//! plain `#[cfg(test)]` unit tests — no server, no tokio runtime needed. +//! +//! Byte-parity targets: `daemon/events/sse-format.ts` (frame encoding), +//! `daemon/events/types.ts` (`DaemonEventMap` member shapes), +//! `daemon/entry.ts::getActiveTasks` (the active-task projection). + +use bytes::Bytes; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::tasks::TaskSummary; + +/// `event: \ndata: \n\n` — byte-parity with `sseFormat()` +/// (`daemon/events/sse-format.ts`). `data` is any pre-built JSON value; a +/// serialization failure can't happen for the `Value`s this module builds +/// (no floats/maps with non-string keys), but falls back to `null` rather +/// than panicking on the request path if it ever did. +pub fn frame(name: &str, data: &Value) -> Bytes { + let payload = serde_json::to_string(data).unwrap_or_else(|_| "null".to_string()); + Bytes::from(format!("event: {name}\ndata: {payload}\n\n")) +} + +/// Wraps a `crate::setup::SetupOrchestrator::lifecycle_snapshot()` phase +/// object in the `{"state": ...}` envelope `DaemonEventMap["lifecycle"]` +/// pins — byte-parity with `daemon/events/types.ts::LifecycleState`. The +/// orchestrator is local-api's real clone -> install -> start pipeline +/// (KEPT, not dropped — see `crate::setup`'s module doc), so `phase` is +/// exactly one of `idle`/`cloning`/`checking-out`/`clone-failed`/ +/// `installing`/`install-failed`/`starting`/`running`/`start-failed`/ +/// `crashed`, not a synthetic idle/running collapse. +pub fn lifecycle(phase: Value) -> Value { + json!({ "state": phase }) +} + +/// `local-api` has no pause/crash trigger wired yet (Phase 1) — always +/// `{state:"running"}`, no `reason`. Byte-parity shape with `DaemonStatus` +/// (`daemon/events/types.ts`); `daemon.sse-shapes.e2e.test.ts`'s "status" +/// oracle only pins this shape (plus "no unexpected keys"), not a +/// mechanism to ever leave `"running"`. +pub fn status() -> Value { + json!({ "state": "running" }) +} + +/// Byte-parity projection with `entry.ts::getActiveTasks`, which maps the +/// full `TaskSummary` down to just `{id, command, logName?}` for the SSE +/// wire. Callers pass an already-`Running`-filtered list (see +/// `TaskRegistry::list`) — this function does no filtering itself. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ActiveTask<'a> { + id: &'a str, + command: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + log_name: Option<&'a str>, +} + +pub fn active_tasks(running: &[TaskSummary]) -> Value { + let active: Vec> = running + .iter() + .map(|t| ActiveTask { + id: &t.id, + command: &t.command, + log_name: t.log_name.as_deref(), + }) + .collect(); + json!({ "active": active }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tasks::TaskStatus; + + fn task(id: &str, command: &str, log_name: Option<&str>) -> TaskSummary { + TaskSummary { + id: id.to_string(), + command: command.to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: log_name.map(str::to_string), + intentional: None, + } + } + + #[test] + fn frame_matches_daemon_wire_format() { + let f = frame("status", &json!({"state":"running"})); + assert_eq!( + std::str::from_utf8(&f).unwrap(), + "event: status\ndata: {\"state\":\"running\"}\n\n" + ); + } + + #[test] + fn frame_ends_with_blank_line_separator() { + let f = frame("lifecycle", &json!({"state":{"phase":"idle"}})); + let text = std::str::from_utf8(&f).unwrap(); + assert!(text.starts_with("event: lifecycle\ndata: ")); + assert!(text.ends_with("\n\n")); + assert_eq!(text.matches('\n').count(), 3); + } + + #[test] + fn lifecycle_wraps_the_given_phase_in_a_state_envelope() { + assert_eq!( + lifecycle(json!({"phase": "idle"})), + json!({"state": {"phase": "idle"}}) + ); + assert_eq!( + lifecycle(json!({"phase": "running", "port": 3000, "htmlSupport": false})), + json!({"state": {"phase": "running", "port": 3000, "htmlSupport": false}}) + ); + } + + #[test] + fn status_is_always_running_with_no_extra_keys() { + let s = status(); + assert_eq!(s, json!({"state": "running"})); + assert_eq!(s.as_object().unwrap().len(), 1); + } + + #[test] + fn active_tasks_empty_list_is_empty_array() { + assert_eq!(active_tasks(&[]), json!({"active": []})); + } + + #[test] + fn active_tasks_projects_id_command_lognamed() { + let tasks = vec![task("task1", "sleep 10", Some("dev"))]; + assert_eq!( + active_tasks(&tasks), + json!({"active": [{"id":"task1","command":"sleep 10","logName":"dev"}]}) + ); + } + + #[test] + fn active_tasks_omits_lognamed_key_when_absent() { + let tasks = vec![task("task1", "sleep 10", None)]; + let value = active_tasks(&tasks); + let entry = &value["active"][0]; + assert!(entry.get("logName").is_none()); + assert_eq!(entry["id"], "task1"); + assert_eq!(entry["command"], "sleep 10"); + } +} diff --git a/apps/native/crates/local-api/src/fs_util.rs b/apps/native/crates/local-api/src/fs_util.rs new file mode 100644 index 0000000000..5ab674cbd3 --- /dev/null +++ b/apps/native/crates/local-api/src/fs_util.rs @@ -0,0 +1,311 @@ +//! Owner-only (0600) file discipline and durable atomic replacement, in ONE +//! place. +//! +//! Several families used to hand-roll this shape independently — the sandbox +//! resurrection sidecars (`sandbox::persist`), the sandbox registry and the +//! threads database (the two tenants of `studio.db` and its `-wal`/`-shm` +//! companions), the instance/child-lifetime lock files (`crate::lib`), the +//! retained task logs and run spool, and the tools-catalog endpoint writer +//! (`routes::fs`). Each copy embodied the same easy-to-drift invariants, so +//! they live here once: +//! +//! - **0600 at creation, never create-then-chmod**: the window between the +//! two is exactly when another same-uid process could read a +//! credential-bearing file. +//! - **The atomic-replace fsync order**: private same-directory temp → +//! write → fsync → rename → fsync(parent), with the temp removed on any +//! pre-rename failure so a failed update leaves the previous valid file +//! byte-for-byte intact and no `.tmp` litter beside it. +//! - **The SQLite file family**: one database is THREE files (`db`, +//! `db-wal`, `db-shm`). The suffix pair was previously spelled at three +//! call sites, one of which had already drifted to a lossy +//! `Display`-based join. +//! +//! This is deliberately dependency-free plumbing; policy (what to write, +//! when to swallow errors, quarantine strategy) stays with callers. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const MAX_TEMP_CREATE_ATTEMPTS: usize = 128; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +/// Clamps `path` to owner-only (0600). No-op off Unix. +pub(crate) fn set_owner_only(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Async twin of [`set_owner_only`] for tokio callers (append-log writers, +/// spool files) that must not block the executor on metadata I/O. +pub(crate) async fn set_owner_only_async(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Opens (creating if absent) `path` read+write with owner-only permissions +/// from the very first instant it exists. `mode` applies only on create, so +/// a pre-existing permissive file is clamped through the returned handle +/// before any caller can lock it or write through it. +pub(crate) fn open_owner_only(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + options.mode(0o600); + let file = options.open(path)?; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + Ok(file) + } + #[cfg(not(unix))] + options.open(path) +} + +/// Ensures `path` exists as an owner-only file, without keeping a handle. +/// The create-time-0600-plus-clamp semantics are [`open_owner_only`]'s. +pub(crate) fn create_owner_only(path: &Path) -> io::Result<()> { + drop(open_owner_only(path)?); + Ok(()) +} + +/// The three files SQLite may materialize for one database in WAL mode: +/// the database itself plus its `-wal` and `-shm` companions. Every caller +/// that secures, inspects, or quarantines "the database" must handle all +/// three, in this order, or a permission clamp / quarantine silently skips +/// live data. +pub(crate) fn sqlite_file_family(db_path: &Path) -> [PathBuf; 3] { + fn companion(db_path: &Path, suffix: &str) -> PathBuf { + let mut value = db_path.as_os_str().to_os_string(); + value.push(suffix); + PathBuf::from(value) + } + [ + db_path.to_path_buf(), + companion(db_path, "-wal"), + companion(db_path, "-shm"), + ] +} + +/// Fsyncs the directory that holds `path`, making a rename/unlink of `path` +/// durable. No-op off Unix (Windows directory handles need platform-specific +/// flags; atomic rename still preserves coherence there). +#[cfg(unix)] +pub(crate) fn sync_parent_dir(path: &Path) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "file has no parent directory") + })?; + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +pub(crate) fn sync_parent_dir(_path: &Path) -> io::Result<()> { + Ok(()) +} + +fn create_private_parent_dir(path: &Path) -> io::Result<()> { + let Some(parent) = path.parent() else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "file has no parent directory", + )); + }; + + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(parent) +} + +fn create_private_temp(path: &Path) -> io::Result<(PathBuf, File)> { + let parent = path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "file has no parent directory") + })?; + let target_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid file name"))?; + + for _ in 0..MAX_TEMP_CREATE_ATTEMPTS { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temp_path = parent.join(format!( + ".{target_name}.{}.{}.tmp", + std::process::id(), + sequence + )); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // Callers store credential-bearing content (clone URLs, MCP + // endpoint headers). The temp must never exist briefly with + // umask-dependent group/world permissions. + options.mode(0o600); + } + match options.open(&temp_path) { + Ok(file) => return Ok((temp_path, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not reserve a unique atomic-write temp file", + )) +} + +pub(crate) fn atomic_replace(path: &Path, contents: &[u8]) -> io::Result<()> { + atomic_replace_with_hook(path, contents, |_| Ok(())) +} + +/// Writes and fsyncs a private same-directory temp file, then atomically +/// replaces `path` and fsyncs its parent directory. The hook is a test seam +/// for the only meaningful crash boundary: after durable temp contents but +/// before rename. Any pre-rename failure removes the temp and leaves the old +/// valid destination byte-for-byte intact. +pub(crate) fn atomic_replace_with_hook( + path: &Path, + contents: &[u8], + before_rename: F, +) -> io::Result<()> +where + F: FnOnce(&Path) -> io::Result<()>, +{ + create_private_parent_dir(path)?; + let (temp_path, mut temp_file) = create_private_temp(path)?; + + let result = (|| { + temp_file.write_all(contents)?; + temp_file.flush()?; + temp_file.sync_all()?; + drop(temp_file); + + before_rename(&temp_path)?; + std::fs::rename(&temp_path, path)?; + sync_parent_dir(path) + })(); + + if result.is_err() { + // Best effort: if rename already succeeded the temp path no longer + // exists. If anything failed before rename this prevents stale private + // temp files from accumulating beside the source of truth. + let _ = std::fs::remove_file(&temp_path); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_files(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".tmp")) + }) + .collect() + } + + #[test] + fn atomic_replace_round_trips_and_creates_missing_parents() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/deeply/value.json"); + atomic_replace(&path, b"first").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"first"); + atomic_replace(&path, b"second").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second"); + assert!(temp_files(path.parent().unwrap()).is_empty()); + } + + #[test] + fn pre_rename_failure_preserves_destination_and_cleans_temp() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("value.json"); + atomic_replace(&path, b"stable").unwrap(); + + let error = atomic_replace_with_hook(&path, b"replacement", |_| { + Err(io::Error::other("injected failure before rename")) + }) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Other); + assert_eq!(std::fs::read(&path).unwrap(), b"stable"); + assert!(temp_files(dir.path()).is_empty()); + } + + #[cfg(unix)] + #[test] + fn atomic_replace_produces_an_owner_only_file() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("value.json"); + atomic_replace(&path, b"secret").unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[cfg(unix)] + #[test] + fn create_owner_only_clamps_a_pre_existing_permissive_file() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("value.db"); + std::fs::write(&path, b"existing").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + create_owner_only(&path).unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + // Ensuring privacy must not truncate: the database content survives. + assert_eq!(std::fs::read(&path).unwrap(), b"existing"); + } + + /// Pins the `-wal`/`-shm` pair so the two `studio.db` tenants (threads + /// database and sandbox registry) can never disagree again about what + /// "the whole database" means. + #[test] + fn sqlite_file_family_pins_the_wal_and_shm_companions() { + let family = sqlite_file_family(Path::new("/app/studio.db")); + assert_eq!( + family, + [ + PathBuf::from("/app/studio.db"), + PathBuf::from("/app/studio.db-wal"), + PathBuf::from("/app/studio.db-shm"), + ] + ); + } +} diff --git a/apps/native/crates/local-api/src/http_util.rs b/apps/native/crates/local-api/src/http_util.rs new file mode 100644 index 0000000000..0e9c65b86a --- /dev/null +++ b/apps/native/crates/local-api/src/http_util.rs @@ -0,0 +1,300 @@ +//! Cross-family HTTP plumbing every proxy/SSE/JSON route in this crate must +//! agree on. Each item here exists because the same contract used to be typed +//! out per route family and had already drifted (or was one edit away from +//! it): +//! +//! - [`HOP_BY_HOP_HEADER_NAMES`] / [`strip_hop_by_hop_headers`] / +//! [`is_hop_by_hop`] — the RFC 7230 §6.1 connection-header set. Three proxy +//! families (the app-API proxy, the preview proxy, the WebSocket bridge) +//! each carried their own copy; the WebSocket copy had silently dropped +//! `proxy-connection`. One list, one drift channel closed. +//! - [`dispatch_sse_headers`] / [`event_stream_response`] — the TWO +//! deliberately different SSE header contracts (dispatch-lifecycle +//! `no-store` vs. the events-family `no-cache` + anti-buffering set). Each +//! was typed in two-plus route files; a route family picking headers by +//! hand is how it ends up on the wrong contract. +//! - [`json_body`] / [`json_body_or_default`] — request-body JSON parsing +//! with the crate's pinned 400 messages. The per-family message wording is +//! wire contract (the fs family's daemon-parity oracle pins "Failed to +//! parse body"), so it stays a parameter where families differ instead of +//! being re-typed per file. +//! - [`query_param`] — tolerant single-parameter extraction from a raw query +//! string, previously duplicated per intercept route. + +use axum::body::Body; +use axum::http::{header, HeaderMap, HeaderName, StatusCode}; +use axum::response::{IntoResponse, Response}; +use serde::de::DeserializeOwned; + +use crate::error::ApiError; + +/// The hop-by-hop header names every proxy family in this crate strips: the +/// RFC 7230 §6.1 set plus the legacy `proxy-connection`. All-lowercase, so +/// entries compare directly against `HeaderName::as_str()` (which is always +/// lowercase). +pub(crate) const HOP_BY_HOP_HEADER_NAMES: [&str; 9] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Whether `name` describes one HTTP connection rather than the proxied +/// message. Predicate form of [`HOP_BY_HOP_HEADER_NAMES`], for callers that +/// filter while copying between header maps (the WebSocket bridge) instead of +/// mutating one in place. Callers still handle `Connection`-nominated tokens +/// themselves — those are per-message, not a fixed list. +pub(crate) fn is_hop_by_hop(name: &HeaderName) -> bool { + HOP_BY_HOP_HEADER_NAMES.contains(&name.as_str()) +} + +/// Removes headers that describe one HTTP connection rather than the proxied +/// message. RFC 7230 §6.1 also allows `Connection` to name arbitrary +/// additional hop-by-hop fields, so collect those tokens before removing +/// `Connection` itself. +pub(crate) fn strip_hop_by_hop_headers(headers: &mut HeaderMap) { + let connection_tokens: Vec = headers + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|name| HeaderName::from_bytes(name.trim().as_bytes()).ok()) + .collect(); + + for name in connection_tokens { + headers.remove(name); + } + for name in HOP_BY_HOP_HEADER_NAMES { + headers.remove(name); + } +} + +/// SSE headers pinned by the contract's Dispatch Lifecycle section — +/// byte-parity with `daemon/routes/dispatch.ts`'s `SSE_HEADERS`, and +/// DELIBERATELY different from [`event_stream_response`]'s events-family set +/// (`no-store` here, not `no-cache`; no `X-Accel-Buffering`/ +/// `Content-Encoding`, since those aren't pinned for the dispatch routes). +pub(crate) fn dispatch_sse_headers() -> [(HeaderName, &'static str); 3] { + [ + (header::CONTENT_TYPE, "text/event-stream"), + (header::CACHE_CONTROL, "no-store"), + (header::CONNECTION, "keep-alive"), + ] +} + +/// A `200 text/event-stream` response with the events-family header set the +/// daemon-parity oracle pins (`no-cache`, `X-Accel-Buffering: no`, +/// `Content-Encoding: identity` — byte-parity with +/// `daemon/routes/events-stream.ts`). `context` names the route family in the +/// (never expected — every part is statically valid) builder-failure log/500. +pub(crate) fn event_stream_response(body: Body, context: &'static str) -> Response { + match Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive") + .header("X-Accel-Buffering", "no") + .header(header::CONTENT_ENCODING, "identity") + .body(body) + { + Ok(response) => response, + Err(error) => { + tracing::error!(%error, context, "failed to build SSE response"); + ApiError::internal(format!("failed to build {context}")).into_response() + } + } +} + +/// Parses a required JSON request body; any failure (including an empty body) +/// is a 400 with the crate's standard `invalid JSON body: …` message. +pub(crate) fn json_body(bytes: &[u8]) -> Result { + parse_json_slice(bytes, "invalid JSON body") +} + +/// Parses an OPTIONAL JSON request body: empty bytes mean `T::default()`, +/// anything non-empty must parse. `error_prefix` is the owning family's +/// pinned 400 wording (`"invalid JSON body"` for the thread routes, the +/// daemon-parity `"Failed to parse body"` for the fs family) — a parameter, +/// not a constant, precisely so the per-family wire messages stay explicit at +/// the call site instead of silently converging. +pub(crate) fn json_body_or_default( + bytes: &[u8], + error_prefix: &'static str, +) -> Result { + if bytes.is_empty() { + return Ok(T::default()); + } + parse_json_slice(bytes, error_prefix) +} + +fn parse_json_slice( + bytes: &[u8], + error_prefix: &'static str, +) -> Result { + serde_json::from_slice(bytes).map_err(|e| ApiError::bad_request(format!("{error_prefix}: {e}"))) +} + +/// The value of `name` in a raw query string, percent-decoded tolerantly (an +/// undecodable value is returned raw rather than dropped). A bare key with no +/// `=` never matches. Routes that must instead REJECT an undecodable value +/// (`routes/intercept/watch.rs`'s `types` filter 400s on bad +/// percent-encoding) keep their own strict parsing on purpose. +pub(crate) fn query_param(query: &str, name: &str) -> Option { + query.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == name).then(|| { + urlencoding::decode(value) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| value.to_string()) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + /// The one canonical list: it must carry the full RFC 7230 §6.1 set PLUS + /// legacy `proxy-connection`, and the predicate must agree with it — this + /// is exactly the drift that had already happened when the WebSocket + /// bridge kept its own copy. + #[test] + fn hop_by_hop_list_covers_rfc7230_plus_proxy_connection() { + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ] { + assert!( + HOP_BY_HOP_HEADER_NAMES.contains(&name), + "missing hop-by-hop header {name:?}" + ); + assert!(is_hop_by_hop(&HeaderName::from_static(name))); + } + assert_eq!(HOP_BY_HOP_HEADER_NAMES.len(), 9); + assert!(!is_hop_by_hop(&header::CONTENT_TYPE)); + assert!(!is_hop_by_hop(&header::SET_COOKIE)); + } + + #[test] + fn strips_standard_and_connection_nominated_hop_by_hop_headers() { + let mut headers = HeaderMap::new(); + headers.append( + header::CONNECTION, + HeaderValue::from_static("keep-alive, x-remove-me"), + ); + headers.append(header::CONNECTION, HeaderValue::from_static("x-remove-too")); + headers.insert("keep-alive", HeaderValue::from_static("timeout=5")); + headers.insert( + header::TRANSFER_ENCODING, + HeaderValue::from_static("chunked"), + ); + headers.insert("proxy-connection", HeaderValue::from_static("keep-alive")); + headers.insert("x-remove-me", HeaderValue::from_static("one")); + headers.insert("x-remove-too", HeaderValue::from_static("two")); + headers.insert("x-end-to-end", HeaderValue::from_static("keep")); + + strip_hop_by_hop_headers(&mut headers); + + assert!(headers.get(header::CONNECTION).is_none()); + assert!(headers.get("keep-alive").is_none()); + assert!(headers.get(header::TRANSFER_ENCODING).is_none()); + assert!(headers.get("proxy-connection").is_none()); + assert!(headers.get("x-remove-me").is_none()); + assert!(headers.get("x-remove-too").is_none()); + assert_eq!(headers.get("x-end-to-end").unwrap(), "keep"); + } + + /// The dispatch contract's set must stay `no-store` with NO anti-buffering + /// extras — picking up the events-family headers here would be the exact + /// cross-contract drift this module exists to prevent. + #[test] + fn dispatch_sse_headers_pin_the_no_store_contract() { + let headers = dispatch_sse_headers(); + assert_eq!(headers.len(), 3); + assert_eq!(headers[0], (header::CONTENT_TYPE, "text/event-stream")); + assert_eq!(headers[1], (header::CACHE_CONTROL, "no-store")); + assert_eq!(headers[2], (header::CONNECTION, "keep-alive")); + } + + #[test] + fn event_stream_response_pins_the_events_family_header_set() { + let response = event_stream_response(Body::empty(), "test stream"); + assert_eq!(response.status(), StatusCode::OK); + let headers = response.headers(); + assert_eq!(headers[header::CONTENT_TYPE], "text/event-stream"); + assert_eq!(headers[header::CACHE_CONTROL], "no-cache"); + assert_eq!(headers[header::CONNECTION], "keep-alive"); + assert_eq!(headers["x-accel-buffering"], "no"); + assert_eq!(headers[header::CONTENT_ENCODING], "identity"); + } + + #[test] + fn json_body_rejects_empty_and_malformed_bodies() { + assert!(json_body::(b"{\"a\":1}").is_ok()); + assert!(json_body::(b"").is_err()); + assert!(json_body::(b"not json").is_err()); + } + + #[test] + fn json_body_or_default_treats_empty_as_default_and_keeps_the_family_prefix() { + #[derive(serde::Deserialize, Default, PartialEq, Debug)] + struct Probe { + n: Option, + } + assert_eq!( + json_body_or_default::(b"", "Failed to parse body").unwrap(), + Probe::default() + ); + assert_eq!( + json_body_or_default::(br#"{"n":2}"#, "Failed to parse body") + .unwrap() + .n, + Some(2) + ); + let err = json_body_or_default::(b"nope", "Failed to parse body").unwrap_err(); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + let message = err.body["error"].as_str().unwrap(); + assert!(message.starts_with("Failed to parse body: "), "{message}"); + } + + #[test] + fn query_param_reads_one_decoded_value_out_of_a_raw_query() { + // The `path` shapes `preview-fetch` depends on. + assert_eq!( + query_param("path=/index.html", "path").as_deref(), + Some("/index.html") + ); + // Percent-encoded, and not the first parameter. + assert_eq!( + query_param("x=1&path=%2Fa%2Fb.json&y=2", "path").as_deref(), + Some("/a/b.json") + ); + assert_eq!(query_param("nope=1", "path"), None); + assert_eq!(query_param("", "path"), None); + // The `branch` shapes agent-sandbox-sessions depends on. + assert_eq!( + query_param("branch=main", "branch").as_deref(), + Some("main") + ); + assert_eq!( + query_param("x=1&branch=feature%2Ffoo", "branch").as_deref(), + Some("feature/foo") + ); + assert_eq!(query_param("other=1", "branch"), None); + // An undecodable value falls back to the raw text, never to None. + assert_eq!(query_param("path=%FF", "path").as_deref(), Some("%FF")); + } +} diff --git a/apps/native/crates/local-api/src/lib.rs b/apps/native/crates/local-api/src/lib.rs new file mode 100644 index 0000000000..d3af0e32dd --- /dev/null +++ b/apps/native/crates/local-api/src/lib.rs @@ -0,0 +1,1280 @@ +//! `local-api` as a library — the in-process embedding entrypoint the +//! Tauri shell (`apps/native/src-tauri/`) links against directly (no +//! subprocess — see the desktop migration contract's "in-process +//! axum, no sidecar" decision and the native implementation contract §5). This +//! file is a MECHANICAL EXTRACTION of the boot sequence that used to live +//! entirely in `main.rs`'s `fn main()`: every side effect (dir creation, +//! `AppState` construction, router build, listener bind, +//! `routes::git::register`) is byte-for-byte unchanged, just reachable +//! from a function signature instead of only from a binary's entrypoint. +//! `main.rs` now calls into [`boot_from_env`] for exactly what its old +//! `main()` body did — the daemon-parity and local-api contract e2e +//! suites (which only ever observe the compiled `local-api` binary's +//! externally-visible behavior: env vars in, stdout/HTTP/SIGTERM out) +//! should see zero behavior change from this refactor. +//! +//! Added per the Phase 3 (Tauri shell) brief's explicit license: "you MAY +//! add a small pub entrypoint to crates/local-api ... keep it a +//! mechanical extraction." Two entrypoints: +//! +//! - [`boot_from_env`] — used by the `local-api` BINARY's `main()`. Reads +//! the full env contract (`LOCAL_API_TOKEN`/`_PORT`/`_WORKDIR`/ +//! `_BOOT_ID` + legacy `DAEMON_*`/`APP_ROOT`/`PROXY_PORT` aliases), +//! prints the `LOCAL_API_PORT=`/`LOCAL_API_PREVIEW_PORT=`/`LOCAL_API_TOKEN=` +//! stdout lines a test harness may discover, and serves until SIGTERM +//! (unix) / Ctrl+C (non-unix). Behaviorally identical to the old `main()` +//! body, plus the new preview-port line. +//! - [`start`] — used by the Tauri shell. Takes an explicit +//! [`StartOptions`] (no env vars, no stdout side effects — the shell +//! already knows its own per-launch token/port/workdir), binds BOTH the +//! main and preview listeners (the "port router" — see [`ServerHandle`]'s +//! doc comment), builds each router, spawns both serve loops in the +//! background, and returns a [`ServerHandle`] the caller drives: read +//! `.port()`/`.preview_port()`/`.state()`, and call `.shutdown()` on +//! window-close/app-exit. Does NOT install its own SIGTERM handler — the +//! embedding app owns its own process lifecycle (see +//! `src-tauri/src/local_api_embed.rs`). +//! +//! Shutdown is an explicit ordered pipeline: stop listener admission; close +//! request/setup admission; reap chat harnesses; concurrently TERM→KILL and +//! join every global/per-sandbox process owner; run git publish; perform one +//! final awaited registry sweep; then join (or abort+join) both axum tasks. +//! The app-root instance lock remains held until those joins complete. + +mod auth; +mod client_auth; +mod config; +mod cors; +mod error; +mod events; +mod fs_util; +mod http_util; +mod log_store; +mod mutation; +mod process_group; +mod process_util; +mod router; +mod routes; +mod sandbox; +mod setup; +mod shutdown; +mod state; +mod tasks; +mod time_util; +mod ui; + +// Re-exported so `AppState`'s public fields name types reachable from +// outside this crate (Rust's `private_interfaces` lint would otherwise +// fire on a `pub` struct field whose type lives in a `mod` that isn't +// itself `pub` — these modules stay private; only the specific types an +// embedder needs are named here). +pub use config::ConfigStore; +pub use events::Broadcaster; +pub use sandbox::SandboxManager; +pub use setup::SetupOrchestrator; +pub use shutdown::{ShutdownCoordinator, ShutdownHook}; +pub use state::{ApiMode, AppState}; +pub use tasks::{KillSignal, TaskRegistry}; +pub use ui::{UiAsset, UiAssetProvider}; + +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::net::TcpListener; +use tokio::sync::Notify; + +/// Explicit boot parameters for [`start`] — the Tauri-shell embedding +/// path. Unlike [`boot_from_env`], nothing here is read from process env; +/// the caller (the shell) already resolved its own per-launch +/// token/port/root before calling in. +pub struct StartOptions { + pub token: String, + pub boot_id: String, + /// `LOCAL_API_WORKDIR` equivalent — the fs/bash/git clamp root and the + /// parent of the `.decocms/` local thread-store directory. + pub app_root: PathBuf, + /// TCP port to bind; `0` picks an OS-assigned ephemeral port (read + /// back via [`ServerHandle::port`]). + pub port: u16, + pub mode: ApiMode, + /// PEM certificate + key for local HTTPS, or `None` to serve plain HTTP. + /// + /// The desktop app needs its origin to be a real domain (per-sandbox + /// cookie jars) AND a secure context (Web Crypto); only TLS gives both. + /// Standalone/bearer callers and the tests keep plain HTTP. + pub tls: Option, +} + +/// Paths to the material [`start`] serves HTTPS with. +#[derive(Clone, Debug)] +pub struct TlsFiles { + pub cert: PathBuf, + pub key: PathBuf, + /// The root `cert` chains to. Not served — handed to spawned CLI children + /// (`NODE_EXTRA_CA_CERTS`) whose runtimes read neither the macOS keychain + /// nor Mozilla's roots, so they can verify this process's own listener. + pub ca: PathBuf, +} + +/// Browser-facing authentication and optional bundled-UI configuration for an +/// in-process Tauri server. `expected_host` and `control_origin` are explicit +/// because development traffic can arrive through Vite (`:4420`) while Rust +/// itself listens on another port (`:43121`). +pub struct EmbeddedOptions { + pub expected_host: String, + pub control_origin: String, + /// The authority the Rust listener itself answers on, when the browser + /// reaches this server through a proxy on a DIFFERENT port (dev: Vite on + /// `:4420` in front of `:43121`). Accepted as an additional `Host`, and + /// preferred for the local subprocesses — `rclone` and the agent harness' + /// MCP — which have no reason to route their loopback traffic through the + /// browser's dev server. `None` when the two are the same, as in release. + pub listener_host: Option, + /// Extra one-time capabilities for additional windows. `StartOptions.token` + /// is always included as the first bootstrap secret. + pub additional_bootstrap_secrets: Vec, + pub ui_assets: Option>, + /// Mounts the credentialed preview-cookie round-trip probe used by the + /// real WKWebView boot smoke. Disabled by default and never controlled by + /// an HTTP query parameter or other runtime request input. + pub preview_cookie_selftest: bool, +} + +impl EmbeddedOptions { + pub fn new(expected_host: impl Into, control_origin: impl Into) -> Self { + Self { + expected_host: expected_host.into(), + control_origin: control_origin.into(), + listener_host: None, + additional_bootstrap_secrets: Vec::new(), + ui_assets: None, + preview_cookie_selftest: false, + } + } +} + +/// Selects the caller-auth contract without weakening the existing standalone +/// binary. The `local-api` executable and [`start`] remain bearer-only. +pub enum ClientAuthMode { + Bearer, + Embedded(EmbeddedOptions), +} + +/// Boot-time failure from [`start`]. Deliberately NOT a process-exit call +/// (unlike the binary's [`boot_from_env`]) — an embedder like the Tauri +/// shell must be able to surface this as a UI error rather than have the +/// whole app process die. +#[derive(Debug, thiserror::Error)] +pub enum StartError { + #[error("failed to load local TLS material {path:?}: {source}")] + Tls { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to create repo dir {path:?}: {source}")] + RepoDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to create {path:?}: {source}")] + DecocmsDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("another local-api instance already owns {path:?}: {source}")] + InstanceLock { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("prior local-api children did not release {path:?}: {source}")] + ChildLifetimeFence { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to recover filesystem mutations under {path:?}: {source}")] + MutationRecovery { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to bind 127.0.0.1:{port}: {source}")] + Bind { + port: u16, + #[source] + source: std::io::Error, + }, + #[error("failed to recover the local chat store: {message}")] + LocalStore { message: String }, + #[error("invalid embedded client-auth configuration: {message}")] + EmbeddedAuth { message: String }, +} + +/// A running server the caller owns the lifecycle of. TWO listeners run +/// under one `ServerHandle` — see the module doc's port-router summary and +/// `router.rs`'s module doc for the exact route split: +/// +/// - the MAIN listener ([`ServerHandle::port`]) — `/health`, `/_sandbox/*`, +/// `/threads*`, `/models`, and the app-API intercept-or-proxy fallback +/// (bearer + Origin gated). +/// - the PREVIEW listener ([`ServerHandle::preview_port`]) — ONLY the +/// reverse-proxy-to-dev-server fallback (`routes::proxy::fallback`), no +/// auth at all. Moved to its own loopback port (rather than sharing the +/// main port behind a `/upstream`-style prefix) so the previewed app runs +/// on a genuinely separate origin from the app's own API — verified via a +/// spike that SSE + WebSocket both stream cleanly through a port-isolated +/// cross-origin iframe in the real WKWebView, unlike the `*.localhost` +/// subdomain approach. +/// +/// Each listener's serve loop runs on its own `tokio::spawn`'d task; dropping +/// a `ServerHandle` WITHOUT calling [`ServerHandle::shutdown`] leaves both +/// tasks running detached (nothing calls `abort()` on drop by design). An +/// unclean process exit, e.g. `SIGKILL`, bypasses git publish and every local +/// cleanup hook; harnesses have their own parent-death watchdog, but callers +/// must use `shutdown()` for the complete ordered reap/publish/drain contract. +pub struct ServerHandle { + port: u16, + preview_port: u16, + state: AppState, + shutdown_notify: Arc, + preview_shutdown_notify: Arc, + serve_task: tokio::task::JoinHandle<()>, + preview_serve_task: tokio::task::JoinHandle<()>, + /// Kernel-held advisory lock for this app root. The file handle—not a + /// stale PID marker—is the lifetime fence: a second process cannot run + /// queue recovery against live harness work, and the OS releases it even + /// after SIGKILL. Graceful shutdown releases it only after every mutation + /// and process owner proves quiescence; on a failed proof the handle is + /// intentionally retained until OS process exit instead of permitting an + /// unsafe same-root restart. + _instance_lock: Arc, +} + +/// How long `shutdown()` waits for in-flight connections to drain before +/// abandoning the wait and letting the process exit. The frontend's +/// long-lived `/_sandbox/events` SSE stream never completes on its own — it +/// ends only when the webview is torn down, which is AFTER we return — so an +/// unbounded wait deadlocks app quit. +/// The ONE local SQLite file, at the top of the app root. +/// +/// Threads/chat history and the sandbox registry share it: two databases were +/// two files, two WALs and two version stories for one process's local state. +/// `PRAGMA user_version` belongs to the threads migration ladder; the sandbox +/// registry versions itself through its own `sandbox_metadata` table, so the +/// two subsystems never fight over the header field. +/// The control-cookie value, stable across launches for one app-data dir. +/// +/// Deliberately NOT per-launch. The desktop dev loop restarts the backend on +/// every rebuild, and a fresh token invalidated the HttpOnly cookie the live +/// webview still held — so every request 401'd (including `/_auth/status`, +/// which is why the shell showed no sandboxes and the preview iframe never +/// opened) and nothing recovered, because the frontend bootstraps once at +/// module init. A stable token makes a backend restart invisible to a page +/// that is already open, which is how any ordinary web session behaves. +/// +/// Stored 0600 beside the other local-api state. This is no weaker than what +/// already lives there: the same directory holds the OAuth refresh token and +/// the TLS CA key, and both are readable by any process running as this user. +/// A rotation is one `rm` away. +/// The per-launch credential for the org-filesystem WebDAV surface — see +/// [`client_auth::ClientAuth::mount_token`]. +/// +/// NOT persisted, unlike the session token: nothing holds it across a restart +/// (mounts are respawned every launch), so the shortest possible lifetime is +/// free. Two v4 UUIDs — the same source `boot_id` uses, and well past the +/// entropy a loopback credential needs. +fn generate_mount_token() -> String { + format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ) +} + +fn stable_session_token(app_root: &Path) -> String { + let path = app_root.join(".decocms").join("session-token"); + if let Ok(existing) = std::fs::read_to_string(&path) { + let existing = existing.trim(); + if !existing.is_empty() { + return existing.to_string(); + } + } + let minted = generate_token(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // Created 0600 rather than written-then-chmod'ed: the window between the + // two is exactly when another local process could read it. + let write = || -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + use std::io::Write; + options.open(&path)?.write_all(minted.as_bytes()) + }; + if let Err(error) = write() { + tracing::warn!(%error, "could not persist the local session token; it will not survive a restart"); + } + minted +} + +pub(crate) const STUDIO_DB_FILE_NAME: &str = "studio.db"; + +/// Busy timeout for every connection to the ONE shared `studio.db` file. +/// +/// Two independent connections open it — the threads store +/// (`routes/threads/db.rs`) and the sandbox registry (`sandbox/registry.rs`). +/// They contend for the same write lock, so a timeout typed once per opener +/// is a drift channel: whichever side ends up with the shorter value surfaces +/// spurious "database is locked" errors under exactly the load the longer +/// side was tuned to ride out. +pub(crate) const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5); + +/// The app-wide child-lifetime fence every spawned child anchors to — the +/// shared lock the boot-time recovery fence waits on and the watchdogs that +/// reap children of a dead parent hold. One path, derived one way, so a +/// spawn family can never anchor to a fence nobody is watching. +pub(crate) fn shared_child_lifetime_lock_path(app_root: &std::path::Path) -> std::path::PathBuf { + app_root.join(".decocms").join("child-lifetime.lock") +} + +const SHUTDOWN_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); +const HARNESS_REAP_TIMEOUT: Duration = Duration::from_secs(5); +const FS_MUTATION_REAP_TIMEOUT: Duration = Duration::from_secs(3); +const CHILD_TERM_GRACE: Duration = Duration::from_secs(2); +const CHILD_KILL_GRACE: Duration = Duration::from_secs(2); +/// Parent-death watchdogs normally finish in roughly one second. Keep startup +/// bounded while allowing generous scheduler/CI slack; expiration fails boot +/// closed instead of recovering durable work beside an indeterminate old +/// child. +const CHILD_CRASH_REAP_TIMEOUT: Duration = Duration::from_secs(15); + +impl ServerHandle { + pub fn port(&self) -> u16 { + self.port + } + + /// The dedicated loopback port serving ONLY the dev-server reverse + /// proxy — see this struct's doc comment. A caller (the Tauri shell, an + /// e2e harness) points a preview iframe/request at + /// `http://localhost:/`, never at [`Self::port`]. + pub fn preview_port(&self) -> u16 { + self.preview_port + } + + pub fn state(&self) -> &AppState { + &self.state + } + + /// Graceful shutdown. Consumes `self` because each listener task and the + /// app-root lock have one lifecycle owner. + pub async fn shutdown(self) { + let ServerHandle { + state, + shutdown_notify, + preview_shutdown_notify, + mut serve_task, + mut preview_serve_task, + _instance_lock, + .. + } = self; + + // Listener shutdown is phase zero: stop accepting HTTP immediately. + // Long-lived responses (notably events SSE) are allowed a bounded + // drain at the end and forcibly closed if they outlive it. + shutdown_notify.notify_one(); + preview_shutdown_notify.notify_one(); + + // Close every source of new side-effecting work before taking any + // process snapshot. The coordinator waits for already-admitted + // spawn+registry critical sections; setup closes are synchronous. + let admission_fence = state.shutdown.begin_shutdown(); + let mutations = state.shutdown.mutations(); + let mutation_fence = mutations.begin_shutdown(FS_MUTATION_REAP_TIMEOUT); + state.setup.close(); + state.sandbox_manager.begin_shutdown(); + let ((), mutation_quiescence) = tokio::join!(admission_fence, mutation_fence); + if !mutation_quiescence.is_quiescent() { + tracing::error!( + owners_remaining = mutation_quiescence.owners_remaining, + commit_quiescent = mutation_quiescence.commit_quiescent, + "shutdown: filesystem mutation owners did not quiesce" + ); + } + + // Chat harnesses own process state outside TaskRegistry. Reap both + // families concurrently before setup/git can observe their worktrees. + let (decopilot_stopped, dispatch_stopped) = tokio::join!( + routes::intercept::decopilot::shutdown_all(HARNESS_REAP_TIMEOUT), + routes::dispatch::shutdown_all(HARNESS_REAP_TIMEOUT), + ); + if !decopilot_stopped { + tracing::error!("shutdown: one or more Decopilot queues did not stop in time"); + } + if !dispatch_stopped { + tracing::error!("shutdown: one or more legacy dispatches did not stop in time"); + } + + // Each setup shutdown internally snapshots once, signals every child + // concurrently, waits one shared TERM grace, then escalates the + // remaining subset under one shared KILL grace. Global and all + // per-sandbox registries run concurrently too. + let (global_setup, sandbox_setups) = tokio::join!( + state.setup.shutdown(CHILD_TERM_GRACE, CHILD_KILL_GRACE), + state + .sandbox_manager + .shutdown_all(CHILD_TERM_GRACE, CHILD_KILL_GRACE), + ); + let global_setup_stopped = + global_setup.worker_stopped && global_setup.tasks.remaining.is_empty(); + if !global_setup_stopped { + tracing::error!( + remaining = ?global_setup.tasks.remaining, + worker_stopped = global_setup.worker_stopped, + "shutdown: global setup did not fully stop" + ); + } + let mut sandbox_setups_stopped = true; + for sandbox in &sandbox_setups { + if !sandbox.result.tasks.remaining.is_empty() || !sandbox.result.worker_stopped { + sandbox_setups_stopped = false; + tracing::error!( + handle = %sandbox.handle, + remaining = ?sandbox.result.tasks.remaining, + worker_stopped = sandbox.result.worker_stopped, + "shutdown: sandbox setup did not fully stop" + ); + } + } + + // Defense-in-depth for global, non-setup request tasks: admission is + // already closed, so this final snapshot is complete and awaited. It + // runs BEFORE publish: a task that survives means quiescence was not + // proven and git must not race it. + let final_tasks = state + .tasks + .kill_all_and_wait(CHILD_TERM_GRACE, CHILD_KILL_GRACE) + .await; + let final_tasks_stopped = final_tasks.remaining.is_empty(); + if !final_tasks_stopped { + tracing::error!( + remaining = ?final_tasks.remaining, + "shutdown: global task registry did not fully stop" + ); + } + + // Only the git publish hook remains registered. Publishing while any + // child family is merely "timed out" would trade shutdown latency for + // silent worktree corruption, so skip it unless EVERY owner proved + // quiescence. Listener/serve cleanup still continues either way. + let process_tree_quiescent = decopilot_stopped + && dispatch_stopped + && global_setup_stopped + && sandbox_setups_stopped + && final_tasks_stopped + && mutation_quiescence.is_quiescent(); + if process_tree_quiescent { + state.shutdown.run().await; + } else { + tracing::error!( + "shutdown: skipping git publish because child quiescence was not proven" + ); + } + + // A publish hook owns its Git process tree through a hidden registry + // entry. If the coordinator's bounded hook budget expires, it drops + // only the hook waiter; the detached owner remains enumerable here. + // Attempt the same bounded TERM -> KILL + join policy as every other + // process family, and report any owner that still cannot prove exit. + let post_publish_tasks = state + .tasks + .kill_all_and_wait(CHILD_TERM_GRACE, CHILD_KILL_GRACE) + .await; + let post_publish_stopped = post_publish_tasks.remaining.is_empty(); + if !post_publish_stopped { + tracing::error!( + remaining = ?post_publish_tasks.remaining, + "shutdown: post-publish git owners did not fully stop" + ); + } + + // `with_graceful_shutdown` waits for every in-flight response. The + // events SSE stream can intentionally live forever, so poll each + // JoinHandle until one shared deadline and remember which handles + // completed. A JoinHandle that already returned Ready MUST NOT be + // polled again (Tokio panics); this explicit loop avoids the former + // `timeout(join!(...))` bug where one listener completed, the other + // timed out, and cleanup awaited the completed handle a second time. + let drain_deadline = tokio::time::Instant::now() + SHUTDOWN_DRAIN_TIMEOUT; + let mut serve_done = false; + let mut preview_done = false; + while !serve_done || !preview_done { + tokio::select! { + _ = &mut serve_task, if !serve_done => serve_done = true, + _ = &mut preview_serve_task, if !preview_done => preview_done = true, + _ = tokio::time::sleep_until(drain_deadline) => break, + } + } + if !serve_done || !preview_done { + tracing::warn!( + "shutdown: graceful drain exceeded {SHUTDOWN_DRAIN_TIMEOUT:?} \ + (long-lived stream still open) — aborting serve tasks" + ); + if !serve_done { + serve_task.abort(); + let _ = serve_task.await; + } + if !preview_done { + preview_serve_task.abort(); + let _ = preview_serve_task.await; + } + } + + // Explicitly last: serve tasks also held clones and have now been + // joined. Release the app-root fence only when every side-effecting + // owner proved quiescence. If a cancellation-resistant owner remains, + // retain this final Arc until OS process exit: graceful shutdown stays + // bounded, but another server in this process cannot acquire the same + // root and race the old owner. The OS still closes the descriptor on + // normal exit, SIGTERM escalation, or SIGKILL. + if process_tree_quiescent && post_publish_stopped { + drop(_instance_lock); + } else { + tracing::error!( + "shutdown: retaining app-root lock until process exit because owner quiescence was not proven" + ); + std::mem::forget(_instance_lock); + } + } +} + +/// Builds `AppState`, the router, binds the listener, and spawns the +/// server in the background. See the module doc comment for the shutdown +/// hooks this registers. +pub async fn start(opts: StartOptions) -> Result { + start_with_client_auth(opts, ClientAuthMode::Bearer).await +} + +/// Embedded counterpart to [`start`]. The standalone/binary path never calls +/// this and therefore keeps its byte-compatible bearer contract. +pub async fn start_embedded( + opts: StartOptions, + embedded: EmbeddedOptions, +) -> Result { + start_with_client_auth(opts, ClientAuthMode::Embedded(embedded)).await +} + +pub async fn start_with_client_auth( + opts: StartOptions, + client_auth_mode: ClientAuthMode, +) -> Result { + let (client_auth, ui_assets, preview_cookie_selftest_origin) = match client_auth_mode { + ClientAuthMode::Bearer => ( + client_auth::ClientAuth::bearer(Arc::::from(opts.token.clone())), + None, + None, + ), + ClientAuthMode::Embedded(embedded) => { + let preview_cookie_selftest_origin = embedded + .preview_cookie_selftest + .then(|| Arc::::from(embedded.control_origin.clone())); + let mut bootstrap_secrets = + Vec::with_capacity(embedded.additional_bootstrap_secrets.len() + 1); + bootstrap_secrets.push(opts.token.clone()); + bootstrap_secrets.extend(embedded.additional_bootstrap_secrets); + let expected_host = embedded.expected_host.clone(); + // rclone talks to local-api as a plain HTTP client, so it needs the + // same scheme the listeners actually serve. + let control_scheme = if embedded.control_origin.starts_with("https://") { + "https" + } else { + "http" + }; + let control_origin = embedded.control_origin.clone(); + // Loopback subprocesses address the Rust listener DIRECTLY. Routing + // them through the browser's dev proxy made an unrelated middleman + // part of the agent's request path, where its buffering and restarts + // truncate long-lived streams; it also has nothing to add, since + // nothing on that path is a browser. Falls back to the browser + // authority when the two are the same (release). + let local_host = embedded + .listener_host + .clone() + .unwrap_or_else(|| expected_host.clone()); + let mut expected_hosts = vec![embedded.expected_host]; + if let Some(listener_host) = embedded.listener_host { + if listener_host != expected_hosts[0] { + expected_hosts.push(listener_host); + } + } + let mount_token = generate_mount_token(); + let auth = client_auth::ClientAuth::embedded( + expected_hosts, + embedded.control_origin, + bootstrap_secrets, + stable_session_token(&opts.app_root), + mount_token.clone(), + ) + .map_err(|message| StartError::EmbeddedAuth { message })?; + // The two non-browser clients that must satisfy this process's own + // guard. Published once here rather than threaded through + // `AppState`, which this module family may not add fields to. + if let Some(session_token) = auth.session_token() { + sandbox::org_mount::set_credentials(sandbox::org_mount::MountCredentials { + base_url: format!("{control_scheme}://{local_host}"), + origin: control_origin, + cookie: format!("{}={session_token}", client_auth::LOCAL_SESSION_COOKIE_NAME), + mount_token, + ca_cert: opts.tls.as_ref().map(|tls| tls.ca.clone()), + }); + } + // Previews are served per-sandbox at `.`: + // the same registrable domain, so the iframe stays first-party and + // can hold cookies, but a distinct host, so each sandbox gets its + // own cookie jar. See `src-tauri/src/control_origin.rs`. + if let Some(host) = expected_host.split(':').next() { + routes::intercept::set_preview_host(host.to_string()); + } + (auth, embedded.ui_assets, preview_cookie_selftest_origin) + } + }; + + let repo_dir = opts.app_root.join("repo"); + // Byte-parity with the daemon's boot-time `mkdirSync(repoDir, { + // recursive: true })` — bash commands with the default cwd shouldn't + // ENOENT before anything has been written into the workspace yet. + std::fs::create_dir_all(&repo_dir).map_err(|source| StartError::RepoDir { + path: repo_dir.clone(), + source, + })?; + // `.decocms/` is local-api's OWN directory (distinct from a project's + // `.deco/tools/` catalog dir): lockfiles, run-stream spools and staged + // mutations. The SQLite store itself is `/studio.db`. + let decocms_dir = opts.app_root.join(".decocms"); + std::fs::create_dir_all(&decocms_dir).map_err(|source| StartError::DecocmsDir { + path: decocms_dir.clone(), + source, + })?; + let instance_lock_path = decocms_dir.join("local-api.lock"); + let instance_lock = Arc::new( + acquire_instance_lock(&instance_lock_path).map_err(|source| StartError::InstanceLock { + path: instance_lock_path, + source, + })?, + ); + // The main instance lock above is deliberately first: a genuinely LIVE + // second process still fails immediately. After SIGKILL the OS releases + // that main lock, while independent watchdogs survive long enough to reap + // old harnesses/tasks. Every watchdog inherited a SHARED lock on this + // separate file; the successor waits for EXCLUSIVE ownership before any + // mutation/queue recovery can observe or promote durable work. + let child_lifetime_lock_path = shared_child_lifetime_lock_path(&opts.app_root); + let child_recovery_fence = + acquire_child_recovery_fence(&child_lifetime_lock_path, CHILD_CRASH_REAP_TIMEOUT) + .await + .map_err(|source| StartError::ChildLifetimeFence { + path: child_lifetime_lock_path.clone(), + source, + })?; + // Staged downloads/catalog swaps and rename-to-trash deletions live + // outside the repository. A crash can leave them behind, so the process + // that owns the app-root lock recovers journaled catalog swaps and clears + // only abort-safe stages before accepting requests. In particular, a + // `previous-catalog` directory may be the only known-good copy and must + // never be blindly deleted. Never recover before acquiring the lock: a + // losing second instance must not touch the live owner's stages. + let mutation_stage_root = decocms_dir.join("mutations"); + routes::fs::recover_mutation_stages(&mutation_stage_root, &repo_dir) + .await + .map_err(|source| StartError::MutationRecovery { + path: mutation_stage_root, + source, + })?; + + let config = Arc::new(ConfigStore::new()); + // `/logs` — the durable, file-backed home for retained log + // output (see the `log_store` module doc: files are the source of truth, + // RAM holds only transient live fan-out). Sibling of `repo_dir` above, + // same as a per-handle `Sandbox`'s own `logs` dir is a sibling of its + // `repo` (see `sandbox/manager.rs::get_or_create`). + let logs = Arc::new(log_store::LogStore::new(opts.app_root.join("logs"))); + let tasks = Arc::new(TaskRegistry::new_with_child_lifetime_lock( + logs, + child_lifetime_lock_path.clone(), + )); + let broadcaster = Arc::new(Broadcaster::new()); + let setup_orchestrator = SetupOrchestrator::new( + repo_dir.clone(), + opts.app_root.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + let shutdown = Arc::new(ShutdownCoordinator::new()); + // `/sandboxes//repo` — created lazily per handle by + // `SandboxManager::ensure`, not eagerly here (unlike `repo_dir` above, + // which every plain-path request needs immediately). + let sandbox_manager = SandboxManager::new(opts.app_root.clone()); + + let state = AppState { + token: opts.token.into(), + boot_id: opts.boot_id.into(), + app_root: opts.app_root, + repo_dir, + mode: opts.mode, + config, + tasks: tasks.clone(), + broadcaster, + shutdown: shutdown.clone(), + setup: setup_orchestrator, + sandbox_manager, + }; + + // Git family's SIGTERM/shutdown publish hook — see + // `routes/git.rs`'s module doc ("Shutdown-hook wiring"). + routes::git::register(&state); + + // Clear worktree registrations orphaned by a sandbox directory that + // vanished while the app was down; git would otherwise refuse to re-add a + // worktree at that path. Detached and best-effort — it only walks the repo + // store, so it must never delay serving. + let prune_root = state.app_root.clone(); + tokio::spawn(async move { + sandbox::repo_store::prune_all(&prune_root).await; + // Org mounts outlive the process that served them: a SIGKILLed app + // leaves the kernel holding NFS mounts backed by nothing, and the + // sandbox that owns each path can never be re-provisioned there until + // it is reclaimed. See `sandbox::org_mount`. + sandbox::org_mount::prune_stale_mounts(&prune_root).await; + }); + + let listener = TcpListener::bind(("127.0.0.1", opts.port)) + .await + .map_err(|source| StartError::Bind { + port: opts.port, + source, + })?; + let bound_port = listener.local_addr().map(|a| a.port()).unwrap_or(opts.port); + + // The preview listener always binds an EPHEMERAL port — there is no + // "requested preview port" the way `opts.port` can pin the main one; + // callers only ever learn it back via `ServerHandle::preview_port`. + let preview_listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|source| StartError::Bind { port: 0, source })?; + let preview_port = preview_listener.local_addr().map(|a| a.port()).unwrap_or(0); + // Publish it for the SANDBOX_START intercept, which reports this listener + // as the sandbox's `previewUrl` (see that module's `preview_url`). + routes::intercept::set_preview_port(preview_port); + + // Both listeners are known-good before durable queued work is resumed, so + // a bind failure can never launch an agent in a process that won't serve. + // Recovery still runs before either router starts accepting requests. + // Release EXCLUSIVE immediately before recovery. Any harness or task that + // recovery spawns takes SHARED ownership through the same path before its + // command starts. With the main instance lock still held, no other process + // can enter this handoff. + drop(child_recovery_fence); + routes::intercept::decopilot::recover_durable_queue(&state) + .await + .map_err(|message| StartError::LocalStore { message })?; + + // One rustls config shared by both listeners: they serve the same leaf, + // which covers the control host and the `*.` preview wildcard. + // Loaded BEFORE either task spawns so a bad certificate fails the whole + // start rather than leaving one listener silently on plain HTTP. + let tls_config = match opts.tls.as_ref() { + Some(files) => { + // rustls 0.23 has no default crypto provider and PANICS on first + // use without one. Installing is idempotent-by-ignoring: a second + // call returns Err because another provider is already set, which + // is exactly the state we want. + let _ = rustls::crypto::ring::default_provider().install_default(); + Some( + axum_server::tls_rustls::RustlsConfig::from_pem_file(&files.cert, &files.key) + .await + .map_err(|source| StartError::Tls { + path: files.cert.clone(), + source, + })?, + ) + } + None => None, + }; + + // Mirrors `preview_url`'s gate. Under a real domain each sandbox has its + // own host, so the CSP needs a WILDCARD — which matches at least one + // label, exactly a handle, and deliberately NOT the bare host. Under a + // single-label host every sandbox shares one origin, and a wildcard would + // match none of it. + let preview_base = routes::intercept::preview_host_base(); + let preview_scheme = if tls_config.is_some() { + "https" + } else { + "http" + }; + let preview_csp_origin = if preview_base.contains('.') { + format!("{preview_scheme}://*.{preview_base}:{preview_port}") + } else { + format!("{preview_scheme}://{preview_base}:{preview_port}") + }; + let app = router::build(state.clone(), client_auth, ui_assets, preview_csp_origin); + let preview_app = router::build_preview( + state.clone(), + preview_port, + preview_base, + preview_cookie_selftest_origin, + ); + + // Previews follow the listeners' scheme; the webview refuses a plain-http + // iframe inside an https shell (mixed content) and the URL must match. + routes::intercept::set_preview_scheme(if tls_config.is_some() { + "https" + } else { + "http" + }); + + let shutdown_notify = Arc::new(Notify::new()); + let notify_for_task = shutdown_notify.clone(); + let main_instance_lock = instance_lock.clone(); + let main_tls = tls_config.clone(); + let serve_task = tokio::spawn(async move { + let _instance_lock = main_instance_lock; + serve_maybe_tls(listener, app, main_tls, notify_for_task, "local-api server").await; + }); + + // A SEPARATE `Notify` (not the main listener's) — each is a one-shot, + // single-consumer signal (`notify_one` stores a permit for the first + // future `notified().await` even if called before that call starts + // waiting), so two listeners sharing ONE `Notify` would risk + // `notify_one()` waking only whichever task happened to be waiting + // first, leaving the other's serve loop running past shutdown. + let preview_shutdown_notify = Arc::new(Notify::new()); + let preview_notify_for_task = preview_shutdown_notify.clone(); + let preview_instance_lock = instance_lock.clone(); + let preview_tls = tls_config.clone(); + let preview_serve_task = tokio::spawn(async move { + let _instance_lock = preview_instance_lock; + serve_maybe_tls( + preview_listener, + preview_app, + preview_tls, + preview_notify_for_task, + "local-api preview server", + ) + .await; + }); + + Ok(ServerHandle { + port: bound_port, + preview_port, + state, + shutdown_notify, + preview_shutdown_notify, + serve_task, + preview_serve_task, + _instance_lock: instance_lock, + }) +} + +fn acquire_instance_lock(path: &Path) -> std::io::Result { + // Owner-only from creation (and clamped when pre-existing) so the + // ownership fence never leaks app metadata — see `fs_util`. + let file = fs_util::open_owner_only(path)?; + file.try_lock()?; + Ok(file) +} + +async fn acquire_child_recovery_fence(path: &Path, budget: Duration) -> std::io::Result { + let file = fs_util::open_owner_only(path)?; + acquire_exclusive_with_budget(file, path, budget).await +} + +async fn acquire_exclusive_with_budget( + file: File, + path: &Path, + budget: Duration, +) -> std::io::Result { + let deadline = tokio::time::Instant::now() + budget; + loop { + match file.try_lock() { + Ok(()) => return Ok(file), + Err(std::fs::TryLockError::WouldBlock) => { + if tokio::time::Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "timed out after {}ms waiting for old child watchdogs on {path:?}", + budget.as_millis() + ), + )); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(std::fs::TryLockError::Error(error)) => return Err(error), + } + } +} + +/// The `local-api` binary's entire boot sequence — see the module doc +/// comment. Reads the env contract, prints the discovery stdout lines, +/// serves until SIGTERM/Ctrl+C, then runs shutdown hooks. Exits the +/// process (`std::process::exit(1)`) on any boot failure, matching the old +/// `main()` body's behavior exactly. +pub async fn boot_from_env() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + // Signal ownership must exist before `start()` opens and recovers the + // durable queue. A dev-loop SIGTERM delivered during that await used to + // take the platform default path and bypass every shutdown fence. The + // listener records the first signal without cancelling startup; once + // startup returns, the already-queued request flows through the normal + // ordered `ServerHandle::shutdown`. A second signal remains an operator + // escape hatch if recovery or shutdown itself is wedged. + let termination = InstalledTerminationSignal::install(); + + let token = resolve_token(); + let boot_id = resolve_boot_id(); + let app_root = resolve_app_root(); + let mode = ApiMode::from_env(); + // Bind ONLY 127.0.0.1, never 0.0.0.0 — deliberate divergence from the + // daemon (which binds 0.0.0.0, acceptable for a sandboxed cluster + // pod/VM, unacceptable for a process running on a user's laptop). See + // the native local-API contract. + let port = resolve_port(); + + let handle = match start(StartOptions { + token, + boot_id, + app_root, + port, + mode, + // The standalone binary serves plain HTTP: local HTTPS exists for the + // desktop webview's origin, and nothing here has a webview. + tls: None, + }) + .await + { + Ok(h) => h, + Err(err) => { + eprintln!("[local-api] {err}"); + std::process::exit(1); + } + }; + + // A test harness that requested port 0 (ephemeral) discovers the real + // port from this line — see the contract doc's `LOCAL_API_PORT` row. + // Printed unconditionally (not only when 0 was requested) since it's + // harmless and keeps the contract simple. + println!("LOCAL_API_PORT={}", handle.port()); + // The preview listener's port is ALWAYS ephemeral (see `start`'s doc + // comment) — this is the only way a caller (an e2e harness, in + // particular) ever learns it. + println!("LOCAL_API_PREVIEW_PORT={}", handle.preview_port()); + tracing::info!(port = handle.port(), preview_port = handle.preview_port(), boot_id = %handle.state().boot_id, "local-api listening"); + + termination.wait().await; + tracing::info!("shutdown signal received, running shutdown hooks"); + handle.shutdown().await; +} + +/// Resolves `LOCAL_API_TOKEN` (canonical) or `DAEMON_TOKEN` (legacy alias +/// — required so the daemon-parity oracle's `daemon.e2e.helpers.ts` can +/// spawn this binary unmodified). New name wins when both are set. If +/// neither is set, generates a fresh 32-byte token, hex-encoded, and +/// prints it as `LOCAL_API_TOKEN=` on its own line before the +/// process starts listening — mirrors how the token reaches a caller today +/// when nothing pre-seeds it. +fn resolve_token() -> String { + if let Ok(v) = std::env::var("LOCAL_API_TOKEN") { + if !v.is_empty() { + return v; + } + } + if let Ok(v) = std::env::var("DAEMON_TOKEN") { + if !v.is_empty() { + return v; + } + } + let token = generate_token(); + println!("LOCAL_API_TOKEN={token}"); + token +} + +fn generate_token() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// `LOCAL_API_BOOT_ID` / `DAEMON_BOOT_ID`, else a generated UUID — mirrors +/// `entry.ts` generating `DAEMON_BOOT_ID` when unset. +fn resolve_boot_id() -> String { + std::env::var("LOCAL_API_BOOT_ID") + .or_else(|_| std::env::var("DAEMON_BOOT_ID")) + .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()) +} + +/// `LOCAL_API_WORKDIR` / `APP_ROOT` — required. Unlike the daemon (which +/// falls back to `"/"`), local-api refuses to boot without an explicit +/// workdir: a silent `/` clamp on a user's laptop is a much scarier +/// default than it is inside a disposable sandbox container. +fn resolve_app_root() -> PathBuf { + let raw = std::env::var("LOCAL_API_WORKDIR") + .or_else(|_| std::env::var("APP_ROOT")) + .unwrap_or_else(|_| { + eprintln!( + "[local-api] missing required env: set LOCAL_API_WORKDIR (or the legacy APP_ROOT alias)" + ); + std::process::exit(1); + }); + PathBuf::from(raw) +} + +/// `LOCAL_API_PORT` / `PROXY_PORT` — required. `0` picks an ephemeral port +/// (see `resolve_token`'s sibling stdout contract for how a caller +/// discovers it). +fn resolve_port() -> u16 { + let raw = std::env::var("LOCAL_API_PORT") + .or_else(|_| std::env::var("PROXY_PORT")) + .unwrap_or_else(|_| { + eprintln!( + "[local-api] missing required env: set LOCAL_API_PORT (or the legacy PROXY_PORT alias)" + ); + std::process::exit(1); + }); + raw.parse().unwrap_or_else(|_| { + eprintln!("[local-api] invalid port value: {raw:?}"); + std::process::exit(1); + }) +} + +/// Pre-armed standalone-binary signal receiver. The Tauri shell has its own +/// equivalent in `src-tauri/src/setup.rs`, routing signals through +/// `AppHandle::exit` and the retained embedded [`ServerHandle`]. +struct InstalledTerminationSignal { + first: tokio::sync::oneshot::Receiver<()>, +} + +impl InstalledTerminationSignal { + fn install() -> Self { + let (first_tx, first) = tokio::sync::oneshot::channel(); + + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + // `signal(...)` installs Tokio's process-wide handlers + // synchronously here—before the startup/recovery future is first + // polled. The spawned task only waits on already-installed streams. + let mut terminate = + signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + let mut interrupt = + signal(SignalKind::interrupt()).expect("failed to install SIGINT handler"); + tokio::spawn(async move { + let first_name = tokio::select! { + _ = terminate.recv() => "SIGTERM", + _ = interrupt.recv() => "SIGINT", + }; + tracing::info!(signal = first_name, "standalone shutdown signal queued"); + let _ = first_tx.send(()); + + let second_name = tokio::select! { + _ = terminate.recv() => "SIGTERM", + _ = interrupt.recv() => "SIGINT", + }; + tracing::error!( + signal = second_name, + "second standalone shutdown signal received; forcing exit" + ); + std::process::exit(1); + }); + } + + #[cfg(not(unix))] + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + let _ = first_tx.send(()); + let _ = tokio::signal::ctrl_c().await; + std::process::exit(1); + }); + + Self { first } + } + + async fn wait(self) { + let _ = self.first.await; + } +} + +/// Serve `app` on `listener`, over TLS when configured, until `shutdown` fires. +/// +/// `axum::serve` and `axum_server` have different shutdown shapes, so this +/// keeps the choice in one place instead of forking every call site. +async fn serve_maybe_tls( + listener: TcpListener, + app: axum::Router, + tls: Option, + shutdown: Arc, + label: &'static str, +) { + let Some(tls) = tls else { + let result = axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown.notified().await; + }) + .await; + if let Err(err) = result { + tracing::error!(error = %err, "{label} error"); + } + return; + }; + + let std_listener = match listener.into_std() { + Ok(listener) => listener, + Err(err) => { + tracing::error!(error = %err, "{label} could not take the listener for TLS"); + return; + } + }; + let handle = axum_server::Handle::new(); + let shutdown_handle = handle.clone(); + tokio::spawn(async move { + shutdown.notified().await; + // `None` — connections are dropped at once rather than lingering; the + // only clients are this app's own webview and its children. + shutdown_handle.graceful_shutdown(None); + }); + let result = axum_server::from_tcp_rustls(std_listener, tls) + .handle(handle) + .serve(app.into_make_service()) + .await; + if let Err(err) = result { + tracing::error!(error = %err, "{label} error"); + } +} + +#[cfg(test)] +mod tests { + use std::fs::OpenOptions; + + use super::*; + + #[test] + fn instance_lock_is_exclusive_and_kernel_released() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("local-api.lock"); + let first = acquire_instance_lock(&path).unwrap(); + let error = acquire_instance_lock(&path).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + + drop(first); + // Parallel process-spawning tests can briefly retain a fork-inherited + // descriptor before exec closes it. The kernel release is still + // bounded; avoid asserting on that transient pre-exec window. + let deadline = std::time::Instant::now() + Duration::from_secs(1); + let _restarted = loop { + match acquire_instance_lock(&path) { + Ok(lock) => break lock, + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && std::time::Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("instance lock was not released: {error}"), + } + }; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[tokio::test] + async fn child_recovery_fence_waits_for_shared_watchdog_and_is_bounded() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("child-lifetime.lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + let shared = options.open(&path).unwrap(); + shared.try_lock_shared().unwrap(); + + let error = acquire_child_recovery_fence(&path, Duration::from_millis(40)) + .await + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + + drop(shared); + let exclusive = acquire_child_recovery_fence(&path, Duration::from_secs(1)) + .await + .expect("exclusive recovery ownership after shared watchdog exits"); + let contender = options.open(&path).unwrap(); + assert!(matches!( + contender.try_lock_shared(), + Err(std::fs::TryLockError::WouldBlock) + )); + drop(exclusive); + } + + #[tokio::test] + async fn server_start_rejects_same_root_until_owner_shuts_down() { + let dir = tempfile::tempdir().unwrap(); + let options = |boot_id: &str| StartOptions { + tls: None, + token: "t".repeat(32), + boot_id: boot_id.to_string(), + app_root: dir.path().to_path_buf(), + port: 0, + mode: ApiMode::Strict, + }; + let first = start(options("first")).await.unwrap(); + match start(options("second")).await { + Err(StartError::InstanceLock { source, .. }) => { + assert_eq!(source.kind(), std::io::ErrorKind::WouldBlock) + } + Ok(second) => { + second.shutdown().await; + panic!("a second server acquired the live app root") + } + Err(error) => panic!("unexpected second-start error: {error}"), + } + + first.shutdown().await; + let deadline = tokio::time::Instant::now() + Duration::from_secs(1); + let restarted = loop { + match start(options("restarted")).await { + Ok(server) => break server, + Err(StartError::InstanceLock { source, .. }) + if source.kind() == std::io::ErrorKind::WouldBlock + && tokio::time::Instant::now() < deadline => + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(error) => panic!("server could not restart after shutdown: {error}"), + } + }; + restarted.shutdown().await; + } +} diff --git a/apps/native/crates/local-api/src/log_store/mod.rs b/apps/native/crates/local-api/src/log_store/mod.rs new file mode 100644 index 0000000000..90b3ad2d43 --- /dev/null +++ b/apps/native/crates/local-api/src/log_store/mod.rs @@ -0,0 +1,54 @@ +//! File-backed log retention — the durable, on-disk half of this crate's +//! log pipeline. Owner decision (see the module-owner report this shipped +//! with): **files are the source of truth for RETAINED output; RAM holds +//! only the TRANSIENT live fan-out**. Concretely: +//! +//! - [`store::LogStore`] owns every byte anyone might want to read back +//! later — SSE replay-on-connect frames (`routes/events.rs`), a task's +//! buffered output (`GET /_sandbox/tasks/:id`), and a task's own +//! replay-then-live stream (`GET /_sandbox/tasks/:id/stream`). One +//! instance per app workdir (the process-global one wired in `lib.rs`, +//! one more per `sandbox::manager::Sandbox`), rooted at `/logs`. +//! - The broadcaster (`events::Broadcaster`) and a task's own +//! `chunks_tx` (`tasks::registry::TaskEntry`) still exist, but ONLY as +//! in-memory fan-out for whoever is live-subscribed RIGHT NOW — neither +//! buffers a backlog anymore. A subscriber that connects late gets +//! caught up by reading [`store::LogStore`] instead. +//! +//! Byte-parity target: `packages/sandbox/daemon/process/log-tee.ts` +//! (`LogTee`, the at-cap rotation behavior — see [`store::WRITE_CAP_BYTES`]) +//! and `packages/sandbox/daemon/events/replay.ts` (`ReplayBuffer`, the +//! tail-read size — see [`store::DEFAULT_TAIL_BYTES`]). The old daemon kept +//! these as TWO separate mechanisms (an in-RAM 256KB replay buffer feeding +//! SSE, a 10MB on-disk tee purely for audit) that happened to duplicate the +//! same bytes; this crate collapses them into ONE file-backed mechanism +//! that serves both jobs — write once at up to 10MB (with LogTee-style +//! rotation), read back only the last [`store::DEFAULT_TAIL_BYTES`] for a +//! replay frame. +//! +//! Two independent key namespaces share the same [`store::LogStore`] root +//! (see [`store::app_key`] and `tasks::registry`'s task-scoped keys): +//! - `"app/"` — a STABLE, reused path per named source +//! (`"setup"`, `"dev"`, `"start"` stay verbatim; unsafe names use a +//! reversible encoding), mirroring the old daemon's +//! `appLogPath(logsDir, name)` layout. Fed by the setup pipeline +//! (`setup/{clone,install,dev}.rs`); read by `routes/events.rs`'s +//! connect-time `"log"` frame replay. +//! - `"tasks//.{out,err}"` — a FRESH private path per +//! task even though daemon-compatible public ids restart at `task1` each +//! boot, split by stream like the old in-RAM per-task ring buffers. Fed and +//! read by `tasks::registry::TaskRegistry`; cleaned up when a task is deleted +//! (`DELETE /_sandbox/tasks/:id`). +//! +//! One deliberate behavior change from the old (fully in-RAM) daemon: since +//! files persist across a `local-api` relaunch, a fresh SSE connect can +//! replay a transcript that a PREVIOUS process instance wrote (e.g. the +//! last clone's output, if the app was quit and reopened before a new one +//! ran). The old daemon lost all replay history on every restart (nothing +//! was ever persisted). This is intentional, not a bug: it follows directly +//! from "files are the source of truth" and the wire contract (frame +//! shape/timing) is unchanged either way. + +pub mod store; + +pub use store::{app_key, LogStore, DEFAULT_TAIL_BYTES}; diff --git a/apps/native/crates/local-api/src/log_store/store.rs b/apps/native/crates/local-api/src/log_store/store.rs new file mode 100644 index 0000000000..1a5ab45315 --- /dev/null +++ b/apps/native/crates/local-api/src/log_store/store.rs @@ -0,0 +1,627 @@ +//! [`LogStore`] — async, file-backed append-only log retention. See the +//! `log_store` module doc for the design rationale (files are the source of +//! truth for retained output; RAM holds only transient live fan-out). + +use std::collections::HashMap; +use std::path::PathBuf; + +use tokio::fs::{self, File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufWriter}; +use tokio::sync::Mutex; + +/// Byte-parity with `LOG_MAX_BYTES` (`process/task-manager.ts`) / +/// `INSTALL_LOG_MAX_BYTES` (`setup/orchestrator.ts`) — both 10MB in the old +/// daemon. Once a file's accumulated size would exceed this, [`LogStore::append`] +/// rotates: flush + drop the old handle, delete the file, reopen fresh, and +/// write a `[log rotated at N bytes]` marker line before the chunk that +/// triggered rotation — byte-parity in behavior (not text: the old marker +/// used a bare `maxBytes` number too) with `process/log-tee.ts::LogTee`'s +/// `rotate()`. +pub const WRITE_CAP_BYTES: u64 = 10 * 1024 * 1024; + +/// Byte-parity with `REPLAY_BYTES` (`packages/sandbox/daemon/constants.ts`) +/// — the default/expected read size for an SSE replay-on-connect frame: +/// only the LAST 256KB of a (possibly up-to-10MB) file is ever handed back +/// by [`LogStore::tail_read`] for that purpose. Callers may pass any +/// `max_bytes`; this is just the value `routes/events.rs` and +/// `tasks::registry` both use, so they agree without hardcoding it twice. +pub const DEFAULT_TAIL_BYTES: usize = 256 * 1024; + +/// Builds the `"app/"` [`LogStore`] key for a named, STABLE log +/// source (`"setup"`, `"dev"`, `"start"`) — byte-parity in spirit with the +/// old daemon's `appLogPath(logsDir, name)` on-disk layout. Shared by every +/// writer (`setup/{clone,install,dev}.rs`) and the one reader +/// (`routes/events.rs`) so both sides always agree on the exact key for a +/// given source name. +pub fn app_key(source: &str) -> String { + format!("app/{}", encode_app_source(source)) +} + +/// Unsafe source names must remain reversible when the file-backed catalog is +/// rebuilt after a process restart. A lossy filename sanitizer made +/// `dev:web` and `dev_web` share one file and replayed both as `dev_web`. +/// Keep the familiar `app/setup` and `app/dev` paths for ordinary names; use a +/// reserved, hex-encoded form for everything else (including names beginning +/// with the reserved prefix, so the mapping stays one-to-one). +const APP_SOURCE_ESCAPE_PREFIX: &str = "__decocms_source_v1__"; + +fn encode_app_source(source: &str) -> String { + let can_use_verbatim = !source.is_empty() + && !source.starts_with(APP_SOURCE_ESCAPE_PREFIX) + && !source.contains('/') + && sanitize_segment(source) == source; + if can_use_verbatim { + return source.to_string(); + } + + let mut encoded = String::with_capacity(APP_SOURCE_ESCAPE_PREFIX.len() + source.len() * 2); + encoded.push_str(APP_SOURCE_ESCAPE_PREFIX); + for byte in source.as_bytes() { + use std::fmt::Write as _; + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} + +fn decode_app_source(filename: &str) -> Option { + let Some(hex) = filename.strip_prefix(APP_SOURCE_ESCAPE_PREFIX) else { + return Some(filename.to_string()); + }; + if hex.len() % 2 != 0 { + return None; + } + let mut bytes = Vec::with_capacity(hex.len() / 2); + for pair in hex.as_bytes().chunks_exact(2) { + let pair = std::str::from_utf8(pair).ok()?; + bytes.push(u8::from_str_radix(pair, 16).ok()?); + } + String::from_utf8(bytes).ok() +} + +struct Writer { + file: BufWriter, + /// Bytes written since this file was last (re)opened fresh — reset to + /// the marker's length on rotation, seeded from the on-disk file size + /// the FIRST time a key is opened (so a file `LogStore` didn't itself + /// write this process's lifetime, e.g. left over from truncate/rotate + /// bookkeeping, still counts correctly toward the cap). + written: u64, + /// Latches `true` the first time this key rotates and never resets — + /// byte-parity with `LogTee.isTruncated()`'s `rotatedOnce` flag. + rotated: bool, +} + +/// See the `log_store` module doc for the full design. One instance per app +/// workdir, rooted at `/logs`. +pub struct LogStore { + root: PathBuf, + cap_bytes: u64, + writers: Mutex>, + #[cfg(test)] + append_gate: std::sync::Mutex>, +} + +#[cfg(test)] +struct AppendTestGate { + entered: std::sync::Arc, + release: std::sync::Arc, +} + +impl LogStore { + pub fn new(root: PathBuf) -> Self { + Self::with_cap(root, WRITE_CAP_BYTES) + } + + pub(crate) fn root(&self) -> &std::path::Path { + &self.root + } + + /// Same as [`Self::new`] but with a caller-chosen rotation cap — used by + /// this module's own tests to exercise rotation without writing 10MB of + /// fixture data. Not `#[cfg(test)]`-gated so other modules' tests in + /// this crate can use it too (still `pub(crate)`: no reason for an + /// embedder to override the byte-parity cap). + pub(crate) fn with_cap(root: PathBuf, cap_bytes: u64) -> Self { + Self { + root, + cap_bytes, + writers: Mutex::new(HashMap::new()), + #[cfg(test)] + append_gate: std::sync::Mutex::new(None), + } + } + + /// Maps a `/`-separated key (e.g. `"app/setup"`, `"tasks/task5.out"`) + /// onto a real path under `root`, sanitizing EACH segment + /// independently — a source/task-id name is never trusted verbatim as + /// a path component (no traversal, no hidden-file/absolute-path + /// confusion). + fn path_for(&self, key: &str) -> PathBuf { + let mut path = self.root.clone(); + for segment in key.split('/') { + path.push(sanitize_segment(segment)); + } + path + } + + /// Appends `data` to `key`'s file, rotating first if this write would + /// push the file past the configured cap. A no-op for empty `data` or + /// on any I/O error (log writes must never crash the pipeline that + /// produced them — byte-parity in spirit with `LogTee.write()`'s own + /// swallowed-error catch blocks). + /// + /// Returns once the write is flushed to the OS — callers MUST complete + /// this await before emitting the corresponding LIVE broadcast frame, + /// so a client that connects between the two never sees "neither + /// replay nor live" for that chunk (see the `log_store` module doc). + pub async fn append(&self, key: &str, data: &str) { + if data.is_empty() { + return; + } + #[cfg(test)] + self.wait_on_append_gate().await; + let bytes = data.as_bytes(); + let mut guard = self.writers.lock().await; + if !self.ensure_writer(&mut guard, key).await { + return; + } + let would_exceed = guard + .get(key) + .map(|w| w.written + bytes.len() as u64 > self.cap_bytes) + .unwrap_or(false); + if would_exceed { + self.rotate(&mut guard, key).await; + } + if let Some(writer) = guard.get_mut(key) { + Self::write_bytes(writer, bytes).await; + } + } + + /// Resets `key` to an empty, fresh file — byte-parity in spirit with + /// `setup/orchestrator.ts`'s `unlinkSync(cloneLogPath)` / + /// `unlinkSync(installLogPath)` right before opening a new tee for a + /// FRESH clone/install run. Used by `setup/clone.rs` exactly once per + /// genuinely fresh clone (not on a checkout-existing-repo re-run) — see + /// that file's own doc comment for why only that branch resets it. + pub async fn truncate(&self, key: &str) { + let mut guard = self.writers.lock().await; + self.close_and_delete(&mut guard, key).await; + } + + /// Removes `key` entirely — closes any open handle and deletes the + /// file. Used by `tasks::registry::TaskRegistry::remove` to clean up a + /// deleted task's per-task files (lifecycle completeness: no orphaned + /// files survive a task the caller explicitly removed). + pub async fn remove(&self, key: &str) { + let mut guard = self.writers.lock().await; + self.close_and_delete(&mut guard, key).await; + } + + /// `true` once `key` has rotated at least once (this process's + /// lifetime) — surfaced as `TaskSummary::truncated`. + pub async fn is_truncated(&self, key: &str) -> bool { + let guard = self.writers.lock().await; + guard.get(key).map(|w| w.rotated).unwrap_or(false) + } + + /// Returns the last (up to) `max_bytes` bytes of `key`'s file, decoded + /// lossily (a byte offset chosen purely by size can land mid-codepoint; + /// `from_utf8_lossy` turns any truncated sequence into replacement + /// characters rather than panicking or corrupting the rest of the + /// text). Missing file / any I/O error -> `""` (never a hard failure — + /// a replay frame with nothing to show is just skipped by the caller). + pub async fn tail_read(&self, key: &str, max_bytes: usize) -> String { + let path = self.path_for(key); + // Also the missing-file gate: clamping a file that does not exist + // fails, which is exactly the "" case documented above. + if crate::fs_util::set_owner_only_async(&path).await.is_err() { + return String::new(); + } + let Ok(mut file) = File::open(&path).await else { + return String::new(); + }; + let Ok(metadata) = file.metadata().await else { + return String::new(); + }; + let len = metadata.len(); + let start = len.saturating_sub(max_bytes as u64); + if start > 0 && file.seek(std::io::SeekFrom::Start(start)).await.is_err() { + return String::new(); + } + let mut buf = Vec::new(); + if file.read_to_end(&mut buf).await.is_err() { + return String::new(); + } + String::from_utf8_lossy(&buf).into_owned() + } + + /// Lists every stable application-log source currently retained on disk. + /// + /// The filesystem is the source of truth for replay, so this deliberately + /// does not consult [`crate::tasks::TaskRegistry`]. A restarted process has + /// an empty task registry while `/logs/app/*` still contains the + /// setup/dev transcripts the terminal must replay. Results are sorted for + /// deterministic SSE snapshot ordering and only regular, safely-named + /// files are returned (directories and symlinks are ignored). + pub async fn app_sources(&self) -> Vec { + let app_dir = self.root.join("app"); + let Ok(mut entries) = fs::read_dir(app_dir).await else { + return Vec::new(); + }; + let mut sources = Vec::new(); + loop { + let entry = match entries.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) | Err(_) => break, + }; + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_file() { + continue; + } + let Ok(filename) = entry.file_name().into_string() else { + continue; + }; + if filename.is_empty() || sanitize_segment(&filename) != filename { + continue; + } + let Some(source) = decode_app_source(&filename) else { + continue; + }; + sources.push(source); + } + sources.sort_unstable(); + sources.dedup(); + sources + } + + /// Drops an already-flushed writer handle without deleting its retained + /// file. Task finalization uses this to keep file descriptors and writer + /// metadata bounded by the actively-writing task set rather than every + /// task seen since boot. A concurrent write owns the mutex and makes this + /// a harmless no-op; its next finalization/removal gets another chance. + pub(crate) fn close_writer_if_idle(&self, key: &str) { + if let Ok(mut guard) = self.writers.try_lock() { + guard.remove(key); + } + } + + #[cfg(test)] + pub(crate) fn block_next_append( + &self, + ) -> ( + std::sync::Arc, + std::sync::Arc, + ) { + let entered = std::sync::Arc::new(tokio::sync::Semaphore::new(0)); + let release = std::sync::Arc::new(tokio::sync::Semaphore::new(0)); + let mut guard = self + .append_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = Some(AppendTestGate { + entered: entered.clone(), + release: release.clone(), + }); + (entered, release) + } + + #[cfg(test)] + async fn wait_on_append_gate(&self) { + let gate = self + .append_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + let Some(gate) = gate else { + return; + }; + gate.entered.add_permits(1); + if let Ok(permit) = gate.release.acquire().await { + permit.forget(); + }; + } + + #[cfg(test)] + pub(crate) async fn open_writer_count(&self) -> usize { + self.writers.lock().await.len() + } + + async fn ensure_writer(&self, guard: &mut HashMap, key: &str) -> bool { + if guard.contains_key(key) { + return true; + } + let path = self.path_for(key); + if let Some(parent) = path.parent() { + if fs::create_dir_all(parent).await.is_err() { + return false; + } + } + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + options.mode(0o600); + let Ok(file) = options.open(&path).await else { + return false; + }; + if crate::fs_util::set_owner_only_async(&path).await.is_err() { + return false; + } + let written = fs::metadata(&path).await.map(|m| m.len()).unwrap_or(0); + guard.insert( + key.to_string(), + Writer { + file: BufWriter::new(file), + written, + rotated: false, + }, + ); + true + } + + async fn rotate(&self, guard: &mut HashMap, key: &str) { + self.close_and_delete(guard, key).await; + if !self.ensure_writer(guard, key).await { + return; + } + let marker = format!("[log rotated at {} bytes]\n", self.cap_bytes); + if let Some(writer) = guard.get_mut(key) { + Self::write_bytes(writer, marker.as_bytes()).await; + writer.rotated = true; + } + } + + async fn close_and_delete(&self, guard: &mut HashMap, key: &str) { + if let Some(mut writer) = guard.remove(key) { + let _ = writer.file.flush().await; + } + let path = self.path_for(key); + let _ = fs::remove_file(&path).await; + } + + async fn write_bytes(writer: &mut Writer, bytes: &[u8]) { + if writer.file.write_all(bytes).await.is_err() { + return; + } + if writer.file.flush().await.is_err() { + return; + } + writer.written += bytes.len() as u64; + } +} + +/// Lowercases nothing (source/task-id names are already the display form) — +/// just maps every char outside `[A-Za-z0-9._-]` to `_` and strips leading +/// dots, so a key segment can never escape `root` (no `..`, no absolute +/// path, no hidden file) regardless of what a future caller passes as a +/// source/task-id name. +fn sanitize_segment(raw: &str) -> String { + let mapped: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + let trimmed = mapped.trim_start_matches('.'); + if trimmed.is_empty() { + "_".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store(cap: u64) -> (tempfile::TempDir, LogStore) { + let dir = tempfile::tempdir().unwrap(); + let store = LogStore::with_cap(dir.path().to_path_buf(), cap); + (dir, store) + } + + #[tokio::test] + async fn append_then_tail_read_roundtrips() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("app/setup", "hello ").await; + store.append("app/setup", "world\n").await; + assert_eq!( + store.tail_read("app/setup", DEFAULT_TAIL_BYTES).await, + "hello world\n" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn log_files_are_owner_only_on_create_and_reopen() { + use std::os::unix::fs::PermissionsExt; + + let (dir, store) = store(WRITE_CAP_BYTES); + store.append("app/setup", "first\n").await; + let path = dir.path().join("app/setup"); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + store.close_writer_if_idle("app/setup"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(store.tail_read("app/setup", 4096).await, "first\n"); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + store.append("app/setup", "second\n").await; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[tokio::test] + async fn tail_read_of_missing_key_is_empty() { + let (_dir, store) = store(WRITE_CAP_BYTES); + assert_eq!(store.tail_read("app/never-written", 1024).await, ""); + } + + #[tokio::test] + async fn tail_read_only_returns_the_last_max_bytes() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("app/setup", "0123456789").await; + assert_eq!(store.tail_read("app/setup", 4).await, "6789"); + } + + #[tokio::test] + async fn tail_read_cuts_lossily_at_a_multibyte_boundary() { + let (_dir, store) = store(WRITE_CAP_BYTES); + // "é" is 2 bytes (0xC3 0xA9); "a é" = ['a',' ',0xC3,0xA9] — asking + // for the last 1 byte lands mid-codepoint (just the trailing 0xA9), + // which must decode lossily rather than panicking. + store.append("app/setup", "a é").await; + let tail = store.tail_read("app/setup", 1).await; + assert_eq!(tail.chars().count(), 1); + assert_eq!(tail, "\u{FFFD}"); + } + + #[tokio::test] + async fn append_rotates_at_cap_with_a_marker_line() { + let (_dir, store) = store(10); + store.append("app/setup", "0123456789").await; // exactly at cap, no rotation yet + assert!(!store.is_truncated("app/setup").await); + store.append("app/setup", "x").await; // would exceed -> rotates + assert!(store.is_truncated("app/setup").await); + let content = store.tail_read("app/setup", 4096).await; + assert!( + content.starts_with("[log rotated at 10 bytes]\n"), + "got: {content:?}" + ); + assert!(content.ends_with('x'), "got: {content:?}"); + // The pre-rotation content must be GONE (rotation deletes, not + // trims) — byte-parity with LogTee's rotate-by-unlink, not a + // sliding window. + assert!(!content.contains("0123456789")); + } + + #[tokio::test] + async fn truncate_resets_to_an_empty_fresh_file() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("app/setup", "old transcript\n").await; + store.truncate("app/setup").await; + assert_eq!(store.tail_read("app/setup", 4096).await, ""); + assert!(!store.is_truncated("app/setup").await); + store.append("app/setup", "fresh\n").await; + assert_eq!(store.tail_read("app/setup", 4096).await, "fresh\n"); + } + + #[tokio::test] + async fn remove_deletes_the_file() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("tasks/task1.out", "hi\n").await; + store.remove("tasks/task1.out").await; + assert_eq!(store.tail_read("tasks/task1.out", 4096).await, ""); + } + + #[tokio::test] + async fn sanitizes_unsafe_key_segments() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("app/../../etc/passwd", "nope\n").await; + // Whatever path this resolved to, it must stay under `root` — the + // sanitizer strips leading dots per-segment, so this lands at + // `root/app/etc/passwd` (or similar), never escaping upward. + let path = store.path_for("app/../../etc/passwd"); + assert!(path.starts_with(&store.root)); + } + + #[tokio::test] + async fn concurrent_appends_to_the_same_key_never_corrupt_or_drop_data() { + let (_dir, store) = store(WRITE_CAP_BYTES); + let store = std::sync::Arc::new(store); + let mut handles = Vec::new(); + for i in 0..20 { + let store = store.clone(); + handles.push(tokio::spawn(async move { + store.append("app/setup", &format!("line-{i}\n")).await; + })); + } + for h in handles { + h.await.unwrap(); + } + let content = store.tail_read("app/setup", 4096).await; + for i in 0..20 { + assert!( + content.contains(&format!("line-{i}\n")), + "missing line-{i} in: {content:?}" + ); + } + } + + #[tokio::test] + async fn app_sources_are_discovered_from_preexisting_files_in_stable_order() { + let (dir, store) = store(WRITE_CAP_BYTES); + let app_dir = dir.path().join("app"); + tokio::fs::create_dir_all(app_dir.join("not-a-source")) + .await + .unwrap(); + tokio::fs::write(app_dir.join("setup"), "old setup\n") + .await + .unwrap(); + tokio::fs::write(app_dir.join("dev"), "old dev\n") + .await + .unwrap(); + tokio::fs::write(app_dir.join(".hidden"), "ignore me\n") + .await + .unwrap(); + + assert_eq!(store.app_sources().await, vec!["dev", "setup"]); + } + + #[tokio::test] + async fn app_source_filenames_are_reversible_and_collision_free_after_restart() { + let dir = tempfile::tempdir().unwrap(); + let store = LogStore::new(dir.path().to_path_buf()); + + assert_eq!(app_key("setup"), "app/setup"); + assert_eq!(app_key("dev"), "app/dev"); + assert_ne!(app_key("dev:web"), app_key("dev_web")); + store.append(&app_key("setup"), "setup output\n").await; + store.append(&app_key("dev:web"), "colon output\n").await; + store + .append(&app_key("dev_web"), "underscore output\n") + .await; + + let restarted = LogStore::new(dir.path().to_path_buf()); + assert_eq!( + restarted.app_sources().await, + vec!["dev:web", "dev_web", "setup"] + ); + assert_eq!( + restarted.tail_read(&app_key("dev:web"), 4096).await, + "colon output\n" + ); + assert_eq!( + restarted.tail_read(&app_key("dev_web"), 4096).await, + "underscore output\n" + ); + assert!(dir.path().join("app/setup").is_file()); + } + + #[tokio::test] + async fn rotated_app_log_remains_discoverable() { + let (_dir, store) = store(10); + store.append("app/setup", "0123456789").await; + store.append("app/setup", "x").await; + + assert_eq!(store.app_sources().await, vec!["setup"]); + assert!(store + .tail_read("app/setup", 4096) + .await + .starts_with("[log rotated at 10 bytes]\n")); + } +} diff --git a/apps/native/crates/local-api/src/main.rs b/apps/native/crates/local-api/src/main.rs new file mode 100644 index 0000000000..682dadc2ed --- /dev/null +++ b/apps/native/crates/local-api/src/main.rs @@ -0,0 +1,17 @@ +//! `local-api` — the desktop app's Rust HTTP core, binary entrypoint. +//! +//! SHARED FILE (see the native module-ownership contract) — family +//! implementers should never need to touch it. +//! +//! Phase 3 note: the actual boot sequence (env parsing, dir creation, +//! `AppState`/router construction, bind, serve-until-SIGTERM) now lives in +//! `lib.rs::boot_from_env()` — a mechanical extraction so the Tauri shell +//! (`apps/native/src-tauri/`) can embed the exact same server-construction +//! logic in-process (via `lib.rs::start()`) without going through env vars, +//! stdout, or a subprocess at all. This binary's behavior is unchanged: +//! same env contract, same stdout lines, same SIGTERM handling. + +#[tokio::main] +async fn main() { + local_api::boot_from_env().await; +} diff --git a/apps/native/crates/local-api/src/mutation.rs b/apps/native/crates/local-api/src/mutation.rs new file mode 100644 index 0000000000..5cfc4967d1 --- /dev/null +++ b/apps/native/crates/local-api/src/mutation.rs @@ -0,0 +1,519 @@ +//! Shutdown-safe ownership for filesystem mutations. +//! +//! Long preparation (network fetches, catalog rendering) runs outside the +//! repository and is registered here as a cancellable owner. The only code +//! allowed to make a prepared result visible in the repository holds a short +//! [`MutationCommitPermit`]. Every admitted commit runs in a separate, +//! non-aborted task that owns its permit until the filesystem operation really +//! returns. This matters for `tokio::fs`: dropping its async waiter does not +//! cancel an already-running blocking-pool syscall. Shutdown closes both +//! admission paths synchronously, first asks every long owner to cancel +//! cooperatively, then aborts owners that exceed that grace. Only after those +//! owners retire does it cross the independent commit barrier before git +//! publish is allowed to start. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures_util::FutureExt; +use tokio::sync::{oneshot, watch, OwnedRwLockReadGuard, RwLock}; +use tokio::task::AbortHandle; +use tokio::time::Instant; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MutationOwnerError { + ShuttingDown, + Panicked, + Stopped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MutationShutdownOutcome { + pub owners_remaining: usize, + pub commit_quiescent: bool, +} + +impl MutationShutdownOutcome { + pub fn is_quiescent(self) -> bool { + self.owners_remaining == 0 && self.commit_quiescent + } +} + +/// Cancellation receiver handed to a long mutation owner. Owners select on +/// [`Self::cancelled`] around every potentially long await and clean their +/// staging directory before returning. +pub struct MutationCancellation { + receiver: watch::Receiver, +} + +impl MutationCancellation { + pub fn is_cancelled(&self) -> bool { + *self.receiver.borrow() + } + + pub async fn cancelled(&mut self) { + if self.is_cancelled() { + return; + } + while self.receiver.changed().await.is_ok() { + if self.is_cancelled() { + return; + } + } + } +} + +/// Proof that this short final filesystem transition linearized before +/// shutdown. The guard is intentionally opaque; dropping it ends the commit. +struct MutationCommitPermit { + _guard: OwnedRwLockReadGuard<()>, +} + +struct MutationOwnerControl { + cancellation: watch::Sender, + abort: AbortHandle, +} + +/// Registration is retired by the owner future's destruction, not by a line +/// of code after its await. This proves all preparation locals have dropped on +/// normal return, panic, or forced abort. Final commit tasks are deliberately +/// independent: their permits remain visible through the commit barrier even +/// after the preparation owner has retired. +struct MutationOwnerCompletion { + coordinator: Arc, + id: u64, +} + +impl Drop for MutationOwnerCompletion { + fn drop(&mut self) { + self.coordinator.finish_owner(self.id); + } +} + +pub struct MutationCoordinator { + accepting: AtomicBool, + commits: Arc>, + owners: Mutex>, + owner_changes: watch::Sender, + next_owner: AtomicU64, +} + +impl MutationCoordinator { + pub fn new() -> Self { + let (owner_changes, _) = watch::channel(0); + Self { + accepting: AtomicBool::new(true), + commits: Arc::new(RwLock::new(())), + owners: Mutex::new(HashMap::new()), + owner_changes, + next_owner: AtomicU64::new(1), + } + } + + /// Admits one short, final repository transition. The second check after + /// acquiring the read lock closes the queued-reader race with shutdown's + /// writer barrier. + async fn admit_commit(&self) -> Option { + if !self.accepting.load(Ordering::SeqCst) { + return None; + } + let guard = self.commits.clone().read_owned().await; + if !self.accepting.load(Ordering::SeqCst) { + return None; + } + Some(MutationCommitPermit { _guard: guard }) + } + + /// Runs one final repository transition in a detached, non-aborted task. + /// + /// `tokio::fs` delegates filesystem calls to the blocking pool. Aborting + /// the async waiter does not cancel a syscall that has already started, so + /// a permit owned by an abortable request/preparation future could be + /// released before the underlying rename/remove completed. The task + /// spawned here has no exposed abort handle: caller disconnect and forced + /// owner abort only drop the result receiver, while this task keeps the + /// read permit until `operation` actually returns. Shutdown's write barrier + /// therefore stays non-quiescent for the true commit lifetime. + pub async fn run_commit( + self: &Arc, + operation: F, + ) -> Result + where + T: Send + 'static, + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + if !self.accepting.load(Ordering::SeqCst) { + return Err(MutationOwnerError::ShuttingDown); + } + + let coordinator = self.clone(); + let (result_tx, result_rx) = oneshot::channel(); + tokio::spawn(async move { + let outcome = match coordinator.admit_commit().await { + Some(permit) => { + let outcome = std::panic::AssertUnwindSafe(operation()) + .catch_unwind() + .await + .map_err(|_| MutationOwnerError::Panicked); + // Be explicit about the ordering proof: the operation's + // future is complete before the barrier permit is released. + drop(permit); + outcome + } + None => Err(MutationOwnerError::ShuttingDown), + }; + let _ = result_tx.send(outcome); + }); + + result_rx.await.unwrap_or(Err(MutationOwnerError::Stopped)) + } + + /// Detaches a long preparation from its request future and registers it + /// before spawning. Client disconnects therefore cannot orphan unknown + /// work, and shutdown can cancel and join it through the registry. + pub async fn run_owned(self: &Arc, work: F) -> Result + where + T: Send + 'static, + F: FnOnce(MutationCancellation) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + if !self.accepting.load(Ordering::SeqCst) { + return Err(MutationOwnerError::ShuttingDown); + } + + let id = self.next_owner.fetch_add(1, Ordering::Relaxed); + let (cancel, receiver) = watch::channel(false); + let (start_tx, start_rx) = oneshot::channel(); + let (result_tx, result_rx) = oneshot::channel::>(); + // The start gate keeps user work dormant until its cancellation and + // abort controls are both visible in the registry. + let owner_task = tokio::spawn(async move { + if start_rx.await.is_err() { + return None; + } + Some( + std::panic::AssertUnwindSafe(work(MutationCancellation { receiver })) + .catch_unwind() + .await, + ) + }); + let abort = owner_task.abort_handle(); + + let completion = MutationOwnerCompletion { + coordinator: self.clone(), + id, + }; + // A separate completion owner holds the JoinHandle. `JoinHandle::await` + // becomes ready only after the preparation future has returned or has + // actually been dropped by abort. Registry retirement therefore cannot + // race ahead of destruction of the work future it represents. + tokio::spawn(async move { + let outcome = match owner_task.await { + Ok(Some(Ok(value))) => Ok(value), + Ok(Some(Err(_))) => Err(MutationOwnerError::Panicked), + Ok(None) | Err(_) => Err(MutationOwnerError::Stopped), + }; + drop(completion); + let _ = result_tx.send(outcome); + }); + + { + let mut owners = match self.owners.lock() { + Ok(owners) => owners, + Err(poisoned) => poisoned.into_inner(), + }; + if !self.accepting.load(Ordering::SeqCst) { + drop(owners); + abort.abort(); + return Err(MutationOwnerError::ShuttingDown); + } + owners.insert( + id, + MutationOwnerControl { + cancellation: cancel, + abort, + }, + ); + } + let _ = start_tx.send(()); + + result_rx.await.unwrap_or(Err(MutationOwnerError::Stopped)) + } + + /// Synchronously closes admission and signals all registered owners. The + /// returned future gives cooperative cleanup one `budget`, force-aborts + /// the remaining task futures, then gives actual future destruction a + /// second `budget`. The commit barrier shares whichever owner phase is + /// active: registration removal therefore happens before a true terminal + /// outcome, and a false outcome remains a hard publish gate. + pub fn begin_shutdown( + &self, + budget: Duration, + ) -> impl Future + '_ { + self.accepting.store(false, Ordering::SeqCst); + let cooperative_deadline = Instant::now() + budget; + let cancellations: Vec> = { + let owners = match self.owners.lock() { + Ok(owners) => owners, + Err(poisoned) => poisoned.into_inner(), + }; + owners + .values() + .map(|owner| owner.cancellation.clone()) + .collect() + }; + for cancellation in cancellations { + let _ = cancellation.send(true); + } + + async move { + let mut barrier_deadline = cooperative_deadline; + let mut owners_remaining = self.wait_for_owners(cooperative_deadline).await; + if owners_remaining > 0 { + let aborts: Vec = { + let owners = match self.owners.lock() { + Ok(owners) => owners, + Err(poisoned) => poisoned.into_inner(), + }; + owners.values().map(|owner| owner.abort.clone()).collect() + }; + for abort in aborts { + abort.abort(); + } + barrier_deadline = Instant::now() + budget; + owners_remaining = self.wait_for_owners(barrier_deadline).await; + } + let commit_quiescent = tokio::time::timeout_at(barrier_deadline, self.commits.write()) + .await + .is_ok(); + MutationShutdownOutcome { + owners_remaining, + commit_quiescent, + } + } + } + + fn finish_owner(&self, id: u64) { + let removed = { + let mut owners = match self.owners.lock() { + Ok(owners) => owners, + Err(poisoned) => poisoned.into_inner(), + }; + owners.remove(&id).is_some() + }; + if removed { + self.owner_changes + .send_modify(|generation| *generation += 1); + } + } + + async fn wait_for_owners(&self, deadline: Instant) -> usize { + let mut changes = self.owner_changes.subscribe(); + loop { + let remaining = match self.owners.lock() { + Ok(owners) => owners.len(), + Err(poisoned) => poisoned.into_inner().len(), + }; + if remaining == 0 { + return 0; + } + if tokio::time::timeout_at(deadline, changes.changed()) + .await + .is_err() + { + return match self.owners.lock() { + Ok(owners) => owners.len(), + Err(poisoned) => poisoned.into_inner().len(), + }; + } + } + } +} + +impl Default for MutationCoordinator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + + struct DropProbe(Arc); + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn shutdown_waits_for_an_admitted_commit_before_quiescence() { + let coordinator = Arc::new(MutationCoordinator::new()); + let permit = coordinator.admit_commit().await.expect("commit admitted"); + let shutting_down = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { coordinator.begin_shutdown(Duration::from_secs(1)).await }) + }; + tokio::task::yield_now().await; + assert!(!shutting_down.is_finished()); + assert!(coordinator.admit_commit().await.is_none()); + + drop(permit); + assert!(shutting_down.await.unwrap().is_quiescent()); + } + + #[tokio::test] + async fn shutdown_cancels_and_joins_long_owner_cleanup() { + let coordinator = Arc::new(MutationCoordinator::new()); + let cleaned = Arc::new(AtomicBool::new(false)); + let owner_cleaned = cleaned.clone(); + let (started_tx, started_rx) = oneshot::channel(); + let owner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator + .run_owned(move |mut cancellation| async move { + let _ = started_tx.send(()); + cancellation.cancelled().await; + owner_cleaned.store(true, Ordering::SeqCst); + "cancelled" + }) + .await + }) + }; + started_rx.await.unwrap(); + + let outcome = coordinator.begin_shutdown(Duration::from_secs(1)).await; + assert!(outcome.is_quiescent()); + assert!(cleaned.load(Ordering::SeqCst)); + assert_eq!(owner.await.unwrap().unwrap(), "cancelled"); + } + + #[tokio::test] + async fn ignored_cooperative_cancellation_is_force_aborted_and_quiescent() { + let coordinator = Arc::new(MutationCoordinator::new()); + let (started_tx, started_rx) = oneshot::channel(); + let owner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator + .run_owned(move |_cancellation| async move { + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }) + .await + }) + }; + started_rx.await.unwrap(); + + let outcome = coordinator.begin_shutdown(Duration::from_millis(50)).await; + assert!(outcome.is_quiescent()); + assert_eq!(owner.await.unwrap(), Err(MutationOwnerError::Stopped)); + } + + #[tokio::test] + async fn forced_abort_drops_the_owned_preparation_before_quiescence() { + let coordinator = Arc::new(MutationCoordinator::new()); + let dropped = Arc::new(AtomicBool::new(false)); + let owner_dropped = dropped.clone(); + let (started_tx, started_rx) = oneshot::channel(); + let owner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator + .run_owned(move |_cancellation| async move { + let _probe = DropProbe(owner_dropped); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }) + .await + }) + }; + started_rx.await.unwrap(); + + let outcome = coordinator.begin_shutdown(Duration::from_millis(50)).await; + assert!(outcome.is_quiescent()); + assert!( + dropped.load(Ordering::SeqCst), + "quiescence was reported before the owner future's locals dropped" + ); + assert_eq!(owner.await.unwrap(), Err(MutationOwnerError::Stopped)); + } + + #[tokio::test] + async fn forced_owner_abort_cannot_release_an_in_flight_commit_barrier() { + let coordinator = Arc::new(MutationCoordinator::new()); + let (commit_started_tx, commit_started_rx) = oneshot::channel(); + let (release_commit_tx, release_commit_rx) = oneshot::channel(); + let owner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + let commit_coordinator = coordinator.clone(); + coordinator + .run_owned(move |_cancellation| async move { + commit_coordinator + .run_commit(move || async move { + let _ = commit_started_tx.send(()); + let _ = release_commit_rx.await; + "committed" + }) + .await + }) + .await + }) + }; + commit_started_rx.await.unwrap(); + + let first = coordinator.begin_shutdown(Duration::from_millis(25)).await; + assert_eq!(first.owners_remaining, 0); + assert!( + !first.commit_quiescent, + "forced owner abort must not hide the detached commit task" + ); + assert_eq!(owner.await.unwrap(), Err(MutationOwnerError::Stopped)); + + release_commit_tx.send(()).unwrap(); + let second = coordinator.begin_shutdown(Duration::from_secs(1)).await; + assert!( + second.is_quiescent(), + "commit barrier did not release after the real operation completed" + ); + } + + #[tokio::test] + async fn caller_disconnect_does_not_orphan_detached_owner() { + let coordinator = Arc::new(MutationCoordinator::new()); + let cleaned = Arc::new(AtomicBool::new(false)); + let owner_cleaned = cleaned.clone(); + let (started_tx, started_rx) = oneshot::channel(); + let caller = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator + .run_owned(move |mut cancellation| async move { + let _ = started_tx.send(()); + cancellation.cancelled().await; + owner_cleaned.store(true, Ordering::SeqCst); + }) + .await + }) + }; + started_rx.await.unwrap(); + + caller.abort(); + assert!(caller.await.unwrap_err().is_cancelled()); + let outcome = coordinator.begin_shutdown(Duration::from_secs(1)).await; + assert!(outcome.is_quiescent()); + assert!( + cleaned.load(Ordering::SeqCst), + "dropping the request waiter must not detach owner state from shutdown" + ); + } +} diff --git a/apps/native/crates/local-api/src/process_group.rs b/apps/native/crates/local-api/src/process_group.rs new file mode 100644 index 0000000000..de94382365 --- /dev/null +++ b/apps/native/crates/local-api/src/process_group.rs @@ -0,0 +1,783 @@ +//! Ownership fence for a subprocess and every member of its process group. +//! +//! The command's immediate child is not a sufficient lifetime fence. A shell +//! can start `server >/dev/null 2>&1 &` and exit immediately: its pipes close +//! and its PID becomes reapable while the server remains in the same process +//! group. Treating that leader status as terminal both lies to `TaskRegistry` +//! and leaves the server outside coordinated shutdown. +//! +//! On Unix, [`ProcessGroupChild::spawn`] therefore starts an independent +//! watchdog as the process-group leader and joins the requested command to its +//! already-live group. The watchdog script and its anchored-group signaling +//! helpers live in `harness::watchdog` — the ONE shared copy for this crate +//! and the harness dispatch family, so crash-recovery semantics cannot drift +//! between the two. The watchdog: +//! +//! - pins the PGID until Studio has proved that no other group member exists; +//! - stays alive across an immediate command-leader exit or closed stdio; +//! - receives a parent-liveness pipe, so an uncatchable Studio crash makes it +//! TERM, then KILL, the remaining group; +//! - ignores TERM itself, allowing graceful TERM to reach the workload while +//! preserving the ownership anchor for a later KILL/reap. +//! +//! A successful [`ProcessGroupChild::wait`] means the direct child was reaped, +//! no same-PG descendant remained, and the watchdog itself was reaped under the +//! same ownership fence used by signaling. Only that result authorizes a task's +//! terminal transition. + +use std::path::Path; +use std::process::{ExitStatus, Output, Stdio}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use tokio::io::AsyncReadExt; +#[cfg(unix)] +use tokio::process::ChildStdin; +use tokio::process::{Child, ChildStderr, ChildStdout, Command}; + +use crate::tasks::KillSignal; + +type SignalGroup = fn(u32, KillSignal) -> bool; + +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OwnershipState { + /// The independent group anchor has not been successfully reaped. Its PID + /// therefore still pins the numeric PGID, even if the command leader has + /// already exited. + Armed(Option), + /// Successful group-quiescence proof is a one-way transition. Never signal + /// this number again, even if a later process receives it. + Reaped(ExitStatus), + /// The owner was dropped without a completed proof. Its best-effort KILL + /// ran and every escaped control is permanently inert. + Released, +} + +struct ProcessGroupOwnership { + state: Mutex, + signal_group: SignalGroup, +} + +impl ProcessGroupOwnership { + fn new(pid: Option, signal_group: SignalGroup) -> Self { + Self { + state: Mutex::new(OwnershipState::Armed(pid)), + signal_group, + } + } + + fn lock(&self) -> MutexGuard<'_, OwnershipState> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Runs the signal command while holding the ownership fence. A concurrent + /// successful wait cannot reap/release the anchor until this signal has + /// completed; conversely, once wait disarms first this fails closed. + fn signal(&self, signal: KillSignal) -> bool { + let state = self.lock(); + let OwnershipState::Armed(Some(pid)) = *state else { + return false; + }; + (self.signal_group)(pid, signal) + } + + fn cleanup_on_owner_drop(&self) { + let mut state = self.lock(); + if let OwnershipState::Armed(pid) = *state { + if let Some(pid) = pid { + let _ = (self.signal_group)(pid, KillSignal::Kill); + } + *state = OwnershipState::Released; + } + } + + #[cfg(test)] + fn state(&self) -> OwnershipState { + *self.lock() + } +} + +#[cfg(unix)] +struct GroupAnchor { + child: Child, + parent_liveness: Option, + shutdown_started: bool, +} + +/// Owns one spawned command leader, its independent group anchor, and all +/// authority to signal their PGID. +pub(crate) struct ProcessGroupChild { + // Drop runs our explicit `Drop` implementation before fields are dropped, + // so group cleanup happens while both child handles still pin identities. + child: Child, + ownership: Arc, + leader_status: Option, + #[cfg(unix)] + anchor: Option, +} + +/// Signal capability for select loops whose wait future already mutably +/// borrows the owning [`ProcessGroupChild`]. It cannot extend signal authority +/// past a successful whole-group reap: both handles share the same one-way +/// ownership state. +pub(crate) struct ProcessGroupControl { + ownership: Arc, +} + +impl ProcessGroupControl { + pub(crate) async fn signal(&self, signal: KillSignal) -> bool { + let ownership = Arc::clone(&self.ownership); + tokio::task::spawn_blocking(move || ownership.signal(signal)) + .await + .unwrap_or(false) + } +} + +impl ProcessGroupChild { + /// Spawns `command` into an independently anchored process group. + /// + /// Callers should configure stdio, cwd, environment, and `kill_on_drop`, + /// but must use this method instead of `Command::spawn`: wrapping an + /// already-spawned group leader cannot protect the leader-exited descendant + /// case because that leader itself is the identity that must stay pinned. + pub(crate) async fn spawn( + command: &mut Command, + child_lifetime_lock_path: &Path, + ) -> std::io::Result { + #[cfg(unix)] + { + if let Some(parent) = child_lifetime_lock_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + // Deep callers (notably Git helpers and freshly materialized + // sandbox registries) may select the app-wide fence before + // anything else has created its directory. Do this on Tokio's + // filesystem pool before the synchronous anchor setup so a + // valid spawn cannot fail merely because its retention path + // has not been touched yet. + tokio::fs::create_dir_all(parent).await?; + } + let (mut anchor_child, parent_liveness, group_id) = + spawn_group_anchor(child_lifetime_lock_path)?; + let process_group = i32::try_from(group_id) + .map_err(|_| std::io::Error::other("watchdog pid does not fit a process group"))?; + command.process_group(process_group); + let child = match command.spawn() { + Ok(child) => child, + Err(error) => { + drop(parent_liveness); + // No workload was spawned. Let the watchdog prove that + // its group is empty before it exits and releases the + // inherited shared child-lifetime fence. + let _ = anchor_child.wait().await; + return Err(error); + } + }; + let ownership = Arc::new(ProcessGroupOwnership::new( + Some(group_id), + signal_anchored_process_group, + )); + Ok(Self { + child, + ownership, + leader_status: None, + anchor: Some(GroupAnchor { + child: anchor_child, + parent_liveness: Some(parent_liveness), + shutdown_started: false, + }), + }) + } + + #[cfg(not(unix))] + { + let child = command.spawn()?; + Ok(Self::new(child)) + } + } + + /// Unanchored constructor retained for non-Unix and ownership-fence unit + /// tests. Production Unix route/setup spawns must use [`Self::spawn`]. + #[cfg(not(unix))] + fn new(child: Child) -> Self { + Self::new_with_signaller(child, signal_process_group) + } + + #[cfg(any(test, not(unix)))] + fn new_with_signaller(child: Child, signal_group: SignalGroup) -> Self { + let ownership = Arc::new(ProcessGroupOwnership::new(child.id(), signal_group)); + Self { + child, + ownership, + leader_status: None, + #[cfg(unix)] + anchor: None, + } + } + + /// Returns the owned process-group id. On Unix this is the independent + /// anchor's pid, not necessarily the immediate command child's pid. + pub(crate) fn id(&self) -> Option { + match *self.ownership.lock() { + OwnershipState::Armed(pid) => pid, + OwnershipState::Reaped(_) | OwnershipState::Released => None, + } + } + + pub(crate) fn control(&self) -> ProcessGroupControl { + ProcessGroupControl { + ownership: Arc::clone(&self.ownership), + } + } + + pub(crate) fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + pub(crate) fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } + + /// Signals the owned process group if and only if its anchor has not been + /// successfully reaped. The blocking `kill`/`taskkill` invocation runs off + /// the async worker thread and is serialized with the final disarm. + pub(crate) async fn signal(&self, signal: KillSignal) -> bool { + self.control().signal(signal).await + } + + /// Waits until the direct leader AND every same-process-group descendant + /// are gone, then reaps the independent anchor and atomically retires all + /// signal authority. + /// + /// Group-enumeration failures retry forever rather than returning an + /// unproven terminal result. Keeping this future pending deliberately + /// leaves the corresponding TaskRegistry entry owned and Running, so a + /// later Stop/shutdown can retry signaling it. + pub(crate) async fn wait(&mut self) -> std::io::Result { + loop { + { + let state = self.ownership.lock(); + if let OwnershipState::Reaped(status) = *state { + return Ok(status); + } + if matches!(*state, OwnershipState::Released) { + return Err(std::io::Error::other( + "process-group owner was already released", + )); + } + } + + if self.leader_status.is_none() { + match self.child.try_wait() { + Ok(Some(status)) => self.leader_status = Some(status), + Ok(None) => {} + Err(error) => { + tracing::error!(%error, "cannot reap process-group command leader; retaining ownership"); + } + } + } + + #[cfg(unix)] + let anchored_group = match (self.leader_status, self.anchor.as_ref()) { + (Some(leader_status), Some(_)) => { + let group_id = self.id().ok_or_else(|| { + std::io::Error::other("process-group ownership disappeared before reap") + })?; + Some((leader_status, group_id)) + } + _ => None, + }; + #[cfg(unix)] + if let Some((leader_status, group_id)) = anchored_group { + match group_has_non_anchor_members(group_id).await { + Ok(true) => {} + Ok(false) => { + // Once only the anchor remains, no process capable of + // forking another same-group member exists. Kill/reap + // the anchor under the signal fence; that exact + // critical section closes the PGID-reuse window. + let mut state = self.ownership.lock(); + if let OwnershipState::Reaped(status) = *state { + return Ok(status); + } + if matches!(*state, OwnershipState::Released) { + return Err(std::io::Error::other( + "process-group owner was released during reap", + )); + } + let Some(anchor) = self.anchor.as_mut() else { + return Err(std::io::Error::other( + "process-group anchor disappeared during reap", + )); + }; + if !anchor.shutdown_started { + let _ = anchor.child.start_kill(); + anchor.shutdown_started = true; + } + match anchor.child.try_wait() { + Ok(Some(_)) => { + drop(anchor.parent_liveness.take()); + *state = OwnershipState::Reaped(leader_status); + return Ok(leader_status); + } + Ok(None) => {} + Err(error) => { + tracing::error!(%error, "cannot reap process-group anchor; retaining ownership"); + } + } + } + Err(error) => { + tracing::error!( + %error, + group_id, + "cannot prove process-group quiescence; retaining ownership" + ); + } + } + } + + #[cfg(not(unix))] + if let Some(status) = self.leader_status { + let mut state = self.ownership.lock(); + *state = OwnershipState::Reaped(status); + return Ok(status); + } + + // Unit-only unanchored Unix owners retain the previous + // direct-child fence semantics. All production Unix call sites use + // `spawn`, which always installs `anchor`. + #[cfg(unix)] + if self.anchor.is_none() { + if let Some(status) = self.leader_status { + let mut state = self.ownership.lock(); + *state = OwnershipState::Reaped(status); + return Ok(status); + } + } + + tokio::time::sleep(POLL_INTERVAL).await; + } + } + + /// KILLs the group and keeps owning it until [`Self::wait`] proves the + /// complete reap. `warning_after` is diagnostic only: expiration never + /// turns an unproven cleanup into a terminal task. + pub(crate) async fn kill_and_reap( + &mut self, + warning_after: Duration, + context: &'static str, + ) -> ExitStatus { + let _ = self.signal(KillSignal::Kill).await; + match tokio::time::timeout(warning_after, self.wait()).await { + Ok(Ok(status)) => return status, + Ok(Err(error)) => { + tracing::error!(%error, context, "process-group reap failed; retaining ownership"); + } + Err(_) => { + tracing::error!( + context, + "process-group reap exceeded warning deadline; retaining ownership" + ); + } + } + loop { + match self.wait().await { + Ok(status) => return status, + Err(error) => { + tracing::error!(%error, context, "process-group reap retry failed; retaining ownership"); + tokio::time::sleep(POLL_INTERVAL).await; + } + } + } + } + + /// Borrowing `wait_with_output`: drain both pipes concurrently while + /// retaining the group owner, then wait for whole-group quiescence. + pub(crate) async fn wait_with_output(&mut self) -> std::io::Result { + let stdout = self.take_stdout(); + let stderr = self.take_stderr(); + let read_stdout = async move { + let mut bytes = Vec::new(); + if let Some(mut pipe) = stdout { + pipe.read_to_end(&mut bytes).await?; + } + Ok::<_, std::io::Error>(bytes) + }; + let read_stderr = async move { + let mut bytes = Vec::new(); + if let Some(mut pipe) = stderr { + pipe.read_to_end(&mut bytes).await?; + } + Ok::<_, std::io::Error>(bytes) + }; + let (stdout, stderr) = tokio::try_join!(read_stdout, read_stderr)?; + let status = self.wait().await?; + Ok(Output { + status, + stdout, + stderr, + }) + } +} + +impl Drop for ProcessGroupChild { + fn drop(&mut self) { + // Best effort and intentionally synchronous: Drop may run during + // runtime teardown. The parent-liveness writer is dropped immediately + // afterward, waking the independent watchdog if the direct KILL did + // not complete before the runtime disappeared. + self.ownership.cleanup_on_owner_drop(); + } +} + +/// Opens+locks the shared child-lifetime fence and spawns the shared +/// parent-liveness watchdog (`harness::watchdog` — the ONE copy of the +/// script, so its crash-recovery semantics cannot drift from the harness +/// dispatch family's) under this crate's own `ps`-visible argv0 label. +#[cfg(unix)] +fn spawn_group_anchor( + child_lifetime_lock_path: &Path, +) -> std::io::Result<(Child, ChildStdin, u32)> { + let lifetime_lock = harness::watchdog::open_shared_lifetime_lock(child_lifetime_lock_path)?; + let anchor = + harness::watchdog::spawn_anchor("decocms-local-api-watchdog", Some(lifetime_lock))?; + Ok((anchor.child, anchor.parent_liveness, anchor.group_id)) +} + +#[cfg(unix)] +async fn group_has_non_anchor_members(group_id: u32) -> std::io::Result { + let output = Command::new("pgrep") + .args(["-g", &group_id.to_string(), "."]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output() + .await?; + // `pgrep` exits 1 when it matched nothing. That is a valid empty group + // observation (e.g. the anchor was already KILLed but remains our unreaped + // child handle); all other nonzero statuses are indeterminate. + if !output.status.success() { + if output.status.code() == Some(1) { + return Ok(false); + } + return Err(std::io::Error::other(format!( + "pgrep exited {:?}", + output.status.code() + ))); + } + let anchor_pid = group_id.to_string(); + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .any(|pid| !pid.is_empty() && pid != anchor_pid)) +} + +/// Signal every current member except the anchor — the shared +/// enumerate-then-signal helper in `harness::watchdog` (see its doc for why +/// group-wide `kill -SIG -` is forbidden while the anchor owns the +/// restart fence). The anchor is this group's leader, so its pid IS the +/// group id. Kept as a named `fn` so it fits the [`SignalGroup`] pointer. +#[cfg(unix)] +fn signal_anchored_process_group(group_id: u32, signal: KillSignal) -> bool { + let signal = match signal { + KillSignal::Term => harness::spawn::Signal::Term, + KillSignal::Kill => harness::spawn::Signal::Kill, + }; + harness::watchdog::signal_non_anchor_members(group_id, group_id, signal) +} + +#[cfg(not(unix))] +fn signal_process_group(pid: u32, _signal: KillSignal) -> bool { + std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +#[cfg(test)] +mod tests { + use std::fs::OpenOptions; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + static REUSE_SIGNALS: AtomicUsize = AtomicUsize::new(0); + static WAIT_SIGNALS: AtomicUsize = AtomicUsize::new(0); + static DROP_SIGNALS: AtomicUsize = AtomicUsize::new(0); + static ESCAPED_SIGNALS: AtomicUsize = AtomicUsize::new(0); + static IGNORED_SIGNALS: AtomicUsize = AtomicUsize::new(0); + + fn record_reuse_signal(_pid: u32, _signal: KillSignal) -> bool { + REUSE_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + fn record_wait_signal(_pid: u32, _signal: KillSignal) -> bool { + WAIT_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + fn record_drop_signal(_pid: u32, _signal: KillSignal) -> bool { + DROP_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + fn record_escaped_signal(_pid: u32, _signal: KillSignal) -> bool { + ESCAPED_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + fn ignore_signal(_pid: u32, _signal: KillSignal) -> bool { + IGNORED_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + #[test] + fn missing_process_identity_fails_closed() { + let ownership = ProcessGroupOwnership::new(None, record_reuse_signal); + assert_eq!(ownership.state(), OwnershipState::Armed(None)); + assert!(!ownership.signal(KillSignal::Term)); + } + + #[test] + fn reaped_pid_is_never_signaled_even_if_the_number_is_reused() { + REUSE_SIGNALS.store(0, Ordering::SeqCst); + let ownership = ProcessGroupOwnership::new(Some(41_337), record_reuse_signal); + *ownership.lock() = OwnershipState::Reaped(success_status()); + assert!(!ownership.signal(KillSignal::Kill)); + assert_eq!(REUSE_SIGNALS.load(Ordering::SeqCst), 0); + } + + #[cfg(unix)] + fn success_status() -> ExitStatus { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(0) + } + + #[cfg(windows)] + fn success_status() -> ExitStatus { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(0) + } + + #[cfg(unix)] + #[tokio::test] + async fn spawn_materializes_a_missing_child_lifetime_lock_parent() { + let dir = tempfile::tempdir().unwrap(); + let lifetime_lock_path = dir + .path() + .join("not-yet-created") + .join("nested") + .join("child-lifetime.lock"); + let mut command = Command::new("sh"); + command + .args(["-c", "exit 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + + let mut owned = ProcessGroupChild::spawn(&mut command, &lifetime_lock_path) + .await + .expect("spawn creates the child-lifetime fence parent"); + tokio::time::timeout(Duration::from_secs(2), owned.wait()) + .await + .expect("fixture group completed") + .expect("fixture wait succeeded"); + + assert!(lifetime_lock_path.is_file()); + } + + #[cfg(unix)] + #[tokio::test] + async fn leader_exit_and_closed_stdio_do_not_hide_a_live_group_descendant() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("descendant.pid"); + let lifetime_lock_path = dir.path().join("child-lifetime.lock"); + let mut command = Command::new("sh"); + command + .args([ + "-c", + &format!( + "sleep 30 >/dev/null 2>&1 & echo $! > {}", + pid_file.display() + ), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut owned = ProcessGroupChild::spawn(&mut command, &lifetime_lock_path) + .await + .expect("spawn anchored fixture"); + + let descendant = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(raw) = tokio::fs::read_to_string(&pid_file).await { + if let Ok(pid) = raw.trim().parse::() { + break pid; + } + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }) + .await + .expect("fixture wrote descendant pid"); + + assert!( + tokio::time::timeout(Duration::from_millis(150), owned.wait()) + .await + .is_err(), + "leader exit and stdio EOF must not look like whole-group terminal" + ); + let contender = OpenOptions::new() + .read(true) + .write(true) + .open(&lifetime_lock_path) + .expect("open lifetime fence contender"); + assert!( + matches!(contender.try_lock(), Err(std::fs::TryLockError::WouldBlock)), + "anchor must retain its shared restart fence while a descendant lives" + ); + assert!(owned.signal(KillSignal::Term).await); + let status = tokio::time::timeout(Duration::from_secs(3), owned.wait()) + .await + .expect("TERM reaped background descendant") + .expect("group reap succeeded"); + assert!(status.success(), "direct shell leader exited successfully"); + assert!(!process_alive(descendant)); + assert!(matches!(owned.ownership.state(), OwnershipState::Reaped(_))); + let successor = OpenOptions::new() + .read(true) + .write(true) + .open(&lifetime_lock_path) + .expect("open post-reap lifetime fence"); + successor + .try_lock() + .expect("exclusive recovery fence becomes available only after reap"); + } + + #[cfg(unix)] + #[tokio::test] + async fn cleanup_warning_deadline_does_not_release_an_unreaped_owner() { + IGNORED_SIGNALS.store(0, Ordering::SeqCst); + let mut command = Command::new("sh"); + command + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .process_group(0); + let child = command.spawn().expect("spawn cleanup-timeout fixture"); + let pid = child.id().expect("fixture pid"); + let owned = ProcessGroupChild::new_with_signaller(child, ignore_signal); + let control = owned.control(); + let cleanup = tokio::spawn(async move { + let mut owned = owned; + owned + .kill_and_reap(Duration::from_millis(25), "test cleanup") + .await + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !cleanup.is_finished(), + "warning timeout must retain the live owner instead of returning terminal" + ); + assert!(control.signal(KillSignal::Term).await); + assert!(IGNORED_SIGNALS.load(Ordering::SeqCst) >= 2); + + let _ = std::process::Command::new("kill") + .args(["-KILL", &format!("-{pid}")]) + .status(); + tokio::time::timeout(Duration::from_secs(2), cleanup) + .await + .expect("cleanup completed after a proven reap") + .expect("cleanup task did not panic"); + } + + #[cfg(unix)] + #[tokio::test] + async fn panic_after_successful_wait_cannot_run_drop_cleanup() { + WAIT_SIGNALS.store(0, Ordering::SeqCst); + let mut command = Command::new("sh"); + command + .args(["-c", "exit 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .process_group(0); + let child = command.spawn().expect("spawn process-group fixture"); + let mut owned = ProcessGroupChild::new_with_signaller(child, record_wait_signal); + owned.wait().await.expect("wait fixture"); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _owned = owned; + panic!("panic after wait"); + })); + assert!(panic.is_err()); + assert_eq!(WAIT_SIGNALS.load(Ordering::SeqCst), 0); + } + + #[cfg(unix)] + #[tokio::test] + async fn dropping_unreaped_owner_runs_group_cleanup_once() { + DROP_SIGNALS.store(0, Ordering::SeqCst); + let mut command = Command::new("sh"); + command + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .process_group(0); + let child = command.spawn().expect("spawn process-group fixture"); + let owned = ProcessGroupChild::new_with_signaller(child, record_drop_signal); + drop(owned); + assert_eq!(DROP_SIGNALS.load(Ordering::SeqCst), 1); + } + + #[cfg(unix)] + #[tokio::test] + async fn control_that_outlives_owner_is_permanently_inert() { + ESCAPED_SIGNALS.store(0, Ordering::SeqCst); + let mut command = Command::new("sh"); + command + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .process_group(0); + let child = command.spawn().expect("spawn process-group fixture"); + let owned = ProcessGroupChild::new_with_signaller(child, record_escaped_signal); + let escaped = owned.control(); + + drop(owned); + assert_eq!(ESCAPED_SIGNALS.load(Ordering::SeqCst), 1); + assert!(!escaped.signal(KillSignal::Term).await); + assert_eq!(ESCAPED_SIGNALS.load(Ordering::SeqCst), 1); + } + + #[cfg(unix)] + fn process_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } +} diff --git a/apps/native/crates/local-api/src/process_util.rs b/apps/native/crates/local-api/src/process_util.rs new file mode 100644 index 0000000000..f43d6ebda8 --- /dev/null +++ b/apps/native/crates/local-api/src/process_util.rs @@ -0,0 +1,361 @@ +//! Shared process-lifecycle helpers for every family that spawns a child +//! through [`ProcessGroupChild`] and registers it in a [`TaskRegistry`] +//! (`routes/bash.rs`, `routes/scripts.rs`, the `setup` pipeline, and the +//! internal Git/grep owners). +//! +//! Why this module exists: these helpers define wire- and registry-visible +//! contracts — the `128 + signal` exit-code convention, the +//! exit-code→[`TaskStatus`] classification, the `"tasks"` SSE payload shape, +//! and the TERM→KILL escalation window. They were once duplicated per family +//! (each `routes/*` file owns its route, per the native module-ownership +//! contract) with an explicit note to hoist them into a shared `process` +//! helper module once a third family needed the same shape; the setup +//! pipeline became that third family. One copy here means a change to any of +//! these contracts is a single, reviewable edit instead of a silent drift +//! between families that must stay byte-identical on the wire. + +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::AsyncReadExt; +use tokio::process::{ChildStderr, ChildStdout}; + +use crate::events::Broadcaster; +use crate::process_group::ProcessGroupChild; +use crate::tasks::{ + KillHandle, KillSignal, OutputStream, ProcessController, TaskRegistry, TaskStatus, +}; + +/// Byte-parity with `killWithEscalation`'s 3s TERM->KILL escalation window +/// in `process/task-manager.ts`. +const ESCALATE_AFTER: Duration = Duration::from_secs(3); + +/// Shell convention (`128 + signal` for a signal death, otherwise the plain +/// exit code) — byte-parity with the +/// `signal ? 128 + SIGNAL_NUMBERS[signal] : (code ?? 1)` mapping in +/// `task-manager.ts`'s `child.on("close", ...)`. +#[cfg(unix)] +pub(crate) fn exit_status_to_code(status: std::process::ExitStatus) -> i32 { + use std::os::unix::process::ExitStatusExt; + match status.signal() { + Some(sig) => 128 + sig, + None => status.code().unwrap_or(1), + } +} + +#[cfg(not(unix))] +pub(crate) fn exit_status_to_code(status: std::process::ExitStatus) -> i32 { + status.code().unwrap_or(1) +} + +/// Exit-code→[`TaskStatus`] classification — byte-parity with +/// `TaskManager`'s finalize logic. Tracks process *lifecycle*, not success: +/// a clean nonzero exit is still `Exited`; only the `-1` spawn-failure +/// sentinel or a `>128` signal-death code map away from it. +pub(crate) fn classify_status(timed_out: bool, exit_code: i32) -> TaskStatus { + if timed_out { + TaskStatus::Timeout + } else if exit_code == 0 { + TaskStatus::Exited + } else if exit_code == -1 { + TaskStatus::Failed + } else if exit_code > 128 { + TaskStatus::Killed + } else { + TaskStatus::Exited + } +} + +/// `{"active": [{id, command, logName?}]}` — byte-parity with +/// `getActiveTasks()` in `entry.ts`, broadcast under the `"tasks"` name on +/// every registration and every exit. Every family emits through this one +/// copy so `GET /_sandbox/tasks` stream consumers see one payload shape no +/// matter which family spawned the task. Callers pass the RESOLVED sandbox's +/// registry + broadcaster so a per-handle task list fans out on the RIGHT +/// stream. +pub(crate) fn emit_tasks_event(tasks: &TaskRegistry, broadcaster: &Broadcaster) { + let active: Vec = tasks + .list(Some(&[TaskStatus::Running])) + .into_iter() + .map(|t| { + let mut obj = json!({"id": t.id, "command": t.command}); + if let Some(name) = t.log_name { + obj["logName"] = json!(name); + } + obj + }) + .collect(); + broadcaster.emit("tasks", json!({"active": active})); +} + +/// Signals the whole process group `pid` leads via an external +/// `kill -SIG -` (`taskkill /T` off-Unix) — this crate has no +/// `libc`/`nix` dependency, so no raw `kill(2)` syscall. Reaches the group's +/// descendants too, matching `task-manager.ts`'s `process.kill(-pgid, +/// signal)`. For anchored groups prefer [`ProcessGroupChild::signal`], which +/// spares the anchor; this raw variant backs the setup family's +/// persisted-orphan reaping, where no live owner exists. +#[cfg(unix)] +pub(crate) async fn kill_group(pid: u32, signal: KillSignal) { + let _ = tokio::process::Command::new("kill") + .arg(signal.flag()) + .arg(format!("-{pid}")) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .status() + .await; +} + +#[cfg(not(unix))] +pub(crate) async fn kill_group(pid: u32, _signal: KillSignal) { + let _ = tokio::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .status() + .await; +} + +/// Synchronous cancellation bridge for an HTTP request whose child lives in +/// a detached, registry-visible owner. Axum may drop a handler future when +/// the client disconnects; dropping this guard requests the configured +/// signal through the task's [`KillHandle`] instead of either leaking the +/// child or tying process ownership to the socket future. The detached owner +/// remains responsible for escalation, the complete process-group reap, and +/// the terminal registry transition. +pub(crate) struct CancelOnDrop { + kill: Option, + signal: KillSignal, +} + +impl CancelOnDrop { + pub(crate) fn new(kill: KillHandle, signal: KillSignal) -> Self { + Self { + kill: Some(kill), + signal, + } + } + + pub(crate) fn disarm(&mut self) { + self.kill = None; + } +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if let Some(kill) = self.kill.take() { + let _ = kill(self.signal); + } + } +} + +/// Where each drained chunk goes. Families differ ONLY here: bash decodes +/// UTF-8 across chunk boundaries into registry output (+ an await-mode RAM +/// copy); scripts/setup append per-chunk lossy text to the task's log files +/// and `log` SSE frames. The drain/kill/escalation loop itself is one copy — +/// [`drive_group_to_exit`]. +#[async_trait::async_trait] +pub(crate) trait OutputSink: Send { + async fn write(&mut self, stream: OutputStream, bytes: &[u8]); +} + +/// What the drain loop proved: the leader's exit status (`None` when it was +/// never observed, the `-1` sentinel case) and whether the configured +/// timeout fired. Callers map this through [`exit_status_to_code`] / +/// [`classify_status`] and finalize their own registry entry. +pub(crate) struct DriveOutcome { + pub(crate) exit_status: Option, + pub(crate) timed_out: bool, +} + +/// The one drain/kill/escalation loop: drains stdout/stderr into `sink`, +/// honors an optional timeout (KILL on expiry) and durable +/// [`ProcessController`] signals (escalating TERM to KILL after +/// [`ESCALATE_AFTER`], byte-parity with `killWithEscalation`), and keeps the +/// group leader unreaped until every inherited output writer has closed — +/// its unreaped PID pins the process-group identity, so timeout/controller +/// signals can never target a recycled PGID. +pub(crate) async fn drive_group_to_exit( + child: &mut ProcessGroupChild, + mut stdout_pipe: ChildStdout, + mut stderr_pipe: ChildStderr, + timeout: Option, + controller: &ProcessController, + sink: &mut S, +) -> DriveOutcome { + // No timeout configured: use a far-future deadline instead of + // `Option>>` bookkeeping. Guarded out of the + // `select!` below via `timer_active` so it's never actually polled in + // that case. 100 years comfortably avoids `Instant` overflow while + // being "never" for any real call. + let sleep_duration = timeout.unwrap_or(Duration::from_secs(60 * 60 * 24 * 365 * 100)); + let sleep = tokio::time::sleep(sleep_duration); + tokio::pin!(sleep); + let escalation = tokio::time::sleep(ESCALATE_AFTER); + tokio::pin!(escalation); + + let mut stdout_open = true; + let mut stderr_open = true; + let mut exited = false; + let mut exit_status: Option = None; + let mut timed_out = false; + let mut timer_active = timeout.is_some(); + let mut observed_signal = None; + let mut escalation_active = false; + + let mut so_chunk = [0u8; 8192]; + let mut se_chunk = [0u8; 8192]; + + loop { + if exited && !stdout_open && !stderr_open { + break; + } + tokio::select! { + res = stdout_pipe.read(&mut so_chunk), if stdout_open => { + match res { + Ok(0) | Err(_) => stdout_open = false, + Ok(n) => sink.write(OutputStream::Stdout, &so_chunk[..n]).await, + } + } + res = stderr_pipe.read(&mut se_chunk), if stderr_open => { + match res { + Ok(0) | Err(_) => stderr_open = false, + Ok(n) => sink.write(OutputStream::Stderr, &se_chunk[..n]).await, + } + } + // Do not reap the group leader while descendants may still own an + // inherited pipe. Keeping the leader unreaped pins its PID/PGID, + // so every timeout/controller signal remains ownership-safe. + status = child.wait(), if !exited && !stdout_open && !stderr_open => { + exited = true; + timer_active = false; + escalation_active = false; + exit_status = status.ok(); + } + _ = &mut sleep, if timer_active && !exited => { + timer_active = false; + escalation_active = false; + timed_out = true; + child.signal(KillSignal::Kill).await; + } + sig = controller.wait_for_change(observed_signal), if !exited => { + observed_signal = Some(sig); + if child.signal(sig).await { + if matches!(sig, KillSignal::Term) { + escalation.as_mut().reset(tokio::time::Instant::now() + ESCALATE_AFTER); + escalation_active = true; + } else { + escalation_active = false; + } + } + } + _ = &mut escalation, if escalation_active && !exited => { + escalation_active = false; + child.signal(KillSignal::Kill).await; + } + } + } + + DriveOutcome { + exit_status, + timed_out, + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + use crate::tasks::{now_ms, TaskEntry, TaskSummary}; + + #[test] + fn classify_status_matches_task_manager_finalize() { + assert_eq!(classify_status(true, 0), TaskStatus::Timeout); + assert_eq!(classify_status(false, 0), TaskStatus::Exited); + assert_eq!(classify_status(false, -1), TaskStatus::Failed); + assert_eq!(classify_status(false, 137), TaskStatus::Killed); + assert_eq!(classify_status(false, 1), TaskStatus::Exited); + } + + #[cfg(unix)] + #[test] + fn exit_status_to_code_follows_the_128_plus_signal_shell_convention() { + use std::os::unix::process::ExitStatusExt; + use std::process::ExitStatus; + // Raw wait statuses: low byte = signal, next byte = exit code. + assert_eq!(exit_status_to_code(ExitStatus::from_raw(0)), 0); + assert_eq!(exit_status_to_code(ExitStatus::from_raw(3 << 8)), 3); + assert_eq!(exit_status_to_code(ExitStatus::from_raw(9)), 137); // SIGKILL + assert_eq!(exit_status_to_code(ExitStatus::from_raw(15)), 143); // SIGTERM + } + + fn summary(id: &str, command: &str, status: TaskStatus, log_name: Option<&str>) -> TaskSummary { + TaskSummary { + id: id.to_string(), + command: command.to_string(), + status, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: log_name.map(str::to_string), + intentional: None, + } + } + + /// Pins the `"tasks"` SSE payload shape every family now emits through + /// the one shared copy: running tasks only, `logName` present only when + /// the task has one. + #[test] + fn tasks_event_payload_shape_is_pinned() { + let dir = tempfile::tempdir().unwrap(); + let logs = Arc::new(crate::log_store::LogStore::new(dir.path().join("logs"))); + let tasks = TaskRegistry::new(logs); + let broadcaster = Broadcaster::new(); + let mut rx = broadcaster.subscribe(); + + tasks.insert(TaskEntry::new( + summary("task1", "npm run dev", TaskStatus::Running, Some("dev")), + None, + )); + tasks.insert(TaskEntry::new( + summary("task2", "echo done", TaskStatus::Exited, None), + None, + )); + + emit_tasks_event(&tasks, &broadcaster); + let event = rx.try_recv().expect("tasks event emitted"); + assert_eq!(event.name, "tasks"); + assert_eq!( + event.data, + json!({ + "active": [{ "id": "task1", "command": "npm run dev", "logName": "dev" }], + }) + ); + } + + #[test] + fn cancel_on_drop_fires_its_configured_signal_unless_disarmed() { + let fired: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = fired.clone(); + let handle: KillHandle = Arc::new(move |signal| { + sink.lock().unwrap().push(signal); + true + }); + + { + let mut guard = CancelOnDrop::new(handle.clone(), KillSignal::Term); + guard.disarm(); + } + assert!(fired.lock().unwrap().is_empty(), "disarmed guard is inert"); + + drop(CancelOnDrop::new(handle, KillSignal::Kill)); + assert_eq!(*fired.lock().unwrap(), vec![KillSignal::Kill]); + } +} diff --git a/apps/native/crates/local-api/src/router.rs b/apps/native/crates/local-api/src/router.rs new file mode 100644 index 0000000000..9029df125d --- /dev/null +++ b/apps/native/crates/local-api/src/router.rs @@ -0,0 +1,1353 @@ +//! The route table — TWO separate routers, one per loopback listener (see +//! `lib.rs`'s `start`/`ServerHandle` doc comments for why: the app's own API +//! and the dev-server preview now live on distinct origins, verified by a +//! spike as necessary for SSE/WebSocket to stream cleanly through a +//! port-isolated cross-origin iframe in the real WKWebView). SHARED FILE +//! (see the native module-ownership contract) — adding, removing, or +//! re-pathing a route is a change here; family implementers edit their +//! `routes/*.rs` handler bodies, never this file's wiring. If a family +//! genuinely needs a new path this file doesn't have, that's an interface +//! request, not a local edit. +//! +//! ## [`build`] — the MAIN listener +//! +//! 1. Public zone — `GET /health`; in embedded mode also exact UI assets, +//! HTML-navigation SPA fallback, and one-time `/_local/session/bootstrap`. +//! 2. `/_sandbox/*`, `/threads*`, `/models` — nested sub-routers, each +//! wrapped in [`guard`] (standalone Origin + bearer, or embedded exact +//! Host/unsafe-Origin + HttpOnly session cookie) +//! and each with its own JSON-404 fallback so an unmatched path WITHIN +//! one of these prefixes returns `{"error":"Not found: "}` rather +//! than falling through to the app-API fallback. +//! 3. Everything else (no matching prefix and not a public UI response) — +//! [`app_or_ui_fallback`] authenticates, removes only the local control +//! cookie, then invokes `routes::upstream::proxy`, the app-API +//! intercept-or-proxy catchall. Formerly reached only via a +//! `/upstream` prefix; a bare path now gets the exact same treatment, +//! since the reverse-proxy family that used to share this fallback +//! moved to its own listener (below). WRAPPED in [`guard]` — merged in +//! from a small sub-router (`app_api`) that carries its own +//! `.layer(guard)`, exactly like the zone-2 nests above (see [`build`]'s +//! body for why `.merge()`, not `.nest()`, is what mounts it at the +//! root with no path prefix). +//! +//! ## [`build_preview`] — the PREVIEW listener +//! +//! `routes::proxy::fallback` (the reverse-HTTP-proxy-to-dev-server catchall — +//! handle-header/active routing, the sniffed port, and the WS upgrade). No +//! local-api AUTHENTICATION is applied here: sandbox cookies and authorization +//! belong to the application under preview and are proxied unchanged. What IS +//! applied is [`preview_fence`], which removes local-api's own control cookie +//! and requires the `Host` to name a known sandbox — see its doc comment. An +//! explicitly enabled selftest route is the sole exception to the +//! otherwise-all-proxy route table, and is mounted outside the fence (it +//! answers on the bare host and proxies nowhere). +//! +//! ## OPTIONS preflight +//! +//! Intercepted by [`intercept_options`] as the OUTERMOST layer — before +//! ANY routing, including the zone-2 guards — so a preflight never risks a +//! 401/405. This is a deliberate reading of a genuinely ambiguous sentence +//! in the native local-API contract, which lists +//! "OPTIONS preflight" among examples of daemon-unauthenticated routes now +//! "tightened" to require the bearer. Taken completely literally that +//! would mean every real browser CORS preflight 401s (browsers never +//! attach `Authorization` to a preflight — that's the whole point of +//! preflight: ask permission BEFORE sending credentials), which would make +//! cross-origin `fetch()` from the embedded webview permanently broken. +//! No test in either suite pins OPTIONS-requires-bearer specifically (see +//! the bootstrap report). Implemented here: OPTIONS still enforces the +//! Origin allowlist (403 `forbidden_origin` on an unrecognized Origin, +//! matching the "checked before auth, fail fast" security posture) but +//! never the bearer. Flagged for the Phase 3 (Tauri shell) owner to +//! confirm against a real WKWebView preflight. + +use std::sync::Arc; + +use axum::extract::{Extension, OriginalUri, State}; +use axum::http::{header, Method, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::Router; + +use crate::client_auth::ClientAuth; +use crate::cors; +use crate::error::ApiError; +use crate::routes; +use crate::state::AppState; +use crate::ui::{self, UiAssetProvider}; + +type Request = axum::extract::Request; + +#[derive(Clone)] +struct GuardState { + auth: ClientAuth, + mode: crate::state::ApiMode, +} + +#[derive(Clone)] +struct MainRuntime { + auth: ClientAuth, + ui_assets: Option>, + preview_origin: Arc, +} + +pub fn build( + state: AppState, + auth: ClientAuth, + ui_assets: Option>, + preview_origin: String, +) -> Router { + let guard_state = GuardState { + auth: auth.clone(), + mode: state.mode, + }; + let runtime = MainRuntime { + auth: auth.clone(), + ui_assets, + preview_origin: preview_origin.into(), + }; + let sandbox = Router::new() + .route("/read", post(routes::fs::read)) + .route("/write", post(routes::fs::write)) + .route("/unlink", post(routes::fs::unlink)) + .route("/mkdir", post(routes::fs::mkdir)) + .route("/rename", post(routes::fs::rename)) + .route("/edit", post(routes::fs::edit)) + .route("/grep", post(routes::fs::grep)) + .route("/glob", post(routes::fs::glob)) + .route("/write_from_url", post(routes::fs::write_from_url)) + .route("/upload_to_url", post(routes::fs::upload_to_url)) + .route("/tools/sync", post(routes::fs::tools_sync)) + .route("/bash", post(routes::bash::bash)) + .route("/tasks", get(routes::tasks::list)) + .route("/tasks/kill-all", post(routes::tasks::kill_all)) + .route( + "/tasks/:id", + get(routes::tasks::get).delete(routes::tasks::delete), + ) + .route("/tasks/:id/kill", post(routes::tasks::kill)) + .route("/tasks/:id/stream", get(routes::tasks::stream)) + .route("/git/status", get(routes::git::status)) + .route("/git/diff", get(routes::git::diff).post(routes::git::diff)) + .route("/git/publish", post(routes::git::publish)) + .route("/git/discard", post(routes::git::discard)) + .route("/git/rebase", post(routes::git::rebase)) + .route( + "/config", + get(routes::config::read) + .post(routes::config::update) + .put(routes::config::update), + ) + .route("/setup/clone", post(routes::setup::clone)) + .route("/setup/install", post(routes::setup::install)) + .route("/setup/ensure", post(routes::setup::ensure)) + .route("/setup/start", post(routes::setup::start)) + // NEW — no daemon precedent (see routes::setup::stop's own doc + // comment): kills the resolved target's running dev/start task + // WITHOUT respawning, giving the sandbox drawer's Stop button a + // real desktop-local action to call. + .route("/setup/stop", post(routes::setup::stop)) + .route("/orgfs-config", post(routes::orgfs::orgfs_config)) + // `/_sandbox/orgfs/:org/:volume/**` — the loopback WebDAV surface + // rclone mounts as the org filesystem (P1 of + // `apps/native/docs/org-fs-plan.md`). Nested as a catchall rather + // than routed per path/method: everything after `/` is + // the in-volume path, and PROPFIND/MKCOL/MOVE are extension methods + // `axum::routing` has no filter for. Nested BEFORE `.layer(guard)` + // below so it inherits the same Origin + bearer/cookie + // authentication every other `/_sandbox` route gets. + .nest("/orgfs", routes::webdav::router()) + .route("/scripts", get(routes::scripts::list)) + .route("/exec/:name", post(routes::scripts::exec)) + .route("/exec/:name/kill", post(routes::scripts::exec_kill)) + .route("/events", get(routes::events::events)) + .route("/dispatch", post(routes::dispatch::dispatch)) + .route("/runs/:id", delete(routes::dispatch::cancel_run)) + .route("/preview-handle", post(routes::proxy::set_preview_handle)) + // GET /_sandbox/repo-dir — see routes::repo_dir's module doc (a + // git-backed sandbox's absolute workdir, for the webview's + // "Open in VS Code/Cursor" deep link). + .route("/repo-dir", get(routes::repo_dir::get)) + .fallback(sandbox_not_found) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + + let threads = Router::new() + .route( + "/", + get(routes::threads::list).post(routes::threads::create), + ) + .route( + "/:id", + get(routes::threads::get) + .patch(routes::threads::update) + .delete(routes::threads::delete), + ) + .route( + "/:id/messages", + get(routes::threads::messages_list).post(routes::threads::messages_create), + ) + .route("/:id/runs", get(routes::threads::runs_list)) + .fallback(sandbox_not_found) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + + let models = Router::new() + .route("/", get(routes::models::list)) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + + // Literal internal app-API routes. The general app-API catchall is owned + // by `app_or_ui_fallback`, where public UI navigation can be distinguished + // from a private upstream request before authentication is selected. + let app_api = Router::new() + // Literal route, matched BEFORE the app-API `proxy` fallback below + // — see `routes/upstream.rs::complete_session`'s doc comment for + // why this HTTP mirror of the `auth_complete_session` Tauri command + // exists (the e2e contract suite has no Tauri context to invoke IPC + // commands through). Renamed from the old `/upstream/_complete_session` + // now that the `/upstream` prefix is gone — a stable, collision-free + // bare path under its own `/_auth` namespace (distinct from any + // `/api/*` path a real mesh org might route). + .route( + "/_auth/complete-session", + post(routes::upstream::complete_session), + ) + // Black-box mirror of the Tauri `auth_status` command. Kept beside + // the existing complete-session/logout mirrors so all three observe + // the same process-wide upstream session. + .route("/_auth/status", get(routes::upstream::status)) + // Same rationale as `/_auth/complete-session` right above — see + // `routes/upstream.rs::logout`'s doc comment. Renamed from + // `/upstream/_logout`. + .route("/_auth/logout", post(routes::upstream::logout)) + // The WEBVIEW half of the browser-completed MCP OAuth flow. Guarded + // like everything else here: a parked authorization code must only be + // readable by a caller that proved it is this app. Its unauthenticated + // sibling — the redirect target the system browser actually lands on — + // is registered outside this layer; see `routes::mcp_callback`. + .route( + "/_auth/mcp-callback/result", + get(routes::mcp_callback::result).delete(routes::mcp_callback::discard), + ) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + + let mut app = Router::new() + .route("/health", get(routes::health::health)) + // CORS-only — `.route_layer()` wraps ONLY the routes registered so + // far (`/health`), never the `.nest()`ed zone-2 routers below (each + // already wrapped in the full `guard`, applied independently) or + // `app_api`'s own fallback merged in next (same reasoning — it + // carries its own `guard`). See [`cors_only`]'s doc comment for why + // `/health` needs this at all. + .route_layer(middleware::from_fn_with_state( + guard_state.clone(), + cors_only, + )) + .nest("/_sandbox", sandbox) + .nest("/threads", threads) + .nest("/models", models) + // `.merge()`, not `.nest()`: these literal routes live at the root. + .merge(app_api) + // Registered OUTSIDE `guard`, deliberately. This is where an MCP + // OAuth provider redirects the user's SYSTEM BROWSER, which has no + // per-launch session cookie and no way to be given one, so requiring + // it would 401 every consent that just succeeded. It is not the + // security boundary — the webview keeps `state` in memory and rejects + // any callback that does not match it — and it only ever parks bytes; + // reading them back still requires the guard. See + // `routes::mcp_callback`'s doc comment. + .route("/_auth/mcp-callback", get(routes::mcp_callback::receive)); + + if auth.is_embedded() { + let private_session = Router::new() + .route("/_local/session", get(session_status)) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + app = app.merge(private_session).route( + "/_local/session/bootstrap", + post(bootstrap_embedded_session), + ); + } + + app.fallback(app_or_ui_fallback) + .layer(middleware::from_fn_with_state( + guard_state, + intercept_options, + )) + .layer(Extension(runtime)) + // Exact Host validation is intentionally outermost so public UI, + // health, bootstrap, OPTIONS, and private routes share the same + // DNS-rebinding boundary. + .layer(middleware::from_fn_with_state(auth, require_expected_host)) + // …and authority normalization sits outside even that, because it is + // what makes `Host` present at all. See [`normalize_authority`]. + .layer(middleware::from_fn(normalize_authority)) + .with_state(state) +} + +/// Restore `Host` from the HTTP/2 `:authority` pseudo-header. +/// +/// HTTP/2 REPLACED `Host` with `:authority` (RFC 9113 §8.3.1), which hyper +/// surfaces on the request URI rather than in the header map. Every host check +/// in this process reads the `Host` header, so an h2 request arrives looking +/// like it has no host at all and each of them fails closed: the preview fence +/// 404s a perfectly valid sandbox, and `require_expected_host` would 403 the +/// entire API. +/// +/// That is not hypothetical — both listeners terminate TLS, ALPN offers h2, +/// and every modern client takes it (WKWebView, which IS the shipped app's +/// browser, and `curl` without `--http1.1`). It went unnoticed because the +/// dev webview reaches the API through Vite on its own port, and because the +/// preview proxy silently fell back to the ACTIVE sandbox whenever it could +/// not read a host — a fallback the fence has now removed. +/// +/// Normalizing ONCE, outermost, rather than teaching each check about both +/// shapes: there is exactly one place to get right, and a check added later +/// inherits the fix instead of re-introducing the bug. `:authority` wins over +/// any `Host` an h2 client also sent, as the RFC requires. An HTTP/1.1 +/// origin-form request has no authority on the URI, so its `Host` is left +/// exactly as received. +async fn normalize_authority(mut req: Request, next: Next) -> Response { + if let Some(authority) = req.uri().authority().map(|value| value.as_str().to_owned()) { + match axum::http::HeaderValue::from_str(&authority) { + Ok(value) => { + req.headers_mut().insert(header::HOST, value); + } + Err(_) => return ApiError::forbidden_origin().into_response(), + } + } + next.run(req).await +} + +/// The PREVIEW listener's router — see the module doc's "[`build_preview`] +/// — the PREVIEW listener" section. Application cookies and authorization +/// headers reach the selected sandbox unchanged; local-api's OWN control +/// cookie never does — see [`preview_fence`]. +/// +/// `preview_host_base`: the registrable host previews are served under +/// (`preview_url`'s base). Passed in rather than read from the process-global +/// so the fence is a function of its arguments and can be tested. +pub fn build_preview( + state: AppState, + preview_port: u16, + preview_host_base: &str, + cookie_selftest_control_origin: Option>, +) -> Router { + let fence = PreviewFence { + state: state.clone(), + // Only a registrable base has per-handle preview hosts to fence on; + // under a single-label host (`localhost`) every sandbox shares one + // origin, so there is no label to check. Mirrors `preview_url`'s gate. + base: preview_host_base + .contains('.') + .then(|| Arc::::from(preview_host_base.to_ascii_lowercase())), + }; + let mut app = Router::new() + .fallback(routes::proxy::fallback) + .layer(middleware::from_fn_with_state(fence, preview_fence)); + if let Some(control_origin) = cookie_selftest_control_origin { + app = app + .route( + "/_local/selftest/preview-cookie", + get(routes::proxy::preview_cookie_selftest) + .options(routes::proxy::preview_cookie_selftest), + ) + .route( + "/_local/selftest/preview-frame", + get(routes::proxy::preview_frame_selftest), + ) + .layer(Extension(routes::proxy::PreviewCookieSelftest { + control_origin, + expected_host: Arc::from(format!("localhost:{preview_port}")), + cookie_value: Arc::from(uuid::Uuid::new_v4().simple().to_string()), + })); + } + // Outermost, so the fence AND the selftest's own Host check both see a + // `Host` on an h2 request — see [`normalize_authority`]. + app.layer(middleware::from_fn(normalize_authority)) + .with_state(state) +} + +#[derive(Clone)] +struct PreviewFence { + state: AppState, + /// `Some` only when previews get per-handle hosts — see [`build_preview`]. + base: Option>, +} + +/// The preview listener's ONLY pre-routing step, and the reason the listener +/// can serve untrusted sandbox output at all. +/// +/// Cookies ignore the port (RFC 6265 §8.5), so the control cookie set on the +/// browser origin is attached to any same-site request — including one a +/// PREVIEWED page makes back at this listener. Two independent things stop +/// that from becoming a privilege escalation: +/// +/// 1. The control cookie is removed from every request, unconditionally. This +/// listener has no route that consumes it, and everything it does serve is +/// forwarded verbatim into a sandbox's own dev server, which must never see +/// it. Application cookies are untouched. +/// 2. The `Host` must name a sandbox this process knows. Without this, a +/// request arriving on the BARE control host resolved through +/// `resolve_preview`'s `active()` fallback and was proxied into whichever +/// sandbox happened to be focused. +/// +/// Either alone would close the reported path; both are here because they fail +/// independently — 1 survives a routing change, 2 survives a header-handling +/// change. +async fn preview_fence( + State(fence): State, + mut req: Request, + next: Next, +) -> Response { + crate::client_auth::remove_local_session_cookie(req.headers_mut()); + if let Some(base) = fence.base.as_deref() { + if !names_a_known_sandbox(&fence.state, req.headers(), base) { + return ApiError::not_found("Not found").into_response(); + } + } + next.run(req).await +} + +/// Whether `Host` is `