zeroclaw/src/tools/auth_profile.rs
maxtongwang e057e17de5 fix(channel/bluebubbles): register service key + fix pre-existing fmt
- add "channel.bluebubbles" to SUPPORTED_PROXY_SERVICE_KEYS so proxy
  scope = "services" can target BlueBubbles via exact service key
  (addresses final CodeRabbit finding on PR #2271)
- apply cargo fmt to auth_profile.rs and quota_tools.rs (pre-existing
  formatting drift that would block cargo fmt --check in CI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 13:37:18 -08:00

310 lines
11 KiB
Rust

//! Tool for managing auth profiles (list, switch, refresh).
//!
//! Allows the agent to:
//! - List all configured auth profiles with expiry status
//! - Switch active profile for a provider
//! - Refresh OAuth tokens that are expired or expiring
use crate::auth::{normalize_provider, AuthService};
use crate::config::Config;
use crate::tools::{Tool, ToolResult};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::fmt::Write as _;
use std::sync::Arc;
pub struct ManageAuthProfileTool {
config: Arc<Config>,
}
impl ManageAuthProfileTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
fn auth_service(&self) -> AuthService {
AuthService::from_config(&self.config)
}
async fn handle_list(&self, provider_filter: Option<&str>) -> Result<ToolResult> {
let auth = self.auth_service();
let data = auth.load_profiles().await?;
let mut output = String::new();
let _ = writeln!(output, "## Auth Profiles\n");
let mut count = 0u32;
for (id, profile) in &data.profiles {
if let Some(filter) = provider_filter {
let normalized = normalize_provider(filter).unwrap_or_else(|_| filter.to_string());
if profile.provider != normalized {
continue;
}
}
count += 1;
let is_active = data
.active_profiles
.get(&profile.provider)
.map_or(false, |active| active == id);
let active_marker = if is_active { " [ACTIVE]" } else { "" };
let _ = writeln!(
output,
"- **{}** ({}){active_marker}",
profile.profile_name, profile.provider
);
if let Some(ref acct) = profile.account_id {
let _ = writeln!(output, " Account: {acct}");
}
let _ = writeln!(output, " Type: {:?}", profile.kind);
if let Some(ref ts) = profile.token_set {
if let Some(expires) = ts.expires_at {
let now = chrono::Utc::now();
if expires < now {
let ago = now.signed_duration_since(expires);
let _ = writeln!(output, " Token: EXPIRED ({}h ago)", ago.num_hours());
} else {
let left = expires.signed_duration_since(now);
let _ = writeln!(
output,
" Token: valid (expires in {}h {}m)",
left.num_hours(),
left.num_minutes() % 60
);
}
} else {
let _ = writeln!(output, " Token: no expiry set");
}
let has_refresh = ts.refresh_token.is_some();
let _ = writeln!(
output,
" Refresh token: {}",
if has_refresh { "yes" } else { "no" }
);
} else if profile.token.is_some() {
let _ = writeln!(output, " Token: API key (no expiry)");
}
}
if count == 0 {
if provider_filter.is_some() {
let _ = writeln!(output, "No profiles found for the specified provider.");
} else {
let _ = writeln!(output, "No auth profiles configured.");
}
} else {
let _ = writeln!(output, "\nTotal: {count} profile(s)");
}
Ok(ToolResult {
success: true,
output,
error: None,
})
}
async fn handle_switch(&self, provider: &str, profile_name: &str) -> Result<ToolResult> {
let auth = self.auth_service();
let profile_id = auth.set_active_profile(provider, profile_name).await?;
Ok(ToolResult {
success: true,
output: format!("Switched active profile for {provider} to: {profile_id}"),
error: None,
})
}
async fn handle_refresh(&self, provider: &str) -> Result<ToolResult> {
let normalized = normalize_provider(provider)?;
let auth = self.auth_service();
let result = match normalized.as_str() {
"openai-codex" => match auth.get_valid_openai_access_token(None).await {
Ok(Some(_)) => "OpenAI Codex token refreshed successfully.".to_string(),
Ok(None) => "No OpenAI Codex profile found to refresh.".to_string(),
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("OpenAI token refresh failed: {e}")),
})
}
},
"gemini" => match auth.get_valid_gemini_access_token(None).await {
Ok(Some(_)) => "Gemini token refreshed successfully.".to_string(),
Ok(None) => "No Gemini profile found to refresh.".to_string(),
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Gemini token refresh failed: {e}")),
})
}
},
other => {
// For non-OAuth providers, just verify the token exists
match auth.get_provider_bearer_token(other, None).await {
Ok(Some(_)) => format!("Provider '{other}' uses API key auth (no refresh needed). Token is present."),
Ok(None) => format!("No profile found for provider '{other}'."),
Err(e) => return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Token check failed for '{other}': {e}")),
}),
}
}
};
Ok(ToolResult {
success: true,
output: result,
error: None,
})
}
}
#[async_trait]
impl Tool for ManageAuthProfileTool {
fn name(&self) -> &str {
"manage_auth_profile"
}
fn description(&self) -> &str {
"Manage auth profiles: list all profiles with token status, switch active profile \
for a provider, or refresh expired OAuth tokens. Use when user asks about accounts, \
tokens, or when you encounter expired/rate-limited credentials."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "switch", "refresh"],
"description": "Action to perform: 'list' shows all profiles, 'switch' changes active profile, 'refresh' renews OAuth tokens"
},
"provider": {
"type": "string",
"description": "Provider name (e.g., 'gemini', 'openai-codex', 'anthropic'). Required for switch and refresh."
},
"profile": {
"type": "string",
"description": "Profile name to switch to (for 'switch' action). E.g., 'default', 'work', 'personal'."
}
},
"required": ["action"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let action = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("list");
let provider = args.get("provider").and_then(|v| v.as_str());
let result = match action {
"list" => self.handle_list(provider).await,
"switch" => {
let Some(provider) = provider else {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'provider' is required for switch action".into()),
});
};
let profile = args
.get("profile")
.and_then(|v| v.as_str())
.unwrap_or("default");
self.handle_switch(provider, profile).await
}
"refresh" => {
let Some(provider) = provider else {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'provider' is required for refresh action".into()),
});
};
self.handle_refresh(provider).await
}
other => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unknown action '{other}'. Valid: list, switch, refresh"
)),
}),
};
match result {
Ok(outcome) => Ok(outcome),
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(e.to_string()),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manage_auth_profile_schema() {
let tool = ManageAuthProfileTool::new(Arc::new(Config::default()));
let schema = tool.parameters_schema();
assert!(schema["properties"]["action"]["enum"].is_array());
assert_eq!(tool.name(), "manage_auth_profile");
assert!(tool.description().contains("auth profiles"));
}
#[tokio::test]
async fn test_list_empty_profiles() {
let tmp = tempfile::TempDir::new().unwrap();
let config = Config {
workspace_dir: tmp.path().to_path_buf(),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
let tool = ManageAuthProfileTool::new(Arc::new(config));
let result = tool.execute(json!({"action": "list"})).await.unwrap();
assert!(result.success);
assert!(result.output.contains("Auth Profiles"));
}
#[tokio::test]
async fn test_switch_missing_provider() {
let tool = ManageAuthProfileTool::new(Arc::new(Config::default()));
let result = tool.execute(json!({"action": "switch"})).await.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("provider"));
}
#[tokio::test]
async fn test_refresh_missing_provider() {
let tool = ManageAuthProfileTool::new(Arc::new(Config::default()));
let result = tool.execute(json!({"action": "refresh"})).await.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("provider"));
}
#[tokio::test]
async fn test_unknown_action() {
let tool = ManageAuthProfileTool::new(Arc::new(Config::default()));
let result = tool.execute(json!({"action": "delete"})).await.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("Unknown action"));
}
}