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
20 changes: 19 additions & 1 deletion src/cmd/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::term;

use crate::cmd::dispatch::{config_path, hint};
use crate::cmd::login::run_login;
use crate::cmd::models::{flag_agent, save_agent_account};
use crate::cmd::models::{clear_agent_account, flag_agent, save_agent_account};

pub(crate) fn run_logout(
parsed: &ParsedArgs,
Expand Down Expand Up @@ -112,6 +112,12 @@ pub(crate) fn run_account(
Ok(0)
}
"use" => {
if parsed.flag_true("clear") {
let agent = flag_agent(parsed).ok_or_else(|| {
hint("--clear needs an agent: {bin} account use --agent <agent> --clear")
})?;
return clear_agent_account(&path, &agent);
}
let name = parsed
.passthrough
.get(1)
Expand Down Expand Up @@ -170,6 +176,7 @@ pub(crate) fn run_account(
if cfg.active_profile == old {
cfg.active_profile = new.clone();
}
cfg.rebind_agents(&old, Some(&new));
write_config(&cfg, &path)?;
println!("{} {old} → {}", term::ok("Renamed"), term::accent(&new));
Ok(0)
Expand All @@ -192,8 +199,19 @@ pub(crate) fn run_account(
format!("Account \"{name}\" was not found."),
));
}
let unbound = cfg.rebind_agents(&name, None);
write_config(&cfg, &path)?;
println!("{} removed {}", term::ok("Removed"), term::accent(&name));
if !unbound.is_empty() {
println!(
"{}",
term::dim(&format!(
"Unbound {}. An unbound agent uses the active account {}.",
unbound.join(", "),
cfg.active_profile
))
);
}
Ok(0)
}
other => Err(hint(&format!(
Expand Down
1 change: 1 addition & 0 deletions src/cmd/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ pub(crate) fn allowed_flags(command: &str) -> Option<&'static [&'static str]> {
"device-code",
"paste",
"agent",
"clear",
],
"logs" => &[
"profile", "base-url", "config", "json", "key", "limit", "model", "status",
Expand Down
191 changes: 184 additions & 7 deletions src/cmd/keys.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::Path;

use crate::config::write_config;
use crate::config::Config;
use crate::http::{delete_key, fetch_keys, is_active_key_row};
use crate::key::{
config_for_credential, load_config_if_present, profile_or_default, resolve_api_key,
resolve_base_url,
account_for_agent, config_for_credential, key_from_flag, load_config_if_present, mask_api_key,
profile_or_default, resolve_api_key, resolve_base_url, validate_api_key,
};
use crate::parse::{get_string_flag, ParsedArgs};
use crate::term;
Expand All @@ -14,6 +16,7 @@ use crate::api::{get, send, Ctx};
use crate::cmd::dispatch::{config_path, hint};
use crate::cmd::key_audit::{last4, revoke_ref, short_ref, suggest_removals};
use crate::cmd::key_scope::{render_show, scope_fields};
use crate::cmd::models::{flag_agent, known_agent};

pub(crate) fn keys_credential(
parsed: &ParsedArgs,
Expand All @@ -30,6 +33,50 @@ pub(crate) fn keys_credential(
Ok((path, cfg, base, api_key))
}

/// The account a key minted for `agent` lands in, and its stored credential.
/// `--key` and `ANYROUTER_API_KEY` are refused: they would mint in their own
/// account, and the env var would keep beating the new key at launch.
fn agent_mint_credential(
parsed: &ParsedArgs,
env: &BTreeMap<String, String>,
agent: &str,
) -> Result<(std::path::PathBuf, Config, String, String, String), String> {
for (set, what) in [
(get_string_flag(&parsed.flags, "key").is_some(), "--key"),
(
env.get("ANYROUTER_API_KEY")
.is_some_and(|k| !k.trim().is_empty() && !crate::key::is_dummy_key(k)),
"ANYROUTER_API_KEY",
),
] {
if set {
return Err(hint(&format!(
"{what} is set, so the new key would be minted in that key's account, not {agent}'s, and {what} also beats every agent key at launch. Drop it, or create without --agent: {{bin}} keys create"
)));
}
}
let path = config_path(parsed, env);
let cfg = load_config_if_present(&path).ok_or_else(crate::key::no_key_error)?;
let (account, bound) = account_for_agent(&cfg, &parsed.flags, env, agent);
let profile = cfg.profiles.get(&account).ok_or_else(|| {
format!(
"Account \"{account}\" was not found. See: {} account list",
crate::help::invoked_bin()
)
})?;
let cred = resolve_api_key(&parsed.flags, env, Some(profile)).ok_or_else(|| {
let whose = if bound { format!(" (bound to {agent})") } else { String::new() };
crate::exit::tag(
crate::exit::AUTH,
hint(&format!(
"Account \"{account}\"{whose} has no key. Sign in to it: {{bin}} login --profile {account}"
)),
)
})?;
let base = resolve_base_url(&parsed.flags, Some(profile));
Ok((path, cfg, base, cred, account))
}

fn key_ctx(parsed: &ParsedArgs, base: &str, cred: &str) -> Ctx {
Ctx {
base: base.to_string(),
Expand Down Expand Up @@ -69,12 +116,58 @@ fn resolve_key(
}
}

pub(crate) fn default_key_name() -> String {
pub(crate) fn default_key_name(agent: Option<&str>) -> String {
let host = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| "device".into());
let short = host.split('.').next().unwrap_or("device");
format!("cli-{short}").chars().take(40).collect()
let name = match agent {
Some(agent) => format!("cli-{agent}-{short}"),
None => format!("cli-{short}"),
};
name.chars().take(40).collect()
}

/// `--agent <id>`, checked against the known coding agents.
fn agent_flag(parsed: &ParsedArgs) -> Result<Option<String>, String> {
flag_agent(parsed).map(|a| known_agent(&a)).transpose()
}

/// Store `key` as `agent`'s own launch key. Returns the key it replaced.
fn set_agent_key(cfg: &mut Config, agent: &str, key: &str) -> Result<Option<String>, String> {
validate_api_key(key)?;
Ok(cfg
.agent_binding_mut(agent)
.api_key
.replace(key.to_string()))
}

/// Drop `agent`'s own launch key. Returns the key it removed.
fn unset_agent_key(cfg: &mut Config, agent: &str) -> Option<String> {
let old = cfg
.agents
.get_mut(agent)
.and_then(|b| b.api_key.take())
.filter(|k| !k.trim().is_empty());
cfg.prune_empty_agents();
old
}

fn print_agent_key_saved(agent: &str, key: &str, replaced: Option<&str>) {
println!(
"{} {agent} key {}",
term::ok("Saved"),
mask_api_key(Some(key))
);
if let Some(old) = replaced.filter(|old| *old != key) {
println!(
"{}",
term::dim(&format!(
"Replaced {}. It still works until you revoke it.",
mask_api_key(Some(old))
))
);
}
}

pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> Result<i32, String> {
Expand Down Expand Up @@ -142,13 +235,20 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> R
Ok(0)
}
"create" => {
let (path, mut cfg, base, cred) = keys_credential(parsed, env)?;
let agent = agent_flag(parsed)?;
let (path, mut cfg, base, cred, account) = match &agent {
Some(agent) => agent_mint_credential(parsed, env, agent)?,
None => {
let (path, cfg, base, cred) = keys_credential(parsed, env)?;
(path, cfg, base, cred, String::new())
}
};
let name = parsed
.passthrough
.get(1)
.cloned()
.filter(|s| !s.is_empty())
.unwrap_or_else(default_key_name);
.unwrap_or_else(|| default_key_name(agent.as_deref()));
let mut body = scope_fields(parsed, crate::civil::now_secs())?;
body.remove("disabled");
body.insert("name".into(), serde_json::Value::String(name.clone()));
Expand All @@ -166,6 +266,31 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> R
.filter(|s| !s.is_empty())
.ok_or("Create-key response did not include a key.")?
.to_string();
if let Some(agent) = &agent {
// Show the secret before saving: if the save fails, the key
// already exists on the server and this is its only copy.
if parsed.flag_true("quiet") {
println!("{key}");
} else {
println!("Created \"{name}\" in {account}:\n\n {key}\n\nShown once.");
}
let bound = cfg.agent_binding(agent).and_then(|b| b.profile.clone());
let replaced = cfg.agent_binding_mut(agent).api_key.replace(key.clone());
write_config(&cfg, &path)
.map_err(|err| format!("The key above was not saved for {agent}: {err}"))?;
if let Some(bound) = bound.filter(|b| *b != account) {
eprintln!(
"{}",
term::dim(&format!(
"{agent} is bound to {bound}, but this key is in {account}. Its own key wins at launch."
))
);
}
if !parsed.flag_true("quiet") {
print_agent_key_saved(agent, &key, replaced.as_deref());
}
return Ok(0);
}
if parsed.flag_true("quiet") {
println!("{key}");
return Ok(0);
Expand Down Expand Up @@ -226,6 +351,58 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> R
}
Ok(0)
}
"set" => {
let agent = agent_flag(parsed)?
.ok_or_else(|| hint("Usage: {bin} keys set --agent <agent> - (reads the key from stdin)"))?;
let raw = parsed
.passthrough
.get(1)
.cloned()
.ok_or_else(|| hint("Usage: {bin} keys set --agent <agent> - (reads the key from stdin)"))?;
if raw != "-" {
return Err(hint(
"Pass - and paste the key on stdin, so it stays out of your shell history: {bin} keys set --agent <agent> -",
));
}
if std::io::stdin().is_terminal() {
eprint!("Paste the key for {agent}, then press Enter: ");
}
let (key, _) = key_from_flag("-", &mut std::io::stdin().lock(), &mut std::io::sink())?
.ok_or_else(|| hint("Usage: {bin} keys set --agent <agent> - (reads the key from stdin)"))?;
let path = config_path(parsed, env);
let mut cfg = load_config_if_present(&path).unwrap_or_default();
let replaced = set_agent_key(&mut cfg, &agent, &key)?;
write_config(&cfg, &path)?;
print_agent_key_saved(&agent, &key, replaced.as_deref());
Ok(0)
}
"unset" => {
let agent = agent_flag(parsed)?
.ok_or_else(|| hint("Usage: {bin} keys unset --agent <agent>"))?;
let path = config_path(parsed, env);
let Some(mut cfg) = load_config_if_present(&path) else {
println!("{agent} has no key of its own.");
return Ok(0);
};
let Some(old) = unset_agent_key(&mut cfg, &agent) else {
println!("{agent} has no key of its own.");
return Ok(0);
};
write_config(&cfg, &path)?;
println!(
"{} {agent} key {}",
term::ok("Removed"),
mask_api_key(Some(&old))
);
let last4: String = old.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
println!(
"{}",
term::dim(&crate::help::with_bin(&format!(
"{agent} now launches with its account key. The removed key still works until you revoke it: {{bin}} keys revoke {last4}"
)))
);
Ok(0)
}
"use" => Err(hint(
"This machine cannot fetch another key's secret. Get a fresh key: {bin} login, or paste one copied from the dashboard: {bin} login --key -",
)),
Expand Down Expand Up @@ -265,7 +442,7 @@ pub(crate) fn run_keys(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> R
Ok(0)
}
other => Err(crate::cmd::dispatch::hint(&format!(
"Unknown keys subcommand \"{other}\". Try: list | create | show | update | audit | revoke"
"Unknown keys subcommand \"{other}\". Try: list | create | set | unset | show | update | audit | revoke"
))),
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/cmd/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::auth::acquire_api_key;
use crate::config::{write_config, DEFAULT_PROFILE};
use crate::install::{ensure_tool_installed, Install};
use crate::key::{
load_config_if_present, profile_for_agent, resolve_base_url, resolve_launch_api_key,
load_config_if_present, profile_for_agent, resolve_base_url, resolve_launch_key,
resolve_launch_model,
};
use crate::parse::{get_string_flag, ParsedArgs};
Expand Down Expand Up @@ -174,8 +174,8 @@ pub(crate) fn launch(
if is_systemone_model(&guard_model) {
return Err(chat_model_error(&guard_model, tool_name));
}
let key = if let Some(key) =
resolve_launch_api_key(&parsed.flags, env, existing.as_ref(), tool_name)
let key = if let Some((key, _)) =
resolve_launch_key(&parsed.flags, env, existing.as_ref(), tool_name)
{
key
} else {
Expand Down
31 changes: 30 additions & 1 deletion src/cmd/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,10 @@ pub(crate) fn known_agent(name: &str) -> Result<String, String> {
if KNOWN_AGENTS.iter().any(|(k, _)| *k == id) {
Ok(id.to_string())
} else {
let known: Vec<&str> = KNOWN_AGENTS.iter().map(|(k, _)| *k).collect();
Err(format!(
"Unknown coding agent \"{name}\". Known: claude, codex, grok, opencode, pi, pool."
"Unknown coding agent \"{name}\". Known: {}.",
known.join(", ")
))
}
}
Expand Down Expand Up @@ -381,6 +383,33 @@ pub(crate) fn save_agent_account(path: &Path, agent: &str, profile: &str) -> Res
Ok(0)
}

pub(crate) fn clear_agent_account(path: &Path, agent: &str) -> Result<i32, String> {
let agent = known_agent(agent)?;
let mut cfg = load_config_if_present(path).ok_or_else(no_key_error)?;
if let Some(binding) = cfg.agents.get_mut(&agent) {
binding.profile = None;
}
cfg.prune_empty_agents();
write_config(&cfg, path)?;
println!(
"{} {agent} uses the active account {}",
term::ok("Cleared"),
term::accent(&cfg.active_profile)
);
if cfg
.agent_binding(&agent)
.is_some_and(|b| b.api_key.as_deref().is_some_and(|k| !k.trim().is_empty()))
{
println!(
"{}",
term::dim(&crate::help::with_bin(&format!(
"{agent} still has its own key, which wins. Remove it: {{bin}} keys unset --agent {agent}"
)))
);
}
Ok(0)
}

pub(crate) fn palette_bind_detail(agent: &str) -> String {
format!("for {agent}")
}
Expand Down
Loading
Loading