fix(auth): rebase PR #200 onto main and restore auth CLI flow
This commit is contained in:
parent
96109d46d1
commit
d42cb1e906
11 changed files with 594 additions and 44 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -3890,17 +3890,6 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-test"
|
|
||||||
version = "0.4.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"tokio",
|
|
||||||
"tokio-stream",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
|
|
@ -5126,7 +5115,6 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-serial",
|
"tokio-serial",
|
||||||
"tokio-test",
|
|
||||||
"tokio-tungstenite 0.24.0",
|
"tokio-tungstenite 0.24.0",
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ chacha20poly1305 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
base64 = "0.22"
|
|
||||||
|
|
||||||
# CSPRNG for secure token generation
|
# CSPRNG for secure token generation
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@ impl AnthropicAuthKind {
|
||||||
pub fn from_metadata_value(value: &str) -> Option<Self> {
|
pub fn from_metadata_value(value: &str) -> Option<Self> {
|
||||||
match value.trim().to_ascii_lowercase().as_str() {
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
"api-key" | "x-api-key" | "apikey" => Some(Self::ApiKey),
|
"api-key" | "x-api-key" | "apikey" => Some(Self::ApiKey),
|
||||||
"authorization" | "bearer" | "auth-token" | "oauth" => {
|
"authorization" | "bearer" | "auth-token" | "oauth" => Some(Self::Authorization),
|
||||||
Some(Self::Authorization)
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ impl TokenSet {
|
||||||
pub fn is_expiring_within(&self, skew: Duration) -> bool {
|
pub fn is_expiring_within(&self, skew: Duration) -> bool {
|
||||||
match self.expires_at {
|
match self.expires_at {
|
||||||
Some(expires_at) => {
|
Some(expires_at) => {
|
||||||
let now_plus_skew = Utc::now() + chrono::Duration::from_std(skew).unwrap_or_default();
|
let now_plus_skew =
|
||||||
|
Utc::now() + chrono::Duration::from_std(skew).unwrap_or_default();
|
||||||
expires_at <= now_plus_skew
|
expires_at <= now_plus_skew
|
||||||
}
|
}
|
||||||
None => false,
|
None => false,
|
||||||
|
|
@ -180,7 +181,8 @@ impl AuthProfilesStore {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
data.active_profiles.retain(|_, active| active != profile_id);
|
data.active_profiles
|
||||||
|
.retain(|_, active| active != profile_id);
|
||||||
data.updated_at = Utc::now();
|
data.updated_at = Utc::now();
|
||||||
self.save_locked(&data)?;
|
self.save_locked(&data)?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|
@ -234,7 +236,8 @@ impl AuthProfilesStore {
|
||||||
|
|
||||||
let mut profiles = BTreeMap::new();
|
let mut profiles = BTreeMap::new();
|
||||||
for (id, p) in &mut persisted.profiles {
|
for (id, p) in &mut persisted.profiles {
|
||||||
let (access_token, access_migrated) = self.decrypt_optional(p.access_token.as_deref())?;
|
let (access_token, access_migrated) =
|
||||||
|
self.decrypt_optional(p.access_token.as_deref())?;
|
||||||
let (refresh_token, refresh_migrated) =
|
let (refresh_token, refresh_migrated) =
|
||||||
self.decrypt_optional(p.refresh_token.as_deref())?;
|
self.decrypt_optional(p.refresh_token.as_deref())?;
|
||||||
let (id_token, id_migrated) = self.decrypt_optional(p.id_token.as_deref())?;
|
let (id_token, id_migrated) = self.decrypt_optional(p.id_token.as_deref())?;
|
||||||
|
|
@ -370,12 +373,13 @@ impl AuthProfilesStore {
|
||||||
return Ok(PersistedAuthProfiles::default());
|
return Ok(PersistedAuthProfiles::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut persisted: PersistedAuthProfiles = serde_json::from_slice(&bytes).with_context(|| {
|
let mut persisted: PersistedAuthProfiles =
|
||||||
format!(
|
serde_json::from_slice(&bytes).with_context(|| {
|
||||||
"Failed to parse auth profile store at {}",
|
format!(
|
||||||
self.path.display()
|
"Failed to parse auth profile store at {}",
|
||||||
)
|
self.path.display()
|
||||||
})?;
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
if persisted.schema_version == 0 {
|
if persisted.schema_version == 0 {
|
||||||
persisted.schema_version = CURRENT_SCHEMA_VERSION;
|
persisted.schema_version = CURRENT_SCHEMA_VERSION;
|
||||||
|
|
@ -402,7 +406,8 @@ impl AuthProfilesStore {
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = serde_json::to_vec_pretty(persisted).context("Failed to serialize auth profiles")?;
|
let json =
|
||||||
|
serde_json::to_vec_pretty(persisted).context("Failed to serialize auth profiles")?;
|
||||||
let tmp_name = format!(
|
let tmp_name = format!(
|
||||||
"{}.tmp.{}.{}",
|
"{}.tmp.{}.{}",
|
||||||
PROFILES_FILENAME,
|
PROFILES_FILENAME,
|
||||||
|
|
@ -576,9 +581,7 @@ fn profile_kind_to_string(kind: AuthProfileKind) -> &'static str {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_optional_datetime(value: Option<&str>) -> Result<Option<DateTime<Utc>>> {
|
fn parse_optional_datetime(value: Option<&str>) -> Result<Option<DateTime<Utc>>> {
|
||||||
value
|
value.map(parse_datetime).transpose()
|
||||||
.map(parse_datetime)
|
|
||||||
.transpose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_datetime(value: &str) -> Result<DateTime<Utc>> {
|
fn parse_datetime(value: &str) -> Result<DateTime<Utc>> {
|
||||||
|
|
@ -602,7 +605,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_id_format() {
|
fn profile_id_format() {
|
||||||
assert_eq!(profile_id("openai-codex", "default"), "openai-codex:default");
|
assert_eq!(
|
||||||
|
profile_id("openai-codex", "default"),
|
||||||
|
"openai-codex:default"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -955,7 +955,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||||
.default_provider
|
.default_provider
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "openrouter".into());
|
.unwrap_or_else(|| "openrouter".into());
|
||||||
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider(
|
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider_with_options(
|
||||||
&provider_name,
|
&provider_name,
|
||||||
config.api_key.as_deref(),
|
config.api_key.as_deref(),
|
||||||
config.api_url.as_deref(),
|
config.api_url.as_deref(),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod approval;
|
pub mod approval;
|
||||||
|
pub mod auth;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod cost;
|
pub mod cost;
|
||||||
|
|
|
||||||
535
src/main.rs
535
src/main.rs
|
|
@ -32,13 +32,16 @@
|
||||||
dead_code
|
dead_code
|
||||||
)]
|
)]
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{bail, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use tracing::info;
|
use dialoguer::{Input, Password};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{info, warn};
|
||||||
use tracing_subscriber::{fmt, EnvFilter};
|
use tracing_subscriber::{fmt, EnvFilter};
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
mod approval;
|
mod approval;
|
||||||
|
mod auth;
|
||||||
mod channels;
|
mod channels;
|
||||||
mod rag {
|
mod rag {
|
||||||
pub use zeroclaw::rag::*;
|
pub use zeroclaw::rag::*;
|
||||||
|
|
@ -219,6 +222,12 @@ enum Commands {
|
||||||
migrate_command: MigrateCommands,
|
migrate_command: MigrateCommands,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Manage provider subscription authentication profiles
|
||||||
|
Auth {
|
||||||
|
#[command(subcommand)]
|
||||||
|
auth_command: AuthCommands,
|
||||||
|
},
|
||||||
|
|
||||||
/// Discover and introspect USB hardware
|
/// Discover and introspect USB hardware
|
||||||
Hardware {
|
Hardware {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
@ -232,6 +241,89 @@ enum Commands {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum AuthCommands {
|
||||||
|
/// Login with OpenAI Codex OAuth
|
||||||
|
Login {
|
||||||
|
/// Provider (`openai-codex`)
|
||||||
|
#[arg(long)]
|
||||||
|
provider: String,
|
||||||
|
/// Profile name (default: default)
|
||||||
|
#[arg(long, default_value = "default")]
|
||||||
|
profile: String,
|
||||||
|
/// Use OAuth device-code flow
|
||||||
|
#[arg(long)]
|
||||||
|
device_code: bool,
|
||||||
|
},
|
||||||
|
/// Complete OAuth by pasting redirect URL or auth code
|
||||||
|
PasteRedirect {
|
||||||
|
/// Provider (`openai-codex`)
|
||||||
|
#[arg(long)]
|
||||||
|
provider: String,
|
||||||
|
/// Profile name (default: default)
|
||||||
|
#[arg(long, default_value = "default")]
|
||||||
|
profile: String,
|
||||||
|
/// Full redirect URL or raw OAuth code
|
||||||
|
#[arg(long)]
|
||||||
|
input: Option<String>,
|
||||||
|
},
|
||||||
|
/// Paste setup token / auth token (for Anthropic subscription auth)
|
||||||
|
PasteToken {
|
||||||
|
/// Provider (`anthropic`)
|
||||||
|
#[arg(long)]
|
||||||
|
provider: String,
|
||||||
|
/// Profile name (default: default)
|
||||||
|
#[arg(long, default_value = "default")]
|
||||||
|
profile: String,
|
||||||
|
/// Token value (if omitted, read interactively)
|
||||||
|
#[arg(long)]
|
||||||
|
token: Option<String>,
|
||||||
|
/// Auth kind override (`authorization` or `api-key`)
|
||||||
|
#[arg(long)]
|
||||||
|
auth_kind: Option<String>,
|
||||||
|
},
|
||||||
|
/// Alias for `paste-token` (interactive by default)
|
||||||
|
SetupToken {
|
||||||
|
/// Provider (`anthropic`)
|
||||||
|
#[arg(long)]
|
||||||
|
provider: String,
|
||||||
|
/// Profile name (default: default)
|
||||||
|
#[arg(long, default_value = "default")]
|
||||||
|
profile: String,
|
||||||
|
},
|
||||||
|
/// Refresh OpenAI Codex access token using refresh token
|
||||||
|
Refresh {
|
||||||
|
/// Provider (`openai-codex`)
|
||||||
|
#[arg(long)]
|
||||||
|
provider: String,
|
||||||
|
/// Profile name or profile id
|
||||||
|
#[arg(long)]
|
||||||
|
profile: Option<String>,
|
||||||
|
},
|
||||||
|
/// Remove auth profile
|
||||||
|
Logout {
|
||||||
|
/// Provider
|
||||||
|
#[arg(long)]
|
||||||
|
provider: String,
|
||||||
|
/// Profile name (default: default)
|
||||||
|
#[arg(long, default_value = "default")]
|
||||||
|
profile: String,
|
||||||
|
},
|
||||||
|
/// Set active profile for a provider
|
||||||
|
Use {
|
||||||
|
/// Provider
|
||||||
|
#[arg(long)]
|
||||||
|
provider: String,
|
||||||
|
/// Profile name or full profile id
|
||||||
|
#[arg(long)]
|
||||||
|
profile: String,
|
||||||
|
},
|
||||||
|
/// List auth profiles
|
||||||
|
List,
|
||||||
|
/// Show auth status with active profile and token expiry info
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum MigrateCommands {
|
enum MigrateCommands {
|
||||||
/// Import memory from an `OpenClaw` workspace into this `ZeroClaw` workspace
|
/// Import memory from an `OpenClaw` workspace into this `ZeroClaw` workspace
|
||||||
|
|
@ -609,6 +701,8 @@ async fn main() -> Result<()> {
|
||||||
migration::handle_command(migrate_command, &config).await
|
migration::handle_command(migrate_command, &config).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Commands::Auth { auth_command } => handle_auth_command(auth_command, &config).await,
|
||||||
|
|
||||||
Commands::Hardware { hardware_command } => {
|
Commands::Hardware { hardware_command } => {
|
||||||
hardware::handle_command(hardware_command.clone(), &config)
|
hardware::handle_command(hardware_command.clone(), &config)
|
||||||
}
|
}
|
||||||
|
|
@ -619,6 +713,443 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct PendingOpenAiLogin {
|
||||||
|
profile: String,
|
||||||
|
code_verifier: String,
|
||||||
|
state: String,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct PendingOpenAiLoginFile {
|
||||||
|
profile: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
code_verifier: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
encrypted_code_verifier: Option<String>,
|
||||||
|
state: String,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_openai_login_path(config: &Config) -> std::path::PathBuf {
|
||||||
|
auth::state_dir_from_config(config).join("auth-openai-pending.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_openai_secret_store(config: &Config) -> security::secrets::SecretStore {
|
||||||
|
security::secrets::SecretStore::new(
|
||||||
|
&auth::state_dir_from_config(config),
|
||||||
|
config.secrets.encrypt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_owner_only_permissions(path: &std::path::Path) -> Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn set_owner_only_permissions(_path: &std::path::Path) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_pending_openai_login(config: &Config, pending: &PendingOpenAiLogin) -> Result<()> {
|
||||||
|
let path = pending_openai_login_path(config);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let secret_store = pending_openai_secret_store(config);
|
||||||
|
let encrypted_code_verifier = secret_store.encrypt(&pending.code_verifier)?;
|
||||||
|
let persisted = PendingOpenAiLoginFile {
|
||||||
|
profile: pending.profile.clone(),
|
||||||
|
code_verifier: None,
|
||||||
|
encrypted_code_verifier: Some(encrypted_code_verifier),
|
||||||
|
state: pending.state.clone(),
|
||||||
|
created_at: pending.created_at.clone(),
|
||||||
|
};
|
||||||
|
let tmp = path.with_extension(format!(
|
||||||
|
"tmp.{}.{}",
|
||||||
|
std::process::id(),
|
||||||
|
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||||
|
));
|
||||||
|
let json = serde_json::to_vec_pretty(&persisted)?;
|
||||||
|
std::fs::write(&tmp, json)?;
|
||||||
|
set_owner_only_permissions(&tmp)?;
|
||||||
|
std::fs::rename(tmp, &path)?;
|
||||||
|
set_owner_only_permissions(&path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_pending_openai_login(config: &Config) -> Result<Option<PendingOpenAiLogin>> {
|
||||||
|
let path = pending_openai_login_path(config);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(path)?;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let persisted: PendingOpenAiLoginFile = serde_json::from_slice(&bytes)?;
|
||||||
|
let secret_store = pending_openai_secret_store(config);
|
||||||
|
let code_verifier = if let Some(encrypted) = persisted.encrypted_code_verifier {
|
||||||
|
secret_store.decrypt(&encrypted)?
|
||||||
|
} else if let Some(plaintext) = persisted.code_verifier {
|
||||||
|
plaintext
|
||||||
|
} else {
|
||||||
|
bail!("Pending OpenAI login is missing code verifier");
|
||||||
|
};
|
||||||
|
Ok(Some(PendingOpenAiLogin {
|
||||||
|
profile: persisted.profile,
|
||||||
|
code_verifier,
|
||||||
|
state: persisted.state,
|
||||||
|
created_at: persisted.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_pending_openai_login(config: &Config) {
|
||||||
|
let path = pending_openai_login_path(config);
|
||||||
|
if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&path) {
|
||||||
|
let _ = file.set_len(0);
|
||||||
|
let _ = file.sync_all();
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_auth_input(prompt: &str) -> Result<String> {
|
||||||
|
let input = Password::new()
|
||||||
|
.with_prompt(prompt)
|
||||||
|
.allow_empty_password(false)
|
||||||
|
.interact()?;
|
||||||
|
Ok(input.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_plain_input(prompt: &str) -> Result<String> {
|
||||||
|
let input: String = Input::new().with_prompt(prompt).interact_text()?;
|
||||||
|
Ok(input.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_openai_account_id_for_profile(access_token: &str) -> Option<String> {
|
||||||
|
let account_id = auth::openai_oauth::extract_account_id_from_jwt(access_token);
|
||||||
|
if account_id.is_none() {
|
||||||
|
warn!(
|
||||||
|
"Could not extract OpenAI account id from OAuth access token; \
|
||||||
|
requests may fail until re-authentication."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
account_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_expiry(profile: &auth::profiles::AuthProfile) -> String {
|
||||||
|
match profile
|
||||||
|
.token_set
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|token_set| token_set.expires_at.as_ref().cloned())
|
||||||
|
{
|
||||||
|
Some(ts) => {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
if ts <= now {
|
||||||
|
format!("expired at {}", ts.to_rfc3339())
|
||||||
|
} else {
|
||||||
|
let mins = (ts - now).num_minutes();
|
||||||
|
format!("expires in {mins}m ({})", ts.to_rfc3339())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "n/a".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Result<()> {
|
||||||
|
let auth_service = auth::AuthService::from_config(config);
|
||||||
|
|
||||||
|
match auth_command {
|
||||||
|
AuthCommands::Login {
|
||||||
|
provider,
|
||||||
|
profile,
|
||||||
|
device_code,
|
||||||
|
} => {
|
||||||
|
let provider = auth::normalize_provider(&provider)?;
|
||||||
|
if provider != "openai-codex" {
|
||||||
|
bail!("`auth login` currently supports only --provider openai-codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
if device_code {
|
||||||
|
match auth::openai_oauth::start_device_code_flow(&client).await {
|
||||||
|
Ok(device) => {
|
||||||
|
println!("OpenAI device-code login started.");
|
||||||
|
println!("Visit: {}", device.verification_uri);
|
||||||
|
println!("Code: {}", device.user_code);
|
||||||
|
if let Some(uri_complete) = &device.verification_uri_complete {
|
||||||
|
println!("Fast link: {uri_complete}");
|
||||||
|
}
|
||||||
|
if let Some(message) = &device.message {
|
||||||
|
println!("{message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_set =
|
||||||
|
auth::openai_oauth::poll_device_code_tokens(&client, &device).await?;
|
||||||
|
let account_id =
|
||||||
|
extract_openai_account_id_for_profile(&token_set.access_token);
|
||||||
|
|
||||||
|
let saved = auth_service
|
||||||
|
.store_openai_tokens(&profile, token_set, account_id, true)?;
|
||||||
|
clear_pending_openai_login(config);
|
||||||
|
|
||||||
|
println!("Saved profile {}", saved.id);
|
||||||
|
println!("Active profile for openai-codex: {}", saved.id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!(
|
||||||
|
"Device-code flow unavailable: {e}. Falling back to browser/paste flow."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pkce = auth::openai_oauth::generate_pkce_state();
|
||||||
|
let pending = PendingOpenAiLogin {
|
||||||
|
profile: profile.clone(),
|
||||||
|
code_verifier: pkce.code_verifier.clone(),
|
||||||
|
state: pkce.state.clone(),
|
||||||
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
};
|
||||||
|
save_pending_openai_login(config, &pending)?;
|
||||||
|
|
||||||
|
let authorize_url = auth::openai_oauth::build_authorize_url(&pkce);
|
||||||
|
println!("Open this URL in your browser and authorize access:");
|
||||||
|
println!("{authorize_url}");
|
||||||
|
println!();
|
||||||
|
println!("Waiting for callback at http://localhost:1455/auth/callback ...");
|
||||||
|
|
||||||
|
let code = match auth::openai_oauth::receive_loopback_code(
|
||||||
|
&pkce.state,
|
||||||
|
std::time::Duration::from_secs(180),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Callback capture failed: {e}");
|
||||||
|
println!(
|
||||||
|
"Run `zeroclaw auth paste-redirect --provider openai-codex --profile {profile}`"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let token_set =
|
||||||
|
auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
|
||||||
|
let account_id = extract_openai_account_id_for_profile(&token_set.access_token);
|
||||||
|
|
||||||
|
let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?;
|
||||||
|
clear_pending_openai_login(config);
|
||||||
|
|
||||||
|
println!("Saved profile {}", saved.id);
|
||||||
|
println!("Active profile for openai-codex: {}", saved.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCommands::PasteRedirect {
|
||||||
|
provider,
|
||||||
|
profile,
|
||||||
|
input,
|
||||||
|
} => {
|
||||||
|
let provider = auth::normalize_provider(&provider)?;
|
||||||
|
if provider != "openai-codex" {
|
||||||
|
bail!("`auth paste-redirect` currently supports only --provider openai-codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = load_pending_openai_login(config)?.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"No pending OpenAI login found. Run `zeroclaw auth login --provider openai-codex` first."
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if pending.profile != profile {
|
||||||
|
bail!(
|
||||||
|
"Pending login profile mismatch: pending={}, requested={}",
|
||||||
|
pending.profile,
|
||||||
|
profile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirect_input = match input {
|
||||||
|
Some(value) => value,
|
||||||
|
None => read_plain_input("Paste redirect URL or OAuth code")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let code = auth::openai_oauth::parse_code_from_redirect(
|
||||||
|
&redirect_input,
|
||||||
|
Some(&pending.state),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let pkce = auth::openai_oauth::PkceState {
|
||||||
|
code_verifier: pending.code_verifier.clone(),
|
||||||
|
code_challenge: String::new(),
|
||||||
|
state: pending.state.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let token_set =
|
||||||
|
auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
|
||||||
|
let account_id = extract_openai_account_id_for_profile(&token_set.access_token);
|
||||||
|
|
||||||
|
let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?;
|
||||||
|
clear_pending_openai_login(config);
|
||||||
|
|
||||||
|
println!("Saved profile {}", saved.id);
|
||||||
|
println!("Active profile for openai-codex: {}", saved.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCommands::PasteToken {
|
||||||
|
provider,
|
||||||
|
profile,
|
||||||
|
token,
|
||||||
|
auth_kind,
|
||||||
|
} => {
|
||||||
|
let provider = auth::normalize_provider(&provider)?;
|
||||||
|
let token = match token {
|
||||||
|
Some(token) => token.trim().to_string(),
|
||||||
|
None => read_auth_input("Paste token")?,
|
||||||
|
};
|
||||||
|
if token.is_empty() {
|
||||||
|
bail!("Token cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = auth::anthropic_token::detect_auth_kind(&token, auth_kind.as_deref());
|
||||||
|
let mut metadata = std::collections::HashMap::new();
|
||||||
|
metadata.insert(
|
||||||
|
"auth_kind".to_string(),
|
||||||
|
kind.as_metadata_value().to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let saved =
|
||||||
|
auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?;
|
||||||
|
println!("Saved profile {}", saved.id);
|
||||||
|
println!("Active profile for {provider}: {}", saved.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCommands::SetupToken { provider, profile } => {
|
||||||
|
let provider = auth::normalize_provider(&provider)?;
|
||||||
|
let token = read_auth_input("Paste token")?;
|
||||||
|
if token.is_empty() {
|
||||||
|
bail!("Token cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = auth::anthropic_token::detect_auth_kind(&token, Some("authorization"));
|
||||||
|
let mut metadata = std::collections::HashMap::new();
|
||||||
|
metadata.insert(
|
||||||
|
"auth_kind".to_string(),
|
||||||
|
kind.as_metadata_value().to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let saved =
|
||||||
|
auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?;
|
||||||
|
println!("Saved profile {}", saved.id);
|
||||||
|
println!("Active profile for {provider}: {}", saved.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCommands::Refresh { provider, profile } => {
|
||||||
|
let provider = auth::normalize_provider(&provider)?;
|
||||||
|
if provider != "openai-codex" {
|
||||||
|
bail!("`auth refresh` currently supports only --provider openai-codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
match auth_service
|
||||||
|
.get_valid_openai_access_token(profile.as_deref())
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(_) => {
|
||||||
|
println!("OpenAI Codex token is valid (refresh completed if needed).");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
bail!(
|
||||||
|
"No OpenAI Codex auth profile found. Run `zeroclaw auth login --provider openai-codex`."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCommands::Logout { provider, profile } => {
|
||||||
|
let provider = auth::normalize_provider(&provider)?;
|
||||||
|
let removed = auth_service.remove_profile(&provider, &profile)?;
|
||||||
|
if removed {
|
||||||
|
println!("Removed auth profile {provider}:{profile}");
|
||||||
|
} else {
|
||||||
|
println!("Auth profile not found: {provider}:{profile}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCommands::Use { provider, profile } => {
|
||||||
|
let provider = auth::normalize_provider(&provider)?;
|
||||||
|
let active = auth_service.set_active_profile(&provider, &profile)?;
|
||||||
|
println!("Active profile for {provider}: {active}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCommands::List => {
|
||||||
|
let data = auth_service.load_profiles()?;
|
||||||
|
if data.profiles.is_empty() {
|
||||||
|
println!("No auth profiles configured.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, profile) in &data.profiles {
|
||||||
|
let active = data
|
||||||
|
.active_profiles
|
||||||
|
.get(&profile.provider)
|
||||||
|
.is_some_and(|active_id| active_id == id);
|
||||||
|
let marker = if active { "*" } else { " " };
|
||||||
|
println!("{marker} {id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCommands::Status => {
|
||||||
|
let data = auth_service.load_profiles()?;
|
||||||
|
if data.profiles.is_empty() {
|
||||||
|
println!("No auth profiles configured.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, profile) in &data.profiles {
|
||||||
|
let active = data
|
||||||
|
.active_profiles
|
||||||
|
.get(&profile.provider)
|
||||||
|
.is_some_and(|active_id| active_id == id);
|
||||||
|
let marker = if active { "*" } else { " " };
|
||||||
|
println!(
|
||||||
|
"{} {} kind={:?} account={} expires={}",
|
||||||
|
marker,
|
||||||
|
id,
|
||||||
|
profile.kind,
|
||||||
|
profile.account_id.as_deref().unwrap_or("unknown"),
|
||||||
|
format_expiry(profile)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("Active profiles:");
|
||||||
|
for (provider, active) in &data.active_profiles {
|
||||||
|
println!(" {provider}: {active}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -4083,8 +4083,10 @@ fn print_summary(config: &Config) {
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
style("or: zeroclaw auth paste-token --provider anthropic --auth-kind authorization")
|
style(
|
||||||
.yellow()
|
"or: zeroclaw auth paste-token --provider anthropic --auth-kind authorization"
|
||||||
|
)
|
||||||
|
.yellow()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let env_var = provider_env_var(provider);
|
let env_var = provider_env_var(provider);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ use crate::tools::ToolSpec;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub struct AnthropicProvider {
|
pub struct AnthropicProvider {
|
||||||
credential: Option<String>,
|
credential: Option<String>,
|
||||||
|
|
@ -408,6 +407,7 @@ impl Provider for AnthropicProvider {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auth::anthropic_token::{detect_auth_kind, AnthropicAuthKind};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn creates_with_key() {
|
fn creates_with_key() {
|
||||||
|
|
|
||||||
|
|
@ -374,7 +374,21 @@ fn parse_custom_provider_url(
|
||||||
|
|
||||||
/// Factory: create the right provider from config (without custom URL)
|
/// Factory: create the right provider from config (without custom URL)
|
||||||
pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<dyn Provider>> {
|
pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<dyn Provider>> {
|
||||||
create_provider_with_url(name, api_key, None)
|
create_provider_with_options(name, api_key, &ProviderRuntimeOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory: create provider with runtime options (auth profile override, state dir).
|
||||||
|
pub fn create_provider_with_options(
|
||||||
|
name: &str,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
options: &ProviderRuntimeOptions,
|
||||||
|
) -> anyhow::Result<Box<dyn Provider>> {
|
||||||
|
match name {
|
||||||
|
"openai-codex" | "openai_codex" | "codex" => {
|
||||||
|
Ok(Box::new(openai_codex::OpenAiCodexProvider::new(options)))
|
||||||
|
}
|
||||||
|
_ => create_provider_with_url(name, api_key, None),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Factory: create the right provider from config with optional custom base URL
|
/// Factory: create the right provider from config with optional custom base URL
|
||||||
|
|
@ -561,6 +575,7 @@ pub fn create_resilient_provider(
|
||||||
create_resilient_provider_with_options(
|
create_resilient_provider_with_options(
|
||||||
primary_name,
|
primary_name,
|
||||||
api_key,
|
api_key,
|
||||||
|
api_url,
|
||||||
reliability,
|
reliability,
|
||||||
&ProviderRuntimeOptions::default(),
|
&ProviderRuntimeOptions::default(),
|
||||||
)
|
)
|
||||||
|
|
@ -570,23 +585,27 @@ pub fn create_resilient_provider(
|
||||||
pub fn create_resilient_provider_with_options(
|
pub fn create_resilient_provider_with_options(
|
||||||
primary_name: &str,
|
primary_name: &str,
|
||||||
api_key: Option<&str>,
|
api_key: Option<&str>,
|
||||||
|
api_url: Option<&str>,
|
||||||
reliability: &crate::config::ReliabilityConfig,
|
reliability: &crate::config::ReliabilityConfig,
|
||||||
options: &ProviderRuntimeOptions,
|
options: &ProviderRuntimeOptions,
|
||||||
) -> anyhow::Result<Box<dyn Provider>> {
|
) -> anyhow::Result<Box<dyn Provider>> {
|
||||||
let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
|
let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
|
||||||
|
|
||||||
providers.push((
|
let primary_provider = match primary_name {
|
||||||
primary_name.to_string(),
|
"openai-codex" | "openai_codex" | "codex" => {
|
||||||
create_provider_with_url(primary_name, api_key, api_url)?,
|
create_provider_with_options(primary_name, api_key, options)?
|
||||||
));
|
}
|
||||||
|
_ => create_provider_with_url(primary_name, api_key, api_url)?,
|
||||||
|
};
|
||||||
|
providers.push((primary_name.to_string(), primary_provider));
|
||||||
|
|
||||||
for fallback in &reliability.fallback_providers {
|
for fallback in &reliability.fallback_providers {
|
||||||
if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) {
|
if fallback == primary_name || providers.iter().any(|(name, _)| name == fallback) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback providers don't use the custom api_url (it's specific to primary)
|
// Fallback providers don't use the custom api_url (it's specific to primary).
|
||||||
match create_provider(fallback, api_key) {
|
match create_provider_with_options(fallback, api_key, options) {
|
||||||
Ok(provider) => providers.push((fallback.clone(), provider)),
|
Ok(provider) => providers.push((fallback.clone(), provider)),
|
||||||
Err(_error) => {
|
Err(_error) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|
@ -718,6 +737,12 @@ pub fn list_providers() -> Vec<ProviderInfo> {
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
local: false,
|
local: false,
|
||||||
},
|
},
|
||||||
|
ProviderInfo {
|
||||||
|
name: "openai-codex",
|
||||||
|
display_name: "OpenAI Codex (OAuth)",
|
||||||
|
aliases: &["openai_codex", "codex"],
|
||||||
|
local: false,
|
||||||
|
},
|
||||||
ProviderInfo {
|
ProviderInfo {
|
||||||
name: "ollama",
|
name: "ollama",
|
||||||
display_name: "Ollama",
|
display_name: "Ollama",
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ fn extract_stream_event_text(event: &Value, saw_delta: bool) -> Option<String> {
|
||||||
Some("response.output_text.done") if !saw_delta => {
|
Some("response.output_text.done") if !saw_delta => {
|
||||||
nonempty_preserve(event.get("text").and_then(Value::as_str))
|
nonempty_preserve(event.get("text").and_then(Value::as_str))
|
||||||
}
|
}
|
||||||
Some("response.completed") | Some("response.done") => event
|
Some("response.completed" | "response.done") => event
|
||||||
.get("response")
|
.get("response")
|
||||||
.and_then(|value| serde_json::from_value::<ResponsesResponse>(value.clone()).ok())
|
.and_then(|value| serde_json::from_value::<ResponsesResponse>(value.clone()).ok())
|
||||||
.and_then(|response| extract_responses_text(&response)),
|
.and_then(|response| extract_responses_text(&response)),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue