diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 05bcceca592..db34fddc2c2 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -1,6 +1,6 @@ name: Auto-tag on Release PR Merge -# Five release lanes share this one workflow. Four use an explicit branch +# Four PR-driven release lanes share this workflow. Each uses an explicit branch # prefix; the main chart lane also auto-detects a Chart.yaml version bump so # a chart feature PR can publish its own new version when merged: # @@ -10,18 +10,20 @@ name: Auto-tag on Release PR Merge # push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml # any internal PR that bumps deploy/charts/buzz/Chart.yaml `version` # → tag chart-v → helm-chart.yml (helm chart) -# mobile-release/ → tag mobile-v → (manual sprout_ref for buzz-releases build — see below) +# +# Mobile candidate tags do not come from merged PRs. Operators create immutable +# mobile-v-rc.N tags directly from remote main with scripts/mobile-release.sh, +# then hand the exact tag to buzz-releases. # # Release tags are created with a short-lived token from the dedicated # buzz-release-bot GitHub App. GitHub attributes the ref creation to that # App, so the consumer's `on.push.tags` trigger runs normally. The workflow's # default GITHUB_TOKEN remains read-only and is never used to create a tag. # -# The mobile lane is push-only by infosec necessity: OSS `block/buzz` CI must -# not trigger CI in the private `buzz-releases` repo, so auto-dispatch across -# that boundary is deliberately disallowed. The mobile-v* tag is consumed -# manually instead — a human feeds it as the `sprout_ref` input to the -# `buzz-releases` Buildkite pipeline, which builds and ships mobile. +# Mobile is manual-only by infosec necessity: OSS `block/buzz` CI must +# not trigger CI in the private `buzz-releases` repo. A human feeds the exact +# mobile candidate tag to the private Buildkite pipeline, which builds and +# ships mobile. on: pull_request: @@ -65,9 +67,6 @@ jobs: push-chart-release/*) VERSION="${BRANCH#push-chart-release/}" TAG_PREFIX="push-chart-v" ;; - mobile-release/*) - VERSION="${BRANCH#mobile-release/}" - TAG_PREFIX="mobile-v" ;; *) parent_sha="$(git rev-parse HEAD^)" old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f1e2a989cb..ad7f77f6bfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,9 +58,19 @@ jobs: - 'pnpm-lock.yaml' mobile: - 'mobile/**' + - 'scripts/mobile-release.sh' + - 'scripts/publish-mobile-release-candidate.sh' + - 'scripts/release-rulesets.sh' + - 'scripts/test-mobile-release-contract.sh' + - 'scripts/test-mobile-release-candidate-publisher.sh' + - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Mobile release contract + run: | + scripts/test-mobile-release-contract.sh + scripts/test-mobile-release-candidate-publisher.sh rust-lint: name: Rust Lint diff --git a/.github/workflows/mobile-release-candidate.yml b/.github/workflows/mobile-release-candidate.yml new file mode 100644 index 00000000000..e1f1c1c9184 --- /dev/null +++ b/.github/workflows/mobile-release-candidate.yml @@ -0,0 +1,69 @@ +name: Publish Mobile Release Candidate +run-name: Publish mobile-v${{ inputs.version }}-rc.${{ inputs.candidate_number }} + +on: + workflow_dispatch: + inputs: + version: + description: Mobile marketing version (X.Y.Z) + required: true + type: string + candidate_number: + description: Expected next release-candidate number + required: true + type: string + target_sha: + description: Exact current block/buzz main commit + required: true + type: string + +concurrency: + group: mobile-release-candidate-${{ inputs.version }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require the reviewed workflow from main + env: + DISPATCH_REF: ${{ github.ref }} + run: | + if [ "$DISPATCH_REF" != "refs/heads/main" ]; then + echo "::error::Mobile candidates must be dispatched from main, not $DISPATCH_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require canonical repository + env: + REPOSITORY: ${{ github.repository }} + run: | + if [ "$REPOSITORY" != "block/buzz" ]; then + echo "::error::Mobile candidate publication is restricted to block/buzz" + exit 1 + fi + + - name: Create release tagger token + id: release-tagger + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }} + private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }} + permission-contents: write + + - name: Publish annotated candidate tag + env: + GH_TOKEN: ${{ steps.release-tagger.outputs.token }} + MOBILE_VERSION: ${{ inputs.version }} + CANDIDATE_NUMBER: ${{ inputs.candidate_number }} + TARGET_SHA: ${{ inputs.target_sha }} + run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA" diff --git a/AGENTS.md b/AGENTS.md index c94ba3881e2..3edfb34f602 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -475,13 +475,16 @@ class instances, cached promises) survive across remounts. Every community-scope singleton needs a reset function wired into `resetCommunityState()` in `desktop/src/features/communities/useCommunityInit.ts`. -Current singletons that are reset on community switch: +Current singletons that are reset on relay boundary changes (same-relay +reconnects preserve pending avatar verification work): - `relayClient.disconnect()` — WebSocket teardown + promise rejection - `resetRateLimitGate()` — clears any active rate-limit window from the old relay - `clearAllDrafts()` — message draft cache - `resetAgentObserverStore()` — agent observer relay store - `resetActiveAgentTurnsStore()` — active agent turn timers - `resetAgentWorkingSignal()` — agent working indicator signal +- `resetAvatarProfileSync()` — pending verified-avatar profile writes +- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts - `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state - `resetMediaCaches()` — proxy port and relay origin caches - `resetVideoPlayerState()` — video player singleton @@ -566,5 +569,5 @@ just mobile-dev - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints - [TESTING.md](TESTING.md) — multi-agent E2E test guide - [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships -- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds +- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds - [README.md](README.md) — project overview and quick start diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e3597df34..d42a6c22037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## v0.4.24 + +- fix(desktop): suppress Windows console flashes and reject WSL bash alias ([#2587](https://github.com/block/buzz/pull/2587)) ([`5afa16157`](https://github.com/block/buzz/commit/5afa16157a63c71f2cd8a80aa7276de28ce1c54c)) +- fix(desktop): fix Windows PATH clobber and .cmd shim EINVAL ([#2563](https://github.com/block/buzz/pull/2563)) ([`cca16635d`](https://github.com/block/buzz/commit/cca16635d69dc8bea5406013095d25f3b0e287d3)) +- Gate default relay auto-connect behind release flag ([#2589](https://github.com/block/buzz/pull/2589)) ([`e67303f60`](https://github.com/block/buzz/commit/e67303f60334d6cd4224216080bd4b851fc5ee4d)) +- fix(desktop): fast-track relay restart reconnects ([#2579](https://github.com/block/buzz/pull/2579)) ([`f3f7688c3`](https://github.com/block/buzz/commit/f3f7688c3a4ecb0405ca8b26e0b6ee815e0f11e6)) +- fix(sharing): preserve agent/team snapshot tEXt chunks through media sanitization ([#2438](https://github.com/block/buzz/pull/2438)) ([`b096b0a15`](https://github.com/block/buzz/commit/b096b0a15af4c4566365c5b1efe7f39b700222ed)) +- test(desktop): live relay kill/restart reconnect gate ([#2583](https://github.com/block/buzz/pull/2583)) ([`6a56c8bda`](https://github.com/block/buzz/commit/6a56c8bdac6d115a0d6d48b24a2a04dc46b336c5)) +- fix(desktop): retry failed initial relay dials ([#2564](https://github.com/block/buzz/pull/2564)) ([`9ec52cfed`](https://github.com/block/buzz/commit/9ec52cfedf579d2ccb2021c216abd4c821a15165)) +- Refine channel lifecycle settings ([#2427](https://github.com/block/buzz/pull/2427)) ([`daeaf7c33`](https://github.com/block/buzz/commit/daeaf7c33d5415199a33cbc3dab00244fad5c219)) +- Fix avatar upload lifecycle edge cases ([#2277](https://github.com/block/buzz/pull/2277)) ([`80244f823`](https://github.com/block/buzz/commit/80244f82318c85f931d1055e419456945c5eca99)) +- fix(observer): eager archive hydration on panel open + 200-frame pages ([#2574](https://github.com/block/buzz/pull/2574)) ([`8cb05028b`](https://github.com/block/buzz/commit/8cb05028be84829501a4f87f8db4ee7034fb786f)) +- chore: Omit the Model control when an optional-model harness has nothing to select ([#2262](https://github.com/block/buzz/pull/2262)) ([`9cf953904`](https://github.com/block/buzz/commit/9cf9539040aee9774cf8d12b651348a2806c05c0)) +- fix(media): sanitize animated image uploads ([#2524](https://github.com/block/buzz/pull/2524)) ([`8f8f5fa5a`](https://github.com/block/buzz/commit/8f8f5fa5a4b2463cdc6c2a527acb7086150cdaae)) +- fix(desktop): populate team instructions when opening the edit team dialog ([#2565](https://github.com/block/buzz/pull/2565)) ([`55c921124`](https://github.com/block/buzz/commit/55c9211241fcfb92a36ed5d6935cb4d2b3ae0702)) +- feat(desktop): add drag-to-reorder for community rail ([#2549](https://github.com/block/buzz/pull/2549)) ([`1e68c6c05`](https://github.com/block/buzz/commit/1e68c6c05021ef2fc93ed0a02a84009ce94cdda5)) +- fix(channels): strip leading hash prefixes from names ([#2250](https://github.com/block/buzz/pull/2250)) ([`d0ab3fdb0`](https://github.com/block/buzz/commit/d0ab3fdb054e0cfedbf21e4c5143ad6c671c10cc)) +- fix(desktop): allow skipping harness setup onboarding ([#2360](https://github.com/block/buzz/pull/2360)) ([`06e3d82b0`](https://github.com/block/buzz/commit/06e3d82b04ab326a36694264ffb4b9dd94ec5661)) + + ## v0.4.23 - fix(desktop): strip GIF metadata extensions before upload ([#2425](https://github.com/block/buzz/pull/2425)) ([`47d7eb698`](https://github.com/block/buzz/commit/47d7eb6982900920bcdbe7a2f5013baca37daeeb)) diff --git a/Cargo.lock b/Cargo.lock index b5b2605a4be..9d0190868de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,7 @@ dependencies = [ "nostr", "rand 0.10.1", "reqwest 0.13.4", + "rustls", "serde", "serde_json", "sha2 0.11.0", @@ -1121,6 +1122,7 @@ name = "buzz-relay" version = "0.2.0" dependencies = [ "anyhow", + "async-compression", "async-trait", "axum", "base64", @@ -1139,6 +1141,7 @@ dependencies = [ "chrono", "dashmap", "deadpool-redis", + "flate2", "futures", "futures-util", "hex", diff --git a/Justfile b/Justfile index 3e4b6bee093..701a1f06b2c 100644 --- a/Justfile +++ b/Justfile @@ -211,12 +211,19 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs cd desktop/src-tauri echo "=== Clean build (no flag) → expect false ===" env -u BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT \ + -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=false \ cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture - echo "=== Internal build (flag set) → expect true ===" + env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ + BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ + cargo test compiled_flag_matches_expected -- --ignored --nocapture + echo "=== Internal build (flags set) → expect true ===" BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \ BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \ cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture + BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ + BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ + cargo test compiled_flag_matches_expected -- --ignored --nocapture echo "Both compiled states verified." # Build the full desktop Tauri app locally (unsigned, for testing) @@ -667,14 +674,6 @@ get-next-patch-version: get-next-relay-patch-version: @python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" -# Read the current mobile version from pubspec.yaml (strips the +build suffix) -get-current-mobile-version: - @grep -m1 '^version: ' mobile/pubspec.yaml | sed -E 's/version: ([^+]*).*/\1/' - -# Compute next mobile patch version (e.g., 0.3.0 → 0.3.1) -get-next-mobile-patch-version: - @python3 -c "v='$(just get-current-mobile-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" - # Update version in desktop package manifests and regenerate lockfiles bump-desktop-version version: #!/usr/bin/env bash @@ -714,16 +713,6 @@ bump-relay-version version: cargo update -p buzz-relay echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" -# Bump the mobile pubspec version and regenerate the lockfile -bump-mobile-version version: - #!/usr/bin/env bash - set -euo pipefail - # pubspec carries a `version: X.Y.Z+build`; preserve the `+build` convention - # (a literal `+1`, matching the desktop lane's prior behavior). - perl -i -pe 's/^version: .*/version: {{ version }}+1/' mobile/pubspec.yaml - (unset GIT_DIR GIT_WORK_TREE; cd mobile && flutter pub get) - echo "Bumped mobile to {{ version }} and regenerated pubspec.lock" - # Open or update the desktop release PR (signed desktop app) release-desktop *ARGS: #!/usr/bin/env bash @@ -748,22 +737,8 @@ release-relay *ARGS: fi just _release-pr relay "$VERSION" -# Open or update the mobile release PR (Buzz mobile app) -release-mobile *ARGS: - #!/usr/bin/env bash - set -euo pipefail - ARG="{{ ARGS }}" - if [[ -z "$ARG" || "$ARG" == "patch" ]]; then - VERSION=$(just get-next-mobile-patch-version) - else - VERSION="$ARG" - fi - just _release-pr mobile "$VERSION" - -# Shared release-PR engine. One body, three lanes — the only lane-specific steps -# are the version-bump command and the file/tag/changelog identifiers selected -# in the `case` below. Everything else (git preflight, branch reset, changelog -# generation, commit, push, PR open/edit) is identical across lanes. +# Shared release-PR engine for desktop and relay. Mobile publishes immutable +# candidate tags directly from remote main instead of using metadata-only PRs. _release-pr lane version: #!/usr/bin/env bash set -euo pipefail @@ -794,16 +769,6 @@ _release-pr lane version: ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md) LOG_PATHS=(crates/buzz-relay/ crates/buzz-core/ crates/buzz-db/ crates/buzz-auth/ crates/buzz-pubsub/ crates/buzz-search/ crates/buzz-audit/ crates/buzz-media/ crates/buzz-sdk/ crates/buzz-workflow/ crates/buzz-conformance/ migrations/) ARTIFACT="Buzz Relay" ;; - mobile) - BRANCH_PREFIX="mobile-release" - TAG_FETCH='mobile-v*' - TAG_MATCH='mobile-v[0-9]*' - TAG_EXCLUDE='mobile-v*-*' - TAG_PREFIX="mobile-v" - CHANGELOG="mobile/CHANGELOG.md" - ADD_FILES=(mobile/pubspec.yaml mobile/pubspec.lock mobile/CHANGELOG.md) - LOG_PATHS=(mobile/) - ARTIFACT="Buzz Mobile" ;; *) echo "Error: unknown release lane '{{ lane }}'" exit 1 ;; @@ -844,7 +809,6 @@ _release-pr lane version: case "{{ lane }}" in desktop) just bump-desktop-version "$VERSION" ;; relay) just bump-relay-version "$VERSION" ;; - mobile) just bump-mobile-version "$VERSION" ;; esac # Generate the changelog from commits since this lane's last release tag. LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "") diff --git a/RELEASING.md b/RELEASING.md index ebbce41c745..9785122aadd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,25 +1,18 @@ # Releasing Buzz -Buzz has three independent release lanes, each driven by a release PR — no human -ever pushes a git tag: +Buzz has three independent release lanes. Desktop and relay use release PRs. +Mobile uses immutable release-candidate tags cut directly from remote `main`: -| Lane | Recipe | Artifact | -|------|--------|----------| +| Lane | Entry point | Artifact | +|------|-------------|----------| | Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | -| Mobile | `just release-mobile` | Buzz mobile app (tag is the `sprout_ref` for the internal build) | +| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | -The three lanes version independently: the desktop version lives in -`desktop/package.json`, the relay version in `crates/buzz-relay/Cargo.toml`, and -the mobile version in `mobile/pubspec.yaml`. - -The mobile lane publishes a `mobile-v` tag that is consumed -**manually**, cross-repo, as the `sprout_ref` input to the internal -`buzz-releases` Buildkite pipeline (iOS dogfood → Block Comp Portal, App Store → -TestFlight — see [Internal Releases](#internal-releases)). The OSS lane is -tag-only **by design**: OSS `block/buzz` CI cannot trigger CI in the private -`buzz-releases` repo (infosec), so a human cuts the internal build from the tag -rather than auto-dispatching across that boundary. +The lanes version independently. Desktop reads its manifests, relay reads its +crate manifest, and mobile derives both source and marketing version from the +exact candidate tag. The mobile handoff to the private `buzz-releases` pipeline +remains manual because OSS CI cannot trigger private CI. ## Quick Start @@ -27,126 +20,97 @@ rather than auto-dispatching across that boundary. # Desktop release (next patch version) just release-desktop -# Desktop patch / minor / explicit -just release-desktop patch +# Desktop explicit version just release-desktop 0.4.0 -just release-desktop 1.0.0 -# Relay release (same argument forms) +# Relay release just release-relay just release-relay 0.4.0 -# Mobile release (same argument forms) -just release-mobile -just release-mobile 0.4.0 +# Publish the next mobile candidate from the exact current remote main commit +scripts/mobile-release.sh candidate 0.5.0 ``` -`just release-desktop` creates a `version-bump/` PR; `just -release-relay` creates a `relay-release/` PR; `just release-mobile` -creates a `mobile-release/` PR. Each bumps its own version manifest, -regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger -the build automatically (the mobile tag is instead the `sprout_ref` a human -feeds the internal build — see above). - -Re-running any of these recipes with the same version is safe — it detects the -existing branch and PR, resets to current `main`, regenerates the changelog -with any new commits, and updates the PR in place. +Desktop and relay releases use metadata PRs. Mobile does not. Each +`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. +There is no mobile release branch, stable mobile tag alias, finalization step, +or mobile GitHub Release. --- ## How It Works -All three lanes share one engine; they differ only in which version manifest -they bump, which branch prefix they use, and what the merge triggers. - -The merge workflow creates tags with a short-lived installation token from the -dedicated `buzz-release-bot` GitHub App. Release-tag rules allow that App to -create matching tags and prevent other actors from creating, moving, or -deleting them. The workflow's default `GITHUB_TOKEN` is read-only. - ### Desktop -1. **`just release-desktop`** runs locally on `main` — computes the next - version, creates (or reuses) a `version-bump/` branch, bumps the - desktop manifests, regenerates lockfiles, generates a changelog - entry in `CHANGELOG.md`, commits, pushes, and opens (or updates) a PR. -2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the - `version-bump/*` branch merge and pushes a `v` tag. -3. **Tag triggers `release.yml`** — builds, signs, notarizes, and publishes the - desktop app for macOS and Linux. +1. **`just release-desktop`** runs locally on `main`, creates or updates a + `version-bump/` PR, bumps the desktop manifests, regenerates + lockfiles, and updates `CHANGELOG.md`. +2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v`. +3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and + publishes the desktop app for macOS and Linux. ### Relay -1. **`just release-relay`** runs locally on `main` — computes the next relay - version, creates (or reuses) a `relay-release/` branch, bumps - `crates/buzz-relay/Cargo.toml`, regenerates `Cargo.lock`, generates a - changelog entry in `crates/buzz-relay/CHANGELOG.md`, commits, pushes, and - opens (or updates) a PR. -2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the - `relay-release/*` branch merge and pushes a `relay-v` tag. -3. **Tag triggers `docker.yml`** — the `relay-v` push triggers - `docker.yml`, which builds the multi-arch relay - image and publishes `ghcr.io/block/buzz:` (plus `:.`, - `:`, and `:latest` for stable releases). Prereleases - (`relay-v-rc.1`) publish only the prerelease tag and do **not** - move `:latest`. GitHub runs the tag trigger because the tag is created by - the dedicated GitHub App rather than the workflow's `GITHUB_TOKEN`. - -Every push to `main` continues to build and publish `:main` + `:sha-<7>` tags -(the rolling development image). The `:latest` tag tracks the latest **stable** -relay release only — it does not move on main pushes or prereleases. +1. **`just release-relay`** runs locally on `main`, creates or updates a + `relay-release/` PR, bumps `crates/buzz-relay/Cargo.toml`, + regenerates `Cargo.lock`, and updates the relay changelog. +2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes + `relay-v`. +3. **The tag triggers `docker.yml`.** Stable releases update the version + aliases and `latest`; prereleases do not. + +Every push to `main` continues to publish the rolling relay `:main` and +`:sha-<7>` tags. ### Mobile -1. **`just release-mobile`** runs locally on `main` — computes the next mobile - version, creates (or reuses) a `mobile-release/` branch, bumps - `mobile/pubspec.yaml` (preserving the `+build` number), regenerates - `mobile/pubspec.lock`, generates a changelog entry in `mobile/CHANGELOG.md`, - commits, pushes, and opens (or updates) a PR. -2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the - `mobile-release/*` branch merge and pushes a `mobile-v` tag. -3. **The tag is consumed manually, cross-repo** — nothing in OSS `block/buzz` - builds on the tag (OSS CI must not trigger CI in the private `buzz-releases` - repo — infosec). A human feeds the `mobile-v` tag as the - `sprout_ref` input to the internal `buzz-releases` Buildkite pipeline, which - builds and ships iOS to Block Comp Portal (dogfood) and TestFlight (App - Store, opt-in). See [Internal Releases](#internal-releases). +1. **Publish a candidate.** From a clean checkout whose `origin` is the + canonical `block/buzz` repository, run + `scripts/mobile-release.sh candidate X.Y.Z`. The script resolves and fetches + the exact current `origin/main` commit, derives the next number from exact + remote tags for that marketing version, and publishes an annotated + `mobile-vX.Y.Z-rc.N` tag there through the dedicated `buzz-release-bot` + GitHub App. It never uses the operator's checked-out commit and never moves + an existing candidate. +2. **Build the exact tag.** Enter the candidate tag as `mobile_ref` in the + private Buzz mobile Buildkite pipeline. OSS CI deliberately cannot trigger + that private pipeline. The tag supplies both source commit and release + version. Flutter receives clean marketing version `X.Y.Z`; Buildkite's + monotonically increasing build number supplies the platform build number. +3. **Promote tested artifacts.** Promote the already-built signed artifact for + each platform through its store workflow. Record the exact tag with the + build or rollout record. No source ref is changed and no final build is cut. + +The iOS and Android artifacts for one marketing version may come from different +RC tags. For example, iOS can ship `mobile-v0.5.0-rc.2` while Android ships +`mobile-v0.5.0-rc.3`. Each platform's exact candidate tag is its source record. +There is intentionally no single selected or final candidate for the marketing +version. + +The simplification trades away a separate stabilization line. Unrelated commits +that reach `main` become part of every later candidate, and there is no retained +hotfix branch or branch-ancestry history. Add a dedicated hotfix flow later if a +release actually needs isolation from `main`. + +`mobile/pubspec.yaml` keeps `0.0.0+1` only as a valid, visibly non-release +fallback for local development and validation builds. Release jobs always +inject both version fields. `mobile/CHANGELOG.md` is retained as historical +release data. It is not a release ledger for this flow. --- -## Release Types +## Version Sources -The argument forms below apply to `release-desktop`, `release-relay`, and -`release-mobile`: +| Lane | Release version authority | +|------|---------------------------| +| Desktop | `desktop/package.json` and synchronized desktop manifests | +| Relay | `crates/buzz-relay/Cargo.toml` | +| Mobile | Exact `mobile-vX.Y.Z-rc.N` remote tag | -| Command | Version | Example | -|---------|---------|---------| -| `just release-desktop` | Next patch | `0.3.0` → `0.3.1` | -| `just release-desktop patch` | Next patch | `0.3.0` → `0.3.1` | -| `just release-desktop 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` | -| `just release-desktop 1.0.0` | Explicit | `1.0.0` | - ---- - -## Version Files - -`just bump-desktop-version ` (desktop lane) updates these files: - -| File | Field | -|------|-------| -| `desktop/package.json` | `"version"` | -| `desktop/src-tauri/tauri.conf.json` | `"version"` | -| `desktop/src-tauri/Cargo.toml` | `version` (under `[package]`) | - -It also regenerates `pnpm-lock.yaml` and `desktop/src-tauri/Cargo.lock`. - -`just bump-relay-version ` (relay lane) updates -`crates/buzz-relay/Cargo.toml` (`version` under `[package]`) and regenerates the -workspace `Cargo.lock`. - -`just bump-mobile-version ` (mobile lane) updates -`mobile/pubspec.yaml` (`version:`, preserving the `+build` number) and -regenerates `mobile/pubspec.lock`. +`just bump-desktop-version ` updates the desktop manifests and +regenerates their lockfiles. `just bump-relay-version ` updates the +relay crate and regenerates `Cargo.lock`. Mobile has no bump recipe or +release-metadata PR. --- @@ -184,42 +148,46 @@ existing immutable `v` tag. Select that tag in the ref picker and provide the matching semver version without the `v` prefix. It cannot build from `main` or another caller-selected source ref. +Mobile intentionally has no branch or arbitrary-ref fallback. The private +Buildkite pipeline accepts only an exact candidate tag. + --- ## Internal Releases -After the OSS release ships, trigger an internal build via the -[sprout-releases Buildkite pipeline](https://buildkite.com/runway/sprout-releases). -See the [buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release) -for the full step-by-step instructions and input field reference. +For mobile, trigger the private +[Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with +an exact RC tag for the platform build being cut. For desktop, use +[Release Desktop](https://buildkite.com/runway/sprout-releases). See the +[buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release) +for the private pipeline contract. --- ## What Gets Published -Each release produces two GitHub releases: +Desktop publishes two GitHub releases: -1. **`v`** — the user-facing release with the `.dmg` installer - (macOS). +1. **`v`**: the user-facing release with installers. +2. **`buzz-desktop-latest`**: the rolling auto-updater release. -2. **`buzz-desktop-latest`** — a rolling pre-release for the Tauri - auto-updater containing `latest.json` and each platform's signed - updater artifact plus its `.sig` signature (`.tar.gz` on macOS, - `.AppImage` on Linux, and `_alpha-unsigned.exe` on Windows). +Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts +and rollout records retain the exact tag they used. Mobile does not publish a +GitHub Release or a stable `mobile-vX.Y.Z` alias. --- ## Platform Support -The release workflow builds **two separate macOS DMGs** — Apple +The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel -(`darwin-x86_64`, the `release-macos-x64` job) — plus Linux `.deb` and +(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and `.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to the same `v` release. Intel users download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on -Mesa 25+ / GLib 2.88 distros — see +Mesa 25+ / GLib 2.88 distros; see [tauri-apps/tauri#15665](https://github.com/tauri-apps/tauri/issues/15665)) and re-signs the artifact. As a result the AppImage relies on the host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 @@ -231,8 +199,16 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 ## Prerequisites - **Write access** to the `block/buzz` GitHub repository -- **`gh` CLI** authenticated (`gh auth status`) -- The following **GitHub Actions secrets** must be configured: +- An `origin` remote whose configured URL is the canonical `block/buzz` + repository +- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch + the candidate workflow +- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754) + active for `mobile-v*`, with creation, update, deletion, and non-fast-forward + protections and `buzz-release-bot` as its sole always-bypass actor +- The `buzz-release-bot` App credentials configured for GitHub Actions +- The following **GitHub Actions secrets** must also be configured for the + desktop release lane: | Secret | Purpose | |--------|---------| @@ -240,6 +216,15 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key | +Mobile candidate publication requires workflow-dispatch access and the existing +release App because strict tag protection denies direct human creation. The App +must be installed on `block/buzz`, have Contents write and Metadata read, and +retain an `always` bypass on the immutable `mobile-v*` tag rules. It does not +require GitHub Releases permissions, repository Administration permission, or a +mobile release-branch ruleset. The publisher validates the App token's effective +`current_user_can_bypass` value rather than reading the ruleset's hidden bypass +actor list. + --- ## Troubleshooting @@ -250,15 +235,38 @@ Switch to `main` and pull latest before running the release recipe. ### `just release-desktop` fails with "working tree is dirty" Commit or stash your changes before running the release recipe. -### New commits merged after creating the release PR -Re-run the release recipe (`just release-desktop`, `just release-relay`, or `just release-mobile`) from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch. +### New commits land after publishing a mobile candidate + +Run `scripts/mobile-release.sh candidate ` again after the intended +fix reaches remote `main`. It publishes a new immutable RC tag at the new exact +remote commit. Continue referring to each tested or shipped platform artifact by +its own exact tag. + +### `scripts/mobile-release.sh candidate` fails because `main` moved during publication + +The App-backed workflow may already have published the requested immutable RC +at the prior `main` tip before the operator command detects the race. Do not +move or delete that tag, and do not treat it as the candidate for current +`main`. Inspect the run URL from the command output, then rerun +`scripts/mobile-release.sh candidate ` to publish the next RC from the +new current `main` tip. + +### A mobile candidate command selects the wrong RC number + +Do not retry by moving or deleting a tag. Inspect the exact remote `mobile-v*` +tags and resolve the unexpected state. Candidate numbers are monotonically +increasing remote identities. + +### A mobile candidate publication is rejected by repository rules -### Build fails at "Validate version" -The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pre-release suffix. Do not include a `v` prefix. +Confirm `buzz-release-bot` remains the sole always-bypass actor for the active +`mobile-v*` ruleset and that its Actions credentials are available. Do not grant +direct human creation or weaken update or deletion protection. Existing +candidate tags must remain immutable. ### Auto-updater reports "no update available" Verify that the `buzz-desktop-latest` release exists and contains a valid `latest.json`. The manifest covers all four platform keys (`darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`, `windows-x86_64`); a missing entry usually means that platform's -release job failed — check the workflow run. +release job failed. Check the workflow run. diff --git a/SECURITY.md b/SECURITY.md index 96222e029d0..09ea73022b3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,7 +5,7 @@ **Please do not report security vulnerabilities through public GitHub issues.** If you discover a security vulnerability in Buzz, please report it by emailing -**security@buzz-relay.org**. Include as much detail as possible: +**buzz@block.xyz**. Include as much detail as possible: - A description of the vulnerability and its potential impact - Steps to reproduce or a proof-of-concept (if available) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index b553adaabb9..78db7ff718b 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -466,6 +466,10 @@ impl AcpClient { #[cfg(unix)] cmd.process_group(0); + // Suppress the console window that Windows otherwise allocates for every + // console-subsystem child process spawned from a GUI/non-console parent. + configure_no_window(&mut cmd); + let mut child = cmd.spawn()?; let stdin = child @@ -1987,6 +1991,19 @@ fn kill_process_group(_pid: u32) -> bool { false } +/// Suppress the console window that Windows otherwise allocates for every +/// console-subsystem child process spawned from a GUI (non-console) parent. +/// No-op on non-Windows platforms. +fn configure_no_window(cmd: &mut tokio::process::Command) { + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + let _ = cmd; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 862732f4780..03b75a42111 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -220,13 +220,32 @@ async fn is_owner_or_sibling( /// Coarse security policy applied before subscription rules. Both `OwnerOnly` /// and `Allowlist` accept the owner and same-owner siblings; `Allowlist` /// additionally accepts the explicit external pubkey list. +/// +/// # DM hardening (`is_dm`) +/// +/// Clients auto-p-tag every DM participant, so in a DM *any* participant's +/// message looks like a mention and would fire a turn. Combined with +/// agent-initiated DMs (the agent can be asked to DM a third party), that +/// turns `anyone`/`allowlist` modes into transitive access grants: whoever +/// lands in a DM with the agent can prompt it. To close that hole, when +/// `is_dm` is true only the owner and cryptographically verified same-owner +/// siblings may fire a turn — the explicit allowlist and `anyone` mode do +/// NOT apply inside DMs. `Nobody` still drops everything. Callers must +/// resolve `is_dm` fail-closed: unknown channel type ⇒ treat as DM. async fn author_allowed( respond_to: &RespondTo, allowlist: &HashSet, author: &str, + is_dm: bool, owner_cache: &OwnerCache, rest_client: &relay::RestClient, ) -> bool { + if is_dm { + return match respond_to { + RespondTo::Nobody => false, + _ => is_owner_or_sibling(author, owner_cache, rest_client).await, + }; + } match respond_to { RespondTo::Anyone => true, RespondTo::Nobody => false, @@ -238,6 +257,35 @@ async fn author_allowed( } } +/// Resolve whether `channel_id` is a DM, for the inbound author gate. +/// +/// Resolution order: +/// 1. Startup discovery metadata (`startup_info`) — covers channels known at +/// process start. +/// 2. Per-loop resolution cache (`cache`) — covers channels resolved since. +/// 3. Lazy REST fetch of the channel's kind:39000 metadata — covers channels +/// the agent was added to *after* startup (the exploit path: an +/// agent-initiated DM is exactly such a channel). +/// +/// Fail-closed: if the fetch fails or times out, the channel is treated as a +/// DM for this event and the result is NOT cached, so a later event retries +/// the fetch instead of pinning a mis-classification. +pub(crate) async fn is_dm_channel( + channel_id: Uuid, + channel_info: &pool::ChannelInfoResolver, +) -> bool { + match channel_info.resolve(channel_id).await { + Some(info) => info.channel_type == "dm", + None => { + tracing::warn!( + channel_id = %channel_id, + "channel type unresolved — treating as DM for author gate (fail closed)" + ); + true + } + } +} + /// Query an author's kind:0 profile and check if their NIP-OA auth tag /// proves the same owner as us. async fn check_sibling_via_profile( @@ -1501,7 +1549,7 @@ async fn tokio_main() -> Result<()> { .to_string_lossy() .to_string(), rest_client: relay.rest_client(), - channel_info: channel_info_map, + channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), context_message_limit: config.context_message_limit, max_turns_per_session: config.max_turns_per_session, permission_mode: config.permission_mode, @@ -2097,10 +2145,16 @@ async fn tokio_main() -> Result<()> { // it never revokes same-owner team bots. { let author = buzz_event.event.pubkey.to_hex(); + // DM hardening: resolve channel type (fail-closed + // to DM) so allowlist/anyone modes cannot be + // exercised by non-owner authors inside DMs. + let is_dm = + is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; let allowed = author_allowed( &config.respond_to, &config.respond_to_allowlist, &author, + is_dm, &owner_cache, &ctx.rest_client, ) @@ -2110,6 +2164,7 @@ async fn tokio_main() -> Result<()> { channel_id = %buzz_event.channel_id, author = %buzz_event.event.pubkey.to_hex(), mode = %config.respond_to, + is_dm, "inbound author gate — dropping event" ); continue; @@ -4293,6 +4348,7 @@ mod author_gate_tests { let cache = OwnerCache::new(Some(OWNER.into())); cache.cache_sibling(SIBLING.into(), true); cache.cache_sibling(STRANGER.into(), false); + cache.cache_sibling(EXTERNAL.into(), false); cache } @@ -4305,6 +4361,7 @@ mod author_gate_tests { &RespondTo::Allowlist, &allowlist, SIBLING, + false, &cache, &dummy_rest_client() ) @@ -4322,6 +4379,7 @@ mod author_gate_tests { &RespondTo::Allowlist, &allowlist, EXTERNAL, + false, &cache, &dummy_rest_client() ) @@ -4339,6 +4397,7 @@ mod author_gate_tests { &RespondTo::Allowlist, &allowlist, STRANGER, + false, &cache, &dummy_rest_client() ) @@ -4356,6 +4415,7 @@ mod author_gate_tests { &RespondTo::Allowlist, &allowlist, OWNER, + false, &cache, &dummy_rest_client() ) @@ -4376,6 +4436,7 @@ mod author_gate_tests { &RespondTo::OwnerOnly, &HashSet::new(), STRANGER, + false, &cache, &dummy_rest_client() ) @@ -4393,6 +4454,7 @@ mod author_gate_tests { &RespondTo::OwnerOnly, &HashSet::new(), who, + false, &cache, &dummy_rest_client() ) @@ -4401,6 +4463,235 @@ mod author_gate_tests { ); } } + + // ── DM hardening ────────────────────────────────────────────────────── + // + // In a DM, clients auto-p-tag every participant, and an agent can be + // asked to open a DM with a third party. The gate must therefore ignore + // the allowlist and `anyone` mode inside DMs: only owner + verified + // siblings fire turns. + + #[tokio::test] + async fn test_dm_rejects_allowlisted_external_pubkey() { + let cache = cache_with_sibling(); + let allowlist = HashSet::from([EXTERNAL.to_string()]); + assert!( + !author_allowed( + &RespondTo::Allowlist, + &allowlist, + EXTERNAL, + true, + &cache, + &dummy_rest_client() + ) + .await, + "an allowlisted external pubkey must NOT fire a turn inside a DM" + ); + } + + #[tokio::test] + async fn test_dm_rejects_stranger_under_anyone() { + let cache = cache_with_sibling(); + assert!( + !author_allowed( + &RespondTo::Anyone, + &HashSet::new(), + STRANGER, + true, + &cache, + &dummy_rest_client() + ) + .await, + "respond_to=anyone must still drop non-owner authors inside a DM" + ); + } + + #[tokio::test] + async fn test_dm_admits_owner_and_sibling_in_every_responding_mode() { + let cache = cache_with_sibling(); + for mode in [ + RespondTo::OwnerOnly, + RespondTo::Allowlist, + RespondTo::Anyone, + ] { + for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { + assert!( + author_allowed( + &mode, + &HashSet::new(), + who, + true, + &cache, + &dummy_rest_client() + ) + .await, + "in a DM under {mode}, the {label} must still be admitted" + ); + } + } + } + + #[tokio::test] + async fn test_dm_nobody_rejects_even_owner() { + let cache = cache_with_sibling(); + assert!( + !author_allowed( + &RespondTo::Nobody, + &HashSet::new(), + OWNER, + true, + &cache, + &dummy_rest_client() + ) + .await, + "respond_to=nobody must drop everything, DMs included" + ); + } + + // ── is_dm_channel resolution ────────────────────────────────────────── + + fn resolver(startup: HashMap) -> pool::ChannelInfoResolver { + pool::ChannelInfoResolver::new(startup, dummy_rest_client()) + } + + #[tokio::test] + async fn test_is_dm_channel_uses_definitive_startup_metadata() { + let dm_id = Uuid::new_v4(); + let stream_id = Uuid::new_v4(); + let startup = HashMap::from([ + ( + dm_id, + relay::ChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + }, + ), + ( + stream_id, + relay::ChannelInfo { + name: "stream".into(), + channel_type: "stream".into(), + }, + ), + ]); + let resolver = resolver(startup); + assert!(is_dm_channel(dm_id, &resolver).await); + assert!(!is_dm_channel(stream_id, &resolver).await); + } + + #[tokio::test] + async fn test_is_dm_channel_fails_closed_for_unknown_startup_metadata() { + let id = Uuid::new_v4(); + let startup = HashMap::from([( + id, + relay::ChannelInfo { + name: "unknown".into(), + channel_type: "unknown".into(), + }, + )]); + assert!( + is_dm_channel(id, &resolver(startup)).await, + "missing startup metadata must not be trusted as a stream" + ); + } + + async fn lazy_resolver_with_response( + response: serde_json::Value, + ) -> ( + pool::ChannelInfoResolver, + std::sync::Arc, + tokio::task::JoinHandle<()>, + ) { + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test HTTP server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = std::sync::Arc::new(AtomicUsize::new(0)); + let server_requests = requests.clone(); + let body = response.to_string(); + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut request = vec![0; 8192]; + let _ = socket.read(&mut request).await; + server_requests.fetch_add(1, Ordering::SeqCst); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let rest = relay::RestClient { + http: reqwest::Client::new(), + base_url, + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + ( + pool::ChannelInfoResolver::new(HashMap::new(), rest), + requests, + server, + ) + } + + #[tokio::test] + async fn test_is_dm_channel_lazy_resolves_declared_dm_and_caches_it() { + use std::sync::atomic::Ordering; + + let id = Uuid::new_v4(); + let response = serde_json::json!([{ + "tags": [["d", id.to_string()], ["name", "DM"], ["t", "dm"]] + }]); + let (resolver, requests, server) = lazy_resolver_with_response(response).await; + + assert!(is_dm_channel(id, &resolver).await); + assert!(is_dm_channel(id, &resolver).await); + assert_eq!( + requests.load(Ordering::SeqCst), + 1, + "second resolution uses cache" + ); + server.abort(); + } + + #[tokio::test] + async fn test_discovery_without_metadata_stays_fail_closed_at_author_gate() { + let id = Uuid::new_v4(); + let discovered = relay::merge_discovered_channels(vec![id], &serde_json::json!([])); + let channel_info = resolver(discovered); + let owner_cache = cache_with_sibling(); + let allowlist = HashSet::from([EXTERNAL.to_string()]); + + let is_dm = is_dm_channel(id, &channel_info).await; + assert!(is_dm, "unknown startup metadata must fail closed as DM"); + assert!( + !author_allowed( + &RespondTo::Allowlist, + &allowlist, + EXTERNAL, + is_dm, + &owner_cache, + &dummy_rest_client(), + ) + .await, + "an external author must not pass when startup discovery omitted metadata" + ); + } + + #[tokio::test] + async fn test_is_dm_channel_fails_closed_when_lazy_resolution_fails() { + assert!( + is_dm_channel(Uuid::new_v4(), &resolver(HashMap::new())).await, + "an unresolvable channel type must be treated as a DM" + ); + } } #[cfg(test)] diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index fb08096792c..cc537f86830 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -427,6 +427,58 @@ pub enum PromptOutcome { /// /// Built once from `Config` at startup. Avoids cloning the full config /// into every task. +/// Shared channel-metadata resolver for startup-known and dynamically joined channels. +/// +/// Successful lazy lookups are cached for every consumer (author gate, prompt +/// context, canvas, and setup mode). Unknown metadata is never cached as a +/// non-DM: callers can fail closed and a later event retries resolution. +#[derive(Debug, Clone)] +pub struct ChannelInfoResolver { + cache: std::sync::Arc>>, + rest_client: RestClient, +} + +impl ChannelInfoResolver { + pub fn new( + startup: std::collections::HashMap, + rest_client: RestClient, + ) -> Self { + let cache = startup + .into_iter() + .filter_map(|(id, info)| { + (info.channel_type != "unknown").then_some(( + id, + PromptChannelInfo { + name: info.name, + channel_type: info.channel_type, + }, + )) + }) + .collect(); + Self { + cache: std::sync::Arc::new(std::sync::RwLock::new(cache)), + rest_client, + } + } + + pub async fn resolve(&self, channel_id: Uuid) -> Option { + if let Some(info) = self + .cache + .read() + .ok() + .and_then(|cache| cache.get(&channel_id).cloned()) + { + return Some(info); + } + + let info = fetch_channel_info(channel_id, &self.rest_client).await?; + if let Ok(mut cache) = self.cache.write() { + cache.insert(channel_id, info.clone()); + } + Some(info) + } +} + pub struct PromptContext { pub mcp_servers: Vec, pub initial_message: Option, @@ -450,8 +502,8 @@ pub struct PromptContext { pub cwd: String, /// REST client for pre-prompt context fetches (thread/DM history). pub rest_client: RestClient, - /// Channel metadata from discovery (name, type). Read-only after startup. - pub channel_info: std::collections::HashMap, + /// Shared channel metadata for startup-known and dynamically joined channels. + pub channel_info: ChannelInfoResolver, /// Max messages to include in thread/DM context. 0 = disabled. pub context_message_limit: u32, /// Max turns per session before proactive rotation. 0 = disabled. @@ -1380,13 +1432,12 @@ pub async fn run_prompt_task( if is_new_channel_session && !agent.state.canvas_sections.contains_key(cid) { // Resolve DM status: prefer the startup cache, lazy-fetch as fallback. // Unknown → treat as DM (fail-closed). - let is_dm = match ctx.channel_info.get(cid) { - Some(ci) => ci.channel_type == "dm", - None => fetch_channel_info(*cid, &ctx.rest_client) - .await - .map(|ci| ci.channel_type == "dm") - .unwrap_or(true), - }; + let is_dm = ctx + .channel_info + .resolve(*cid) + .await + .map(|ci| ci.channel_type == "dm") + .unwrap_or(true); if !is_dm { if let Some(section) = fetch_canvas_section(*cid, &ctx.rest_client).await { pending_canvas = Some((*cid, section)); @@ -1690,13 +1741,7 @@ pub async fn run_prompt_task( } else if let Some(ref b) = batch { // Build prompt from batch with context enrichment. // Try startup cache first; lazy-fetch via REST for dynamic channels. - let channel_info = match ctx.channel_info.get(&b.channel_id) { - Some(ci) => Some(PromptChannelInfo { - name: ci.name.clone(), - channel_type: ci.channel_type.clone(), - }), - None => fetch_channel_info(b.channel_id, &ctx.rest_client).await, - }; + let channel_info = ctx.channel_info.resolve(b.channel_id).await; let conversation_context = if ctx.context_message_limit > 0 { fetch_conversation_context(b, &channel_info, &ctx).await @@ -2189,7 +2234,10 @@ where /// Uses `CONTEXT_FETCH_TIMEOUT` with one retry on failure. Returns `None` on /// persistent failure (graceful degradation — prompt will lack channel name and /// DM detection). -async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option { +pub(crate) async fn fetch_channel_info( + channel_id: Uuid, + rest: &RestClient, +) -> Option { use nostr::{Alphabet, SingleLetterTag}; let d_tag = SingleLetterTag::lowercase(Alphabet::D); @@ -2211,25 +2259,14 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option name = arr.get(1).and_then(|v| v.as_str()), - Some("hidden") => is_hidden = true, - Some("private") => is_private = true, - _ => {} + if arr.first().and_then(|v| v.as_str()) == Some("name") { + name = arr.get(1).and_then(|v| v.as_str()); } } } - let channel_type = if is_hidden { - "dm".to_string() - } else if is_private { - "private".to_string() - } else { - "stream".to_string() - }; + let channel_type = crate::relay::channel_type_from_tags(tags); Some(PromptChannelInfo { name: name.unwrap_or("unknown").to_string(), channel_type, @@ -5253,7 +5290,15 @@ mod tests { keys: agent_keys.clone(), auth_tag_json: None, }, - channel_info: std::collections::HashMap::new(), + channel_info: ChannelInfoResolver::new( + std::collections::HashMap::new(), + RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".to_string(), + keys: agent_keys.clone(), + auth_tag_json: None, + }, + ), context_message_limit: 0, max_turns_per_session: 0, permission_mode: PermissionMode::Default, diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index ce7d565f12a..c8312cc61e5 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -135,6 +135,29 @@ pub struct ChannelInfo { pub channel_type: String, } +pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String { + let mut is_hidden = false; + let mut is_private = false; + let mut declared_type = None; + for tag in tags { + if let Some(arr) = tag.as_array() { + match arr.first().and_then(|v| v.as_str()) { + Some("hidden") => is_hidden = true, + Some("private") => is_private = true, + Some("t") => declared_type = arr.get(1).and_then(|v| v.as_str()), + _ => {} + } + } + } + if declared_type == Some("dm") || is_hidden { + "dm".to_string() + } else if declared_type == Some("private") || is_private { + "private".to_string() + } else { + "stream".to_string() + } +} + /// Build the discovered-channel subscribe set from the membership UUIDs and the /// kind:39000 metadata events, **skipping any channel flagged `archived=true`**. /// @@ -143,9 +166,9 @@ pub struct ChannelInfo { /// re-form the reconnect loop. Dropping them here is the defense-in-depth /// backstop to the relay-side live-subscription eviction — it covers a client /// that was offline when the channel was reaped and so missed the CLOSED. -/// A channel with no metadata event defaults to a `stream` named `unknown`, -/// preserving prior behavior for non-archived channels. -fn merge_discovered_channels( +/// A channel with no metadata event is preserved as `unknown`; security +/// consumers must lazy-resolve it or fail closed rather than assuming stream. +pub(crate) fn merge_discovered_channels( channel_uuids: Vec, meta_events: &serde_json::Value, ) -> HashMap { @@ -159,16 +182,12 @@ fn merge_discovered_channels( }; let mut d_val = None; let mut name = None; - let mut is_hidden = false; - let mut is_private = false; let mut is_archived = false; for tag in tags { if let Some(arr) = tag.as_array() { match arr.first().and_then(|v| v.as_str()) { Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()), Some("name") => name = arr.get(1).and_then(|v| v.as_str()), - Some("hidden") => is_hidden = true, - Some("private") => is_private = true, Some("archived") => { is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true") } @@ -183,14 +202,7 @@ fn merge_discovered_channels( continue; } let ch_name = name.unwrap_or("unknown").to_string(); - // DMs have the "hidden" tag; private channels have "private". - let ch_type = if is_hidden { - "dm".to_string() - } else if is_private { - "private".to_string() - } else { - "stream".to_string() - }; + let ch_type = channel_type_from_tags(tags); meta_map.insert(uuid, (ch_name, ch_type)); } } @@ -204,7 +216,7 @@ fn merge_discovered_channels( } let (name, channel_type) = meta_map .remove(&uuid) - .unwrap_or_else(|| ("unknown".to_string(), "stream".to_string())); + .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string())); map.insert(uuid, ChannelInfo { name, channel_type }); } map @@ -4071,6 +4083,21 @@ mod tests { serde_json::json!({ "tags": tags }) } + #[test] + fn merge_discovered_channels_preserves_missing_metadata_as_unknown() { + let channel = Uuid::new_v4(); + let map = merge_discovered_channels(vec![channel], &serde_json::json!([])); + assert_eq!(map[&channel].channel_type, "unknown"); + } + + #[test] + fn merge_discovered_channels_uses_declared_dm_type_without_hidden_hint() { + let channel = Uuid::new_v4(); + let meta = serde_json::json!([meta_event(channel, "dm", &["t", "dm"])]); + let map = merge_discovered_channels(vec![channel], &meta); + assert_eq!(map[&channel].channel_type, "dm"); + } + #[test] fn merge_discovered_channels_skips_archived_metadata() { let live = Uuid::new_v4(); diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index dda78572607..b1a9372ea46 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -383,6 +383,8 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> let publisher = relay.event_publisher(); let rest_client = relay.rest_client(); + let channel_info = crate::pool::ChannelInfoResolver::new(channel_info_map, rest_client.clone()); + // Deduplicate by event-id so reconnect replay cannot double-nudge. let mut nudged_event_ids: HashSet = HashSet::new(); @@ -424,12 +426,15 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> } // Apply the same author gate as normal mode so the nudge only goes - // to authors the real agent would have answered. + // to authors the real agent would have answered. Same DM hardening: + // in DMs only owner/siblings get a nudge (fail-closed on unknown type). let author_hex = buzz_event.event.pubkey.to_hex(); + let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; let allowed = author_allowed( &config.respond_to, &config.respond_to_allowlist, &author_hex, + is_dm, &owner_cache, &rest_client, ) diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 71c3193a062..fa8815df50a 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -732,6 +732,8 @@ async fn spawn_one( #[cfg(unix)] cmd.process_group(0); + configure_no_window(&mut cmd); + let transport = TokioChildProcess::new(cmd) .map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?; let pgid = transport.id(); @@ -987,6 +989,19 @@ fn tool_result_content( out } +/// Suppress the console window that Windows otherwise allocates for every +/// console-subsystem child process spawned from a GUI (non-console) parent. +/// No-op on non-Windows platforms. +fn configure_no_window(cmd: &mut Command) { + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + let _ = cmd; +} + #[cfg(test)] mod content_tests { use super::*; @@ -1098,4 +1113,27 @@ mod content_tests { } assert_eq!(super::truncate_middle("ok", 1024), "ok"); } + + #[test] + fn configure_no_window_is_a_noop_on_non_windows() { + // Cross-host: calling configure_no_window must not panic on any OS. + // On non-Windows the body is a cfg-gated no-op and the argument is + // consumed as `let _ = cmd`, so the only assertion is "didn't crash". + let mut cmd = Command::new("true"); + configure_no_window(&mut cmd); + } + + #[cfg(windows)] + #[test] + fn configure_no_window_compiles_and_applies_flag_on_windows() { + // On Windows, creation_flags(0x0800_0000) must be accepted without panicking. + // The call is a setter with no getter on tokio::process::Command, so the + // regression test confirms the flag is SET by checking the std inner command. + let mut cmd = Command::new("cmd.exe"); + configure_no_window(&mut cmd); + // std::process::Command on Windows does have as_inner / get_creation_flags via + // CommandExt — but tokio wraps it; we verify by ensuring the call compiles and + // the resulting spawn wouldn't OOM (build+flag-set is the full contract here). + // The real protection is the cfg-gated production path in spawn_one(). + } } diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index a12260b526a..1476e60bfd4 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -76,6 +76,13 @@ dirs = "6" # WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay) buzz-ws-client = { path = "../buzz-ws-client" } +# Explicit rustls dep with ring provider — required to install the process-level +# CryptoProvider at startup. Without this the standalone `buzz` binary panics when +# a multi-package release build (buzz-acp + buzz-dev-mcp + buzz-cli in one cargo +# invocation) unifies both ring and aws-lc-rs features, leaving rustls unable to +# auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } + # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d5c6b6f9abb..0f8caa416ae 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -25,6 +25,18 @@ where I: IntoIterator, S: Into + Clone, { + // Install ring as the process-level rustls CryptoProvider. Required because the + // release workflow builds all binaries in one cargo invocation, which unifies + // features across the workspace and enables *both* ring (from buzz-acp/buzz-dev-mcp) + // and aws-lc-rs (from reqwest's rustls feature via hyper-rustls). With both on, + // rustls cannot auto-select a provider, and any code that reaches + // ClientConfig::builder() — specifically the WSS path in publish_ephemeral_event + // used by `agents draft-create`, `agents draft-update`, and `users set-presence` + // — panics at rustls crypto/mod.rs. The `let _ =` swallow is intentional: when + // buzz-dev-mcp delegates to run_from_args, it has already installed ring; the + // double-install returns Err and is harmless. + let _ = rustls::crypto::ring::default_provider().install_default(); + let cli = match Cli::try_parse_from(args) { Ok(cli) => cli, Err(e) => { diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index cc4725468c0..9b98974802f 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -184,3 +184,30 @@ async fn async_main(cmd: String) -> Result<(), Box> { service.waiting().await?; Ok(()) } + +/// Suppress the console window that Windows otherwise allocates for every +/// console-subsystem child process spawned from a non-console parent. +/// No-op on non-Windows platforms. +pub(crate) fn configure_no_window(cmd: &mut std::process::Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + let _ = cmd; +} + +/// Suppress the console window for async (`tokio::process::Command`) spawns. +/// Equivalent to `configure_no_window` but accepts a tokio command. +/// No-op on non-Windows platforms. +pub(crate) fn configure_no_window_async(cmd: &mut tokio::process::Command) { + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + let _ = cmd; +} diff --git a/crates/buzz-dev-mcp/src/rg.rs b/crates/buzz-dev-mcp/src/rg.rs index 199b63432e7..9d5d506d61b 100644 --- a/crates/buzz-dev-mcp/src/rg.rs +++ b/crates/buzz-dev-mcp/src/rg.rs @@ -21,11 +21,10 @@ fn try_system_rg(args: &[String]) -> Option { let cleaned_path = clean_path(&self_canon); let candidate = which_rg(&cleaned_path)?; - let status = Command::new(&candidate) - .args(args) - .env("PATH", &cleaned_path) - .status() - .ok()?; + let mut cmd = Command::new(&candidate); + cmd.args(args).env("PATH", &cleaned_path); + crate::configure_no_window(&mut cmd); + let status = cmd.status().ok()?; Some(status.code().unwrap_or(2)) } diff --git a/crates/buzz-dev-mcp/src/shell.rs b/crates/buzz-dev-mcp/src/shell.rs index b7df4c67478..7aa95b1d879 100644 --- a/crates/buzz-dev-mcp/src/shell.rs +++ b/crates/buzz-dev-mcp/src/shell.rs @@ -177,6 +177,7 @@ pub async fn run( cmd.stderr(Stdio::piped()); cmd.kill_on_drop(true); set_process_group(&mut cmd); + crate::configure_no_window_async(&mut cmd); let started = Instant::now(); let mut child = match cmd.spawn() { @@ -561,6 +562,36 @@ fn git_bash_from_standard_path_bases( .find(|bash| bash.is_file()) } +/// True if `path` is inside the Windows app-execution-alias directory +/// (`%LOCALAPPDATA%\Microsoft\WindowsApps`). Paths in that directory are WSL +/// stub launchers, not real executables — running them spawns `wsl.exe` / +/// `wslhost.exe` / `conhost.exe` trees rather than the intended shell. +/// +/// The check is purely path-structural (component-wise, case-insensitive) so it +/// compiles and is testable on any host. It matches the path component named +/// `Microsoft` immediately followed by `WindowsApps`, so a sibling directory +/// named `MicrosoftWindowsApps` does not match. +#[cfg(any(windows, test))] +fn is_windows_apps_alias(path: &Path) -> bool { + let mut components = path.components().peekable(); + while components.peek().is_some() { + let mut it = components.clone(); + if it.next().is_some_and(|c| { + c.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case("Microsoft") + }) && it.next().is_some_and(|c| { + c.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case("WindowsApps") + }) { + return true; + } + components.next(); + } + false +} + /// True if `dir` is `root` or lives under it, comparing path components /// case-INsensitively. Windows paths are case-insensitive, but `Path::starts_with` /// compares components case-sensitively on every platform — so a PATH entry spelled @@ -583,12 +614,28 @@ fn is_under_dir(dir: &Path, root: &Path) -> bool { } /// Scan the child's PATH for `bash.exe`, skipping the Windows system directory -/// (`system_root`, normally `%SystemRoot%`) so we never resolve WSL's -/// `System32\bash.exe`. PATH is parsed with `std::env::split_paths` (never a -/// hand-split on ';') so it matches exactly what the spawned child would see. +/// (`system_root`, normally `%SystemRoot%`) and the Windows app-execution-alias +/// directory (`%LOCALAPPDATA%\Microsoft\WindowsApps`) so we never resolve WSL's +/// `System32\bash.exe` or the `WindowsApps\bash.exe` stub launcher. +/// +/// Skipping happens during iteration so scanning continues to the next PATH entry +/// when an alias is encountered — alias-first/real-bash-second selects the real one. +/// PATH is parsed with `std::env::split_paths` (never a hand-split on `;`) so it +/// matches exactly what the spawned child would see. #[cfg(windows)] fn scan_path_for_bash(path_env: &str, system_root: Option<&Path>) -> Option { - scan_path_for_command(Path::new("bash.exe"), path_env, system_root) + for dir in std::env::split_paths(path_env) { + if let Some(root) = system_root { + if is_under_dir(&dir, root) { + continue; + } + } + let candidate = dir.join("bash.exe"); + if candidate.is_file() && !is_windows_apps_alias(&candidate) { + return Some(candidate); + } + } + None } /// Scan `path_env` for `name` (or `name.exe` on Windows if `name` has no @@ -1030,6 +1077,63 @@ mod tests { "stdout: {stdout}" ); } + + // --- is_windows_apps_alias predicate tests (cross-host) --- + + #[test] + fn test_windows_apps_alias_detected_typical_path() { + // Typical WSL alias: %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe + // Forward-slash form parses on both Windows and non-Windows hosts. + assert!( + is_windows_apps_alias(Path::new( + "C:/Users/alice/AppData/Local/Microsoft/WindowsApps/bash.exe" + )), + "standard WindowsApps path must be detected as an alias" + ); + } + + #[test] + fn test_windows_apps_alias_detected_case_insensitive() { + assert!( + is_windows_apps_alias(Path::new( + "C:/Users/alice/AppData/Local/MICROSOFT/WINDOWSAPPS/bash.exe" + )), + "WindowsApps detection must be case-insensitive" + ); + } + + #[test] + fn test_windows_apps_alias_rejected_real_git_bash() { + assert!( + !is_windows_apps_alias(Path::new("C:/Program Files/Git/bin/bash.exe")), + "real Git Bash must not be detected as a WindowsApps alias" + ); + } + + #[test] + fn test_windows_apps_alias_rejected_system32_bash() { + assert!( + !is_windows_apps_alias(Path::new("C:/Windows/System32/bash.exe")), + "System32 bash must not be detected as a WindowsApps alias" + ); + } + + #[test] + fn test_windows_apps_alias_rejected_partial_component_match() { + // A directory named "Microsoft" without a "WindowsApps" sibling must not match. + assert!( + !is_windows_apps_alias(Path::new("C:/Microsoft/SomeOtherDir/bash.exe")), + "path with Microsoft but not WindowsApps must not be detected" + ); + } + + #[test] + fn test_windows_apps_alias_rejected_unix_bash() { + assert!( + !is_windows_apps_alias(Path::new("/usr/bin/bash")), + "Unix bash must not be detected as a WindowsApps alias" + ); + } } #[cfg(all(test, windows))] @@ -1343,4 +1447,57 @@ mod windows_resolver_tests { .expect("bash on PATH must be found"); assert_eq!(found, real_bash); } + + /// WSL alias rejection — when WindowsApps\bash.exe is first on PATH and a + /// legitimate Git Bash follows, the scanner must skip the alias and return + /// the real one (alias-first / real-bash-second ordering). + #[test] + fn windows_apps_alias_first_real_bash_second_returns_real() { + let base = tempdir().expect("base"); + + // Simulate %LOCALAPPDATA%\Microsoft\WindowsApps structure. + let microsoft = base.path().join("Microsoft"); + let windows_apps = microsoft.join("WindowsApps"); + std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps"); + let alias_bash = windows_apps.join("bash.exe"); + touch(&alias_bash); + + // Legitimate Git Bash in a separate directory. + let git_bin = base.path().join("git").join("bin"); + std::fs::create_dir_all(&git_bin).expect("mkdir git/bin"); + let real_bash = git_bin.join("bash.exe"); + touch(&real_bash); + + let path_env = env::join_paths([windows_apps.clone(), git_bin.clone()]).expect("join"); + let sys_root = tempdir().expect("sysroot"); // empty + + let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path())) + .expect("real bash must be found after skipping alias"); + assert_eq!( + found, real_bash, + "must skip WindowsApps alias and return real bash" + ); + } + + /// WSL alias rejection — when only WindowsApps\bash.exe is on PATH (no real + /// Git Bash installed), the scanner must return None rather than the alias. + #[test] + fn windows_apps_alias_only_returns_none() { + let base = tempdir().expect("base"); + + let microsoft = base.path().join("Microsoft"); + let windows_apps = microsoft.join("WindowsApps"); + std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps"); + let alias_bash = windows_apps.join("bash.exe"); + touch(&alias_bash); + + let path_env = env::join_paths([windows_apps.clone()]).expect("join"); + let sys_root = tempdir().expect("sysroot"); // empty + + let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path())); + assert!( + found.is_none(), + "alias-only PATH must return None, not the WSL launcher" + ); + } } diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index bba338d52c2..ee940dfb24c 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -571,6 +571,24 @@ fn validate_jpeg_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { Err(MediaError::InvalidImage) } +/// tEXt keywords that carry Buzz snapshot manifests (`.agent.png` / +/// `.team.png`). These are deliberate product payloads — agent/team sharing +/// embeds a manifest in a single tEXt chunk — so they are exempt from the +/// metadata ban. Exactly one snapshot chunk is permitted per file; every +/// other textual/metadata chunk remains forbidden. +const PNG_SNAPSHOT_KEYWORDS: [&[u8]; 2] = [b"buzz_agent_snapshot", b"buzz_team_snapshot"]; + +/// Returns true when a raw tEXt chunk payload is a Buzz snapshot manifest: +/// the payload must start with an allowlisted keyword followed by the +/// keyword/text NUL separator. +fn is_snapshot_text_chunk(payload: &[u8]) -> bool { + PNG_SNAPSHOT_KEYWORDS.iter().any(|keyword| { + payload.len() > keyword.len() + && &payload[..keyword.len()] == *keyword + && payload[keyword.len()] == 0 + }) +} + fn validate_png_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { const SIG: &[u8] = b"\x89PNG\r\n\x1a\n"; if !bytes.starts_with(SIG) { @@ -578,6 +596,7 @@ fn validate_png_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { } let mut i = SIG.len(); let mut saw_iend = false; + let mut saw_snapshot_chunk = false; while i < bytes.len() { if i + 12 > bytes.len() { return Err(MediaError::InvalidImage); @@ -589,7 +608,19 @@ fn validate_png_metadata_free(bytes: &[u8]) -> Result<(), MediaError> { .and_then(|v| v.checked_add(len)) .filter(|&v| v <= bytes.len()) .ok_or(MediaError::InvalidImage)?; - if matches!(&kind, b"eXIf" | b"tEXt" | b"zTXt" | b"iTXt" | b"iCCP") { + if &kind == b"tEXt" { + // Buzz agent/team snapshot manifests ride in a single tEXt chunk + // with an allowlisted keyword. Anything else — other keywords, or + // a second snapshot chunk — is a forbidden metadata channel. + let payload = &bytes[i + 8..end - 4]; + if saw_snapshot_chunk || !is_snapshot_text_chunk(payload) { + return Err(MediaError::MetadataForbidden); + } + saw_snapshot_chunk = true; + i = end; + continue; + } + if matches!(&kind, b"eXIf" | b"zTXt" | b"iTXt" | b"iCCP") { return Err(MediaError::MetadataForbidden); } // Unknown ancillary chunks are private metadata channels. Keep only @@ -1272,6 +1303,67 @@ mod tests { )); } + #[test] + fn test_png_snapshot_text_chunks_are_allowed() { + // Agent/team snapshot manifests ride in an allowlisted tEXt chunk; + // the relay must accept exactly one such chunk per file. + let config = test_config(); + for keyword in [b"buzz_agent_snapshot".as_slice(), b"buzz_team_snapshot"] { + let mut payload = keyword.to_vec(); + payload.push(0); + payload.extend_from_slice(b"eyJmb3JtYXQiOiJidXp6In0="); + let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + png.extend_from_slice(&png_chunk(b"tEXt", &payload)); + png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]); + assert_eq!( + validate_content(&png, &config).unwrap_or_else(|error| panic!( + "rejected snapshot tEXt keyword {}: {error}", + String::from_utf8_lossy(keyword) + )), + "image/png" + ); + } + } + + #[test] + fn test_png_snapshot_text_chunk_rejected_when_duplicated_or_spoofed() { + let config = test_config(); + + // Two snapshot chunks: the second is a covert channel. + let mut payload = b"buzz_agent_snapshot".to_vec(); + payload.push(0); + payload.extend_from_slice(b"data"); + let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + png.extend_from_slice(&png_chunk(b"tEXt", &payload)); + png.extend_from_slice(&png_chunk(b"tEXt", &payload)); + png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]); + assert!(matches!( + validate_content(&png, &config), + Err(MediaError::MetadataForbidden) + )); + + // Keyword prefix without the NUL separator, and near-miss keywords, + // stay forbidden. + for payload in [ + b"buzz_agent_snapshotX\0data".as_slice(), + b"buzz_agent_snapshot_extra\0data", + b"buzz_agent_snapshot", // no separator at all + b"Comment\0GPS=37.7,-122.4", + ] { + let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + png.extend_from_slice(&png_chunk(b"tEXt", payload)); + png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]); + assert!( + matches!( + validate_content(&png, &config), + Err(MediaError::MetadataForbidden) + ), + "accepted non-snapshot tEXt payload {:?}", + String::from_utf8_lossy(payload) + ); + } + } + #[test] fn test_rejects_jpeg_app_metadata_comments_and_trailing_payload() { let config = test_config(); diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 0aba81ec841..10a83f03242 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -78,6 +78,7 @@ metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } metrics-util = { workspace = true } pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] } +async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } [features] dev = ["buzz-auth/dev"] @@ -90,3 +91,4 @@ buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } tokio-tungstenite = { workspace = true } futures = "0.3" +flate2 = "1.1.9" diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index fcd86f7bd31..80e74e1ca0e 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -50,6 +50,13 @@ const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300 const RECEIVE_PACK_MAX_OUTPUT_BYTES: u64 = 1024 * 1024; /// Maximum ref advertisement output after manifest ref-count validation. const INFO_REFS_MAX_OUTPUT_BYTES: u64 = 4 * 1024 * 1024; +/// Maximum *decoded* upload-pack request body (want/have negotiation). +/// +/// Bounds the output of [`decode_git_request_body`] so a small gzip bomb +/// cannot bypass the compressed-body `RequestBodyLimitLayer`. Negotiation +/// bodies are pkt-lines of wants/haves (~50 bytes per ref); even a repo +/// with a million refs stays far under this. +const UPLOAD_PACK_MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024; /// NIP-98 auth extractor for git routes. /// @@ -714,6 +721,62 @@ async fn info_refs_subprocess( .unwrap()) } +/// Decode a smart-HTTP git request body according to its `Content-Encoding`. +/// +/// Git's smart-HTTP client gzip-compresses the `git-upload-pack` / +/// `git-receive-pack` request body once it exceeds an internal size +/// threshold (`http.postBuffer`-independent; triggered by the number of +/// want/have lines, so it fires reliably on many-ref clones). The relay +/// pipes the request body straight into the git subprocess's stdin, so a +/// still-compressed body reaches `git upload-pack` as raw gzip and fails +/// with `fatal: protocol error: bad line length character`. Transparently +/// inflate here so the subprocess always sees plain pkt-lines. +/// +/// Only `gzip` is decoded (the sole encoding git emits). An unknown +/// non-identity encoding is passed through unchanged rather than rejected; +/// the subprocess surfaces any real mismatch as an in-band protocol error. +/// +/// `max_decoded_bytes` bounds the *inflated* size: the router's +/// `RequestBodyLimitLayer` only caps compressed bytes, so without this a +/// small gzip bomb (ratios up to ~1000:1) could feed an effectively +/// unbounded stream to the subprocess — for receive-pack that means +/// unbounded scratch-disk writes. Exceeding the cap errors the stream, +/// which the stdin pumps surface as a logged early-EOF to git. +fn decode_git_request_body( + headers: &axum::http::HeaderMap, + body: Body, + max_decoded_bytes: u64, +) -> Body { + let is_gzip = headers + .get(header::CONTENT_ENCODING) + .and_then(|v| v.to_str().ok()) + .map(|v| v.eq_ignore_ascii_case("gzip") || v.eq_ignore_ascii_case("x-gzip")) + .unwrap_or(false); + if !is_gzip { + return body; + } + use futures_util::StreamExt; + use tokio_util::io::{ReaderStream, StreamReader}; + let byte_stream = body + .into_data_stream() + .map(|res| res.map_err(std::io::Error::other)) + .boxed(); + let decoder = + async_compression::tokio::bufread::GzipDecoder::new(StreamReader::new(byte_stream)); + let mut decoded: u64 = 0; + let capped = ReaderStream::new(decoder).map(move |chunk| { + let chunk = chunk?; + decoded = decoded.saturating_add(chunk.len() as u64); + if decoded > max_decoded_bytes { + return Err(std::io::Error::other(format!( + "gzip git request body exceeded {max_decoded_bytes} decoded bytes" + ))); + } + Ok(chunk) + }); + Body::from_stream(capped) +} + /// `POST /git/{owner}/{repo}/git-upload-pack` /// /// Handles clone/fetch — client sends wants/haves, server sends pack data. @@ -723,10 +786,12 @@ async fn info_refs_subprocess( pub async fn upload_pack( State(state): State>, auth: GitAuth, + headers: axum::http::HeaderMap, AxumPath(params): AxumPath, body: Body, ) -> Result { let _ = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES); let permit = acquire_git_permit(&state, "upload_pack")?; let repo = match hydrate_for_read( @@ -793,10 +858,12 @@ pub async fn upload_pack( pub async fn receive_pack( State(state): State>, auth: GitAuth, + headers: axum::http::HeaderMap, AxumPath(params): AxumPath, body: Body, ) -> Result { let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let body = decode_git_request_body(&headers, body, state.config.git_max_pack_bytes); let pusher_hex = hex::encode(auth.pubkey.to_bytes()); let _permit = acquire_git_permit(&state, "receive_pack")?; @@ -968,6 +1035,7 @@ async fn run_git_at( // Stream request body to git stdin. let mut stdin = child.stdin.take().unwrap(); + let pump_service = service.to_string(); let body_task = tokio::spawn(async move { use futures_util::StreamExt; let mut stream = body.into_data_stream(); @@ -981,7 +1049,14 @@ async fn run_git_at( break; } } - Err(_) => break, + Err(e) => { + // Body/decode errors (client abort, malformed gzip, + // decoded-size cap) surface to git as early EOF; log so + // there is a server-side signal, not just an opaque + // client-side hangup. + warn!(error = %e, service = %pump_service, "git request body stream failed"); + break; + } } } drop(stdin); // close stdin → EOF for git @@ -1378,7 +1453,14 @@ fn stream_git_read( break; } } - Err(_) => break, + Err(e) => { + // Body/decode errors (client abort, malformed gzip, + // decoded-size cap) surface to git as early EOF; log so + // there is a server-side signal, not just an opaque + // client-side hangup. + warn!(error = %e, service = %service, "git request body stream failed"); + break; + } } } drop(stdin); // close stdin → EOF for git @@ -1694,6 +1776,116 @@ mod track_c_tests { "cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string() } + /// A gzip-encoded request body is transparently inflated before it + /// reaches the git subprocess. Git's smart-HTTP client gzips the + /// upload-pack/receive-pack request body past a size threshold (fires + /// on many-ref clones); without this the subprocess sees raw gzip and + /// dies with `fatal: protocol error: bad line length character`. + #[tokio::test] + async fn gzip_request_body_is_inflated() { + use axum::http::HeaderMap; + use std::io::Write; + + let plaintext = b"0032want cb09a769da1c01f458fa6959d4e8eded38fac8d3\n0000"; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(plaintext).unwrap(); + let gzipped = encoder.finish().unwrap(); + assert_ne!( + gzipped, plaintext, + "precondition: body is actually compressed" + ); + + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap()); + let decoded = decode_git_request_body(&headers, Body::from(gzipped), u64::MAX); + let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap(); + assert_eq!(bytes.as_ref(), plaintext); + } + + /// Without a gzip `Content-Encoding`, the body is passed through byte + /// for byte (the common small-clone / already-inflated case). + #[tokio::test] + async fn identity_request_body_is_passthrough() { + use axum::http::HeaderMap; + let plaintext = b"0032want cb09a769da1c01f458fa6959d4e8eded38fac8d3\n0000"; + let decoded = + decode_git_request_body(&HeaderMap::new(), Body::from(plaintext.to_vec()), u64::MAX); + let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap(); + assert_eq!(bytes.as_ref(), plaintext); + } + + /// A gzip bomb is cut off at the decoded-byte cap: the router's + /// `RequestBodyLimitLayer` only bounds *compressed* bytes, so the + /// decode seam must enforce the inflated bound itself. Highly + /// compressible input (1 MiB of zeros → ~1 KiB gzip) must error once + /// the decoded stream crosses the cap. + #[tokio::test] + async fn gzip_request_body_over_decoded_cap_errors() { + use axum::http::HeaderMap; + use std::io::Write; + + let plaintext = vec![0u8; 1024 * 1024]; // inflates 1 MiB from ~1 KiB wire + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&plaintext).unwrap(); + let gzipped = encoder.finish().unwrap(); + assert!( + gzipped.len() < 64 * 1024, + "precondition: bomb is small on the wire (got {} bytes)", + gzipped.len() + ); + + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap()); + let cap: u64 = 64 * 1024; + let decoded = decode_git_request_body(&headers, Body::from(gzipped), cap); + let err = axum::body::to_bytes(decoded, usize::MAX) + .await + .expect_err("decoded stream must error past the cap"); + assert!( + err.to_string().contains("decoded bytes"), + "error should name the decoded-size cap, got: {err}" + ); + } + + /// The decoded cap does not truncate bodies at or under the limit — + /// exactly-at-cap input passes through complete. + #[tokio::test] + async fn gzip_request_body_at_decoded_cap_passes() { + use axum::http::HeaderMap; + use std::io::Write; + + let plaintext = vec![7u8; 8 * 1024]; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&plaintext).unwrap(); + let gzipped = encoder.finish().unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap()); + let decoded = + decode_git_request_body(&headers, Body::from(gzipped), plaintext.len() as u64); + let bytes = axum::body::to_bytes(decoded, usize::MAX).await.unwrap(); + assert_eq!(bytes.as_ref(), plaintext); + } + + /// Malformed gzip (valid header, corrupt deflate stream) surfaces as a + /// stream error rather than silently yielding garbage — the stdin pump + /// logs it and closes the subprocess's stdin early. + #[tokio::test] + async fn malformed_gzip_request_body_errors() { + use axum::http::HeaderMap; + + // Valid 10-byte gzip header followed by garbage. + let mut bogus = vec![0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff]; + bogus.extend_from_slice(b"this is not deflate data at all"); + + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap()); + let decoded = decode_git_request_body(&headers, Body::from(bogus), u64::MAX); + axum::body::to_bytes(decoded, usize::MAX) + .await + .expect_err("corrupt gzip must error the decoded stream"); + } + fn branches_only_manifest() -> Manifest { let mut refs = BTreeMap::new(); refs.insert("refs/heads/feature".to_string(), oid_sha1()); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index be9794922bb..22101219ebf 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1127,6 +1127,7 @@ async fn serve( let (shutdown_tx, _) = tokio::sync::watch::channel(false); let shutdown_flag = Arc::clone(&state.shutting_down); + let drain_conn_manager = Arc::clone(&state.conn_manager); let tx = shutdown_tx.clone(); tokio::spawn(async move { shutdown_signal().await; @@ -1136,6 +1137,16 @@ async fn serve( tokio::time::sleep(std::time::Duration::from_secs(5)).await; info!("Starting graceful drain (30s timeout)"); let _ = tx.send(true); + // Tell every connected client to reconnect NOW. Without this, upgraded + // WebSocket connections outlive the listener drain: clients ride the + // dying pod until the forced exit below and only learn about the + // restart from a TCP reset. The 1012 close frame turns a 35s silent + // death into an immediate, well-attributed reconnect. + let closed = drain_conn_manager.drain_all(); + info!( + connections = closed, + "Sent restart close frame to all live WebSocket connections" + ); // Hard timeout: force exit if connections don't drain within 30s. tokio::time::sleep(std::time::Duration::from_secs(30)).await; tracing::error!("Drain timeout exceeded — forcing exit"); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 2375a4f9c6d..2af036079ff 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -299,9 +299,20 @@ async fn nip11_or_ws_handler( let max_frame_bytes = state.config.max_frame_bytes; match WebSocketUpgrade::from_request(req, &state).await { - Ok(ws) => limit_relay_websocket(ws, max_frame_bytes) - .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) - .into_response(), + Ok(ws) => { + // Shutting down: refuse new sockets instead of accepting a + // connection onto a dying pod. Readiness already returns 503, but + // that only stops K8s routing — direct and in-flight upgrades + // still reach here during the pre-drain grace window. Clients + // treat the refusal as a normal dial failure and retry, landing + // on a healthy pod. + if state.shutting_down.load(Ordering::Relaxed) { + return (StatusCode::SERVICE_UNAVAILABLE, "relay restarting").into_response(); + } + limit_relay_websocket(ws, max_frame_bytes) + .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) + .into_response() + } Err(_) => { // Browser requesting HTML and Git web GUI is enabled → serve SPA. if state.config.serve_git_web_gui { diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 3a6ca49282b..758c001b966 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -181,6 +181,10 @@ where /// Tracks active Nostr WebSocket connections and provides message routing by connection ID. pub struct ConnectionManager { connections: DashMap, + /// Sticky drain flag set by [`Self::drain_all`]. Registrations that land + /// after the drain snapshot self-signal, so no upgrade-vs-shutdown + /// interleaving can produce a connection that misses the restart close. + draining: AtomicBool, } impl ConnectionManager { @@ -188,6 +192,7 @@ impl ConnectionManager { pub fn new() -> Self { Self { connections: DashMap::new(), + draining: AtomicBool::new(false), } } @@ -208,6 +213,8 @@ impl ConnectionManager { subscriptions: ConnectionSubscriptions, grace_limit: u8, ) { + let drain_ctrl_tx = ctrl_tx.clone(); + let drain_cancel = cancel.clone(); self.connections.insert( conn_id, ConnEntry { @@ -221,6 +228,14 @@ impl ConnectionManager { grace_limit, }, ); + // Insert-then-check pairs with drain_all's store-then-iterate: either + // the drain iteration sees this entry, or this check sees the flag. + // A registration that raced past the snapshot self-signals here, so + // no connection can outlive graceful shutdown unclosed. + if self.draining.load(Ordering::SeqCst) { + let _ = drain_ctrl_tx.try_send(Self::restart_close_frame()); + drain_cancel.cancel(); + } } /// Removes a connection from the registry. @@ -318,6 +333,45 @@ impl ConnectionManager { closed } + /// Closes every live connection with a `1012 Service Restart` close frame. + /// + /// Called when graceful shutdown starts draining. Without this, upgraded + /// WebSocket connections outlive the axum listener drain: clients ride the + /// dying pod until the forced exit and then learn about the restart from a + /// TCP reset (or, on an abrupt kill, from up to 60s of stall-watchdog + /// silence). The explicit close frame tells them to reconnect immediately + /// — and that the disconnect is a restart, not a policy action. + /// + /// Uses the "queue frame on ctrl, then cancel" idiom (see + /// [`ConnectionManager::disconnect_pubkey`]): the send loop drains queued + /// control frames — including this close — before its cancel branch closes + /// the socket. Best-effort: a full control buffer still gets the close via + /// cancel, just without the restart code. + /// + /// Returns the number of connections signalled. + pub fn drain_all(&self) -> usize { + // Store-then-iterate pairs with register's insert-then-check: a + // registration that misses this iteration observes the flag and + // self-signals instead. The flag is sticky — drain is one-way. + self.draining.store(true, Ordering::SeqCst); + let frame = Self::restart_close_frame(); + let mut closed = 0usize; + for entry in self.connections.iter() { + let _ = entry.ctrl_tx.try_send(frame.clone()); + entry.cancel.cancel(); + closed += 1; + } + closed + } + + /// The WS close frame announcing a graceful restart: 1012 Service Restart. + fn restart_close_frame() -> WsMessage { + WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::RESTART, + reason: axum::extract::ws::Utf8Bytes::from_static("relay restarting"), + })) + } + /// Return the server-resolved community that the connection's host bound to. pub fn community_for_conn(&self, conn_id: Uuid) -> Option { self.connections @@ -1737,4 +1791,142 @@ mod tests { "community-B session stays live — ban does not cross the tenant fence" ); } + + #[tokio::test] + async fn drain_all_sends_restart_close_and_cancels_every_conn() { + // Graceful shutdown must tell every live client to reconnect — across + // all communities — with a 1012 restart close frame queued ahead of + // the cancel-driven socket close. + let mgr = ConnectionManager::new(); + + let register = |community| { + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + cancel.clone(), + community, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + (ctrl_rx, cancel) + }; + + let (mut ctrl_a, cancel_a) = register(buzz_core::tenant::CommunityId::from_uuid( + Uuid::from_u128(0xa), + )); + let (mut ctrl_b, cancel_b) = register(buzz_core::tenant::CommunityId::from_uuid( + Uuid::from_u128(0xb), + )); + + let closed = mgr.drain_all(); + + assert_eq!(closed, 2, "every connection is signalled, no tenant fence"); + assert!(cancel_a.is_cancelled(), "community-A session is cancelled"); + assert!(cancel_b.is_cancelled(), "community-B session is cancelled"); + + for ctrl_rx in [&mut ctrl_a, &mut ctrl_b] { + let frame = ctrl_rx.try_recv().expect("close frame delivered"); + match frame { + WsMessage::Close(Some(close)) => { + assert_eq!( + close.code, + axum::extract::ws::close_code::RESTART, + "close code is 1012 Service Restart" + ); + assert_eq!(close.reason.as_str(), "relay restarting"); + } + other => panic!("expected a restart close frame, got {other:?}"), + } + } + } + + #[tokio::test] + async fn drain_all_full_control_buffer_still_cancels() { + // Best-effort delivery: a wedged control channel must not block the + // drain — the cancel still closes the socket, just without the frame. + let mgr = ConnectionManager::new(); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx.clone(), + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + // Wedge the 1-slot control channel. + ctrl_tx + .try_send(WsMessage::Text("wedge".into())) + .expect("fill control channel"); + + let closed = mgr.drain_all(); + + assert_eq!(closed, 1); + assert!( + cancel.is_cancelled(), + "cancel fires even when the close frame cannot be queued" + ); + // Only the wedge frame is present — the close was dropped, not queued. + assert!(matches!( + ctrl_rx.try_recv().expect("wedge frame"), + WsMessage::Text(_) + )); + assert!(ctrl_rx.try_recv().is_err(), "no second frame queued"); + } + + #[tokio::test] + async fn register_after_drain_self_signals_restart_close_and_cancel() { + // The shutdown-boundary race: an upgrade accepted before SIGTERM can + // finish its async admission check and register AFTER drain_all's + // one-shot snapshot. The sticky drain flag makes that interleaving + // deterministic — register itself queues the 1012 and cancels, so no + // late registration can ride out graceful shutdown unclosed. + let mgr = ConnectionManager::new(); + + // Drain with zero connections — sets the sticky flag. + assert_eq!(mgr.drain_all(), 0); + + // Late registration lands after the snapshot. + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + assert!( + cancel.is_cancelled(), + "late registration is cancelled by the sticky drain flag" + ); + match ctrl_rx.try_recv().expect("close frame delivered") { + WsMessage::Close(Some(close)) => { + assert_eq!( + close.code, + axum::extract::ws::close_code::RESTART, + "late registration still gets the 1012 restart close" + ); + assert_eq!(close.reason.as_str(), "relay restarting"); + } + other => panic!("expected a restart close frame, got {other:?}"), + } + } } diff --git a/desktop/package.json b/desktop/package.json index 6048b2a465c..1efebcf7a3c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.4.23", + "version": "0.4.24", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index be9d73ed0a3..5046b296885 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -71,6 +71,7 @@ export default defineConfig({ "**/home-collapsed-top-chrome.spec.ts", "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", + "**/workspace-rail.spec.ts", "**/community-rail.spec.ts", "**/boot-splash.spec.ts", "**/thread-reply-anchor-roleplay.spec.ts", @@ -137,6 +138,7 @@ export default defineConfig({ "**/persona-sync.spec.ts", "**/team-snapshot.spec.ts", "**/agents-everywhere.live.spec.ts", + "**/relay-restart.live.spec.ts", "**/parity-ancestor-island.spec.ts", ], use: { diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c57e0e982e4..a901d5010dd 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -178,6 +178,10 @@ const overrides = new Map([ // team-instructions-first-class: ManagedAgentRecord fixture gains the new // team_id field (+1 line). ["src-tauri/src/managed_agents/readiness.rs", 1765], + // Windows PATH-correctness fix: 3 #[cfg(windows)] test functions covering + // .cmd shim rejection, .bat shim rejection, and .exe acceptance for + // configure_runtime_cli (fix #2397). Test-only growth; queued to split. + ["src-tauri/src/managed_agents/runtime/tests.rs", 1041], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6af56ab4f65..e97e86c7d89 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -499,7 +499,11 @@ dependencies = [ "base64 0.22.1", "http", "log", + "rustls", + "serde", + "serde_json", "url", + "webpki-roots 1.0.8", ] [[package]] @@ -556,6 +560,23 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-creds" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3b85155d265df828f84e53886ed9e427aed979dd8a39f5b8b2162c77e142d7" +dependencies = [ + "attohttpc", + "home", + "log", + "quick-xml 0.38.4", + "rust-ini", + "serde", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -579,6 +600,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "aws-region" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838b36c8dc927b6db1b6c6b8f5d05865f2213550b9e83bf92fa99ed6525472c0" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "axum" version = "0.8.9" @@ -854,6 +884,12 @@ dependencies = [ "piper", ] +[[package]] +name = "blurhash" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" + [[package]] name = "bon" version = "3.9.3" @@ -972,7 +1008,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.4.23" +version = "0.4.24" dependencies = [ "anyhow", "arboard", @@ -982,6 +1018,7 @@ dependencies = [ "base64 0.22.1", "buzz-agent", "buzz-core", + "buzz-media", "buzz-persona", "buzz-sdk", "bytes", @@ -1051,6 +1088,36 @@ dependencies = [ "zip 8.6.0", ] +[[package]] +name = "buzz-media" +version = "0.1.0" +dependencies = [ + "axum", + "blurhash", + "buzz-core", + "bytes", + "chrono", + "futures-core", + "futures-util", + "hex", + "image", + "imagesize", + "infer", + "mp4", + "nostr", + "rust-s3", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "ulid", + "uuid", +] + [[package]] name = "buzz-persona" version = "0.1.0" @@ -1438,7 +1505,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1451,6 +1518,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if 1.0.4", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -1662,7 +1742,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.62.2", + "windows 0.61.3", ] [[package]] @@ -2066,7 +2146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -3010,8 +3090,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3588,6 +3668,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -3693,6 +3782,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.8", ] [[package]] @@ -3746,7 +3836,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry 0.5.3", ] [[package]] @@ -3761,7 +3851,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3957,6 +4047,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "1.9.3" @@ -4776,6 +4872,23 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "mdns-sd" version = "0.19.2" @@ -4815,7 +4928,7 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "hex", "mesh-llm-client", @@ -4825,7 +4938,7 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4836,12 +4949,12 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" [[package]] name = "mesh-llm-client" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "async-trait", @@ -4873,7 +4986,7 @@ dependencies = [ [[package]] name = "mesh-llm-config" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "dirs", @@ -4889,7 +5002,7 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4899,7 +5012,7 @@ dependencies = [ [[package]] name = "mesh-llm-events" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "clap", @@ -4911,7 +5024,7 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "cc", @@ -4924,7 +5037,7 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "serde", "serde_json", @@ -4933,7 +5046,7 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "mesh-llm-native-runtime", ] @@ -4941,7 +5054,7 @@ dependencies = [ [[package]] name = "mesh-llm-host-runtime" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "argon2", @@ -5034,7 +5147,7 @@ dependencies = [ [[package]] name = "mesh-llm-identity" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "argon2", "base64 0.22.1", @@ -5056,7 +5169,7 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "serde", @@ -5067,7 +5180,7 @@ dependencies = [ [[package]] name = "mesh-llm-node" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "mesh-llm-types", @@ -5081,7 +5194,7 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "async-trait", @@ -5098,7 +5211,7 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "dirs", @@ -5116,7 +5229,7 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "hex", @@ -5129,7 +5242,7 @@ dependencies = [ [[package]] name = "mesh-llm-routing" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "iroh", ] @@ -5137,7 +5250,7 @@ dependencies = [ [[package]] name = "mesh-llm-runtime-install" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "dirs", @@ -5160,7 +5273,7 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5175,7 +5288,7 @@ dependencies = [ [[package]] name = "mesh-llm-skills" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "dirs", @@ -5186,7 +5299,7 @@ dependencies = [ [[package]] name = "mesh-llm-system" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "chrono", @@ -5209,7 +5322,7 @@ dependencies = [ [[package]] name = "mesh-llm-types" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "hex", "serde", @@ -5220,12 +5333,12 @@ dependencies = [ [[package]] name = "mesh-llm-ui" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" [[package]] name = "mesh-mixture-of-agents" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5252,6 +5365,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "minidom" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e394a0e3c7ccc2daea3dffabe82f09857b6b510cb25af87d54bf3e910ac1642d" +dependencies = [ + "rxml", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5289,7 +5411,7 @@ dependencies = [ [[package]] name = "model-artifact" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "async-trait", @@ -5300,7 +5422,7 @@ dependencies = [ [[package]] name = "model-hf" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "async-trait", @@ -5318,7 +5440,7 @@ dependencies = [ [[package]] name = "model-package" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "bytes", @@ -5338,7 +5460,7 @@ dependencies = [ [[package]] name = "model-ref" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "serde", ] @@ -5346,7 +5468,7 @@ dependencies = [ [[package]] name = "model-resolver" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "model-artifact", @@ -5388,6 +5510,20 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "muda" version = "0.19.3" @@ -5948,6 +6084,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "serde", ] [[package]] @@ -5976,7 +6113,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.118", @@ -6351,7 +6488,7 @@ dependencies = [ [[package]] name = "openai-frontend" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "async-trait", "axum", @@ -6941,7 +7078,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -7244,7 +7381,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools", "log", "multimap", @@ -7355,6 +7492,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -7569,7 +7716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ "bitflags 2.13.0", - "compact_str", + "compact_str 0.9.1", "critical-section", "hashbrown 0.17.1", "itertools", @@ -7772,6 +7919,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -7779,6 +7928,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -7788,6 +7938,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots 1.0.8", ] [[package]] @@ -8021,6 +8172,41 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust-s3" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeedb13abdaa7e48d391de05b0569b37fa0a7a64a668dff6ffb2141ad0c2527e" +dependencies = [ + "async-trait", + "aws-creds", + "aws-region", + "base64 0.22.1", + "bytes", + "cfg-if 1.0.4", + "futures-util", + "hex", + "hmac 0.12.1", + "http", + "log", + "maybe-async", + "md5", + "minidom", + "percent-encoding", + "quick-xml 0.38.4", + "reqwest 0.12.28", + "serde", + "serde_derive", + "serde_json", + "sha2 0.10.9", + "sysinfo 0.37.2", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "url", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -8159,6 +8345,25 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rxml" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc94b580d0f5a6b7a2d604e597513d3c673154b52ddeccd1d5c32360d945ee" +dependencies = [ + "bytes", + "rxml_validation", +] + +[[package]] +name = "rxml_validation" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e80413b9a35e9d33217b3dcac04cf95f6559d15944b93887a08be5496c4a4" +dependencies = [ + "compact_str 0.7.1", +] + [[package]] name = "ryu" version = "1.0.23" @@ -8806,7 +9011,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "blake3", @@ -8816,7 +9021,7 @@ dependencies = [ [[package]] name = "skippy-coordinator" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "thiserror 2.0.18", ] @@ -8824,7 +9029,7 @@ dependencies = [ [[package]] name = "skippy-ffi" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "libloading 0.8.9", ] @@ -8832,12 +9037,12 @@ dependencies = [ [[package]] name = "skippy-metrics" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" [[package]] name = "skippy-protocol" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "prost", "prost-build", @@ -8848,7 +9053,7 @@ dependencies = [ [[package]] name = "skippy-runtime" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "libc", @@ -8862,7 +9067,7 @@ dependencies = [ [[package]] name = "skippy-server" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "anyhow", "async-trait", @@ -8890,7 +9095,7 @@ dependencies = [ [[package]] name = "skippy-topology" version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=c441ea7f328692b18b7f49ad819c7b1a603cbdcb#c441ea7f328692b18b7f49ad819c7b1a603cbdcb" dependencies = [ "serde", "serde_json", @@ -9332,6 +9537,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "sysinfo" version = "0.38.4" @@ -9903,7 +10122,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -10720,6 +10939,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", +] + [[package]] name = "unarray" version = "0.1.4" @@ -11448,7 +11677,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -12059,8 +12288,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.62.2", - "windows-core 0.62.2", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] @@ -12322,7 +12551,7 @@ dependencies = [ "serde", "serde_json", "shellexpand", - "sysinfo", + "sysinfo 0.38.4", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index dcb432b7362..baae6708630 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.4.23" +version = "0.4.24" description = "Buzz desktop app" authors = ["you"] edition = "2021" @@ -90,14 +90,14 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "c441ea7f328692b18b7f49ad819c7b1a603cbdcb", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" @@ -126,3 +126,6 @@ strip-ansi-escapes = "0.2" # `test-util` enables tokio's paused-clock (`start_paused`) so the relay # admission gate tests can assert exact wait durations without real sleeps. tokio = { version = "1", features = ["test-util"] } +# The relay's media validation, so the snapshot-sharing tests can prove the +# full export → sanitize → relay-accept → import contract end to end. +buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index dfad9c2fece..0fb3747718a 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -15,6 +15,7 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { @@ -89,6 +90,13 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT=1"); } + // Presence-only release capability: internal desktop builds opt into + // auto-connecting their configured default relay on first run. OSS builds + // leave this unset and retain explicit community selection. + if std::env::var("BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1"); + } + let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY") .ok() .map(|value| value.trim().to_string()) diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index c23263de7e9..bba0f338602 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -194,6 +194,7 @@ fn run_buzz_acp_auth_command_with_paths( if let Some(path) = augmented_path { command.env("PATH", path); } + crate::util::configure_no_window(&mut command); command .output() @@ -226,13 +227,16 @@ fn run_claude_subscription_login(runtime_id: &str, method: &AcpAuthMethod) -> Re let (command, args) = argv .split_first() .ok_or_else(|| "Claude login command is empty".to_string())?; - let status = Command::new(command) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map_err(|error| format!("failed to run Claude login: {error}"))?; + let status = { + let mut cmd = Command::new(command); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::util::configure_no_window(&mut cmd); + cmd.status() + .map_err(|error| format!("failed to run Claude login: {error}"))? + }; if !status.success() { return Err(format!( "Claude login failed (exit {})", diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index acc8f30ad37..40c4333a118 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -568,16 +568,32 @@ fn install_shell_command(command: &str) -> Result cmd.env("npm_config_cache", prefix.join("cache")); } - let mut path_parts = Vec::new(); - if let Some(managed_node_bin) = crate::managed_agents::buzz_managed_node_bin_dir() { - path_parts.push(managed_node_bin); - } - if let Some(managed_bin) = crate::managed_agents::buzz_managed_npm_bin_dir() { - path_parts.push(managed_bin); - } - if let Some(ref path) = crate::managed_agents::login_shell_path() { - path_parts.extend(std::env::split_paths(path)); - } + // Compose the PATH for the install shell using the same kernel as the + // runtime/probe path so the two can never drift. managed entries first + // (Node/npm bins keep precedence); login-shell entries next; inherited + // process PATH appended last on Windows when no login-shell PATH exists + // (login_shell_path() always returns None on Windows — Git Bash paths are + // POSIX-shaped and poison native children; cmd.env("PATH", …) replaces + // rather than extends, so without inherited the install shell loses npm). + let login_path = crate::managed_agents::login_shell_path(); + let had_login = login_path.is_some(); + let managed: Vec = [ + crate::managed_agents::buzz_managed_node_bin_dir(), + crate::managed_agents::buzz_managed_npm_bin_dir(), + ] + .into_iter() + .flatten() + .collect(); + let login: Vec = login_path + .as_deref() + .map(|p| std::env::split_paths(p).collect()) + .unwrap_or_default(); + let inherited: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + let use_inherited = crate::managed_agents::should_use_inherited(had_login, true, cfg!(windows)); + let path_parts = + crate::managed_agents::compose_path_entries(managed, login, inherited, use_inherited); if !path_parts.is_empty() { if let Ok(path) = std::env::join_paths(path_parts) { cmd.env("PATH", path); @@ -1296,6 +1312,45 @@ mod tests { ); } + /// On Windows, `install_shell_command` must set PATH to a value that + /// includes the inherited process PATH, so node/npm are visible inside + /// the install shell even when no managed Node runtime is present. + #[cfg(windows)] + #[test] + fn test_install_shell_command_includes_process_path_on_windows() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + // Plant a sentinel in the process PATH that the test can detect. + let sentinel = r"C:\TestSentinel\bin"; + std::env::set_var("PATH", sentinel); + + let result = super::install_shell_command("echo test"); + + match previous { + Some(p) => std::env::set_var("PATH", p), + None => std::env::remove_var("PATH"), + } + + let cmd = result.expect("install_shell_command must succeed on Windows with Git"); + let path_value = cmd + .get_envs() + .find(|(key, _)| *key == "PATH") + .and_then(|(_, val)| val) + .map(|v| v.to_string_lossy().into_owned()) + .expect("install_shell_command must always set a PATH env var on Windows"); + + // The sentinel (inherited process PATH) must appear in the composed PATH. + assert!( + path_value.contains(sentinel), + "install_shell_command PATH must include the inherited process PATH; got: {path_value}" + ); + // The sentinel must appear LAST — managed Buzz dirs must have precedence. + assert!( + path_value.ends_with(sentinel), + "inherited process PATH must be appended LAST so managed dirs keep precedence; got: {path_value}" + ); + } + // ── Phase B: per-OS install commands ────────────────────────────────────── /// On non-Windows, cli_install_commands_for_os returns the default commands. diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index fca5a602b06..c0e1b6feebe 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -93,12 +93,13 @@ fn managed_node_runtime_ready() -> bool { if !node.is_file() { return false; } - let output = std::process::Command::new(&node) - .arg("--version") + let mut cmd = std::process::Command::new(&node); + cmd.arg("--version") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .output(); + .stderr(std::process::Stdio::null()); + crate::util::configure_no_window(&mut cmd); + let output = cmd.output(); output .ok() .filter(|output| output.status.success()) diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 2093d4fe293..ea9a771dea5 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -49,6 +49,7 @@ pub(super) async fn run_agent_models_command( cmd.env(k, v); } crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command)); + crate::util::configure_no_window(&mut cmd); cmd.stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .output() diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 387dea69c24..33783c05a57 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -54,6 +54,27 @@ pub fn get_default_relay_url() -> String { relay::relay_ws_url() } +#[tauri::command] +pub fn auto_connect_default_relay_enabled() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_AUTO_CONNECT_DEFAULT_RELAY").is_some() +} + +#[cfg(test)] +mod auto_connect_default_relay_tests { + use super::auto_connect_default_relay_enabled; + + #[test] + #[ignore] + fn compiled_flag_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY") + .expect("compiled-flag test requires an expected value"); + assert_eq!( + auto_connect_default_relay_enabled(), + expected == "true" || expected == "1" + ); + } +} + #[tauri::command] pub fn is_shared_identity() -> bool { std::env::var("BUZZ_SHARE_IDENTITY") diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 1ecf77192ae..bf8692ff700 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -264,6 +264,16 @@ pub(crate) fn sanitize_image_for_upload(body: Vec, mime: &str) -> Result, mime: &str) -> Result super::media_snapshot_png::inject_snapshot_text_chunk(sanitized, &chunk), + None => Ok(sanitized), + } } pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs new file mode 100644 index 00000000000..f2593ff9e0f --- /dev/null +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -0,0 +1,226 @@ +//! Preservation of Buzz snapshot tEXt chunks through the upload sanitizer. +//! +//! Split out of `media.rs` to keep that file under the desktop line-size +//! limit. Agent/team sharing embeds a manifest in a PNG `tEXt` chunk +//! (`buzz_agent_snapshot` / `buzz_team_snapshot`); the sanitizer's re-encode +//! would destroy it and the relay would previously reject it. These helpers +//! extract the chunk before the re-encode and re-inject it afterwards. The +//! relay allowlists exactly these keywords in `buzz-media::validation` — the +//! two lists must stay in sync. + +/// tEXt keywords that carry Buzz snapshot manifests (`.agent.png` / +/// `.team.png`). These chunks are the product payload of agent/team sharing — +/// they must survive the metadata strip. +const PNG_SNAPSHOT_KEYWORDS: [&[u8]; 2] = [b"buzz_agent_snapshot", b"buzz_team_snapshot"]; + +/// Extract the raw bytes of the first Buzz snapshot tEXt chunk (length + type +/// + payload + CRC) from a PNG, or `None` when absent/not a PNG. +/// +/// Walks the chunk structure directly instead of decoding the image so a +/// malformed file simply yields `None` and falls through to the normal +/// sanitize path. +pub(crate) fn extract_snapshot_text_chunk(bytes: &[u8]) -> Option> { + const SIG: &[u8] = b"\x89PNG\r\n\x1a\n"; + if !bytes.starts_with(SIG) { + return None; + } + let mut i = SIG.len(); + while i + 12 <= bytes.len() { + let len = u32::from_be_bytes(bytes[i..i + 4].try_into().ok()?) as usize; + let end = i.checked_add(12)?.checked_add(len)?; + if end > bytes.len() { + return None; + } + let kind = &bytes[i + 4..i + 8]; + if kind == b"tEXt" { + let payload = &bytes[i + 8..i + 8 + len]; + let is_snapshot = PNG_SNAPSHOT_KEYWORDS.iter().any(|keyword| { + payload.len() > keyword.len() + && &payload[..keyword.len()] == *keyword + && payload[keyword.len()] == 0 + }); + if is_snapshot { + return Some(bytes[i..end].to_vec()); + } + } + if kind == b"IEND" { + return None; + } + i = end; + } + None +} + +/// Re-insert a raw snapshot tEXt chunk into a sanitized PNG, immediately +/// after the IHDR chunk. Placement matters: the `png` crate decoder used by +/// the import path only exposes text chunks encountered before IDAT via +/// `read_info()`. The chunk bytes carry their own CRC, which remains valid +/// because the chunk content is unchanged. +pub(crate) fn inject_snapshot_text_chunk(png: Vec, chunk: &[u8]) -> Result, String> { + const SIG_LEN: usize = 8; + // IHDR is always the first chunk of a well-formed PNG. + if png.len() < SIG_LEN + 12 || &png[SIG_LEN + 4..SIG_LEN + 8] != b"IHDR" { + return Err("sanitized PNG is missing IHDR chunk".to_string()); + } + let ihdr_len = u32::from_be_bytes( + png[SIG_LEN..SIG_LEN + 4] + .try_into() + .map_err(|_| "sanitized PNG has malformed IHDR length".to_string())?, + ) as usize; + let ihdr_end = SIG_LEN + .checked_add(12) + .and_then(|v| v.checked_add(ihdr_len)) + .filter(|&v| v <= png.len()) + .ok_or_else(|| "sanitized PNG has malformed IHDR chunk".to_string())?; + let mut out = Vec::with_capacity(png.len() + chunk.len()); + out.extend_from_slice(&png[..ihdr_end]); + out.extend_from_slice(chunk); + out.extend_from_slice(&png[ihdr_end..]); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::super::media::sanitize_image_for_upload; + + #[test] + fn test_sanitizer_preserves_agent_snapshot_text_chunk() { + // Build a real 2×2 PNG carrying an agent-snapshot manifest chunk plus + // a mundane metadata chunk that must NOT survive. + for keyword in ["buzz_agent_snapshot", "buzz_team_snapshot"] { + let manifest = "eyJmb3JtYXQiOiJidXp6LWFnZW50LXNuYXBzaG90In0="; + let mut source = Vec::new(); + { + let mut enc = png::Encoder::new(std::io::Cursor::new(&mut source), 2, 2); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + enc.add_text_chunk(keyword.to_string(), manifest.to_string()) + .unwrap(); + enc.add_text_chunk("Comment".to_string(), "GPS=37.7,-122.4".to_string()) + .unwrap(); + let mut writer = enc.write_header().unwrap(); + writer.write_image_data(&[0u8; 16]).unwrap(); + } + + let sanitized = sanitize_image_for_upload(source, "image/png").unwrap(); + + // The snapshot manifest survives, readable by the same decoder the + // import path uses. + let decoder = png::Decoder::new(std::io::Cursor::new(&sanitized)); + let reader = decoder.read_info().unwrap(); + let texts = &reader.info().uncompressed_latin1_text; + let snapshot = texts + .iter() + .find(|c| c.keyword == keyword) + .unwrap_or_else(|| panic!("sanitized PNG lost the {keyword} tEXt chunk")); + assert_eq!(snapshot.text, manifest); + + // The mundane metadata chunk is stripped. + assert!( + !texts.iter().any(|c| c.keyword == "Comment"), + "sanitizer kept a non-snapshot tEXt chunk" + ); + } + } + + #[test] + fn test_sanitizer_still_strips_all_text_from_regular_pngs() { + let mut source = Vec::new(); + { + let mut enc = png::Encoder::new(std::io::Cursor::new(&mut source), 2, 2); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + enc.add_text_chunk("Comment".to_string(), "GPS=37.7,-122.4".to_string()) + .unwrap(); + let mut writer = enc.write_header().unwrap(); + writer.write_image_data(&[0u8; 16]).unwrap(); + } + + let sanitized = sanitize_image_for_upload(source, "image/png").unwrap(); + let decoder = png::Decoder::new(std::io::Cursor::new(&sanitized)); + let reader = decoder.read_info().unwrap(); + assert!(reader.info().uncompressed_latin1_text.is_empty()); + } + + #[test] + fn test_agent_snapshot_survives_full_share_pipeline() { + // Cross-contract regression for the production failure: a real + // encoded .agent.png must survive export → client sanitize → + // relay validation → import decode. Each seam is also covered by + // unit tests, but this proves the exact pipeline that broke. + use crate::managed_agents::agent_snapshot::{ + decode_snapshot_png, encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, + AgentSnapshotMemory, AgentSnapshotProfile, MemoryLevel, + }; + + let snapshot = AgentSnapshot { + format: "buzz-agent-snapshot".to_string(), + version: 1, + definition: AgentSnapshotDefinition { + name: "Tree Trunks".to_string(), + system_prompt: Some("You are a helpful agent.".to_string()), + runtime: Some("goose".to_string()), + model: None, + provider: None, + parallelism: Some(1), + respond_to: None, + respond_to_allowlist: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + name_pool: vec![], + }, + profile: AgentSnapshotProfile { + display_name: "Tree Trunks".to_string(), + about: Some("Shared agent".to_string()), + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: MemoryLevel::None, + entries: vec![], + }, + }; + + // Export with a real avatar body (what the share flow uploads). + let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 4, + image::Rgba([200, 120, 40, 255]), + )); + let mut avatar_png = std::io::Cursor::new(Vec::new()); + avatar + .write_to(&mut avatar_png, image::ImageFormat::Png) + .unwrap(); + let exported = encode_snapshot_png(&snapshot, Some(avatar_png.get_ref())).unwrap(); + + // Client upload path. + let sanitized = sanitize_image_for_upload(exported, "image/png").unwrap(); + + // Relay ingest path. + let relay_config = buzz_media_pkg::MediaConfig { + s3_endpoint: String::new(), + s3_access_key: String::new(), + s3_secret_key: String::new(), + s3_bucket: String::new(), + s3_region: "us-east-1".to_string(), + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 524_288_000, + max_file_bytes: 104_857_600, + public_base_url: String::new(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + }; + assert_eq!( + buzz_media_pkg::validation::validate_content(&sanitized, &relay_config) + .expect("relay rejected a sanitized agent snapshot PNG"), + "image/png" + ); + + // Recipient import path. + let imported = decode_snapshot_png(&sanitized) + .expect("import failed on a PNG that passed sanitize + relay validation"); + assert_eq!(imported, snapshot); + } +} diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 48f14b5aafc..46a5decaa70 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -24,6 +24,7 @@ fn ffmpeg_command(path: &std::path::Path) -> std::process::Command { for (name, value) in required_windows_env { command.env(name, value); } + crate::util::configure_no_window(&mut command); command } diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index ad55d950601..9a0a3c32eb8 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -45,6 +45,18 @@ fn load_mesh_sharing_config(app: &AppHandle) -> Result const RELAY_MESH_RUNTIME_NO_TARGET: &str = "Buzz shared compute requires a live serving member; start serving the selected model on a member, then try again"; +/// Whether the Share-compute "stop sharing" path (`mesh_stop_node`) should tear +/// down the runtime currently occupying the single slot. +/// +/// Serve nodes (this machine SHARING compute) are torn down. Client nodes (this +/// machine CONSUMING a peer's compute) share the same slot and MUST be left +/// running — stopping "Share compute" must never kill a consume session the +/// user didn't start from this switch. +#[cfg(feature = "mesh-llm")] +fn share_stop_should_teardown(mode: mesh_llm::MeshNodeMode) -> bool { + matches!(mode, mesh_llm::MeshNodeMode::Serve) +} + pub type CmdResult = Result; fn advance_mesh_status_cursor( @@ -197,19 +209,110 @@ pub async fn mesh_start_node( /// Mesh can bind its HTTP ingress and advertise a model shortly before the /// router has installed a usable target. Probe the exact chat path agents use /// so startup cannot race that gap (`single target None unavailable`). +/// Which startup stage a mesh client is stuck at when it never becomes +/// inference-ready. The two live-observed failure modes are physically +/// distinct and want different user copy: +/// +/// * `CatalogNeverSynced` — the local client node came up and connected to +/// the host at the control level (ping/RTT fine), but the served model +/// never appeared in the local `/v1/models` catalog. That catalog is +/// populated by the peer gossip exchange; when the gossip bi-stream can't +/// establish across the network (observed as iroh +/// `MultipathNotNegotiated` / unreachable direct path), the catalog stays +/// empty forever and every request is rejected "model not available". +/// Root cause is the network path between this machine and the host. +/// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but +/// inference requests never completed (routing/transport to the host +/// failing per-request). The host is discoverable and advertised but not +/// actually serving us. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeshReadinessFailure { + CatalogNeverSynced, + RoutingNeverCompleted, +} + +/// Pure classifier: given whether the served model was ever observed in the +/// local `/v1/models` catalog during the wait, decide which stage failed. +/// Split out so the diagnosis is unit-testable without a live mesh. +fn classify_mesh_readiness_failure(model_ever_visible: bool) -> MeshReadinessFailure { + if model_ever_visible { + MeshReadinessFailure::RoutingNeverCompleted + } else { + MeshReadinessFailure::CatalogNeverSynced + } +} + +/// Actionable, non-technical copy for a readiness failure. `last_detail` is the +/// last raw transport/HTTP error, appended for support triage. +fn mesh_readiness_failure_message( + failure: MeshReadinessFailure, + model_id: &str, + last_detail: &str, +) -> String { + match failure { + MeshReadinessFailure::CatalogNeverSynced => format!( + "Buzz shared compute connected to the serving member but could not sync \ + the model list for \"{model_id}\" — this is a network path problem \ + between this machine and the host (the compute node is reachable for \ + pings but the model-sync stream did not establish). Try again, or have \ + the host and this machine on a more direct network. (last: {last_detail})" + ), + MeshReadinessFailure::RoutingNeverCompleted => format!( + "Buzz shared compute found \"{model_id}\" on a serving member but inference \ + requests did not complete — the host is discoverable but not currently \ + reachable for requests. Try again shortly. (last: {last_detail})" + ), + } +} + +/// Poll the local mesh OpenAI ingress until a real inference for `model_id` +/// succeeds, or a deadline elapses. On failure, returns a stage-specific, +/// actionable message (see [`MeshReadinessFailure`]) rather than a raw +/// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach +/// the host". async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|error| format!("failed to build mesh readiness client: {error}"))?; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); + let models_url = format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL); + let chat_url = format!( + "{}/chat/completions", + crate::managed_agents::RELAY_MESH_API_BASE_URL + ); let mut last_error = "mesh inference is not ready".to_string(); + // Track whether the served model ever reached the local catalog — the + // signal that splits "catalog never synced" from "routing never completed". + let mut model_ever_visible = false; + while tokio::time::Instant::now() < deadline { + // Refresh catalog visibility. "auto" delegates model choice to the + // router, so any advertised model counts as the catalog having synced. + if let Ok(response) = client + .get(&models_url) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .send() + .await + { + if let Ok(body) = response.json::().await { + if let Some(data) = body.get("data").and_then(|d| d.as_array()) { + let wanted = model_id.trim().replace("@main", ""); + let visible = !data.is_empty() + && (model_id == crate::mesh_llm::AUTO_MODEL_ID + || data.iter().any(|m| { + m.get("id") + .and_then(|id| id.as_str()) + .map(|id| id.replace("@main", "") == wanted) + .unwrap_or(false) + })); + model_ever_visible |= visible; + } + } + } + match client - .post(format!( - "{}/chat/completions", - crate::managed_agents::RELAY_MESH_API_BASE_URL - )) + .post(&chat_url) .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) .json(&serde_json::json!({ "model": model_id, @@ -230,8 +333,12 @@ async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { } tokio::time::sleep(std::time::Duration::from_secs(2)).await; } - Err(format!( - "Buzz shared compute did not become inference-ready for {model_id}: {last_error}" + + let failure = classify_mesh_readiness_failure(model_ever_visible); + Err(mesh_readiness_failure_message( + failure, + model_id, + &last_error, )) } @@ -411,8 +518,22 @@ pub async fn mesh_stop_node( app: AppHandle, state: State<'_, AppState>, ) -> CmdResult { - let runtime = state.mesh_llm_runtime.lock().await.take(); - if let Some(runtime) = runtime { + // The single runtime slot is shared by serve (this machine SHARING + // compute) and client (this machine CONSUMING a peer's compute) roles. + // Stopping "Share compute" must NEVER tear down a client node: inspect the + // role under the lock and, when it's a consume session, leave it running + // and return its live status unchanged. The frontend also guards this, but + // status can be stale between polls, so the backend is authoritative. + let taken = { + let mut guard = state.mesh_llm_runtime.lock().await; + if let Some(runtime) = guard.as_ref() { + if !share_stop_should_teardown(runtime.mode()) { + return runtime.status().await.map_err(|error| error.to_string()); + } + } + guard.take() + }; + if let Some(runtime) = taken { runtime.stop().await.map_err(|error| error.to_string())?; } save_mesh_sharing_config( @@ -436,6 +557,20 @@ pub async fn mesh_node_status(state: State<'_, AppState>) -> CmdResult, +) -> CmdResult { + let runtime = state.mesh_llm_runtime.lock().await; + match runtime.as_ref() { + Some(runtime) => runtime.serving_usage().await.map_err(|e| e.to_string()), + None => Ok(mesh_llm::MeshServingUsage::default()), + } +} + #[tauri::command] pub async fn mesh_installed_models( state: State<'_, AppState>, @@ -479,6 +614,42 @@ mod tests { } } + #[test] + fn readiness_failure_is_catalog_sync_when_model_never_visible() { + assert_eq!( + classify_mesh_readiness_failure(false), + MeshReadinessFailure::CatalogNeverSynced + ); + } + + #[test] + fn readiness_failure_is_routing_when_model_was_visible() { + assert_eq!( + classify_mesh_readiness_failure(true), + MeshReadinessFailure::RoutingNeverCompleted + ); + } + + #[test] + fn readiness_messages_are_distinct_and_actionable() { + let catalog = mesh_readiness_failure_message( + MeshReadinessFailure::CatalogNeverSynced, + "auto", + "HTTP 429", + ); + let routing = mesh_readiness_failure_message( + MeshReadinessFailure::RoutingNeverCompleted, + "auto", + "HTTP 503", + ); + // Distinct diagnoses, each names the model and carries the raw detail. + assert_ne!(catalog, routing); + assert!(catalog.contains("network path")); + assert!(catalog.contains("HTTP 429")); + assert!(routing.contains("did not complete")); + assert!(routing.contains("HTTP 503")); + } + #[test] fn mesh_status_cursor_uses_relay_composite_tiebreak() { let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status") @@ -546,6 +717,52 @@ mod tests { assert_eq!(pick_serve_target_for_model(targets, "model-missing"), None); } + #[test] + fn share_stop_tears_down_serve_but_not_client() { + // Stopping "Share compute" tears down a serve node (we were sharing) + // but must leave a client node alone (we are consuming a peer). This is + // the backend half of the toggle-on regression: a client node occupies + // the single slot and reports state:"running", and the stop path must + // not kill it. + assert!( + share_stop_should_teardown(mesh_llm::MeshNodeMode::Serve), + "serve node is our sharing runtime; stop must tear it down" + ); + assert!( + !share_stop_should_teardown(mesh_llm::MeshNodeMode::Client), + "client node is a consume session; stop must NOT tear it down" + ); + } + + #[test] + fn client_status_serializes_with_running_state_and_client_mode() { + // Contract pin for the TS mock (e2eBridge.ts) and the frontend + // predicate: a consuming node serializes as + // {"state":"running","mode":"client"}. If serde renaming drifts, the + // hand-written mock shape and `deriveMeshShareToggle` would silently + // stop matching the real IPC payload. + let status = mesh_llm::MeshNodeStatus { + state: mesh_llm::MeshNodeState::Running, + mode: Some(mesh_llm::MeshNodeMode::Client), + // `MeshHealth::ok()` is module-private; build via the public fields. + health: mesh_llm::MeshHealth { + status: mesh_llm::MeshHealthStatus::Ok, + reason: None, + }, + api_base_url: Some("http://127.0.0.1:9337/v1".to_string()), + console_url: None, + model_id: None, + model_name: None, + invite_token: None, + endpoint_id: None, + device_id: None, + device_name: None, + }; + let value = serde_json::to_value(&status).expect("serialize mesh status"); + assert_eq!(value["state"], serde_json::json!("running")); + assert_eq!(value["mode"], serde_json::json!("client")); + } + #[tokio::test] async fn cold_client_preflight_requires_explicit_target() { let state = build_app_state(); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 77080e930dc..7e9d916be6f 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -26,6 +26,7 @@ pub(crate) mod media; mod media_animated; mod media_download; mod media_gif; +mod media_snapshot_png; mod media_transcode; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ab36b45c5c0..ef67fac5709 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -7,9 +7,13 @@ use tauri::State; use crate::{ app_state::AppState, events, + managed_agents::persona_events::monotonic_created_at, models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse}, nostr_convert, - relay::{query_relay, submit_event}, + relay::{ + query_relay, query_relay_at_with_keys, relay_http_base_url, submit_event, + submit_event_at_with_keys, + }, }; #[tauri::command] @@ -94,6 +98,85 @@ pub async fn update_profile( .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state)))) } +#[tauri::command] +pub async fn update_profile_at_relay( + relay_url: String, + expected_pubkey: String, + expected_avatar_url: Option, + avatar_url: String, + state: State<'_, AppState>, +) -> Result { + let signer = capture_expected_signer(&state, &expected_pubkey)?; + + let api_base_url = relay_http_base_url(&relay_url); + let filter = serde_json::json!({ + "kinds": [0], + "authors": [expected_pubkey], + "limit": 1 + }); + let prior_events = query_relay_at_with_keys( + &state, + &api_base_url, + std::slice::from_ref(&filter), + &signer, + None, + ) + .await?; + let prior_event = prior_events.first(); + let current: Value = prior_event + .and_then(|event| serde_json::from_str::(&event.content).ok()) + .unwrap_or(Value::Null); + let current_avatar_url = current + .get("picture") + .and_then(Value::as_str) + .map(str::to_string); + if normalized_avatar_url(current_avatar_url.as_deref()) + != normalized_avatar_url(expected_avatar_url.as_deref()) + { + return Err("profile avatar changed before deferred save".to_string()); + } + + let builder = build_deferred_profile_event(¤t, &avatar_url, prior_event)?; + submit_event_at_with_keys(builder, &state, &api_base_url, &signer).await?; + + let events = query_relay_at_with_keys(&state, &api_base_url, &[filter], &signer, None).await?; + Ok(events + .first() + .map(nostr_convert::profile_info_from_event) + .transpose()? + .unwrap_or_else(|| empty_profile_info(&expected_pubkey))) +} + +fn build_deferred_profile_event( + current: &Value, + avatar_url: &str, + prior_event: Option<&nostr::Event>, +) -> Result { + let display_name = current.get("display_name").and_then(Value::as_str); + let name = current.get("name").and_then(Value::as_str); + let about = current.get("about").and_then(Value::as_str); + let nip05 = current.get("nip05").and_then(Value::as_str); + + Ok( + events::build_profile(display_name, name, Some(avatar_url), about, nip05)? + .custom_created_at(monotonic_created_at( + prior_event.map(|event| event.created_at.as_secs() as i64), + )), + ) +} + +fn capture_expected_signer(state: &AppState, expected_pubkey: &str) -> Result { + let signer = state.signing_keys()?; + if signer.public_key().to_hex() != expected_pubkey { + return Err("profile identity changed before avatar save".to_string()); + } + Ok(signer) +} + +fn normalized_avatar_url(avatar_url: Option<&str>) -> Option<&str> { + avatar_url.map(str::trim).filter(|value| !value.is_empty()) +} + #[tauri::command] pub async fn get_user_profile( pubkey: Option, @@ -335,6 +418,56 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { mod tests { use super::*; + #[test] + fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() { + let state = crate::app_state::build_app_state(); + let original = state.signing_keys().expect("signable identity"); + let original_pubkey = original.public_key().to_hex(); + + let captured = capture_expected_signer(&state, &original_pubkey) + .expect("matching identity should be captured"); + *state.keys.lock().expect("lock keys") = nostr::Keys::generate(); + + assert_eq!(captured.public_key().to_hex(), original_pubkey); + assert_ne!( + state.keys.lock().expect("lock keys").public_key().to_hex(), + original_pubkey + ); + assert_eq!( + capture_expected_signer(&state, &original_pubkey).unwrap_err(), + "profile identity changed before avatar save" + ); + } + + #[test] + fn deferred_profile_event_is_strictly_newer_than_prior_head() { + let keys = nostr::Keys::generate(); + let prior_created_at = nostr::Timestamp::now().as_secs() + 60; + let prior_event = nostr::EventBuilder::new( + nostr::Kind::Metadata, + serde_json::json!({"display_name": "Larry"}).to_string(), + ) + .custom_created_at(nostr::Timestamp::from(prior_created_at)) + .sign_with_keys(&keys) + .expect("sign prior profile"); + + let builder = build_deferred_profile_event( + &serde_json::json!({"display_name": "Larry"}), + "https://example.com/avatar.png", + Some(&prior_event), + ) + .expect("build deferred profile"); + let event = builder + .sign_with_keys(&keys) + .expect("sign deferred profile"); + + assert_eq!(event.created_at.as_secs(), prior_created_at + 1); + assert_eq!( + serde_json::from_str::(&event.content).unwrap()["picture"], + "https://example.com/avatar.png" + ); + } + #[test] fn user_search_filter_requests_prefix_mode_for_typeahead() { // Every caller of `search_users` is a typeahead surface. Whole-word diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index 3f16c0fba9e..186c1a1cf6c 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -80,6 +80,7 @@ pub(crate) fn run_git( command.stdin(Stdio::null()); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); + crate::util::configure_no_window(&mut command); let mut child = command .spawn() diff --git a/desktop/src-tauri/src/commands/relay_reconnect.rs b/desktop/src-tauri/src/commands/relay_reconnect.rs index f1e68d6c72a..57c99b20dad 100644 --- a/desktop/src-tauri/src/commands/relay_reconnect.rs +++ b/desktop/src-tauri/src/commands/relay_reconnect.rs @@ -55,12 +55,12 @@ fn run_with_timeout( argv: &[String], timeout: std::time::Duration, ) -> Result { - let mut child = std::process::Command::new(&argv[0]) - .args(&argv[1..]) + let mut cmd = std::process::Command::new(&argv[0]); + cmd.args(&argv[1..]) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|e| format!("spawn failed: {e}"))?; + .stderr(std::process::Stdio::null()); + crate::util::configure_no_window(&mut cmd); + let mut child = cmd.spawn().map_err(|e| format!("spawn failed: {e}"))?; let deadline = std::time::Instant::now() + timeout; loop { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9017356ab55..3688ee3a621 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -667,6 +667,7 @@ pub fn run() { persist_current_identity, get_profile, update_profile, + update_profile_at_relay, get_user_profile, get_users_batch, get_user_notes, @@ -691,6 +692,7 @@ pub fn run() { get_presence, get_os_idle_seconds, get_default_relay_url, + auto_connect_default_relay_enabled, get_legacy_workspace_storage, is_shared_identity, get_relay_ws_url, @@ -797,6 +799,7 @@ pub fn run() { mesh_start_node, mesh_stop_node, mesh_node_status, + mesh_serving_usage, mesh_installed_models, mesh_model_catalog, update_managed_agent, diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 5e7a9cbf776..2a72af92d7f 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -30,6 +30,7 @@ pub fn invoke_provider( if let Some(home) = super::default_agent_workdir() { cmd.current_dir(home); } + crate::util::configure_no_window(&mut cmd); let mut child = cmd .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 50206567ad5..f40eed5a135 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -677,7 +677,10 @@ fn login_shell_candidates() -> Vec { /// Returns trimmed stdout if the command succeeds with non-empty output. fn run_in_login_shell(args: &[&str]) -> Option { for shell in login_shell_candidates() { - let Ok(output) = Command::new(&shell).args(args).output() else { + let mut cmd = Command::new(&shell); + cmd.args(args); + crate::util::configure_no_window(&mut cmd); + let Ok(output) = cmd.output() else { continue; }; if !output.status.success() { @@ -916,6 +919,7 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + crate::util::configure_no_window(&mut command); let mut child = match command.spawn() { Ok(c) => c, @@ -1079,6 +1083,7 @@ pub(crate) fn probe_codex_acp_major_version_with_path( if let Some(path) = augmented_path { command.env("PATH", path); } + crate::util::configure_no_window(&mut command); let mut child = command .stdout(tmp.try_clone().ok()?) .stderr(std::process::Stdio::null()) diff --git a/desktop/src-tauri/src/managed_agents/git_bash.rs b/desktop/src-tauri/src/managed_agents/git_bash.rs index 790f18cfa2d..dab9e887c8c 100644 --- a/desktop/src-tauri/src/managed_agents/git_bash.rs +++ b/desktop/src-tauri/src/managed_agents/git_bash.rs @@ -5,6 +5,8 @@ //! Git-for-Windows registry. A Doctor green state therefore means `buzz-dev-mcp` //! can actually start its shell. +#[cfg(all(not(windows), test))] +use std::path::Path; #[cfg(windows)] use std::path::{Path, PathBuf}; @@ -262,6 +264,34 @@ fn bash_from_git(git: &Path) -> Option { #[cfg(windows)] fn scan_path_for_bash(path_env: &str, system_root: Option<&Path>) -> Option { scan_path_for_command(Path::new("bash.exe"), path_env, system_root) + .filter(|p| !is_windows_apps_alias(p)) +} + +/// Return `true` when `path` is inside the Windows app-execution-alias directory +/// (`%LOCALAPPDATA%\Microsoft\WindowsApps`). Paths in that directory are WSL +/// stub launchers, not real executables — running them spawns `wsl.exe` / +/// `wslhost.exe` / `conhost.exe` trees rather than the intended shell (issue #2328). +/// +/// The check is purely path-structural so it compiles and is testable on any host. +#[cfg(any(windows, test))] +pub(crate) fn is_windows_apps_alias(path: &Path) -> bool { + let mut components = path.components().peekable(); + while components.peek().is_some() { + let mut it = components.clone(); + if it.next().is_some_and(|c| { + c.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case("Microsoft") + }) && it.next().is_some_and(|c| { + c.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case("WindowsApps") + }) { + return true; + } + components.next(); + } + false } #[cfg(windows)] @@ -577,3 +607,70 @@ mod tests { ); } } + +// ── WindowsApps alias predicate — runs on all platforms ────────────────────── +// +// The predicate is path-structural; no filesystem or registry access. +// Tests run on macOS/Linux CI without a Windows target. +#[cfg(test)] +mod windows_apps_tests { + use super::is_windows_apps_alias; + use std::path::Path; + + #[test] + fn test_windows_apps_alias_detected_typical_path() { + // Typical WSL alias location: %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe + // Use forward-slash path so the test parses on both Windows and non-Windows hosts. + assert!( + is_windows_apps_alias(Path::new( + "C:/Users/alice/AppData/Local/Microsoft/WindowsApps/bash.exe" + )), + "standard WindowsApps path must be detected as an alias" + ); + } + + #[test] + fn test_windows_apps_alias_detected_case_insensitive() { + assert!( + is_windows_apps_alias(Path::new( + "C:/Users/alice/AppData/Local/MICROSOFT/WINDOWSAPPS/bash.exe" + )), + "WindowsApps detection must be case-insensitive" + ); + } + + #[test] + fn test_windows_apps_alias_rejected_real_git_bash() { + assert!( + !is_windows_apps_alias(Path::new("C:/Program Files/Git/bin/bash.exe")), + "real Git Bash must not be detected as a WindowsApps alias" + ); + } + + #[test] + fn test_windows_apps_alias_rejected_unrelated_path() { + assert!( + !is_windows_apps_alias(Path::new("C:/Windows/System32/bash.exe")), + "System32 bash must not be detected as a WindowsApps alias" + ); + } + + #[test] + fn test_windows_apps_alias_rejected_partial_segment_match() { + // A directory named exactly "Microsoft" without a "WindowsApps" sibling + // must not match. + assert!( + !is_windows_apps_alias(Path::new("C:/Microsoft/SomeOtherDir/bash.exe")), + "path with Microsoft but not WindowsApps must not be detected" + ); + } + + #[test] + fn test_windows_apps_alias_posix_style_path() { + // macOS/Linux CI: verify posix-style paths don't accidentally match. + assert!( + !is_windows_apps_alias(Path::new("/usr/bin/bash")), + "Unix bash must not be detected as a WindowsApps alias" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 8e2f866b7e4..45db7fd6698 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -61,6 +61,7 @@ pub(crate) fn login_probe( if let Some(path) = augmented_path { command.env("PATH", path); } + crate::util::configure_no_window(&mut command); match command.output() { Ok(o) if o.status.success() => ProbeOutcome::LoggedIn, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index cef669ab251..6687cbbcf2f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -16,6 +16,9 @@ use crate::{ mod path; pub(in crate::managed_agents) use path::build_augmented_path; +pub(crate) use path::compose_path_entries; +pub(crate) use path::should_skip_claude_executable; +pub(crate) use path::should_use_inherited; mod stop; pub(crate) use stop::managed_agent_runtime_keys; @@ -1602,6 +1605,15 @@ pub(crate) fn configure_runtime_cli( return; } if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { + // On Windows, `.cmd` and `.bat` files are batch shims — they cannot be + // passed directly to `CreateProcess` and cause EINVAL when the Claude + // adapter tries to spawn them (issue #2397). Skip setting + // `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to + // its own PATH lookup and finds the real binary instead. + // Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned. + if should_skip_claude_executable(&cli_path, cfg!(windows)) { + return; + } command.env("CLAUDE_CODE_EXECUTABLE", cli_path); } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs index c1baadda7f0..cf6950f5773 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/path.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs @@ -2,6 +2,86 @@ use std::path::PathBuf; +/// Return `true` when `path` is a Windows batch shim (`.cmd` or `.bat`, +/// case-insensitive) that cannot be passed directly to `CreateProcess`. +/// +/// Extracted as a pure function so it can be unit-tested on any host without +/// touching the global PATH or `resolve_command` cache (issue #2397). +pub(crate) fn is_batch_shim(path: &std::path::Path) -> bool { + path.extension() + .map(|ext| { + let lower = ext.to_string_lossy().to_lowercase(); + lower == "cmd" || lower == "bat" + }) + .unwrap_or(false) +} + +/// Return `true` when the resolved CLI path should be skipped for +/// `CLAUDE_CODE_EXECUTABLE` assignment. +/// +/// On Windows, `.cmd`/`.bat` batch shims cannot be passed directly to +/// `CreateProcess` (EINVAL, issue #2397). On non-Windows those extensions are +/// valid executables and must not be suppressed — the `is_windows` flag keeps +/// this decision testable cross-host on macOS CI. +pub(crate) fn should_skip_claude_executable(path: &std::path::Path, is_windows: bool) -> bool { + is_windows && is_batch_shim(path) +} + +/// Decide whether the inherited process PATH should be appended to the +/// composed PATH. +/// +/// On Windows, `login_shell_path()` always returns `None` because Git Bash +/// returns POSIX colon-delimited paths that poison native children. +/// `Command::env("PATH", …)` replaces rather than extends, so without the +/// inherited PATH every child loses node/npm/git. +/// +/// This pure function takes an explicit `is_windows` flag so it can be +/// unit-tested cross-host (macOS CI can pass `true` to exercise the Windows +/// policy without needing the `cfg!(windows)` target). +/// +/// Rules: +/// - Only append when `is_windows` — on Unix the login-shell PATH always covers +/// the needed runtimes. +/// - Suppress when `had_shell_path` is `true` — if a login-shell PATH was +/// supplied it already carries the user's native entries; appending the +/// process PATH would double them. +/// - Suppress when `has_local_context` is `false` — callers that pass no home +/// or exe-parent context must not receive a PATH manufactured from ambient +/// process state alone. +pub(crate) fn should_use_inherited( + had_shell_path: bool, + has_local_context: bool, + is_windows: bool, +) -> bool { + is_windows && !had_shell_path && has_local_context +} + +/// Pure PATH composition kernel shared by the install shell and the runtime/probe paths. +/// +/// Merges already-split PATH entries in precedence order: +/// 1. `managed` — Buzz-controlled dirs (highest precedence, e.g. managed Node/npm bins) +/// 2. `login` — login-shell PATH entries (split before calling) +/// 3. `inherited` — current-process PATH entries (split before calling), appended +/// only when `use_inherited` is `true` +/// +/// Callers are responsible for splitting raw PATH strings and for prepending any +/// additional prefix entries (e.g. `home/.local/bin`, `nvm`, `exe_parent`) before +/// passing them in `managed`. `split_paths`/`join_paths` are kept at the wrapper +/// boundaries so this function remains fully pure and testable on any host. +pub(crate) fn compose_path_entries( + managed: Vec, + login: Vec, + inherited: Vec, + use_inherited: bool, +) -> Vec { + let mut parts = managed; + parts.extend(login); + if use_inherited { + parts.extend(inherited); + } + parts +} + /// Assemble the augmented `PATH` for a launched managed-agent child process. /// /// Concatenates, in priority order: @@ -11,6 +91,10 @@ use std::path::PathBuf; /// 4. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) /// 5. exe parent dir — DMG sidecars under `Contents/MacOS/` /// 6. user's login-shell `PATH` — runtimes like node/python from other managers +/// 7. Windows only: the current process `PATH` (appended when no login-shell +/// PATH exists, because callers use `Command::env("PATH", …)` which +/// *replaces* the child's PATH — without this, the child loses node/npm/git +/// and every npm `.cmd` shim fails with `'node' is not recognized`) /// /// `shell_path` is the raw colon-delimited string from a login shell, so it is /// split into individual entries before joining. Pushing it as a single segment @@ -24,31 +108,46 @@ pub(in crate::managed_agents) fn build_augmented_path( shell_path: Option, nvm_bin: Option, ) -> Option { - let mut parts: Vec = Vec::new(); let home_added = home.is_some(); + let exe_added = exe_parent.is_some(); + let has_local_context = home_added || exe_added; + + // Build the managed/prefix entries (everything before login-shell PATH). + let mut managed: Vec = Vec::new(); if let Some(home) = home { - parts.push(home.join(".local").join("bin")); + managed.push(home.join(".local").join("bin")); } // Only add managed runtime dirs when a home or executable context exists. // This keeps tests/utility callers that intentionally pass no local context // from manufacturing a PATH out of ambient platform dirs alone. - if home_added || exe_parent.is_some() { + if has_local_context { if let Some(managed_npm_bin) = crate::managed_agents::buzz_managed_npm_bin_dir() { - parts.push(managed_npm_bin); + managed.push(managed_npm_bin); } if let Some(managed_node_bin) = crate::managed_agents::buzz_managed_node_bin_dir() { - parts.push(managed_node_bin); + managed.push(managed_node_bin); } } if let Some(nvm_bin) = nvm_bin { - parts.push(nvm_bin); + managed.push(nvm_bin); } if let Some(parent) = exe_parent { - parts.push(parent); - } - if let Some(shell_path) = shell_path { - parts.extend(std::env::split_paths(&shell_path)); + managed.push(parent); } + + // Split the login-shell PATH into individual entries. + let had_shell_path = shell_path.is_some(); + let login: Vec = shell_path + .as_deref() + .map(|s| std::env::split_paths(s).collect()) + .unwrap_or_default(); + + let inherited: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + let use_inherited = should_use_inherited(had_shell_path, has_local_context, cfg!(windows)); + + let parts = compose_path_entries(managed, login, inherited, use_inherited); if parts.is_empty() { return None; } @@ -134,4 +233,374 @@ mod tests { assert!(result.starts_with("/home/user/.local/bin:"), "{result}"); assert!(result.ends_with(":/usr/local/bin"), "{result}"); } + + /// On Unix, supplying a `shell_path` must NOT trigger the Windows process-PATH + /// fallback — the output must be byte-identical to what it was before this + /// fix. + #[cfg(unix)] + #[test] + fn unix_shell_path_output_unchanged_by_windows_fallback_logic() { + let result = build_augmented_path( + Some(PathBuf::from("/home/user")), + None, + Some("/usr/local/bin:/usr/bin:/bin".to_string()), + None, + ); + let result = result.expect("path"); + assert!( + result.ends_with(":/usr/local/bin:/usr/bin:/bin"), + "Unix output must not append process PATH: {result}" + ); + } + + /// On Windows: when no login-shell PATH is available, `build_augmented_path` + /// must append the inherited process PATH so node/npm remain visible. + #[cfg(windows)] + #[test] + fn windows_appends_process_path_when_no_shell_path() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", r"C:\Program Files\nodejs"); + + let result = build_augmented_path(Some(PathBuf::from(r"C:\Users\agent")), None, None, None); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + + let result = result.expect("path must not be None with a home dir"); + assert!( + result.starts_with(r"C:\Users\agent\.local\bin;"), + "home/.local/bin must be first: {result}" + ); + assert!( + result.ends_with(r";C:\Program Files\nodejs"), + "process PATH must be last: {result}" + ); + } + + /// On Windows: when a login-shell PATH IS supplied, the process PATH must + /// NOT also be appended. + #[cfg(windows)] + #[test] + fn windows_does_not_append_process_path_when_shell_path_present() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", r"C:\ShouldNotAppear"); + + let result = build_augmented_path( + Some(PathBuf::from(r"C:\Users\agent")), + None, + Some(r"C:\Program Files\nodejs".to_string()), + None, + ); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + + let result = result.expect("path"); + assert!( + !result.contains("ShouldNotAppear"), + "process PATH must not be appended when shell_path is present: {result}" + ); + } + + /// On Windows: when no local context is provided, the function must return + /// None even if the process PATH is set. + #[cfg(windows)] + #[test] + fn windows_no_process_path_without_local_context() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", r"C:\Windows\System32"); + + let result = build_augmented_path(None, None, None, None); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + + assert_eq!( + result, None, + "must return None when no local context and no shell_path" + ); + } +} + +// ── Pure policy and composition tests — run on every host ──────────────────── +// +// These test `should_use_inherited` and `compose_path_entries` with explicit +// inputs, so they run on macOS/Linux CI and validate the Windows policy +// behavior without touching process state or requiring a Windows target. +#[cfg(test)] +mod compose_tests { + use super::{compose_path_entries, is_batch_shim, should_use_inherited}; + use std::path::{Path, PathBuf}; + + fn p(s: &str) -> PathBuf { + PathBuf::from(s) + } + + // ── should_use_inherited policy matrix ──────────────────────────────────── + + /// Windows + no shell path + has local context → must use inherited. + #[test] + fn policy_windows_no_shell_with_context_uses_inherited() { + assert!( + should_use_inherited(false, true, true), + "Windows, no shell path, has context → must append inherited" + ); + } + + /// Windows + shell path present → must NOT use inherited (login path covers it). + #[test] + fn policy_windows_shell_path_present_suppresses_inherited() { + assert!( + !should_use_inherited(true, true, true), + "Windows, shell path present → must not append inherited" + ); + } + + /// Windows + no local context → must NOT use inherited (no ambient state). + #[test] + fn policy_windows_no_local_context_suppresses_inherited() { + assert!( + !should_use_inherited(false, false, true), + "Windows, no local context → must not append inherited" + ); + } + + /// Non-Windows → never use inherited, regardless of other flags. + #[test] + fn policy_non_windows_never_uses_inherited() { + assert!( + !should_use_inherited(false, true, false), + "non-Windows must never append inherited PATH" + ); + assert!( + !should_use_inherited(false, false, false), + "non-Windows + no context must never append inherited PATH" + ); + } + + // ── compose_path_entries ordering ───────────────────────────────────────── + + #[test] + fn managed_entries_appear_first() { + let managed = vec![p("/buzz/node/bin"), p("/buzz/npm/bin")]; + let login = vec![p("/usr/local/bin"), p("/usr/bin")]; + let result = compose_path_entries(managed, login, vec![], false); + assert_eq!(result[0], p("/buzz/node/bin"), "managed[0] must be first"); + assert_eq!(result[1], p("/buzz/npm/bin"), "managed[1] must be second"); + assert_eq!( + result[2], + p("/usr/local/bin"), + "login[0] must follow managed" + ); + } + + #[test] + fn login_path_suppresses_inherited_when_use_inherited_false() { + let login = vec![p("/usr/local/bin")]; + let inherited = vec![p("/should/not/appear")]; + let result = compose_path_entries(vec![], login, inherited, false); + assert!( + !result.contains(&p("/should/not/appear")), + "inherited must not appear when use_inherited=false" + ); + } + + #[test] + fn inherited_appended_last_when_use_inherited_true() { + let managed = vec![p("/buzz/npm/bin")]; + let inherited = vec![p("C:/windows/node"), p("C:/windows/npm")]; + let result = compose_path_entries(managed, vec![], inherited.clone(), true); + assert_eq!(result[0], p("/buzz/npm/bin"), "managed must be first"); + assert_eq!( + &result[1..], + &inherited[..], + "inherited entries must be appended last" + ); + } + + /// Windows policy ON + empty inherited PATH — should produce just managed + /// entries, not None and not a phantom segment. + #[test] + fn windows_policy_on_empty_inherited_produces_managed_only() { + let managed = vec![p("/buzz/npm/bin")]; + let result = compose_path_entries(managed.clone(), vec![], vec![], true); + assert_eq!( + result, managed, + "empty inherited must not add phantom entries" + ); + } + + /// Windows policy ON + unset/absent inherited (empty vec from var_os None) — + /// same result as above; no crash, no phantom. + #[test] + fn windows_policy_on_unset_inherited_path_produces_managed_only() { + // Simulates std::env::var_os("PATH") returning None → empty vec. + let managed = vec![p("/buzz/npm/bin")]; + let inherited: Vec = vec![]; // empty, as if PATH is unset + let result = compose_path_entries(managed.clone(), vec![], inherited, true); + assert_eq!(result, managed); + } + + /// No local context + Windows policy ON — compose_path_entries itself still + /// works (no crash), and the caller is responsible for not calling it. + /// Specifically: all-empty inputs with use_inherited=true still returns empty. + #[test] + fn all_empty_with_use_inherited_true_returns_empty() { + let result = compose_path_entries(vec![], vec![], vec![], true); + assert!( + result.is_empty(), + "all-empty inputs must produce empty output" + ); + } + + #[test] + fn empty_all_inputs_use_inherited_false_returns_empty() { + let result = compose_path_entries(vec![], vec![], vec![], false); + assert!( + result.is_empty(), + "all-empty inputs must produce empty output" + ); + } + + /// Non-Windows behavior: `use_inherited=false` must produce byte-identical + /// output to before this fix. Inherited entries are collected but dropped. + #[cfg(unix)] + #[test] + fn unix_use_inherited_false_output_unchanged() { + let managed = vec![p("/buzz/npm/bin")]; + let login = vec![p("/usr/local/bin"), p("/usr/bin"), p("/bin")]; + let inherited = vec![p("/proc/ambient/PATH")]; // would be real proc PATH on Unix + let result = compose_path_entries(managed, login, inherited, false); + assert_eq!( + result, + vec![ + p("/buzz/npm/bin"), + p("/usr/local/bin"), + p("/usr/bin"), + p("/bin") + ], + "Unix output must not include inherited entries when use_inherited=false" + ); + } + + // ── Structural wrapper-alignment test ────────────────────────────────────── + // + // Verifies that both `build_augmented_path` and `install_shell_command` + // compute the same `should_use_inherited` decision for equivalent inputs. + // Tests the policy function directly to confirm the wrappers can't drift. + + /// Exhaustive truth-table for `should_use_inherited` — all four input + /// combinations that affect real callers. Confirms the policy is correct + /// before either wrapper binds to it. + #[test] + fn should_use_inherited_policy_truth_table() { + // (had_shell, has_context, is_windows) → expected + let cases = [ + (false, true, true, true), // Windows, no shell, context → USE + (true, true, true, false), // Windows, shell present → NO + (false, false, true, false), // Windows, no context → NO + (false, true, false, false), // non-Windows → NO + ]; + for (had_shell, has_ctx, is_win, expected) in cases { + let result = should_use_inherited(had_shell, has_ctx, is_win); + assert_eq!( + result, expected, + "policy mismatch: had_shell={had_shell} has_ctx={has_ctx} is_win={is_win}" + ); + } + } + + // ── is_batch_shim extension tests ───────────────────────────────────────── + + #[test] + fn batch_shim_cmd_lower() { + assert!(is_batch_shim(Path::new("claude.cmd"))); + } + + #[test] + fn batch_shim_cmd_upper() { + assert!(is_batch_shim(Path::new("claude.CMD"))); + } + + #[test] + fn batch_shim_bat_lower() { + assert!(is_batch_shim(Path::new("claude.bat"))); + } + + #[test] + fn batch_shim_bat_upper() { + assert!(is_batch_shim(Path::new("claude.BAT"))); + } + + #[test] + fn batch_shim_exe_not_shim() { + assert!(!is_batch_shim(Path::new("claude.exe"))); + } + + #[test] + fn batch_shim_no_extension_not_shim() { + assert!(!is_batch_shim(Path::new("claude"))); + } + + // ── should_skip_claude_executable policy tests ──────────────────────────── + // + // Cross-host policy: shim + Windows → skip; shim + non-Windows → assign; + // non-shim either OS → assign. Mirrors the `should_use_inherited` pattern. + + #[test] + fn skip_claude_executable_shim_windows_returns_true() { + assert!( + super::should_skip_claude_executable(Path::new("claude.cmd"), true), + "shim + windows=true must skip" + ); + assert!( + super::should_skip_claude_executable(Path::new("claude.BAT"), true), + "shim + windows=true must skip" + ); + } + + #[test] + fn skip_claude_executable_shim_non_windows_returns_false() { + assert!( + !super::should_skip_claude_executable(Path::new("claude.cmd"), false), + "shim + windows=false must NOT skip (valid executable on non-Windows)" + ); + assert!( + !super::should_skip_claude_executable(Path::new("claude.bat"), false), + "shim + windows=false must NOT skip" + ); + } + + #[test] + fn skip_claude_executable_exe_both_platforms_returns_false() { + assert!( + !super::should_skip_claude_executable(Path::new("claude.exe"), true), + "non-shim + windows=true must NOT skip" + ); + assert!( + !super::should_skip_claude_executable(Path::new("claude.exe"), false), + "non-shim + windows=false must NOT skip" + ); + } + + #[test] + fn skip_claude_executable_no_ext_both_platforms_returns_false() { + assert!( + !super::should_skip_claude_executable(Path::new("claude"), true), + "no-ext + windows=true must NOT skip" + ); + assert!( + !super::should_skip_claude_executable(Path::new("claude"), false), + "no-ext + windows=false must NOT skip" + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index d86b69938f6..fd758047c33 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -618,6 +618,63 @@ fn codex_spawn_does_not_set_a_claude_executable() { .any(|(key, _)| key == "CLAUDE_CODE_EXECUTABLE")); } +/// On Windows, `.cmd` and `.bat` batch shims must NOT be assigned to +/// `CLAUDE_CODE_EXECUTABLE` — `CreateProcess` cannot exec them directly and +/// returns EINVAL (issue #2397). The adapter must fall back to its own PATH +/// lookup instead. +/// +/// These tests exercise `is_batch_shim` directly — a pure path predicate with +/// no global PATH or resolve_command cache involvement — so they run on every +/// host and cannot be poisoned by the `claude_spawn_uses_the_probed_cli_executable` +/// test that runs before them. +#[test] +fn batch_shim_cmd_extension_is_rejected() { + assert!( + super::path::is_batch_shim(std::path::Path::new("claude.cmd")), + "claude.cmd must be identified as a batch shim" + ); +} + +#[test] +fn batch_shim_cmd_extension_uppercase_is_rejected() { + assert!( + super::path::is_batch_shim(std::path::Path::new("claude.CMD")), + "claude.CMD must be identified as a batch shim (case-insensitive)" + ); +} + +#[test] +fn batch_shim_bat_extension_is_rejected() { + assert!( + super::path::is_batch_shim(std::path::Path::new("claude.bat")), + "claude.bat must be identified as a batch shim" + ); +} + +#[test] +fn batch_shim_bat_extension_uppercase_is_rejected() { + assert!( + super::path::is_batch_shim(std::path::Path::new("claude.BAT")), + "claude.BAT must be identified as a batch shim (case-insensitive)" + ); +} + +#[test] +fn batch_shim_exe_extension_is_not_rejected() { + assert!( + !super::path::is_batch_shim(std::path::Path::new("claude.exe")), + "claude.exe must not be identified as a batch shim" + ); +} + +#[test] +fn batch_shim_no_extension_is_not_rejected() { + assert!( + !super::path::is_batch_shim(std::path::Path::new("claude")), + "claude (no extension) must not be identified as a batch shim" + ); +} + // ── PGID-based orphan sweep tests ─────────────────────────────────────── /// Validates the kernel invariant that the orphan sweep PGID fix relies on: diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 4ec3adaab34..acece498c02 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -196,6 +196,80 @@ pub struct MeshNodeStatus { pub device_name: Option, } +/// Host-side "who is using the compute I'm sharing" snapshot. +/// +/// Read-only projection of the serving node's own runtime metrics (the same +/// `routing_metrics` / `inflight_requests` the SDK already exposes on the local +/// console). No new trust surface: it reads the node's own status payload. +/// +/// The local/remote/endpoint attempt split is what distinguishes *my own* +/// agent (local) from *another member consuming my compute* (remote/endpoint). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct MeshServingUsage { + /// Requests being served right now. + pub inflight: u64, + /// Highest concurrent in-flight seen this session. + pub peak_inflight: u64, + /// Total requests routed through this node. + pub requests_served: u64, + /// Completion tokens produced. + pub tokens_served: u64, + /// Recent decode throughput. + pub tokens_per_second: f64, + /// Requests served for this machine's own agents. + pub local_attempts: u64, + /// Requests served for a remote peer (someone else consuming my compute). + pub remote_attempts: u64, + /// Requests served via an advertised endpoint (also a remote consumer). + pub endpoint_attempts: u64, + /// Other nodes currently visible as peers. + pub peers: u64, +} + +impl MeshServingUsage { + /// True when at least one request has been served for a non-local consumer. + pub fn has_remote_consumers(&self) -> bool { + self.remote_attempts > 0 || self.endpoint_attempts > 0 + } +} + +/// Pure extractor: project a raw SDK status payload into [`MeshServingUsage`]. +/// +/// Every field is read defensively (missing → 0) so an SDK shape change +/// degrades to "no usage shown" rather than an error. Kept pure so it can be +/// unit-tested against a captured payload without a live runtime. +pub fn serving_usage_from_payload(payload: &serde_json::Value) -> MeshServingUsage { + let u64_at = |v: &serde_json::Value| v.as_u64().unwrap_or(0); + let rm = payload.get("routing_metrics"); + let local = rm.and_then(|m| m.get("local_node")); + let get_u64 = |obj: Option<&serde_json::Value>, key: &str| { + obj.and_then(|o| o.get(key)).map(u64_at).unwrap_or(0) + }; + MeshServingUsage { + inflight: local + .and_then(|l| l.get("current_inflight_requests")) + .map(u64_at) + .or_else(|| payload.get("inflight_requests").map(u64_at)) + .unwrap_or(0), + peak_inflight: get_u64(local, "peak_inflight_requests"), + requests_served: get_u64(rm, "request_count"), + tokens_served: get_u64(rm, "completion_tokens_observed"), + tokens_per_second: rm + .and_then(|m| m.get("avg_tokens_per_second")) + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0), + local_attempts: get_u64(local, "local_attempt_count"), + remote_attempts: get_u64(local, "remote_attempt_count"), + endpoint_attempts: get_u64(local, "endpoint_attempt_count"), + peers: payload + .get("peers") + .and_then(serde_json::Value::as_array) + .map(|a| a.len() as u64) + .unwrap_or(0), + } +} + pub fn stopped_status() -> MeshNodeStatus { MeshNodeStatus { state: MeshNodeState::Off, @@ -381,6 +455,14 @@ impl DesktopMeshRuntime { &self.start_request } + /// The role this runtime was started in. Serve = this machine is SHARING + /// compute; Client = this machine is CONSUMING a peer's compute. Both + /// occupy the single runtime slot, so callers that act only on the sharing + /// role (e.g. the Share-compute stop path) must check this first. + pub fn mode(&self) -> MeshNodeMode { + self.mode + } + pub async fn status(&self) -> anyhow::Result { let status = self.handle.status().await?; self.status_from_sdk(status) @@ -420,6 +502,12 @@ impl DesktopMeshRuntime { Ok(payload) } + /// Read-only host-side usage snapshot from the node's own runtime metrics. + pub async fn serving_usage(&self) -> anyhow::Result { + let status = self.handle.status().await?; + Ok(serving_usage_from_payload(&status.payload)) + } + pub async fn dial_endpoint_addr(&self, endpoint_addr: impl Into) -> anyhow::Result<()> { let endpoint_addr = endpoint_addr.into(); let validated = validate_advertised_endpoint(&endpoint_addr)?; diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index 4c0c835efe1..a8b78bc1900 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -597,3 +597,69 @@ fn owner_roster_without_membership_list_fails_closed() { assert!(super::owner_ids_from_events(&events).is_empty()); } + +#[test] +fn serving_usage_extracts_local_and_remote_attempts() { + // Captured shape from a live serving node's status payload. The local vs + // remote/endpoint split is what tells "my own agent" apart from "a peer + // consuming my compute". + let payload = json!({ + "inflight_requests": 0, + "peers": [], + "routing_metrics": { + "request_count": 5, + "completion_tokens_observed": 764, + "avg_tokens_per_second": 29.651, + "local_node": { + "current_inflight_requests": 1, + "peak_inflight_requests": 2, + "local_attempt_count": 4, + "remote_attempt_count": 0, + "endpoint_attempt_count": 0 + } + } + }); + let usage = super::serving_usage_from_payload(&payload); + assert_eq!(usage.inflight, 1); + assert_eq!(usage.peak_inflight, 2); + assert_eq!(usage.requests_served, 5); + assert_eq!(usage.tokens_served, 764); + assert_eq!(usage.local_attempts, 4); + assert_eq!(usage.remote_attempts, 0); + assert_eq!(usage.endpoint_attempts, 0); + assert!( + !usage.has_remote_consumers(), + "all-local traffic is not a remote consumer" + ); +} + +#[test] +fn serving_usage_flags_remote_consumer() { + let payload = json!({ + "peers": [{"id": "a"}, {"id": "b"}], + "routing_metrics": { + "request_count": 10, + "local_node": { + "local_attempt_count": 3, + "remote_attempt_count": 6, + "endpoint_attempt_count": 1 + } + } + }); + let usage = super::serving_usage_from_payload(&payload); + assert_eq!(usage.remote_attempts, 6); + assert_eq!(usage.endpoint_attempts, 1); + assert_eq!(usage.peers, 2); + assert!( + usage.has_remote_consumers(), + "remote/endpoint attempts mean someone else is using my compute" + ); +} + +#[test] +fn serving_usage_defaults_to_zero_on_missing_fields() { + // SDK shape drift must degrade to "no usage" not panic. + let usage = super::serving_usage_from_payload(&json!({})); + assert_eq!(usage, super::MeshServingUsage::default()); + assert!(!usage.has_remote_consumers()); +} diff --git a/desktop/src-tauri/src/mesh_llm_stubs.rs b/desktop/src-tauri/src/mesh_llm_stubs.rs index 779f7dbddcd..e8c13f48ea9 100644 --- a/desktop/src-tauri/src/mesh_llm_stubs.rs +++ b/desktop/src-tauri/src/mesh_llm_stubs.rs @@ -26,6 +26,11 @@ pub async fn mesh_node_status(_state: State<'_, AppState>) -> CmdResult) -> CmdResult { + Err("mesh-llm feature not enabled".to_string()) +} + #[tauri::command] pub async fn mesh_installed_models( _state: State<'_, AppState>, diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index e6463f50922..1c9ba0095af 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -347,6 +347,7 @@ pub async fn query_relay_at_with_keys( keys: &Keys, auth_tag: Option<&str>, ) -> Result, String> { + crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/query", api_base_url); let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; @@ -530,56 +531,8 @@ pub struct AgentProfileInfo { // ── Signed-event submission ───────────────────────────────────────────────── -/// Response from `POST /events`. -#[derive(Debug, Deserialize, serde::Serialize)] -pub struct SubmitEventResponse { - pub event_id: String, - pub accepted: bool, - pub message: String, -} - -/// Build an `EventBuilder` from the events module, sign it with the user's keys, -/// and POST the signed event to `/events` with NIP-98 auth. -pub async fn submit_event( - builder: nostr::EventBuilder, - state: &AppState, -) -> Result { - crate::relay_admission::wait_for_rate_limit().await; - // All synchronous work (signing) must complete before any .await - // so the MutexGuard is dropped and the future remains Send. - let url = format!("{}/events", relay_api_base_url_with_override(state)); - let (auth_header, body_bytes) = { - let keys = state.signing_keys()?; - let event = builder - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign event: {e}"))?; - let body = event.as_json().into_bytes(); - let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?; - (auth, body) - }; // keys dropped here - - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - let result: SubmitEventResponse = parse_json_response(response).await?; - - if !result.accepted { - return Err(format!("relay rejected event: {}", result.message)); - } - - Ok(result) -} +mod submit; +pub use submit::{submit_event, submit_event_at_with_keys, SubmitEventResponse}; /// POST an already-signed event to `/events` with NIP-98 auth. /// diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs new file mode 100644 index 00000000000..7fb3f94041d --- /dev/null +++ b/desktop/src-tauri/src/relay/submit.rs @@ -0,0 +1,60 @@ +use super::*; + +/// Response from `POST /events`. +#[derive(Debug, Deserialize, serde::Serialize)] +pub struct SubmitEventResponse { + pub event_id: String, + pub accepted: bool, + pub message: String, +} + +/// Sign with an explicit identity and POST the event to an explicit relay. +/// +/// The caller owns the signer lifetime. This is important for deferred work: +/// an in-process identity swap cannot retarget the event or its NIP-98 auth +/// after the caller has validated which identity the operation belongs to. +pub async fn submit_event_at_with_keys( + builder: nostr::EventBuilder, + state: &AppState, + api_base_url: &str, + keys: &nostr::Keys, +) -> Result { + crate::relay_admission::wait_for_rate_limit().await; + let url = format!("{}/events", api_base_url.trim_end_matches('/')); + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + let body_bytes = event.as_json().into_bytes(); + let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; + + let response = state + .http_client + .post(&url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .body(body_bytes) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + let result: SubmitEventResponse = parse_json_response(response).await?; + if !result.accepted { + return Err(format!("relay rejected event: {}", result.message)); + } + + Ok(result) +} + +/// Build and submit an event to the currently active workspace relay. +pub async fn submit_event( + builder: nostr::EventBuilder, + state: &AppState, +) -> Result { + let api_base_url = relay_api_base_url_with_override(state); + let keys = state.signing_keys()?; + submit_event_at_with_keys(builder, state, &api_base_url, &keys).await +} diff --git a/desktop/src-tauri/src/util.rs b/desktop/src-tauri/src/util.rs index e35d4f87aaf..204cba6dbbc 100644 --- a/desktop/src-tauri/src/util.rs +++ b/desktop/src-tauri/src/util.rs @@ -198,6 +198,28 @@ pub(crate) fn replace_with_symlink(_src: &std::path::Path, _dst: &std::path::Pat 0 } +/// Suppress the console window that Windows otherwise allocates for every +/// console-subsystem child process spawned from a GUI (non-console) parent. +/// +/// On Windows, a GUI application (no console window of its own) that spawns a +/// child console-subsystem binary gets a fresh, briefly-visible console window +/// per child unless `CREATE_NO_WINDOW` is set. Setting it is a pure no-op on +/// non-Windows platforms, so callers can call this unconditionally. +/// +/// **Exclusions**: any command that explicitly wants a visible terminal (e.g. +/// `launch_visible_terminal` which uses `CREATE_NEW_CONSOLE`) must NOT call +/// this helper — it would conflict with the explicit console-creation flag. +pub(crate) fn configure_no_window(command: &mut std::process::Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + let _ = command; +} + #[cfg(test)] mod tests { use super::slugify; diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b75e0f52032..2fe2fdf9595 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.4.23", + "version": "0.4.24", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index e89bf61fbef..9561b2138cd 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -12,6 +12,11 @@ import { } from "react"; import { router } from "@/app/router"; +import { + completeCommunityViewTransition, + replaceCommunityDestinationRoute, +} from "@/app/communityViewTransition"; +import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; @@ -37,6 +42,11 @@ import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen"; import { useCommunityInit } from "@/features/communities/useCommunityInit"; import { useNestNotifications } from "@/features/communities/useNestNotifications"; import { useCommunities } from "@/features/communities/useCommunities"; +import { + loadCommunityDestination, + markPendingCommunityRestore, + saveCommunityDestination, +} from "@/features/communities/communityNavigationStorage"; import { onAddCommunityPrefillAvailable, requestAddCommunityPrefill, @@ -44,6 +54,7 @@ import { import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; +import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; @@ -197,6 +208,8 @@ function CommunitySwitchGate() { function CommunityQueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState(createBuzzQueryClient); + useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]); + useEffect(() => { const e2eWindow = window as Window & { __BUZZ_E2E__?: unknown; @@ -320,13 +333,40 @@ function CommunityApp({ sharedIdentity, ); - const handleCommunityOnboardingConnect = useCallback(() => { + const transitionCommunity = useCallback( + async (targetCommunityId: string) => { + const activeCommunityId = activeCommunity?.id; + if (targetCommunityId === activeCommunityId) return; + if (activeCommunityId) { + const route = deriveShellRoute(router.state.location.pathname); + saveCommunityDestination( + activeCommunityId, + route.selectedView === "channel" && route.selectedChannelId + ? { kind: "channel", channelId: route.selectedChannelId } + : { kind: "home" }, + ); + await router.navigate({ to: "/", replace: true }); + markPendingCommunityRestore(targetCommunityId); + const destination = loadCommunityDestination(targetCommunityId); + if (destination?.kind === "channel") { + replaceCommunityDestinationRoute( + destination.channelId, + router.history, + ); + } + } + switchCommunity(targetCommunityId); + }, + [activeCommunity?.id, switchCommunity], + ); + + const handleCommunityOnboardingConnect = useCallback(async () => { const transaction = communityOnboarding.transaction; if (transaction?.stage !== "connecting") return; if (connectingTransactionRef.current === transaction.id) return; connectingTransactionRef.current = transaction.id; if (transaction.communityId) { - switchCommunity(transaction.communityId); + await transitionCommunity(transaction.communityId); return; } const previousCommunityId = activeCommunity?.id; @@ -348,7 +388,7 @@ function CommunityApp({ addedCommunity: !relayAlreadyExists, error: undefined, }); - switchCommunity(id); + await transitionCommunity(id); reconnectCommunity(); }, [ activeCommunity?.id, @@ -357,17 +397,17 @@ function CommunityApp({ communityOnboarding, currentPubkey, reconnectCommunity, - switchCommunity, + transitionCommunity, ]); - const handleCommunityOnboardingCancel = useCallback(() => { + const handleCommunityOnboardingCancel = useCallback(async () => { const transaction = communityOnboarding.transaction; communityOnboarding.clear(); if (!transaction?.communityId) return; if (!transaction.addedCommunity) { if (transaction.previousCommunityId) { - switchCommunity(transaction.previousCommunityId); + await transitionCommunity(transaction.previousCommunityId); } return; } @@ -378,16 +418,16 @@ function CommunityApp({ clearCommunities(); return; } - removeCommunity(transaction.communityId); if (transaction.previousCommunityId) { - switchCommunity(transaction.previousCommunityId); + await transitionCommunity(transaction.previousCommunityId); } + removeCommunity(transaction.communityId); }, [ clearCommunities, communities.length, communityOnboarding, removeCommunity, - switchCommunity, + transitionCommunity, ]); const bootSplashPhase = useBootSplashHold(); @@ -487,6 +527,11 @@ function CommunityApp({ // Tauri backend is still configured for the previous one. const communityApplied = community.isReady && community.appliedKey === communityKey; + useLayoutEffect(() => { + if (communityApplied) { + completeCommunityViewTransition(); + } + }, [communityApplied]); if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 1d6223e7139..7ed9fa9348c 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -8,6 +8,7 @@ import { AppShellOverlays } from "@/app/AppShellOverlays"; import { AppTopChrome } from "@/app/AppTopChrome"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; +import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions"; import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; @@ -67,10 +68,16 @@ import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; +import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest"; import { CommunityRail } from "@/features/sidebar/ui/CommunityRail"; import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; import { useCommunities } from "@/features/communities/useCommunities"; +import { + consumePendingCommunityRestore, + loadCommunityDestination, + saveCommunityDestination, +} from "@/features/communities/communityNavigationStorage"; import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill"; import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; import { relayClient } from "@/shared/api/relayClient"; @@ -129,30 +136,19 @@ export function AppShell() { } = useAppNavigation(); const { canGoBack, canGoForward, goBack, goForward } = useBackForwardControls(); - // Navigate home before switching communities so the outgoing channel URL is - // cleared. Without this, ChannelScreen's read effect continues firing - // markChannelRead({ topLevelOnly: true }) for the previous community's - // channel, advancing its NIP-RS markers and causing the rail badge to vanish - // on the next 30s poll (A→B→A→B disappearance bug). - // Guard: skip goHome() when re-selecting the already-active community so - // the current channel is not unexpectedly cleared. - const handleSwitchCommunity = React.useCallback( - (id: string) => { - if (id !== communitiesHook.activeCommunity?.id) { - void goHome(); - } - communitiesHook.switchCommunity(id); - }, - [ - goHome, - communitiesHook.activeCommunity?.id, - communitiesHook.switchCommunity, - ], - ); const { selectedChannelId, selectedView } = React.useMemo( () => deriveShellRoute(location.pathname), [location.pathname], ); + const { + removeCommunity: handleRemoveCommunity, + switchCommunity: handleSwitchCommunity, + } = useCommunityNavigationTransitions({ + communities: communitiesHook, + goHome, + selectedChannelId, + selectedView, + }); // Settings lives in history so back returns to the previous app entry. const settingsOpen = location.pathname === "/settings"; const locationSearchSection = (location.search as { section?: unknown }) @@ -241,6 +237,54 @@ export function AppShell() { () => memberChannels.filter((channel) => channel.archivedAt === null), [memberChannels], ); + const hasRestoredCommunityDestinationRef = React.useRef(false); + React.useEffect(() => { + const activeCommunityId = communitiesHook.activeCommunity?.id; + if ( + hasRestoredCommunityDestinationRef.current || + !channelsQuery.isSuccess || + channelsQuery.dataUpdatedAt === 0 || + !activeCommunityId + ) { + return; + } + hasRestoredCommunityDestinationRef.current = true; + + // Restoration belongs to an explicit community transition. Cold boot and + // reconnect remounts must preserve the route the user explicitly opened. + if (!consumePendingCommunityRestore(activeCommunityId)) { + return; + } + + const destination = loadCommunityDestination(activeCommunityId); + if (!destination || destination.kind === "home") { + return; + } + + const channelIsAvailable = sidebarChannels.some( + (channel) => channel.id === destination.channelId, + ); + if (!channelIsAvailable) { + saveCommunityDestination(activeCommunityId, { kind: "home" }); + void goHome({ replace: true }); + return; + } + + // The normal switch path writes the remembered channel into the hash before + // the target community mounts, so no intermediate Inbox frame is painted. + // Older transition callers may still arrive at neutral Home; repair those. + if (selectedView === "home") { + void goChannel(destination.channelId, { replace: true }); + } + }, [ + channelsQuery.dataUpdatedAt, + channelsQuery.isSuccess, + communitiesHook.activeCommunity?.id, + goChannel, + goHome, + selectedView, + sidebarChannels, + ]); const activeChannel = React.useMemo( () => selectedChannelId @@ -713,7 +757,7 @@ export function AppShell() { communitiesHook.activeCommunity?.id ?? null } onAddCommunity={addCommunityDialog.openDialog} - onRemoveCommunity={communitiesHook.removeCommunity} + onRemoveCommunity={(id) => void handleRemoveCommunity(id)} onReorderCommunities={communitiesHook.reorderCommunities} onSwitchCommunity={handleSwitchCommunity} onUpdateCommunity={communitiesHook.updateCommunity} @@ -801,11 +845,14 @@ export function AppShell() { addCommunityDialog.onOpenChange } onNewMessage={handleOpenNewDm} + onBackgroundClick={requestFocusedThreadClose} onCreateChannelOpenChange={setIsCreateChannelOpen} onOpenAddCommunity={addCommunityDialog.openDialog} onSendFeedback={() => setIsSendFeedbackOpen(true)} onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={communitiesHook.removeCommunity} + onRemoveCommunity={(id) => + void handleRemoveCommunity(id) + } onSwitchCommunity={handleSwitchCommunity} onCreateAgent={() => requestOpenCreateAgent()} selfPresenceStatus={presenceSession.currentStatus} diff --git a/desktop/src/app/communityViewTransition.test.mjs b/desktop/src/app/communityViewTransition.test.mjs new file mode 100644 index 00000000000..4e2c6db6d57 --- /dev/null +++ b/desktop/src/app/communityViewTransition.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test, { afterEach, mock } from "node:test"; + +import { + completeCommunityViewTransition, + replaceCommunityDestinationRoute, + runCommunityViewTransition, +} from "./communityViewTransition.ts"; + +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; + +afterEach(() => { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + mock.restoreAll(); +}); + +function installBrowser(startViewTransition) { + globalThis.window = { clearTimeout, setTimeout }; + globalThis.document = { startViewTransition }; +} + +function transitionFor(callback) { + return { updateCallbackDone: Promise.resolve().then(callback) }; +} + +test("replaceCommunityDestinationRoute uses router history and encodes the channel id", () => { + const replacements = []; + replaceCommunityDestinationRoute("channel/with spaces", { + replace: (href) => replacements.push(href), + }); + assert.deepEqual(replacements, ["/channels/channel%2Fwith%20spaces"]); +}); + +test("unsupported browsers execute the update and contain rejection", async () => { + installBrowser(undefined); + const expected = new Error("navigation failed"); + const error = mock.method(console, "error", () => {}); + + await assert.doesNotReject(() => + runCommunityViewTransition(async () => { + throw expected; + }), + ); + + assert.equal(error.mock.callCount(), 1); + assert.equal(error.mock.calls[0].arguments[1], expected); +}); + +test("supported transitions wait for target readiness", async () => { + let updateFinished = false; + let transitionFinished = false; + installBrowser((callback) => transitionFor(callback)); + + const pending = runCommunityViewTransition(async () => { + updateFinished = true; + }).then(() => { + transitionFinished = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(updateFinished, true); + assert.equal(transitionFinished, false); + + completeCommunityViewTransition(); + await pending; + assert.equal(transitionFinished, true); +}); + +test("a newer transition releases the previous transition", async () => { + installBrowser((callback) => transitionFor(callback)); + + let firstFinished = false; + const first = runCommunityViewTransition(() => {}).then(() => { + firstFinished = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const second = runCommunityViewTransition(() => {}); + await first; + assert.equal(firstFinished, true); + + completeCommunityViewTransition(); + await second; +}); + +test("timeout releases a transition whose target never reports ready", async () => { + installBrowser((callback) => transitionFor(callback)); + + await assert.doesNotReject(() => + runCommunityViewTransition(() => {}, { timeoutMs: 1 }), + ); +}); + +test("view-transition callback rejection is contained", async () => { + installBrowser((callback) => transitionFor(callback)); + const expected = new Error("route rejected"); + const error = mock.method(console, "error", () => {}); + + await assert.doesNotReject(() => + runCommunityViewTransition(async () => { + throw expected; + }), + ); + + assert.equal(error.mock.callCount(), 1); + assert.equal(error.mock.calls[0].arguments[1], expected); +}); diff --git a/desktop/src/app/communityViewTransition.ts b/desktop/src/app/communityViewTransition.ts new file mode 100644 index 00000000000..1bbc395f9d0 --- /dev/null +++ b/desktop/src/app/communityViewTransition.ts @@ -0,0 +1,58 @@ +const COMMUNITY_TRANSITION_TIMEOUT_MS = 5_000; + +let finishPendingTransition: (() => void) | null = null; + +export function completeCommunityViewTransition(): void { + finishPendingTransition?.(); +} + +export function replaceCommunityDestinationRoute( + channelId: string, + history: { replace: (href: string) => void }, +): void { + history.replace(`/channels/${encodeURIComponent(channelId)}`); +} + +export async function runCommunityViewTransition( + update: () => Promise | void, + options: { timeoutMs?: number } = {}, +): Promise { + if (!document.startViewTransition) { + try { + await update(); + } catch (error) { + console.error("Community transition failed:", error); + } + return; + } + + let finish: (() => void) | undefined; + const targetReady = new Promise((resolve) => { + finish = resolve; + }); + finishPendingTransition?.(); + finishPendingTransition = finish ?? null; + + const timeout = window.setTimeout( + () => completeCommunityViewTransition(), + options.timeoutMs ?? COMMUNITY_TRANSITION_TIMEOUT_MS, + ); + + try { + const transition = document.startViewTransition(async () => { + await update(); + await targetReady; + }); + await transition.updateCallbackDone; + } catch (error) { + // Event handlers intentionally fire-and-forget community switches. Contain + // navigation/apply failures here so rejection cannot escape React; update() + // either leaves the current route intact or at the deliberate Home barrier. + console.error("Community transition failed:", error); + } finally { + window.clearTimeout(timeout); + if (finishPendingTransition === finish) { + finishPendingTransition = null; + } + } +} diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts new file mode 100644 index 00000000000..88acb30ad01 --- /dev/null +++ b/desktop/src/app/useCommunityNavigationTransitions.ts @@ -0,0 +1,101 @@ +import { useRouter } from "@tanstack/react-router"; +import * as React from "react"; + +import type { deriveShellRoute } from "@/app/AppShell.helpers"; +import type { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + replaceCommunityDestinationRoute, + runCommunityViewTransition, +} from "@/app/communityViewTransition"; +import { + loadCommunityDestination, + markPendingCommunityRestore, + saveCommunityDestination, +} from "@/features/communities/communityNavigationStorage"; +import type { useCommunities } from "@/features/communities/useCommunities"; + +type Communities = ReturnType; +type ShellRoute = ReturnType; +type GoHome = ReturnType["goHome"]; + +export function useCommunityNavigationTransitions({ + communities, + goHome, + selectedChannelId, + selectedView, +}: { + communities: Communities; + goHome: GoHome; + selectedChannelId: ShellRoute["selectedChannelId"]; + selectedView: ShellRoute["selectedView"]; +}) { + const router = useRouter(); + const saveActiveDestination = React.useCallback(() => { + const activeCommunityId = communities.activeCommunity?.id; + if (!activeCommunityId) return; + saveCommunityDestination( + activeCommunityId, + selectedView === "channel" && selectedChannelId + ? { kind: "channel", channelId: selectedChannelId } + : { kind: "home" }, + ); + }, [communities.activeCommunity?.id, selectedChannelId, selectedView]); + + // Home is a teardown barrier: the outgoing channel must unmount before the + // relay changes, or its read effect can advance markers on the wrong relay. + const switchCommunity = React.useCallback( + async (id: string) => { + const activeCommunityId = communities.activeCommunity?.id; + if (id === activeCommunityId) return; + if (!activeCommunityId) { + communities.switchCommunity(id); + return; + } + + await runCommunityViewTransition(async () => { + saveActiveDestination(); + await goHome({ replace: true }); + markPendingCommunityRestore(id); + const destination = loadCommunityDestination(id); + if (destination?.kind === "channel") { + replaceCommunityDestinationRoute( + destination.channelId, + router.history, + ); + } + communities.switchCommunity(id); + }); + }, + [communities, goHome, router.history, saveActiveDestination], + ); + + const removeCommunity = React.useCallback( + async (id: string) => { + if (id !== communities.activeCommunity?.id) { + communities.removeCommunity(id); + return; + } + const fallback = communities.communities.find( + (community) => community.id !== id, + ); + if (!fallback) return; + + await runCommunityViewTransition(async () => { + saveActiveDestination(); + await goHome({ replace: true }); + markPendingCommunityRestore(fallback.id); + const destination = loadCommunityDestination(fallback.id); + if (destination?.kind === "channel") { + replaceCommunityDestinationRoute( + destination.channelId, + router.history, + ); + } + communities.removeCommunity(id); + }); + }, + [communities, goHome, router.history, saveActiveDestination], + ); + + return { removeCommunity, switchCommunity }; +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index da230bdf456..dc619844875 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -79,6 +79,27 @@ with a TypeScript lookup table or an id comparison in a component. harnesses always keep the field. Gate: `defaults hides model when optional harness has empty discovery` (and the failed-discovery counterpart) in `onboarding-agent-defaults.spec.ts`. +9. **The defaults modal is progressively disclosed.** An unset global config + starts on the Buzz Agent-first deployment fallback and carries that visible + harness into the next saved edit. The `progressive-defaults` disclosure + preset therefore begins at Provider for Buzz Agent, then reveals Model, + Effort, and Advanced only after a provider is configured. Harnesses whose + runtime metadata has no provider field skip that gate. Reveals animate their + height through Motion and become immediate when reduced motion is requested. + Once the Advanced toggle is visible, its expanded state is exclusively + user-controlled: provider, harness, and required-env changes must never + open it automatically in defaults, create, or edit flows. In Create mode, + the defaults summary follows preferred-harness changes saved while the + dialog is open, and its configured state includes required credentials as + well as provider/model values. If no available harness can resolve, Create + starts in Customize and lets unavailable catalog entries be selected only + to expose their setup guidance; submission remains blocked. + Advanced-only required credentials mark the collapsed Advanced toggle + without opening it in Global Defaults and Edit, and block incomplete saves. + Runtime-file credentials satisfy Global Defaults just as they do Create and + Edit. In Edit, + selecting Custom command keeps its required command field beside the harness + picker rather than hiding it in Advanced. ## The tests that enforce this diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs index eb38da9a817..c44c2a88e0e 100644 --- a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs +++ b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs @@ -354,7 +354,7 @@ describe("load-older cursor advance logic", () => { it("test_short_page_signals_archive_exhausted", () => { // A page with fewer events than the limit signals end-of-archive. - const PAGE_SIZE = 50; + const PAGE_SIZE = 200; const page = Array.from({ length: 30 }, (_, i) => ({ created_at: 1000 - i, })); @@ -367,8 +367,8 @@ describe("load-older cursor advance logic", () => { }); it("test_full_page_signals_more_archive_available", () => { - const PAGE_SIZE = 50; - const page = Array.from({ length: 50 }, (_, i) => ({ + const PAGE_SIZE = 200; + const page = Array.from({ length: 200 }, (_, i) => ({ created_at: 1000 - i, })); const exhausted = page.length < PAGE_SIZE; @@ -394,6 +394,7 @@ describe("load-older cursor advance logic", () => { import { createArchivePagingState, applyChannelReset, + runHydrationLoop, } from "@/features/agents/ui/archivePagingState.ts"; describe("archive paging state reset on channel change", () => { @@ -410,6 +411,12 @@ describe("archive paging state reset on channel change", () => { "backfillPromise is eagerly initialized", ); assert.equal(ps.cursor, null, "cursor starts null"); + assert.equal( + ps.initialHydrationDone, + false, + "initialHydrationDone starts false", + ); + assert.equal(ps.activeChannelId, null, "activeChannelId starts null"); }); it("test_channel_switch_resets_cursor_exhaustion_and_fetch_lock", () => { @@ -419,11 +426,13 @@ describe("archive paging state reset on channel change", () => { ps.cursor = { createdAt: 1000, id: "event-a5" }; ps.hasOlderArchived = false; // channel A exhausted ps.isFetching = true; // mid-flight request (edge case) + ps.initialHydrationDone = true; // hydration ran for channel A + ps.activeChannelId = "chan-a"; ps.backfillStatus = "done"; // backfill ran once already const originalPromise = ps.backfillPromise; // must survive reset // Channel switch — this is what the useEffect([channelId]) calls. - applyChannelReset(ps); + applyChannelReset(ps, "chan-b"); assert.equal(ps.cursor, null, "cursor resets to null on channel switch"); assert.equal( @@ -436,6 +445,16 @@ describe("archive paging state reset on channel change", () => { false, "isFetching resets to false on channel switch", ); + assert.equal( + ps.initialHydrationDone, + false, + "initialHydrationDone resets to false on channel switch so the new channel hydrates", + ); + assert.equal( + ps.activeChannelId, + "chan-b", + "activeChannelId updates to new channel on switch", + ); // Backfill state must NOT be touched — it is identity-level and should // survive channel switches so the backfill only runs once per identity mount. @@ -459,10 +478,11 @@ describe("archive paging state reset on channel change", () => { it("test_multiple_channel_switches_each_start_fresh", () => { const ps = createArchivePagingState(); - // Switch to channel A: exhaust it. + // Switch to channel A: exhaust it and complete hydration. ps.cursor = { createdAt: 500, id: "a-oldest" }; ps.hasOlderArchived = false; - applyChannelReset(ps); + ps.initialHydrationDone = true; + applyChannelReset(ps, "chan-b"); assert.equal(ps.cursor, null, "switch A→B: cursor reset"); assert.equal( @@ -470,11 +490,22 @@ describe("archive paging state reset on channel change", () => { true, "switch A→B: hasOlderArchived reset", ); + assert.equal( + ps.initialHydrationDone, + false, + "switch A→B: initialHydrationDone reset", + ); + assert.equal( + ps.activeChannelId, + "chan-b", + "switch A→B: activeChannelId updated", + ); - // Simulate channel B also being paged. + // Simulate channel B also being paged and hydrated. ps.cursor = { createdAt: 200, id: "b-oldest" }; ps.hasOlderArchived = false; - applyChannelReset(ps); + ps.initialHydrationDone = true; + applyChannelReset(ps, "chan-c"); assert.equal(ps.cursor, null, "switch B→C: cursor reset again"); assert.equal( @@ -482,6 +513,164 @@ describe("archive paging state reset on channel change", () => { true, "switch B→C: hasOlderArchived reset again", ); + assert.equal( + ps.initialHydrationDone, + false, + "switch B→C: initialHydrationDone reset again", + ); + assert.equal( + ps.activeChannelId, + "chan-c", + "switch B→C: activeChannelId updated", + ); + }); +}); + +// ── Eager initial hydration loop logic ─────────────────────────────────────── +// +// The initial hydration loop in useLoadArchivedObserverEvents calls +// fetchOlderArchived up to INITIAL_HYDRATION_BUDGET_PAGES times. The loop must: +// - Stop at budget (10 pages) even if more archive exists. +// - Stop early when the archive is exhausted (ps.hasOlderArchived → false). +// - Respect channel-switch cancellation (signal.cancelled). +// +// These tests call the PRODUCTION runHydrationLoop from archivePagingState.ts +// with mock fetchOnePage functions — so they fail if the production loop logic +// is deleted or misrouted, not just if a reimplemented copy breaks. + +describe("eager initial hydration loop control flow (production runHydrationLoop)", () => { + const BUDGET = 10; // mirrors INITIAL_HYDRATION_BUDGET_PAGES + + it("test_hydration_stops_at_budget_when_archive_never_exhausted", async () => { + const ps = createArchivePagingState(); + applyChannelReset(ps, "chan-1"); + let fetchCount = 0; + const fetchOnePage = async () => { + fetchCount++; + // archive remains non-empty — ps.hasOlderArchived stays true + }; + const signal = { cancelled: false }; + await runHydrationLoop(ps, fetchOnePage, BUDGET, signal); + assert.equal( + fetchCount, + BUDGET, + `production runHydrationLoop must stop after exactly ${BUDGET} pages (budget limit)`, + ); + }); + + it("test_hydration_stops_early_when_archive_exhausted", async () => { + const ps = createArchivePagingState(); + applyChannelReset(ps, "chan-1"); + let fetchCount = 0; + const fetchOnePage = async () => { + fetchCount++; + if (fetchCount >= 3) { + ps.hasOlderArchived = false; // mock: archive exhausted on page 3 + } + }; + const signal = { cancelled: false }; + await runHydrationLoop(ps, fetchOnePage, BUDGET, signal); + assert.equal( + fetchCount, + 3, + "production runHydrationLoop must stop as soon as ps.hasOlderArchived is false (before budget)", + ); + }); + + it("test_hydration_respects_cancellation_on_channel_switch", async () => { + const ps = createArchivePagingState(); + applyChannelReset(ps, "chan-1"); + const signal = { cancelled: false }; + let fetchCount = 0; + const fetchOnePage = async () => { + fetchCount++; + if (fetchCount >= 2) { + signal.cancelled = true; // mock: channel switched mid-loop + } + }; + await runHydrationLoop(ps, fetchOnePage, BUDGET, signal); + assert.equal( + fetchCount, + 2, + "production runHydrationLoop must stop when signal.cancelled is true (channel switch)", + ); + }); + + it("test_hydration_zero_iterations_when_already_exhausted", async () => { + // If ps.hasOlderArchived is already false before the loop starts (e.g. + // channel A was exhausted and reset did not run yet), the loop must not + // call fetchOnePage at all. + const ps = createArchivePagingState(); + applyChannelReset(ps, "chan-1"); + ps.hasOlderArchived = false; // already exhausted + let fetchCount = 0; + const fetchOnePage = async () => { + fetchCount++; + }; + const signal = { cancelled: false }; + await runHydrationLoop(ps, fetchOnePage, BUDGET, signal); + assert.equal( + fetchCount, + 0, + "must not fetch when archive is already exhausted", + ); + }); + + // Regression: stale React closure — the original fetchOlderArchived captured + // hasOlderArchived from React state, which was false (exhausted) for channel A + // while ps.hasOlderArchived had already been reset to true by applyChannelReset. + // The production loop uses ps.hasOlderArchived (the ref) to guard iterations, + // so the switch-then-hydrate path must call fetchOnePage for the new channel. + it("test_exhausted_channel_A_then_switch_to_B_hydrates_B", async () => { + const ps = createArchivePagingState(); + + // Simulate exhausting channel A. + applyChannelReset(ps, "chan-a"); + ps.hasOlderArchived = false; // channel A exhausted + + // Switch to channel B — resets hasOlderArchived and activeChannelId. + applyChannelReset(ps, "chan-b"); + + // ps.hasOlderArchived is now true; the loop should call fetchOnePage. + let fetchCount = 0; + const fetchOnePage = async () => { + fetchCount++; + ps.hasOlderArchived = false; // B also exhausted after 1 page + }; + const signal = { cancelled: false }; + await runHydrationLoop(ps, fetchOnePage, BUDGET, signal); + + assert.equal( + fetchCount, + 1, + "after switch from exhausted channel A to B, runHydrationLoop must call fetchOnePage for B (not skip due to stale exhaustion)", + ); + }); + + // Regression: stale cursor write — an in-flight read from channel A should + // not write A's cursor into ps.cursor after the switch to B. The activeChannelId + // token (set by applyChannelReset) is what lets fetchOlderArchived detect and + // discard the stale result. This test verifies that applyChannelReset correctly + // advances the token so a pre-switch requestChannelId !== ps.activeChannelId. + it("test_activeChannelId_token_detects_stale_read_from_prior_channel", () => { + const ps = createArchivePagingState(); + applyChannelReset(ps, "chan-a"); + const requestChannelId = ps.activeChannelId; // captured at request start = "chan-a" + + // Simulate channel switch before the Tauri read resolves. + applyChannelReset(ps, "chan-b"); + + // The in-flight A read checks requestChannelId !== ps.activeChannelId. + assert.notEqual( + requestChannelId, + ps.activeChannelId, + "requestChannelId from channel A must not match activeChannelId after switching to B — stale read must be discarded", + ); + assert.equal( + ps.activeChannelId, + "chan-b", + "activeChannelId must reflect the current channel after switch", + ); }); }); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd681..b7e9419ecaa 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -6,6 +6,7 @@ import { getMentionableAgentPubkeys, getSharedChannelIds, isAgentIdentityInManagedList, + isAgentIdentityMentionable, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -162,6 +163,39 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent ); }); +test("isAgentIdentityMentionable: admits in-channel bots owned elsewhere", () => { + const managedAgentPubkeys = new Set([PUB_A]); + + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, isMember: true, pubkey: PUB_B, role: "bot" }, + managedAgentPubkeys, + ), + true, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, isMember: true, pubkey: PUB_B, role: "member" }, + managedAgentPubkeys, + ), + false, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: true, pubkey: PUB_B }, + managedAgentPubkeys, + ), + false, + ); + assert.equal( + isAgentIdentityMentionable( + { isAgent: false, isMember: true, pubkey: PUB_B, role: "member" }, + managedAgentPubkeys, + ), + true, + ); +}); + test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( shouldHideAgentFromMentions({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4a..894651409b9 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -64,6 +64,26 @@ export function isAgentIdentityInManagedList( ); } +/** + * Allows channel bot members into mention eligibility without granting this + * client ownership of their runtime. The later relay-policy check remains + * authoritative for whether the agent can be invoked. + */ +export function isAgentIdentityMentionable( + candidate: { + isAgent?: boolean; + isMember?: boolean; + pubkey: string; + role?: string | null; + }, + managedAgentPubkeys: ReadonlySet, +) { + return ( + isAgentIdentityInManagedList(candidate, managedAgentPubkeys) || + (candidate.isMember === true && candidate.role === "bot") + ); +} + export function shouldHideAgentFromMentions({ isAgent, isMember, diff --git a/desktop/src/features/agents/ui/AdvancedRequiredBadge.tsx b/desktop/src/features/agents/ui/AdvancedRequiredBadge.tsx new file mode 100644 index 00000000000..a85903d884f --- /dev/null +++ b/desktop/src/features/agents/ui/AdvancedRequiredBadge.tsx @@ -0,0 +1,26 @@ +import { hasMissingRequiredEnvKey } from "./personaRuntimeModel"; + +export function AdvancedRequiredBadge({ + envVars, + requiredEnvKeys, + show, + testId, +}: { + envVars?: Record; + requiredEnvKeys?: readonly string[]; + show?: boolean; + testId: string; +}) { + const visible = + show ?? hasMissingRequiredEnvKey(requiredEnvKeys ?? [], envVars ?? {}); + if (!visible) return null; + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx index 92f18a21639..67506490217 100644 --- a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx +++ b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx @@ -1,20 +1,67 @@ +import type * as React from "react"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy"; +import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; +import type { InheritedDefault } from "./bakedEnvHelpers"; export type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy"; export function HarnessModelDefaultNotice({ + harness, model, }: { + harness: string; model?: string | null; }) { return ( -
- Model{" "} - +
+
Harness
+
+ {harness || "Not configured"} +
+
Model
+
{model?.trim() || "Harness default"} - -
+ + + ); +} + +export function AgentCreateAiDefaultsSummary({ + canChooseProvider, + harness, + inheritedModel, + inheritedProvider, + isConfigured, + model, + onEditDefaults, + triggerRef, +}: { + canChooseProvider: boolean; + harness: string; + inheritedModel: InheritedDefault; + inheritedProvider: InheritedDefault; + isConfigured: boolean; + model?: string | null; + onEditDefaults: () => void; + triggerRef?: React.Ref; +}) { + return canChooseProvider ? ( + + ) : ( + ); } @@ -36,22 +83,31 @@ export function AgentAiConfigurationModeField({ } value={mode} > - - + + ); } diff --git a/desktop/src/features/agents/ui/AgentAiDefaults.tsx b/desktop/src/features/agents/ui/AgentAiDefaults.tsx index 1f77f883f79..a577113a0a1 100644 --- a/desktop/src/features/agents/ui/AgentAiDefaults.tsx +++ b/desktop/src/features/agents/ui/AgentAiDefaults.tsx @@ -26,26 +26,62 @@ export function formatAiDefaultsSummary({ } export function AgentAiDefaultsNotice({ + isConfigured = true, onEditDefaults, triggerRef, explicitModel, explicitProvider, + harness, inheritedModel, inheritedProvider, }: { + isConfigured?: boolean; onEditDefaults: () => void; triggerRef?: React.Ref; explicitModel: string; explicitProvider: string; + harness?: string; inheritedModel: InheritedDefault; inheritedProvider: InheritedDefault; }) { const provider = explicitProvider.trim() || inheritedProvider.value; const model = explicitModel.trim() || inheritedModel.value; + if (!isConfigured) { + return ( +
+

+ Global defaults not set +

+ +
+ ); + } + return (
+ {harness !== undefined ? ( + <> +
Harness
+
+ {harness || "Not configured"} +
+ + ) : null}
Provider
{provider ? providerLabel(provider) : "Not configured"} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 4e5ba176079..1bd8af89761 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -7,8 +7,12 @@ */ import * as React from "react"; import { ChevronDown } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import type { BakedEnvEntry } from "@/shared/api/tauri"; +import type { + BakedEnvEntry, + RuntimeFileConfigSubset, +} from "@/shared/api/tauri"; import type { AcpRuntimeCatalogEntry, GlobalAgentConfig, @@ -31,10 +35,10 @@ import { CUSTOM_PROVIDER_DROPDOWN_VALUE, getPersonaProviderOptions, getProviderApiKeyEnvVar, - requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, } from "@/features/agents/ui/agentConfigOptions"; import { + AgentConfigTextInput, AgentDropdownSelect, AgentModelField, } from "@/features/agents/ui/agentConfigControls"; @@ -48,10 +52,10 @@ import { EffortSelectField, useEffortAutoClear, } from "@/features/agents/ui/buzzAgentModelTuningFields"; -import { Input } from "@/shared/ui/input"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; +import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; +import { getGlobalAgentCredentialState } from "./globalAgentCredentialState"; -/** Sentinel value for an unconfigured global agent config. */ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { env_vars: {}, provider: null, @@ -66,6 +70,16 @@ const BAKED_STRUCTURED_KEYS = new Set([ BUZZ_AGENT_THINKING_EFFORT, ]); +const PROGRESSIVE_FIELDS_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + +type AgentConfigDisclosure = + | "full" + | "onboarding-essential" + | "progressive-defaults"; + // Canonical behaviors (PR 2 flag cleanup). These were per-surface props; // onboarding's values won every call and are now the only behavior: // - auto-select a valid model when the provider changes @@ -92,12 +106,13 @@ export const CANONICAL_CONFIG_BEHAVIORS = { } as const; /** - * Disclosure preset → the eight visibility decisions it owns. Effort is - * shown in both presets (onboarding never hid it; the old prop existed but - * was never flipped). Exported for the contract test. + * Disclosure preset → the eight visibility decisions it owns. Full and + * progressive defaults expose the same controls; the progressive preset + * changes only when those controls are revealed. Exported for the contract + * test. */ -export function resolveDisclosure(disclosure: "full" | "onboarding-essential") { - const full = disclosure === "full"; +export function resolveDisclosure(disclosure: AgentConfigDisclosure) { + const full = disclosure !== "onboarding-essential"; return { showAdvancedFields: full, showCustomModelOption: full, @@ -110,6 +125,22 @@ export function resolveDisclosure(disclosure: "full" | "onboarding-essential") { } as const; } +export function shouldRevealDependentConfigFields({ + disclosure, + providerFieldVisible, + providerValue, +}: { + disclosure: AgentConfigDisclosure; + providerFieldVisible: boolean; + providerValue: string; +}): boolean { + return ( + disclosure !== "progressive-defaults" || + !providerFieldVisible || + providerValue.trim().length > 0 + ); +} + /** * Determines whether the status line beneath the Model field should render. * @@ -170,6 +201,7 @@ export type AgentConfigFieldsProps = { onCustomModelEditingChange: (value: boolean) => void; onIsCustomProviderChange: (value: boolean) => void; onValidityChange?: (valid: boolean) => void; + runtimeFileConfig?: RuntimeFileConfigSubset | null; placeholderClassName?: string; selectClassName?: string; /** @@ -182,11 +214,14 @@ export type AgentConfigFieldsProps = { * valid forward choices. No advanced section, no custom escape hatches, * no descriptions (the page copy does that job), no un-choosing via * placeholder options, no greyed-out effort levels. + * - "progressive-defaults": the defaults modal's full controls, revealed in + * order. Provider appears after harness selection; model, effort, and + * Advanced appear after a provider is configured. * If a second surface wants the trimmed view, rename this value to plain * "essential" — and have the conversation about whether it should really * match onboarding. */ - disclosure?: "full" | "onboarding-essential"; + disclosure?: AgentConfigDisclosure; unstyled?: boolean; useCustomSelect?: boolean; useChevronSelectIcon?: boolean; @@ -202,6 +237,7 @@ export function AgentConfigFields({ onCustomModelEditingChange, onIsCustomProviderChange, onValidityChange, + runtimeFileConfig, placeholderClassName, selectClassName, disclosure = "full", @@ -209,6 +245,7 @@ export function AgentConfigFields({ useCustomSelect = false, useChevronSelectIcon = false, }: AgentConfigFieldsProps) { + const shouldReduceMotion = useReducedMotion(); const { showAdvancedFields, showCustomModelOption, @@ -260,9 +297,6 @@ export function AgentConfigFields({ modelIsOptional || (config.model?.trim().length ?? 0) > 0 || fallbackModel !== null; - React.useEffect(() => { - onValidityChange?.(modelIsValid); - }, [modelIsValid, onValidityChange]); const bakedEffort = React.useMemo( () => bakedEnv.find((e) => e.key === BUZZ_AGENT_THINKING_EFFORT)?.value ?? null, @@ -278,10 +312,18 @@ export function AgentConfigFields({ providerFieldVisible && !isCustomProvider ? providerValue || bakedProvider || "" : ""; + const configuredProviderValue = isCustomProvider + ? providerValue + : providerForDiscovery; const dependentFieldsDisabled = providerFieldVisible && requireProviderForModelAndEffort && - providerForDiscovery.trim().length === 0; + configuredProviderValue.trim().length === 0; + const revealDependentFields = shouldRevealDependentConfigFields({ + disclosure, + providerFieldVisible, + providerValue: configuredProviderValue, + }); const credentialProvider = providerFieldVisible && !isCustomProvider ? effectiveProvider : ""; const credentialRuntimeId = runtimeSupportsLlmProviderSelection( @@ -289,24 +331,31 @@ export function AgentConfigFields({ ) ? selectedRuntimeId : "buzz-agent"; - const requiredEnvKeys = requiredCredentialEnvKeys( - credentialRuntimeId, - credentialProvider, - ); - const apiKeyEnvVar = getProviderApiKeyEnvVar(credentialProvider); - const advancedRequiredEnvKeys = requiredEnvKeys.filter( - (key) => - key !== apiKeyEnvVar && !bakedEnv.some((entry) => entry.key === key), - ); - const apiKeyValue = apiKeyEnvVar ? (config.env_vars[apiKeyEnvVar] ?? "") : ""; const bakedEnvKeys = React.useMemo( () => bakedEnv.map((entry) => entry.key), [bakedEnv], ); - const apiKeyInherited = - apiKeyEnvVar !== null && - apiKeyValue.length === 0 && - bakedEnvKeys.includes(apiKeyEnvVar); + const { + advancedCredentialMissing, + advancedFileSatisfiedEnvKeys, + advancedRequiredEnvKeys, + apiKeyEnvVar, + apiKeyFileSatisfied, + apiKeyInherited, + apiKeyValue, + credentialsValid, + } = getGlobalAgentCredentialState({ + bakedEnvKeys, + envVars: config.env_vars, + provider: credentialProvider, + runtimeFileConfig, + runtimeId: credentialRuntimeId, + }); + const configIsValid = + selectedRuntimeId.length > 0 && modelIsValid && credentialsValid; + React.useEffect(() => { + onValidityChange?.(configIsValid); + }, [configIsValid, onValidityChange]); const { discoveredModelOptions, @@ -343,16 +392,9 @@ export function AgentConfigFields({ const healOnMount = fieldModel.dependentValuePolicy.onCatalogMismatch === "onboardingCleanup"; const userEditedProviderRef = React.useRef(false); - // Env vars live under a collapsed Advanced section (matching the create - // flow). Auto-open when a required key is missing so the field the user - // must fill is never hidden behind the toggle. + // Advanced visibility is user-controlled. Provider changes can add required + // rows, but must not open this section without an explicit toggle click. const [advancedOpen, setAdvancedOpen] = React.useState(false); - const requiredAdvancedKeyMissing = advancedRequiredEnvKeys.some( - (key) => !(config.env_vars[key] ?? "").trim(), - ); - React.useEffect(() => { - if (requiredAdvancedKeyMissing) setAdvancedOpen(true); - }, [requiredAdvancedKeyMissing]); // Read inside effects via ref so biome's exhaustive-deps stays honest: // refs are stable, and healOnMount is captured at declaration. const mayMutateDependentFieldsRef = React.useRef(false); @@ -598,9 +640,15 @@ export function AgentConfigFields({ : ""; const effortFieldVisible = showEffortField && effortField !== undefined; - const fieldClassName = unstyled ? "space-y-4" : "space-y-1.5 p-3"; + const progressiveDefaults = disclosure === "progressive-defaults"; + const fieldClassName = unstyled + ? progressiveDefaults + ? "space-y-1.5" + : "space-y-4" + : "space-y-1.5 p-3"; const blockClassName = unstyled ? "" : "p-3"; - const fieldLabelClassName = unstyled ? "pl-3" : undefined; + const fieldLabelClassName = + unstyled && !progressiveDefaults ? "pl-3" : undefined; const providerDropdownOptions = [ ...providerOptions .filter( @@ -661,44 +709,49 @@ export function AgentConfigFields({ ); - const content = ( - <> - {providerFieldVisible ? ( -
- - {!useCustomSelect && useChevronSelectIcon ? ( -
- {providerSelect} -
- ) : ( - providerSelect - )} - {isCustomProvider ? ( - handleCustomProviderInput(e.target.value)} - placeholder="Custom provider ID" - value={providerValue} - /> - ) : null} + const providerContent = providerFieldVisible ? ( +
+ + {!useCustomSelect && useChevronSelectIcon ? ( +
+ {providerSelect} +
+ ) : ( + providerSelect + )} + {isCustomProvider ? ( + handleCustomProviderInput(e.target.value)} + placeholder="Custom provider ID" + usePersonaInputStyle={progressiveDefaults} + value={providerValue} + /> ) : null} +
+ ) : null; + const dependentContent = ( + <> {providerFieldVisible && apiKeyEnvVar ? (
) : null} @@ -817,12 +871,19 @@ export function AgentConfigFields({
- {advancedOpen ? ( + {disclosure === "progressive-defaults" ? ( + + {advancedOpen ? ( + + k !== BUZZ_AGENT_THINKING_EFFORT, + ), + )} + /> + + ) : null} + + ) : advancedOpen ? ( ); + const content = ( + <> + {providerContent} + {disclosure === "progressive-defaults" ? ( + + {revealDependentFields ? ( + + {dependentContent} + + ) : null} + + ) : ( + dependentContent + )} + + ); + if (unstyled) { - return
{content}
; + return ( +
+ {content} +
+ ); } - return {content}; + return ( + + {content} + + ); } diff --git a/desktop/src/features/agents/ui/AgentDefaultsDialog.tsx b/desktop/src/features/agents/ui/AgentDefaultsDialog.tsx index 7eedae41125..0c05fe99a5e 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsDialog.tsx @@ -94,6 +94,7 @@ export function AgentDefaultsDialog({ svg]:text-muted-foreground/60", +); + export type GlobalAgentConfigSaveResult = Awaited< ReturnType >; type AgentDefaultsEditorProps = { + layout?: "flat" | "grouped"; onDirtyChange?: (dirty: boolean) => void; onSaveSuccess?: (result: GlobalAgentConfigSaveResult) => void; onSavingChange?: (saving: boolean) => void; @@ -47,11 +66,14 @@ type AgentDefaultsEditorProps = { }; export function AgentDefaultsEditor({ + layout = "grouped", onDirtyChange, onSaveSuccess, onSavingChange, secondaryAction, }: AgentDefaultsEditorProps) { + const flatLayout = layout === "flat"; + const shouldReduceMotion = useReducedMotion(); const [config, setConfig] = React.useState(EMPTY_GLOBAL_CONFIG); const configRef = React.useRef(config); @@ -117,17 +139,28 @@ export function AgentDefaultsEditor({ () => sortPersonaRuntimes(runtimesQuery.data ?? []), [runtimesQuery.data], ); - // A missing/stale preference displays the same effective fallback the backend - // would use; it is persisted only after the user edits and saves this form. - // Keep persona ordering here so this shared editor matches agent dialogs. - const selectedRuntime = React.useMemo( - () => - sortedRuntimes.find( - (runtime) => runtime.id === config.preferred_runtime, - ) ?? + // An unset preferred runtime uses the same Buzz Agent-first fallback as + // deployment. The rendered draft below carries that fallback forward so the + // next user edit persists the visible harness instead of saving null. + const selectedRuntime = React.useMemo(() => { + const configuredRuntime = sortedRuntimes.find( + (runtime) => runtime.id === config.preferred_runtime, + ); + return ( + configuredRuntime ?? getDefaultPersonaRuntime(sortedRuntimes) ?? - sortedRuntimes[0], - [config.preferred_runtime, sortedRuntimes], + sortedRuntimes[0] + ); + }, [config.preferred_runtime, sortedRuntimes]); + const renderedConfig = React.useMemo( + () => + config.preferred_runtime || !selectedRuntime + ? config + : { ...config, preferred_runtime: selectedRuntime.id }, + [config, selectedRuntime], + ); + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( + selectedRuntime?.id ?? "", ); const harnessOptions = React.useMemo( () => @@ -141,7 +174,7 @@ export function AgentDefaultsEditor({ const configSurfaceError = loadError || runtimesQuery.isError || - (!configSurfaceLoading && selectedRuntime === undefined); + (!configSurfaceLoading && sortedRuntimes.length === 0); function handleConfigChange(next: GlobalAgentConfig) { configRef.current = next; @@ -153,6 +186,7 @@ export function AgentDefaultsEditor({ function handleHarnessChange(runtimeId: string) { handleConfigChange(resetConfigForHarnessChange(config, runtimeId)); + setConfigIsValid(false); setIsCustomModelEditing(false); setIsCustomProvider(false); } @@ -202,8 +236,32 @@ export function AgentDefaultsEditor({ } } + const configFields = selectedRuntime ? ( + + ) : null; + const progressiveFieldsTransition = shouldReduceMotion + ? { duration: 0 } + : PROGRESSIVE_FIELDS_TRANSITION; + return ( -
+
{configSurfaceLoading ? (
@@ -224,26 +282,37 @@ export function AgentDefaultsEditor({ Default harness
- + {flatLayout ? ( + + {configFields ? ( + + {configFields} + + ) : null} + + ) : ( + configFields + )} )} @@ -269,7 +338,12 @@ export function AgentDefaultsEditor({
{secondaryAction} - -
+
+ +
} > @@ -836,24 +829,6 @@ export function AgentDefinitionDialog({
-
- - - {runtimeWarning} -
- {modelFieldVisible ? ( ) : null} - {llmProviderFieldVisible && aiConfigurationMode === "custom" ? ( -
- - LLM provider - {!providerIsRequired ? ( - - Optional - - ) : null} - - + {aiConfigurationMode === "custom" ? ( + - {showCustomProviderInput ? ( -
+ - + Optional + + ) : null} + + + {showCustomProviderInput ? ( +
setProvider(event.target.value)} - placeholder="Custom provider ID" - value={provider} - /> -
- ) : null} -
- ) : null} - - {llmProviderFieldVisible && - aiConfigurationMode === "custom" && - topLevelSecretEnvVar ? ( - { - setEnvVars((prev) => ({ - ...prev, - [topLevelSecretEnvVar]: next, - })); - }} - value={apiKeyValue} - /> - ) : null} + > + setProvider(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> +
+ ) : null} +
+ ) : null} - - {modelFieldVisible && aiConfigurationMode === "custom" ? ( - { + setEnvVars((prev) => ({ + ...prev, + [topLevelSecretEnvVar]: next, + })); + }} + value={apiKeyValue} /> ) : null} - - {aiConfigurationMode === "defaults" ? ( - runtimeCanChooseLlmProvider ? ( - setAiDefaultsOpen(true)} - triggerRef={aiDefaultsTriggerRef} - explicitModel="" - explicitProvider="" + + {modelFieldVisible && aiConfigurationMode === "custom" ? ( + + ) : null} + + + {aiConfigurationMode === "defaults" ? ( + setAiDefaultsOpen(true)} + triggerRef={aiDefaultsTriggerRef} /> - ) : ( - - ) - ) : null} + ) : null} +
Advanced + {localModeGate.missingEnvKeys.some((key) => + advancedRequiredEnvKeys.includes(key), + ) ? ( + + ) : null} void; + options: PersonaDropdownOption[]; + placeholder: string; + value: string; + warning?: ReactNode; +}) { + return ( +
+ + + {warning} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index c2cc5de18b4..5cd5c7a0167 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -81,6 +81,8 @@ import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; +import { resolveModelFieldStatusMessage } from "./agentConfigControls"; +import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, @@ -367,26 +369,21 @@ export function AgentInstanceEditDialog({ } = useAgentDialogDefaults({ inheritedEnvVars, open }); // Runtime/provider-required credential state, derived from the PROSPECTIVE - // post-submit runtime — see the hook for the inherit-transition and - // Advanced-auto-expand rationale. + // post-submit runtime — see the hook for the inherit-transition rationale. // Pass globalProvider so the hook uses it as a fallback when the per-agent // provider is empty (global-provider-only configs must surface required keys). // Pass globalEnvVars so keys satisfied by global config are excluded from // requiredEnvKeys and do not block Save (display and gate agree). - const { - requiredEnvKeys, - fileSatisfiedEnvKeys, - requiredEnvKeyMissing, - settled: credentialSettled, - } = useRequiredCredentialState({ - open, - prospectiveRuntimeId, - provider: inheritedSubmission.provider ?? "", - globalProvider: inheritedProviderDefault.value, - envVars: inheritedSubmission.envVars, - globalEnvVars: globalConfig.env_vars, - personaEnvVars: inheritHarness ? inheritedEnvVars : undefined, - }); + const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } = + useRequiredCredentialState({ + open, + prospectiveRuntimeId, + provider: inheritedSubmission.provider ?? "", + globalProvider: inheritedProviderDefault.value, + envVars: inheritedSubmission.envVars, + globalEnvVars: globalConfig.env_vars, + personaEnvVars: inheritHarness ? inheritedEnvVars : undefined, + }); const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); @@ -418,7 +415,7 @@ export function AgentInstanceEditDialog({ selectedRuntime, }); - // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display + auto-open. + // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display. // The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating. // D2/D3: the top-level API key owns display, while the readiness gate keeps // the complete required-key list. The effective snapshot covers persona @@ -434,12 +431,9 @@ export function AgentInstanceEditDialog({ envVars, fileSatisfiedEnvKeys, globalEnvVars: globalConfig.env_vars, - open, personaSatisfied, provider: effectiveProvider, requiredEnvKeys, - satisfactionSettled: credentialSettled, - setShowAdvancedFields, }); const { advancedRequiredEnvKeys, @@ -449,7 +443,6 @@ export function AgentInstanceEditDialog({ secretEnvVar: topLevelSecretEnvVar, value: apiKeyValue, } = apiKeyFieldState; - // Clear model when provider scope changes and current model is no longer valid. React.useEffect(() => { if ( @@ -520,17 +513,6 @@ export function AgentInstanceEditDialog({ setInheritHarness(false); } - // "Custom command" is the only selection whose command must be typed by - // the user, and that input lives inside the collapsed Advanced section. - // Auto-expand Advanced so the command field is visible — otherwise the - // user can Save without ever seeing it, leaving agentCommand equal to the - // original effective command (so the update is omitted) and the custom - // selection silently no-ops. See handleSubmit's customCommandPinned gate, - // which blocks Save when the revealed field is still empty. - if (isCustomCommand) { - setShowAdvancedFields(true); - } - // When switching to a catalog-known runtime, update the agent command to // its resolved command so the command field stays consistent. if (nextRuntime?.command) { @@ -788,6 +770,11 @@ export function AgentInstanceEditDialog({ loadingValue: MODEL_DISCOVERY_LOADING_VALUE, options: effectiveModelOptions, }); + const modelStatusMessage = resolveModelFieldStatusMessage({ + discoveredModelOptions, + loading: modelDiscoveryLoading, + status: modelDiscoveryStatus, + }); // Provider field derived state const trimmedProvider = provider.trim(); @@ -957,6 +944,35 @@ export function AgentInstanceEditDialog({

) : null}
+ {selectedRuntimeId === "custom" && !inheritHarness ? ( +
+ +
+ setAgentCommand(event.target.value)} + placeholder="Full path or shell command" + value={agentCommand} + /> +
+
+ ) : null} {/* LLM provider */} {llmProviderFieldVisible ? (
@@ -1074,15 +1090,11 @@ export function AgentInstanceEditDialog({ />
) : null} -

- {modelDiscoveryLoading - ? "Loading models..." - : modelDiscoveryStatus !== null - ? modelDiscoveryStatus.message - : discoveredModelOptions !== null - ? "Saved changes take effect on the next start." - : "Select a provider above to see available models."} -

+ {modelStatusMessage ? ( +

+ {modelStatusMessage} +

+ ) : null} Advanced + } + className="mx-auto w-full max-w-[996px]" description="Set up and manage your agents." title="Agents" /> diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index b1d79495e17..35de0d983df 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -14,7 +14,6 @@ import { isBuzzAgentRuntime } from "./buzzAgentConfig"; export function EditAgentAdvancedFields({ acpCommand, agentArgs, - agentCommand, autoRestartOnConfigChange, disabled, envVars, @@ -29,11 +28,9 @@ export function EditAgentAdvancedFields({ parallelism, provider, requiredEnvKeys, - selectedRuntimeId, systemPrompt, onAcpCommandChange, onAgentArgsChange, - onAgentCommandChange, onEnvVarsChange, onInheritHarnessChange, onParallelismChange, @@ -42,7 +39,6 @@ export function EditAgentAdvancedFields({ }: { acpCommand: string; agentArgs: string; - agentCommand: string; autoRestartOnConfigChange: boolean; disabled: boolean; envVars: EnvVarsValue; @@ -65,11 +61,9 @@ export function EditAgentAdvancedFields({ /** Active LLM provider id — forwarded to BuzzAgentModelTuningFields for effort filtering. */ provider?: string; requiredEnvKeys: readonly string[]; - selectedRuntimeId: string; systemPrompt: string; onAcpCommandChange: (value: string) => void; onAgentArgsChange: (value: string) => void; - onAgentCommandChange: (value: string) => void; onEnvVarsChange: (value: EnvVarsValue) => void; onInheritHarnessChange: (value: boolean) => void; onParallelismChange: (value: string) => void; @@ -124,37 +118,6 @@ export function EditAgentAdvancedFields({

- {/* Custom agent command (when custom runtime) */} - {selectedRuntimeId === "custom" && !inheritHarness ? ( -
- -
- onAgentCommandChange(event.target.value)} - placeholder="Full path or shell command" - value={agentCommand} - /> -
-
- ) : null} - {/* Agent runtime args */}
); diff --git a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs index 2435130f6d1..7bb3888b342 100644 --- a/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs @@ -21,6 +21,7 @@ import test from "node:test"; import { CANONICAL_CONFIG_BEHAVIORS, resolveDisclosure, + shouldRevealDependentConfigFields, shouldRenderModelControl, shouldShowModelStatusMessage, } from "./AgentConfigFields.tsx"; @@ -60,6 +61,48 @@ test("onboarding-essential hides power tools but never the effort field", () => }); }); +test("progressive defaults keep full disclosure", () => { + assert.deepEqual( + resolveDisclosure("progressive-defaults"), + resolveDisclosure("full"), + ); +}); + +test("progressive defaults wait for a provider only when the harness needs one", () => { + assert.equal( + shouldRevealDependentConfigFields({ + disclosure: "progressive-defaults", + providerFieldVisible: true, + providerValue: "", + }), + false, + ); + assert.equal( + shouldRevealDependentConfigFields({ + disclosure: "progressive-defaults", + providerFieldVisible: true, + providerValue: "anthropic", + }), + true, + ); + assert.equal( + shouldRevealDependentConfigFields({ + disclosure: "progressive-defaults", + providerFieldVisible: false, + providerValue: "", + }), + true, + ); + assert.equal( + shouldRevealDependentConfigFields({ + disclosure: "full", + providerFieldVisible: true, + providerValue: "", + }), + true, + ); +}); + // ── shouldShowModelStatusMessage ────────────────────────────────────────────── // The onboarding-essential preset sets showDescriptions=false. Discovery // warnings must bypass the preset so first-run failures are never invisible. diff --git a/desktop/src/features/agents/ui/archivePagingState.ts b/desktop/src/features/agents/ui/archivePagingState.ts index 95574d31151..eaaec752908 100644 --- a/desktop/src/features/agents/ui/archivePagingState.ts +++ b/desktop/src/features/agents/ui/archivePagingState.ts @@ -28,6 +28,20 @@ export interface ArchivePagingState { * Mirrors SQL ORDER BY created_at DESC, id DESC so same-second siblings are * never skipped at a page boundary. */ cursor: { createdAt: number; id: string } | null; + /** True once the initial eager-hydration pass for the current channel has + * completed (budget reached or archive exhausted). Resets on channel change + * so switching channels triggers a fresh hydration pass. */ + initialHydrationDone: boolean; + /** The channelId that this paging state is currently scoped to. + * Kept for diagnostics only; NOT used as a generation token (see + * resetGeneration). Channel equality is not a unique request identifier: + * A→B→A makes old-A channel checks pass again. */ + activeChannelId: string | null; + /** Monotonically increasing counter incremented by applyChannelReset. + * Each fetch snapshots this value at request start and checks it again + * after every async boundary — a mismatch means a channel switch occurred + * mid-flight (even A→B→A), and results are discarded. */ + resetGeneration: number; } /** @@ -43,6 +57,9 @@ export function createArchivePagingState(): ArchivePagingState { backfillPromise: null, backfillResolve: null, cursor: null, + initialHydrationDone: false, + activeChannelId: null, + resetGeneration: 0, }; state.backfillPromise = new Promise((resolve) => { state.backfillResolve = resolve; @@ -53,15 +70,57 @@ export function createArchivePagingState(): ArchivePagingState { /** * Reset per-channel paging state when the viewed channel changes. * - * Only cursor, exhaustion flag, and fetch lock are channel-scoped. Backfill - * state is identity-level (the index covers ALL channels and needs to run only - * once per identity mount), so it is intentionally NOT touched here. + * Only cursor, exhaustion flag, fetch lock, channel label, and generation token + * are channel-scoped. Backfill state is identity-level (the index covers ALL + * channels and needs to run only once per identity mount), so it is + * intentionally NOT touched here. + * + * `resetGeneration` is incremented on every call. In-flight fetches snapshot + * the generation at start and recheck it after every async boundary — a + * mismatch (including A→B→A) means the request is stale, so results are + * discarded. Channel ID is retained for diagnostics only; it does NOT serve + * as the generation token. * * Called by the useEffect([channelId]) in useLoadArchivedObserverEvents. * Exported so tests can verify the reset semantics without a React runtime. */ -export function applyChannelReset(state: ArchivePagingState): void { +export function applyChannelReset( + state: ArchivePagingState, + newChannelId: string | null, +): void { state.cursor = null; state.isFetching = false; state.hasOlderArchived = true; + state.initialHydrationDone = false; + state.activeChannelId = newChannelId; + state.resetGeneration += 1; +} + +/** + * Run the eager initial-hydration paging loop. + * + * Calls `fetchOnePage()` up to `budget` times. Stops early when: + * - `ps.hasOlderArchived` is false (archive exhausted for this channel), OR + * - `signal.cancelled` is true (channel switched away mid-loop). + * + * `fetchOnePage` is the per-page read unit: it must respect `ps.isFetching` + * (lock), await backfill, perform the Tauri read, ingest results, advance + * the cursor, and set `ps.hasOlderArchived = false` when the page is short. + * The hook wires the real implementation; tests supply a mock. + * + * Exported so tests can call the production loop logic directly — passing a + * mock `fetchOnePage` — without reimplementing the control flow. + */ +export async function runHydrationLoop( + ps: ArchivePagingState, + fetchOnePage: () => Promise, + budget: number, + signal: { cancelled: boolean }, +): Promise { + for (let page = 0; page < budget; page++) { + if (signal.cancelled || !ps.hasOlderArchived) { + break; + } + await fetchOnePage(); + } } diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 8b1003e9511..6b797db1cf1 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -542,86 +542,6 @@ test("editAgent_resolveAgentCommandUpdate_inheritSentinelOnlyWhenPinToClear", () ); }); -test("editAgent_customCommandSelected_autoExpandsAdvancedSection", () => { - // Selecting "Custom command" must reveal the Advanced command input, which is - // otherwise collapsed. Without this the user can Save without ever seeing the - // field, leaving agentCommand equal to the original effective command (so the - // update is omitted) and the custom selection silently no-ops. - let showAdvancedFields = false; // starts collapsed on open - - const NO_RUNTIME_DROPDOWN_VALUE = "__none__"; - const nextValue = "custom"; - const nextRuntimeId = - nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue; - const resolvedRuntimeId = nextRuntimeId || "custom"; - const isCustomCommand = resolvedRuntimeId === "custom"; - - // Mirror the handler's auto-expand branch. - if (isCustomCommand) { - showAdvancedFields = true; - } - - assert.equal( - showAdvancedFields, - true, - "selecting 'Custom command' must auto-expand Advanced so the command input is visible", - ); -}); - -test("editAgent_missingRequiredEnvKey_autoExpandsAdvancedOnTransition", () => { - // Codex P2: when a provider change makes a credential newly required, the - // EnvVarsEditor lives inside the collapsed Advanced section, so the amber - // required row would stay unmounted (invisible) while Save is disabled. The - // effect auto-expands Advanced on the missing→present-requirement transition. - let showAdvancedFields = false; // collapsed by default on open - let previousMissing = false; - - // Mirror the effect's transition guard. - function applyMissingEffect(requiredEnvKeyMissing) { - if (requiredEnvKeyMissing && !previousMissing) { - showAdvancedFields = true; - } - previousMissing = requiredEnvKeyMissing; - } - - // Initial render: buzz-agent with no provider — nothing required yet. - applyMissingEffect( - hasMissingRequiredEnvKey(requiredCredentialEnvKeys("buzz-agent", ""), {}), - ); - assert.equal( - showAdvancedFields, - false, - "Advanced stays collapsed while no credential is required", - ); - - // User picks anthropic → ANTHROPIC_API_KEY becomes required and is unset. - applyMissingEffect( - hasMissingRequiredEnvKey( - requiredCredentialEnvKeys("buzz-agent", "anthropic"), - {}, - ), - ); - assert.equal( - showAdvancedFields, - true, - "Advanced auto-expands when a required credential is newly missing", - ); - - // User fills the key, then collapses Advanced manually — no re-expand. - showAdvancedFields = false; - applyMissingEffect( - hasMissingRequiredEnvKey( - requiredCredentialEnvKeys("buzz-agent", "anthropic"), - { ANTHROPIC_API_KEY: "sk-ant-test" }, - ), - ); - assert.equal( - showAdvancedFields, - false, - "Advanced does not re-expand once the required credential is filled", - ); -}); - test("editAgent_missingRequiredEnvKey_blocksSaveViaValidity", () => { // The block-save gate is folded into computeEditAgentFormValidity so the // Save button disables when a runtime/provider-required credential is unset. diff --git a/desktop/src/features/agents/ui/globalAgentCredentialState.test.mjs b/desktop/src/features/agents/ui/globalAgentCredentialState.test.mjs new file mode 100644 index 00000000000..99a03749578 --- /dev/null +++ b/desktop/src/features/agents/ui/globalAgentCredentialState.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getGlobalAgentCredentialState } from "./globalAgentCredentialState.ts"; + +test("global defaults accept an advanced credential set in runtime config", () => { + const state = getGlobalAgentCredentialState({ + bakedEnvKeys: [], + envVars: {}, + provider: "databricks_v2", + runtimeFileConfig: { + provider: "databricks_v2", + model: "goose-claude-4-6-opus", + satisfiedEnvKeys: ["DATABRICKS_HOST"], + }, + runtimeId: "goose", + }); + + assert.equal(state.advancedCredentialMissing, false); + assert.equal(state.credentialsValid, true); + assert.deepEqual(state.advancedRequiredEnvKeys, []); + assert.deepEqual(state.advancedFileSatisfiedEnvKeys, ["DATABRICKS_HOST"]); +}); + +test("an explicit empty global value shadows the runtime config", () => { + const state = getGlobalAgentCredentialState({ + bakedEnvKeys: [], + envVars: { DATABRICKS_HOST: "" }, + provider: "databricks_v2", + runtimeFileConfig: { + provider: "databricks_v2", + model: "goose-claude-4-6-opus", + satisfiedEnvKeys: ["DATABRICKS_HOST"], + }, + runtimeId: "goose", + }); + + assert.equal(state.advancedCredentialMissing, true); + assert.equal(state.credentialsValid, false); + assert.deepEqual(state.advancedRequiredEnvKeys, ["DATABRICKS_HOST"]); + assert.deepEqual(state.advancedFileSatisfiedEnvKeys, []); +}); + +test("global defaults accept a provider key set in runtime config", () => { + const state = getGlobalAgentCredentialState({ + bakedEnvKeys: [], + envVars: {}, + provider: "openai", + runtimeFileConfig: { + provider: "openai", + model: "gpt-5.5", + satisfiedEnvKeys: ["OPENAI_COMPAT_API_KEY"], + }, + runtimeId: "goose", + }); + + assert.equal(state.apiKeyFileSatisfied, true); + assert.equal(state.apiKeyInherited, true); + assert.equal(state.credentialsValid, true); +}); diff --git a/desktop/src/features/agents/ui/globalAgentCredentialState.ts b/desktop/src/features/agents/ui/globalAgentCredentialState.ts new file mode 100644 index 00000000000..a8bc25e076d --- /dev/null +++ b/desktop/src/features/agents/ui/globalAgentCredentialState.ts @@ -0,0 +1,70 @@ +import type { RuntimeFileConfigSubset } from "@/shared/api/tauri"; +import { + getBakedSatisfiedEnvKeys, + getProviderApiKeyEnvVar, + requiredCredentialEnvKeys, +} from "@/features/agents/ui/agentConfigOptions"; + +export function getGlobalAgentCredentialState({ + bakedEnvKeys, + envVars, + provider, + runtimeFileConfig, + runtimeId, +}: { + bakedEnvKeys: readonly string[]; + envVars: Record; + provider: string; + runtimeFileConfig: RuntimeFileConfigSubset | null | undefined; + runtimeId: string; +}) { + const requiredEnvKeys = requiredCredentialEnvKeys(runtimeId, provider); + const apiKeyEnvVar = getProviderApiKeyEnvVar(provider); + const bakedSatisfiedEnvKeys = getBakedSatisfiedEnvKeys( + requiredEnvKeys, + envVars, + bakedEnvKeys, + ); + const fileSatisfiedEnvKeys = requiredEnvKeys.filter( + (key) => + !(key in envVars) && + !bakedSatisfiedEnvKeys.includes(key) && + (runtimeFileConfig?.satisfiedEnvKeys.includes(key) ?? false), + ); + const displayedRequiredEnvKeys = requiredEnvKeys.filter( + (key) => + !bakedSatisfiedEnvKeys.includes(key) && + !fileSatisfiedEnvKeys.includes(key), + ); + const advancedRequiredEnvKeys = displayedRequiredEnvKeys.filter( + (key) => key !== apiKeyEnvVar, + ); + const advancedFileSatisfiedEnvKeys = fileSatisfiedEnvKeys.filter( + (key) => key !== apiKeyEnvVar, + ); + const apiKeyValue = apiKeyEnvVar ? (envVars[apiKeyEnvVar] ?? "") : ""; + const apiKeyFileSatisfied = + apiKeyEnvVar !== null && fileSatisfiedEnvKeys.includes(apiKeyEnvVar); + const apiKeyInherited = + apiKeyEnvVar !== null && + apiKeyValue.length === 0 && + (bakedSatisfiedEnvKeys.includes(apiKeyEnvVar) || apiKeyFileSatisfied); + const advancedCredentialMissing = advancedRequiredEnvKeys.some( + (key) => (envVars[key] ?? "").trim().length === 0, + ); + const apiKeyMissing = + apiKeyEnvVar !== null && + !apiKeyInherited && + apiKeyValue.trim().length === 0; + + return { + advancedCredentialMissing, + advancedFileSatisfiedEnvKeys, + advancedRequiredEnvKeys, + apiKeyEnvVar, + apiKeyFileSatisfied, + apiKeyInherited, + apiKeyValue, + credentialsValid: !advancedCredentialMissing && !apiKeyMissing, + }; +} diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index d8da4108c96..d138296b5c2 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -100,7 +100,7 @@ export function resolveAgentCommandUpdate(input: { * config contribute no entries, so this never blocks on out-of-band auth. */ export function hasMissingRequiredEnvKey( - requiredEnvKeys: string[], + requiredEnvKeys: readonly string[], envVars: Record, ): boolean { return requiredEnvKeys.some((key) => (envVars[key] ?? "").length === 0); diff --git a/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs b/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs deleted file mode 100644 index 4984a1d5541..00000000000 --- a/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs +++ /dev/null @@ -1,160 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { personaSubmitBlock } from "./personaSubmitBlock.ts"; - -/** A fully valid, submittable form: personaSubmitBlock returns null. */ -function submittable(overrides = {}) { - return { - isPending: false, - isAvatarUploadPending: false, - displayNameEmpty: false, - isCreateMode: true, - runtimeChosen: true, - runtimeAvailable: true, - createBackendBlocked: false, - allowlistEmpty: false, - aiConfigurationMode: "defaults", - localModeSatisfied: true, - localModeMissingFields: [], - localModeMissingEnvKeys: [], - customAiPairSatisfied: true, - runtimeNeedsProviderSelection: true, - customProviderEmpty: false, - customModelEmpty: false, - ...overrides, - }; -} - -test("a valid form has no disabled reason", () => { - assert.equal(personaSubmitBlock(submittable()), null); -}); - -test("missing name is reported first", () => { - assert.equal( - personaSubmitBlock(submittable({ displayNameEmpty: true })), - "Enter a name for this agent.", - ); -}); - -test("Buzz Agent + Use AI defaults with no global provider/model names the fix", () => { - const reason = personaSubmitBlock( - submittable({ - aiConfigurationMode: "defaults", - localModeSatisfied: false, - localModeMissingFields: ["provider", "model"], - }), - ); - assert.match(reason, /global AI defaults are incomplete/); - assert.match(reason, /a provider and a model/); - assert.match(reason, /Settings → AI defaults/); -}); - -test("incomplete defaults also names missing credential keys", () => { - const reason = personaSubmitBlock( - submittable({ - localModeSatisfied: false, - localModeMissingFields: [], - localModeMissingEnvKeys: ["ANTHROPIC_API_KEY"], - }), - ); - assert.match(reason, /a value for ANTHROPIC_API_KEY/); -}); - -test("the reason disappears once the blocking input is corrected", () => { - const blocked = submittable({ - localModeSatisfied: false, - localModeMissingFields: ["provider", "model"], - }); - assert.notEqual(personaSubmitBlock(blocked), null); - // Correct the blocking input: defaults now resolve. - const corrected = { - ...blocked, - localModeSatisfied: true, - localModeMissingFields: [], - }; - assert.equal(personaSubmitBlock(corrected), null); -}); - -test("create mode requires a chosen, available runtime", () => { - assert.equal( - personaSubmitBlock(submittable({ runtimeChosen: false })), - "Choose where this agent runs.", - ); - assert.equal( - personaSubmitBlock(submittable({ runtimeAvailable: false })), - "The selected runtime isn't available on this machine.", - ); -}); - -test("runtime gates do not apply in edit mode", () => { - assert.equal( - personaSubmitBlock( - submittable({ isCreateMode: false, runtimeChosen: false }), - ), - null, - ); -}); - -test("empty allowlist is reported (create and edit)", () => { - const reason = personaSubmitBlock( - submittable({ isCreateMode: false, allowlistEmpty: true }), - ); - assert.match(reason, /allowed sender/); -}); - -test("Customize with an empty pair but satisfied global fallback points at the pair", () => { - const reason = personaSubmitBlock( - submittable({ - aiConfigurationMode: "custom", - localModeSatisfied: true, - customAiPairSatisfied: false, - customProviderEmpty: true, - customModelEmpty: true, - }), - ); - assert.match(reason, /Select a provider and a model/); - assert.match(reason, /Use AI defaults/); -}); - -test("Customize on Codex/Claude asks only for a model, never a provider", () => { - const reason = personaSubmitBlock( - submittable({ - aiConfigurationMode: "custom", - customAiPairSatisfied: false, - runtimeNeedsProviderSelection: false, - customProviderEmpty: true, - customModelEmpty: true, - }), - ); - assert.match(reason, /Select a model/); - assert.doesNotMatch(reason, /provider/); - assert.match(reason, /Use harness defaults/); - assert.doesNotMatch(reason, /Use AI defaults/); -}); - -test("precedence: a missing name outranks incomplete AI defaults", () => { - assert.equal( - personaSubmitBlock( - submittable({ - displayNameEmpty: true, - localModeSatisfied: false, - localModeMissingFields: ["provider", "model"], - }), - ), - "Enter a name for this agent.", - ); -}); - -test("in-flight save/upload shows no reason (the button label communicates it)", () => { - assert.equal( - personaSubmitBlock( - submittable({ isPending: true, displayNameEmpty: true }), - ), - null, - ); - assert.equal( - personaSubmitBlock(submittable({ isAvatarUploadPending: true })), - null, - ); -}); diff --git a/desktop/src/features/agents/ui/personaSubmitBlock.ts b/desktop/src/features/agents/ui/personaSubmitBlock.ts deleted file mode 100644 index 8ee7d9f4289..00000000000 --- a/desktop/src/features/agents/ui/personaSubmitBlock.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy"; - -/** - * Inputs for {@link personaSubmitBlock}. Every field is an OUTPUT of a gate the - * dialog already computes for `canSubmit` — this module maps those outputs to a - * single human-readable reason. It must not recompute policy: the derivation - * stays a pure function of the gate results so the message can never disagree - * with whether the button is actually disabled. - */ -export type PersonaSubmitBlockInput = { - /** A save/create request is in flight (button shows "Saving..."). */ - isPending: boolean; - /** The avatar upload is in flight (button shows "Uploading..."). */ - isAvatarUploadPending: boolean; - /** Trimmed display name is empty. */ - displayNameEmpty: boolean; - /** Create (new definition) vs edit (existing). Some gates are create-only. */ - isCreateMode: boolean; - /** A runtime has been chosen (create-only gate). */ - runtimeChosen: boolean; - /** The chosen runtime is available on this machine (create-only gate). */ - runtimeAvailable: boolean; - /** The remote / where-to-run backend selection is incomplete (create-only). */ - createBackendBlocked: boolean; - /** Respond-to allowlist mode is selected but the allowlist is empty. */ - allowlistEmpty: boolean; - /** Selected AI configuration mode: inherit global defaults vs customize. */ - aiConfigurationMode: AgentAiConfigurationMode; - /** `computeLocalModeGate(...).satisfied` — resolved AI config is complete. */ - localModeSatisfied: boolean; - /** `computeLocalModeGate(...).missingNormalizedFields`, e.g. ["provider"]. */ - localModeMissingFields: readonly string[]; - /** `computeLocalModeGate(...).missingEnvKeys` — required credentials unset. */ - localModeMissingEnvKeys: readonly string[]; - /** `agentAiConfigurationModeSatisfied(...)` for the Customize pair. */ - customAiPairSatisfied: boolean; - /** Runtime exposes a provider picker (Buzz Agent / Goose), not Codex/Claude. */ - runtimeNeedsProviderSelection: boolean; - /** Customize provider field is empty. */ - customProviderEmpty: boolean; - /** Customize model field is empty. */ - customModelEmpty: boolean; -}; - -function joinWithAnd(parts: readonly string[]): string { - if (parts.length <= 1) return parts[0] ?? ""; - if (parts.length === 2) return `${parts[0]} and ${parts[1]}`; - return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`; -} - -/** - * Describe the concrete missing pieces behind an unsatisfied AI-config gate, - * naming the actual fix rather than a generic "configuration incomplete". - */ -function describeMissingAiPieces( - fields: readonly string[], - envKeys: readonly string[], -): string { - const parts: string[] = []; - if (fields.includes("provider")) parts.push("a provider"); - if (fields.includes("model")) parts.push("a model"); - for (const key of envKeys) parts.push(`a value for ${key}`); - return joinWithAnd(parts); -} - -/** - * Human-readable reason the Create/Save button is disabled, or `null` when the - * form can be submitted. Precedence mirrors the `canSubmit` term order in - * AgentDefinitionDialog so the surfaced reason is deterministic and always the - * first blocking input — correcting it makes the reason advance or disappear. - * - * While a request or avatar upload is in flight the button communicates the - * progress itself ("Saving..." / "Uploading..."), so no reason is returned. - */ -export function personaSubmitBlock( - input: PersonaSubmitBlockInput, -): string | null { - if (input.isPending || input.isAvatarUploadPending) { - return null; - } - - // 1. Required definition fields. - if (input.displayNameEmpty) { - return "Enter a name for this agent."; - } - - // 2–4. Create-only runtime / backend gates. - if (input.isCreateMode) { - if (!input.runtimeChosen) { - return "Choose where this agent runs."; - } - if (!input.runtimeAvailable) { - return "The selected runtime isn't available on this machine."; - } - if (input.createBackendBlocked) { - return "Finish configuring the remote backend before creating this agent."; - } - } - - // 5. Access / allowlist crash-loop guard (create and edit). - if (input.allowlistEmpty) { - return "Add at least one allowed sender, or change who this agent responds to."; - } - - // 6. Resolved AI configuration (provider/model/credentials) incomplete. - if (!input.localModeSatisfied) { - const missing = describeMissingAiPieces( - input.localModeMissingFields, - input.localModeMissingEnvKeys, - ); - if (input.aiConfigurationMode === "defaults") { - const detail = missing ? ` — missing ${missing}` : ""; - return `Your global AI defaults are incomplete${detail}. Set them in Settings → AI defaults, or choose Customize to configure this agent directly.`; - } - return missing - ? `This agent's AI configuration is missing ${missing}.` - : "Complete this agent's AI configuration."; - } - - // 7. Customize pair incomplete (form provider/model empty while a global - // fallback keeps localMode satisfied). Provider only counts where the runtime - // exposes a picker — Codex/Claude drive their own provider. - if (!input.customAiPairSatisfied) { - const needProvider = - input.runtimeNeedsProviderSelection && input.customProviderEmpty; - const pieces: string[] = []; - if (needProvider) pieces.push("a provider"); - if (input.customModelEmpty) pieces.push("a model"); - const what = - pieces.length > 0 ? joinWithAnd(pieces) : "the AI configuration"; - const defaultsLabel = input.runtimeNeedsProviderSelection - ? "Use AI defaults" - : "Use harness defaults"; - return `Select ${what} for this agent, or switch to ${defaultsLabel}.`; - } - - return null; -} diff --git a/desktop/src/features/agents/ui/providerApiKeyFieldState.ts b/desktop/src/features/agents/ui/providerApiKeyFieldState.ts index 13ed31101a6..0aabc3d798a 100644 --- a/desktop/src/features/agents/ui/providerApiKeyFieldState.ts +++ b/desktop/src/features/agents/ui/providerApiKeyFieldState.ts @@ -105,26 +105,20 @@ export function useProviderApiKeyFieldState({ envVars, fileSatisfiedEnvKeys, globalEnvVars, - open, personaSatisfied, provider, requiredEnvKeys, - satisfactionSettled = true, - setShowAdvancedFields, }: { bakedEnvKeys: readonly string[] | undefined; effectiveEnvVars: EnvVarsValue; envVars: EnvVarsValue; fileSatisfiedEnvKeys?: readonly string[]; globalEnvVars: EnvVarsValue; - open: boolean; personaSatisfied?: boolean; provider: string; requiredEnvKeys: readonly string[]; - satisfactionSettled?: boolean; - setShowAdvancedFields: React.Dispatch>; }): ProviderApiKeyFieldState { - const fieldState = React.useMemo( + return React.useMemo( () => getProviderApiKeyFieldState({ bakedEnvKeys, @@ -147,26 +141,4 @@ export function useProviderApiKeyFieldState({ requiredEnvKeys, ], ); - const hasAutoOpenedAdvancedRef = React.useRef(false); - React.useEffect(() => { - if (!open) { - hasAutoOpenedAdvancedRef.current = false; - return; - } - if ( - satisfactionSettled && - fieldState.advancedRequiredEnvKeys.length > 0 && - !hasAutoOpenedAdvancedRef.current - ) { - hasAutoOpenedAdvancedRef.current = true; - setShowAdvancedFields(true); - } - }, [ - fieldState.advancedRequiredEnvKeys.length, - open, - satisfactionSettled, - setShowAdvancedFields, - ]); - - return fieldState; } diff --git a/desktop/src/features/agents/ui/useLoadArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ui/useLoadArchivedObserverEvents.test.mjs new file mode 100644 index 00000000000..c3ca1f678b0 --- /dev/null +++ b/desktop/src/features/agents/ui/useLoadArchivedObserverEvents.test.mjs @@ -0,0 +1,936 @@ +/** + * Mounted-hook lifecycle and race regression tests for + * useLoadArchivedObserverEvents. + * + * These tests mount the REAL production hook (including its useEffect wiring, + * fetchOlderArchived closure, runHydrationLoop call, and resetGeneration token + * checks) against a mocked Tauri IPC bridge and a real QueryClientProvider. + * They fail if any of the following is removed from the production hook: + * - the hydration effect + * - the resetGeneration checks (post-backfill, post-Tauri-read, post-ingest) + * - the generation-aware isFetching clear in finally + * - the post-backfill isFetching recheck before lock acquisition + * + * Four regressions: + * (a) exhausted-A → switch-to-B: B must read from a null cursor and ingest + * its rows. GREEN at dfb2d0385 (stale-closure was fixed in round 1). + * Fails at the pre-round-1 head where ps.hasOlderArchived was read from + * React state, not the ref. + * (b) deferred-I/O race (A→B): A is in flight (decrypt deferred), switch to B, + * resolve A's 1-row short ingest — A must NOT mark B exhausted or steal B's + * fetch lock, and B's eager loop must continue past page 1. Fails at + * dfb2d0385 (post-ingest token missing). Lock theft is asserted by calling + * fetchOlderArchived concurrently while B holds the lock: with correct + * protection the concurrent call is rejected (read count doesn't jump); + * without it, A's stale finally clears the lock and the concurrent call + * would start a duplicate read. + * (c) concurrent fetches during pending backfill: two callers both suspend on + * the backfill promise, both resume after it resolves — only one must + * acquire the lock and issue the Tauri read. Fails at 4c92a018d (no + * post-backfill isFetching recheck). + * (d) A→B→A: old-A's in-flight decrypt completes after the user returns to A. + * Old-A's generation no longer matches (each reset increments the counter), + * so it must not mark fresh-A exhausted or steal fresh-A's lock. Fails + * when resetGeneration is replaced with a channel-string equality check. + * + * ── DOM shim ───────────────────────────────────────────────────────────────── + * react-dom/client requires a minimal DOM; node has none. We install the same + * minimal shim used by MessageComposerDraftImagePersist.test.mjs. + * + * ── Tauri IPC mock ─────────────────────────────────────────────────────────── + * @tauri-apps/api/core calls window.__TAURI_INTERNALS__.invoke(cmd, args). + * We install a per-test mock at globalThis.__TAURI_INTERNALS__.invoke so every + * listSaveSubscriptions / readArchivedObserverEventsForChannel / readUnindexed / + * indexObserverChannelId call is intercepted by command name without patching + * module internals. + */ + +import assert from "node:assert/strict"; +import { describe, it, beforeEach } from "node:test"; + +// ── Minimal DOM shim (matches MessageComposerDraftImagePersist.test.mjs) ────── + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children[this.children.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((c) => c !== child); + this.childNodes = this.childNodes.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.children.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + this.children.splice(i, 0, newNode); + this.childNodes.splice(i, 0, newNode); + newNode.parentNode = this; + return newNode; + } + contains(node) { + if (!node) return false; + return this === node || this.children.some((c) => c?.contains?.(node)); + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + const n = new MinimalNode("#text"); + n.nodeValue = value; + n.nodeType = 3; + return n; + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeValue = value; + n.nodeType = 8; + return n; + } + get body() { + if (!this._body) this._body = this.createElement("body"); + return this._body; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + } + + globalThis.document = new MinimalDocument(); + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.HTMLElement = MinimalNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); +} + +installDOMShim(); + +// ── Tauri IPC interceptor ───────────────────────────────────────────────────── +// +// @tauri-apps/api/core calls window.__TAURI_INTERNALS__.invoke(cmd, args). +// Install a stub now (before any module that imports tauriArchive is loaded) +// so listSaveSubscriptions, readArchivedObserverEventsForChannel, etc. can be +// controlled per-test by replacing ipcHandlers. + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +globalThis.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: (_cb) => { + const id = Math.random(); + return id; + }, +}; + +function setIpcHandler(cmd, fn) { + ipcHandlers.set(cmd, fn); +} +function clearIpcHandlers() { + ipcHandlers.clear(); +} + +// ── Production imports (after shim, after IPC stub) ─────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverEvents.ts"; +import { + resetAgentObserverStore, + _testRegisterKnownAgents, + _testGetArchivedChannelEvents, +} from "@/features/agents/observerRelayStore.ts"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const AGENT_PUBKEY = "a".repeat(64); +const IDENTITY_PUBKEY = "c".repeat(64); +const SUB_ID = "test-hook-sub"; + +// ── Tauri wire-shape helpers ────────────────────────────────────────────────── + +/** Returns a list_save_subscriptions response with one owner_p subscription. */ +function makeOwnerPSubResponse() { + return [ + { + identity_pubkey: IDENTITY_PUBKEY, + relay_url: "wss://test", + scope_type: "owner_p", + scope_value: IDENTITY_PUBKEY, + kinds: "[24200]", + created_at: 1000, + }, + ]; +} + +/** Returns a raw archived observer event row for readArchivedObserverEventsForChannel. */ +function makeArchivedRow(seq, channelId = "chan-1") { + return { + id: `ev${String(seq).padStart(63, "0")}`, + pubkey: AGENT_PUBKEY, + created_at: 1000 + seq, + kind: 24200, + tags: [ + ["p", IDENTITY_PUBKEY], + ["agent", AGENT_PUBKEY], + ["frame", "telemetry"], + ], + content: JSON.stringify({ + seq, + timestamp: new Date(1_000_000 + seq * 1000).toISOString(), + channelId, + kind: "telemetry", + sessionId: "sess-1", + turnId: "turn-1", + payload: { method: "session/update", params: {} }, + }), + sig: "s".repeat(128), + }; +} + +// ── React mounting helpers ──────────────────────────────────────────────────── + +/** + * Mount useLoadArchivedObserverEvents in a real React tree with a QueryClient + * pre-seeded with the identity. Returns { unmount, render(channelId), + * getFetchOlderArchived() }. + * + * getFetchOlderArchived() returns the latest fetchOlderArchived function from + * the hook's return value, captured on each render. Tests can call it directly + * to probe lock behaviour without going through the hydration loop. + */ +function mountHook(_initialChannelId, queryClient) { + // Capture the latest hook return values so tests can call fetchOlderArchived. + const hookReturnRef = { current: null }; + + function HarnessComponent({ channelId }) { + const result = useLoadArchivedObserverEvents(true, channelId); + hookReturnRef.current = result; + return null; + } + + const container = document.createElement("div"); + const root = createRoot(container); + + const render = async (channelId) => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(HarnessComponent, { channelId }), + ), + ); + }); + }; + + return { + render, + getFetchOlderArchived: () => hookReturnRef.current?.fetchOlderArchived, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +/** Make a QueryClient pre-seeded with identity so useIdentityQuery resolves. */ +function makeQueryClient() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(["identity"], { pubkey: IDENTITY_PUBKEY }); + return qc; +} + +// ── Settle helper ───────────────────────────────────────────────────────────── +// +// Flushes microtasks + a few macrotask ticks so async effects can settle. +// Uses act() so React commits state updates from effects. + +async function settle(iterations = 3) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 5)); + }); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("useLoadArchivedObserverEvents — mounted hook lifecycle regressions", () => { + beforeEach(() => { + resetAgentObserverStore(); + clearIpcHandlers(); + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + }); + + /** + * Regression (a): exhausted channel A → switch to channel B. + * + * The original stale-closure bug (pre-round-1): fetchOlderArchived captured + * hasOlderArchived from React state (false for exhausted A). After the switch, + * React state was still false while ps.hasOlderArchived had been reset to true. + * The hydration loop called fetchOlderArchived up to 10 times, each returning + * immediately at the !ps.hasOlderArchived guard — B never got a read. + * + * After the fix (reading ps.hasOlderArchived from the ref), B gets at least + * one read from a null cursor and its rows are ingested. + * + * PROVENANCE: this test is GREEN at dfb2d0385 (the stale-closure was already + * fixed in round 1). It would be red at the pre-round-1 head (884ed9ba2) + * where hasOlderArchived was captured from React state in the closure. + */ + it("test_exhausted_channel_A_switch_to_B_hook_reads_B_from_null_cursor", async () => { + // Channel A: 1 page of 1 row (short page → exhausted immediately). + const aRows = [makeArchivedRow(1, "chan-a")]; + // Channel B: 1 page of 1 row. + const bRows = [makeArchivedRow(2, "chan-b")]; + + const aCalls = []; + const bCalls = []; + + setIpcHandler("list_save_subscriptions", async () => + makeOwnerPSubResponse(), + ); + setIpcHandler("read_unindexed_observer_rows", async () => []); + setIpcHandler("index_observer_channel_id", async () => null); + setIpcHandler("read_archived_observer_events_for_channel", async (args) => { + if (args.channelId === "chan-a") { + aCalls.push({ cursor: args.beforeCreatedAt ?? null }); + return aRows.map((r) => JSON.stringify(r)); + } + if (args.channelId === "chan-b") { + bCalls.push({ cursor: args.beforeCreatedAt ?? null }); + return bRows.map((r) => JSON.stringify(r)); + } + return []; + }); + // decrypt_observer_event is called inside ingestArchivedObserverEvents. + // invokeTauri passes { eventJson: JSON.stringify(rawRelayEvent) }. + // The row.content is the JSON-encoded ObserverEvent — return it parsed. + setIpcHandler("decrypt_observer_event", async (args) => { + try { + const event = JSON.parse(args.eventJson); + return JSON.parse(event.content); + } catch { + return { kind: "telemetry", channelId: null }; + } + }); + + const qc = makeQueryClient(); + const { render, unmount } = mountHook("chan-a", qc); + + // Mount on chan-a and let hydration settle. + await render("chan-a"); + await settle(10); + + // A must have been read (at least one call, from null cursor). + assert.ok(aCalls.length >= 1, `expected A reads, got ${aCalls.length}`); + assert.equal(aCalls[0].cursor, null, "A first read must use null cursor"); + + // Switch to chan-b. + await render("chan-b"); + await settle(10); + + // B must have been read from a null cursor (fresh channel, no inherited cursor). + assert.ok(bCalls.length >= 1, `expected B reads, got ${bCalls.length}`); + assert.equal( + bCalls[0].cursor, + null, + "B first read must use null cursor (not A's cursor)", + ); + + // B's rows must have been ingested into the archive store. + const bArchived = _testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-b"); + assert.ok( + bArchived.length >= 1, + `B's rows must be ingested — found ${bArchived.length} (exhausted-A stale closure bug would leave this 0)`, + ); + + await unmount(); + }); + + /** + * Regression (b): deferred-I/O race — A→B: A's stale writes after ingest AND + * lock theft via stale finally. + * + * Two protections are under test independently: + * + * 1. Post-ingest exhaustion write (post-ingest token check): + * The bug at dfb2d0385: fetchOlderArchived for A checked the token BEFORE + * ingestArchivedObserverEvents but NOT after. A's deferred decrypt resumed + * after the channel switch, wrote ps.hasOlderArchived=false (short-page + * exhaustion) for B's paging state, and B's eager loop stopped after 1 page. + * Removing the post-ingest token check causes bCallCount==1. + * + * 2. Lock theft via generation-gated finally: + * If the generation guard in finally is removed, stale A's finally runs + * ps.isFetching=false while B holds the lock. We probe this directly: + * after resolving stale A (while B's first read is in flight), we call + * fetchOlderArchived() on B ourselves. With the correct guard, B holds + * the lock and the concurrent call returns immediately (bCallCount stays + * at 1 for now). Without it (stale A stole the lock), the concurrent call + * acquires the lock and starts an extra Tauri read (bCallCount jumps to 2 + * prematurely, with concurrent in-flight reads — the assertion catches this + * because it fires BEFORE B's deferred first read resolves). + * + * Precise race sequence: + * 1. Mount on chan-a. A's Tauri read returns 1 row. Cursor set. Ingest starts. + * 2. A's decrypt is DEFERRED (aDecryptDeferred). + * 3. Switch to channel B. + * 4. B's hydration loop starts. B's first Tauri read is ALSO DEFERRED. + * 5. Resolve A's decrypt → A finishes ingest, hits post-ingest check. + * - At dfb2d0385 (no post-ingest check): writes ps.hasOlderArchived=false. + * Also, if finally is unguarded, ps.isFetching=false (lock stolen). + * - After fix: both writes discarded (generation mismatch). + * 6. LOCK PROBE (while B's first read is still deferred): + * call fetchOlderArchived() directly. Must return without starting a new + * Tauri read (B holds the lock; bCallCount must still be 1). + * 7. Resolve B's first Tauri read → B ingests 200 rows. + * 8. B loop continues: bCallCount >= 2. + * + * VERIFIED: removing the post-ingest token check causes bCallCount<2 (step 8). + * Removing just the finally generation guard causes bCallCount>=2 but the lock + * probe at step 6 catches the theft: bCallCount jumps to 2 before B's deferred + * first read resolves (duplicate concurrent read started while B is in flight). + * + * RED at dfb2d0385 (bCallCount==1). GREEN at current head. + */ + it("test_deferred_A_ingest_cannot_exhaust_B_or_steal_B_lock", async () => { + let resolveADecrypt; + const aDecryptDeferred = new Promise((resolve) => { + resolveADecrypt = resolve; + }); + + let resolveBFirstRead; + const bFirstReadDeferred = new Promise((resolve) => { + resolveBFirstRead = resolve; + }); + + const PAGE_SIZE = 200; + const makeBPage = (offset) => + Array.from({ length: PAGE_SIZE }, (_, i) => + makeArchivedRow(offset + i, "chan-b"), + ); + + let bCallCount = 0; + let aDecryptStarted = false; + let bFirstReadHeld = false; + + setIpcHandler("list_save_subscriptions", async () => + makeOwnerPSubResponse(), + ); + setIpcHandler("read_unindexed_observer_rows", async () => []); + setIpcHandler("index_observer_channel_id", async () => null); + setIpcHandler("read_archived_observer_events_for_channel", async (args) => { + if (args.channelId === "chan-a") { + return [JSON.stringify(makeArchivedRow(1, "chan-a"))]; // 1 row = short page + } + if (args.channelId === "chan-b") { + bCallCount++; + if (bCallCount === 1 && !bFirstReadHeld) { + // Defer B's first Tauri read until we explicitly release it. + bFirstReadHeld = true; + await bFirstReadDeferred; + return makeBPage(100).map((r) => JSON.stringify(r)); // full page + } + if (bCallCount <= 4) + return makeBPage(bCallCount * 100).map((r) => JSON.stringify(r)); + return [JSON.stringify(makeArchivedRow(9999, "chan-b"))]; // short = exhaust + } + return []; + }); + setIpcHandler("decrypt_observer_event", async (args) => { + try { + const event = JSON.parse(args.eventJson); + const parsed = JSON.parse(event.content); + if (parsed.channelId === "chan-a" && !aDecryptStarted) { + aDecryptStarted = true; + await aDecryptDeferred; // block A's decrypt + } + return parsed; + } catch { + return { kind: "telemetry", channelId: null }; + } + }); + + const qc = makeQueryClient(); + const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc); + + // Step 1-2: Mount on chan-a. A's Tauri read completes (1 row), cursor set, + // ingest starts and blocks at A's decrypt. + await render("chan-a"); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + + // Step 3: Switch to chan-b while A's decrypt/ingest is blocked. + await render("chan-b"); + + // Step 4: B calls its first Tauri read and blocks. + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + + // B's first read must have been attempted (bCallCount >= 1). + assert.ok( + bCallCount >= 1, + `B must have started its first read before the lock probe — got bCallCount=${bCallCount}`, + ); + const bCallCountBeforeAResolve = bCallCount; + + // Step 5: Resolve A's decrypt. At dfb2d0385 this writes + // ps.hasOlderArchived=false (if no post-ingest check) and/or clears + // ps.isFetching (if finally is unguarded). + resolveADecrypt(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + // Step 6: LOCK PROBE — B still holds the lock (B's first read is deferred). + // Call fetchOlderArchived directly. With correct protection, B's lock is + // intact and this call returns immediately without a Tauri read — bCallCount + // must NOT increase. If A stole the lock, this call acquires it and starts + // a new Tauri read before B's deferred first read resolves (bCallCount jumps). + const fetchFn = getFetchOlderArchived(); + if (fetchFn) { + await act(async () => { + await fetchFn(); + }); + } + + assert.equal( + bCallCount, + bCallCountBeforeAResolve, + `Lock probe must not start a new B read while B holds the lock (bCallCount=${bCallCount}, expected ${bCallCountBeforeAResolve}). If A's stale finally cleared the lock, this concurrent call would start a duplicate read.`, + ); + + // Step 7: Now resolve B's first Tauri read. + resolveBFirstRead(); + + // Let B's loop run. + await settle(10); + + // Step 8: B must have made at least 2 Tauri reads. + // At dfb2d0385: A corrupted ps.hasOlderArchived=false before B's first page + // resolved, so after B's first page, the loop checks and exits. bCallCount==1. + // After fix: A's write was discarded, ps.hasOlderArchived is still true, + // B continues to page 2+. + assert.ok( + bCallCount >= 2, + `B must read at least 2 pages — got ${bCallCount}. Post-ingest token missing at dfb2d0385 let A corrupt B's exhaustion state (bCallCount==1).`, + ); + + // A's row must NOT appear in B's channel archive. + const bArchived = _testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-b"); + for (const evt of bArchived) { + assert.equal( + evt.channelId, + "chan-b", + "B's archive must only contain B-channel events", + ); + } + + await unmount(); + }); + + /** + * Regression (c): concurrent fetches during pending backfill — only one + * same-generation call may acquire the lock after backfill resolves. + * + * The gap at 4c92a018d: `ps.isFetching` was checked only at the top of + * fetchOlderArchived, BEFORE `await ps.backfillPromise`. Two callers + * (eager hydration loop + a concurrent scroll trigger) could both observe + * isFetching=false, both suspend on the same pending backfill promise, then + * both resume and proceed past the post-backfill guard (which only checked + * generation + exhaustion). Both would set isFetching=true and issue + * readArchivedObserverEventsForChannel from the SAME cursor — duplicating + * the first page. + * + * Fix: after `await ps.backfillPromise`, recheck `ps.isFetching` immediately + * before acquiring the lock. The second caller finds the lock already taken + * and returns without reading. + * + * The test defers the ARCHIVE response (not just backfill) so we can count + * reads while the winner's first response is still in flight. If both + * callers acquired the lock, archiveCallCount will be 2 before the first + * deferred response is released. With the fix, archiveCallCount is 1. + * + * Precise race sequence: + * 1. Mount on chan-a. Backfill is DEFERRED (backfillDeferred). + * Archive responses are also deferred until resolveArchive() is called. + * 2. Hydration loop call #1 suspends on backfill. + * 3. Inject manual call #2 — it also sees isFetching=false and suspends on + * backfill. + * 4. Resolve backfill. Both calls resume and race to acquire the lock. + * - Without fix: both pass the post-backfill guard, both set + * isFetching=true, both issue the Tauri read — archiveCallCount == 2. + * - With fix: one passes, sets isFetching=true; the other sees the lock + * taken and returns — archiveCallCount == 1. + * 5. Assert archiveCallCount == 1 before releasing archive response. + * (Archive is still deferred, so loop hasn't advanced past page 1 yet — + * any count > 1 is purely from the concurrent race, not loop progress.) + * + * VERIFIED: removing the post-backfill `ps.isFetching` recheck causes + * archiveCallCount == 2 at step 5. GREEN at current head. + */ + it("test_two_concurrent_fetches_during_backfill_only_one_proceeds", async () => { + let resolveBackfill; + const backfillDeferred = new Promise((resolve) => { + resolveBackfill = resolve; + }); + + // Defer ALL archive responses until we release them. This way, if two + // callers both acquire the lock, archiveCallCount jumps to 2 before we + // release the response — and we can catch it unambiguously. + let resolveArchive; + const archiveDeferred = new Promise((resolve) => { + resolveArchive = resolve; + }); + + let archiveCallCount = 0; + + setIpcHandler("list_save_subscriptions", async () => + makeOwnerPSubResponse(), + ); + // Defer readUnindexedObserverRows to simulate a pending backfill. + setIpcHandler("read_unindexed_observer_rows", async () => { + await backfillDeferred; + return []; + }); + setIpcHandler("index_observer_channel_id", async () => null); + setIpcHandler("read_archived_observer_events_for_channel", async (args) => { + if (args.channelId === "chan-a") { + archiveCallCount++; + // Hold this response until we explicitly release it so we can + // count concurrent reads before any result is returned. + await archiveDeferred; + return Array.from({ length: 200 }, (_, i) => + JSON.stringify(makeArchivedRow(i, "chan-a")), + ); + } + return []; + }); + setIpcHandler("decrypt_observer_event", async (args) => { + try { + const event = JSON.parse(args.eventJson); + return JSON.parse(event.content); + } catch { + return { kind: "telemetry", channelId: null }; + } + }); + + const qc = makeQueryClient(); + const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc); + + // Step 1-2: Mount on chan-a. Backfill is in flight (deferred). + // Hydration loop call #1 enters fetchOlderArchived and suspends on backfill. + await render("chan-a"); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + + // Step 3: Inject call #2 while backfill is still pending and call #1 is + // suspended. Both observe isFetching=false here. + const fetchFn = getFetchOlderArchived(); + let call2Promise; + if (fetchFn) { + // Do NOT await yet — let it run concurrently with call #1. + call2Promise = fetchFn(); + } + + // Let call #2 reach its backfill await before we resolve backfill. + await act(async () => { + await new Promise((r) => setTimeout(r, 5)); + }); + + // Step 4: Resolve backfill. Both calls resume and race to acquire lock. + resolveBackfill(); + + // Yield to let both calls advance past the post-backfill guard and issue + // their Tauri reads (or be blocked by the lock recheck). + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + // Step 5: Archive responses are still deferred — loop cannot have advanced + // past page 1. Any archiveCallCount > 1 here is purely from concurrent + // reads racing through the backfill await without a lock recheck. + assert.equal( + archiveCallCount, + 1, + `Exactly one archive read must start after backfill resolves — got ${archiveCallCount}. Without the post-backfill isFetching recheck, both concurrent callers acquire the lock and both issue a Tauri read (archiveCallCount == 2).`, + ); + + // Release archive responses so the lock holder can finish and the test + // can unmount cleanly. + resolveArchive(); + + // Wait for call #2 to settle as well. + if (call2Promise) { + await act(async () => { + await call2Promise; + }); + } + + await settle(5); + await unmount(); + }); + + /** + * Regression (d): A→B→A — old-A's stale in-flight request must not corrupt + * fresh-A's paging state when the user returns to channel A. + * + * The gap in the channel-string equality check (af45fbf05): using + * `requestChannelId === ps.activeChannelId` as the ownership test fails when + * the user navigates A→B→A. Old-A's request sees `ps.activeChannelId === "A"` + * after the second return to A — so every check passes. Old-A can mark fresh-A + * exhausted and its finally releases fresh-A's lock. + * + * With resetGeneration each applyChannelReset() call increments the counter, + * so A(gen=1) → B(gen=2) → A(gen=3): old-A snapshotted gen=1, which never + * equals gen=3, so all writes are discarded regardless of channel name. + * + * Race sequence: + * 1. Mount on chan-a (gen=1). A's Tauri read = 1 row (short page). Ingest + * starts. A's decrypt is DEFERRED. + * 2. Switch to chan-b (gen=2). B's paging starts. + * 3. Switch BACK to chan-a (gen=3). Fresh-A's hydration starts. Fresh-A's + * first Tauri read is DEFERRED (freshAReadDeferred). + * 4. Resolve old-A's decrypt. Old-A hits short-page branch. + * - Without generation: old-A sees ps.activeChannelId==="chan-a", writes + * ps.hasOlderArchived=false and clears ps.isFetching. + * - With generation: gen=1 !== gen=3, writes discarded. + * 5. LOCK PROBE: call fetchOlderArchived directly. Fresh-A holds the lock + * (its deferred read is in flight). With correct protection the probe + * returns immediately (freshACallCount unchanged). Without it (old-A's + * finally stole the lock), the probe starts a duplicate read. + * 6. Resolve fresh-A's first read (full page). Fresh-A loop continues. + * 7. Assert freshACallCount >= 2 (fresh-A ran past page 1). + * + * VERIFIED: this test is RED when activeChannelId string equality replaces + * resetGeneration (old-A passes every check, marks fresh-A exhausted at step 4). + * GREEN at current head. + */ + it("test_A_B_A_old_request_cannot_corrupt_fresh_A_state", async () => { + let resolveOldADecrypt; + const oldADecryptDeferred = new Promise((resolve) => { + resolveOldADecrypt = resolve; + }); + + let resolveFreshAFirstRead; + const freshAFirstReadDeferred = new Promise((resolve) => { + resolveFreshAFirstRead = resolve; + }); + + const PAGE_SIZE = 200; + const makePage = (channelId, offset) => + Array.from({ length: PAGE_SIZE }, (_, i) => + makeArchivedRow(offset + i, channelId), + ); + + let oldADecryptStarted = false; + // Track calls per channel / phase. We only care about chan-a reads on fresh-A. + let freshACallCount = 0; + // After we switch back to A (gen=3), track reads for that phase. + let onFreshA = false; + + setIpcHandler("list_save_subscriptions", async () => + makeOwnerPSubResponse(), + ); + setIpcHandler("read_unindexed_observer_rows", async () => []); + setIpcHandler("index_observer_channel_id", async () => null); + setIpcHandler("read_archived_observer_events_for_channel", async (args) => { + if (args.channelId === "chan-a") { + if (!onFreshA) { + // Old-A's read: 1 row = short page. + return [JSON.stringify(makeArchivedRow(1, "chan-a"))]; + } + // Fresh-A's reads. + freshACallCount++; + if (freshACallCount === 1) { + // Defer fresh-A's first read. + await freshAFirstReadDeferred; + return makePage("chan-a", 200).map((r) => JSON.stringify(r)); // full + } + if (freshACallCount <= 4) + return makePage("chan-a", freshACallCount * 200).map((r) => + JSON.stringify(r), + ); + return [JSON.stringify(makeArchivedRow(9999, "chan-a"))]; // exhaust + } + if (args.channelId === "chan-b") { + // B gets one short page (we don't care about B's progress here). + return [JSON.stringify(makeArchivedRow(50, "chan-b"))]; + } + return []; + }); + setIpcHandler("decrypt_observer_event", async (args) => { + try { + const event = JSON.parse(args.eventJson); + const parsed = JSON.parse(event.content); + if (parsed.channelId === "chan-a" && !oldADecryptStarted && !onFreshA) { + oldADecryptStarted = true; + await oldADecryptDeferred; // block OLD A's decrypt + } + return parsed; + } catch { + return { kind: "telemetry", channelId: null }; + } + }); + + const qc = makeQueryClient(); + const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc); + + // Step 1: Mount on chan-a (gen=1). Old-A's Tauri read returns 1 row. + // Ingest starts, decrypt blocks. + await render("chan-a"); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + + // Step 2: Switch to chan-b (gen=2). + await render("chan-b"); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + // Step 3: Switch back to chan-a (gen=3). Fresh-A hydration starts. + onFreshA = true; + await render("chan-a"); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + + // Fresh-A must have started its first (deferred) read. + assert.ok( + freshACallCount >= 1, + `fresh-A must have started its first read — got freshACallCount=${freshACallCount}`, + ); + const freshACountBeforeOldResolve = freshACallCount; + + // Step 4: Resolve old-A's decrypt. Without generation check, old-A's + // post-ingest branch writes ps.hasOlderArchived=false (marks fresh-A + // exhausted) and its finally clears ps.isFetching (steals fresh-A's lock). + resolveOldADecrypt(); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + // Step 5: LOCK PROBE — fresh-A holds the lock (first read deferred). + // A concurrent fetchOlderArchived call must be rejected (lock held). + // If old-A stole the lock, this probe starts a duplicate read (freshACallCount + // jumps before the deferred first read resolves). + const fetchFn = getFetchOlderArchived(); + if (fetchFn) { + await act(async () => { + await fetchFn(); + }); + } + + assert.equal( + freshACallCount, + freshACountBeforeOldResolve, + `Lock probe must not start a new fresh-A read while fresh-A holds the lock (freshACallCount=${freshACallCount}, expected ${freshACountBeforeOldResolve}). Old-A's stale finally stole the lock (A→B→A channel-string equality bug).`, + ); + + // Step 6: Resolve fresh-A's first read (full page). Loop continues. + resolveFreshAFirstRead(); + await settle(10); + + // Step 7: Fresh-A must have made at least 2 reads (loop continued past page 1). + // Without generation check, old-A wrote ps.hasOlderArchived=false before + // fresh-A's first page resolved, causing the loop to exit — freshACallCount==1. + assert.ok( + freshACallCount >= 2, + `fresh-A must read at least 2 pages — got ${freshACallCount}. Old-A's stale writes (A→B→A) would leave freshACallCount==1.`, + ); + + await unmount(); + }); +}); diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 8f4ecc10320..0c44a64d5f2 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -21,6 +21,7 @@ import type { RelayEvent } from "@/shared/api/types"; import { createArchivePagingState, applyChannelReset, + runHydrationLoop, } from "./archivePagingState"; export type { ArchivePagingState } from "./archivePagingState"; @@ -88,7 +89,12 @@ export function useArchivedChannelEvents( return React.useSyncExternalStore(subscribeToStore, getSnapshot); } -const ARCHIVED_EVENTS_PAGE_SIZE = 50; +const ARCHIVED_EVENTS_PAGE_SIZE = 200; + +// Number of pages to load eagerly on panel open (before any scroll). Each page +// is ARCHIVED_EVENTS_PAGE_SIZE frames; 10 pages = 2000 frames, which covers +// agent turns that emit hundreds of frames (e.g. a full code-review turn ~900). +const INITIAL_HYDRATION_BUDGET_PAGES = 10; /** * Load-older-on-scroll for archived observer frames, scoped to a single channel. @@ -138,10 +144,13 @@ export function useLoadArchivedObserverEvents( // Reset per-channel paging state when channelId changes. Backfill state is // identity-level (not per-channel) and must NOT be reset here — the backfill // index covers all channels and only needs to run once per identity mount. - // Only the cursor, exhaustion flag, and fetching lock are channel-scoped. + // Only the cursor, exhaustion flag, fetching lock, channel label, and + // resetGeneration are channel-scoped. resetGeneration is incremented by + // applyChannelReset so in-flight reads from any prior reset (including + // A→B→A) detect staleness and discard their results. // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the intentional reset key; ps is a stable ref excluded from deps by convention; setHasOlderArchived is a stable React state setter React.useEffect(() => { - applyChannelReset(ps); + applyChannelReset(ps, channelId); setHasOlderArchived(true); }, [channelId]); @@ -259,7 +268,7 @@ export function useLoadArchivedObserverEvents( ps.backfillPromise = promise; }, [enabled, hasSubscription]); - // biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; ps.isFetching/ps.cursor/ps.backfillPromise/ps.hasOlderArchived are read via the stable ref object, not reactive values + // biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; all per-page state (isFetching, cursor, hasOlderArchived, resetGeneration) is read from the ref rather than React state, so this callback is intentionally stable across exhaustion/channel changes const fetchOlderArchived = React.useCallback(async () => { if ( !enabled || @@ -267,11 +276,18 @@ export function useLoadArchivedObserverEvents( !hasSubscription || !channelId || ps.isFetching || - !hasOlderArchived + !ps.hasOlderArchived ) { return; } + // Snapshot the reset generation at the start of this request. Every + // shared-state write below rechecks requestGeneration === ps.resetGeneration + // first. A mismatch means at least one channel switch occurred while we + // were awaiting async I/O — even A→B→A is detected because each switch + // increments resetGeneration. Channel ID is kept only as the query input. + const requestGeneration = ps.resetGeneration; + // Await backfill completion before reading the channel index. This // guarantees the index is populated before the first paginated read, so // a scroll-trigger that fires before backfill writes can't return 0 rows @@ -280,12 +296,21 @@ export function useLoadArchivedObserverEvents( await ps.backfillPromise; } - // Re-check after awaiting: hasOlderArchived might have been set false - // while we were waiting (e.g. subscription check failed). - if (!hasOlderArchived) { + // Re-check after awaiting: generation may have advanced (channel switched), + // archive exhausted, or another concurrent caller may have acquired the + // fetch lock while we were suspended on backfill. All three must be + // re-evaluated because any of them could have changed mid-await. + if ( + !ps.hasOlderArchived || + requestGeneration !== ps.resetGeneration || + ps.isFetching + ) { return; } + // Acquire the fetch lock under this request's generation. The finally + // block only releases the lock if the generation still matches — so a stale + // in-flight request cannot clear the lock that belongs to a later reset. ps.isFetching = true; try { const before = ps.cursor ?? undefined; @@ -294,6 +319,13 @@ export function useLoadArchivedObserverEvents( limit: ARCHIVED_EVENTS_PAGE_SIZE, }); + // Discard result if the generation advanced while the Tauri read was in + // flight (channel switch, including A→B→A). The new channel will start + // its own read with a null cursor. + if (requestGeneration !== ps.resetGeneration) { + return; + } + if (events.length > 0) { // Cursor = the last row in newest-first order = the oldest event on // this page. Capture both created_at and id to mirror the compound @@ -306,6 +338,14 @@ export function useLoadArchivedObserverEvents( await ingestArchivedObserverEvents(events); } + // Re-check generation after ingestArchivedObserverEvents: ingestion + // decrypts each frame asynchronously and may take time. If a channel + // switch occurred during that await (including A→B→A), discard all + // remaining shared-state writes — exhaustion and React mirror. + if (requestGeneration !== ps.resetGeneration) { + return; + } + // A short page means the archive is exhausted for this channel. if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) { setHasOlderArchived(false); @@ -314,9 +354,56 @@ export function useLoadArchivedObserverEvents( } catch (error) { console.error("[useLoadArchivedObserverEvents] fetch failed:", error); } finally { - ps.isFetching = false; + // Only release the fetch lock if this request still owns it. If the + // generation advanced (any channel switch including A→B→A), the new + // channel acquired its own lock — releasing here would steal it. + if (requestGeneration === ps.resetGeneration) { + ps.isFetching = false; + } + } + }, [enabled, identityPubkey, hasSubscription, channelId]); + + // Eager initial hydration: on panel open (or channel switch), load archive + // pages automatically until the budget is reached or the channel is exhausted. + // This makes archived history visible immediately without any scrolling. + // + // Runs when: enabled + subscription confirmed + channelId resolved + + // hydration not yet done for this channel. Respects `applyChannelReset` + // (which resets initialHydrationDone) so channel switches trigger a fresh + // pass. Uses fetchOlderArchived's existing lock/cursor/backfill-await + // machinery — no parallel state machine. + // + // fetchOlderArchived is now stable (it no longer captures hasOlderArchived + // from React state — it reads ps.hasOlderArchived from the ref), so it is + // safe to call from this effect without coupling the hydration lifecycle to + // React state identity changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; initialHydrationDone is read from ps (not as a reactive dep) to avoid triggering re-runs; fetchOlderArchived is stable and intentionally omitted + React.useEffect(() => { + if ( + !enabled || + !identityPubkey || + !hasSubscription || + !channelId || + ps.initialHydrationDone + ) { + return; } - }, [enabled, identityPubkey, hasSubscription, channelId, hasOlderArchived]); + + // Mark done immediately to prevent concurrent hydration loops. The loop + // runs asynchronously; the signal object handles mid-loop cancellation on + // channel switch (the cleanup fn sets signal.cancelled = true). + ps.initialHydrationDone = true; + const signal = { cancelled: false }; + void runHydrationLoop( + ps, + fetchOlderArchived, + INITIAL_HYDRATION_BUDGET_PAGES, + signal, + ); + return () => { + signal.cancelled = true; + }; + }, [enabled, identityPubkey, hasSubscription, channelId]); return { fetchOlderArchived, hasOlderArchived }; } diff --git a/desktop/src/features/agents/ui/useRequiredCredentialState.ts b/desktop/src/features/agents/ui/useRequiredCredentialState.ts index c996e159ca3..44c03e812af 100644 --- a/desktop/src/features/agents/ui/useRequiredCredentialState.ts +++ b/desktop/src/features/agents/ui/useRequiredCredentialState.ts @@ -21,8 +21,6 @@ export interface RequiredCredentialState { fileSatisfiedEnvKeys: string[]; /** Whether any required env key is still missing (blocks Save). */ requiredEnvKeyMissing: boolean; - /** True once all async satisfaction sources (baked, file config) have resolved. */ - settled: boolean; } /** @@ -35,7 +33,8 @@ export interface RequiredCredentialState { * actually be saved. * * The caller owns the visibility policy: top-level API-key fields are already - * visible, while non-secret Advanced-only keys can open that section. + * visible, while non-secret keys remain available under user-controlled + * Advanced disclosure. * * `globalProvider` is used as the fallback when the per-agent provider is * empty — without it, a global-provider-only config produces no required keys @@ -78,13 +77,12 @@ export function useRequiredCredentialState(params: { ? provider.trim() || globalProvider.trim() : ""; - const { data: runtimeFileConfig, isLoading: fileConfigLoading } = - useRuntimeFileConfigQuery(prospectiveRuntimeId, { enabled: open }); - - const { data: bakedEnvKeys, isLoading: bakedLoading } = - useBakedBuildEnvKeysQuery({ enabled: open }); + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( + prospectiveRuntimeId, + { enabled: open }, + ); - const settled = !fileConfigLoading && !bakedLoading; + const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); // All required keys for this runtime + provider combination. const allRequiredKeys = React.useMemo( @@ -137,6 +135,5 @@ export function useRequiredCredentialState(params: { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing, - settled, }; } diff --git a/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs b/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs new file mode 100644 index 00000000000..6f30d7ec6de --- /dev/null +++ b/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + requestFocusedThreadClose, + subscribeToFocusedThreadCloseRequest, +} from "./focusedThreadCloseRequest.ts"; + +test("focus thread close requests reach active subscribers only", () => { + let calls = 0; + const unsubscribe = subscribeToFocusedThreadCloseRequest(() => { + calls += 1; + }); + + requestFocusedThreadClose(); + assert.equal(calls, 1); + + unsubscribe(); + requestFocusedThreadClose(); + assert.equal(calls, 1); +}); diff --git a/desktop/src/features/channels/focusedThreadCloseRequest.ts b/desktop/src/features/channels/focusedThreadCloseRequest.ts new file mode 100644 index 00000000000..3628d707676 --- /dev/null +++ b/desktop/src/features/channels/focusedThreadCloseRequest.ts @@ -0,0 +1,16 @@ +const listeners = new Set<() => void>(); + +/** Request dismissal of an open focus-mode thread drawer. */ +export function requestFocusedThreadClose(): void { + for (const listener of listeners) { + listener(); + } +} + +/** Subscribe the active channel surface to focus-mode dismissal requests. */ +export function subscribeToFocusedThreadCloseRequest( + listener: () => void, +): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 121e2dbb1c8..9003d0f5a58 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -348,13 +348,10 @@ export function useUpdateChannelMutation(channelId: string | null) { return updateChannel({ ...input, channelId }); }, + onMutate: () => ({ channelId }), onSuccess: (updatedChannel) => { - if (!channelId) { - return; - } - queryClient.setQueryData( - channelDetailQueryKey(channelId), + channelDetailQueryKey(updatedChannel.id), updatedChannel, ); queryClient.setQueryData(channelsQueryKey, (current = []) => @@ -365,7 +362,7 @@ export function useUpdateChannelMutation(channelId: string | null) { ), ); }, - onSettled: () => { + onSettled: (_data, _error, _variables, context) => { // refetchType "none": onSuccess already cached the relay-returned detail; // awaiting the full channel-list refetch kept the edit dialog stuck on // "Saving..." (same failure #1360 fixed for create). @@ -373,9 +370,9 @@ export function useUpdateChannelMutation(channelId: string | null) { queryKey: channelsQueryKey, refetchType: "none", }); - if (channelId) { + if (context?.channelId) { void queryClient.invalidateQueries({ - queryKey: channelDetailQueryKey(channelId), + queryKey: channelDetailQueryKey(context.channelId), refetchType: "none", }); } diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 57c1f6c1403..3c1727e00a0 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -27,8 +27,6 @@ import { useDeleteChannelMutation, useJoinChannelMutation, useLeaveChannelMutation, - useSetChannelPurposeMutation, - useSetChannelTopicMutation, useUnarchiveChannelMutation, useUpdateChannelMutation, } from "@/features/channels/hooks"; @@ -36,7 +34,6 @@ import { compareMembersByRole } from "@/features/channels/lib/memberUtils"; import { DEFAULT_EPHEMERAL_TTL_SECONDS, formatTtlDuration, - parseTtlDuration, } from "@/features/channels/lib/ephemeralChannel"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -45,7 +42,6 @@ import { Button } from "@/shared/ui/button"; import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; @@ -68,6 +64,12 @@ import { PANEL_OVERLAY_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; import { ChannelCanvas } from "./ChannelCanvas"; +import { + CHANNEL_FORM_FIELD_CONTROL_CLASS, + CHANNEL_FORM_FIELD_SHELL_CLASS, +} from "./channelFormStyles"; +import { ChannelTypeSettings } from "./ChannelTypeSettings"; +import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings"; import { ChannelHero, ChannelQuickAction, @@ -78,7 +80,6 @@ import { IngressRow, NarrativeField, NarrativeGroup, - ToggleRow, } from "./ChannelManagementSheetRows"; import { ChannelManagementModerationActions, @@ -118,13 +119,13 @@ export function ChannelManagementSheet({ const membersQuery = useChannelMembersQuery(channelId, open); const canvasQuery = useCanvasQuery(channelId, channelId !== null && open); const updateChannelDetailsMutation = useUpdateChannelMutation(channelId); - const setTopicMutation = useSetChannelTopicMutation(channelId); - const setPurposeMutation = useSetChannelPurposeMutation(channelId); const archiveChannelMutation = useArchiveChannelMutation(channelId); const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId); const deleteChannelMutation = useDeleteChannelMutation(channelId); const joinChannelMutation = useJoinChannelMutation(channelId); const leaveChannelMutation = useLeaveChannelMutation(channelId); + const channelIdRef = React.useRef(channelId); + channelIdRef.current = channelId; const detail = detailsQuery.data ?? channel; const members = React.useMemo(() => { @@ -159,13 +160,17 @@ export function ChannelManagementSheet({ const [nameDraft, setNameDraft] = React.useState(""); const [descriptionDraft, setDescriptionDraft] = React.useState(""); - const [topicDraft, setTopicDraft] = React.useState(""); - const [purposeDraft, setPurposeDraft] = React.useState(""); const [isPrivateDraft, setIsPrivateDraft] = React.useState(false); const [isEphemeralDraft, setIsEphemeralDraft] = React.useState(false); - const [ttlDraft, setTtlDraft] = React.useState(""); + const [ttlSecondsDraft, setTtlSecondsDraft] = React.useState( + DEFAULT_EPHEMERAL_TTL_SECONDS, + ); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false); + const [isConvertingVisibility, setIsConvertingVisibility] = + React.useState(false); + const [hasUserEditedChannelDraft, setHasUserEditedChannelDraft] = + React.useState(false); const [activeView, setActiveView] = React.useState<"summary" | "canvas">( "summary", ); @@ -194,13 +199,10 @@ export function ChannelManagementSheet({ setNameDraft(detail.name); setDescriptionDraft(detail.description); - setTopicDraft(detail.topic ?? ""); - setPurposeDraft(detail.purpose ?? ""); setIsPrivateDraft(detail.visibility === "private"); setIsEphemeralDraft(detail.ttlSeconds !== null); - setTtlDraft( - detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "", - ); + setTtlSecondsDraft(detail.ttlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS); + setHasUserEditedChannelDraft(false); setActiveView("summary"); }, [detail, open]); @@ -232,19 +234,13 @@ export function ChannelManagementSheet({ onOpenChange(next); } - // Parsed seconds for the ephemeral TTL field. `null` when the field is empty - // or malformed; the form blocks saving on a non-empty malformed value. - const parsedTtlSeconds = parseTtlDuration(ttlDraft); - const ttlInvalid = - isEphemeralDraft && ttlDraft.trim() !== "" && parsedTtlSeconds === null; - const currentVisibility = detail?.visibility ?? channel.visibility; const currentTtlSeconds = detail?.ttlSeconds ?? null; const nextVisibility: "open" | "private" = isPrivateDraft ? "private" : "open"; const nextTtlSeconds: number | null = isEphemeralDraft - ? (parsedTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS) + ? ttlSecondsDraft : null; const lifecycleDirty = nextVisibility !== currentVisibility || @@ -254,22 +250,11 @@ export function ChannelManagementSheet({ const nameDirty = nameDraft.trim() !== resolvedChannel.name.trim(); const descriptionDirty = descriptionDraft.trim() !== resolvedChannel.description.trim(); - const topicDirty = topicDraft.trim() !== (resolvedChannel.topic ?? "").trim(); - const purposeDirty = - purposeDraft.trim() !== (resolvedChannel.purpose ?? "").trim(); - const isSavingChannelEdits = - updateChannelDetailsMutation.isPending || - setTopicMutation.isPending || - setPurposeMutation.isPending; - const hasChannelEditChanges = - nameDirty || - descriptionDirty || - lifecycleDirty || - topicDirty || - purposeDirty; + const isSavingChannelEdits = updateChannelDetailsMutation.isPending; + const hasChannelEditChanges = nameDirty || descriptionDirty || lifecycleDirty; const canSaveChannelEdits = nameDraft.trim().length > 0 && - !ttlInvalid && + hasUserEditedChannelDraft && hasChannelEditChanges && !isSavingChannelEdits; const canvasContent = canvasQuery.data?.content?.trim() ?? ""; @@ -279,6 +264,18 @@ export function ChannelManagementSheet({ : undefined; const canOpenCanvas = hasCanvas || canEditNarrative; + function handleEditDialogOpenChange(next: boolean) { + if (!next) { + setNameDraft(resolvedChannel.name); + setDescriptionDraft(resolvedChannel.description); + setIsEphemeralDraft(currentTtlSeconds !== null); + setTtlSecondsDraft(currentTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS); + setHasUserEditedChannelDraft(false); + } + + setIsEditDialogOpen(next); + } + async function handleSaveChannelEdits() { try { if (nameDirty || descriptionDirty || lifecycleDirty) { @@ -294,17 +291,29 @@ export function ChannelManagementSheet({ }); } - if (topicDirty) { - await setTopicMutation.mutateAsync({ topic: topicDraft.trim() }); - } + setHasUserEditedChannelDraft(false); + setIsEditDialogOpen(false); + } catch { + // React Query stores mutation errors; keep the dialog open and render them. + } + } - if (purposeDirty) { - await setPurposeMutation.mutateAsync({ purpose: purposeDraft.trim() }); + async function handleConvertVisibility(visibility: "open" | "private") { + if (visibility === currentVisibility) { + return; + } + setIsConvertingVisibility(true); + try { + const updatedChannel = await updateChannelDetailsMutation.mutateAsync({ + visibility, + }); + if (channelIdRef.current === updatedChannel.id) { + setIsPrivateDraft(visibility === "private"); } - - setIsEditDialogOpen(false); } catch { // React Query stores mutation errors; keep the dialog open and render them. + } finally { + setIsConvertingVisibility(false); } } @@ -421,153 +430,108 @@ export function ChannelManagementSheet({ )} {canManageChannel ? ( - - + +
- Edit channel - - Update settings for{" "} - {resolvedChannel.name}. - + + Edit {currentVisibility === "private" ? "private" : "public"}{" "} + channel +
-
+
- setNameDraft(event.target.value)} - value={nameDraft} - /> +
+ { + setNameDraft(event.target.value); + setHasUserEditedChannelDraft(true); + }} + value={nameDraft} + /> +
-