From 8f17cb648e50ed9fafafdbe146891a6099c9a8ce Mon Sep 17 00:00:00 2001 From: Zach Kelling Date: Sat, 17 Oct 2026 21:43:13 -0700 Subject: [PATCH 1/6] Add remote cloud coding session workflow to the Hanzo CLI --- CLOUD-SESSIONS.md | 84 ++++++ LLM.md | 6 +- README.md | 16 +- src/commands/cloud_sessions.rs | 467 +++++++++++++++++++++++++++++++++ src/commands/dev.rs | 8 +- src/commands/man.rs | 4 + src/commands/mod.rs | 1 + src/main.rs | 20 +- tests/cloud_sessions.rs | 37 +++ 9 files changed, 624 insertions(+), 19 deletions(-) create mode 100644 CLOUD-SESSIONS.md create mode 100644 src/commands/cloud_sessions.rs create mode 100644 tests/cloud_sessions.rs diff --git a/CLOUD-SESSIONS.md b/CLOUD-SESSIONS.md new file mode 100644 index 000000000..84f53a0ce --- /dev/null +++ b/CLOUD-SESSIONS.md @@ -0,0 +1,84 @@ +# Cloud coding sessions + +`hanzo --cloud` runs work on Hanzo's Go cloud coding service. The Rust CLI submits +the task and returns a session ID. Closing the terminal does not stop the run. +Ordinary `hanzo` runs locally; its cloud session stream is remote observation, +not remote execution. + +`--metered` selects Hanzo's metered models for a local session. This replaces +the old `--cloud` model-routing spelling: `--cloud` now means remote execution. + +```sh +git push -u origin HEAD +hanzo --cloud "Fix the flaky cache test and run the suite 30 times" +hanzo --cloud "Check the API documentation against the server" --watch +hanzo --cloud --repo github.com/acme/api --base main --mode plan "Explain the cache" +hanzo --cloud # list coding sessions +hanzo --cloud --resume SESSION_ID # inspect status and recent events +hanzo --cloud --resume SESSION_ID --watch +hanzo -p "Add regression tests" --cloud SESSION_ID +hanzo --teleport SESSION_ID +``` + +`hanzo code --cloud` accepts the same flags. `--claude` and `--codex` select a +cloud harness; the default is Hanzo's `dev` agent. Availability and connected +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`. + +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. +The terminal shows recent events; the browser session holds the transcript. + +`--teleport` checks the repository identity and clean working tree, fetches the +session branch, and creates `cloud/SESSION_ID`. It never forces a checkout or +overwrites an existing branch. The transcript remains linked in the browser; +native harness conversation import is not implemented. + +Cloud runs use the authenticated identity from `hanzo auth login`. Repository +access, environments, sandbox isolation, credentials, execution limits and pull +request creation are enforced by the service. An accepted session means queued +or started, not successfully completed: use `--watch` and inspect its result. +An error result exits nonzero. + +Existing resource commands expose the supporting services: + +```sh +hanzo provider github user get # connection status +hanzo provider github user connect --help # connect your GitHub identity +hanzo provider github installations # repository installations +hanzo environment list +hanzo environment set --help +hanzo agent coding changes SESSION_ID +hanzo agent coding artifacts SESSION_ID +hanzo agent sessions pause SESSION_ID +hanzo agent sessions stop SESSION_ID +hanzo auto automations --help +``` + +## Verification and remaining parity work + +The CLI workspace tests and spec derivation gates pass. A live read-only run, +`sess_c5ef4906c227756ab6055b729d0f51b8`, cloned GitHub, executed the agent, and +completed successfully. Live session listing, completed-session inspection, +GitHub connection status, and environment listing also answered successfully. + +A later live build probe, `sess_b1148a7e2be0ea3433d20dd42c189549`, leased a sandbox +but failed inference with `402: a billable tenant is required (no anonymous +usage)`. This is a production identity/billing failure, not successful cloud +execution. The CLI prints the actual run failure and exits nonzero. Production +health and full parity must not be claimed from the successful first probe alone. + +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, +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/LLM.md b/LLM.md index d803f9250..d399e536e 100644 --- a/LLM.md +++ b/LLM.md @@ -184,7 +184,7 @@ hanzo "fix the test" bare session, default backend **Every other agent is one file**, `gen:src/agents/.rs`: its names, binary, npm package (`hanzo upgrade ` installs it under `~/.hanzo/bin`), -`routable` (its model calls can go through api.hanzo.ai under `--cloud`), `toolset` +`routable` (its model calls can go through api.hanzo.ai under `--metered`), `toolset` (`Never` / `Always` / `Routed`, when it reads a home of Hanzo's own), `build` (the argv, env and files of one run, through `Run`) and `read` (its output on a linked run, through `Reader`). No agent reads another's options; `agents/wire.rs` holds only @@ -206,7 +206,7 @@ launcher of the same session path. **Routing is direct by default** (`--no-route` is gone, being the default). A third-party tool talks straight to the account it is signed in to: `resolve_routing` returns -`Route::Inherit` and touches no env. `--cloud` (`Options::cloud`) is the opt-in to +`Route::Inherit` and touches no env. `--metered` (`Options::cloud`) is the opt-in to Hanzo's metered models (the credential plan: provider key, bearer, stored Hanzo key). `dev` is ours and its account is Hanzo's, so it routes through the gateway when signed in without a flag. An agent whose file says `routable: false` (Antigravity, Cursor, Gemini, @@ -316,7 +316,7 @@ the default), no dollars for a model it does not name. Claude counts cache token apart from input and dev/codex inside it (`Tally`, `Ended.apart`). *Metered* is `GET /v1/usage/summary?range=custom&start&end` for the session window, asked only for a routed run that reported nothing, and labelled org-wide (it includes other -sessions) and possibly lagging. A run on the backend's own account (the default, no `--cloud`; +sessions) and possibly lagging. A run on the backend's own account (the default, no `--metered`; Cursor, Antigravity: `resolve_routing` always `Inherit` for those two, and `External::build` attaches no MCP) says "not observed". The cloud summary is read tolerantly (`Llm::of`): `cacheReadTokens`, `cacheWriteTokens`, `cachedShare` (a diff --git a/README.md b/README.md index 6a234c153..45f8e60fa 100644 --- a/README.md +++ b/README.md @@ -67,23 +67,29 @@ streams live to mission-control. Auto-approve is on by default; the repo's own Plain `hanzo dev` runs our own Hanzo Dev agent — `curl -fsSL https://hanzo.sh | sh` installs it. Another tool is a flag: `--claude`, `--codex`, `--cursor` (`--agent`) or `--agy`. A third-party tool talks straight to the account it is already signed in to; -`--cloud` runs it on Hanzo's metered models instead. Hanzo Dev's account is Hanzo's, so +`--metered` runs it on Hanzo's metered models instead. Hanzo Dev's account is Hanzo's, so it uses the cloud when you are signed in. ```bash hanzo dev "port this module to async" # our own agent, headless on that task hanzo dev --claude "fix the failing test" # Claude Code, on your own Claude account hanzo dev --claude -- -c # args after `--` go to the tool -hanzo dev --codex --cloud "…" # Codex on Hanzo's metered models +hanzo dev --codex --metered "…" # Codex on Hanzo's metered models hanzo dev --model enso-ultra "refactor the auth middleware" hanzo dev --resume # continue a prior session hanzo dev --ask "…" # confirm each action (alias: --safe) hanzo dev --no-link "…" # keep it local, don't stream to cloud ``` -`hanzo code` is the code index (`search`, `index`, `context`, `file`, `tree`, `ask`), -not a launcher. `hanzo code …` and `hanzo run …` as launchers are gone: they answer -with the `hanzo dev` line to use. +`hanzo --cloud "task"` executes remotely in a Hanzo sandbox and returns a session +ID. Add `--watch` to follow progress; closing the terminal leaves the cloud run +working. `hanzo --cloud` lists runs, and `hanzo --teleport SESSION_ID` fetches a +finished run's branch. See [Cloud coding sessions](CLOUD-SESSIONS.md) for setup, +follow-ups, and the supported workflow. + +`hanzo code` also serves the code index (`search`, `index`, `context`, `file`, +`tree`, `ask`). Its task form forwards to `hanzo dev`; `hanzo code --cloud` uses +the same remote execution path. Enso and Zen models (`enso`, `enso-flash`, `enso-pro`, `enso-ultra`, `zen6`, …) are Hanzo's own family; the gateway is the sole authority on the model catalog. diff --git a/src/commands/cloud_sessions.rs b/src/commands/cloud_sessions.rs new file mode 100644 index 000000000..d444af10b --- /dev/null +++ b/src/commands/cloud_sessions.rs @@ -0,0 +1,467 @@ +//! Cloud execution uses the Go coding service, not the local session streamer. +use anyhow::{bail, Context, Result}; +use hanzo_client::{Method, Reply}; +use serde_json::{json, Value}; +use std::{process::Command, time::Duration}; + +use super::product::{envelope_error, Seam}; +use crate::config::Config; +use hanzo_gen::backend::{self, Selection}; + +/// Local agent arguments remain Gen's; cloud execution belongs to this CLI. +#[derive(clap::Args, Clone)] +#[command(mut_arg("cloud", |a| a.long("metered").help("Use Hanzo's metered models for a local coding session")))] +pub struct SessionArgs { + #[command(flatten)] + pub local: hanzo_gen::Args, + #[command(flatten)] + pub remote: CloudArgs, +} + +impl std::ops::Deref for SessionArgs { + type Target = hanzo_gen::Args; + fn deref(&self) -> &Self::Target { + &self.local + } +} +impl std::ops::DerefMut for SessionArgs { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.local + } +} + +impl SessionArgs { + pub fn dev(mut self, command: &str) -> Result { + self.local = self.local.dev(command)?; + Ok(self) + } + pub fn into_options(self, root: hanzo_gen::Root) -> Result { + self.local.into_options(root) + } +} + +#[derive(clap::Args, Clone)] +pub struct CloudArgs { + /// Run on a Hanzo cloud sandbox; without a task, list cloud coding sessions. + #[arg(long = "cloud", conflicts_with_all = ["cloud", "link", "no_link", "no_mcp", "project_mcp", "ask", "no_sandbox", "runtime", "gpus", "theme", "passthrough"])] + pub cloud_execution: bool, + /// Cloud task. With a positional session id, continue that session's work. + #[arg(short = 'p', long = "prompt", requires = "cloud_execution")] + cloud_prompt: Option, + /// Repository (GitHub URL or Hanzo repository); defaults to origin. + #[arg(long, requires = "cloud_execution")] + repo: Option, + /// Cloud base branch; defaults to the current branch when using origin. + #[arg(long, requires = "cloud_execution")] + base: Option, + /// What the cloud run may do: build, plan, or setup. + #[arg(long, requires = "cloud_execution", value_parser = ["build", "plan", "setup"])] + mode: Option, + /// Follow cloud progress until the run finishes (Ctrl-C only detaches). + #[arg(long, requires = "cloud_execution")] + watch: 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, +} + +pub(crate) async fn run(cfg: &mut Config, input: SessionArgs, desktop: bool) -> Result<()> { + let SessionArgs { + local: args, + remote, + } = input; + let seam = Seam::open(cfg, &args.brand).await?; + if let Some(id) = &remote.teleport { + return checkout(&seam, id).await; + } + let named = args.named_backend(); + 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"); + } + let (backend, _) = backend::select(Selection { + named, + positional: None, + tail: None, + configured: None, + })?; + (backend, Some(prompt), args.resume.or(args.positional)) + } else { + let (backend, task) = backend::select(Selection { + named, + positional: args.positional, + tail: args.tail, + configured: None, + })?; + (backend, task, args.resume) + }; + let Some(task) = task else { + if let Some(id) = previous { + return watch(&seam, &id, remote.watch).await; + } + let page = call(&seam, Method::GET, "/v1/agent/sessions?kind=coding", None).await?; + let sessions = page + .get("sessions") + .and_then(Value::as_array) + .context("cloud session list has no sessions array")?; + if sessions.is_empty() { + println!("No cloud coding sessions."); + } + for session in sessions { + println!( + "{} {:8} {}", + field(session, "id"), + field(session, "status"), + field(session, "title") + ); + } + return Ok(()); + }; + if task.trim().is_empty() { + bail!("a cloud task cannot be empty"); + } + 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 field(&prior, "branch").is_empty() { + bail!( + "this run has no pushed branch to continue; start a new --cloud task with --repo" + ); + } + let repo = match remote.repo { + Some(repo) => repo, + None => { + let origin = repository( + &git(&["remote", "get-url", "origin"]) + .context("supply --repo to identify the previous run's repository host")?, + )?; + if !same_repo(&origin, field(&prior, "repo")) { + bail!("this checkout does not match the previous run; supply --repo"); + } + origin + } + }; + body["repo"] = json!(repo); + body["after"] = json!(id); + } else if let Some(repo) = remote.repo { + body["repo"] = json!(repo); + } else { + body["repo"] = json!(repository( + &git(&["remote", "get-url", "origin"]) + .context("no origin remote; supply --repo with a cloud repository")? + )?); + if !git(&["status", "--porcelain"])?.is_empty() { + crate::warn("uncommitted files stay local; the cloud clones the pushed branch"); + } + let branch = git(&["symbolic-ref", "--quiet", "--short", "HEAD"]) + .context("detached HEAD; supply --repo and --base")?; + if let Ok(ahead) = git(&[ + "rev-list", + "--count", + &format!("refs/remotes/origin/{branch}..HEAD"), + ]) { + if ahead != "0" { + bail!("push your local commits before starting a cloud session"); + } + } else { + bail!("current branch has no upstream; push it with `git push -u origin HEAD` first, or name --repo and --base explicitly"); + } + body["base"] = json!(branch); + } + for (key, value) in [ + ("base", remote.base), + ("mode", remote.mode), + ("model", args.model), + ] { + if let Some(value) = value { + body[key] = json!(value); + } + } + let accepted = call(&seam, Method::POST, "/v1/agent/coding", Some(body)).await?; + let id = field(&accepted, "sessionId"); + session_id(id)?; + println!( + "Cloud coding session: {id}\nRepository: {}\nhttps://hanzo.bot/sessions/{id}", + field(&accepted, "repo") + ); + if !field(&accepted, "branch").is_empty() { + println!( + "Branch: {}\nFetch branch: hanzo --teleport {id}", + field(&accepted, "branch") + ); + } + println!("Runs independently of this terminal.\nFollow: hanzo --cloud --resume {id} --watch"); + if remote.watch { + watch(&seam, id, true).await?; + } + Ok(()) +} + +fn decode(Reply { status, body }: Reply) -> Result { + if !(200..300).contains(&status) { + bail!("cloud coding: HTTP {status}: {body}"); + } + // A fetched session's `status: error` describes the run, not the GET. + // Keep its events so watch can print the actual failure before exiting. + let is_session = body.get("id").and_then(Value::as_str).is_some() + && body.get("kind").and_then(Value::as_str) == Some("coding"); + if !is_session { + if let Some(error) = envelope_error(&body) { + bail!("cloud coding: {error}"); + } + } + // A policy approval is pending, not an accepted execution. + if body.get("approval").is_some() || body.get("status").and_then(Value::as_str) == Some("held") + { + bail!("cloud request is awaiting approval: {body}"); + } + Ok(body.get("data").cloned().unwrap_or(body)) +} + +async fn call(seam: &Seam, method: Method, path: &str, body: Option) -> Result { + decode(seam.send(method, path, &[], body).await?) +} + +fn session_id(id: &str) -> Result<()> { + if id.is_empty() + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + bail!("invalid cloud session id"); + } + Ok(()) +} + +async fn detail(seam: &Seam, id: &str) -> Result { + session_id(id)?; + let value = call(seam, Method::GET, &format!("/v1/agent/sessions/{id}"), None).await?; + if field(&value, "kind") != "coding" { + bail!("{id} is not a cloud coding session"); + } + Ok(value) +} + +fn field<'a>(value: &'a Value, key: &str) -> &'a str { + value.get(key).and_then(Value::as_str).unwrap_or("") +} + +fn finished(status: &str) -> bool { + matches!(status, "done" | "error" | "stopped") +} + +fn same_repo(origin: &str, remote: &str) -> bool { + origin == remote || origin.strip_prefix("github.com/") == Some(remote) +} + +async fn watch(seam: &Seam, id: &str, follow: bool) -> Result<()> { + let mut last = 0; + let mut status = String::new(); + loop { + let info = detail(seam, id).await?; + 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); + if seq > last { + if seq > last + 1 { + println!("Earlier events omitted; see the full session in the browser."); + } + print_event(event); + last = seq; + } + } + } + let current = field(&info, "status"); + if current != status { + println!("Session {id}: {current}"); + status = current.into(); + } + if !field(&info, "pr").is_empty() { + println!("Pull request: {}", field(&info, "pr")); + } + if current == "error" { + bail!("cloud coding session {id} failed"); + } + if !follow || finished(current) || current == "paused" { + return Ok(()); + } + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(2)) => {}, + result = tokio::signal::ctrl_c() => { + result?; + println!("Detached; cloud session {id} continues running."); + return Ok(()); + } + } + } +} + +fn print_event(event: &Value) { + let payload = &event["payload"]; + for key in ["plan", "error", "message", "text"] { + if let Some(text) = payload.get(key).and_then(Value::as_str) { + if !text.trim().is_empty() { + println!("{text}"); + } + return; + } + } + if field(payload, "type") == "item.completed" { + let item = &payload["item"]; + for key in ["text", "aggregated_output"] { + if let Some(text) = item.get(key).and_then(Value::as_str) { + if !text.trim().is_empty() { + println!("{text}"); + } + return; + } + } + } + if field(payload, "type") == "item.started" { + let command = field(&payload["item"], "command"); + if !command.is_empty() { + println!("$ {command}"); + } + } +} + +fn git(args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .output() + .context("running git")?; + if !output.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +/// Normalize only known hosting addresses; never send embedded credentials. +fn repository(remote: &str) -> Result { + let value = if let Some(path) = remote.strip_prefix("git@github.com:") { + format!("github.com/{path}") + } else if let Some(path) = remote.strip_prefix("git@git.hanzo.ai:") { + path.to_owned() + } else { + let url = reqwest::Url::parse(remote) + .context("origin must be on GitHub or git.hanzo.ai; use --repo")?; + if !url.username().is_empty() && !(url.scheme() == "ssh" && url.username() == "git") + || url.password().is_some() + { + bail!("origin contains credentials; use --repo with a credential-free repository name"); + } + match url.host_str() { + Some("github.com") => format!("github.com{}", url.path()), + Some("git.hanzo.ai") => url.path().trim_start_matches('/').to_owned(), + _ => bail!( + "origin is not on GitHub or git.hanzo.ai; use --repo with a supported repository" + ), + } + }; + let value = value.trim_end_matches('/').trim_end_matches(".git"); + let path = value.strip_prefix("github.com/").unwrap_or(value); + let parts: Vec<_> = path.split('/').collect(); + if parts.len() != 2 + || parts.iter().any(|s| { + s.is_empty() + || *s == "." + || *s == ".." + || !s + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + }) + { + bail!("origin must name one owner/repository"); + } + Ok(value.to_owned()) +} + +async fn checkout(seam: &Seam, id: &str) -> Result<()> { + let info = detail(seam, id).await?; + if !finished(field(&info, "status")) && field(&info, "status") != "paused" { + bail!("wait for the cloud session to finish or pause before fetching its work"); + } + let origin = repository(&git(&["remote", "get-url", "origin"])?)?; + let remote_repo = field(&info, "repo"); + if !same_repo(&origin, remote_repo) { + bail!("this checkout's origin does not match the cloud repository {remote_repo}"); + } + if !git(&["status", "--porcelain"])?.is_empty() { + bail!("working tree is not clean; commit or stash before --teleport"); + } + let branch = field(&info, "branch"); + if branch.is_empty() || branch.starts_with('-') { + bail!("session has no valid branch to fetch"); + } + git(&["check-ref-format", "--branch", branch])?; + git(&["fetch", "origin", &format!("refs/heads/{branch}")])?; + git(&["switch", "--create", &format!("cloud/{id}"), "FETCH_HEAD"])?; + println!("Checked out cloud/{id}.\nTranscript: https://hanzo.bot/sessions/{id}\nContinue locally: hanzo"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn origins_are_normalized_without_credentials() { + for input in [ + "git@github.com:acme/app.git", + "https://github.com/acme/app.git", + "ssh://git@github.com/acme/app.git", + ] { + assert_eq!(repository(input).unwrap(), "github.com/acme/app"); + } + assert_eq!( + repository("git@git.hanzo.ai:acme/app.git").unwrap(), + "acme/app" + ); + for input in [ + "https://secret@github.com/a/b", + "https://github.com.evil/a/b", + "/tmp/repo", + "git@github.com:../app", + "https://github.com/a/b/tree/main", + ] { + assert!(repository(input).is_err(), "{input}"); + } + } + #[test] + fn errors_and_approvals_are_not_success() { + assert!(decode(Reply { + status: 200, + body: json!({"id":"sess_failed", "kind":"coding", "status":"error", "recentEvents":[]}), + }) + .is_ok()); + assert!(decode(Reply { + status: 503, + body: json!({"error":"capacity"}) + }) + .is_err()); + assert!(decode(Reply { + status: 202, + body: json!({"status":"held", "approval": {"id":"a"}}) + }) + .is_err()); + assert_eq!( + decode(Reply { + status: 202, + body: json!({"sessionId":"sess_x"}) + }) + .unwrap()["sessionId"], + "sess_x" + ); + for id in ["", "../x", "s?other=x", "s/x"] { + assert!(session_id(id).is_err()); + } + } +} diff --git a/src/commands/dev.rs b/src/commands/dev.rs index 3f7170921..5e6ab451a 100644 --- a/src/commands/dev.rs +++ b/src/commands/dev.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use anyhow::Result; use hanzo_gen::host::Pending; -use hanzo_gen::{Account, Args, Host, Profile, Provider, Root}; +use hanzo_gen::{Account, Host, Profile, Provider, Root}; +use super::cloud_sessions::SessionArgs; use serde_json::Value; use tokio::sync::Mutex; @@ -153,7 +154,10 @@ pub enum Target { /// /// Do not add a second launcher for a new spelling: add the spelling to /// `select` and let it land here like the rest. -pub async fn session(config: &mut Config, args: Args, target: Target) -> Result<()> { +pub async fn session(config: &mut Config, args: SessionArgs, target: Target) -> Result<()> { + if args.remote.cloud_execution || args.remote.teleport.is_some() { + return super::cloud_sessions::run(config, args, target == Target::Desktop).await; + } onboarding::first_run(config, paths::DEFAULT_BRAND).await; let mut opts = args.into_options(Root::need()?)?; if target == Target::Desktop { diff --git a/src/commands/man.rs b/src/commands/man.rs index 6e3dae2b2..73c2c8133 100644 --- a/src/commands/man.rs +++ b/src/commands/man.rs @@ -121,6 +121,10 @@ pub fn page(cmd: &clap::Command) -> String { \x20 `hanzo status` shows cloud health and local inference cluster status.\n\n", ); + o.push_str(" `hanzo --cloud \"task\"` runs in a cloud sandbox; `--watch` follows it.\n\ + \x20 `hanzo --cloud` lists runs; `--cloud --resume ID --watch` follows one.\n\ + \x20 `hanzo --teleport ID` fetches its branch into a clean checkout.\n\ + \x20 `--metered` selects Hanzo models for a local session.\n\n"); o.push_str(&format!("{}\n", b("GLOBAL FLAGS"))); for (f, d) in [ ("--config=FILE", "Use a custom CLI config file for this invocation."), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 01ad6bfa2..e3ed457e3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -23,6 +23,7 @@ pub mod up; // The one coding/agent orchestrator, behind `hanzo dev` and `hanzo desktop`. pub mod dev; +pub mod cloud_sessions; // Kept resources reachable in the additive model (money, network, provider // connectors, local dev helpers, and the TS SDK proxies). diff --git a/src/main.rs b/src/main.rs index 96ba3aa2e..fd85494e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,7 +78,7 @@ struct Cli { /// The coding-session args, flattened so a bare `hanzo [flags] [task]` is a /// coding session with them. Ignored when an explicit subcommand is given. #[command(flatten)] - code: hanzo_gen::Args, + code: commands::cloud_sessions::SessionArgs, /// Optional: a truly-bare `hanzo` (no subcommand) launches a cloud-linked /// coding session from the flattened `code` args above. `--help`/`-h` and every @@ -98,8 +98,9 @@ enum Commands { Code, /// Start a coding session: a coding agent with the Hanzo MCP toolset - /// attached, on the account its tool is signed in to (`--cloud` for - /// Hanzo's metered models), and the session streamed live to mission control + /// attached, on the account its tool is signed in to (`--metered` for + /// Hanzo's metered models), and the session streamed live to mission control. + /// Use `--cloud` to execute the task remotely in a Hanzo sandbox. /// /// Runs our own `dev` agent by default. Name another tool with a flag: /// @@ -111,14 +112,14 @@ enum Commands { /// A trailing task runs headless (`hanzo dev --claude "fix the failing /// test"`); omit it for an interactive session. #[command(verbatim_doc_comment)] - Dev(hanzo_gen::Args), + Dev(commands::cloud_sessions::SessionArgs), /// Point an agent at the desktop and browser instead of the repo /// /// The same session as `hanzo dev` — same backends, same linking, same /// metering — aimed somewhere else. The Hanzo browser/computer tools ARE /// how it drives the desktop, so this pins the toolset on. - Desktop(hanzo_gen::Args), + Desktop(commands::cloud_sessions::SessionArgs), /// Manage identities and credentials Auth { @@ -528,7 +529,7 @@ enum UpCommands { #[derive(Subcommand)] enum SandboxCommands { /// Run a coding agent in an isolated sandbox (matches `sbx run `) - Run(Box), + Run(Box), /// List active sandboxes & agent workspaces #[command(alias = "ls", alias = "ps")] List, @@ -821,7 +822,8 @@ fn alias(cmd: &clap::Command, mut argv: Vec) -> Vec { let help = rest.iter().any(|a| a == "--help" || a == "-h"); let index = first_word(rest.iter().cloned()) .is_some_and(|w| cmd.find_subcommand("code").is_some_and(|c| c.find_subcommand(&w).is_some())); - if !(help || index) { + let cloud = rest.iter().any(|a| a == "--cloud" || a == "--teleport"); + if cloud || !(help || index) { argv[at] = "dev".into(); } argv @@ -1348,8 +1350,8 @@ mod tests { let opts = args.dev("hanzo dev").unwrap().into_options(root()).expect("resolves"); assert!(!opts.cloud && opts.no_link && !opts.mcp && opts.no_sandbox); assert_eq!(opts.resume.as_deref(), Some("abc")); - // `--cloud` opts into Hanzo's metered models; `--no-route` is gone, being the default. - let cli = Cli::try_parse_from(["hanzo", "dev", "--claude", "--cloud"]).expect("parses"); + // `--metered` selects cloud models for a local run; `--cloud` executes remotely. + let cli = Cli::try_parse_from(["hanzo", "dev", "--claude", "--metered"]).expect("parses"); let Some(Commands::Dev(args)) = cli.command else { panic!("expected dev") }; assert!(args.dev("hanzo dev").unwrap().into_options(root()).expect("resolves").cloud); assert!(Cli::try_parse_from(["hanzo", "dev", "--claude", "--no-route"]).is_err()); diff --git a/tests/cloud_sessions.rs b/tests/cloud_sessions.rs new file mode 100644 index 000000000..ce76f5fde --- /dev/null +++ b/tests/cloud_sessions.rs @@ -0,0 +1,37 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn cloud_entry_points_are_exposed() { + for prefix in [vec![], vec!["dev"], vec!["code", "--cloud"]] { + Command::cargo_bin("hanzo") + .unwrap() + .args(prefix) + .arg("-h") + .assert() + .success() + .stdout( + predicate::str::contains("--cloud") + .and(predicate::str::contains("--teleport")) + .and(predicate::str::contains("--watch")), + ); + } +} + +#[test] +fn cloud_does_not_silently_ignore_local_execution_options() { + for option in ["--no-link", "--metered", "--no-sandbox", "--ask"] { + Command::cargo_bin("hanzo") + .unwrap() + .args(["--cloud", option, "task"]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used")); + } + Command::cargo_bin("hanzo") + .unwrap() + .args(["--mode", "plan", "task"]) + .assert() + .failure() + .stderr(predicate::str::contains("--cloud")); +} From 330853646d67304354247140a0dc5ed688355631 Mon Sep 17 00:00:00 2001 From: Zach Kelling Date: Sat, 17 Oct 2026 21:43:30 -0700 Subject: [PATCH 2/6] Record repeat live cloud identity failure --- CLOUD-SESSIONS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLOUD-SESSIONS.md b/CLOUD-SESSIONS.md index 84f53a0ce..4618b4be1 100644 --- a/CLOUD-SESSIONS.md +++ b/CLOUD-SESSIONS.md @@ -75,6 +75,8 @@ but failed inference with `402: a billable tenant is required (no anonymous usage)`. This is a production identity/billing failure, not successful cloud execution. The CLI prints the actual run failure and exits nonzero. Production health and full parity must not be claimed from the successful first probe alone. +A repeat repository-backed plan run, `sess_58451903b3e7afe6ebe9494c51de0b24`, +failed with the same 402, confirming this also affects GitHub-backed execution. Still unimplemented here: uploading a local repository bundle, importing the native harness conversation into a local session, and delivering follow-up From e351a1e3a34676985856d2c24f35c822470b99ff Mon Sep 17 00:00:00 2001 From: Zach Kelling Date: Sun, 18 Oct 2026 19:34:40 -0700 Subject: [PATCH 3/6] Add opt-in production CLI and installer end-to-end checks Exercise actual token login, authenticated usage, local file creation and remote coding completion. Repair the live auth test to call usage summary and verify the identity scope. Testing: make test passed; live login with source and published binaries passed; local dev file-creation test passed. Production cloud test currently reproduces the API 402 pending release. --- E2E.md | 21 ++++++++ Makefile | 8 +++ scripts/e2e-installed.sh | 14 ++++++ tests/cli.rs | 13 +++-- tests/production.rs | 103 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 E2E.md create mode 100755 scripts/e2e-installed.sh create mode 100644 tests/production.rs diff --git a/E2E.md b/E2E.md new file mode 100644 index 000000000..9f4c962a2 --- /dev/null +++ b/E2E.md @@ -0,0 +1,21 @@ +# CLI end-to-end checks + +`make test` runs the Rust workspace, command/parser checks, installer fixtures and API-spec drift checks. Live checks are opt-in: unit tests alone do not prove production works. + +Run the production checks using your existing login: + +```sh +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. + +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 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/Makefile b/Makefile index c52a7681b..1f1cc84f1 100644 --- a/Makefile +++ b/Makefile @@ -129,3 +129,11 @@ build: ## The shipped binary. lint: @cargo clippy --locked --all-targets -- -D warnings + +.PHONY: e2e e2e-installed +e2e: ## Real login, usage, local dev and cloud sessions (requires HANZO_E2E_TOKEN). + @test -n "$${HANZO_E2E_TOKEN:-}" || { echo 'Set HANZO_E2E_TOKEN using hanzo auth token'; exit 1; } + @cargo test --locked --test production -- --ignored --nocapture --test-threads=1 + +e2e-installed: ## Install published tools in isolation and test production end to end. + @sh scripts/e2e-installed.sh diff --git a/scripts/e2e-installed.sh b/scripts/e2e-installed.sh new file mode 100755 index 000000000..554bf8eb7 --- /dev/null +++ b/scripts/e2e-installed.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +: "${HANZO_E2E_TOKEN:?Set HANZO_E2E_TOKEN using hanzo auth token}" +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 + "$e2e_dir/bin/$tool" --version +done +[ "$("$e2e_dir/bin/hanzo" --version)" = "$("$e2e_dir/bin/hanzo-node" --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/tests/cli.rs b/tests/cli.rs index b8f44d0eb..cd5035baf 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -490,12 +490,15 @@ fn live_login_whoami_usage_with_env_token() { .stdout(predicate::str::contains("/")) .stdout(predicate::str::contains("not signed in").not()); - // `usage` fans out to api.hanzo.ai with each held account's own token. A - // stacked view always renders; a broke/expired account is a row state, not - // a crash — so success + at least one row is the honest assertion. - hanzo() - .arg("usage") + // Exercise the published command and verify that the response belongs to + // an authenticated account, rather than accepting an HTML page or empty JSON. + let usage = hanzo() + .args(["usage", "summary"]) .args(cfg_args(&cfg)) .assert() .success(); + let summary: serde_json::Value = serde_json::from_slice(&usage.get_output().stdout) + .expect("usage summary returns JSON"); + assert!(summary["scope"]["org"].as_str().is_some_and(|v| !v.is_empty())); + assert!(summary["scope"]["user"].as_str().is_some_and(|v| !v.is_empty())); } diff --git a/tests/production.rs b/tests/production.rs new file mode 100644 index 000000000..70b8b4177 --- /dev/null +++ b/tests/production.rs @@ -0,0 +1,103 @@ +//! Opt-in tests of real installed binaries and production services. +//! Run with HANZO_E2E_TOKEN and optionally HANZO_E2E_BIN; see E2E.md. +use assert_cmd::Command; +use std::{fs, path::PathBuf, time::Duration}; + +struct Session { + root: tempfile::TempDir, + binary: PathBuf, +} +impl Session { + fn new() -> Self { + let token = std::env::var("HANZO_E2E_TOKEN") + .expect("HANZO_E2E_TOKEN is required; obtain it with hanzo auth token"); + assert!(!token.trim().is_empty(), "HANZO_E2E_TOKEN is empty"); + let s = Self { + root: tempfile::tempdir().unwrap(), + binary: std::env::var_os("HANZO_E2E_BIN") + .map(PathBuf::from) + .unwrap_or_else(|| assert_cmd::cargo::cargo_bin("hanzo")), + }; + s.cmd() + .args(["auth", "login", "--token", "-"]) + .write_stdin(format!("{}\n", token.trim())) + .assert() + .success(); + s + } + fn cmd(&self) -> Command { + let mut c = Command::new(&self.binary); + c.current_dir(self.root.path()) + .env("HOME", self.root.path()) + .env("NO_COLOR", "1") + .env("HANZO_NO_ANIMATION", "1") + .args([ + "--config", + self.root.path().join("config.toml").to_str().unwrap(), + ]) + .timeout(Duration::from_secs(300)); + for key in [ + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + ] { + c.env_remove(key); + } + c + } +} + +#[test] +#[ignore = "uses a real production identity and network"] +fn production_login_and_usage() { + let s = Session::new(); + s.cmd().args(["auth", "show"]).assert().success(); + let out = s.cmd().args(["usage", "summary"]).assert().success(); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + assert!(v["scope"]["org"].as_str().is_some_and(|s| !s.is_empty())); + assert!(v["scope"]["user"].as_str().is_some_and(|s| !s.is_empty())); +} + +#[test] +#[ignore = "runs a metered coding task in a temporary directory"] +fn production_local_dev_writes_a_file() { + let s = Session::new(); + Command::new("git") + .args(["init", "--quiet"]) + .current_dir(s.root.path()) + .assert() + .success(); + s.cmd().args(["dev", "--metered", "--no-link", "--no-mcp", + "Create the file hanzo-e2e.txt in the current directory containing exactly HANZO_E2E_OK followed by a newline. Do not modify any other file. Then read it back to verify it."]) + .assert().success(); + assert_eq!( + fs::read_to_string(s.root.path().join("hanzo-e2e.txt")).unwrap(), + "HANZO_E2E_OK\n" + ); +} + +#[test] +#[ignore = "leases a production sandbox and runs a metered read-only task"] +fn production_cloud_session_completes() { + let s = Session::new(); + let out = s + .cmd() + .args([ + "--cloud", + "--mode", + "plan", + "--repo", + "https://github.com/hanzoai/cli", + "-p", + "Read Cargo.toml and report the package name. Do not edit files.", + "--watch", + ]) + .assert() + .success(); + let text = String::from_utf8_lossy(&out.get_output().stdout); + assert!( + text.contains("hanzo-cli"), + "cloud task did not report the repository's package name" + ); +} From b09604c753b7db263ecc71faaec7927358ea2aaf Mon Sep 17 00:00:00 2001 From: Zach Kelling Date: Mon, 19 Oct 2026 13:11:22 -0700 Subject: [PATCH 4/6] Release 8.5.752 with remote cloud sessions and production smoke tests Testing: full CLI suite, live token login, local dev file creation, and completed production cloud session. Risk: medium; --cloud now selects remote sessions and --metered selects local billed inference. How to revert: install v8.5.751. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76eefacfe..bba0a308f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -886,7 +886,7 @@ dependencies = [ [[package]] name = "hanzo-cli" -version = "8.5.751" +version = "8.5.752" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 5060673bd..243669a9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hanzo-cli" -version = "8.5.751" +version = "8.5.752" edition = "2021" authors = ["Hanzo AI"] description = "Unified CLI for Hanzo AI development tools" From 2248f975bcc99fa3126823ea661c0f4dd2895708 Mon Sep 17 00:00:00 2001 From: Zach Kelling Date: Mon, 19 Oct 2026 13:12:53 -0700 Subject: [PATCH 5/6] Keep PATH lookup tests from changing other tests process environment Testing: full CLI suite rerun; first-match lookup still tested with two executable fixtures. Risk: low; production lookup delegates to the same pure path search. How to revert: revert this commit. --- src/commands/version.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/commands/version.rs b/src/commands/version.rs index 2fa65d699..7806207bd 100644 --- a/src/commands/version.rs +++ b/src/commands/version.rs @@ -138,7 +138,11 @@ fn real(p: &Path) -> PathBuf { /// shelling out, so this answers the same way in a bare environment. fn which(name: &str) -> Option { let path = std::env::var_os("PATH")?; - std::env::split_paths(&path).map(|dir| dir.join(name)).find(|p| is_executable(p)) + which_in(name, &path) +} + +fn which_in(name: &str, path: &std::ffi::OsStr) -> Option { + std::env::split_paths(path).map(|dir| dir.join(name)).find(|p| is_executable(p)) } #[cfg(unix)] @@ -258,13 +262,7 @@ mod tests { stub(&late, DELEGATE, "hanzo 2.0.0"); let joined = std::env::join_paths([&early, &late]).unwrap(); - // SAFETY: single-threaded test; restored before returning. - let prev = std::env::var_os("PATH"); - std::env::set_var("PATH", &joined); - let found = which(DELEGATE).expect("finds the delegate"); - if let Some(prev) = prev { - std::env::set_var("PATH", prev); - } + let found = which_in(DELEGATE, &joined).expect("finds the delegate"); assert_eq!(version_of(&found).as_deref(), Some("1.0.0"), "the earlier PATH entry wins"); } From 5765bf6da03739fc90caf2e92303aa7abfa4fe3b Mon Sep 17 00:00:00 2001 From: Zach Kelling Date: Mon, 19 Oct 2026 13:13:43 -0700 Subject: [PATCH 6/6] Document deployed cloud billing fix and repeatable live checks --- CLOUD-SESSIONS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CLOUD-SESSIONS.md b/CLOUD-SESSIONS.md index 4618b4be1..adf2971ee 100644 --- a/CLOUD-SESSIONS.md +++ b/CLOUD-SESSIONS.md @@ -70,13 +70,13 @@ The CLI workspace tests and spec derivation gates pass. A live read-only run, completed successfully. Live session listing, completed-session inspection, GitHub connection status, and environment listing also answered successfully. -A later live build probe, `sess_b1148a7e2be0ea3433d20dd42c189549`, leased a sandbox -but failed inference with `402: a billable tenant is required (no anonymous -usage)`. This is a production identity/billing failure, not successful cloud -execution. The CLI prints the actual run failure and exits nonzero. Production -health and full parity must not be claimed from the successful first probe alone. -A repeat repository-backed plan run, `sess_58451903b3e7afe6ebe9494c51de0b24`, -failed with the same 402, confirming this also affects GitHub-backed execution. +The delegated-identity 402 failure found during live testing is fixed in production +(API v8.5.838 and later). IAM's confined inference token carries a validated +billing organization even though its role is deliberately limited to member. +The API now preserves that validated organization without granting admin access. +A production cloud completion test passed after deployment. Token login and a +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