diff --git a/CLOUD-SESSIONS.md b/CLOUD-SESSIONS.md new file mode 100644 index 000000000..adf2971ee --- /dev/null +++ b/CLOUD-SESSIONS.md @@ -0,0 +1,86 @@ +# 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. + +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 +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/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" 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/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/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/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/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/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/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"); } 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/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/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")); +} 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" + ); +}