diff --git a/CLOUD-PARITY.md b/CLOUD-PARITY.md new file mode 100644 index 000000000..c8f9466f0 --- /dev/null +++ b/CLOUD-PARITY.md @@ -0,0 +1,37 @@ +# Cloud coding session acceptance + +Target: the cloud-session workflows in the supplied Claude Code field guide. +An API route alone does not count as end-to-end support. Pending means not yet +verified in production, including capabilities already present in the service. + +| Workflow | Evidence / remaining acceptance | +| --- | --- | +| Public installation and authenticated local coding | v8.5.753 installer and three production E2Es passed | +| Independent cloud coding run | Production run completed after terminal detached | +| Commit, authenticated push and PR | Production smoke PRs #6 and #7 in hanzoai/cli; closed after verification | +| Follow-up after completion | Production follow-up read preceding run's committed file | +| Branch checkout from cloud | Production teleport fetched exact committed file | +| Parallel isolated machines and ports | Pending concurrent production acceptance | +| In-flight instructions | In progress: public caller-scoped steering and CLI handoff tracking | +| Queued instructions and retraction | Pending; interrupt-and-follow-up is not a retractable queue | +| Conversation continuation locally | Pending; branch checkout alone does not restore model context | +| Local repository bundle, including non-GitHub repositories | Pending | +| Plan / build / setup modes | Existing CLI selectors; pending full production acceptance | +| Environment selection, scripts, variables and services | Existing service; pending usable CLI workflow and acceptance | +| Network policies and external credential injection | Pending isolation and denied-host acceptance | +| Repository instructions, skills, hooks and MCP | Pending parity audit and production acceptance | +| GitHub personal / org onboarding and useful auth errors | Connected account verified; pending missing-App and SSO cases | +| CI and PR review auto-fix | Pending trigger-to-fix production acceptance | +| Scheduled, HTTP and GitHub triggered runs | Pending coding-specific workflow and acceptance | +| Coordinated projects | Pending | +| Browser, phone and desktop start / follow / steer | Pending browser workflow acceptance | +| Slack start / follow / steer | Existing integration tests; pending production acceptance | +| Diff review, inline comments and draft PRs | Pending | +| Share, archive and delete sessions | Pending ownership and visibility acceptance | +| Idle recovery and durable conversation | Pending restart/reclaim acceptance | +| Self-hosted execution and private networks | Pending | +| Usage, concurrency and clear billing errors | 402 identity fix deployed; pending limits acceptance | + +Do not describe full parity as released until the pending rows have concrete +evidence. Keep release checks in E2E.md and the opt-in production tests; avoid +creating PRs, spending indefinitely, or modifying user repositories in default tests. diff --git a/CLOUD-SESSIONS.md b/CLOUD-SESSIONS.md index adf2971ee..b450ee523 100644 --- a/CLOUD-SESSIONS.md +++ b/CLOUD-SESSIONS.md @@ -26,15 +26,25 @@ accounts are checked by the service. `--model` names a model for harnesses that support it. `--mode plan` reads without pushing; `--mode setup` inspects the repository's environment. The normal mode is `build`. +`--target MACHINE_ID` selects a registered machine you own for a build run. +`--timeout-seconds 600` bounds the execution budget (1–1800 seconds), and +`--effort low|medium|high` selects reasoning effort for supported harnesses. +Steering a running session preserves its configuration; start a new task to +change its repository, harness, model, mode or execution settings. + Without `--repo`, the CLI reads origin and the current branch, refuses known unpushed commits or a missing upstream, and warns about uncommitted files. Only pushed files reach the sandbox. GitHub and Hanzo forge origins are supported; there is no local repository bundle upload. An explicit `--repo` targets that remote directly and does not upload or inspect the local checkout. -Follow-up tasks require a finished or paused run. They use the service's `after` -field to continue from prior work on a new branch. This CLI does not currently -redirect a running cloud sandbox. Ctrl-C while watching only detaches the viewer. +Follow-up tasks on finished runs use the service's `after` field to continue +from prior work on a new branch. On running or paused sessions, the new instruction +redirects the run: it keeps its current work, then starts a follow-up. Add `--watch` +to wait for that handoff and follow the new session. To reconnect during the +handoff, run `hanzo --cloud --resume SESSION_ID --await-followup`. This is an +interrupt-and-continue operation; retractable message queues are still pending. +Ctrl-C while watching only detaches the viewer. The terminal shows recent events; the browser session holds the transcript. `--teleport` checks the repository identity and clean working tree, fetches the @@ -79,8 +89,9 @@ local dev task that writes and verifies a file also passed against production. See [E2E.md](E2E.md) for repeatable source and public-installer checks. Still unimplemented here: uploading a local repository bundle, importing the -native harness conversation into a local session, and delivering follow-up -instructions to a running cloud sandbox. Scheduling, environment configuration, +native harness conversation into a local session, and retractable queued messages. +The new steering workflow requires the matching API release and production +verification before being counted as shipped. Scheduling, environment configuration, pause/stop, PR creation/merge, browser/mobile handoff, and CI auto-fix were not verified end to end in this change. Published API routes alone do not prove those workflows. 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..1cd02301b 100644 --- a/E2E.md +++ b/E2E.md @@ -10,12 +10,18 @@ HANZO_E2E_TOKEN="$(hanzo auth token)" make e2e This exercises token login through stdin, identity, authenticated usage JSON, a local `dev` task that writes and verifies a file in a temporary Git repository, and a read-only cloud task that clones a real repository and completes inference. It requires `dev` on PATH. The local and cloud tasks consume normal usage; the cloud test leases a sandbox. It does not log out your existing account or modify the source repository. +The steering check also starts a bounded read-only task, sends an instruction +while it runs, follows the replacement session, and verifies its repository +answer. It requires an API release serving `/v1/agent/coding/{session}/steer`. +Both sessions have a 120-second execution budget. The test fails if the +instruction is refused or the handoff never completes. + Test the public installer and its actual downloaded binaries: ```sh 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..3963509fc 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,22 @@ 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"; } + 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" + 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 +256,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 +276,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/cloud_sessions.rs b/src/commands/cloud_sessions.rs index d444af10b..76339a488 100644 --- a/src/commands/cloud_sessions.rs +++ b/src/commands/cloud_sessions.rs @@ -57,9 +57,21 @@ pub struct CloudArgs { /// What the cloud run may do: build, plan, or setup. #[arg(long, requires = "cloud_execution", value_parser = ["build", "plan", "setup"])] mode: Option, + /// Run on a registered machine you own instead of a managed sandbox. + #[arg(long, requires = "cloud_execution")] + target: Option, + /// Bound cloud execution time in seconds (maximum 30 minutes). + #[arg(long, requires = "cloud_execution", value_parser = clap::value_parser!(u32).range(1..=1800))] + timeout_seconds: Option, + /// Reasoning effort for the cloud agent. + #[arg(long, requires = "cloud_execution", value_parser = ["low", "medium", "high"])] + effort: Option, /// Follow cloud progress until the run finishes (Ctrl-C only detaches). #[arg(long, requires = "cloud_execution")] watch: bool, + /// Wait for an accepted instruction's follow-up session, then follow it. + #[arg(long, requires_all = ["cloud_execution", "resume"], conflicts_with_all = ["cloud_prompt", "positional", "tail"])] + await_followup: bool, /// Fetch a cloud run's branch into this clean repository. #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["cloud_execution", "resume", "positional", "tail"])] pub teleport: Option, @@ -75,6 +87,7 @@ pub(crate) async fn run(cfg: &mut Config, input: SessionArgs, desktop: bool) -> return checkout(&seam, id).await; } let named = args.named_backend(); + let explicit_harness = named.is_some(); let (backend, task, previous) = if let Some(prompt) = remote.cloud_prompt { if args.tail.is_some() || (args.resume.is_some() && args.positional.is_some()) { bail!("name the previous session once: hanzo -p 'task' --cloud SESSION_ID"); @@ -97,6 +110,10 @@ pub(crate) async fn run(cfg: &mut Config, input: SessionArgs, desktop: bool) -> }; let Some(task) = task else { if let Some(id) = previous { + if remote.await_followup { + let next = wait_for_followup(&seam, &id).await?; + return watch(&seam, &next, true).await; + } return watch(&seam, &id, remote.watch).await; } let page = call(&seam, Method::GET, "/v1/agent/sessions?kind=coding", None).await?; @@ -123,10 +140,34 @@ pub(crate) async fn run(cfg: &mut Config, input: SessionArgs, desktop: bool) -> let mut body = json!({"prompt": task, "tool": backend.as_str(), "desktop": desktop}); if let Some(id) = previous { let prior = detail(&seam, &id).await?; - if !finished(field(&prior, "status")) && field(&prior, "status") != "paused" { - bail!( - "session {id} is still running; wait for it to finish before starting a follow-up" + if !finished(field(&prior, "status")) { + if explicit_harness + || remote.repo.is_some() + || remote.base.is_some() + || remote.mode.is_some() + || remote.target.is_some() + || remote.timeout_seconds.is_some() + || remote.effort.is_some() + || args.model.is_some() + { + bail!("steering keeps the running session's configuration; send only the new prompt, or start a separate cloud task"); + } + call( + &seam, + Method::POST, + &format!("/v1/agent/coding/{id}/steer"), + Some(json!({"text": task})), + ) + .await?; + println!( + "Instruction accepted. Session {id} will keep its work and start a follow-up." ); + if remote.watch { + let next = wait_for_followup(&seam, &id).await?; + return watch(&seam, &next, true).await; + } + println!("Follow: hanzo --cloud --resume {id} --await-followup"); + return Ok(()); } if field(&prior, "branch").is_empty() { bail!( @@ -177,11 +218,16 @@ pub(crate) async fn run(cfg: &mut Config, input: SessionArgs, desktop: bool) -> ("base", remote.base), ("mode", remote.mode), ("model", args.model), + ("targetId", remote.target), + ("effort", remote.effort), ] { if let Some(value) = value { body[key] = json!(value); } } + if let Some(seconds) = remote.timeout_seconds { + body["timeoutSeconds"] = json!(seconds); + } let accepted = call(&seam, Method::POST, "/v1/agent/coding", Some(body)).await?; let id = field(&accepted, "sessionId"); session_id(id)?; @@ -260,10 +306,19 @@ fn same_repo(origin: &str, remote: &str) -> bool { } async fn watch(seam: &Seam, id: &str, follow: bool) -> Result<()> { + let mut id = id.to_owned(); let mut last = 0; let mut status = String::new(); loop { - let info = detail(seam, id).await?; + let info = detail(seam, &id).await?; + if follow { + if let Some(next) = followup(&info)? { + id = next; + last = 0; + status.clear(); + continue; + } + } if let Some(events) = info.get("recentEvents").and_then(Value::as_array) { for event in events { let seq = event.get("seq").and_then(Value::as_i64).unwrap_or(0); @@ -301,6 +356,43 @@ async fn watch(seam: &Seam, id: &str, follow: bool) -> Result<()> { } } +fn followup(info: &Value) -> Result> { + if let Some(events) = info.get("recentEvents").and_then(Value::as_array) { + for event in events.iter().rev() { + let payload = &event["payload"]; + if field(payload, "status") == "followed" { + if !field(payload, "error").is_empty() { + bail!("cloud follow-up failed: {}", field(payload, "error")); + } + let next = field(payload, "next"); + session_id(next)?; + return Ok(Some(next.to_owned())); + } + } + } + Ok(None) +} + +async fn wait_for_followup(seam: &Seam, id: &str) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(300); + loop { + if let Some(next) = followup(&detail(seam, id).await?)? { + println!("Follow-up session: {next}"); + return Ok(next); + } + if tokio::time::Instant::now() >= deadline { + bail!("follow-up has not started yet; inspect session {id} for its handoff status"); + } + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(2)) => {}, + result = tokio::signal::ctrl_c() => { + result?; + bail!("detached while waiting; the accepted follow-up continues in the cloud"); + } + } + } +} + fn print_event(event: &Value) { let payload = &event["payload"]; for key in ["plan", "error", "message", "text"] { @@ -413,6 +505,28 @@ async fn checkout(seam: &Seam, id: &str) -> Result<()> { mod tests { use super::*; #[test] + fn handoffs_follow_the_new_session_and_surface_failures() { + assert_eq!(followup(&json!({"recentEvents": []})).unwrap(), None); + assert_eq!( + followup(&json!({"recentEvents": [ + {"payload": {"status":"stopped"}}, + {"payload": {"status":"followed", "next":"sess_next"}} + ]})) + .unwrap(), + Some("sess_next".into()) + ); + assert!(followup(&json!({"recentEvents": [ + {"payload": {"status":"followed", "error":"capacity exhausted"}} + ]})) + .unwrap_err() + .to_string() + .contains("capacity exhausted")); + assert!(followup(&json!({"recentEvents": [ + {"payload": {"status":"followed", "next":"../other"}} + ]})) + .is_err()); + } + #[test] fn origins_are_normalized_without_credentials() { for input in [ "git@github.com:acme/app.git", 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", diff --git a/tests/cloud_sessions.rs b/tests/cloud_sessions.rs index ce76f5fde..1857f751b 100644 --- a/tests/cloud_sessions.rs +++ b/tests/cloud_sessions.rs @@ -13,11 +13,32 @@ fn cloud_entry_points_are_exposed() { .stdout( predicate::str::contains("--cloud") .and(predicate::str::contains("--teleport")) + .and(predicate::str::contains("--await-followup")) + .and(predicate::str::contains("--target")) .and(predicate::str::contains("--watch")), ); } } +#[test] +fn cloud_controls_require_a_cloud_session_and_valid_bounds() { + for args in [ + vec!["--await-followup"], + vec!["--cloud", "--await-followup"], + vec!["--target", "machine"], + vec!["--effort", "high"], + vec!["--cloud", "--timeout-seconds", "0", "task"], + vec!["--cloud", "--timeout-seconds", "1801", "task"], + ] { + Command::cargo_bin("hanzo") + .unwrap() + .args(args) + .assert() + .failure() + .stderr(predicate::str::contains("error:")); + } +} + #[test] fn cloud_does_not_silently_ignore_local_execution_options() { for option in ["--no-link", "--metered", "--no-sandbox", "--ask"] { diff --git a/tests/production.rs b/tests/production.rs index 70b8b4177..d804ce27c 100644 --- a/tests/production.rs +++ b/tests/production.rs @@ -101,3 +101,64 @@ fn production_cloud_session_completes() { "cloud task did not report the repository's package name" ); } + +#[test] +#[ignore = "leases production sandboxes to verify an in-flight instruction and handoff"] +fn production_cloud_instruction_follows_the_new_session() { + let s = Session::new(); + let started = s + .cmd() + .args([ + "--cloud", + "--mode", + "plan", + "--timeout-seconds", + "120", + "--repo", + "github.com/hanzoai/cli", + "-p", + "Run sleep 60, then read Cargo.toml and report the package name. Do not edit files.", + ]) + .assert() + .success(); + let output = String::from_utf8_lossy(&started.get_output().stdout); + let id = output + .lines() + .find_map(|line| line.strip_prefix("Cloud coding session: ")) + .expect("cloud start must return a session id"); + // The accepted run is registered asynchronously. Wait until its first + // progress event is visible before asking it to redirect. + for _ in 0..30 { + let state = s + .cmd() + .args(["agent", "sessions", "get", id]) + .assert() + .success(); + let value: serde_json::Value = serde_json::from_slice(&state.get_output().stdout).unwrap(); + if value["recentEvents"] + .as_array() + .is_some_and(|events| !events.is_empty()) + { + break; + } + std::thread::sleep(Duration::from_secs(1)); + } + let followed = s.cmd().args([ + "--cloud", id, "-p", + "Skip the sleep. Read Cargo.toml and include HANZO_HANDOFF_OK and the package name in your answer. Do not edit files.", + "--watch", + ]).assert(); + // Stop the original if the redirection was refused; stopping an already + // finished original is harmless. Each accepted run also has a 120s budget. + let _ = s.cmd().args(["agent", "sessions", "stop", id]).output(); + let followed = followed.success(); + let text = String::from_utf8_lossy(&followed.get_output().stdout); + assert!( + text.contains("Follow-up session: sess_"), + "no handoff: {text}" + ); + assert!( + text.contains("HANZO_HANDOFF_OK") && text.contains("hanzo-cli"), + "follow-up did not verify the checkout: {text}" + ); +}