A Hanzo sign-in refreshes itself, Hanzo models are known, and no stray call or link is left

The Hanzo provider asks `dev token` for its credential every ten minutes
instead of reading HANZO_USER_KEY once at startup, so a session outlives the
hour a sign-in token lives. A profile still on the old startup-only provider is
moved to it; one the user changed is left alone. `dev token` prefers an API
key, then a fresh token from the Hanzo CLI, to a sign-in token inherited from
whatever launched the agent.

The chat models api.hanzo.ai serves carry their real window and inputs, so
enso-auto and the rest no longer run on fallback metadata.

A justification without sandbox_permissions runs at the default permissions
instead of failing the shell call. Featured plugins are asked for only with a
ChatGPT sign-in, which is the only caller that endpoint answers. The profile
links to the shared credential only when it exists, and a sign-in links first
so it writes the shared one.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-10-07 10:15:19 -07:00
co-authored by Hanzo Dev
parent 191966e9e0
commit 403b906d17
8 changed files with 335 additions and 7 deletions
+83
View File
@@ -0,0 +1,83 @@
//! The chat models api.hanzo.ai serves, as the agent needs to know them.
//!
//! The agent sizes its context, compaction and image input from a model's
//! metadata, and a slug it cannot find runs on a 272K fallback with a warning.
//! These are the chat slugs `GET https://api.hanzo.ai/v1/models` lists; the
//! embedding, rerank, guard and voice models are not chat models and are absent.
/// What the agent needs to know about one served chat model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Model {
pub slug: &'static str,
pub name: &'static str,
/// Context window in tokens.
pub window: i64,
/// Accepts images as well as text.
pub vision: bool,
}
const MILLION: i64 = 1_000_000;
const fn model(slug: &'static str, name: &'static str, vision: bool) -> Model {
Model {
slug,
name,
window: MILLION,
vision,
}
}
pub const MODELS: &[Model] = &[
model("enso", "Enso", true),
model("enso-auto", "Enso Auto", true),
model("enso-flash", "Enso Flash", true),
model("enso-free", "Enso Free", true),
model("enso-pro", "Enso Pro", false),
model("enso-ultra", "Enso Ultra", true),
model("hanzo/enso", "Enso", true),
model("hanzo/zen", "Zen", true),
model("zen-free", "Zen Free", true),
model("zen-vl", "Zen VL", true),
model("zen5", "Zen 5", true),
model("zen5-coder", "Zen 5 Coder", false),
model("zen5-evo", "Zen 5 Evo", true),
model("zen5-flash", "Zen 5 Flash", true),
model("zen5-mini", "Zen 5 Mini", false),
model("zen5-pro", "Zen 5 Pro", false),
model("zen5-spark", "Zen 5 Spark", true),
model("zen6", "Zen 6", true),
model("zen6-flash", "Zen 6 Flash", true),
];
/// The served chat model named `slug`, if there is one.
pub fn find(slug: &str) -> Option<&'static Model> {
MODELS.iter().find(|model| model.slug == slug)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_model_is_known() {
let model = find(crate::DEFAULT_MODEL).expect("the default model is in the catalog");
assert_eq!(model.window, 1_000_000);
}
#[test]
fn every_slug_is_listed_once() {
for (index, model) in MODELS.iter().enumerate() {
assert!(
!MODELS[..index].iter().any(|other| other.slug == model.slug),
"{} is listed twice",
model.slug
);
}
}
#[test]
fn an_unserved_slug_is_unknown() {
assert_eq!(find("zen-embedding"), None);
assert_eq!(find("not-a-model"), None);
}
}
+32
View File
@@ -50,6 +50,7 @@ fn the_default_profile_shares_one_account() {
let root = tempfile::tempdir().unwrap();
let home = root.path().join(".hanzo/dev");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(root.path().join(".hanzo/auth.json"), "{}").unwrap();
share_credential(&home);
let link = std::fs::read_link(home.join("auth.json")).unwrap();
assert_eq!(link, root.path().join(".hanzo/auth.json"));
@@ -61,6 +62,7 @@ fn sharing_the_account_again_changes_nothing() {
let root = tempfile::tempdir().unwrap();
let home = root.path().join(".hanzo/dev");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(root.path().join(".hanzo/auth.json"), "{}").unwrap();
share_credential(&home);
share_credential(&home);
let link = std::fs::read_link(home.join("auth.json")).unwrap();
@@ -89,3 +91,33 @@ fn an_explicit_profile_keeps_its_own_account() {
share_credential(&home);
assert!(std::fs::symlink_metadata(home.join("auth.json")).is_err());
}
/// With no shared credential there is nothing to link to, so no link is made,
/// and a link left pointing at nothing is removed.
#[test]
fn no_link_names_a_missing_credential() {
let root = tempfile::tempdir().unwrap();
let home = root.path().join(".hanzo/dev");
std::fs::create_dir_all(&home).unwrap();
share_credential(&home);
assert!(std::fs::symlink_metadata(home.join("auth.json")).is_err());
link(&root.path().join(".hanzo/auth.json"), &home.join("auth.json")).unwrap();
share_credential(&home);
assert!(std::fs::symlink_metadata(home.join("auth.json")).is_err());
}
/// A sign-in about to save links first, so it writes the one shared credential.
#[test]
fn a_sign_in_writes_the_shared_credential() {
let root = tempfile::tempdir().unwrap();
let home = root.path().join(".hanzo/dev");
std::fs::create_dir_all(&home).unwrap();
share_for_login(&home);
std::fs::write(home.join("auth.json"), "{\"token\":\"new\"}").unwrap();
assert_eq!(
std::fs::read_to_string(root.path().join(".hanzo/auth.json")).unwrap(),
"{\"token\":\"new\"}"
);
share_credential(&home);
assert!(std::fs::symlink_metadata(home.join("auth.json")).unwrap().is_symlink());
}
+31 -2
View File
@@ -4,6 +4,7 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
pub mod catalog;
pub mod kai;
mod provider;
pub use provider::activate_provider;
@@ -30,6 +31,7 @@ pub fn initialize() -> std::io::Result<()> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")),
)?;
initialize_home(&home)?;
provider::upgrade_auth(&home);
share_credential(&home);
provider::load_hanzo_credentials(&home);
// This function is only invoked before upstream starts threads.
@@ -54,7 +56,22 @@ fn product_home(explicit: Option<OsString>, user: Option<OsString>) -> std::io::
/// One account across the Hanzo tools, so signing in to any of them signs in to
/// all of them. Dev keeps its own sessions and settings; only the credential is
/// shared, and only when the profile is the default one under `~/.hanzo`.
///
/// At startup the profile links to the shared credential only when it exists; a
/// link to nothing is removed, so the profile never holds a dangling link.
fn share_credential(home: &Path) {
link_shared(home, false);
}
/// Point the profile's credential at the shared one before a sign-in saves it,
/// so the sign-in writes the one credential every Hanzo tool reads. The link may
/// name a file the sign-in is about to create; a sign-in that does not finish
/// leaves it for the next start to remove.
pub fn share_for_login(home: &Path) {
link_shared(home, true);
}
fn link_shared(home: &Path, for_login: bool) {
let (Some(hanzo), Some(name)) = (home.parent(), home.file_name()) else {
return;
};
@@ -63,6 +80,13 @@ fn share_credential(home: &Path) {
}
let shared = hanzo.join("auth.json");
let ours = home.join("auth.json");
if !for_login && !shared.exists() {
// Nothing to share yet: a link here would name a file that is not there.
if std::fs::symlink_metadata(&ours).is_ok_and(|metadata| metadata.is_symlink()) {
let _ = std::fs::remove_file(&ours);
}
return;
}
match std::fs::symlink_metadata(&ours) {
// A real file here predates the shared credential; leave the user's alone.
Ok(metadata) if !metadata.is_symlink() => return,
@@ -110,10 +134,15 @@ model = "enso-auto"
name = "Hanzo"
base_url = "https://api.hanzo.ai/v1"
wire_api = "responses"
env_key = "HANZO_USER_KEY"
env_key_instructions = "Run `dev login` to sign in to Hanzo or paste a Hanzo API key, or set HANZO_USER_KEY."
requires_openai_auth = false
# The Hanzo sign-in is a short-lived token, so the agent asks `dev token` for it
# again before it ages out instead of reading it once at startup.
[model_providers.hanzo.auth]
command = "dev"
args = ["token"]
refresh_interval_ms = 600000
[mcp_servers.hanzo]
command = "hanzo-mcp"
args = []
+72 -5
View File
@@ -93,13 +93,36 @@ pub fn clear_hanzo_api_key(home: &Path) -> io::Result<()> {
}
}
/// An IAM sign-in token rather than an API key: a JWT, which ages out within the
/// hour. An API key never does.
fn is_session_token(value: &str) -> bool {
value.starts_with("eyJ") && value.split('.').count() == 3
}
/// The Hanzo credential, in order: an API key set in the environment, a pasted
/// API key, a fresh token from the signed-in Hanzo CLI, and last a sign-in token
/// set in the environment. A sign-in token in the environment is whatever the
/// launching process held when it started, so a fresh one from the CLI is
/// preferred to it.
fn credential(home: &Path) -> Option<String> {
if let Some(key) = std::env::var("HANZO_USER_KEY")
let inherited = std::env::var("HANZO_USER_KEY")
.ok()
.filter(|key| !key.is_empty())
{
return Some(key);
.filter(|key| !key.is_empty());
pick(inherited, saved_key(home), cli_token)
}
fn pick(
inherited: Option<String>,
saved: Option<String>,
cli: impl FnOnce() -> Option<String>,
) -> Option<String> {
if let Some(key) = inherited.as_ref().filter(|key| !is_session_token(key)) {
return Some(key.clone());
}
saved.or_else(cli).or(inherited)
}
fn saved_key(home: &Path) -> Option<String> {
if let Ok(key) = std::fs::read_to_string(home.join("hanzo-api-key")) {
if !key.is_empty()
&& key.len() <= 16_384
@@ -108,6 +131,10 @@ fn credential(home: &Path) -> Option<String> {
return Some(key);
}
}
None
}
fn cli_token() -> Option<String> {
// The native CLI owns storage and refresh of the shared Hanzo IAM account.
// Capture both streams so credentials never reach logs.
let output = Command::new("hanzo")
@@ -129,7 +156,7 @@ pub(super) fn load_hanzo_credentials(home: &Path) {
if std::env::args_os().skip(1).any(|arg| {
matches!(
arg.to_str(),
Some("--help" | "-h" | "--version" | "-V" | "login" | "logout")
Some("--help" | "-h" | "--version" | "-V" | "login" | "logout" | "token")
)
}) {
return;
@@ -152,6 +179,46 @@ pub(super) fn load_hanzo_credentials(home: &Path) {
}
}
/// Move a profile still on the startup-only credential to the refreshing one.
///
/// Earlier releases wrote the Hanzo provider with `env_key = "HANZO_USER_KEY"`,
/// read once when the agent starts, so a session outlived its sign-in token and
/// failed with 401 an hour in. A provider block still holding exactly that
/// default is rewritten to ask `dev token` instead; a provider the user changed
/// is theirs and stays as it is.
pub(super) fn upgrade_auth(home: &Path) {
let path = home.join("config.toml");
let Ok(text) = std::fs::read_to_string(&path) else {
return;
};
let Ok(config) = text.parse::<DocumentMut>() else {
return;
};
let Some(provider) = config
.get("model_providers")
.and_then(|providers| providers.get("hanzo"))
else {
return;
};
if provider.get("auth").is_some()
|| provider.get("env_key").and_then(toml_edit::Item::as_str) != Some("HANZO_USER_KEY")
|| provider.get("base_url").and_then(toml_edit::Item::as_str) != Some(super::API_BASE)
{
return;
}
let _ = edit(home, |config| {
let defaults = super::DEFAULT_CONFIG
.parse::<DocumentMut>()
.expect("valid default config");
let Some(provider) = config["model_providers"]["hanzo"].as_table_like_mut() else {
return;
};
provider.remove("env_key");
provider.remove("env_key_instructions");
provider.insert("auth", defaults["model_providers"]["hanzo"]["auth"].clone());
});
}
#[cfg(test)]
#[path = "provider_tests.rs"]
mod tests;
+67
View File
@@ -31,3 +31,70 @@ fn switching_back_to_hanzo_preserves_unrelated_preferences() {
.insert("model_reasoning_effort".to_string(), "high".into());
assert_eq!(config, expected);
}
const JWT: &str = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ6In0.c2ln";
#[test]
fn an_api_key_in_the_environment_wins() {
let chosen = pick(Some("hk-live-key".into()), Some("saved".into()), || Some(JWT.into()));
assert_eq!(chosen.as_deref(), Some("hk-live-key"));
}
#[test]
fn an_inherited_sign_in_token_yields_to_a_fresh_one() {
let fresh = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmcmVzaCJ9.c2ln";
assert_eq!(pick(Some(JWT.into()), None, || Some(fresh.into())).as_deref(), Some(fresh));
assert_eq!(pick(Some(JWT.into()), Some("saved".into()), || None).as_deref(), Some("saved"));
// With nothing fresher, the inherited token is still better than none.
assert_eq!(pick(Some(JWT.into()), None, || None).as_deref(), Some(JWT));
}
#[test]
fn the_default_provider_refreshes_its_token() {
let config: toml::Value = toml::from_str(crate::DEFAULT_CONFIG).unwrap();
let provider = &config["model_providers"]["hanzo"];
assert!(provider.get("env_key").is_none(), "auth and env_key cannot both be set");
assert_eq!(provider["auth"]["command"].as_str(), Some("dev"));
assert_eq!(provider["auth"]["args"].as_array().unwrap()[0].as_str(), Some("token"));
// Shorter than the hour a sign-in token lives.
assert!(provider["auth"]["refresh_interval_ms"].as_integer().unwrap() < 3_600_000);
}
const OLD_PROVIDER: &str = r#"# mine
model_provider = "hanzo"
model = "enso-auto"
[model_providers.hanzo]
name = "Hanzo"
base_url = "https://api.hanzo.ai/v1"
wire_api = "responses"
env_key = "HANZO_USER_KEY"
env_key_instructions = "Run `dev login` to sign in to Hanzo or paste a Hanzo API key, or set HANZO_USER_KEY."
requires_openai_auth = false
"#;
#[test]
fn a_startup_only_credential_is_upgraded_to_the_refreshing_one() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join("config.toml");
std::fs::write(&path, OLD_PROVIDER).unwrap();
upgrade_auth(home.path());
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("# mine\n"));
let config: toml::Value = toml::from_str(&text).unwrap();
let defaults: toml::Value = toml::from_str(crate::DEFAULT_CONFIG).unwrap();
assert_eq!(config["model_providers"]["hanzo"], defaults["model_providers"]["hanzo"]);
// Twice changes nothing more.
upgrade_auth(home.path());
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
}
#[test]
fn a_provider_the_user_changed_is_left_alone() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join("config.toml");
let theirs = OLD_PROVIDER.replace("env_key = \"HANZO_USER_KEY\"", "env_key = \"MY_KEY\"");
std::fs::write(&path, &theirs).unwrap();
upgrade_auth(home.path());
assert_eq!(std::fs::read_to_string(&path).unwrap(), theirs);
}
+1
View File
@@ -119,6 +119,7 @@ pub(crate) async fn run(login: Login, mut overrides: CliConfigOverrides) -> Resu
println!("Hanzo API key saved.");
}
Choice::ChatGpt | Choice::OpenAiKey => {
hanzo_config::share_for_login(&home);
overrides
.raw_overrides
.push("model_provider=\"openai\"".into());
+7
View File
@@ -52,6 +52,9 @@ enum Command {
/// Discard stored credentials.
Logout,
/// Print the Hanzo credential the agent sends, fresh from the sign-in.
Token,
/// Speak the agent protocol on stdin and stdout.
Serve,
@@ -216,6 +219,10 @@ async fn run(paths: Arg0DispatchPaths) -> Result<()> {
}
Some(Command::Login(login)) => login::run(login, overrides).await?,
Some(Command::Logout) => login::logout(overrides).await?,
Some(Command::Token) => match hanzo_config::hanzo_credential() {
Some(token) => println!("{token}"),
None => anyhow::bail!("Not signed in to Hanzo. Run `dev login`."),
},
Some(Command::Serve) => {
codex_app_server::run_main(paths, overrides, LoaderOverrides::default(), false, false)
.await?;
+42
View File
@@ -268,5 +268,47 @@
"old": " codex_guardian_v2::install(\n &mut builder,",
"new": " // Hanzo: Kai decides in the loop; it runs only for a profile with a kai.toml.\n hanzo_kai::install(&mut builder, thread_manager.clone());\n codex_guardian_v2::install(\n &mut builder,",
"count": 1
},
{
"file": "core-plugins/src/remote_legacy.rs",
"old": " let base_url = config.chatgpt_base_url.trim_end_matches('/');\n let mut url = Url::parse(&format!(\"{base_url}/plugins/featured\"))",
"new": " // Featured plugins are served to a ChatGPT sign-in only; any other caller is\n // refused, so it is not asked.\n let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {\n return Ok(Vec::new());\n };\n let base_url = config.chatgpt_base_url.trim_end_matches('/');\n let mut url = Url::parse(&format!(\"{base_url}/plugins/featured\"))",
"count": 1
},
{
"file": "core-plugins/src/remote_legacy.rs",
"old": " if let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) {\n request =\n request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers());\n }\n",
"new": " request = request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers());\n",
"count": 1
},
{
"file": "core/src/tools/handlers/mod.rs",
"old": " if justification.is_some() && sandbox_permissions.is_none() {\n return Err(FunctionCallError::RespondToModel(\n \"`justification` requires an explicit `sandbox_permissions`; use `sandbox_permissions: \\\"require_escalated\\\"` for unsandboxed execution, or omit `justification`.\".to_string(),\n ));\n }\n\n Ok(sandbox_permissions.unwrap_or_default())",
"new": " // A justification explains an escalation. With none asked for there is\n // nothing for it to explain, so the call runs at the default permissions\n // rather than failing.\n let _ = justification;\n Ok(sandbox_permissions.unwrap_or_default())",
"count": 1
},
{
"file": "models-manager/Cargo.toml",
"old": "[dependencies]",
"new": "[dependencies]\nhanzo-config = { path = \"../../../../crates/hanzo-config\" }",
"count": 1
},
{
"file": "models-manager/src/model_info.rs",
"old": " warn!(\"Unknown model {slug} is used. This will use fallback model metadata.\");\n ModelInfo {\n slug: slug.to_string(),\n display_name: slug.to_string(),",
"new": " let known = hanzo_config::catalog::find(slug);\n if known.is_none() {\n warn!(\"Unknown model {slug} is used. This will use fallback model metadata.\");\n }\n let window = known.map_or(272_000, |model| model.window);\n ModelInfo {\n slug: slug.to_string(),\n display_name: known.map_or_else(|| slug.to_string(), |model| model.name.to_string()),",
"count": 1
},
{
"file": "models-manager/src/model_info.rs",
"old": " context_window: Some(272_000),\n max_context_window: Some(272_000),",
"new": " context_window: Some(window),\n max_context_window: Some(window),",
"count": 1
},
{
"file": "models-manager/src/model_info.rs",
"old": " input_modalities: default_input_modalities(),\n used_fallback_model_metadata: true, // this is the fallback model metadata",
"new": " input_modalities: match known {\n Some(model) if !model.vision => vec![codex_protocol::openai_models::InputModality::Text],\n _ => default_input_modalities(),\n },\n used_fallback_model_metadata: known.is_none(),",
"count": 1
}
]