Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .spec-lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ref=482fcefda628da07a8271d4a63cd27926beadd47
sha256=7d8c9ca26971916a35a86cda76561e743dccecafd9baee137f3bb77b5c181a6f
ref=e50c01a6c94ef0d7de7876fb55b182f595bbcf50
sha256=579538863b33b1bb84f710517581b4a40cd71871354f69a3744b995d563e17c8
repo=hanzo-inc/cloud
path=openapi.yaml
1 change: 1 addition & 0 deletions LLM.md
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,7 @@ a projection that repairs its source hides the defect at the one place it can be

- With no `--as`, the org is `default_org` from the config file, then `HANZO_ORG` (`main.rs`).
- `active_token` (`src/iam/store.rs`) refreshes with the credential the vault holds after taking the lock, not the one read before it: a process that waited may hold a refresh token another one already spent.
- `HANZO_API_KEY`, when set, is the bearer of every cloud command (`Seam::open`), ahead of a stored login, as `hanzo dev`'s provider already reads it. A key is still never filed as an identity. Why: a sandbox leased with an API key gets no CLI session (cloud apps/sandbox `sessionFor`), so its `hanzo` said `not signed in`; signed in as a person it read none of the sessions the key had opened, since a session belongs to its actor (`hanzo/hanzo/z` for the org key `hanzo/grok-bot-iam`) and answered 404.

## `hanzo dev` startup, measured (2026-10-12)

Expand Down
2 changes: 1 addition & 1 deletion spec/cloud.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion spec/live.json

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions src/commands/product/generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

use super::{Field, Op, Ty};

/// 2629 coordinates across 147 products (1085 typed-flag, 451 --data-fallback writes).
/// 2628 coordinates across 147 products (1084 typed-flag, 451 --data-fallback writes).
pub(crate) static OPS: &[Op] = &[
Op { product: "account", nodes: &[], verb: "csrf", method: "GET", path: "/v1/account/csrf", params: &[], rest: &[], fields: &[], sum: "Mints the anti-forgery token a browser echoes as X-CSRF-Token on every change it asks for." },
Op { product: "account", nodes: &[], verb: "embed", method: "GET", path: "/v1/account/embed", params: &[], rest: &[], fields: &[Field { key: "app", id: "query.app", flag: "app", ty: Ty::Str, required: false, choices: &[], query: true, secret: false, repeat: false }], sum: "Reports whether one of this brand's shared embedded apps (cms, erp, help) may be framed by the caller and is actually running, so a console module can choose between the embed and the provision panel." },
Expand Down Expand Up @@ -116,6 +116,7 @@ pub(crate) static OPS: &[Op] = &[
Op { product: "ai", nodes: &[], verb: "answer", method: "GET", path: "/v1/ai/answer", params: &[], rest: &[], fields: &[], sum: "Answer" },
Op { product: "ai", nodes: &[], verb: "feedback", method: "POST", path: "/v1/ai/feedback", params: &[], rest: &[], fields: &[Field { key: "rating", id: "field.rating", flag: "rating", ty: Ty::Num, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "request_id", id: "field.request_id", flag: "request-id", ty: Ty::Str, required: true, choices: &[], query: false, secret: false, repeat: false }, Field { key: "reward", id: "field.reward", flag: "reward", ty: Ty::Num, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "signal", id: "field.signal", flag: "signal", ty: Ty::Str, required: false, choices: &["up", "accept", "regenerate", "down", "switch", "abandon", "revert", "rating", "dismiss"], query: false, secret: false, repeat: false }], sum: "Attaches a per-request outcome reward to the routing decision that served request_id — the enso training loop's quality signal." },
Op { product: "ai", nodes: &[], verb: "k8s-status", method: "GET", path: "/v1/ai/k8s-status", params: &[], rest: &[], fields: &[], sum: "K8s Status" },
Op { product: "ai", nodes: &[], verb: "limits", method: "GET", path: "/v1/ai/limits", params: &[], rest: &[], fields: &[], sum: "Reads the caller's plan usage: for each class of model the plan includes, the share used of the billing period and of its short window, who pays for the next request, and whether the caller is in limited mode and why; the share used of the session and the day; and the ways on." },
Op { product: "ai", nodes: &[], verb: "prometheus", method: "GET", path: "/v1/ai/prometheus", params: &[], rest: &[], fields: &[], sum: "Prometheus" },
Op { product: "ai", nodes: &[], verb: "signin", method: "POST", path: "/v1/ai/signin", params: &[], rest: &[], fields: &[], sum: "Signin" },
Op { product: "ai", nodes: &[], verb: "signout", method: "POST", path: "/v1/ai/signout", params: &[], rest: &[], fields: &[], sum: "Signout" },
Expand Down Expand Up @@ -195,8 +196,6 @@ pub(crate) static OPS: &[Op] = &[
Op { product: "ai", nodes: &["graphs", "owner"], verb: "rm", method: "DELETE", path: "/v1/ai/graphs/{owner}/{name}", params: &["owner", "name"], rest: &[], fields: &[], sum: "Delete a graph" },
Op { product: "ai", nodes: &["graphs", "owner"], verb: "set", method: "PUT", path: "/v1/ai/graphs/{owner}/{name}", params: &["owner", "name"], rest: &[], fields: &[], sum: "Replace a graph" },
Op { product: "ai", nodes: &["graphs", "owner"], verb: "update", method: "PATCH", path: "/v1/ai/graphs/{owner}/{name}", params: &["owner", "name"], rest: &[], fields: &[], sum: "Update a graph" },
Op { product: "ai", nodes: &["limits"], verb: "get", method: "GET", path: "/v1/ai/limits", params: &[], rest: &[], fields: &[], sum: "Reads the caller's plan usage: for each class of model the plan includes, the share used of the billing period and of its short window, who pays for the next request, and whether the caller is in limited mode and why; the share used of the session and the day; and the ways on." },
Op { product: "ai", nodes: &["limits"], verb: "replace", method: "PUT", path: "/v1/ai/limits", params: &[], rest: &[], fields: &[Field { key: "creditsAfterAllowance", id: "field.creditsAfterAllowance", flag: "credits-after-allowance", ty: Ty::Bool, required: false, choices: &[], query: false, secret: false, repeat: false }], sum: "Sets the payer's choice to keep using a model on credits once the plan's included usage of it is spent." },
Op { product: "ai", nodes: &["mcp"], verb: "chat", method: "POST", path: "/v1/ai/mcp/chat", params: &[], rest: &[], fields: &[Field { key: "max_tokens", id: "field.max_tokens", flag: "max-tokens", ty: Ty::Int, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "model", id: "field.model", flag: "model", ty: Ty::Str, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "prompt", id: "field.prompt", flag: "prompt", ty: Ty::Str, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "system", id: "field.system", flag: "system", ty: Ty::Str, required: false, choices: &[], query: false, secret: false, repeat: false }], sum: "Asks one model one prompt and answers the reply, which model served it and who paid." },
Op { product: "ai", nodes: &["mcp"], verb: "decisions", method: "POST", path: "/v1/ai/mcp/decisions", params: &[], rest: &[], fields: &[Field { key: "handle", id: "field.handle", flag: "handle", ty: Ty::Str, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "model", id: "field.model", flag: "model", ty: Ty::Str, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "observe", id: "field.observe", flag: "observe", ty: Ty::Str, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "questions", id: "field.questions", flag: "questions", ty: Ty::Json, required: false, choices: &[], query: false, secret: false, repeat: false }, Field { key: "state", id: "field.state", flag: "state", ty: Ty::Str, required: false, choices: &[], query: false, secret: false, repeat: false }], sum: "Runs one decision — the caller's own POST /v1/decisions, gated and billed exactly as that route is — and answers the decision, which model served it and who paid." },
Op { product: "ai", nodes: &["mcp"], verb: "models", method: "GET", path: "/v1/ai/mcp/models", params: &[], rest: &[], fields: &[Field { key: "q", id: "query.q", flag: "q", ty: Ty::Str, required: false, choices: &[], query: true, secret: false, repeat: false }, Field { key: "class", id: "query.class", flag: "class", ty: Ty::Str, required: false, choices: &[], query: true, secret: false, repeat: false }, Field { key: "family", id: "query.family", flag: "family", ty: Ty::Str, required: false, choices: &[], query: true, secret: false, repeat: false }, Field { key: "capability", id: "query.capability", flag: "capability", ty: Ty::Str, required: false, choices: &[], query: true, secret: false, repeat: false }, Field { key: "free", id: "query.free", flag: "free", ty: Ty::Bool, required: false, choices: &[], query: true, secret: false, repeat: false }, Field { key: "zdr", id: "query.zdr", flag: "zdr", ty: Ty::Bool, required: false, choices: &[], query: true, secret: false, repeat: false }, Field { key: "limit", id: "query.limit", flag: "limit", ty: Ty::Int, required: false, choices: &[], query: true, secret: false, repeat: false }], sum: "Searches the model catalog — the one GET /v1/models lists, every OpenRouter model among it as last synced — by text, class, family, capability or free, and answers each match with its class, family, whether it is free, its list price (per token and per million; variable for a router billed at the answering model's price; a resold model's every vendor rate and conditional rate as billed), context window, output bound, expiry and capabilities." },
Expand Down
20 changes: 20 additions & 0 deletions src/commands/product/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,18 @@ impl Seam {
// party.
let origin = host::origin(cfg).await?;

// A MACHINE CALLER'S KEY, read at the point of use. An API key names no
// principal, so it is never filed as an identity (Identity::from_access_token
// refuses one); a caller that holds only a key — a bot in a sandbox, a CI
// step — sets HANZO_API_KEY and every cloud command speaks as that key. It
// wins over a stored login for the reason `hanzo dev`'s provider tries it
// first: the variable is chosen for this process, the login is the machine's
// default. Without it a box answered `not signed in`, and a box signed in as a
// person read none of the sessions its key had opened (404).
if let Some(token) = machine_key(std::env::var_os("HANZO_API_KEY")) {
return Ok(Self { origin, token, hint: None, org: cfg.org.clone(), wire: Http::default() });
}

// WHO. A local host may have no IAM to sign in to, so a missing credential
// is NOT refused here — the call goes out unauthenticated and the server
// decides. That is the same rule this module already follows for a 403:
Expand Down Expand Up @@ -806,6 +818,14 @@ impl Seam {
}
}

/// The bearer a machine caller set in `HANZO_API_KEY`, or `None` when it is unset,
/// blank or not text. Surrounding whitespace is a paste, not part of the key.
fn machine_key(var: Option<std::ffi::OsString>) -> Option<String> {
let key = var?.into_string().ok()?;
let key = key.trim();
(!key.is_empty()).then(|| key.to_string())
}

/// Map an op's method string to a `reqwest::Method`.
fn parse_method(m: &str) -> Result<Method> {
match m {
Expand Down
20 changes: 20 additions & 0 deletions src/commands/product/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1287,3 +1287,23 @@ fn a_union_flag_sends_json_as_json_and_text_as_text() {
// A scalar that happens to parse as JSON is still text: the union admits no number.
assert_eq!(send("42"), serde_json::json!("42"));
}

// ---- a machine caller's key --------------------------------------------------

/// HANZO_API_KEY is the bearer of a caller that holds only a key — a bot in a
/// sandbox, a CI step. Blank or unset is no key at all, so the stored login (or the
/// `not signed in` refusal) still decides; a pasted key's whitespace is dropped.
#[test]
fn a_machine_key_is_the_variable_trimmed_and_nothing_when_blank() {
use std::ffi::OsString;
assert_eq!(machine_key(None), None);
assert_eq!(machine_key(Some(OsString::from(""))), None);
assert_eq!(machine_key(Some(OsString::from(" \n"))), None);
assert_eq!(machine_key(Some(OsString::from("sk-live-x"))), Some("sk-live-x".into()));
assert_eq!(machine_key(Some(OsString::from(" sk-live-x\n"))), Some("sk-live-x".into()));
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
assert_eq!(machine_key(Some(OsString::from_vec(vec![0xff, 0xfe]))), None, "not text is no key");
}
}
Loading