From 1f663c399ee423a69607911580d01c9fad869cf9 Mon Sep 17 00:00:00 2001 From: Zach Kelling Date: Wed, 21 Oct 2026 14:28:39 -0700 Subject: [PATCH 1/2] fix(installer): clarify verification and install canonical tool names Add read-only hanzo doctor checks and strict provenance verification. Reserve hanzo-node for the network node and use hanzo-cli for control delegation. Release 8.5.754. Testing: 387 Rust unit tests and three cloud parser tests passed; strict clippy passed; installer fixtures passed under sh and bash; real Gen strict provenance verified with GitHub CLI 2.102.1. Risk: installer alias migration removes only the known legacy hanzo-node symlink. Strict mode requires authenticated compatible gh and available release attestations; normal mode continues to require checksums and reports unavailable provenance. How to revert: revert this commit and republish the previous installer; install CLI v8.5.753 if necessary. --- Cargo.lock | 2 +- Cargo.toml | 2 +- E2E.md | 2 +- LLM.md | 18 +-- Makefile | 1 + README.md | 18 ++- install.sh | 54 ++++++--- scripts/e2e-installed.sh | 4 +- scripts/test-installer-strict.sh | 64 +++++++++++ src/commands/doctor.rs | 184 +++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/upgrade.rs | 20 +++- src/commands/version.rs | 14 +-- src/main.rs | 21 +++- src/telemetry.rs | 1 + 15 files changed, 359 insertions(+), 47 deletions(-) create mode 100644 scripts/test-installer-strict.sh create mode 100644 src/commands/doctor.rs diff --git a/Cargo.lock b/Cargo.lock index 5bc5c66ea..04ed1b173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -886,7 +886,7 @@ dependencies = [ [[package]] name = "hanzo-cli" -version = "8.5.753" +version = "8.5.754" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index ae4001652..958898619 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hanzo-cli" -version = "8.5.753" +version = "8.5.754" edition = "2021" authors = ["Hanzo AI"] description = "Unified CLI for Hanzo AI development tools" diff --git a/E2E.md b/E2E.md index 9f4c962a2..85bf54e02 100644 --- a/E2E.md +++ b/E2E.md @@ -16,6 +16,6 @@ Test the public installer and its actual downloaded binaries: HANZO_E2E_TOKEN="$(hanzo auth token)" make e2e-installed ``` -The installer runs in a temporary directory, verifies release checksums, and installs `hanzo`, `hanzo-node`, `dev`, `gen`, and MCP. Tests use that directory first on PATH. It leaves your normal installation intact. A missing token is an error, and every coding test must actually complete; HTTP errors and a missing output file fail the run. +The installer runs in a temporary directory, verifies release checksums, and installs `hanzo`, `hanzo-cli`, `dev`, `gen`, and MCP. Tests use that directory first on PATH. It leaves your normal installation intact. A missing token is an error, and every coding test must actually complete; HTTP errors and a missing output file fail the run. The tests cover the machine and platform on which they run. They do not replace macOS/Windows installation checks or interactive browser OAuth testing. Token login uses a real existing IAM session; it does not simulate a fresh browser sign-in. diff --git a/LLM.md b/LLM.md index d399e536e..b48427fd9 100644 --- a/LLM.md +++ b/LLM.md @@ -11,7 +11,7 @@ the CLI; discovery/marketing repos link here, never copy it. **Install / run**: - `curl -fsSL https://hanzo.sh | sh` — the ONE published install line, on every surface. - hanzo.sh drives THIS repo's `install.sh` once per tool (`hanzo`, `hanzo-mcp`, `dev`); + hanzo.sh drives THIS repo's `install.sh` once per tool (`hanzo`, `hanzo-mcp`, `dev`, `gen`, `zt`); `install.sh` is the one implementation of "fetch a Hanzo binary" (per-platform asset, sha256-verified, second-name symlink). No token: the repo is public. A tool whose releases are attested names its workflow (`HANZO_INSTALL_SIGNER`, hanzo.sh's fifth @@ -41,20 +41,20 @@ to `crates/client`, so one copy links. **This binary answers to TWO names, and that is by design.** cloud's Go control binary (`hanzoai/cloud`, `cmd/hanzo`) owns a small set of control verbs and delegates every other verb to US, through `cloud/cli/link.go` `fabricCLI()`, -which resolves `hanzo-node` on PATH first and only then `hanzo` (with a +which resolves `hanzo-cli` on PATH first and only then `hanzo` (with a self-exec guard). So a machine carrying the Go control binary installs THIS -artifact as `hanzo-node`. Same build, two names — there is no third CLI -codebase, and `hanzo-node` is not a separate product. +artifact as `hanzo-cli`. Same build, two names — there is no third CLI +codebase, and `hanzo-cli` is not a separate product. -The trap that follows from it: a stale `hanzo-node` is INVISIBLE. The user types +The trap that follows from it: a stale `hanzo-cli` is INVISIBLE. The user types `hanzo`, the Go binary delegates, and they get whatever old build is sitting under the other name — with no version in sight. That is not hypothetical: a -v1.7.2 `hanzo-node` served roughly 150 phantom `/v1/cloud/` commands, +v1.7.2 `hanzo-cli` served roughly 150 phantom `/v1/cloud/` commands, because it predates `genspec.rs` and the registry refutation seam and was built from the vendored `spec/products.json` (since deleted). Nothing was wrong with the live code; the INSTALL was old. So: -> Whatever installs `hanzo` MUST install `hanzo-node` at the same version, and +> Whatever installs `hanzo` MUST install `hanzo-cli` at the same version, and > both must be this crate. An installer that ships one name, or ships two > different versions, produces a CLI whose surface nobody can explain. @@ -82,13 +82,13 @@ parser reads (first line, last token, optional `v` stripped) and the shape every other `--version` prints. A skew report is a WARNING, not the answer, so it goes to STDERR and a caller piping the version gets exactly one clean line. -`install.sh` does exactly that: it installs `hanzo`, symlinks `hanzo-node` to +`install.sh` does exactly that: it installs `hanzo`, symlinks `hanzo-cli` to the same file (a copy where links are unavailable), and warns when another `hanzo` earlier on PATH would run instead of the build it just placed — the precedence that let a stale twin hide. The remaining gaps are elsewhere: -- PyPI carries `hanzo`, `hanzo-cli` and `hanzo-node` as SEPARATE projects that +- PyPI carries `hanzo`, `hanzo-cli` and `hanzo-cli` as SEPARATE projects that are not this crate. `curl hanzo.sh | sh` used to install one of them at the name `hanzo`, into `~/.local/bin`, which outranks `/usr/local/bin` on a normal PATH. It now calls THIS installer instead, so there is one download path and diff --git a/Makefile b/Makefile index 636bd0a58..ff8ca4398 100644 --- a/Makefile +++ b/Makefile @@ -74,6 +74,7 @@ verify: ## Refuse a tree the committed evidence contradicts. Hermetic. The drift # because stopping at the first failing package is how they stayed unrun: the # drift gate is upstream of them and fails on real drift by design. test: ## Everything, including the derivation gates in tests/ — `verify` among them. + @sh scripts/test-installer-strict.sh @python3 -m unittest discover -s tests -p test_installer.py @cargo build --quiet --features maintainer --bin driftgate --bin genproduct --locked @cargo test --workspace --locked --no-fail-fast diff --git a/README.md b/README.md index 45f8e60fa..0e14b9c60 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,21 @@ curl -fsSL https://hanzo.sh | sh One install line, and it is the same one every Hanzo surface prints. It resolves the release asset for your platform (linux · macOS on amd64 · arm64, Windows on amd64), verifies its sha256, and puts `hanzo` in `~/.local/bin` — along with `hanzo-mcp` (the -MCP server) and `dev` (the coding agent `hanzo dev` runs). Re-run to upgrade. +MCP server), `hanzo-dev`, `hanzo-gen`, and `hanzo-zet`. The short names `dev`, +`gen`, `mcp`, and `zt` remain available. Re-run to upgrade. + +These are prebuilt native binaries; the installer does not compile crates. Node and +Bot have public source but no downloadable native releases yet, so they are not +included. `hanzo-node` is reserved for the network node, not an alias for the CLI. + +A provenance warning means the tool installed but its provenance was not verified. +Use `curl -fsSL https://hanzo.sh | sh -s -- --strict` to require provenance verification +before installation (requires a current, authenticated GitHub CLI and release +attestations). Checksums are required in either mode. + +Run `hanzo doctor` to check executable paths, versions, authentication and GPU +runtimes. `--offline` skips network checks; `--verify` downloads current releases +and compares installed bytes against checksummed, provenance-verified artifacts. `hanzo.sh` drives this repo's `install.sh`, which is the one implementation of "fetch a Hanzo binary". To install only the CLI: `curl -fsSL https://hanzo.sh | sh -s -- hanzo`. @@ -179,7 +193,7 @@ hanzo link dial [--install] # a local port onto a service on hanzo link status | rm # what is up here; unpublish ``` -`hanzo-node` is not a second program: the installer writes it as a symlink to this same +`hanzo-cli` is not a second program: the installer writes it as a symlink to this same binary, because cloud's Go control binary resolves that name first and delegates to it. One build, two names, so the two can never be different versions. diff --git a/install.sh b/install.sh index 48dc42f98..f311add10 100755 --- a/install.sh +++ b/install.sh @@ -15,7 +15,7 @@ # # HANZO_INSTALL_REPO owning repo (default hanzoai/cli) # HANZO_INSTALL_BIN binary + asset prefix (default hanzo) -# HANZO_INSTALL_ALIAS second name, same build (default hanzo-node; "" = none) +# HANZO_INSTALL_ALIAS second name, same build (default hanzo-cli; "" = none) # HANZO_INSTALL_PREFIX install dir (default ~/.local/bin) # HANZO_VERSION pin a tag (default: highest stable semver) # HANZO_INSTALL_SIGNER the workflow that must have built the asset, for a tool @@ -31,9 +31,10 @@ REPO="${HANZO_INSTALL_REPO:-hanzoai/cli}" BIN="${HANZO_INSTALL_BIN:-hanzo}" # The second name this same build installs under: what cloud's control binary # delegates to. One build, two names — never two versions. -DELEGATE="${HANZO_INSTALL_ALIAS-hanzo-node}" +DELEGATE="${HANZO_INSTALL_ALIAS-hanzo-cli}" PREFIX="${HANZO_INSTALL_PREFIX:-$HOME/.local/bin}" SIGNER="${HANZO_INSTALL_SIGNER:-}" +STRICT="${HANZO_INSTALL_STRICT:-0}" die() { printf '\n%s: %s\n' "$BIN" "$1" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "need $1 on PATH"; } @@ -42,6 +43,23 @@ need curl need tar need awk +case "$STRICT" in 0|1) ;; *) die "HANZO_INSTALL_STRICT must be 0 or 1" ;; esac +# Check the verifier before downloading. Strict mode requires repository- and +# tag-bound provenance for every tool, including those without a pinned workflow. +unchecked="" +if [ -n "$SIGNER" ] || [ "$STRICT" = 1 ]; then + if ! command -v gh >/dev/null 2>&1; then + unchecked="needs gh" + elif ! gh auth status >/dev/null 2>&1; then + unchecked="needs a signed-in gh" + elif ! gh attestation verify --help 2>/dev/null | grep -q -- '--source-ref'; then + unchecked="$(gh --version 2>/dev/null | head -1 || true) cannot check an attestation's tag" + fi + if [ "$STRICT" = 1 ] && [ -n "$unchecked" ]; then + die "strict verification unavailable ($unchecked). Install or upgrade GitHub CLI: https://cli.github.com/ and run gh auth login. Nothing was installed." + fi +fi + # A private repo answers 404 to an anonymous asset fetch, so carry a token when # one is available. Public installs need none. TOKEN="${HANZO_INSTALL_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}" @@ -209,25 +227,18 @@ fetch "$asset.sha256" "$tmp/$asset.sha256" \ # verify an attestation's tag is here, and a verification that ran and failed # refuses the install, with gh's reason. Otherwise it is not checked, and the # line says why and how to check it — never a silent pass. -if [ -n "$SIGNER" ]; then - if ! command -v gh >/dev/null 2>&1; then - unchecked="needs gh" - elif ! gh auth status >/dev/null 2>&1; then - unchecked="needs a signed-in gh" - elif ! gh attestation verify --help 2>/dev/null | grep -q -- '--source-ref'; then - unchecked="$(gh --version 2>/dev/null | head -1 || true) cannot check an attestation's tag" - else - unchecked="" - fi +if [ -n "$SIGNER" ] || [ "$STRICT" = 1 ]; then if [ -z "$unchecked" ]; then - gh attestation verify "$tmp/$asset" --repo "$REPO" --signer-workflow "$REPO/$SIGNER" \ - --source-ref "refs/tags/$TAG" > "$tmp/provenance" 2>&1 \ + set -- gh attestation verify "$tmp/$asset" --repo "$REPO" --source-ref "refs/tags/$TAG" + if [ -n "$SIGNER" ]; then set -- "$@" --signer-workflow "$REPO/$SIGNER"; fi + "$@" > "$tmp/provenance" 2>&1 \ || { sed 's/^/ /' "$tmp/provenance" >&2 - die "$asset has no attestation that $REPO/$SIGNER built it at $TAG — refusing to install"; } - printf '%s: provenance: %s built %s at %s\n' "$BIN" "$REPO/$SIGNER" "$asset" "$TAG" + die "$asset has no matching provenance for $REPO at $TAG — refusing to install"; } + printf '%s: provenance: verified %s from %s at %s\n' "$BIN" "$asset" "$REPO" "$TAG" else printf '%s: provenance not checked (%s): gh attestation verify %s --repo %s --signer-workflow %s/%s --source-ref refs/tags/%s\n' \ "$BIN" "$unchecked" "$asset" "$REPO" "$REPO" "$SIGNER" "$TAG" + printf '%s: provenance: upgrade GitHub CLI at https://cli.github.com/, run gh auth login, then rerun this installer.\n' "$BIN" fi fi @@ -241,7 +252,7 @@ chmod 755 "$PREFIX/$BIN$ext" printf '%s: installed %s\n' "$BIN" "$PREFIX/$BIN$ext" # The SAME build under a second name. cloud's control binary hands every verb it -# does not own to `hanzo-node` (it resolves that name before `hanzo`), so the two +# does not own to `hanzo-cli` (it resolves that name before `hanzo`), so the two # names must never be two versions: install one and not the other, or upgrade one # and not the other, and a user types `hanzo`, gets delegated, and runs an old # build with no version anywhere on screen. That is not theoretical — a stale @@ -261,6 +272,15 @@ if [ -n "$DELEGATE" ]; then printf '%s: installed %s (the same build, second name)\n' "$BIN" "$PREFIX/$DELEGATE$ext" fi +# Retire only the symlink this installer used to create for the CLI. A real +# network-node executable (or a link to hanzod) is never removed. +if [ "$BIN" = hanzo ] && [ "$DELEGATE" = hanzo-cli ] && [ -L "$PREFIX/hanzo-node$ext" ]; then + old_target=$(readlink "$PREFIX/hanzo-node$ext" || true) + case "$old_target" in + "hanzo$ext"|"$PREFIX/hanzo$ext") rm "$PREFIX/hanzo-node$ext" ;; + esac +fi + # Whatever PATH says, name the copy that would ACTUALLY run. An earlier entry # wins, and that precedence is exactly how a stale install hides: the user # upgrades here and keeps running something else, and re-running the installer diff --git a/scripts/e2e-installed.sh b/scripts/e2e-installed.sh index 554bf8eb7..a637b3c89 100755 --- a/scripts/e2e-installed.sh +++ b/scripts/e2e-installed.sh @@ -5,10 +5,10 @@ e2e_dir=$(mktemp -d) trap 'rm -rf "$e2e_dir"' EXIT HUP INT TERM curl -fsSL https://hanzo.sh -o "$e2e_dir/install.sh" sh "$e2e_dir/install.sh" --dir "$e2e_dir/bin" hanzo dev gen mcp -for tool in hanzo hanzo-node dev gen hanzo-mcp; do +for tool in hanzo hanzo-cli dev gen hanzo-mcp; do "$e2e_dir/bin/$tool" --version done -[ "$("$e2e_dir/bin/hanzo" --version)" = "$("$e2e_dir/bin/hanzo-node" --version)" ] +[ "$("$e2e_dir/bin/hanzo" --version)" = "$("$e2e_dir/bin/hanzo-cli" --version)" ] PATH="$e2e_dir/bin:$PATH" HANZO_E2E_BIN="$e2e_dir/bin/hanzo" export PATH HANZO_E2E_BIN cargo test --locked --test production -- --ignored --nocapture --test-threads=1 diff --git a/scripts/test-installer-strict.sh b/scripts/test-installer-strict.sh new file mode 100644 index 000000000..23140c45f --- /dev/null +++ b/scripts/test-installer-strict.sh @@ -0,0 +1,64 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT HUP INT TERM +mkdir -p "$fixture/tools" "$fixture/archive" "$fixture/installed" +printf '#!/bin/sh\necho hanzo 1.2.3\n' > "$fixture/archive/hanzo" +tar -czf "$fixture/hanzo-linux-amd64.tar.gz" -C "$fixture/archive" hanzo +(cd "$fixture" && sha256sum hanzo-linux-amd64.tar.gz > hanzo-linux-amd64.tar.gz.sha256) +cat > "$fixture/tools/uname" <<'SH' +#!/bin/sh +case "$1" in -s) echo Linux;; *) echo x86_64;; esac +SH +cat > "$fixture/tools/gh" <<'SH' +#!/bin/sh +case "$*" in + 'auth status') exit 0 ;; + 'auth token') exit 1 ;; + '--version') echo 'gh version 2.45.0'; exit 0 ;; + 'attestation verify --help') + [ "$VERIFY_MODE" = old ] || echo '--source-ref' + exit 0 ;; + *) [ "$VERIFY_MODE" = pass ] ;; +esac +SH +cat > "$fixture/tools/curl" <<'SH' +#!/bin/sh +url=''; dest='' +while [ $# -gt 0 ]; do + case "$1" in + https://*) url=$1; shift ;; + -o) dest=$2; shift 2 ;; + *) shift ;; + esac +done +cp "$INSTALL_FIXTURE/${url##*/}" "$dest" +SH +chmod +x "$fixture/tools/"* +export INSTALL_FIXTURE="$fixture" +export HANZO_INSTALL_TOKEN='' GH_TOKEN='' GITHUB_TOKEN='' +export HANZO_VERSION=v1.2.3 HANZO_INSTALL_REPO=hanzoai/cli HANZO_INSTALL_BIN=hanzo +export HANZO_INSTALL_ALIAS=hanzo-cli HANZO_INSTALL_PREFIX="$fixture/installed" +export PATH="$fixture/tools:$PATH" +for shell in sh bash; do + for mode in old reject; do + printf 'previous installation\n' > "$fixture/installed/hanzo" + if VERIFY_MODE="$mode" HANZO_INSTALL_STRICT=1 "$shell" "$root/install.sh" > "$fixture/log" 2>&1; then + echo "strict $mode unexpectedly succeeded" >&2; exit 1 + fi + grep -q 'previous installation' "$fixture/installed/hanzo" + done + ln -sf hanzo "$fixture/installed/hanzo-node" + VERIFY_MODE=pass HANZO_INSTALL_STRICT=1 "$shell" "$root/install.sh" > "$fixture/log" 2>&1 + "$fixture/installed/hanzo-cli" --version | grep -q 'hanzo 1.2.3' + [ ! -L "$fixture/installed/hanzo-node" ] + printf 'network node\n' > "$fixture/installed/hanzo-node" + VERIFY_MODE=old HANZO_INSTALL_STRICT=0 HANZO_INSTALL_SIGNER=.github/workflows/release.yml \ + "$shell" "$root/install.sh" > "$fixture/log" 2>&1 + grep -q 'provenance not checked' "$fixture/log" + grep -q 'upgrade GitHub CLI' "$fixture/log" + grep -q 'network node' "$fixture/installed/hanzo-node" + rm "$fixture/installed/hanzo-node" + echo "$shell: strict verification and preservation of existing installs passed" +done diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs new file mode 100644 index 000000000..5014d7fc6 --- /dev/null +++ b/src/commands/doctor.rs @@ -0,0 +1,184 @@ +//! Read-only installation checks; release verification downloads into a temporary directory. +use anyhow::{bail, Context, Result}; +use hanzo_client::Method; +use sha2::{Digest, Sha256}; +use std::{io::Read, path::Path, time::Duration}; +use tokio::process::Command; + +use crate::{commands::product::Seam, config::Config}; + +const TOOLS: &[(&str, &str, &str, &str)] = &[ + ("hanzo", "hanzo", "hanzoai/cli", ""), + ("hanzo-dev", "dev", "hanzoai/dev", ""), + ( + "hanzo-gen", + "gen", + "hanzoai/gen", + ".github/workflows/release.yml", + ), + ("hanzo-mcp", "hanzo-mcp", "hanzoai/mcp", ""), + ("hanzo-zet", "zt", "hanzozt/zt", ""), +]; + +async fn output(command: &mut Command, seconds: u64) -> Result { + command.kill_on_drop(true); + tokio::time::timeout(Duration::from_secs(seconds), command.output()) + .await + .context("command timed out")? + .context("could not execute command") +} + +fn digest(path: &Path) -> Result> { + let mut file = std::fs::File::open(path)?; + let mut hash = Sha256::new(); + let mut bytes = [0; 65536]; + loop { + let n = file.read(&mut bytes)?; + if n == 0 { + break; + } + hash.update(&bytes[..n]); + } + Ok(hash.finalize().to_vec()) +} + +async fn verify(path: &Path, binary: &str, repo: &str, signer: &str) -> Result { + let dir = tempfile::tempdir()?; + let script = dir.path().join("install.sh"); + std::fs::write(&script, include_str!("../../install.sh"))?; + let bin = dir.path().join("bin"); + let result = output( + Command::new("sh") + .arg(&script) + .env("HANZO_INSTALL_REPO", repo) + .env("HANZO_INSTALL_BIN", binary) + .env("HANZO_INSTALL_ALIAS", "") + .env("HANZO_INSTALL_PREFIX", &bin) + .env("HANZO_INSTALL_SIGNER", signer) + .env("HANZO_INSTALL_STRICT", "1") + .env_remove("HANZO_VERSION"), + 180, + ) + .await?; + if !result.status.success() { + bail!("release provenance could not be verified; check GitHub CLI, login and release attestations with the strict installer"); + } + let filename = format!("{binary}{}", std::env::consts::EXE_SUFFIX); + Ok(digest(path)? == digest(&bin.join(filename))?) +} + +pub async fn run(cfg: &mut Config, verify_releases: bool, offline: bool) -> Result<()> { + println!("Hanzo installation diagnostics"); + let mut failures = 0; + for &(name, legacy, repo, signer) in TOOLS { + let path = which::which(name).or_else(|_| which::which(legacy)); + let Ok(path) = path else { + println!("WARN {name}: not installed; run the Hanzo installer"); + continue; + }; + match output(Command::new(&path).arg("--version"), 5).await { + Ok(result) if result.status.success() && !result.stdout.is_empty() => { + let text = String::from_utf8_lossy(&result.stdout); + let version = text.lines().next().unwrap_or("no version output"); + println!("OK {name}: {version} — {}", path.display()); + } + _ => { + println!("FAIL {name}: cannot run {} --version", path.display()); + failures += 1; + } + } + if name != legacy { + if let Ok(other) = which::which(legacy) { + if std::fs::canonicalize(&other).ok() != std::fs::canonicalize(&path).ok() { + println!( + "WARN {legacy}: a separate executable at {}; check PATH precedence", + other.display() + ); + } + } + } + if verify_releases { + match verify(&path, legacy, repo, signer).await { + Ok(true) => println!("OK {name}: matches the checksummed, provenance-verified current release"), + Ok(false) => println!("WARN {name}: differs from the current release; it may be an older version or a local build"), + Err(err) => { println!("FAIL {name}: {err}"); failures += 1; } + } + } + } + if !verify_releases { + println!("INFO Release integrity not checked; use hanzo doctor --verify (downloads current releases)"); + } + if offline { + println!("INFO Authentication not checked in offline mode"); + } else { + let checked = tokio::time::timeout(Duration::from_secs(15), async { + let seam = Seam::open(cfg, crate::iam::paths::DEFAULT_BRAND).await?; + seam.send(Method::GET, "/v1/usage/summary", &[], None).await + }) + .await; + match checked { + Ok(Ok(reply)) + if reply.status == 200 + && reply + .body + .pointer("/scope/org") + .and_then(serde_json::Value::as_str) + .is_some_and(|org| !org.is_empty()) => + { + println!("OK Authentication: API accepted the active account") + } + Ok(Ok(reply)) => println!( + "WARN Authentication: API returned {}; check hanzo auth show", + reply.status + ), + Ok(Err(_)) => println!( + "WARN Authentication unavailable; check hanzo auth show or hanzo auth login" + ), + Err(_) => println!("WARN Authentication check timed out"), + } + } + if let Ok(program) = which::which("nvidia-smi") { + match output( + Command::new(program) + .args(["--query-gpu=name,driver_version", "--format=csv,noheader"]), + 5, + ) + .await + { + Ok(result) if result.status.success() => println!( + "OK NVIDIA GPU: {}", + String::from_utf8_lossy(&result.stdout).trim() + ), + _ => println!( + "WARN NVIDIA driver probe failed; cloud coding does not require a local GPU" + ), + } + } else if which::which("rocminfo").is_ok() { + match output(&mut Command::new("rocminfo"), 5).await { + Ok(result) if result.status.success() => println!("OK ROCm runtime responds"), + _ => println!("WARN ROCm runtime probe failed"), + } + } else { + println!("INFO No NVIDIA/ROCm runtime detected; local GPU capability is unverified and cloud coding remains available"); + } + if failures != 0 { + bail!("{failures} installation checks failed"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn integrity_comparison_reads_bytes_not_filenames() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a"); + let b = dir.path().join("b"); + std::fs::write(&a, b"release").unwrap(); + std::fs::write(&b, b"release").unwrap(); + assert_eq!(digest(&a).unwrap(), digest(&b).unwrap()); + std::fs::write(&b, b"changed").unwrap(); + assert_ne!(digest(&a).unwrap(), digest(&b).unwrap()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e3ed457e3..5fc74ba25 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -18,6 +18,7 @@ pub mod scan; pub mod status; pub mod monitor; pub mod version; +pub mod doctor; pub mod upgrade; pub mod up; diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index 628dfc50d..d88bef986 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -1,7 +1,7 @@ //! `hanzo upgrade [tool…]`: every Hanzo tool to its current release, and the //! coding agents `hanzo` runs. //! -//! Hanzo's own tools (`hanzo`, `mcp`, `dev`, `zt`) are native binaries, and +//! Hanzo's own tools (`hanzo`, `mcp`, `dev`, `gen`, `zt`) are native binaries, and //! hanzo.sh is the one implementation of fetching one: platform, asset name, //! checksum, second name. Upgrading is running it again, into the directory this //! `hanzo` lives in, so the build that answers `hanzo` is the build that was @@ -30,7 +30,7 @@ use hanzo_gen::backend::BackendKind; use hanzo_gen::Root; /// Hanzo's native tools, as hanzo.sh names them. -pub const NATIVE: &[&str] = &["hanzo", "mcp", "dev", "zt"]; +pub const NATIVE: &[&str] = &["hanzo", "mcp", "dev", "gen", "zt"]; /// The installer, served at the root of hanzo.sh. const INSTALLER: &str = "https://hanzo.sh"; @@ -93,7 +93,10 @@ pub fn plan(names: &[String], installed: impl Fn(&Tool) -> bool) -> Result let n = n.trim().to_ascii_lowercase(); let native = match n.as_str() { "hanzo-mcp" => Some("mcp"), - "hanzo-node" | "cli" => Some("hanzo"), + "hanzo-dev" => Some("dev"), + "hanzo-gen" => Some("gen"), + "hanzo-zet" => Some("zt"), + "hanzo-cli" | "cli" => Some("hanzo"), other => NATIVE.iter().copied().find(|t| *t == other), }; if let Some(t) = native { @@ -360,7 +363,14 @@ mod tests { #[test] fn an_unknown_name_is_refused_with_the_list() { let e = plan(&names(&["nope"]), |_| false).unwrap_err().to_string(); - assert!(e.contains("upgradable: hanzo, mcp, dev, zt, claude, codex, interpreter, cursor"), "{e}"); + assert!(e.contains("upgradable: hanzo, mcp, dev, gen, zt, claude, codex, interpreter, cursor"), "{e}"); + } + + #[test] + fn prefixed_product_names_upgrade_their_native_binaries() { + let p = plan(&names(&["hanzo-dev", "hanzo-gen", "hanzo-mcp", "hanzo-zet"]), |_| false).unwrap(); + assert_eq!(p.native, names(&["dev", "gen", "mcp", "zt"])); + assert!(p.tools.is_empty()); } #[test] @@ -372,7 +382,7 @@ mod tests { #[test] fn the_tool_table_is_read_out_of_hanzo_sh() { - let script = "#!/bin/sh\nTOOLS='hanzo:hanzoai/cli:hanzo:hanzo-node\nmcp:hanzoai/mcp:hanzo-mcp:mcp\ndev:hanzoai/dev:dev:\nzt:hanzozt/zt:zt:'\nrest\n"; + let script = "#!/bin/sh\nTOOLS='hanzo:hanzoai/cli:hanzo:hanzo-cli\nmcp:hanzoai/mcp:hanzo-mcp:mcp\ndev:hanzoai/dev:dev:\nzt:hanzozt/zt:zt:'\nrest\n"; let t = table(script); assert_eq!(t.iter().map(|n| (n.name.as_str(), n.repo.as_str(), n.binary.as_str())).collect::>(), [ ("hanzo", "hanzoai/cli", "hanzo"), diff --git a/src/commands/version.rs b/src/commands/version.rs index 7806207bd..bfc6a6ee4 100644 --- a/src/commands/version.rs +++ b/src/commands/version.rs @@ -2,17 +2,17 @@ //! installed under agree with it. THE one implementation behind `hanzo version`, //! `hanzo --version` and `hanzo -V` (see `main`). //! -//! This CLI ships under two names. `hanzo` is what a person types; `hanzo-node` +//! This CLI ships under two names. `hanzo` is what a person types; `hanzo-cli` //! is what cloud's Go control binary delegates to (`cloud/cli/link.go` -//! `fabricCLI()` resolves `hanzo-node` on PATH first, then `hanzo`). Same build, +//! `fabricCLI()` resolves `hanzo-cli` on PATH first, then `hanzo`). Same build, //! two names — by design. //! //! The failure that design allows is INVISIBLE, and it has TWO directions: //! -//! * a stale `hanzo-node` BEHIND us — install one name and not the other, or +//! * a stale `hanzo-cli` BEHIND us — install one name and not the other, or //! upgrade one and not the other, and a user types `hanzo`, gets delegated, //! and runs a different build with no version anywhere on screen. A v1.7.2 -//! `hanzo-node` sitting behind a current `hanzo` is what served ~150 commands +//! `hanzo-cli` sitting behind a current `hanzo` is what served ~150 commands //! that no longer exist. //! * a different file AHEAD of us holding the name `hanzo` — an earlier PATH //! entry wins every bare `hanzo`, so the build a user upgraded is not the @@ -30,14 +30,14 @@ use colored::Colorize; use std::path::{Path, PathBuf}; /// The name cloud's control binary delegates to. -const DELEGATE: &str = "hanzo-node"; +const DELEGATE: &str = "hanzo-cli"; /// The name a person types — the one an earlier PATH entry can steal. const NAME: &str = "hanzo"; /// What we found at the other name. enum Twin { - /// No `hanzo-node` on PATH. Nothing delegates here, nothing to skew. + /// No `hanzo-cli` on PATH. Nothing delegates here, nothing to skew. Absent, /// The same file as us — a link or a copy of this build. The healthy install. Same, @@ -92,7 +92,7 @@ pub fn run() { } } -/// Locate `hanzo-node` on PATH and decide what it is relative to us. +/// Locate `hanzo-cli` on PATH and decide what it is relative to us. fn twin() -> Twin { let Some(path) = which(DELEGATE) else { return Twin::Absent }; diff --git a/src/main.rs b/src/main.rs index fd85494e6..f5f6bea6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -285,6 +285,16 @@ enum Commands { /// Print the CLI version Version, + /// Check installed tools, paths, authentication, GPU runtime and release integrity + Doctor { + /// Download current releases and verify provenance and installed bytes + #[arg(long, conflicts_with = "offline")] + verify: bool, + /// Skip network and authentication checks + #[arg(long)] + offline: bool, + }, + /// Upgrade every Hanzo tool (hanzo, mcp, dev, zt) to its current release, and /// every coding agent installed: npm agents under ~/.hanzo, the rest by their /// own updater (`claude update`, `goose update`, …) or uv. Name a tool to @@ -722,7 +732,9 @@ async fn main() -> Result<()> { _ => "trace", }; tracing_subscriber::fmt().with_env_filter(log_level).init(); - commands::host::retire_shared(); + if matches.subcommand_name() != Some("doctor") { + commands::host::retire_shared(); + } let mut config = config::Config::load(matches.get_one::("config").cloned())?; // The org selection belongs to this invocation, so it is read from the command @@ -734,7 +746,11 @@ async fn main() -> Result<()> { // so a network selected in another shell meanwhile never moves where this // invocation's credentials go. config.network.bound = Some(commands::network::active(&config).name); - let telemetry = telemetry::build(&config); + let telemetry = if matches.subcommand_matches("doctor").is_some_and(|args| args.get_flag("offline")) { + hanzo_event::Telemetry::disabled() + } else { + telemetry::build(&config) + }; // A matched generated operation dispatches first, through the shared seam. if let Some(resolved) = commands::product::resolve(&hand, &matches) { @@ -1002,6 +1018,7 @@ async fn dispatch(command: Commands, mut config: config::Config) -> Result<()> { Commands::Status { infer } => commands::status::run(&mut config, infer).await?, Commands::Monitor(args) => commands::monitor::run(&args).await?, Commands::Version => commands::version::run(), + Commands::Doctor { verify, offline } => commands::doctor::run(&mut config, verify, offline).await?, Commands::Upgrade { tools } => commands::upgrade::run(tools).await?, Commands::Console => commands::up::dashboard()?, Commands::Sandbox { command } => match command { diff --git a/src/telemetry.rs b/src/telemetry.rs index 8b73864a1..32ed7433a 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -63,6 +63,7 @@ pub fn label(command: &Commands) -> &'static str { Commands::Status { .. } => "status", Commands::Monitor(..) => "monitor", Commands::Version => "version", + Commands::Doctor { .. } => "doctor", Commands::Upgrade { .. } => "upgrade", Commands::Console => "console", Commands::Beat => "beat", From 9b39250d598780881d2cf2da03eb50748d0ee677 Mon Sep 17 00:00:00 2001 From: Zach Kelling Date: Wed, 21 Oct 2026 14:32:06 -0700 Subject: [PATCH 2/2] fix(installer): retain verified workflow attribution in success output Testing: all 13 existing installer tests and strict sh/bash fixtures pass. Risk: output-only correction for verified artifacts. How to revert: revert this commit. --- install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index f311add10..3963509fc 100755 --- a/install.sh +++ b/install.sh @@ -234,7 +234,11 @@ if [ -n "$SIGNER" ] || [ "$STRICT" = 1 ]; then "$@" > "$tmp/provenance" 2>&1 \ || { sed 's/^/ /' "$tmp/provenance" >&2 die "$asset has no matching provenance for $REPO at $TAG — refusing to install"; } - printf '%s: provenance: verified %s from %s at %s\n' "$BIN" "$asset" "$REPO" "$TAG" + if [ -n "$SIGNER" ]; then + printf '%s: provenance: %s/%s built %s at %s\n' "$BIN" "$REPO" "$SIGNER" "$asset" "$TAG" + else + printf '%s: provenance: verified %s from %s at %s\n' "$BIN" "$asset" "$REPO" "$TAG" + fi else printf '%s: provenance not checked (%s): gh attestation verify %s --repo %s --signer-workflow %s/%s --source-ref refs/tags/%s\n' \ "$BIN" "$unchecked" "$asset" "$REPO" "$REPO" "$SIGNER" "$TAG"