From d3c3a85db15c53d5f064411e2072cb36649e0821 Mon Sep 17 00:00:00 2001 From: lodar Date: Wed, 12 Aug 2026 09:05:18 +0000 Subject: [PATCH 01/11] buzz-pairing-cli: send the JSON envelope the apps decode, and stop panicking on wss:// MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects that together made it impossible to pair a handset with the Buzz mobile app against an HTTPS relay. Both measured against a live relay. 1. THE ENVELOPE resolve_payload returned a bare bech32 nsec as PayloadType::Nsec in both arms. Mobile _processPayload begins with `jsonDecode(payload) as Map` and nothing in mobile/lib branches on payload_type, so a bare nsec dies on the leading `n` with `FormatException: Unexpected character (at character 1)`. That is verbatim what a real store build produced on a real handset. --envelope-relay now emits the same shape the DESKTOP client sends (desktop/src-tauri/src/commands/pairing.rs:145-148, PayloadType::Custom at :215): {"relayUrl","pubkey","nsec"}. pubkey is DERIVED from the transferred nsec so the two cannot drift. Without the flag the payload is unchanged, so CLI-to-CLI interop testing keeps working exactly as before. 2. wss:// PANICKED BEFORE ANY PAIRING COULD START thread 'main' panicked at rustls-0.23.42/src/crypto/mod.rs:249:14 Could not automatically determine the process-level CryptoProvider Both ring and aws-lc-rs are reachable in the workspace, so rustls refuses to choose. Plain ws:// never reaches that code path — which is why previous interop testing, all of it against a plaintext relay, was structurally blind to it: the defect exists only past the TLS an HTTPS deployment adds. Pin the ring provider and install it at the top of main(). VERIFIED END TO END, not by inspection: - cargo test -p buzz-pairing-cli: 7 passed, 0 failed, including an arm that pins the bare-nsec failure and one that proves the envelope decodes. - A full NIP-AB pairing driven over wss:// against a real relay: SAS matched on both sides, target logged "Received custom payload!" carrying the three-field envelope. - Those exact transferred bytes then replayed through the mobile app's own sequence: jsonDecode -> map; _validateRelayUrl accepts (https, public); _validateCredentials NIP-42 handshake accepted the transferred identity in 46ms against the app's 8s budget. - A real handset paired and got into the app. Co-Authored-By: Claude Opus 5 (cherry picked from commit 28e125f9ce6af5a36419bbbb05e2fa5dfb854021) --- crates/buzz-pairing-cli/Cargo.toml | 5 + crates/buzz-pairing-cli/src/main.rs | 228 ++++++++++++++++++++++++++-- 2 files changed, 224 insertions(+), 9 deletions(-) diff --git a/crates/buzz-pairing-cli/Cargo.toml b/crates/buzz-pairing-cli/Cargo.toml index 58fefe32d96..f9a18f8c50c 100644 --- a/crates/buzz-pairing-cli/Cargo.toml +++ b/crates/buzz-pairing-cli/Cargo.toml @@ -23,3 +23,8 @@ hex = { workspace = true } clap = { version = "4", features = ["derive", "env"] } thiserror = { workspace = true } zeroize = { workspace = true } +# Both ring and aws-lc-rs are present in the workspace tree, so +# rustls cannot select a process-level CryptoProvider on its own and every +# wss:// relay PANICS at connect. Pin one explicitly, install it in main(). +rustls = { version = "0.23", default-features = false, features = ["ring"] } + diff --git a/crates/buzz-pairing-cli/src/main.rs b/crates/buzz-pairing-cli/src/main.rs index 1eb9d215f92..83b74a8fcc2 100644 --- a/crates/buzz-pairing-cli/src/main.rs +++ b/crates/buzz-pairing-cli/src/main.rs @@ -4,10 +4,23 @@ //! //! ```text //! buzz-pair source --relay wss://relay.example.com [--nsec nsec1...] +//! [--envelope-relay https://relay.example.com] //! buzz-pair target [--relay wss://relay.example.com] //! buzz-pair test-vectors //! ``` //! +//! # Payload shape +//! +//! By default `source` transfers a bare bech32 `nsec1...` string +//! ([`PayloadType::Nsec`]). The Buzz **mobile** app does not accept that: its +//! `_processPayload` begins with `jsonDecode(payload) as Map`, +//! so a bare nsec dies on the leading `n` with +//! `FormatException: Unexpected character (at character 1)`. +//! +//! Passing `--envelope-relay ` switches the payload to the JSON +//! envelope the mobile app — and the desktop app, its real counterpart — +//! actually decodes: `{"relayUrl","pubkey","nsec"}` as [`PayloadType::Custom`]. +//! //! The `source` subcommand acts as the secret-holding device; `target` acts //! as the receiving device. Together they exercise the full NIP-AB protocol //! over a live Nostr relay. @@ -53,6 +66,13 @@ enum Cmd { /// nsec (bech32) of the key to transfer. If omitted, generates a test key. #[arg(long)] nsec: Option, + + /// Emit the mobile/desktop JSON envelope `{relayUrl,pubkey,nsec}` instead + /// of a bare nsec. The value is the `https://` URL of the Buzz relay the + /// paired device should join; it becomes the envelope's `relayUrl`, and is + /// distinct from `--relay`, which is the ephemeral pairing relay. + #[arg(long, value_name = "HTTPS_URL")] + envelope_relay: Option, }, /// Act as the target device (scans QR code, receives the secret). @@ -96,6 +116,14 @@ enum CliError { #[tokio::main] async fn main() { + // Without this, every wss:// pairing relay panics inside + // rustls ("Could not automatically determine the process-level + // CryptoProvider") because both ring and aws-lc-rs are in the workspace + // tree. Plain ws:// never reaches this code path, which is why interop + // testing against a plaintext spike relay could not have found it. + // Idempotent: the Err just means a provider was already installed. + let _ = rustls::crypto::ring::default_provider().install_default(); + let cli = Cli::parse(); if let Err(e) = run(cli.command).await { eprintln!("error: {e}"); @@ -105,15 +133,23 @@ async fn main() { async fn run(cmd: Cmd) -> Result<(), CliError> { match cmd { - Cmd::Source { relay, nsec } => cmd_source(relay, nsec).await, + Cmd::Source { + relay, + nsec, + envelope_relay, + } => cmd_source(relay, nsec, envelope_relay).await, Cmd::Target { relay, show_secret } => cmd_target(relay, show_secret).await, Cmd::TestVectors => cmd_test_vectors(), } } -async fn cmd_source(relay_url: String, nsec: Option) -> Result<(), CliError> { +async fn cmd_source( + relay_url: String, + nsec: Option, + envelope_relay: Option, +) -> Result<(), CliError> { // Resolve the payload to transfer. - let (payload_str, payload_type) = resolve_payload(nsec)?; + let (payload_str, payload_type) = resolve_payload(nsec, envelope_relay)?; // Create pairing session. let (mut session, qr) = PairingSession::new_source(relay_url.clone()); @@ -574,16 +610,48 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { serde_json::from_value(arr[2].clone()).ok() } +/// Build the JSON pairing envelope the Buzz apps decode. +/// +/// Exactly the three fields `_processPayload` reads +/// (`mobile/lib/features/pairing/pairing_provider.dart`), in the same shape the +/// desktop app sends (`desktop/src-tauri/src/commands/pairing.rs`): +/// +/// ```json +/// { "relayUrl": "https://…", "pubkey": "<64-char hex>", "nsec": "nsec1…" } +/// ``` +/// +/// `pubkey` is derived from `nsec` rather than taken separately — the two must +/// describe one identity, and deriving it removes the chance of them drifting. +fn build_envelope(relay_url: &str, nsec: &str) -> Result, CliError> { + let sk = SecretKey::parse(nsec).map_err(|e| CliError::InvalidNsec(e.to_string()))?; + let keys = Keys::new(sk); + Ok(Zeroizing::new( + serde_json::json!({ + "relayUrl": relay_url, + "pubkey": keys.public_key().to_hex(), + "nsec": nsec, + }) + .to_string(), + )) +} + /// Resolve the payload to send. /// -/// If `nsec` is provided, parse it as bech32 and return the raw nsec string. -/// Otherwise generate a fresh test key and return its nsec. -fn resolve_payload(nsec: Option) -> Result<(Zeroizing, PayloadType), CliError> { - match nsec { +/// If `nsec` is provided, parse it as bech32; otherwise generate a fresh test key. +/// +/// With `envelope_relay` set, the payload is the JSON envelope +/// ([`PayloadType::Custom`]) the Buzz apps decode. Without it, the payload stays +/// the bare bech32 nsec ([`PayloadType::Nsec`]) — unchanged upstream behaviour, +/// which is correct for CLI-to-CLI interop testing and wrong for the apps. +fn resolve_payload( + nsec: Option, + envelope_relay: Option, +) -> Result<(Zeroizing, PayloadType), CliError> { + let nsec = match nsec { Some(s) => { // Validate it parses as a secret key. let _sk = SecretKey::parse(&s).map_err(|e| CliError::InvalidNsec(e.to_string()))?; - Ok((Zeroizing::new(s), PayloadType::Nsec)) + Zeroizing::new(s) } None => { let keys = Keys::generate(); @@ -592,8 +660,24 @@ fn resolve_payload(nsec: Option) -> Result<(Zeroizing, PayloadTy .to_bech32() .map_err(|e| CliError::InvalidNsec(e.to_string()))?; println!("(no --nsec provided; using generated test key)"); - Ok((Zeroizing::new(nsec_str), PayloadType::Nsec)) + Zeroizing::new(nsec_str) } + }; + + match envelope_relay { + Some(relay) => { + // Gate 2 of `_processPayload` rejects any non-https URL in a release + // build. Warn at mint time rather than let it surface on a handset as + // a second, unrelated-looking failure. + if !relay.starts_with("https://") { + eprintln!( + "warning: --envelope-relay {relay} is not https:// — \ + release builds of the Buzz app will reject it (debug builds allow it)" + ); + } + Ok((build_envelope(&relay, &nsec)?, PayloadType::Custom)) + } + None => Ok((nsec, PayloadType::Nsec)), } } @@ -621,3 +705,129 @@ fn hex_to_32(s: &str) -> Result<[u8; 32], CliError> { .try_into() .map_err(|_| CliError::Other(format!("expected 32 bytes, got wrong length for '{s}'"))) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Map, Value}; + + const RELAY: &str = "https://relay.example.com"; + + fn fresh_nsec() -> String { + Keys::generate().secret_key().to_bech32().unwrap() + } + + /// Stand-in for gate 1 of the mobile app's `_processPayload`: + /// `jsonDecode(payload) as Map`. Dart throws + /// `FormatException` where this returns `Err`. + fn json_decode_as_map(payload: &str) -> Result, serde_json::Error> { + serde_json::from_str::>(payload) + } + + /// The defect this change exists to fix, pinned so it cannot come back + /// silently: the default payload is a bare bech32 nsec, and the app's very + /// first step cannot decode it. Character 1 is the `n` of `nsec1`. + #[test] + fn bare_nsec_payload_fails_the_apps_first_gate() { + let nsec = fresh_nsec(); + let (payload, ty) = resolve_payload(Some(nsec.clone()), None).unwrap(); + + assert!(matches!(ty, PayloadType::Nsec)); + assert!(payload.starts_with("nsec1")); + assert!( + json_decode_as_map(&payload).is_err(), + "a bare nsec must not be JSON — this is the FormatException the mobile app raises" + ); + } + + /// The fix: with `--envelope-relay`, gate 1 passes and the map carries + /// exactly the three fields `_processPayload` reads. + #[test] + fn envelope_payload_clears_the_apps_first_gate() { + let nsec = fresh_nsec(); + let (payload, ty) = resolve_payload(Some(nsec.clone()), Some(RELAY.to_string())).unwrap(); + + assert!(matches!(ty, PayloadType::Custom)); + let map = json_decode_as_map(&payload).expect("envelope must jsonDecode as a map"); + + assert_eq!(map.len(), 3, "exactly three fields, no extras: {map:?}"); + assert_eq!(map["relayUrl"], Value::String(RELAY.to_string())); + assert_eq!(map["nsec"], Value::String(nsec.clone())); + + // `relayUrl` non-null is the app's own second check inside gate 1. + assert!(map["relayUrl"].is_string()); + } + + /// `pubkey` must belong to the transferred `nsec`. A fresh or stale key here + /// decodes fine and then strands the device on a community it cannot sign for. + #[test] + fn envelope_pubkey_is_derived_from_the_transferred_nsec() { + let nsec = fresh_nsec(); + let expected = Keys::new(SecretKey::parse(&nsec).unwrap()) + .public_key() + .to_hex(); + + let (payload, _) = resolve_payload(Some(nsec), Some(RELAY.to_string())).unwrap(); + let map = json_decode_as_map(&payload).unwrap(); + + assert_eq!(map["pubkey"], Value::String(expected)); + assert_eq!( + map["pubkey"].as_str().unwrap().len(), + 64, + "pubkey is 64-char hex, not npub — the app stores it as Community.pubkey" + ); + } + + /// The generated-key path (no `--nsec`) must produce a coherent envelope too, + /// not just the explicit-key path. + #[test] + fn generated_key_envelope_is_internally_consistent() { + let (payload, ty) = resolve_payload(None, Some(RELAY.to_string())).unwrap(); + + assert!(matches!(ty, PayloadType::Custom)); + let map = json_decode_as_map(&payload).unwrap(); + let nsec = map["nsec"].as_str().unwrap(); + let derived = Keys::new(SecretKey::parse(nsec).unwrap()) + .public_key() + .to_hex(); + + assert_eq!(map["pubkey"], Value::String(derived)); + } + + /// Upstream CLI-to-CLI interop behaviour must be untouched when the flag is + /// absent — this crate is still the NIP-AB interop tool. + #[test] + fn absent_flag_leaves_upstream_behaviour_unchanged() { + let nsec = fresh_nsec(); + let (payload, ty) = resolve_payload(Some(nsec.clone()), None).unwrap(); + + assert!(matches!(ty, PayloadType::Nsec)); + assert_eq!(&*payload, &nsec); + } + + /// An unparseable nsec must be rejected at mint time, not shipped inside a + /// well-formed envelope that only fails three gates later on the handset. + #[test] + fn invalid_nsec_is_rejected_before_an_envelope_is_built() { + assert!(build_envelope(RELAY, "nsec1notarealkey").is_err()); + assert!(resolve_payload(Some("definitely-not-bech32".into()), Some(RELAY.into())).is_err()); + } + + /// Unicode and empty relay URLs must not corrupt the JSON — serde escapes + /// them, and Dart's `jsonDecode` reads them back byte-identically. + #[test] + fn odd_relay_urls_stay_well_formed_json() { + let nsec = fresh_nsec(); + for relay in [ + "", + "https://relay.exämple.com/pä†h", + "https://a.com/\"quote\"", + ] { + let payload = build_envelope(relay, &nsec).unwrap(); + let map = json_decode_as_map(&payload) + .unwrap_or_else(|e| panic!("relay {relay:?} broke the envelope: {e}")); + assert_eq!(map["relayUrl"], Value::String(relay.to_string())); + assert_eq!(map.len(), 3); + } + } +} From aa05423302d8b0479227a80e170ddd22a7950e41 Mon Sep 17 00:00:00 2001 From: lodar Date: Mon, 17 Aug 2026 08:13:20 +0000 Subject: [PATCH 02/11] DIVE-3512: cut buzz-cli linux releases, and a verified install path Adds a release rail for the server-side CLI. Upstream's release.yml is the desktop app: it triggers on desktop-v* tags, bundles on macOS, and is guarded to block/buzz, so it neither runs here nor produces /usr/local/bin/buzz. This is a small separate workflow with the same guard pointed at 5dive-ai/buzz. Builds on ubuntu-22.04 rather than ubuntu-latest so the glibc the artifact links against is old enough to run on 22.04 and 24.04 boxes alike, and records the commit, toolchain, sha256 and BuildID of every binary it emits. The install script verifies the download against the release's SHA256SUMS before touching /usr/local/bin, refuses to overwrite an existing binary without --force, and leaves the provenance on the box. The tree is current upstream main plus our one buzz-pairing-cli commit, so the release also carries buzz-pair with the JSON pairing envelope. --- .github/workflows/buzz-cli-release.yml | 138 +++++++++++++++++++++++++ scripts/install-buzz-cli.sh | 99 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 .github/workflows/buzz-cli-release.yml create mode 100755 scripts/install-buzz-cli.sh diff --git a/.github/workflows/buzz-cli-release.yml b/.github/workflows/buzz-cli-release.yml new file mode 100644 index 00000000000..cb09ece9544 --- /dev/null +++ b/.github/workflows/buzz-cli-release.yml @@ -0,0 +1,138 @@ +name: buzz-cli release + +# Cuts the Linux x86-64 `buzz` and `buzz-pair` binaries that customer boxes +# install as /usr/local/bin/buzz and /usr/local/bin/buzz-pair (DIVE-3512). +# +# WHY THIS EXISTS RATHER THAN release.yml: upstream's release rail is the +# desktop app — it triggers on `desktop-v*` tags, builds a Tauri bundle on +# macOS, and is guarded by `if: github.repository == 'block/buzz'`, so it +# neither runs here nor produces a server-side CLI. This is a separate, much +# smaller rail with the same guard pointed at our own repository, so a +# re-fork of this repo cannot fire it by accident. +# +# WHY ubuntu-22.04 AND NOT ubuntu-latest: the artifact is dynamically linked +# against glibc, and a binary built on 24.04 (glibc 2.39) will not start on a +# 22.04 box. Building on the older image means the artifact runs on both. +# +# The build is the provenance: every run records the commit it built, and the +# BuildID and sha256 of what came out. An artifact without that trail must not +# be installed on a customer box. + +on: + workflow_dispatch: + inputs: + release_tag: + description: >- + Tag to publish the binaries under (e.g. cli-v0.1.0). Leave empty to + build and attach artifacts to the run without cutting a release. + required: false + type: string + +permissions: + contents: read + +jobs: + build: + name: Build buzz-cli (linux x86-64) + # Same shape as the guard upstream puts on its own release jobs: this must + # not fire in anybody else's fork of this fork. + if: github.repository == '5dive-ai/buzz' + runs-on: ubuntu-22.04 + timeout-minutes: 60 + permissions: + contents: write # cutting the release and uploading its assets + defaults: + run: + shell: bash + + steps: + - name: Install system dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 + sudo apt-get install -y --no-install-recommends \ + -o Acquire::Retries=3 \ + -o DPkg::Lock::Timeout=120 \ + build-essential \ + pkg-config \ + libssl-dev \ + ca-certificates + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # No setup-rust step: rust-toolchain.toml pins 1.95.0 and the runner's + # preinstalled rustup honors it on the first cargo invocation. + - name: Record toolchain + run: | + rustup show active-toolchain + cargo --version + rustc --version + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: buzz-cli-release + + - name: Build + run: cargo build --release --locked -p buzz-cli -p buzz-pairing-cli + + - name: Record provenance + id: prov + run: | + set -euo pipefail + mkdir -p dist + cp target/release/buzz dist/buzz + cp target/release/buzz-pair dist/buzz-pair + + { + echo "repository: ${GITHUB_REPOSITORY}" + echo "commit: ${GITHUB_SHA}" + echo "workflow_run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo "built_on: $(. /etc/os-release && echo "$PRETTY_NAME") / glibc $(ldd --version | head -1 | awk '{print $NF}')" + echo "toolchain: $(rustc --version)" + echo + for b in buzz buzz-pair; do + echo "== ${b} ==" + echo "size: $(stat -c%s "dist/${b}") bytes" + echo "sha256: $(sha256sum "dist/${b}" | cut -d' ' -f1)" + echo "buildid: $(readelf -n "dist/${b}" | awk '/Build ID/ {print $3}')" + echo "file: $(file -b "dist/${b}")" + echo + done + } | tee dist/PROVENANCE.txt + + ( cd dist && sha256sum buzz buzz-pair > SHA256SUMS ) + + - name: Smoke the artifact + run: | + set -euo pipefail + # A binary that cannot report its own version is not a release + # candidate, whatever the build said. + ./dist/buzz --version + ./dist/buzz --help > /dev/null + ./dist/buzz-pair --help > /dev/null + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-cli-linux-x86_64 + path: dist/ + if-no-files-found: error + + - name: Publish release + if: inputs.release_tag != '' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "buzz-cli $TAG" \ + --notes "$(printf 'Linux x86-64 \`buzz\` and \`buzz-pair\`, built from %s by %s/%s/actions/runs/%s.\n\nInstall with scripts/install-buzz-cli.sh. Verify against SHA256SUMS before installing.\n\n```\n%s\n```\n' \ + "$GITHUB_SHA" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" "$(cat dist/PROVENANCE.txt)")" \ + dist/buzz dist/buzz-pair dist/SHA256SUMS dist/PROVENANCE.txt diff --git a/scripts/install-buzz-cli.sh b/scripts/install-buzz-cli.sh new file mode 100755 index 00000000000..200dc4d8a38 --- /dev/null +++ b/scripts/install-buzz-cli.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Install the buzz CLI onto a box, from a published release of this repository. +# +# This is the distribution path for DIVE-3512. The control plane's per-agent +# buzz config names "buzz_path": "/usr/local/bin/buzz", and `5dive agent buzz +# status ` resolves the binary from the agent's PATH, /usr/local/bin/buzz +# or /opt/buzz/bin/buzz. Installing here is what turns that check from rc 3 +# into rc 0. +# +# curl -fsSL https://raw.githubusercontent.com/5dive-ai/buzz/main/scripts/install-buzz-cli.sh | sudo bash -s -- --tag cli-v0.1.0 +# +# or, from a checkout: +# +# sudo ./scripts/install-buzz-cli.sh --tag cli-v0.1.0 +# +# WHY IT VERIFIES BEFORE IT INSTALLS: an unattributed binary at +# /usr/local/bin/buzz is worse than no binary — nothing ties it to a commit, so +# nothing can say what it does. Every release carries SHA256SUMS and a +# PROVENANCE.txt naming the commit, the workflow run, and each binary's +# BuildID. This script refuses to install if the download does not match +# SHA256SUMS, and it prints the provenance it installed so the box's own logs +# record which build landed. + +set -euo pipefail + +REPO="${BUZZ_REPO:-5dive-ai/buzz}" +PREFIX="${PREFIX:-/usr/local/bin}" +TAG="" +FORCE="false" + +die() { printf 'install-buzz-cli: %s\n' "$*" >&2; exit 1; } +note() { printf ' %s\n' "$*"; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) TAG="${2:-}"; shift 2 ;; + --tag=*) TAG="${1#*=}"; shift ;; + --prefix) PREFIX="${2:-}"; shift 2 ;; + --prefix=*) PREFIX="${1#*=}"; shift ;; + --force) FORCE="true"; shift ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[[ -n "$TAG" ]] || die "a release tag is required (--tag cli-v0.1.0). Refusing to guess: 'latest' would make the installed build unnameable, which is the defect this script exists to prevent." + +command -v curl >/dev/null || die "curl is required" +command -v sha256sum >/dev/null || die "sha256sum is required" + +# Named target, printed, so a wrong-target success cannot be invisible. +BASE="https://github.com/${REPO}/releases/download/${TAG}" +printf 'Installing buzz CLI\n' +note "repository: ${REPO}" +note "tag: ${TAG}" +note "prefix: ${PREFIX}" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +for f in buzz buzz-pair SHA256SUMS PROVENANCE.txt; do + curl -fsSL --retry 3 -o "${WORK}/${f}" "${BASE}/${f}" \ + || die "could not download ${f} from ${BASE} — check that ${TAG} exists and carries this asset" +done + +# Verify BEFORE anything is copied into place. A failure here must leave the +# box exactly as it was. +( cd "$WORK" && sha256sum --check --status SHA256SUMS ) \ + || die "checksum mismatch — the downloaded binaries do not match the release's SHA256SUMS. Nothing was installed." + +printf '\nProvenance of what is about to be installed:\n' +sed 's/^/ /' "${WORK}/PROVENANCE.txt" + +for b in buzz buzz-pair; do + dest="${PREFIX}/${b}" + if [[ -e "$dest" && "$FORCE" != "true" ]]; then + existing_id="$(readelf -n "$dest" 2>/dev/null | awk '/Build ID/ {print $3}')" + die "${dest} already exists (BuildID ${existing_id:-unknown}). Refusing to overwrite: an existing binary may be hand-built and in use. Re-run with --force once you have established what it is." + fi +done + +install -d -m 0755 "$PREFIX" +for b in buzz buzz-pair; do + install -m 0755 "${WORK}/${b}" "${PREFIX}/${b}" +done + +# Record what landed, on the box, so a later reader does not have to ask us. +install -d -m 0755 /var/lib/buzz +cp "${WORK}/PROVENANCE.txt" /var/lib/buzz/installed-provenance.txt +printf 'installed_tag: %s\ninstalled_at: %s\n' "$TAG" "$(date -u +%FT%TZ)" \ + >> /var/lib/buzz/installed-provenance.txt + +printf '\nInstalled:\n' +for b in buzz buzz-pair; do + note "${PREFIX}/${b} ($("${PREFIX}/${b}" --version 2>/dev/null || echo 'version unavailable'))" +done +note "provenance recorded at /var/lib/buzz/installed-provenance.txt" + +printf '\nNext: 5dive agent buzz status should now report "buzz binary: yes (%s/buzz)" and exit 0.\n' "$PREFIX" From 3732c5397f0b727aaae28ac32a1f19940d4a0c54 Mon Sep 17 00:00:00 2001 From: lodar Date: Mon, 17 Aug 2026 08:15:52 +0000 Subject: [PATCH 03/11] DIVE-3512: let the release workflow build from its own branch workflow_dispatch resolves only against the default branch, so as written the recipe could not be run until it had already been merged. A path-scoped push trigger on this branch builds and uploads the run artifact without cutting a release; cutting one still takes a deliberate dispatch that names a tag. --- .github/workflows/buzz-cli-release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/buzz-cli-release.yml b/.github/workflows/buzz-cli-release.yml index cb09ece9544..721469d3602 100644 --- a/.github/workflows/buzz-cli-release.yml +++ b/.github/workflows/buzz-cli-release.yml @@ -19,6 +19,16 @@ name: buzz-cli release # be installed on a customer box. on: + # Building from the branch is how this workflow gets exercised before it is on + # main: workflow_dispatch only resolves against the default branch, so without + # this the recipe could not be run until after it was merged, which is the + # wrong order to find out it is broken. + push: + branches: + - dive-3512-buzz-cli-release + paths: + - '.github/workflows/buzz-cli-release.yml' + - 'scripts/install-buzz-cli.sh' workflow_dispatch: inputs: release_tag: @@ -122,8 +132,10 @@ jobs: path: dist/ if-no-files-found: error + # Only a deliberate dispatch that names a tag cuts a release. A branch + # push builds and uploads the run artifact and stops there. - name: Publish release - if: inputs.release_tag != '' + if: github.event_name == 'workflow_dispatch' && inputs.release_tag != '' env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.release_tag }} From 4fd14084bd092f6df9c86ef9d98bc8f8caf3891b Mon Sep 17 00:00:00 2001 From: lodar Date: Mon, 17 Aug 2026 08:18:46 +0000 Subject: [PATCH 04/11] DIVE-3512: record buzz-pairing-cli's rustls dependency in Cargo.lock Our pairing commit (28e125f) added rustls as a direct dependency of buzz-pairing-cli to pin the CryptoProvider, but never updated Cargo.lock. The crate was already in the lock as a transitive dependency, so the omission is invisible to a plain `cargo build` and only surfaces under --locked, which is what a release build should use: error: cannot update the lock file ... because --locked was passed That means no tree carrying this patch can be built reproducibly, including 5dive-ai/buzz's own main, and upstream CI would reject the patch on the same grounds if it were ever sent there. --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 6c46beedf2f..e01b0d567fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1175,6 +1175,7 @@ dependencies = [ "futures-util", "hex", "nostr 0.44.7", + "rustls", "serde_json", "thiserror 2.0.18", "tokio", From eb6730e57e896d0ed9897c31a640d35a94272ebc Mon Sep 17 00:00:00 2001 From: lodar Date: Mon, 17 Aug 2026 08:20:24 +0000 Subject: [PATCH 05/11] DIVE-3512: port DIVE-3531's publish gate to this fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifted verbatim from 5dive-ai/5dive-chat PR #3 (merged 3e871d9c), which fixed the identical defect on the sibling fork. It applied to this tree with no conflicts. A fork inherits upstream's publish workflows, and their IMAGE_NAME falls through to `ghcr.io/block/*` whenever the GHCR_IMAGE / GHCR_SPRIG_IMAGE override variables are unset — which they are here (actions/variables total_count = 0). So the first ever push to this fork's main fired six inherited workflows, and Sprig image attempted a write to ghcr.io/block/buzz-sprig as 5dive-bot. It failed on a missing credential. That is a missing credential, not a control: the boundary held by accident. Every registry write in docker.yml and sprig-image.yml now fires only on a release tag push or a deliberate workflow_dispatch, the same shape helm-chart.yml already used here. Builds on main and PRs are untouched, so the compile signal survives. sprig.yml additionally learns to CREATE its rolling release rather than only edit it, because a fork inherits no releases and `gh release edit` exits 1 the first time. Audited the rest: push-gateway-helm-chart.yml triggers on push-chart-v* tags only and helm-chart.yml already gates its publish job on chart-v*, so those two need no change. --- .github/workflows/docker.yml | 38 +++++++++++++++++++------------ .github/workflows/sprig-image.yml | 24 ++++++++++++++----- .github/workflows/sprig.yml | 22 ++++++++++++++---- 3 files changed, 60 insertions(+), 24 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 564cd74e9dd..6c47e4c2a2e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -16,14 +16,24 @@ name: Docker image # the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop. # # Triggers: -# - push to main → :main + :sha-<7> -# + :debug-main + :debug-sha-<7> +# - push to main → build both images, publish nothing +# (was :main/:sha-<7> — see PUBLISH GATE below) # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} # + matching :debug-* tags # (+ :latest/:debug-latest for stable releases) # - pull_request → build only (no push), cache stays warm # - workflow_dispatch → manual relay-tag rescue at the tag itself # +# PUBLISH GATE (5dive fork): every write to the registry — the image push, the +# buildcache export, the merged manifest and its attestation — fires only on a +# `relay-v*` tag push or a rescue `workflow_dispatch`, never on a `main` push or +# a PR. Same shape as helm-chart.yml's publish job. `main` pushes and PRs still +# build both images on both arches, so the compile signal is intact; they just +# cannot write to IMAGE_NAME's namespace, which on this fork defaults to +# upstream `ghcr.io/block/*` and is not ours to write to. This makes the +# namespace boundary a control in the workflow rather than an accident of which +# credentials happen to be missing. +# # Why workflow_dispatch carries a version input: # Normal releases arrive through the push:tags trigger above. The input is # retained only for an operator to rerun publication manually at an immutable @@ -175,11 +185,11 @@ jobs: # Push by digest, not by tag — the merge job assembles the tags # into one multi-arch manifest. This is what makes the native-arm # matrix possible. - outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} cache-to: | - ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + ${{ (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - name: Build and push debug image by digest id: build-debug @@ -190,12 +200,12 @@ jobs: target: runtime-debug platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} - name: Export release and debug digests - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' env: RELEASE_DIGEST: ${{ steps.build-release.outputs.digest }} DEBUG_DIGEST: ${{ steps.build-debug.outputs.digest }} @@ -205,7 +215,7 @@ jobs: touch "/tmp/digests-debug/${DEBUG_DIGEST#sha256:}" - name: Upload release digest - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-release-${{ matrix.arch }} @@ -214,7 +224,7 @@ jobs: retention-days: 1 - name: Upload debug digest - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-debug-${{ matrix.arch }} @@ -224,7 +234,7 @@ jobs: merge: name: Merge ${{ matrix.variant }} multi-arch manifest - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 needs: build timeout-minutes: 15 @@ -400,16 +410,16 @@ jobs: file: ./Dockerfile.push-gateway platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' }} cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} - cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} + cache-to: ${{ (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} - name: Export digest - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' env: DIGEST: ${{ steps.build.outputs.digest }} run: mkdir -p /tmp/gateway-digests && touch "/tmp/gateway-digests/${DIGEST#sha256:}" - name: Upload digest - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: gateway-digests-${{ matrix.arch }} @@ -419,7 +429,7 @@ jobs: push-gateway-merge: name: Publish public push gateway image - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 needs: push-gateway-build timeout-minutes: 15 diff --git a/.github/workflows/sprig-image.yml b/.github/workflows/sprig-image.yml index 5d5e12ae0cf..07d197aaf37 100644 --- a/.github/workflows/sprig-image.yml +++ b/.github/workflows/sprig-image.yml @@ -10,12 +10,24 @@ name: Sprig image # No QEMU emulation. # # Triggers: -# - push to main (paths-filtered) → :main + :sha-<7> +# - push to main (paths-filtered) → build only, publish nothing +# (see PUBLISH GATE below) # - tag sprig-v* → semver family (shared with sprig.yml's # binary release — one tag versions both) # - pull_request (paths-filtered) → build only, no push # - workflow_dispatch → manual publish at the current ref # +# PUBLISH GATE (5dive fork): the image push, the buildcache export and the +# merged manifest fire only on a `sprig-v*` tag push or a workflow_dispatch, +# never on a `main` push or a PR — IMAGE_NAME defaults to upstream +# `ghcr.io/block/buzz-sprig`, which this fork cannot and must not write to. +# Mirrors docker.yml and helm-chart.yml. Builds still run on main and PRs. +# +# Residual: this workflow's `push:` trigger carries a `paths` filter, which +# GitHub applies to TAG pushes too (see helm-chart.yml's note). A `sprig-v*` +# tag on a commit touching none of those paths would therefore not publish. +# Left as-is: a release commit bumps Cargo.toml, which is in the filter. +# # NOTE: the first push creates the GHCR package PRIVATE by default. An org # admin must flip ghcr.io/block/buzz-sprig to public once (Package settings → # Change visibility). Subsequent pushes keep the visibility. @@ -120,14 +132,14 @@ jobs: file: ./Dockerfile.sprig platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} cache-to: | - ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + ${{ (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - name: Export digest - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' env: DIGEST: ${{ steps.build.outputs.digest }} run: | @@ -135,7 +147,7 @@ jobs: touch "/tmp/digests/${DIGEST#sha256:}" - name: Upload digest - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sprig-digest-${{ matrix.arch }} @@ -145,7 +157,7 @@ jobs: merge: name: Merge multi-arch manifest - if: github.event_name != 'pull_request' + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 needs: build timeout-minutes: 15 diff --git a/.github/workflows/sprig.yml b/.github/workflows/sprig.yml index 5e50808b3b7..efe1ba6a807 100644 --- a/.github/workflows/sprig.yml +++ b/.github/workflows/sprig.yml @@ -139,10 +139,24 @@ jobs: TITLE="Sprig (rolling)" NOTES="Rolling Linux build of Sprig (all-in-one buzz-acp + buzz-agent + buzz-dev-mcp), tracking \`main\` (\`${SHA}\`)." - gh release edit "$TAG" \ - --prerelease \ - --title "$TITLE" \ - --notes "$NOTES" + # `gh release edit` exits 1 with "release not found" on the first + # ever run in a repo, because a fork inherits no releases. Create it + # then, edit it after. `sprig-latest` matches none of this repo's six + # tag triggers (relay-v*, sprig-v*, chart-v*, push-chart-v*, + # desktop-v*) and no workflow here listens on the `release` event, so + # creating the tag cannot start a workflow loop. + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" \ + --prerelease \ + --title "$TITLE" \ + --notes "$NOTES" + else + gh release create "$TAG" \ + --target "$SHA" \ + --prerelease \ + --title "$TITLE" \ + --notes "$NOTES" + fi gh release upload "$TAG" dist/* --clobber publish-tag: From 809b6b4e10637f4bcbfc2c2fbb8f4c0461dc67f4 Mon Sep 17 00:00:00 2001 From: lodar Date: Mon, 17 Aug 2026 08:20:41 +0000 Subject: [PATCH 06/11] DIVE-3512: rebuild on every push to the branch, not only workflow edits The paths filter meant a commit that changed Cargo.lock or a crate did not rebuild, which is precisely the change most worth rebuilding on. The branch is short-lived; every push to it should be exercised. --- .github/workflows/buzz-cli-release.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/buzz-cli-release.yml b/.github/workflows/buzz-cli-release.yml index 721469d3602..c81dff617fd 100644 --- a/.github/workflows/buzz-cli-release.yml +++ b/.github/workflows/buzz-cli-release.yml @@ -26,9 +26,6 @@ on: push: branches: - dive-3512-buzz-cli-release - paths: - - '.github/workflows/buzz-cli-release.yml' - - 'scripts/install-buzz-cli.sh' workflow_dispatch: inputs: release_tag: From e105c7351b7853b32a360ff393c38549dd0280af Mon Sep 17 00:00:00 2001 From: lodar Date: Mon, 17 Aug 2026 08:25:46 +0000 Subject: [PATCH 07/11] DIVE-3512: smoke what the binary actually has, and identify it by BuildID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build went green and the smoke failed, on my own step: `buzz --version` is a usage error, because buzz-cli's clap command sets no `version` attribute. Fixing that upstream-side would be a one-word divergence for a string the workspace stamps 0.1.0 on every crate, so it is not worth carrying; the binary's real identity is its BuildID, which readelf reads out of the file and which the release already records. So the install script now prints BuildID rather than a version that would always have read 'version unavailable', and the smoke is stronger than the one that failed: it requires the CLI to DISPATCH, not merely print help, by running a subcommand against an unreachable relay and demanding exit 2 — the CLI's own documented 'relay/network error' code — and it requires both binaries to carry a readable BuildID at all. --- .github/workflows/buzz-cli-release.yml | 31 +++++++++++++++++++++++--- scripts/install-buzz-cli.sh | 5 ++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/buzz-cli-release.yml b/.github/workflows/buzz-cli-release.yml index c81dff617fd..d5c5e835f0b 100644 --- a/.github/workflows/buzz-cli-release.yml +++ b/.github/workflows/buzz-cli-release.yml @@ -117,12 +117,37 @@ jobs: - name: Smoke the artifact run: | set -euo pipefail - # A binary that cannot report its own version is not a release - # candidate, whatever the build said. - ./dist/buzz --version + # NOTE: `buzz` has no --version. Its clap command sets no `version` + # attribute, so the flag is an error: {"error":"user_error", + # "message":"unexpected argument '--version' found"}. Do not add one + # here to make a smoke pass — the binary's identity on a box is its + # BuildID, which readelf reads straight out of the file and which + # this release records. That is a stronger answer than a version + # string the workspace stamps 0.1.0 on everything anyway. ./dist/buzz --help > /dev/null ./dist/buzz-pair --help > /dev/null + # The binary must actually dispatch, not merely print help: run a + # subcommand with no relay reachable and require the documented + # failure rather than a crash. Exit 2 is 'relay/network error' per + # the CLI's own exit-code contract; anything else means the artifact + # is broken in a way --help would not show. + set +e + BUZZ_RELAY_URL=http://127.0.0.1:1 ./dist/buzz channel list > /dev/null 2>&1 + rc=$? + set -e + if [[ "$rc" != "2" ]]; then + echo "::error::expected exit 2 (relay/network error) from an unreachable relay, got $rc" + exit 1 + fi + + # Identity must be readable from the file itself, or the install + # script cannot tell an operator what it just put on their box. + for b in buzz buzz-pair; do + id=$(readelf -n "dist/${b}" | awk '/Build ID/ {print $3}') + [[ -n "$id" ]] || { echo "::error::${b} carries no BuildID"; exit 1; } + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: buzz-cli-linux-x86_64 diff --git a/scripts/install-buzz-cli.sh b/scripts/install-buzz-cli.sh index 200dc4d8a38..366692153ec 100755 --- a/scripts/install-buzz-cli.sh +++ b/scripts/install-buzz-cli.sh @@ -90,9 +90,12 @@ cp "${WORK}/PROVENANCE.txt" /var/lib/buzz/installed-provenance.txt printf 'installed_tag: %s\ninstalled_at: %s\n' "$TAG" "$(date -u +%FT%TZ)" \ >> /var/lib/buzz/installed-provenance.txt +# Identify by BuildID, not --version: `buzz` sets no clap `version` attribute, +# so --version is a usage error. The BuildID is in the file, matches the one in +# PROVENANCE.txt, and survives a copy to another box. printf '\nInstalled:\n' for b in buzz buzz-pair; do - note "${PREFIX}/${b} ($("${PREFIX}/${b}" --version 2>/dev/null || echo 'version unavailable'))" + note "${PREFIX}/${b} BuildID $(readelf -n "${PREFIX}/${b}" 2>/dev/null | awk '/Build ID/ {print $3}')" done note "provenance recorded at /var/lib/buzz/installed-provenance.txt" From 595176d57560166d6017e6b3ac53d942440b07c6 Mon Sep 17 00:00:00 2001 From: lodar Date: Mon, 17 Aug 2026 08:29:51 +0000 Subject: [PATCH 08/11] DIVE-3512: assert the CLI's documented error contract, not a guessed exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My previous smoke demanded exit 2 from `buzz channel list` against an unreachable relay and got 1. The binary was right and the assertion was wrong: with no BUZZ_PRIVATE_KEY the call is rejected as bad input and never reaches the network, which is exactly what exit 1 means in the CLI's own table. So assert the contract the CLI actually documents — errors are {"error","message"} JSON on stderr — across two cases, one without an identity and one with a throwaway key against a dead port, and print both observed payloads rather than asserting blind again. The panic check is the part worth keeping: the defect our pairing patch fixes is a rustls CryptoProvider PANIC on wss://, and a panic is precisely what does not produce that JSON. This makes the release smoke a regression guard for the class of bug the patch exists to fix. --- .github/workflows/buzz-cli-release.yml | 51 +++++++++++++++++++------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/.github/workflows/buzz-cli-release.yml b/.github/workflows/buzz-cli-release.yml index d5c5e835f0b..186d949e25f 100644 --- a/.github/workflows/buzz-cli-release.yml +++ b/.github/workflows/buzz-cli-release.yml @@ -127,19 +127,44 @@ jobs: ./dist/buzz --help > /dev/null ./dist/buzz-pair --help > /dev/null - # The binary must actually dispatch, not merely print help: run a - # subcommand with no relay reachable and require the documented - # failure rather than a crash. Exit 2 is 'relay/network error' per - # the CLI's own exit-code contract; anything else means the artifact - # is broken in a way --help would not show. - set +e - BUZZ_RELAY_URL=http://127.0.0.1:1 ./dist/buzz channel list > /dev/null 2>&1 - rc=$? - set -e - if [[ "$rc" != "2" ]]; then - echo "::error::expected exit 2 (relay/network error) from an unreachable relay, got $rc" - exit 1 - fi + # The binary must actually DISPATCH, not merely parse. Assert the + # CLI's own documented error contract -- "Errors are JSON on stderr: + # {\"error\": , \"message\": }" -- rather than a + # specific exit code, because the code depends on how far the call + # gets: with no key it is 1 (bad input) and never reaches the + # network. Both stderr payloads are printed so a future reader can + # see what was actually observed instead of trusting this comment. + # + # The panic check is the one that earns its place: the defect our own + # pairing patch fixed was a rustls CryptoProvider PANIC on wss://, and + # a panic is precisely what does NOT produce this JSON contract. This + # step is the regression guard for that class. + check_contract() { #