Files
dev/crates/hanzo-config/src/provider.rs
T
zeekay ad468d97af upstream: the fork stands alone; Dev fills its two hooks
hanzoai/codex names nothing outside its tree now (d1d710452): Dev's names,
defaults, model catalog, `/model <name>` match, controller interface and
version live in its `hanzo` crate, and Dev's crates depend on that, never
the reverse. hanzo-loop moves there whole; hanzo-tui keeps only the Hanzo
account; hanzo-config reads API_BASE, DEFAULT_MODEL and is_hanzo from it.

hanzo-dev's main registers what Dev adds before anything starts:
`codex_app_server::register(hanzo_kai::install)` puts Kai on every thread,
and `codex_tui::register(Account { login, logout, show })` puts the Hanzo
CLI's sign-in behind /login, /logout, /auth and /status. launch.rs holds
that /login is offered, which it is only when an account is registered.
make test-upstream runs locked against the fork's own lock.
2026-10-15 10:03:29 -07:00

251 lines
8.6 KiB
Rust

use std::io;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use toml_edit::DocumentMut;
/// Credential and model routing selected by a successful login.
#[derive(Clone, Copy)]
pub enum Provider {
Hanzo,
OpenAi,
}
/// Select a provider without changing unrelated user settings or storing tokens.
pub fn activate_provider(home: &Path, provider: Provider) -> io::Result<()> {
edit(home, |config| match provider {
Provider::Hanzo => {
if config
.get("model_providers")
.and_then(|providers| providers.get("hanzo"))
.is_none()
{
let defaults = super::DEFAULT_CONFIG
.parse::<DocumentMut>()
.expect("valid default config");
config["model_providers"]["hanzo"] = defaults["model_providers"]["hanzo"].clone();
}
config["model_provider"] = toml_edit::value("hanzo");
config["model"] = toml_edit::value(hanzo::DEFAULT_MODEL);
}
Provider::OpenAi => {
config["model_provider"] = toml_edit::value("openai");
// Allow the authenticated model catalog to choose its current default.
if config.get("model").and_then(toml_edit::Item::as_str) == Some(hanzo::DEFAULT_MODEL) {
config.remove("model");
}
}
})
}
/// A profile that names no provider runs on Hanzo. Upstream reads an unset provider as
/// OpenAI, whose first screen is a sign-in Dev does not offer.
pub(super) fn default_to_hanzo(home: &Path) {
let Ok(text) = std::fs::read_to_string(home.join("config.toml")) else {
return;
};
let Ok(config) = text.parse::<DocumentMut>() else {
return;
};
if config.get("model_provider").is_none() {
let _ = activate_provider(home, Provider::Hanzo);
}
}
/// Turn one feature on or off for this profile.
pub fn set_feature(home: &Path, key: &str, on: bool) -> io::Result<()> {
edit(home, |config| {
config["features"][key] = toml_edit::value(on)
})
}
/// Rewrite the product config, leaving every setting the change does not name
/// — and every comment around them — exactly as the user left it.
fn edit(home: &Path, change: impl FnOnce(&mut DocumentMut)) -> io::Result<()> {
let path = home.join("config.toml");
let text = std::fs::read_to_string(&path)?;
let mut config = text.parse::<DocumentMut>().map_err(io::Error::other)?;
change(&mut config);
let mut file = tempfile::NamedTempFile::new_in(home)?;
file.write_all(config.to_string().as_bytes())?;
file.as_file().sync_all()?;
file.persist(path).map_err(|error| error.error)?;
Ok(())
}
/// The selected Hanzo credential: explicit environment, saved API key, or IAM.
pub fn hanzo_credential() -> Option<String> {
credential(&super::home())
}
/// Save a pasted Hanzo API key in the profile's private credential file.
/// It is separate from both user configuration and the ChatGPT credential.
pub fn save_hanzo_api_key(home: &Path, key: &str) -> io::Result<()> {
if key.is_empty()
|| key.len() > 16_384
|| key.chars().any(|c| c.is_whitespace() || c.is_control())
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid API key",
));
}
let mut file = tempfile::NamedTempFile::new_in(home)?;
file.write_all(key.as_bytes())?;
file.as_file().sync_all()?;
file.persist(home.join("hanzo-api-key"))
.map_err(|error| error.error)?;
Ok(())
}
/// Forget a pasted API key when signing out or switching to a Hanzo account.
pub fn clear_hanzo_api_key(home: &Path) -> io::Result<()> {
match std::fs::remove_file(home.join("hanzo-api-key")) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
/// 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: `HANZO_API_KEY` as given, an API key in
/// `HANZO_USER_KEY`, a pasted API key, a fresh token from the signed-in Hanzo CLI,
/// and last a sign-in token in `HANZO_USER_KEY`. That sign-in token is whatever the
/// launching process held when it started, so a fresh one from the CLI is preferred
/// to it. `HANZO_API_KEY` is the credential a host hands a run it starts (a cloud
/// sandbox has no CLI to ask), so it is taken whatever its shape.
fn credential(home: &Path) -> Option<String> {
let set = |name| std::env::var(name).ok().filter(|key: &String| !key.is_empty());
choose(set("HANZO_API_KEY"), set("HANZO_USER_KEY"), saved_key(home), cli_token)
}
fn choose(
given: Option<String>,
inherited: Option<String>,
saved: Option<String>,
cli: impl FnOnce() -> Option<String>,
) -> Option<String> {
given.or_else(|| pick(inherited, saved, cli))
}
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
&& !key.chars().any(|c| c.is_whitespace() || c.is_control())
{
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")
.args(["auth", "token"])
.output()
.ok()?;
if !output.status.success() || output.stdout.len() > 16_384 {
return None;
}
let token = String::from_utf8(output.stdout).ok()?;
let token = token.trim();
(!token.is_empty() && !token.chars().any(|c| c.is_whitespace() || c.is_control()))
.then(|| token.to_owned())
}
// Called only from the synchronous entry point, before any threads start.
pub(super) fn load_hanzo_credentials(home: &Path) {
// Help and login commands must work without consulting a credential service.
if std::env::args_os().skip(1).any(|arg| {
matches!(
arg.to_str(),
Some("--help" | "-h" | "--version" | "-V" | "login" | "logout" | "token")
)
}) {
return;
}
let Ok(text) = std::fs::read_to_string(home.join("config.toml")) else {
return;
};
let Ok(config) = text.parse::<DocumentMut>() else {
return;
};
if config
.get("model_provider")
.and_then(toml_edit::Item::as_str)
!= Some("hanzo")
{
return;
}
if let Some(token) = credential(home) {
std::env::set_var("HANZO_USER_KEY", token);
}
}
/// 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"`
/// (or `HANZO_API_KEY`, which nothing here ever read),
/// 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()
|| !matches!(
provider.get("env_key").and_then(toml_edit::Item::as_str),
Some("HANZO_USER_KEY" | "HANZO_API_KEY")
)
|| provider.get("base_url").and_then(toml_edit::Item::as_str) != Some(hanzo::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;