mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Add typed provider credential auth flow
This commit is contained in:
parent
7d01c4e42b
commit
a1d8b738d8
41 changed files with 2505 additions and 580 deletions
26
Cargo.lock
generated
26
Cargo.lock
generated
|
|
@ -1525,6 +1525,27 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.176.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"fabro-http",
|
||||
"fabro-model",
|
||||
"fabro-oauth",
|
||||
"fabro-vault",
|
||||
"httpmock",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.176.2"
|
||||
|
|
@ -1561,6 +1582,7 @@ dependencies = [
|
|||
"dotenvy",
|
||||
"fabro-agent",
|
||||
"fabro-api",
|
||||
"fabro-auth",
|
||||
"fabro-checkpoint",
|
||||
"fabro-config",
|
||||
"fabro-devcontainer",
|
||||
|
|
@ -1753,6 +1775,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"fabro-auth",
|
||||
"fabro-http",
|
||||
"fabro-macros",
|
||||
"fabro-model",
|
||||
|
|
@ -1898,6 +1921,7 @@ dependencies = [
|
|||
"dirs",
|
||||
"fabro-agent",
|
||||
"fabro-api",
|
||||
"fabro-auth",
|
||||
"fabro-config",
|
||||
"fabro-github",
|
||||
"fabro-graphviz",
|
||||
|
|
@ -2147,6 +2171,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"dirs",
|
||||
"fabro-agent",
|
||||
"fabro-auth",
|
||||
"fabro-checkpoint",
|
||||
"fabro-config",
|
||||
"fabro-core",
|
||||
|
|
@ -2168,6 +2193,7 @@ dependencies = [
|
|||
"fabro-types",
|
||||
"fabro-util",
|
||||
"fabro-validate",
|
||||
"fabro-vault",
|
||||
"futures",
|
||||
"git2",
|
||||
"hex",
|
||||
|
|
|
|||
|
|
@ -4353,6 +4353,7 @@ components:
|
|||
enum:
|
||||
- environment
|
||||
- file
|
||||
- credential
|
||||
|
||||
CreateSecretRequest:
|
||||
description: Request to store or update a secret.
|
||||
|
|
|
|||
30
lib/crates/fabro-auth/Cargo.toml
Normal file
30
lib/crates/fabro-auth/Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[package]
|
||||
name = "fabro-auth"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Typed provider credential storage and resolution for Fabro"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
fabro-http.workspace = true
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-oauth = { path = "../fabro-oauth" }
|
||||
fabro-vault = { path = "../fabro-vault" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
shlex = "1"
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
httpmock = "0.8"
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "test-util"] }
|
||||
20
lib/crates/fabro-auth/src/context.rs
Normal file
20
lib/crates/fabro-auth/src/context.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use fabro_model::Provider;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthContextRequest {
|
||||
ApiKey {
|
||||
provider: Provider,
|
||||
env_var_names: Vec<String>,
|
||||
},
|
||||
DeviceCode {
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
expires_in: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthContextResponse {
|
||||
ApiKey { key: String },
|
||||
DeviceCodeConfirmed,
|
||||
}
|
||||
158
lib/crates/fabro-auth/src/credential.rs
Normal file
158
lib/crates/fabro-auth/src/credential.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
use chrono::{DateTime, Duration, Utc};
|
||||
use fabro_model::Provider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AuthCredential {
|
||||
pub provider: Provider,
|
||||
#[serde(flatten)]
|
||||
pub details: AuthDetails,
|
||||
}
|
||||
|
||||
impl AuthCredential {
|
||||
#[must_use]
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
match &self.details {
|
||||
AuthDetails::ApiKey { .. } => false,
|
||||
AuthDetails::CodexOAuth { tokens, .. } => {
|
||||
tokens.expires_at <= Utc::now() + Duration::minutes(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AuthDetails {
|
||||
ApiKey {
|
||||
key: String,
|
||||
},
|
||||
CodexOAuth {
|
||||
tokens: OAuthTokens,
|
||||
config: OAuthConfig,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
account_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OAuthTokens {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OAuthConfig {
|
||||
pub auth_url: String,
|
||||
pub token_url: String,
|
||||
pub client_id: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub redirect_uri: Option<String>,
|
||||
pub use_pkce: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ApiKeyHeader {
|
||||
Bearer(String),
|
||||
Custom { name: String, value: String },
|
||||
}
|
||||
|
||||
pub fn credential_id_for(credential: &AuthCredential) -> Result<String, String> {
|
||||
match (&credential.provider, &credential.details) {
|
||||
(Provider::OpenAi, AuthDetails::ApiKey { .. }) => Ok("openai".to_string()),
|
||||
(Provider::OpenAi, AuthDetails::CodexOAuth { .. }) => Ok("openai_codex".to_string()),
|
||||
(_, AuthDetails::CodexOAuth { .. }) => Err(format!(
|
||||
"codex_oauth credentials are only valid for OpenAI, got {}",
|
||||
credential.provider
|
||||
)),
|
||||
(provider, AuthDetails::ApiKey { .. }) => Ok(provider.as_str().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_credential_secret(name: &str, value: &str) -> Result<AuthCredential, String> {
|
||||
let credential: AuthCredential =
|
||||
serde_json::from_str(value).map_err(|err| format!("invalid credential JSON: {err}"))?;
|
||||
let expected_name = credential_id_for(&credential)?;
|
||||
if name != expected_name {
|
||||
return Err(format!(
|
||||
"credential ID must be '{expected_name}' for this credential, got '{name}'"
|
||||
));
|
||||
}
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn oauth_credential(expires_at: DateTime<Utc>) -> AuthCredential {
|
||||
AuthCredential {
|
||||
provider: Provider::OpenAi,
|
||||
details: AuthDetails::CodexOAuth {
|
||||
tokens: OAuthTokens {
|
||||
access_token: "access".to_string(),
|
||||
refresh_token: Some("refresh".to_string()),
|
||||
expires_at,
|
||||
},
|
||||
config: OAuthConfig {
|
||||
auth_url: "https://auth.openai.com".to_string(),
|
||||
token_url: "https://auth.openai.com/oauth/token".to_string(),
|
||||
client_id: "client".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
|
||||
use_pkce: true,
|
||||
},
|
||||
account_id: Some("acct_123".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_credential_round_trips_through_json() {
|
||||
let credential = oauth_credential(Utc::now() + Duration::hours(1));
|
||||
let json = serde_json::to_string(&credential).unwrap();
|
||||
let parsed: AuthCredential = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, credential);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_refresh_uses_five_minute_buffer() {
|
||||
assert!(oauth_credential(Utc::now() + Duration::minutes(4)).needs_refresh());
|
||||
assert!(!oauth_credential(Utc::now() + Duration::minutes(6)).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_id_for_openai_codex_oauth() {
|
||||
let credential = oauth_credential(Utc::now() + Duration::hours(1));
|
||||
assert_eq!(credential_id_for(&credential).unwrap(), "openai_codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_id_for_openai_api_key() {
|
||||
let credential = AuthCredential {
|
||||
provider: Provider::OpenAi,
|
||||
details: AuthDetails::ApiKey {
|
||||
key: "sk-test".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(credential_id_for(&credential).unwrap(), "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_id_for_non_openai_codex_oauth_errors() {
|
||||
let mut credential = oauth_credential(Utc::now() + Duration::hours(1));
|
||||
credential.provider = Provider::Anthropic;
|
||||
assert!(credential_id_for(&credential).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_credential_secret_validates_name_and_json() {
|
||||
let credential = oauth_credential(Utc::now() + Duration::hours(1));
|
||||
let json = serde_json::to_string(&credential).unwrap();
|
||||
|
||||
assert!(parse_credential_secret("openai_codex", &json).is_ok());
|
||||
assert!(parse_credential_secret("openai", &json).is_err());
|
||||
assert!(parse_credential_secret("openai_codex", "{").is_err());
|
||||
}
|
||||
}
|
||||
24
lib/crates/fabro-auth/src/lib.rs
Normal file
24
lib/crates/fabro-auth/src/lib.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
mod context;
|
||||
mod credential;
|
||||
mod refresh;
|
||||
mod resolve;
|
||||
mod strategy;
|
||||
mod vault_ext;
|
||||
|
||||
pub mod strategies;
|
||||
|
||||
pub use context::{AuthContextRequest, AuthContextResponse};
|
||||
pub use credential::{
|
||||
ApiKeyHeader, AuthCredential, AuthDetails, OAuthConfig, OAuthTokens, credential_id_for,
|
||||
parse_credential_secret,
|
||||
};
|
||||
pub use refresh::refresh_oauth_credential;
|
||||
pub use resolve::{
|
||||
ApiCredential, CliAgentKind, CliCredential, CredentialResolver, CredentialUsage, EnvLookup,
|
||||
ResolveError, ResolvedCredential,
|
||||
};
|
||||
pub use strategy::{
|
||||
AuthMethod, AuthStrategy, CODEX_AUTH_URL, CODEX_CLIENT_ID, CODEX_TOKEN_URL, codex_oauth_config,
|
||||
strategy_for,
|
||||
};
|
||||
pub use vault_ext::{vault_credentials_for_provider, vault_get_credential, vault_set_credential};
|
||||
49
lib/crates/fabro-auth/src/refresh.rs
Normal file
49
lib/crates/fabro-auth/src/refresh.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use chrono::{Duration, Utc};
|
||||
|
||||
use crate::credential::{AuthCredential, AuthDetails, OAuthTokens};
|
||||
|
||||
fn expires_at_from_now(expires_in: Option<u64>) -> chrono::DateTime<Utc> {
|
||||
let seconds = i64::try_from(expires_in.unwrap_or(3600)).unwrap_or(i64::MAX);
|
||||
Utc::now() + Duration::seconds(seconds)
|
||||
}
|
||||
|
||||
pub async fn refresh_oauth_credential(
|
||||
credential: &AuthCredential,
|
||||
) -> anyhow::Result<AuthCredential> {
|
||||
match &credential.details {
|
||||
AuthDetails::ApiKey { .. } => Ok(credential.clone()),
|
||||
AuthDetails::CodexOAuth {
|
||||
tokens,
|
||||
config,
|
||||
account_id,
|
||||
} => {
|
||||
let refresh_token = tokens
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("refresh token missing"))?;
|
||||
let response = fabro_oauth::refresh_token(
|
||||
fabro_oauth::OAuthEndpoint {
|
||||
token_url: &config.token_url,
|
||||
client_id: &config.client_id,
|
||||
},
|
||||
refresh_token,
|
||||
)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(AuthCredential {
|
||||
provider: credential.provider,
|
||||
details: AuthDetails::CodexOAuth {
|
||||
tokens: OAuthTokens {
|
||||
access_token: response.access_token,
|
||||
refresh_token: response
|
||||
.refresh_token
|
||||
.or_else(|| tokens.refresh_token.clone()),
|
||||
expires_at: expires_at_from_now(response.expires_in),
|
||||
},
|
||||
config: config.clone(),
|
||||
account_id: account_id.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
608
lib/crates/fabro-auth/src/resolve.rs
Normal file
608
lib/crates/fabro-auth/src/resolve.rs
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_model::Provider;
|
||||
use fabro_vault::Vault;
|
||||
use shlex::try_quote;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::credential::{ApiKeyHeader, AuthCredential, AuthDetails, credential_id_for};
|
||||
use crate::refresh::refresh_oauth_credential;
|
||||
use crate::vault_ext::{vault_get_credential, vault_set_credential};
|
||||
|
||||
pub type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CliAgentKind {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CredentialUsage {
|
||||
ApiRequest,
|
||||
CliAgent(CliAgentKind),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApiCredential {
|
||||
pub provider: Provider,
|
||||
pub auth_header: ApiKeyHeader,
|
||||
pub extra_headers: HashMap<String, String>,
|
||||
pub base_url: Option<String>,
|
||||
pub codex_mode: bool,
|
||||
pub org_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CliCredential {
|
||||
pub env_vars: HashMap<String, String>,
|
||||
pub login_command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedCredential {
|
||||
Api(ApiCredential),
|
||||
Cli(CliCredential),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveError {
|
||||
#[error("{0} is not configured")]
|
||||
NotConfigured(Provider),
|
||||
#[error("{provider} requires re-authentication: {source}")]
|
||||
RefreshFailed {
|
||||
provider: Provider,
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
#[error("{0} requires re-authentication: missing refresh token")]
|
||||
RefreshTokenMissing(Provider),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CredentialResolver {
|
||||
vault: Arc<AsyncRwLock<Vault>>,
|
||||
env_lookup: EnvLookup,
|
||||
}
|
||||
|
||||
impl CredentialResolver {
|
||||
#[must_use]
|
||||
pub fn new(vault: Arc<AsyncRwLock<Vault>>) -> Self {
|
||||
Self::with_env_lookup(vault, Arc::new(|name| std::env::var(name).ok()))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_env_lookup(vault: Arc<AsyncRwLock<Vault>>, env_lookup: EnvLookup) -> Self {
|
||||
Self { vault, env_lookup }
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
provider: Provider,
|
||||
usage: CredentialUsage,
|
||||
) -> Result<ResolvedCredential, ResolveError> {
|
||||
let initial_credential = {
|
||||
let vault = self.vault.read().await;
|
||||
self.find_credential(&vault, provider, usage)?
|
||||
};
|
||||
|
||||
let credential = if initial_credential.needs_refresh() {
|
||||
let AuthDetails::CodexOAuth { tokens, .. } = &initial_credential.details else {
|
||||
unreachable!("only OAuth credentials can need refresh");
|
||||
};
|
||||
if tokens.refresh_token.is_none() {
|
||||
return Err(ResolveError::RefreshTokenMissing(provider));
|
||||
}
|
||||
|
||||
let refreshed = refresh_oauth_credential(&initial_credential)
|
||||
.await
|
||||
.map_err(|source| ResolveError::RefreshFailed { provider, source })?;
|
||||
let credential_id =
|
||||
credential_id_for(&refreshed).map_err(|message| ResolveError::RefreshFailed {
|
||||
provider,
|
||||
source: anyhow::anyhow!(message),
|
||||
})?;
|
||||
let refreshed_for_store = refreshed.clone();
|
||||
let vault = Arc::clone(&self.vault);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut vault = vault.blocking_write();
|
||||
vault_set_credential(&mut vault, &credential_id, &refreshed_for_store)
|
||||
.map(|_| ())
|
||||
.map_err(anyhow::Error::from)
|
||||
})
|
||||
.await
|
||||
.map_err(|join_err| ResolveError::RefreshFailed {
|
||||
provider,
|
||||
source: anyhow::Error::from(join_err),
|
||||
})?
|
||||
.map_err(|source| ResolveError::RefreshFailed { provider, source })?;
|
||||
refreshed
|
||||
} else {
|
||||
initial_credential
|
||||
};
|
||||
|
||||
let vault = self.vault.read().await;
|
||||
match usage {
|
||||
CredentialUsage::ApiRequest => Ok(ResolvedCredential::Api(
|
||||
self.to_api_credential(&vault, &credential),
|
||||
)),
|
||||
CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli(
|
||||
self.to_cli_credential(&credential, kind),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_credential(
|
||||
&self,
|
||||
vault: &Vault,
|
||||
provider: Provider,
|
||||
usage: CredentialUsage,
|
||||
) -> Result<AuthCredential, ResolveError> {
|
||||
for credential_id in credential_ids_for(provider, usage) {
|
||||
if let Some(credential) = vault_get_credential(vault, credential_id) {
|
||||
return Ok(credential);
|
||||
}
|
||||
}
|
||||
|
||||
for env_var in env_vars_for(provider) {
|
||||
if let Some(value) = self.lookup_env_or_vault(vault, env_var) {
|
||||
return Ok(AuthCredential {
|
||||
provider,
|
||||
details: AuthDetails::ApiKey { key: value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(ResolveError::NotConfigured(provider))
|
||||
}
|
||||
|
||||
fn lookup_env_or_vault(&self, vault: &Vault, name: &str) -> Option<String> {
|
||||
(self.env_lookup)(name).or_else(|| vault.get(name).map(str::to_string))
|
||||
}
|
||||
|
||||
fn to_api_credential(&self, vault: &Vault, credential: &AuthCredential) -> ApiCredential {
|
||||
let mut extra_headers = HashMap::new();
|
||||
let base_url = match credential.provider {
|
||||
Provider::Anthropic => self.lookup_env_or_vault(vault, "ANTHROPIC_BASE_URL"),
|
||||
Provider::OpenAi => self.lookup_env_or_vault(vault, "OPENAI_BASE_URL"),
|
||||
Provider::Gemini => self.lookup_env_or_vault(vault, "GEMINI_BASE_URL"),
|
||||
Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => None,
|
||||
Provider::OpenAiCompatible => None,
|
||||
};
|
||||
let mut api_credential = match &credential.details {
|
||||
AuthDetails::ApiKey { key } => ApiCredential {
|
||||
provider: credential.provider,
|
||||
auth_header: match credential.provider {
|
||||
Provider::Anthropic => ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: key.clone(),
|
||||
},
|
||||
_ => ApiKeyHeader::Bearer(key.clone()),
|
||||
},
|
||||
extra_headers,
|
||||
base_url,
|
||||
codex_mode: false,
|
||||
org_id: if credential.provider == Provider::OpenAi {
|
||||
self.lookup_env_or_vault(vault, "OPENAI_ORG_ID")
|
||||
} else {
|
||||
None
|
||||
},
|
||||
project_id: if credential.provider == Provider::OpenAi {
|
||||
self.lookup_env_or_vault(vault, "OPENAI_PROJECT_ID")
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
AuthDetails::CodexOAuth {
|
||||
tokens, account_id, ..
|
||||
} => {
|
||||
if let Some(account_id) = account_id {
|
||||
extra_headers.insert("ChatGPT-Account-Id".to_string(), account_id.clone());
|
||||
extra_headers.insert("originator".to_string(), "fabro".to_string());
|
||||
}
|
||||
ApiCredential {
|
||||
provider: credential.provider,
|
||||
auth_header: ApiKeyHeader::Bearer(tokens.access_token.clone()),
|
||||
extra_headers,
|
||||
base_url: Some("https://chatgpt.com/backend-api/codex".to_string()),
|
||||
codex_mode: true,
|
||||
org_id: self.lookup_env_or_vault(vault, "OPENAI_ORG_ID"),
|
||||
project_id: self.lookup_env_or_vault(vault, "OPENAI_PROJECT_ID"),
|
||||
}
|
||||
}
|
||||
};
|
||||
if api_credential.provider == Provider::OpenAi && api_credential.codex_mode {
|
||||
api_credential.base_url = Some("https://chatgpt.com/backend-api/codex".to_string());
|
||||
}
|
||||
api_credential
|
||||
}
|
||||
|
||||
fn to_cli_credential(&self, credential: &AuthCredential, kind: CliAgentKind) -> CliCredential {
|
||||
let mut env_vars = HashMap::new();
|
||||
let login_command = match (&credential.provider, &credential.details, kind) {
|
||||
(Provider::OpenAi, AuthDetails::ApiKey { key }, CliAgentKind::Codex) => {
|
||||
env_vars.insert("OPENAI_API_KEY".to_string(), key.clone());
|
||||
Some(codex_login_command(key))
|
||||
}
|
||||
(
|
||||
Provider::OpenAi,
|
||||
AuthDetails::CodexOAuth {
|
||||
tokens, account_id, ..
|
||||
},
|
||||
CliAgentKind::Codex,
|
||||
) => {
|
||||
env_vars.insert("OPENAI_API_KEY".to_string(), tokens.access_token.clone());
|
||||
if let Some(account_id) = account_id {
|
||||
env_vars.insert("CHATGPT_ACCOUNT_ID".to_string(), account_id.clone());
|
||||
}
|
||||
Some(codex_login_command(&tokens.access_token))
|
||||
}
|
||||
(_, AuthDetails::ApiKey { key }, _) => {
|
||||
if let Some(name) = env_vars_for(credential.provider).first() {
|
||||
env_vars.insert((*name).to_string(), key.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
(_, AuthDetails::CodexOAuth { tokens, .. }, _) => {
|
||||
env_vars.insert("OPENAI_API_KEY".to_string(), tokens.access_token.clone());
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
CliCredential {
|
||||
env_vars,
|
||||
login_command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_login_command(api_key: &str) -> String {
|
||||
let quoted = try_quote(api_key)
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.unwrap_or_else(|_| api_key.to_string());
|
||||
format!("PATH=\"$HOME/.local/bin:$PATH\" echo {quoted} | codex login --with-api-key")
|
||||
}
|
||||
|
||||
fn env_vars_for(provider: Provider) -> &'static [&'static str] {
|
||||
provider.api_key_env_vars()
|
||||
}
|
||||
|
||||
fn credential_ids_for(provider: Provider, usage: CredentialUsage) -> &'static [&'static str] {
|
||||
match (provider, usage) {
|
||||
(Provider::OpenAi, CredentialUsage::ApiRequest) => &["openai"],
|
||||
(Provider::OpenAi, CredentialUsage::CliAgent(CliAgentKind::Codex)) => {
|
||||
&["openai_codex", "openai"]
|
||||
}
|
||||
(Provider::Anthropic, _) => &["anthropic"],
|
||||
(Provider::Gemini, _) => &["gemini"],
|
||||
(Provider::Kimi, _) => &["kimi"],
|
||||
(Provider::Zai, _) => &["zai"],
|
||||
(Provider::Minimax, _) => &["minimax"],
|
||||
(Provider::Inception, _) => &["inception"],
|
||||
(Provider::OpenAiCompatible, _) => &[],
|
||||
(Provider::OpenAi, _) => &["openai"],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Duration, Utc};
|
||||
use httpmock::Method::POST;
|
||||
use httpmock::MockServer;
|
||||
|
||||
use super::*;
|
||||
use crate::credential::{OAuthConfig, OAuthTokens};
|
||||
use crate::vault_ext::vault_get_credential;
|
||||
|
||||
fn api_key_credential(provider: Provider, key: &str) -> AuthCredential {
|
||||
AuthCredential {
|
||||
provider,
|
||||
details: AuthDetails::ApiKey {
|
||||
key: key.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth_credential(token_url: String, expires_at: chrono::DateTime<Utc>) -> AuthCredential {
|
||||
AuthCredential {
|
||||
provider: Provider::OpenAi,
|
||||
details: AuthDetails::CodexOAuth {
|
||||
tokens: OAuthTokens {
|
||||
access_token: "expired-access".to_string(),
|
||||
refresh_token: Some("refresh-token".to_string()),
|
||||
expires_at,
|
||||
},
|
||||
config: OAuthConfig {
|
||||
auth_url: "https://auth.openai.com".to_string(),
|
||||
token_url,
|
||||
client_id: "test-client".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
|
||||
use_pkce: true,
|
||||
},
|
||||
account_id: Some("acct_123".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn test_resolver(vault: Vault, env_lookup: EnvLookup) -> CredentialResolver {
|
||||
CredentialResolver::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), env_lookup)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_openai_api_request_prefers_typed_credential() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_credential(
|
||||
&mut vault,
|
||||
"openai",
|
||||
&api_key_credential(Provider::OpenAi, "vault-key"),
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| Some("env-key".to_string())));
|
||||
|
||||
let resolved = resolver
|
||||
.resolve(Provider::OpenAi, CredentialUsage::ApiRequest)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ResolvedCredential::Api(api) = resolved else {
|
||||
panic!("expected api credential");
|
||||
};
|
||||
assert_eq!(
|
||||
api.auth_header,
|
||||
ApiKeyHeader::Bearer("vault-key".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_returns_not_configured_for_missing_provider() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
|
||||
let err = resolver
|
||||
.resolve(Provider::Anthropic, CredentialUsage::ApiRequest)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::NotConfigured(Provider::Anthropic)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_api_credentials_use_x_api_key_header() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_credential(
|
||||
&mut vault,
|
||||
"anthropic",
|
||||
&api_key_credential(Provider::Anthropic, "anthropic-key"),
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
|
||||
let ResolvedCredential::Api(api) = resolver
|
||||
.resolve(Provider::Anthropic, CredentialUsage::ApiRequest)
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected api credential");
|
||||
};
|
||||
|
||||
assert_eq!(api.auth_header, ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "anthropic-key".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_codex_cli_credential_includes_login_command_and_account_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_credential(
|
||||
&mut vault,
|
||||
"openai_codex",
|
||||
&oauth_credential(
|
||||
"https://auth.openai.com/oauth/token".to_string(),
|
||||
Utc::now() + Duration::hours(1),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
|
||||
let ResolvedCredential::Cli(cli) = resolver
|
||||
.resolve(
|
||||
Provider::OpenAi,
|
||||
CredentialUsage::CliAgent(CliAgentKind::Codex),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected cli credential");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
cli.env_vars.get("OPENAI_API_KEY").map(String::as_str),
|
||||
Some("expired-access")
|
||||
);
|
||||
assert_eq!(
|
||||
cli.env_vars.get("CHATGPT_ACCOUNT_ID").map(String::as_str),
|
||||
Some("acct_123")
|
||||
);
|
||||
assert!(
|
||||
cli.login_command
|
||||
.as_deref()
|
||||
.is_some_and(|command| command.contains("codex login --with-api-key"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_api_key_cli_fallback_has_no_account_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_credential(
|
||||
&mut vault,
|
||||
"openai",
|
||||
&api_key_credential(Provider::OpenAi, "openai-key"),
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
|
||||
let ResolvedCredential::Cli(cli) = resolver
|
||||
.resolve(
|
||||
Provider::OpenAi,
|
||||
CredentialUsage::CliAgent(CliAgentKind::Codex),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected cli credential");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
cli.env_vars.get("OPENAI_API_KEY").map(String::as_str),
|
||||
Some("openai-key")
|
||||
);
|
||||
assert!(!cli.env_vars.contains_key("CHATGPT_ACCOUNT_ID"));
|
||||
assert!(cli.login_command.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_env_lookup_overrides_vault_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_credential(
|
||||
&mut vault,
|
||||
"openai",
|
||||
&api_key_credential(Provider::OpenAi, "vault-key"),
|
||||
)
|
||||
.unwrap();
|
||||
vault
|
||||
.set(
|
||||
"OPENAI_ORG_ID",
|
||||
"vault-org",
|
||||
fabro_vault::SecretType::Environment,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = test_resolver(
|
||||
vault,
|
||||
Arc::new(|name| (name == "OPENAI_ORG_ID").then(|| "env-org".to_string())),
|
||||
);
|
||||
|
||||
let ResolvedCredential::Api(api) = resolver
|
||||
.resolve(Provider::OpenAi, CredentialUsage::ApiRequest)
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected api credential");
|
||||
};
|
||||
|
||||
assert_eq!(api.org_id.as_deref(), Some("env-org"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_refreshes_expired_oauth_credentials_and_persists_them() {
|
||||
let server = MockServer::start_async().await;
|
||||
let refresh_mock = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/oauth/token")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.form_urlencoded_tuple("grant_type", "refresh_token")
|
||||
.form_urlencoded_tuple("client_id", "test-client")
|
||||
.form_urlencoded_tuple("refresh_token", "refresh-token");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"expires_in": 3600
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_credential(
|
||||
&mut vault,
|
||||
"openai_codex",
|
||||
&oauth_credential(
|
||||
server.url("/oauth/token"),
|
||||
Utc::now() - Duration::minutes(1),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let vault = Arc::new(AsyncRwLock::new(vault));
|
||||
let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), Arc::new(|_| None));
|
||||
|
||||
let ResolvedCredential::Cli(cli) = resolver
|
||||
.resolve(
|
||||
Provider::OpenAi,
|
||||
CredentialUsage::CliAgent(CliAgentKind::Codex),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected cli credential");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
cli.env_vars.get("OPENAI_API_KEY").map(String::as_str),
|
||||
Some("new-access")
|
||||
);
|
||||
|
||||
let stored = {
|
||||
let vault = vault.read().await;
|
||||
vault_get_credential(&vault, "openai_codex").unwrap()
|
||||
};
|
||||
let AuthDetails::CodexOAuth {
|
||||
tokens, account_id, ..
|
||||
} = stored.details
|
||||
else {
|
||||
panic!("expected codex oauth credential");
|
||||
};
|
||||
assert_eq!(tokens.access_token, "new-access");
|
||||
assert_eq!(tokens.refresh_token.as_deref(), Some("new-refresh"));
|
||||
assert_eq!(account_id.as_deref(), Some("acct_123"));
|
||||
refresh_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_returns_refresh_token_missing_when_expired_oauth_has_no_refresh_token() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
let mut credential = oauth_credential(
|
||||
"https://auth.openai.com/oauth/token".to_string(),
|
||||
Utc::now() - Duration::minutes(1),
|
||||
);
|
||||
let AuthDetails::CodexOAuth { tokens, .. } = &mut credential.details else {
|
||||
unreachable!();
|
||||
};
|
||||
tokens.refresh_token = None;
|
||||
vault_set_credential(&mut vault, "openai_codex", &credential).unwrap();
|
||||
let resolver = test_resolver(vault, Arc::new(|_| None));
|
||||
|
||||
let err = resolver
|
||||
.resolve(
|
||||
Provider::OpenAi,
|
||||
CredentialUsage::CliAgent(CliAgentKind::Codex),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ResolveError::RefreshTokenMissing(Provider::OpenAi)
|
||||
));
|
||||
}
|
||||
}
|
||||
44
lib/crates/fabro-auth/src/strategies/api_key.rs
Normal file
44
lib/crates/fabro-auth/src/strategies/api_key.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use async_trait::async_trait;
|
||||
use fabro_model::Provider;
|
||||
|
||||
use crate::context::{AuthContextRequest, AuthContextResponse};
|
||||
use crate::credential::{AuthCredential, AuthDetails};
|
||||
use crate::strategy::AuthStrategy;
|
||||
|
||||
pub struct ApiKeyStrategy {
|
||||
provider: Provider,
|
||||
}
|
||||
|
||||
impl ApiKeyStrategy {
|
||||
#[must_use]
|
||||
pub fn new(provider: Provider) -> Self {
|
||||
Self { provider }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStrategy for ApiKeyStrategy {
|
||||
async fn init(&mut self) -> anyhow::Result<AuthContextRequest> {
|
||||
Ok(AuthContextRequest::ApiKey {
|
||||
provider: self.provider,
|
||||
env_var_names: self
|
||||
.provider
|
||||
.api_key_env_vars()
|
||||
.iter()
|
||||
.map(|name| (*name).to_string())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<AuthCredential> {
|
||||
match response {
|
||||
AuthContextResponse::ApiKey { key } => Ok(AuthCredential {
|
||||
provider: self.provider,
|
||||
details: AuthDetails::ApiKey { key },
|
||||
}),
|
||||
AuthContextResponse::DeviceCodeConfirmed => {
|
||||
Err(anyhow::anyhow!("expected API key response"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
286
lib/crates/fabro-auth/src/strategies/codex_device.rs
Normal file
286
lib/crates/fabro-auth/src/strategies/codex_device.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use fabro_http::HttpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::context::{AuthContextRequest, AuthContextResponse};
|
||||
use crate::credential::{AuthCredential, AuthDetails, OAuthConfig, OAuthTokens};
|
||||
use crate::strategy::AuthStrategy;
|
||||
|
||||
const DEVICE_AUTH_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||
const DEVICE_AUTH_POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
fn http_client() -> anyhow::Result<HttpClient> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
fabro_http::test_http_client().map_err(anyhow::Error::from)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
fabro_http::http_client().map_err(anyhow::Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
fn join_url(base: &str, path: &str) -> String {
|
||||
format!("{}{}", base.trim_end_matches('/'), path)
|
||||
}
|
||||
|
||||
fn expires_at_from_now(expires_in: Option<u64>) -> chrono::DateTime<chrono::Utc> {
|
||||
let seconds = i64::try_from(expires_in.unwrap_or(3600)).unwrap_or(i64::MAX);
|
||||
chrono::Utc::now() + chrono::Duration::seconds(seconds)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JwtPayload {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
#[serde(default, rename = "https://api.openai.com/auth")]
|
||||
auth_claim: Option<AuthClaim>,
|
||||
#[serde(default)]
|
||||
organizations: Option<Vec<Organization>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthClaim {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Organization {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_jwt_payload(token: &str) -> Option<JwtPayload> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?;
|
||||
serde_json::from_slice(&payload_bytes).ok()
|
||||
}
|
||||
|
||||
pub fn extract_chatgpt_account_id(id_token: &str) -> Option<String> {
|
||||
let payload = parse_jwt_payload(id_token)?;
|
||||
payload
|
||||
.chatgpt_account_id
|
||||
.or_else(|| {
|
||||
payload
|
||||
.auth_claim
|
||||
.and_then(|claim| claim.chatgpt_account_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
payload
|
||||
.organizations
|
||||
.and_then(|orgs| orgs.into_iter().next())
|
||||
.and_then(|org| org.id)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceCodeInitResponse {
|
||||
device_auth_id: String,
|
||||
user_code: String,
|
||||
#[serde(alias = "verificationUrl", alias = "verification_uri")]
|
||||
verification_uri: String,
|
||||
#[serde(default)]
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceCodePollResponse {
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
authorization_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeviceCodeInitRequest<'a> {
|
||||
client_id: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
code_challenge: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CodexDeviceStrategy {
|
||||
config: OAuthConfig,
|
||||
device_auth_id: Option<String>,
|
||||
code_verifier: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexDeviceStrategy {
|
||||
#[must_use]
|
||||
pub fn new(config: OAuthConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
device_auth_id: None,
|
||||
code_verifier: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_codex_device(&self, device_auth_id: &str) -> anyhow::Result<String> {
|
||||
let client = http_client()?;
|
||||
let deadline = Instant::now() + DEVICE_AUTH_TIMEOUT;
|
||||
let url = join_url(&self.config.auth_url, "/api/accounts/deviceauth/token");
|
||||
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(anyhow::anyhow!("device auth timed out after 15 minutes"));
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&json!({ "device_auth_id": device_auth_id }))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"device auth failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
|
||||
let payload: DeviceCodePollResponse = response.json().await?;
|
||||
if let Some(code) = payload.authorization_code {
|
||||
return Ok(code);
|
||||
}
|
||||
|
||||
match payload.status.as_deref() {
|
||||
Some("pending") | Some("running") | None => {
|
||||
sleep(DEVICE_AUTH_POLL_INTERVAL).await;
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(anyhow::anyhow!("device code exchange failed: {other}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStrategy for CodexDeviceStrategy {
|
||||
async fn init(&mut self) -> anyhow::Result<AuthContextRequest> {
|
||||
let pkce = self.config.use_pkce.then(fabro_oauth::generate_pkce);
|
||||
self.code_verifier = pkce.as_ref().map(|codes| codes.verifier.clone());
|
||||
|
||||
let client = http_client()?;
|
||||
let url = join_url(&self.config.auth_url, "/api/accounts/deviceauth/usercode");
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&DeviceCodeInitRequest {
|
||||
client_id: &self.config.client_id,
|
||||
code_challenge: pkce.as_ref().map(|codes| codes.challenge.as_str()),
|
||||
scope: (!self.config.scopes.is_empty())
|
||||
.then(|| self.config.scopes.join(" ")),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"device code request failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
|
||||
let payload: DeviceCodeInitResponse = response.json().await?;
|
||||
self.device_auth_id = Some(payload.device_auth_id);
|
||||
|
||||
Ok(AuthContextRequest::DeviceCode {
|
||||
user_code: payload.user_code,
|
||||
verification_uri: payload.verification_uri,
|
||||
expires_in: payload.expires_in.unwrap_or(900),
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<AuthCredential> {
|
||||
match response {
|
||||
AuthContextResponse::ApiKey { .. } => Err(anyhow::anyhow!(
|
||||
"expected device code confirmation response"
|
||||
)),
|
||||
AuthContextResponse::DeviceCodeConfirmed => {
|
||||
let device_auth_id = self
|
||||
.device_auth_id
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("device auth flow was not initialized"))?;
|
||||
let authorization_code = self.poll_codex_device(&device_auth_id).await?;
|
||||
let token_response = fabro_oauth::exchange_code(
|
||||
fabro_oauth::OAuthEndpoint {
|
||||
token_url: &self.config.token_url,
|
||||
client_id: &self.config.client_id,
|
||||
},
|
||||
&authorization_code,
|
||||
self.config.redirect_uri.as_deref(),
|
||||
self.code_verifier.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
Ok(AuthCredential {
|
||||
provider: fabro_model::Provider::OpenAi,
|
||||
details: AuthDetails::CodexOAuth {
|
||||
tokens: OAuthTokens {
|
||||
access_token: token_response.access_token,
|
||||
refresh_token: token_response.refresh_token,
|
||||
expires_at: expires_at_from_now(token_response.expires_in),
|
||||
},
|
||||
config: self.config.clone(),
|
||||
account_id: token_response
|
||||
.id_token
|
||||
.as_deref()
|
||||
.and_then(extract_chatgpt_account_id),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_jwt(claims: &serde_json::Value) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap());
|
||||
format!("{header}.{payload}.signature")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_chatgpt_account_id_prefers_top_level_claim() {
|
||||
let jwt = make_test_jwt(&json!({
|
||||
"chatgpt_account_id": "top_level",
|
||||
"https://api.openai.com/auth": { "chatgpt_account_id": "nested" },
|
||||
"organizations": [{ "id": "org_123" }]
|
||||
}));
|
||||
assert_eq!(
|
||||
extract_chatgpt_account_id(&jwt).as_deref(),
|
||||
Some("top_level")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_chatgpt_account_id_falls_back_to_nested_claim() {
|
||||
let jwt = make_test_jwt(&json!({
|
||||
"https://api.openai.com/auth": { "chatgpt_account_id": "nested" }
|
||||
}));
|
||||
assert_eq!(extract_chatgpt_account_id(&jwt).as_deref(), Some("nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_chatgpt_account_id_falls_back_to_organization() {
|
||||
let jwt = make_test_jwt(&json!({
|
||||
"organizations": [{ "id": "org_123" }]
|
||||
}));
|
||||
assert_eq!(extract_chatgpt_account_id(&jwt).as_deref(), Some("org_123"));
|
||||
}
|
||||
}
|
||||
2
lib/crates/fabro-auth/src/strategies/mod.rs
Normal file
2
lib/crates/fabro-auth/src/strategies/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod api_key;
|
||||
pub mod codex_device;
|
||||
81
lib/crates/fabro-auth/src/strategy.rs
Normal file
81
lib/crates/fabro-auth/src/strategy.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
use async_trait::async_trait;
|
||||
use fabro_model::Provider;
|
||||
|
||||
use crate::context::{AuthContextRequest, AuthContextResponse};
|
||||
use crate::credential::{AuthCredential, OAuthConfig};
|
||||
use crate::strategies::api_key::ApiKeyStrategy;
|
||||
use crate::strategies::codex_device::CodexDeviceStrategy;
|
||||
|
||||
pub const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const CODEX_AUTH_URL: &str = "https://auth.openai.com";
|
||||
pub const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
|
||||
|
||||
#[async_trait]
|
||||
pub trait AuthStrategy: Send {
|
||||
async fn init(&mut self) -> anyhow::Result<AuthContextRequest>;
|
||||
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<AuthCredential>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthMethod {
|
||||
ApiKey,
|
||||
CodexDevice(OAuthConfig),
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn codex_oauth_config() -> OAuthConfig {
|
||||
OAuthConfig {
|
||||
auth_url: CODEX_AUTH_URL.to_string(),
|
||||
token_url: CODEX_TOKEN_URL.to_string(),
|
||||
client_id: CODEX_CLIENT_ID.to_string(),
|
||||
scopes: vec![
|
||||
"openid".to_string(),
|
||||
"profile".to_string(),
|
||||
"email".to_string(),
|
||||
"offline_access".to_string(),
|
||||
],
|
||||
redirect_uri: Some(format!("{CODEX_AUTH_URL}/deviceauth/callback")),
|
||||
use_pkce: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn strategy_for(provider: Provider, method: AuthMethod) -> Box<dyn AuthStrategy> {
|
||||
match method {
|
||||
AuthMethod::ApiKey => Box::new(ApiKeyStrategy::new(provider)),
|
||||
AuthMethod::CodexDevice(config) => {
|
||||
assert_eq!(
|
||||
provider,
|
||||
Provider::OpenAi,
|
||||
"Codex device auth is only supported for OpenAI"
|
||||
);
|
||||
Box::new(CodexDeviceStrategy::new(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::AuthContextRequest;
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_config_has_expected_defaults() {
|
||||
let config = codex_oauth_config();
|
||||
assert_eq!(config.auth_url, CODEX_AUTH_URL);
|
||||
assert_eq!(config.token_url, CODEX_TOKEN_URL);
|
||||
assert_eq!(config.client_id, CODEX_CLIENT_ID);
|
||||
assert!(config.use_pkce);
|
||||
assert!(config.scopes.contains(&"offline_access".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_strategy_uses_provider_env_names() {
|
||||
let mut strategy = ApiKeyStrategy::new(Provider::Anthropic);
|
||||
let request = strategy.init().await.unwrap();
|
||||
assert_eq!(request, AuthContextRequest::ApiKey {
|
||||
provider: Provider::Anthropic,
|
||||
env_var_names: vec!["ANTHROPIC_API_KEY".to_string()],
|
||||
});
|
||||
}
|
||||
}
|
||||
102
lib/crates/fabro-auth/src/vault_ext.rs
Normal file
102
lib/crates/fabro-auth/src/vault_ext.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use fabro_model::Provider;
|
||||
use fabro_vault::{SecretMetadata, SecretType, Vault};
|
||||
|
||||
use crate::credential::AuthCredential;
|
||||
|
||||
pub fn vault_set_credential(
|
||||
vault: &mut Vault,
|
||||
id: &str,
|
||||
credential: &AuthCredential,
|
||||
) -> Result<SecretMetadata, fabro_vault::Error> {
|
||||
let json = serde_json::to_string(credential)?;
|
||||
vault.set(id, &json, SecretType::Credential, None)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn vault_get_credential(vault: &Vault, id: &str) -> Option<AuthCredential> {
|
||||
let entry = vault.get_entry(id)?;
|
||||
if entry.secret_type != SecretType::Credential {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(&entry.value).ok()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn vault_credentials_for_provider(
|
||||
vault: &Vault,
|
||||
provider: Provider,
|
||||
) -> Vec<(String, AuthCredential)> {
|
||||
vault
|
||||
.credential_entries()
|
||||
.into_iter()
|
||||
.filter_map(|(name, entry)| {
|
||||
serde_json::from_str::<AuthCredential>(&entry.value)
|
||||
.ok()
|
||||
.filter(|credential| credential.provider == provider)
|
||||
.map(|credential| (name.to_string(), credential))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use super::*;
|
||||
use crate::credential::{AuthDetails, OAuthConfig, OAuthTokens};
|
||||
|
||||
fn oauth_credential() -> AuthCredential {
|
||||
AuthCredential {
|
||||
provider: Provider::OpenAi,
|
||||
details: AuthDetails::CodexOAuth {
|
||||
tokens: OAuthTokens {
|
||||
access_token: "access".to_string(),
|
||||
refresh_token: Some("refresh".to_string()),
|
||||
expires_at: Utc::now() + Duration::hours(1),
|
||||
},
|
||||
config: OAuthConfig {
|
||||
auth_url: "https://auth.openai.com".to_string(),
|
||||
token_url: "https://auth.openai.com/oauth/token".to_string(),
|
||||
client_id: "client".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
|
||||
use_pkce: true,
|
||||
},
|
||||
account_id: Some("acct_123".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_credential_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
let credential = oauth_credential();
|
||||
|
||||
vault_set_credential(&mut vault, "openai_codex", &credential).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
vault_get_credential(&vault, "openai_codex").unwrap(),
|
||||
credential
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_credentials_for_provider_filters_by_provider() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
vault_set_credential(&mut vault, "openai_codex", &oauth_credential()).unwrap();
|
||||
vault_set_credential(&mut vault, "anthropic", &AuthCredential {
|
||||
provider: Provider::Anthropic,
|
||||
details: AuthDetails::ApiKey {
|
||||
key: "anthropic-key".to_string(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let credentials = vault_credentials_for_provider(&vault, Provider::OpenAi);
|
||||
|
||||
assert_eq!(credentials.len(), 1);
|
||||
assert_eq!(credentials[0].0, "openai_codex");
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ sleep_inhibitor = ["dep:core-foundation"]
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
fabro-auth = { path = "../fabro-auth" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-llm = { path = "../fabro-llm" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
|
|
|
|||
|
|
@ -217,10 +217,7 @@ fn check_legacy_env(path: Option<PathBuf>) -> CheckResult {
|
|||
"{} is no longer read by fabro",
|
||||
path.display()
|
||||
))],
|
||||
remediation: Some(
|
||||
"Re-enter credentials with `fabro provider login` or `fabro secret set`."
|
||||
.to_string(),
|
||||
),
|
||||
remediation: Some("Re-enter credentials with `fabro provider login`.".to_string()),
|
||||
},
|
||||
None => CheckResult {
|
||||
name: "Legacy .env".to_string(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ use dialoguer::console::Term;
|
|||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::{MultiSelect, Select};
|
||||
use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType};
|
||||
use fabro_auth::{
|
||||
AuthCredential, AuthMethod, codex_oauth_config, credential_id_for, parse_credential_secret,
|
||||
};
|
||||
use fabro_config::user::SETTINGS_CONFIG_FILENAME;
|
||||
use fabro_config::{Storage, envfile, legacy_env};
|
||||
use fabro_model::Provider;
|
||||
|
|
@ -31,7 +34,7 @@ use crate::args::{DoctorArgs, GlobalArgs, InstallArgs, ServerTargetArgs};
|
|||
use crate::commands::server::record;
|
||||
use crate::gh::GhCli;
|
||||
use crate::shared::provider_auth::{
|
||||
prompt_and_validate_key, prompt_confirm, provider_display_name, run_openai_oauth_or_api_key,
|
||||
authenticate_provider, authenticate_provider_with_method, prompt_confirm, provider_display_name,
|
||||
};
|
||||
use crate::{server_client, user_config};
|
||||
|
||||
|
|
@ -693,7 +696,7 @@ async fn setup_github_app(
|
|||
|
||||
async fn persist_vault_secrets(
|
||||
storage_dir: &Path,
|
||||
secrets: &[(String, String)],
|
||||
secrets: &[CreateSecretRequest],
|
||||
server_was_running: bool,
|
||||
) -> Result<()> {
|
||||
if secrets.is_empty() {
|
||||
|
|
@ -702,14 +705,14 @@ async fn persist_vault_secrets(
|
|||
|
||||
if server_was_running {
|
||||
let client = server_client::connect_api_client(storage_dir).await?;
|
||||
for (name, value) in secrets {
|
||||
for secret in secrets {
|
||||
client
|
||||
.create_secret()
|
||||
.body(CreateSecretRequest {
|
||||
name: name.clone(),
|
||||
value: value.clone(),
|
||||
type_: ApiSecretType::Environment,
|
||||
description: None,
|
||||
name: secret.name.clone(),
|
||||
value: secret.value.clone(),
|
||||
type_: secret.type_.clone(),
|
||||
description: secret.description.clone(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
|
@ -718,12 +721,45 @@ async fn persist_vault_secrets(
|
|||
}
|
||||
|
||||
let mut store = Vault::load(Storage::new(storage_dir).secrets_path())?;
|
||||
for (name, value) in secrets {
|
||||
store.set(name, value, SecretType::Environment, None)?;
|
||||
for secret in secrets {
|
||||
validate_vault_secret(secret)?;
|
||||
store.set(
|
||||
&secret.name,
|
||||
&secret.value,
|
||||
local_secret_type(&secret.type_),
|
||||
secret.description.as_deref(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn local_secret_type(secret_type: &ApiSecretType) -> SecretType {
|
||||
match secret_type {
|
||||
ApiSecretType::Environment => SecretType::Environment,
|
||||
ApiSecretType::File => SecretType::File,
|
||||
ApiSecretType::Credential => SecretType::Credential,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_vault_secret(secret: &CreateSecretRequest) -> Result<()> {
|
||||
if secret.type_ != ApiSecretType::Credential {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
parse_credential_secret(&secret.name, &secret.value)
|
||||
.map(|_| ())
|
||||
.map_err(anyhow::Error::msg)
|
||||
}
|
||||
|
||||
fn credential_secret_request(credential: &AuthCredential) -> Result<CreateSecretRequest> {
|
||||
Ok(CreateSecretRequest {
|
||||
name: credential_id_for(credential).map_err(anyhow::Error::msg)?,
|
||||
value: serde_json::to_string(credential)?,
|
||||
type_: ApiSecretType::Credential,
|
||||
description: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)]) -> Result<()> {
|
||||
if secrets.is_empty() {
|
||||
return Ok(());
|
||||
|
|
@ -739,7 +775,7 @@ fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)])
|
|||
async fn persist_install_outputs(
|
||||
storage_dir: &Path,
|
||||
server_env_secrets: &[(String, String)],
|
||||
vault_secrets: &[(String, String)],
|
||||
vault_secrets: &[CreateSecretRequest],
|
||||
server_was_running: bool,
|
||||
) -> Result<()> {
|
||||
persist_server_env_secrets(storage_dir, server_env_secrets)?;
|
||||
|
|
@ -841,32 +877,38 @@ pub(crate) async fn run_install(
|
|||
fabro_util::printerr!(printer, " {}", s.dim.apply_to("──────────────────────"));
|
||||
fabro_util::printerr!(printer, "");
|
||||
|
||||
let mut vault_pairs: Vec<(String, String)> = Vec::new();
|
||||
let mut vault_secrets: Vec<CreateSecretRequest> = Vec::new();
|
||||
let mut server_env_pairs: Vec<(String, String)> = Vec::new();
|
||||
let mut configured_providers: Vec<Provider> = Vec::new();
|
||||
|
||||
let codex_detected = detect_binary_on_path("codex").await;
|
||||
let mut openai_via_oauth = false;
|
||||
let mut openai_configured = false;
|
||||
|
||||
if codex_detected {
|
||||
tracing::debug!("Codex binary detected on PATH");
|
||||
let use_oauth = spawn_blocking(|| {
|
||||
let use_device_auth = spawn_blocking(|| {
|
||||
prompt_confirm(
|
||||
"OpenAI (Codex) detected. Set up OpenAI via browser login?",
|
||||
"OpenAI (Codex) detected. Set up OpenAI with device code login?",
|
||||
true,
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
|
||||
if use_oauth {
|
||||
let pairs = run_openai_oauth_or_api_key(&s, printer).await?;
|
||||
vault_pairs.extend(pairs);
|
||||
if use_device_auth {
|
||||
let credential = authenticate_provider_with_method(
|
||||
Provider::OpenAi,
|
||||
AuthMethod::CodexDevice(codex_oauth_config()),
|
||||
&s,
|
||||
printer,
|
||||
)
|
||||
.await?;
|
||||
vault_secrets.push(credential_secret_request(&credential)?);
|
||||
configured_providers.push(Provider::OpenAi);
|
||||
openai_via_oauth = true;
|
||||
openai_configured = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !openai_via_oauth {
|
||||
if !openai_configured {
|
||||
// First provider — single choice from the top 3
|
||||
let primary_providers = [Provider::Anthropic, Provider::OpenAi, Provider::Gemini];
|
||||
let primary_labels: Vec<String> = primary_providers
|
||||
|
|
@ -882,8 +924,8 @@ pub(crate) async fn run_install(
|
|||
|
||||
let first_provider = primary_providers[primary_idx];
|
||||
{
|
||||
let (env_var, key) = prompt_and_validate_key(first_provider, &s, printer).await?;
|
||||
vault_pairs.push((env_var, key));
|
||||
let credential = authenticate_provider(first_provider, &s, printer).await?;
|
||||
vault_secrets.push(credential_secret_request(&credential)?);
|
||||
configured_providers.push(first_provider);
|
||||
}
|
||||
}
|
||||
|
|
@ -916,8 +958,8 @@ pub(crate) async fn run_install(
|
|||
|
||||
for idx in selected_indices {
|
||||
let provider = remaining_providers[idx];
|
||||
let (env_var, key) = prompt_and_validate_key(provider, &s, printer).await?;
|
||||
vault_pairs.push((env_var, key));
|
||||
let credential = authenticate_provider(provider, &s, printer).await?;
|
||||
vault_secrets.push(credential_secret_request(&credential)?);
|
||||
}
|
||||
}
|
||||
fabro_util::printerr!(printer, "");
|
||||
|
|
@ -953,7 +995,12 @@ pub(crate) async fn run_install(
|
|||
write_github_cli_settings(&mut doc)?;
|
||||
std::fs::write(&user_toml_path, toml::to_string_pretty(&doc)?)?;
|
||||
fabro_util::printerr!(printer, " {} GitHub CLI configured", s.green.apply_to("✔"));
|
||||
vault_pairs.push(("GITHUB_CLI_TOKEN".to_string(), token));
|
||||
vault_secrets.push(CreateSecretRequest {
|
||||
name: "GITHUB_CLI_TOKEN".to_string(),
|
||||
value: token,
|
||||
type_: ApiSecretType::Environment,
|
||||
description: None,
|
||||
});
|
||||
}
|
||||
1 => {
|
||||
let (owner, username) = prompt_github_app_owner(&s).await?;
|
||||
|
|
@ -1085,7 +1132,7 @@ pub(crate) async fn run_install(
|
|||
persist_install_outputs(
|
||||
&storage_dir,
|
||||
&server_env_pairs,
|
||||
&vault_pairs,
|
||||
&vault_secrets,
|
||||
server_was_running,
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -1103,7 +1150,7 @@ pub(crate) async fn run_install(
|
|||
printer,
|
||||
" {} Saved {} workflow-visible secrets to {}",
|
||||
s.green.apply_to("✔"),
|
||||
vault_pairs.len(),
|
||||
vault_secrets.len(),
|
||||
Storage::new(&storage_dir).secrets_path().display()
|
||||
);
|
||||
if server_was_running {
|
||||
|
|
@ -1530,9 +1577,23 @@ client_id = "client-id"
|
|||
("SESSION_SECRET".to_string(), "session".to_string()),
|
||||
("FABRO_JWT_PUBLIC_KEY".to_string(), "public-key".to_string()),
|
||||
];
|
||||
let vault_pairs = vec![("OPENAI_API_KEY".to_string(), "openai-key".to_string())];
|
||||
let vault_secrets = vec![
|
||||
CreateSecretRequest {
|
||||
name: "GITHUB_CLI_TOKEN".to_string(),
|
||||
value: "gh-token".to_string(),
|
||||
type_: ApiSecretType::Environment,
|
||||
description: None,
|
||||
},
|
||||
credential_secret_request(&AuthCredential {
|
||||
provider: Provider::Anthropic,
|
||||
details: fabro_auth::AuthDetails::ApiKey {
|
||||
key: "anthropic-key".to_string(),
|
||||
},
|
||||
})
|
||||
.unwrap(),
|
||||
];
|
||||
|
||||
persist_install_outputs(dir.path(), &server_env_pairs, &vault_pairs, false)
|
||||
persist_install_outputs(dir.path(), &server_env_pairs, &vault_secrets, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -1542,7 +1603,9 @@ client_id = "client-id"
|
|||
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY=public-key"));
|
||||
|
||||
let vault = Vault::load(Storage::new(dir.path()).secrets_path()).unwrap();
|
||||
assert_eq!(vault.get("OPENAI_API_KEY"), Some("openai-key"));
|
||||
assert_eq!(vault.get("GITHUB_CLI_TOKEN"), Some("gh-token"));
|
||||
let anthropic = vault.get_entry("anthropic").unwrap();
|
||||
assert_eq!(anthropic.secret_type, SecretType::Credential);
|
||||
assert_eq!(vault.get("SESSION_SECRET"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use anyhow::Result;
|
||||
use fabro_api::types;
|
||||
use fabro_auth::credential_id_for;
|
||||
use fabro_config::legacy_env;
|
||||
use fabro_model::Provider;
|
||||
use fabro_util::printer::Printer;
|
||||
use fabro_util::terminal::Styles;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
use crate::args::{GlobalArgs, ProviderLoginArgs};
|
||||
use crate::command_context::CommandContext;
|
||||
|
|
@ -19,43 +18,37 @@ pub(super) async fn login_command(
|
|||
let s = Styles::detect_stderr();
|
||||
let ctx = CommandContext::for_target(&args.target, printer)?;
|
||||
let server = ctx.server().await?;
|
||||
|
||||
let use_oauth = args.provider == Provider::OpenAi
|
||||
&& spawn_blocking(|| provider_auth::prompt_confirm("Log in via browser (OAuth)?", true))
|
||||
.await??;
|
||||
|
||||
let env_pairs = if use_oauth {
|
||||
provider_auth::run_openai_oauth_or_api_key(&s, printer).await?
|
||||
} else {
|
||||
let (env_var, key) =
|
||||
provider_auth::prompt_and_validate_key(args.provider, &s, printer).await?;
|
||||
vec![(env_var, key)]
|
||||
};
|
||||
let credential = provider_auth::authenticate_provider(args.provider, &s, printer).await?;
|
||||
let credential_id = credential_id_for(&credential).map_err(anyhow::Error::msg)?;
|
||||
let value = serde_json::to_string(&credential)?;
|
||||
|
||||
{
|
||||
let path = legacy_env::legacy_env_file_path();
|
||||
if path.exists() {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" Warning: {} is no longer read by fabro server. Re-enter credentials with `fabro provider login` or `fabro secret set`.",
|
||||
" Warning: {} is no longer read by fabro server. Re-enter credentials with `fabro provider login`.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, value) in env_pairs {
|
||||
server
|
||||
.api()
|
||||
.create_secret()
|
||||
.body(types::CreateSecretRequest {
|
||||
name: name.clone(),
|
||||
value,
|
||||
type_: types::SecretType::Environment,
|
||||
description: None,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
fabro_util::printerr!(printer, " {} Saved {}", s.green.apply_to("✔"), name);
|
||||
}
|
||||
server
|
||||
.api()
|
||||
.create_secret()
|
||||
.body(types::CreateSecretRequest {
|
||||
name: credential_id.clone(),
|
||||
value,
|
||||
type_: types::SecretType::Credential,
|
||||
description: None,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} Saved {}",
|
||||
s.green.apply_to("✔"),
|
||||
credential_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ pub(crate) async fn execute(
|
|||
artifact_sink,
|
||||
run_control: Some(run_control),
|
||||
github_app,
|
||||
vault: None,
|
||||
on_node: None,
|
||||
registry_override: None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
pub(crate) mod github;
|
||||
pub(crate) mod openai_jwt;
|
||||
pub(crate) mod provider_auth;
|
||||
pub(crate) mod repo;
|
||||
mod utilities;
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(crate) const DEFAULT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub(crate) const DEFAULT_ISSUER: &str = "https://auth.openai.com";
|
||||
pub(crate) const OAUTH_PORT: u16 = 1455;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JwtPayload {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
#[serde(default, rename = "https://api.openai.com/auth")]
|
||||
auth_claim: Option<AuthClaim>,
|
||||
#[serde(default)]
|
||||
organizations: Option<Vec<Organization>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthClaim {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Organization {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_jwt_payload(token: &str) -> Option<JwtPayload> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?;
|
||||
serde_json::from_slice(&payload_bytes).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn extract_account_id(id_token: &str) -> Option<String> {
|
||||
let payload = parse_jwt_payload(id_token)?;
|
||||
payload
|
||||
.chatgpt_account_id
|
||||
.or_else(|| {
|
||||
payload
|
||||
.auth_claim
|
||||
.and_then(|claim| claim.chatgpt_account_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
payload
|
||||
.organizations
|
||||
.and_then(|orgs| orgs.into_iter().next())
|
||||
.and_then(|org| org.id)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_jwt(claims: &serde_json::Value) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap());
|
||||
format!("{header}.{payload}.signature")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_with_chatgpt_account_id() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"chatgpt_account_id": "acct_123"
|
||||
}));
|
||||
let payload = parse_jwt_payload(&jwt).unwrap();
|
||||
assert_eq!(payload.chatgpt_account_id.as_deref(), Some("acct_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_with_nested_auth_claim() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acct_nested"
|
||||
}
|
||||
}));
|
||||
let payload = parse_jwt_payload(&jwt).unwrap();
|
||||
assert_eq!(
|
||||
payload
|
||||
.auth_claim
|
||||
.and_then(|claim| claim.chatgpt_account_id)
|
||||
.as_deref(),
|
||||
Some("acct_nested")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_invalid_format() {
|
||||
assert!(parse_jwt_payload("not-a-jwt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jwt_invalid_base64() {
|
||||
assert!(parse_jwt_payload("header.!!!invalid!!!.sig").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_prefers_top_level() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"chatgpt_account_id": "top_level",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "nested"
|
||||
},
|
||||
"organizations": [{"id": "org"}]
|
||||
}));
|
||||
assert_eq!(extract_account_id(&jwt).as_deref(), Some("top_level"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_falls_back_to_nested() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "nested"
|
||||
}
|
||||
}));
|
||||
assert_eq!(extract_account_id(&jwt).as_deref(), Some("nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_falls_back_to_first_organization() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({
|
||||
"organizations": [{"id": "org_456"}]
|
||||
}));
|
||||
assert_eq!(extract_account_id(&jwt).as_deref(), Some("org_456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_account_id_none_when_missing() {
|
||||
let jwt = make_test_jwt(&serde_json::json!({}));
|
||||
assert!(extract_account_id(&jwt).is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,10 @@ use anyhow::Result;
|
|||
use dialoguer::console::Term;
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::{Confirm, Password};
|
||||
use fabro_auth::{
|
||||
ApiCredential, ApiKeyHeader, AuthContextRequest, AuthContextResponse, AuthCredential,
|
||||
AuthMethod, codex_oauth_config, strategy_for,
|
||||
};
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::generate::{GenerateParams, generate};
|
||||
use fabro_model::{Catalog, Provider};
|
||||
|
|
@ -12,8 +16,6 @@ use fabro_util::terminal::Styles;
|
|||
use tokio::task::spawn_blocking;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::openai_jwt;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider key URLs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -46,86 +48,6 @@ pub(crate) fn provider_display_name(provider: Provider) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI OAuth helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert OAuth tokens to secret name/value pairs.
|
||||
pub(crate) fn openai_oauth_env_pairs(
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut pairs = vec![
|
||||
("OPENAI_API_KEY".to_string(), access_token.to_string()),
|
||||
(
|
||||
"OPENAI_REFRESH_TOKEN".to_string(),
|
||||
refresh_token.to_string(),
|
||||
),
|
||||
];
|
||||
if let Some(id) = account_id {
|
||||
pairs.push(("CHATGPT_ACCOUNT_ID".to_string(), id.to_string()));
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI OAuth browser flow with API-key fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the OpenAI OAuth browser flow, falling back to manual API key entry on
|
||||
/// failure. Returns the env-var pairs to persist.
|
||||
pub(crate) async fn run_openai_oauth_or_api_key(
|
||||
s: &Styles,
|
||||
printer: Printer,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {}",
|
||||
s.dim.apply_to("Opening browser for OpenAI login...")
|
||||
);
|
||||
match fabro_oauth::run_browser_flow(
|
||||
openai_jwt::DEFAULT_ISSUER,
|
||||
openai_jwt::DEFAULT_CLIENT_ID,
|
||||
"openid profile email offline_access",
|
||||
openai_jwt::OAUTH_PORT,
|
||||
"/auth/callback",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tokens) => {
|
||||
tracing::info!("OpenAI OAuth browser flow completed");
|
||||
let account_id = tokens
|
||||
.id_token
|
||||
.as_deref()
|
||||
.and_then(openai_jwt::extract_account_id);
|
||||
let refresh_token = tokens
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenAI did not return a refresh token"))?;
|
||||
let pairs =
|
||||
openai_oauth_env_pairs(&tokens.access_token, refresh_token, account_id.as_deref());
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {} OpenAI configured via browser login",
|
||||
s.green.apply_to("✔")
|
||||
);
|
||||
Ok(pairs)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "OpenAI OAuth browser flow failed");
|
||||
fabro_util::printerr!(printer, " Browser login failed: {e}");
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {}",
|
||||
s.dim.apply_to("Falling back to manual API key entry.")
|
||||
);
|
||||
let (env_var, key) = prompt_and_validate_key(Provider::OpenAi, s, printer).await?;
|
||||
Ok(vec![(env_var, key)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -148,14 +70,23 @@ pub(crate) fn prompt_password(prompt: &str) -> Result<String> {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> {
|
||||
let env_var = provider.api_key_env_vars()[0];
|
||||
let client = LlmClient::from_lookup(|name| {
|
||||
if name == env_var {
|
||||
Some(api_key.to_string())
|
||||
} else {
|
||||
None
|
||||
let auth_header = if provider == Provider::Anthropic {
|
||||
ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: api_key.to_string(),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ApiKeyHeader::Bearer(api_key.to_string())
|
||||
};
|
||||
let client = LlmClient::from_credentials(vec![ApiCredential {
|
||||
provider,
|
||||
auth_header,
|
||||
extra_headers: std::collections::HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
|
|
@ -213,6 +144,112 @@ pub(crate) async fn prompt_and_validate_key(
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn pick_auth_method(provider: Provider) -> Result<AuthMethod> {
|
||||
if provider != Provider::OpenAi {
|
||||
return Ok(AuthMethod::ApiKey);
|
||||
}
|
||||
|
||||
let use_device_auth =
|
||||
spawn_blocking(|| prompt_confirm("Log in with OpenAI account (device code)?", true))
|
||||
.await??;
|
||||
if use_device_auth {
|
||||
Ok(AuthMethod::CodexDevice(codex_oauth_config()))
|
||||
} else {
|
||||
Ok(AuthMethod::ApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn authenticate_provider(
|
||||
provider: Provider,
|
||||
s: &Styles,
|
||||
printer: Printer,
|
||||
) -> Result<AuthCredential> {
|
||||
let method = pick_auth_method(provider).await?;
|
||||
authenticate_provider_with_method(provider, method, s, printer).await
|
||||
}
|
||||
|
||||
pub(crate) async fn authenticate_provider_with_method(
|
||||
provider: Provider,
|
||||
method: AuthMethod,
|
||||
s: &Styles,
|
||||
printer: Printer,
|
||||
) -> Result<AuthCredential> {
|
||||
let mut strategy = strategy_for(provider, method);
|
||||
let request = strategy.init().await?;
|
||||
present_to_user(&request, s, printer)?;
|
||||
let response = await_user_response(&request, s, printer).await?;
|
||||
strategy.complete(response).await
|
||||
}
|
||||
|
||||
pub(crate) fn present_to_user(
|
||||
request: &AuthContextRequest,
|
||||
s: &Styles,
|
||||
printer: Printer,
|
||||
) -> Result<()> {
|
||||
match request {
|
||||
AuthContextRequest::ApiKey {
|
||||
provider,
|
||||
env_var_names,
|
||||
} => {
|
||||
let env_var = env_var_names
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("API_KEY");
|
||||
let url = provider_key_url(*provider);
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {}",
|
||||
s.dim.apply_to(format!("Get your API key at: {url}"))
|
||||
);
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {}",
|
||||
s.dim.apply_to(format!("Expected variable name: {env_var}"))
|
||||
);
|
||||
}
|
||||
AuthContextRequest::DeviceCode {
|
||||
user_code,
|
||||
verification_uri,
|
||||
expires_in,
|
||||
} => {
|
||||
fabro_util::printerr!(printer, " Open this URL in your browser:");
|
||||
fabro_util::printerr!(printer, " {verification_uri}");
|
||||
fabro_util::printerr!(printer, " Enter this one-time code:");
|
||||
fabro_util::printerr!(printer, " {}", s.bold.apply_to(user_code));
|
||||
fabro_util::printerr!(
|
||||
printer,
|
||||
" {}",
|
||||
s.dim
|
||||
.apply_to(format!("Code expires in {} minutes", expires_in / 60))
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn await_user_response(
|
||||
request: &AuthContextRequest,
|
||||
s: &Styles,
|
||||
printer: Printer,
|
||||
) -> Result<AuthContextResponse> {
|
||||
match request {
|
||||
AuthContextRequest::ApiKey { provider, .. } => {
|
||||
let (_, key) = prompt_and_validate_key(*provider, s, printer).await?;
|
||||
Ok(AuthContextResponse::ApiKey { key })
|
||||
}
|
||||
AuthContextRequest::DeviceCode { .. } => {
|
||||
let ready = spawn_blocking(|| {
|
||||
prompt_confirm("Continue after completing sign-in in the browser?", true)
|
||||
})
|
||||
.await??;
|
||||
if !ready {
|
||||
return Err(anyhow::anyhow!("device code login cancelled"));
|
||||
}
|
||||
Ok(AuthContextResponse::DeviceCodeConfirmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -221,33 +258,6 @@ pub(crate) async fn prompt_and_validate_key(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- OpenAI OAuth env pairs --
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_sets_api_key() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert!(pairs.contains(&("OPENAI_API_KEY".to_string(), "tok".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_sets_refresh_token() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert!(pairs.contains(&("OPENAI_REFRESH_TOKEN".to_string(), "ref".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_count() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", None);
|
||||
assert_eq!(pairs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_oauth_env_pairs_with_account_id() {
|
||||
let pairs = openai_oauth_env_pairs("tok", "ref", Some("acct_123"));
|
||||
assert!(pairs.contains(&("CHATGPT_ACCOUNT_ID".to_string(), "acct_123".to_string())));
|
||||
assert_eq!(pairs.len(), 3);
|
||||
}
|
||||
|
||||
// -- Provider key URLs --
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ async fn run_real_cli_test(provider: Provider, model: &str) {
|
|||
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(fabro_agent::LocalSandbox::new(
|
||||
workspace.path().to_path_buf(),
|
||||
));
|
||||
let backend = AgentCliBackend::new(model.to_string(), provider)
|
||||
let backend = AgentCliBackend::new_from_env(model.to_string(), provider)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
|
||||
let mut node = Node::new("real_cli_test");
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ bytes.workspace = true
|
|||
tokio-util.workspace = true
|
||||
tracing.workspace = true
|
||||
fabro-http.workspace = true
|
||||
fabro-auth = { path = "../fabro-auth" }
|
||||
fabro-model = { path = "../fabro-model" }
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_auth::{ApiCredential, ApiKeyHeader};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::Error;
|
||||
|
|
@ -9,6 +10,11 @@ use crate::provider::{ProviderAdapter, StreamEventStream};
|
|||
use crate::providers;
|
||||
use crate::types::{Request, Response};
|
||||
|
||||
const KIMI_BASE_URL: &str = "https://api.moonshot.ai/v1";
|
||||
const ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
||||
const MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
|
||||
const INCEPTION_BASE_URL: &str = "https://api.inceptionlabs.ai/v1";
|
||||
|
||||
/// The core client that routes requests to provider adapters (Section 2.2, 3).
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
|
|
@ -40,90 +46,221 @@ impl Client {
|
|||
///
|
||||
/// Returns `Error` if any provider adapter fails to initialize.
|
||||
pub async fn from_env() -> Result<Self, Error> {
|
||||
Self::from_lookup(|name| std::env::var(name).ok()).await
|
||||
let mut credentials = Vec::new();
|
||||
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
|
||||
credentials.push(ApiCredential {
|
||||
provider: fabro_model::Provider::Anthropic,
|
||||
auth_header: ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: key,
|
||||
},
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: std::env::var("ANTHROPIC_BASE_URL").ok(),
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
});
|
||||
}
|
||||
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
|
||||
let mut extra_headers = HashMap::new();
|
||||
let mut base_url = std::env::var("OPENAI_BASE_URL").ok();
|
||||
let mut codex_mode = false;
|
||||
if let Ok(account_id) = std::env::var("CHATGPT_ACCOUNT_ID") {
|
||||
base_url = Some("https://chatgpt.com/backend-api/codex".to_string());
|
||||
codex_mode = true;
|
||||
extra_headers.insert("ChatGPT-Account-Id".to_string(), account_id);
|
||||
extra_headers.insert("originator".to_string(), "fabro".to_string());
|
||||
}
|
||||
credentials.push(ApiCredential {
|
||||
provider: fabro_model::Provider::OpenAi,
|
||||
auth_header: ApiKeyHeader::Bearer(key),
|
||||
extra_headers,
|
||||
base_url,
|
||||
codex_mode,
|
||||
org_id: std::env::var("OPENAI_ORG_ID").ok(),
|
||||
project_id: std::env::var("OPENAI_PROJECT_ID").ok(),
|
||||
});
|
||||
}
|
||||
if let Ok(key) =
|
||||
std::env::var("GEMINI_API_KEY").or_else(|_| std::env::var("GOOGLE_API_KEY"))
|
||||
{
|
||||
credentials.push(ApiCredential {
|
||||
provider: fabro_model::Provider::Gemini,
|
||||
auth_header: ApiKeyHeader::Bearer(key),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: std::env::var("GEMINI_BASE_URL").ok(),
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
});
|
||||
}
|
||||
if let Ok(key) = std::env::var("KIMI_API_KEY") {
|
||||
credentials.push(ApiCredential {
|
||||
provider: fabro_model::Provider::Kimi,
|
||||
auth_header: ApiKeyHeader::Bearer(key),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
});
|
||||
}
|
||||
if let Ok(key) = std::env::var("ZAI_API_KEY") {
|
||||
credentials.push(ApiCredential {
|
||||
provider: fabro_model::Provider::Zai,
|
||||
auth_header: ApiKeyHeader::Bearer(key),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
});
|
||||
}
|
||||
if let Ok(key) = std::env::var("MINIMAX_API_KEY") {
|
||||
credentials.push(ApiCredential {
|
||||
provider: fabro_model::Provider::Minimax,
|
||||
auth_header: ApiKeyHeader::Bearer(key),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
});
|
||||
}
|
||||
if let Ok(key) = std::env::var("INCEPTION_API_KEY") {
|
||||
credentials.push(ApiCredential {
|
||||
provider: fabro_model::Provider::Inception,
|
||||
auth_header: ApiKeyHeader::Bearer(key),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
});
|
||||
}
|
||||
Self::from_credentials(credentials).await
|
||||
}
|
||||
|
||||
/// Create a Client from a custom variable lookup.
|
||||
/// Create a Client from typed provider credentials.
|
||||
///
|
||||
/// This is useful when credentials come from a source other than process
|
||||
/// environment variables, while still preserving the env-style provider
|
||||
/// configuration surface.
|
||||
pub async fn from_lookup<F>(lookup: F) -> Result<Self, Error>
|
||||
where
|
||||
F: Fn(&str) -> Option<String>,
|
||||
{
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error` if any provider adapter fails to initialize.
|
||||
pub async fn from_credentials(credentials: Vec<ApiCredential>) -> Result<Self, Error> {
|
||||
let mut client = Self {
|
||||
providers: HashMap::new(),
|
||||
default_provider: None,
|
||||
middleware: Vec::new(),
|
||||
};
|
||||
|
||||
// Register providers whose API keys are present in the environment.
|
||||
// Order determines which becomes the default provider.
|
||||
if let Some(key) = lookup("ANTHROPIC_API_KEY") {
|
||||
let mut adapter = providers::AnthropicAdapter::new(key);
|
||||
if let Some(base_url) = lookup("ANTHROPIC_BASE_URL") {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
if let Some(key) = lookup("OPENAI_API_KEY") {
|
||||
let mut adapter = providers::OpenAiAdapter::new(key);
|
||||
if let Some(account_id) = lookup("CHATGPT_ACCOUNT_ID") {
|
||||
// Codex OAuth: route through chatgpt.com backend with required headers
|
||||
adapter = adapter
|
||||
.with_base_url("https://chatgpt.com/backend-api/codex")
|
||||
.with_codex_mode();
|
||||
let mut headers = std::collections::HashMap::new();
|
||||
headers.insert("ChatGPT-Account-Id".to_string(), account_id);
|
||||
headers.insert("originator".to_string(), "fabro".to_string());
|
||||
adapter = adapter.with_default_headers(headers);
|
||||
} else if let Some(base_url) = lookup("OPENAI_BASE_URL") {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
if let Some(org_id) = lookup("OPENAI_ORG_ID") {
|
||||
adapter = adapter.with_org_id(org_id);
|
||||
}
|
||||
if let Some(project_id) = lookup("OPENAI_PROJECT_ID") {
|
||||
adapter = adapter.with_project_id(project_id);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
if let Some(key) = lookup("GEMINI_API_KEY").or_else(|| lookup("GOOGLE_API_KEY")) {
|
||||
let mut adapter = providers::GeminiAdapter::new(key);
|
||||
if let Some(base_url) = lookup("GEMINI_BASE_URL") {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
if let Some(key) = lookup("KIMI_API_KEY") {
|
||||
let adapter =
|
||||
providers::OpenAiCompatibleAdapter::new(key, "https://api.moonshot.ai/v1")
|
||||
for credential in credentials {
|
||||
let auth_value = auth_value(&credential.auth_header);
|
||||
match credential.provider {
|
||||
fabro_model::Provider::Anthropic => {
|
||||
let mut adapter = providers::AnthropicAdapter::new(auth_value);
|
||||
if let Some(base_url) = credential.base_url {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
if !credential.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(credential.extra_headers);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
fabro_model::Provider::OpenAi => {
|
||||
let mut adapter = providers::OpenAiAdapter::new(auth_value);
|
||||
if let Some(base_url) = credential.base_url {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
if !credential.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(credential.extra_headers);
|
||||
}
|
||||
if credential.codex_mode {
|
||||
adapter = adapter.with_codex_mode();
|
||||
}
|
||||
if let Some(org_id) = credential.org_id {
|
||||
adapter = adapter.with_org_id(org_id);
|
||||
}
|
||||
if let Some(project_id) = credential.project_id {
|
||||
adapter = adapter.with_project_id(project_id);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
fabro_model::Provider::Gemini => {
|
||||
let mut adapter = providers::GeminiAdapter::new(auth_value);
|
||||
if let Some(base_url) = credential.base_url {
|
||||
adapter = adapter.with_base_url(base_url);
|
||||
}
|
||||
if !credential.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(credential.extra_headers);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
fabro_model::Provider::Kimi => {
|
||||
let mut adapter = providers::OpenAiCompatibleAdapter::new(
|
||||
auth_value,
|
||||
credential
|
||||
.base_url
|
||||
.unwrap_or_else(|| KIMI_BASE_URL.to_string()),
|
||||
)
|
||||
.with_name("kimi");
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
if let Some(key) = lookup("ZAI_API_KEY") {
|
||||
let adapter =
|
||||
providers::OpenAiCompatibleAdapter::new(key, "https://api.z.ai/api/coding/paas/v4")
|
||||
if !credential.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(credential.extra_headers);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
fabro_model::Provider::Zai => {
|
||||
let mut adapter = providers::OpenAiCompatibleAdapter::new(
|
||||
auth_value,
|
||||
credential
|
||||
.base_url
|
||||
.unwrap_or_else(|| ZAI_BASE_URL.to_string()),
|
||||
)
|
||||
.with_name("zai");
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
if let Some(key) = lookup("MINIMAX_API_KEY") {
|
||||
let adapter = providers::OpenAiCompatibleAdapter::new(key, "https://api.minimax.io/v1")
|
||||
.with_name("minimax");
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
if let Some(key) = lookup("INCEPTION_API_KEY") {
|
||||
let adapter =
|
||||
providers::OpenAiCompatibleAdapter::new(key, "https://api.inceptionlabs.ai/v1")
|
||||
if !credential.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(credential.extra_headers);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
fabro_model::Provider::Minimax => {
|
||||
let mut adapter = providers::OpenAiCompatibleAdapter::new(
|
||||
auth_value,
|
||||
credential
|
||||
.base_url
|
||||
.unwrap_or_else(|| MINIMAX_BASE_URL.to_string()),
|
||||
)
|
||||
.with_name("minimax");
|
||||
if !credential.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(credential.extra_headers);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
fabro_model::Provider::Inception => {
|
||||
let mut adapter = providers::OpenAiCompatibleAdapter::new(
|
||||
auth_value,
|
||||
credential
|
||||
.base_url
|
||||
.unwrap_or_else(|| INCEPTION_BASE_URL.to_string()),
|
||||
)
|
||||
.with_name("inception");
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
if !credential.extra_headers.is_empty() {
|
||||
adapter = adapter.with_default_headers(credential.extra_headers);
|
||||
}
|
||||
client.register_provider(Arc::new(adapter)).await?;
|
||||
}
|
||||
fabro_model::Provider::OpenAiCompatible => {
|
||||
return Err(Error::Configuration {
|
||||
message: "Provider::OpenAiCompatible is not supported by from_credentials"
|
||||
.to_string(),
|
||||
source: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
providers = ?client.provider_names(),
|
||||
default = ?client.default_provider(),
|
||||
"LLM client initialized from environment"
|
||||
"LLM client initialized from typed credentials"
|
||||
);
|
||||
|
||||
Ok(client)
|
||||
|
|
@ -273,6 +410,13 @@ impl Client {
|
|||
}
|
||||
}
|
||||
|
||||
fn auth_value(auth_header: &ApiKeyHeader) -> String {
|
||||
match auth_header {
|
||||
ApiKeyHeader::Bearer(value) => value.clone(),
|
||||
ApiKeyHeader::Custom { value, .. } => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use futures::stream;
|
||||
|
|
@ -417,6 +561,58 @@ mod tests {
|
|||
assert!(matches!(result.unwrap_err(), Error::Configuration { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_credentials_registers_multiple_providers() {
|
||||
let client = Client::from_credentials(vec![
|
||||
ApiCredential {
|
||||
provider: fabro_model::Provider::Anthropic,
|
||||
auth_header: ApiKeyHeader::Custom {
|
||||
name: "x-api-key".to_string(),
|
||||
value: "anthropic-key".to_string(),
|
||||
},
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
},
|
||||
ApiCredential {
|
||||
provider: fabro_model::Provider::OpenAi,
|
||||
auth_header: ApiKeyHeader::Bearer("openai-key".to_string()),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
},
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut providers = client.provider_names();
|
||||
providers.sort_unstable();
|
||||
assert_eq!(providers, vec!["anthropic", "openai"]);
|
||||
assert_eq!(client.default_provider(), Some("anthropic"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_credentials_supports_openai_compatible_provider_constants() {
|
||||
let client = Client::from_credentials(vec![ApiCredential {
|
||||
provider: fabro_model::Provider::Kimi,
|
||||
auth_header: ApiKeyHeader::Bearer("kimi-key".to_string()),
|
||||
extra_headers: HashMap::new(),
|
||||
base_url: None,
|
||||
codex_mode: false,
|
||||
org_id: None,
|
||||
project_id: None,
|
||||
}])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(client.provider_names(), vec!["kimi"]);
|
||||
assert_eq!(client.default_provider(), Some("kimi"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_sets_first_as_default() {
|
||||
let mut client = Client::new(HashMap::new(), None, vec![]);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ pub fn build_authorize_url(
|
|||
// Token types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct OAuthEndpoint<'a> {
|
||||
pub token_url: &'a str,
|
||||
pub client_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
pub id_token: Option<String>,
|
||||
|
|
@ -106,27 +111,33 @@ pub struct TokenResponse {
|
|||
// Token exchange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn exchange_code_for_tokens(
|
||||
client: &fabro_http::HttpClient,
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
pub async fn exchange_code(
|
||||
endpoint: OAuthEndpoint<'_>,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
code_verifier: &str,
|
||||
redirect_uri: Option<&str>,
|
||||
verifier: Option<&str>,
|
||||
) -> Result<TokenResponse, String> {
|
||||
tracing::debug!(issuer, "Exchanging authorization code");
|
||||
tracing::debug!(
|
||||
token_url = endpoint.token_url,
|
||||
"Exchanging authorization code"
|
||||
);
|
||||
|
||||
let body = encode_form(&[
|
||||
let mut params = vec![
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", client_id),
|
||||
("client_id", endpoint.client_id),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", code_verifier),
|
||||
]);
|
||||
];
|
||||
if let Some(redirect_uri) = redirect_uri {
|
||||
params.push(("redirect_uri", redirect_uri));
|
||||
}
|
||||
if let Some(verifier) = verifier {
|
||||
params.push(("code_verifier", verifier));
|
||||
}
|
||||
|
||||
let url = format!("{issuer}/oauth/token");
|
||||
let body = encode_form(¶ms);
|
||||
let client = fabro_http::http_client().map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.post(endpoint.token_url)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.send()
|
||||
|
|
@ -153,23 +164,21 @@ pub async fn exchange_code_for_tokens(
|
|||
// Token refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn refresh_access_token(
|
||||
client: &fabro_http::HttpClient,
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
pub async fn refresh_token(
|
||||
endpoint: OAuthEndpoint<'_>,
|
||||
refresh_token: &str,
|
||||
) -> Result<TokenResponse, String> {
|
||||
tracing::debug!(issuer, "Refreshing access token");
|
||||
tracing::debug!(token_url = endpoint.token_url, "Refreshing access token");
|
||||
|
||||
let body = encode_form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("client_id", client_id),
|
||||
("client_id", endpoint.client_id),
|
||||
("refresh_token", refresh_token),
|
||||
]);
|
||||
|
||||
let url = format!("{issuer}/oauth/token");
|
||||
let client = fabro_http::http_client().map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.post(endpoint.token_url)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.send()
|
||||
|
|
@ -394,14 +403,15 @@ pub async fn run_browser_flow(
|
|||
.map_err(|_| "Did not receive authorization code".to_string())?
|
||||
.map_err(|e| format!("Authorization failed: {e}"))?;
|
||||
|
||||
let client = fabro_http::http_client().map_err(|e| e.to_string())?;
|
||||
exchange_code_for_tokens(
|
||||
&client,
|
||||
issuer,
|
||||
client_id,
|
||||
let token_url = format!("{issuer}/oauth/token");
|
||||
exchange_code(
|
||||
OAuthEndpoint {
|
||||
token_url: &token_url,
|
||||
client_id,
|
||||
},
|
||||
&code,
|
||||
&redirect_uri,
|
||||
&pkce.verifier,
|
||||
Some(&redirect_uri),
|
||||
Some(&pkce.verifier),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -599,14 +609,14 @@ mod tests {
|
|||
})
|
||||
.await;
|
||||
|
||||
let client = test_http_client();
|
||||
let tokens = exchange_code_for_tokens(
|
||||
&client,
|
||||
&server.url(""),
|
||||
"test-client",
|
||||
let tokens = exchange_code(
|
||||
OAuthEndpoint {
|
||||
token_url: &server.url("/oauth/token"),
|
||||
client_id: "test-client",
|
||||
},
|
||||
"test-code",
|
||||
"http://localhost/cb",
|
||||
"test-verifier",
|
||||
Some("http://localhost/cb"),
|
||||
Some("test-verifier"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -637,14 +647,14 @@ mod tests {
|
|||
})
|
||||
.await;
|
||||
|
||||
let client = test_http_client();
|
||||
let tokens = exchange_code_for_tokens(
|
||||
&client,
|
||||
&server.url(""),
|
||||
"test-client",
|
||||
let tokens = exchange_code(
|
||||
OAuthEndpoint {
|
||||
token_url: &server.url("/oauth/token"),
|
||||
client_id: "test-client",
|
||||
},
|
||||
"test-code",
|
||||
"http://localhost/cb",
|
||||
"test-verifier",
|
||||
Some("http://localhost/cb"),
|
||||
Some("test-verifier"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -666,14 +676,14 @@ mod tests {
|
|||
})
|
||||
.await;
|
||||
|
||||
let client = test_http_client();
|
||||
let err = exchange_code_for_tokens(
|
||||
&client,
|
||||
&server.url(""),
|
||||
"test-client",
|
||||
let err = exchange_code(
|
||||
OAuthEndpoint {
|
||||
token_url: &server.url("/oauth/token"),
|
||||
client_id: "test-client",
|
||||
},
|
||||
"bad-code",
|
||||
"http://localhost/cb",
|
||||
"verifier",
|
||||
Some("http://localhost/cb"),
|
||||
Some("verifier"),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
|
@ -681,6 +691,45 @@ mod tests {
|
|||
assert!(err.contains("400"), "error should contain status: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exchange_code_skips_optional_params_when_absent() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
||||
let mock = server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
.path("/oauth/token")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.form_urlencoded_tuple("grant_type", "authorization_code")
|
||||
.form_urlencoded_tuple("client_id", "test-client")
|
||||
.form_urlencoded_tuple("code", "test-code");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"access_token": "access-tok"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let tokens = exchange_code(
|
||||
OAuthEndpoint {
|
||||
token_url: &server.url("/oauth/token"),
|
||||
client_id: "test-client",
|
||||
},
|
||||
"test-code",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tokens.access_token, "access-tok");
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 5: Token refresh
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -710,11 +759,15 @@ mod tests {
|
|||
})
|
||||
.await;
|
||||
|
||||
let client = test_http_client();
|
||||
let tokens =
|
||||
refresh_access_token(&client, &server.url(""), "test-client", "old-refresh-tok")
|
||||
.await
|
||||
.unwrap();
|
||||
let tokens = refresh_token(
|
||||
OAuthEndpoint {
|
||||
token_url: &server.url("/oauth/token"),
|
||||
client_id: "test-client",
|
||||
},
|
||||
"old-refresh-tok",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tokens.id_token.as_deref(), Some("new-id"));
|
||||
assert_eq!(tokens.access_token, "new-access");
|
||||
|
|
@ -735,10 +788,15 @@ mod tests {
|
|||
})
|
||||
.await;
|
||||
|
||||
let client = test_http_client();
|
||||
let err = refresh_access_token(&client, &server.url(""), "test-client", "expired-tok")
|
||||
.await
|
||||
.unwrap_err();
|
||||
let err = refresh_token(
|
||||
OAuthEndpoint {
|
||||
token_url: &server.url("/oauth/token"),
|
||||
client_id: "test-client",
|
||||
},
|
||||
"expired-tok",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("401"), "error should contain status: {err}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ doctest = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
fabro-auth = { path = "../fabro-auth" }
|
||||
fabro-spa = { path = "../fabro-spa" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use tokio::process::Command;
|
|||
use tokio::time::timeout;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::server_secrets::auth_issue_message;
|
||||
|
||||
fn http_client_or_check(
|
||||
name: &str,
|
||||
|
|
@ -196,29 +197,8 @@ pub async fn run_all(state: &AppState) -> DiagnosticsReport {
|
|||
}
|
||||
|
||||
async fn check_llm_providers(state: &AppState) -> CheckResult {
|
||||
let mut configured = Vec::new();
|
||||
for provider in Provider::ALL {
|
||||
if state
|
||||
.provider_credentials
|
||||
.has_any(provider.api_key_env_vars())
|
||||
.await
|
||||
{
|
||||
configured.push(*provider);
|
||||
}
|
||||
}
|
||||
|
||||
if configured.is_empty() {
|
||||
return CheckResult {
|
||||
name: "LLM Providers".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "none configured".to_string(),
|
||||
details: Vec::new(),
|
||||
remediation: Some("Set at least one provider API key".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let client = match state.build_llm_client().await {
|
||||
Ok(client) => client,
|
||||
let result = match state.build_llm_client().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
return CheckResult {
|
||||
name: "LLM Providers".to_string(),
|
||||
|
|
@ -229,13 +209,29 @@ async fn check_llm_providers(state: &AppState) -> CheckResult {
|
|||
};
|
||||
}
|
||||
};
|
||||
if result.client.provider_names().is_empty() && result.auth_issues.is_empty() {
|
||||
return CheckResult {
|
||||
name: "LLM Providers".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "none configured".to_string(),
|
||||
details: Vec::new(),
|
||||
remediation: Some("Set at least one provider API key".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let mut details = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
for provider in configured {
|
||||
for (provider, issue) in &result.auth_issues {
|
||||
failed.push(provider.to_string());
|
||||
details.push(CheckDetail::new(auth_issue_message(*provider, issue)));
|
||||
}
|
||||
for provider_name in result.client.provider_names() {
|
||||
let Ok(provider) = provider_name.parse::<Provider>() else {
|
||||
continue;
|
||||
};
|
||||
let result = timeout(
|
||||
Duration::from_secs(30),
|
||||
probe_llm_provider(&client, provider),
|
||||
probe_llm_provider(&result.client, provider),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
|
|
@ -257,7 +253,7 @@ async fn check_llm_providers(state: &AppState) -> CheckResult {
|
|||
CheckResult {
|
||||
name: "LLM Providers".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: format!("{} configured", details.len()),
|
||||
summary: format!("{} configured", result.client.provider_names().len()),
|
||||
details,
|
||||
remediation: None,
|
||||
}
|
||||
|
|
@ -265,7 +261,7 @@ async fn check_llm_providers(state: &AppState) -> CheckResult {
|
|||
CheckResult {
|
||||
name: "LLM Providers".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "connectivity issues".to_string(),
|
||||
summary: "some providers require attention".to_string(),
|
||||
details,
|
||||
remediation: Some(format!("Connectivity issues with: {}", failed.join(", "))),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ use fabro_workflow::run_materialization::materialize_run;
|
|||
use fabro_workflow::workflow_bundle::{BundledWorkflow, WorkflowBundle};
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::server_secrets::auth_issue_message;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PreparedManifest {
|
||||
|
|
@ -548,12 +549,14 @@ async fn run_llm_check(
|
|||
let default_provider = provider.as_deref().unwrap_or("anthropic");
|
||||
|
||||
match state.build_llm_client().await {
|
||||
Ok(client) => {
|
||||
let configured = client
|
||||
Ok(result) => {
|
||||
let configured = result
|
||||
.client
|
||||
.provider_names()
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let auth_issues = result.auth_issues;
|
||||
let mut model_providers = std::collections::BTreeSet::new();
|
||||
|
||||
for node in graph.nodes.values() {
|
||||
|
|
@ -589,19 +592,28 @@ async fn run_llm_check(
|
|||
let mut all_ok = true;
|
||||
for (model_id, provider_name) in &model_providers {
|
||||
match provider_name.parse::<Provider>() {
|
||||
Ok(_) => {
|
||||
Ok(provider) => {
|
||||
let mut status = CheckStatus::Pass;
|
||||
if !configured.iter().any(|name| name == provider_name) {
|
||||
let remediation = if let Some((_, issue)) = auth_issues
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == provider)
|
||||
{
|
||||
status = CheckStatus::Warning;
|
||||
all_ok = false;
|
||||
}
|
||||
Some(auth_issue_message(provider, issue))
|
||||
} else if !configured.iter().any(|name| name == provider_name) {
|
||||
status = CheckStatus::Warning;
|
||||
all_ok = false;
|
||||
Some(format!("Provider \"{provider_name}\" is not configured"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
checks.push(CheckResult {
|
||||
name: "LLM".into(),
|
||||
status,
|
||||
summary: model_id.clone(),
|
||||
details: vec![CheckDetail::new(format!("Provider: {provider_name}"))],
|
||||
remediation: (status == CheckStatus::Warning)
|
||||
.then(|| format!("Provider \"{provider_name}\" is not configured")),
|
||||
remediation,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use anyhow::Context;
|
|||
use clap::Args;
|
||||
use fabro_config::user::{active_settings_path, load_settings_config};
|
||||
use fabro_config::{Storage, resolve_server_from_file};
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
use fabro_types::settings::server::GithubIntegrationStrategy;
|
||||
use fabro_types::settings::{
|
||||
|
|
@ -20,7 +19,7 @@ use object_store::aws::AmazonS3Builder;
|
|||
use object_store::local::LocalFileSystem;
|
||||
use object_store::memory::InMemory;
|
||||
use tokio::net::{TcpListener, UnixListener};
|
||||
use tokio::sync::watch;
|
||||
use tokio::sync::{RwLock as AsyncRwLock, watch};
|
||||
use tokio::time::interval;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ use crate::server::{
|
|||
RouterOptions, build_app_state_with_path, build_router_with_options,
|
||||
reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler,
|
||||
};
|
||||
use crate::server_secrets::ServerSecrets;
|
||||
use crate::server_secrets::{ProviderCredentials, ServerSecrets};
|
||||
use crate::tls::{ClientAuth, build_rustls_config, serve_tls_with_shutdown};
|
||||
|
||||
const TEST_IN_MEMORY_STORE_ENV: &str = "FABRO_TEST_IN_MEMORY_STORE";
|
||||
|
|
@ -267,22 +266,20 @@ where
|
|||
};
|
||||
let storage = Storage::new(&data_dir);
|
||||
let vault_path = storage.secrets_path();
|
||||
let vault = Vault::load(vault_path.clone())?;
|
||||
let vault_snapshot = vault.snapshot();
|
||||
let vault = Arc::new(AsyncRwLock::new(Vault::load(vault_path.clone())?));
|
||||
let server_secrets = ServerSecrets::load(storage.server_state().env_path())?;
|
||||
|
||||
// Resolve dry-run mode (same pattern as run.rs)
|
||||
let dry_run_mode = if args.dry_run {
|
||||
true
|
||||
} else {
|
||||
match LlmClient::from_lookup(|name| {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.or_else(|| vault_snapshot.get(name).cloned())
|
||||
})
|
||||
.await
|
||||
match ProviderCredentials::new(Arc::clone(&vault))
|
||||
.build_llm_client()
|
||||
.await
|
||||
{
|
||||
Ok(c) if c.provider_names().is_empty() => {
|
||||
Ok(result)
|
||||
if result.client.provider_names().is_empty() && result.auth_issues.is_empty() =>
|
||||
{
|
||||
eprintln!(
|
||||
"{} No LLM providers configured. Running in dry-run mode.",
|
||||
styles.yellow.apply_to("Warning:"),
|
||||
|
|
|
|||
|
|
@ -37,12 +37,12 @@ pub use fabro_api::types::{
|
|||
SshAccessRequest, SshAccessResponse, StartRunRequest, StatusReason as ApiStatusReason,
|
||||
SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts, WriteBlobResponse,
|
||||
};
|
||||
use fabro_auth::parse_credential_secret;
|
||||
use fabro_config::{Storage, resolve_server_from_file};
|
||||
use fabro_graphviz::render::GraphFormat;
|
||||
use fabro_interview::{
|
||||
Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope,
|
||||
};
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::generate::{GenerateParams, generate_object};
|
||||
use fabro_llm::model_test::{ModelTestMode, run_model_test_with_client};
|
||||
use fabro_llm::types::{
|
||||
|
|
@ -105,7 +105,7 @@ use tokio_stream::StreamExt;
|
|||
use tokio_stream::wrappers::{BroadcastStream, UnboundedReceiverStream};
|
||||
use tower::{ServiceExt, service_fn};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::bind::Bind;
|
||||
|
|
@ -113,7 +113,9 @@ use crate::error::ApiError;
|
|||
use crate::jwt_auth::{
|
||||
AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts,
|
||||
};
|
||||
use crate::server_secrets::{ProviderCredentials, ServerSecrets};
|
||||
use crate::server_secrets::{
|
||||
LlmClientResult, ProviderCredentials, ServerSecrets, auth_issue_message,
|
||||
};
|
||||
use crate::{demo, diagnostics, run_manifest, settings_view, static_files, web_auth};
|
||||
|
||||
pub fn default_page_limit() -> u32 {
|
||||
|
|
@ -596,7 +598,7 @@ impl AppState {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_llm_client(&self) -> Result<LlmClient, String> {
|
||||
pub(crate) async fn build_llm_client(&self) -> Result<LlmClientResult, String> {
|
||||
self.provider_credentials.build_llm_client().await
|
||||
}
|
||||
|
||||
|
|
@ -1633,6 +1635,7 @@ fn secret_type_from_api(secret_type: ApiSecretType) -> SecretType {
|
|||
match secret_type {
|
||||
ApiSecretType::Environment => SecretType::Environment,
|
||||
ApiSecretType::File => SecretType::File,
|
||||
ApiSecretType::Credential => SecretType::Credential,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1645,6 +1648,11 @@ async fn create_secret(
|
|||
let name = body.name;
|
||||
let value = body.value;
|
||||
let description = body.description;
|
||||
if secret_type == SecretType::Credential {
|
||||
if let Err(err) = parse_credential_secret(&name, &value) {
|
||||
return ApiError::bad_request(err).into_response();
|
||||
}
|
||||
}
|
||||
let state_for_write = Arc::clone(&state);
|
||||
let result = spawn_blocking(move || {
|
||||
let mut vault = state_for_write.vault.blocking_write();
|
||||
|
|
@ -3873,6 +3881,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
|
|||
artifact_sink: Some(ArtifactSink::Store(state.artifact_store.clone())),
|
||||
run_control: None,
|
||||
github_app,
|
||||
vault: Some(Arc::clone(&state.vault)),
|
||||
on_node: None,
|
||||
registry_override,
|
||||
};
|
||||
|
|
@ -5811,8 +5820,8 @@ async fn test_model(
|
|||
.into_response();
|
||||
}
|
||||
|
||||
let client = match state.build_llm_client().await {
|
||||
Ok(client) => Arc::new(client),
|
||||
let llm_result = match state.build_llm_client().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -5821,6 +5830,14 @@ async fn test_model(
|
|||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Some((_, issue)) = llm_result
|
||||
.auth_issues
|
||||
.iter()
|
||||
.find(|(provider, _)| *provider == info.provider)
|
||||
{
|
||||
return ApiError::bad_request(auth_issue_message(info.provider, issue)).into_response();
|
||||
}
|
||||
let client = Arc::new(llm_result.client);
|
||||
|
||||
let outcome = run_model_test_with_client(info, mode, client).await;
|
||||
Json(serde_json::json!({
|
||||
|
|
@ -6013,8 +6030,8 @@ async fn create_completion(
|
|||
}
|
||||
|
||||
// Get or create LLM client (cached in AppState)
|
||||
let client = match state.build_llm_client().await {
|
||||
Ok(client) => client,
|
||||
let llm_result = match state.build_llm_client().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -6023,6 +6040,10 @@ async fn create_completion(
|
|||
.into_response();
|
||||
}
|
||||
};
|
||||
for (provider, issue) in &llm_result.auth_issues {
|
||||
warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue");
|
||||
}
|
||||
let client = llm_result.client;
|
||||
|
||||
if use_stream {
|
||||
// Streaming path: forward all StreamEvents as SSE
|
||||
|
|
@ -6181,6 +6202,7 @@ mod tests {
|
|||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType};
|
||||
use fabro_model::Provider;
|
||||
use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures};
|
||||
use tower::ServiceExt;
|
||||
|
||||
|
|
@ -6290,6 +6312,115 @@ type = "http"
|
|||
)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_secret_stores_valid_credential_entries() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
let credential = fabro_auth::AuthCredential {
|
||||
provider: Provider::OpenAi,
|
||||
details: fabro_auth::AuthDetails::CodexOAuth {
|
||||
tokens: fabro_auth::OAuthTokens {
|
||||
access_token: "access".to_string(),
|
||||
refresh_token: Some("refresh".to_string()),
|
||||
expires_at: chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc),
|
||||
},
|
||||
config: fabro_auth::OAuthConfig {
|
||||
auth_url: "https://auth.openai.com".to_string(),
|
||||
token_url: "https://auth.openai.com/oauth/token".to_string(),
|
||||
client_id: "client".to_string(),
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: Some("https://auth.openai.com/deviceauth/callback".to_string()),
|
||||
use_pkce: true,
|
||||
},
|
||||
account_id: Some("acct_123".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/secrets"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"name": "openai_codex",
|
||||
"value": serde_json::to_string(&credential).unwrap(),
|
||||
"type": "credential"
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(state.vault.read().await.list().is_empty());
|
||||
assert!(state.vault.read().await.get("openai_codex").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_secret_rejects_invalid_credential_json() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/secrets"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"name": "openai_codex",
|
||||
"value": "{not-json",
|
||||
"type": "credential"
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_secret_rejects_wrong_credential_name() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/secrets"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"name": "openai",
|
||||
"value": serde_json::to_string(&serde_json::json!({
|
||||
"provider": "openai",
|
||||
"type": "codex_oauth",
|
||||
"tokens": {
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": "2030-01-01T00:00:00Z"
|
||||
},
|
||||
"config": {
|
||||
"auth_url": "https://auth.openai.com",
|
||||
"token_url": "https://auth.openai.com/oauth/token",
|
||||
"client_id": "client",
|
||||
"scopes": ["openid"],
|
||||
"redirect_uri": "https://auth.openai.com/deviceauth/callback",
|
||||
"use_pkce": true
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
"type": "credential"
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_secret_by_name_removes_file_secret() {
|
||||
let state = create_app_state();
|
||||
|
|
|
|||
|
|
@ -2,30 +2,15 @@ use std::collections::HashMap;
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_auth::{CredentialResolver, CredentialUsage, ResolveError, ResolvedCredential};
|
||||
use fabro_config::envfile;
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_model::Provider;
|
||||
use fabro_vault::Vault;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
|
||||
const PROVIDER_LOOKUP_NAMES: &[&str] = &[
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"CHATGPT_ACCOUNT_ID",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_PROJECT_ID",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"GEMINI_BASE_URL",
|
||||
"KIMI_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"INCEPTION_API_KEY",
|
||||
];
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum Error {
|
||||
#[error(transparent)]
|
||||
|
|
@ -102,6 +87,7 @@ impl ProviderCredentials {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn get(&self, name: &str) -> Option<String> {
|
||||
let env_value = (self.env_lookup)(name);
|
||||
if env_value.is_some() {
|
||||
|
|
@ -111,29 +97,67 @@ impl ProviderCredentials {
|
|||
self.vault.read().await.get(name).map(str::to_string)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_any(&self, names: &[&str]) -> bool {
|
||||
for name in names {
|
||||
if self.get(name).await.is_some() {
|
||||
return true;
|
||||
pub(crate) async fn build_llm_client(&self) -> Result<LlmClientResult, String> {
|
||||
let resolver =
|
||||
CredentialResolver::with_env_lookup(Arc::clone(&self.vault), self.env_lookup.clone());
|
||||
let mut api_credentials = Vec::new();
|
||||
let mut auth_issues = Vec::new();
|
||||
|
||||
for provider in Provider::ALL {
|
||||
match resolver
|
||||
.resolve(*provider, CredentialUsage::ApiRequest)
|
||||
.await
|
||||
{
|
||||
Ok(ResolvedCredential::Api(credential)) => api_credentials.push(credential),
|
||||
Ok(ResolvedCredential::Cli(_)) => {}
|
||||
Err(ResolveError::NotConfigured(_)) => {}
|
||||
Err(err) => auth_issues.push((*provider, err)),
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) async fn build_llm_client(&self) -> Result<LlmClient, String> {
|
||||
let vault_snapshot = self.vault.read().await.snapshot();
|
||||
let lookup = PROVIDER_LOOKUP_NAMES
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
(self.env_lookup)(name)
|
||||
.or_else(|| vault_snapshot.get(*name).cloned())
|
||||
.map(|value| ((*name).to_string(), value))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
LlmClient::from_lookup(|name| lookup.get(name).cloned())
|
||||
let client = LlmClient::from_credentials(api_credentials)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(LlmClientResult {
|
||||
client,
|
||||
auth_issues,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct LlmClientResult {
|
||||
pub client: LlmClient,
|
||||
pub auth_issues: Vec<(Provider, ResolveError)>,
|
||||
}
|
||||
|
||||
pub(crate) fn provider_display_name(provider: Provider) -> &'static str {
|
||||
match provider {
|
||||
Provider::Anthropic => "Anthropic",
|
||||
Provider::OpenAi => "OpenAI",
|
||||
Provider::Gemini => "Gemini",
|
||||
Provider::Kimi => "Kimi",
|
||||
Provider::Zai => "Zai",
|
||||
Provider::Minimax => "Minimax",
|
||||
Provider::Inception => "Inception",
|
||||
Provider::OpenAiCompatible => "OpenAI Compatible",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auth_issue_message(provider: Provider, err: &ResolveError) -> String {
|
||||
match err {
|
||||
ResolveError::NotConfigured(_) => {
|
||||
format!("{} is not configured", provider_display_name(provider))
|
||||
}
|
||||
ResolveError::RefreshFailed { source, .. } => format!(
|
||||
"{} requires re-authentication: {}",
|
||||
provider_display_name(provider),
|
||||
source
|
||||
),
|
||||
ResolveError::RefreshTokenMissing(_) => format!(
|
||||
"{} requires re-authentication: refresh token missing",
|
||||
provider_display_name(provider)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub enum SecretType {
|
|||
#[default]
|
||||
Environment,
|
||||
File,
|
||||
Credential,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -134,6 +135,7 @@ impl Vault {
|
|||
let mut data = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.secret_type != SecretType::Credential)
|
||||
.map(|(name, entry)| SecretMetadata {
|
||||
name: name.clone(),
|
||||
secret_type: entry.secret_type,
|
||||
|
|
@ -150,6 +152,10 @@ impl Vault {
|
|||
self.entries.get(name).map(|entry| entry.value.as_str())
|
||||
}
|
||||
|
||||
pub fn get_entry(&self, name: &str) -> Option<&SecretEntry> {
|
||||
self.entries.get(name)
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> HashMap<String, String> {
|
||||
self.entries
|
||||
.iter()
|
||||
|
|
@ -158,6 +164,17 @@ impl Vault {
|
|||
.collect()
|
||||
}
|
||||
|
||||
pub fn credential_entries(&self) -> Vec<(&str, &SecretEntry)> {
|
||||
let mut data = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.secret_type == SecretType::Credential)
|
||||
.map(|(name, entry)| (name.as_str(), entry))
|
||||
.collect::<Vec<_>>();
|
||||
data.sort_by(|a, b| a.0.cmp(b.0));
|
||||
data
|
||||
}
|
||||
|
||||
pub fn file_secrets(&self) -> Vec<(String, String)> {
|
||||
let mut data = self
|
||||
.entries
|
||||
|
|
@ -171,7 +188,7 @@ impl Vault {
|
|||
|
||||
pub fn validate_name(name: &str, secret_type: SecretType) -> Result<(), Error> {
|
||||
match secret_type {
|
||||
SecretType::Environment => Self::validate_env_name(name),
|
||||
SecretType::Environment | SecretType::Credential => Self::validate_env_name(name),
|
||||
SecretType::File => Self::validate_file_name(name),
|
||||
}
|
||||
}
|
||||
|
|
@ -332,4 +349,76 @@ mod tests {
|
|||
"pem".to_string()
|
||||
)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_hides_credential_entries_loaded_from_disk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("secrets.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
"OPENAI_API_KEY": {
|
||||
"value": "env",
|
||||
"type": "environment",
|
||||
"created_at": "2026-04-12T00:00:00Z",
|
||||
"updated_at": "2026-04-12T00:00:00Z"
|
||||
},
|
||||
"openai_codex": {
|
||||
"value": "{\"provider\":\"openai\"}",
|
||||
"type": "credential",
|
||||
"created_at": "2026-04-12T00:00:00Z",
|
||||
"updated_at": "2026-04-12T00:00:00Z"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = Vault::load(path).unwrap();
|
||||
|
||||
assert_eq!(store.list().len(), 1);
|
||||
assert_eq!(store.list()[0].name, "OPENAI_API_KEY");
|
||||
assert_eq!(store.get("openai_codex"), Some("{\"provider\":\"openai\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_entry_returns_full_secret_entry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
store
|
||||
.set(
|
||||
"openai_codex",
|
||||
"credential-json",
|
||||
SecretType::Credential,
|
||||
Some("saved auth"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let entry = store.get_entry("openai_codex").unwrap();
|
||||
|
||||
assert_eq!(entry.value, "credential-json");
|
||||
assert_eq!(entry.secret_type, SecretType::Credential);
|
||||
assert_eq!(entry.description.as_deref(), Some("saved auth"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_entries_only_returns_credentials() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Vault::load(dir.path().join("secrets.json")).unwrap();
|
||||
store
|
||||
.set("OPENAI_API_KEY", "env", SecretType::Environment, None)
|
||||
.unwrap();
|
||||
store
|
||||
.set(
|
||||
"openai_codex",
|
||||
"credential-json",
|
||||
SecretType::Credential,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(store.credential_entries().len(), 1);
|
||||
assert_eq!(store.credential_entries()[0].0, "openai_codex");
|
||||
assert_eq!(store.credential_entries()[0].1.value, "credential-json");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
fabro-auth = { path = "../fabro-auth" }
|
||||
fabro-agent = { path = "../fabro-agent" }
|
||||
fabro-config = { path = "../fabro-config" }
|
||||
fabro-graphviz = { path = "../fabro-graphviz" }
|
||||
|
|
@ -64,6 +65,7 @@ tracing.workspace = true
|
|||
walkdir.workspace = true
|
||||
tempfile = "3"
|
||||
toml.workspace = true
|
||||
fabro-vault = { path = "../fabro-vault" }
|
||||
[dev-dependencies]
|
||||
base64.workspace = true
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||
use async_trait::async_trait;
|
||||
use fabro_agent::Sandbox;
|
||||
use fabro_agent::sandbox::ExecResult;
|
||||
use fabro_auth::{CliAgentKind, CredentialResolver, CredentialUsage, ResolvedCredential};
|
||||
use fabro_graphviz::graph::Node;
|
||||
use fabro_llm::types::TokenCounts;
|
||||
use fabro_model::Provider;
|
||||
|
|
@ -369,8 +370,11 @@ pub fn parse_cli_response(provider: Provider, output: &str) -> Option<CliRespons
|
|||
}
|
||||
|
||||
/// Escape a value for safe embedding inside single quotes in a shell command.
|
||||
fn shell_escape(val: &str) -> String {
|
||||
val.replace('\'', "'\\''")
|
||||
fn shell_quote(val: &str) -> String {
|
||||
shlex::try_quote(val).map_or_else(
|
||||
|_| format!("'{}'", val.replace('\'', "'\\''")),
|
||||
std::borrow::Cow::into_owned,
|
||||
)
|
||||
}
|
||||
|
||||
/// CLI backend that invokes external CLI tools (claude, codex, gemini) via
|
||||
|
|
@ -380,16 +384,29 @@ pub struct AgentCliBackend {
|
|||
provider: Provider,
|
||||
env: HashMap<String, String>,
|
||||
poll_interval: std::time::Duration,
|
||||
resolver: Option<CredentialResolver>,
|
||||
}
|
||||
|
||||
impl AgentCliBackend {
|
||||
#[must_use]
|
||||
pub fn new(model: String, provider: Provider) -> Self {
|
||||
pub fn new(model: String, provider: Provider, resolver: CredentialResolver) -> Self {
|
||||
Self {
|
||||
model,
|
||||
provider,
|
||||
env: HashMap::new(),
|
||||
poll_interval: std::time::Duration::from_secs(5),
|
||||
resolver: Some(resolver),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_from_env(model: String, provider: Provider) -> Self {
|
||||
Self {
|
||||
model,
|
||||
provider,
|
||||
env: HashMap::new(),
|
||||
poll_interval: std::time::Duration::from_secs(5),
|
||||
resolver: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -516,26 +533,22 @@ impl CodergenBackend for AgentCliBackend {
|
|||
// base64-encoded command, avoiding filesystem-to-process race
|
||||
// conditions that can occur when writing an env file via the fs API and
|
||||
// sourcing it via the process API.
|
||||
let mut launch_env: HashMap<String, String> = HashMap::new();
|
||||
for name in provider.api_key_env_vars() {
|
||||
if let Ok(val) = std::env::var(name) {
|
||||
launch_env.insert((*name).to_string(), val);
|
||||
}
|
||||
}
|
||||
for (name, val) in &self.env {
|
||||
launch_env.insert(name.clone(), val.clone());
|
||||
}
|
||||
|
||||
// Codex CLI requires `codex login --with-api-key` to store credentials;
|
||||
// it does not read OPENAI_API_KEY from the environment at runtime.
|
||||
if cli == AgentCli::Codex {
|
||||
if let Some(api_key) = launch_env.get("OPENAI_API_KEY") {
|
||||
let login_cmd = format!(
|
||||
"PATH=\"$HOME/.local/bin:$PATH\" echo '{}' | codex login --with-api-key",
|
||||
shell_escape(api_key)
|
||||
);
|
||||
let cli_agent = match cli {
|
||||
AgentCli::Claude => CliAgentKind::Claude,
|
||||
AgentCli::Codex => CliAgentKind::Codex,
|
||||
AgentCli::Gemini => CliAgentKind::Gemini,
|
||||
};
|
||||
let mut launch_env = if let Some(resolver) = &self.resolver {
|
||||
let resolved = resolver
|
||||
.resolve(provider, CredentialUsage::CliAgent(cli_agent))
|
||||
.await
|
||||
.map_err(|e| Error::handler(format!("Failed to resolve CLI credential: {e}")))?;
|
||||
let ResolvedCredential::Cli(cli_credential) = resolved else {
|
||||
return Err(Error::handler("Expected CLI credential".to_string()));
|
||||
};
|
||||
if let Some(login_cmd) = &cli_credential.login_command {
|
||||
let login_result = sandbox
|
||||
.exec_command(&login_cmd, 30_000, None, None, None)
|
||||
.exec_command(login_cmd, 30_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| Error::handler(format!("codex login failed: {e}")))?;
|
||||
if login_result.exit_code != 0 {
|
||||
|
|
@ -546,6 +559,18 @@ impl CodergenBackend for AgentCliBackend {
|
|||
);
|
||||
}
|
||||
}
|
||||
cli_credential.env_vars
|
||||
} else {
|
||||
let mut env = HashMap::new();
|
||||
for name in provider.api_key_env_vars() {
|
||||
if let Ok(val) = std::env::var(name) {
|
||||
env.insert((*name).to_string(), val);
|
||||
}
|
||||
}
|
||||
env
|
||||
};
|
||||
for (name, val) in &self.env {
|
||||
launch_env.insert(name.clone(), val.clone());
|
||||
}
|
||||
|
||||
// Also write env file as fallback for commands that source it (e.g. ensure_cli
|
||||
|
|
@ -554,7 +579,7 @@ impl CodergenBackend for AgentCliBackend {
|
|||
env_lines.extend(
|
||||
launch_env
|
||||
.iter()
|
||||
.map(|(k, v)| format!("export {k}='{}'", shell_escape(v))),
|
||||
.map(|(k, v)| format!("export {k}={}", shell_quote(v))),
|
||||
);
|
||||
{
|
||||
sandbox
|
||||
|
|
@ -1161,7 +1186,7 @@ mod tests {
|
|||
node.attrs
|
||||
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
|
||||
|
||||
let cli_backend = AgentCliBackend::new("model".into(), Provider::Anthropic);
|
||||
let cli_backend = AgentCliBackend::new_from_env("model".into(), Provider::Anthropic);
|
||||
let router = BackendRouter::new(Box::new(StubBackend), cli_backend);
|
||||
assert!(router.should_use_cli(&node));
|
||||
}
|
||||
|
|
@ -1170,7 +1195,7 @@ mod tests {
|
|||
fn router_uses_api_by_default() {
|
||||
let node = Node::new("test");
|
||||
|
||||
let cli_backend = AgentCliBackend::new("model".into(), Provider::Anthropic);
|
||||
let cli_backend = AgentCliBackend::new_from_env("model".into(), Provider::Anthropic);
|
||||
let router = BackendRouter::new(Box::new(StubBackend), cli_backend);
|
||||
assert!(!router.should_use_cli(&node));
|
||||
}
|
||||
|
|
@ -1183,7 +1208,7 @@ mod tests {
|
|||
AttrValue::String("claude-opus-4-6".to_string()),
|
||||
);
|
||||
|
||||
let cli_backend = AgentCliBackend::new("model".into(), Provider::Anthropic);
|
||||
let cli_backend = AgentCliBackend::new_from_env("model".into(), Provider::Anthropic);
|
||||
let router = BackendRouter::new(Box::new(StubBackend), cli_backend);
|
||||
assert!(!router.should_use_cli(&node));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ use fabro_types::settings::run::{
|
|||
PullRequestSettings, RunMode, RunModelSettings as ResolvedRunModelSettings,
|
||||
RunSettings as ResolvedRunSettings, TlsMode as ResolvedTlsMode,
|
||||
};
|
||||
use fabro_vault::Vault;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::artifact_upload::ArtifactSink;
|
||||
use crate::context::Context;
|
||||
|
|
@ -76,6 +78,7 @@ struct RunSession {
|
|||
workflow_path: Option<PathBuf>,
|
||||
workflow_bundle: Option<Arc<WorkflowBundle>>,
|
||||
run_control: Option<Arc<RunControlState>>,
|
||||
vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
}
|
||||
|
||||
pub struct StartServices {
|
||||
|
|
@ -88,6 +91,7 @@ pub struct StartServices {
|
|||
pub artifact_sink: Option<ArtifactSink>,
|
||||
pub run_control: Option<Arc<RunControlState>>,
|
||||
pub github_app: Option<fabro_github::GitHubCredentials>,
|
||||
pub vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
pub on_node: crate::OnNodeCallback,
|
||||
pub registry_override: Option<Arc<HandlerRegistry>>,
|
||||
}
|
||||
|
|
@ -428,6 +432,7 @@ impl RunSession {
|
|||
pr_model: model,
|
||||
workflow_path,
|
||||
workflow_bundle,
|
||||
vault: services.vault,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -701,6 +706,7 @@ impl RunSession {
|
|||
workflow_bundle: self.workflow_bundle,
|
||||
hooks: self.hooks,
|
||||
sandbox_env: self.sandbox_env,
|
||||
vault: self.vault,
|
||||
devcontainer: self.devcontainer,
|
||||
git: self.git,
|
||||
worktree_mode: self.worktree_mode,
|
||||
|
|
@ -1048,6 +1054,7 @@ mod tests {
|
|||
artifact_sink: None,
|
||||
run_control: None,
|
||||
github_app: None,
|
||||
vault: None,
|
||||
on_node: None,
|
||||
registry_override: Some(registry),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ async fn execute_test_run_with_options(
|
|||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: git_options,
|
||||
worktree_mode: None,
|
||||
|
|
@ -275,6 +276,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
|
|||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
worktree_mode: None,
|
||||
|
|
@ -342,6 +344,7 @@ async fn run_with_lifecycle(
|
|||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
worktree_mode: None,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||
use std::time::Instant;
|
||||
|
||||
use fabro_agent::Sandbox;
|
||||
use fabro_auth::CredentialResolver;
|
||||
use fabro_config::RunScratch;
|
||||
use fabro_graphviz::graph;
|
||||
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
|
||||
|
|
@ -12,9 +13,11 @@ use fabro_sandbox::{
|
|||
ReadBeforeWriteSandbox, SandboxEventCallback, SandboxSpec, WorkdirStrategy, WorktreeOptions,
|
||||
WorktreeSandbox,
|
||||
};
|
||||
use fabro_vault::Vault;
|
||||
use shlex::try_quote;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
|
||||
|
|
@ -279,6 +282,7 @@ async fn build_registry(
|
|||
interviewer: Arc<dyn fabro_interview::Interviewer>,
|
||||
sandbox_env: &HashMap<String, String>,
|
||||
graph: &graph::Graph,
|
||||
vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
) -> Result<(Arc<HandlerRegistry>, Option<Client>, bool), Error> {
|
||||
let build_no_backend = || Arc::new(default_registry(Arc::clone(&interviewer), || None));
|
||||
|
||||
|
|
@ -306,11 +310,18 @@ async fn build_registry(
|
|||
let provider = spec.provider;
|
||||
let fallback_chain = spec.fallback_chain.clone();
|
||||
let mcp_servers = spec.mcp_servers.clone();
|
||||
let resolver = vault.map(CredentialResolver::new);
|
||||
let registry = Arc::new(default_registry(interviewer, move || {
|
||||
let api = AgentApiBackend::new(model.clone(), provider, fallback_chain.clone())
|
||||
.with_env(env.clone())
|
||||
.with_mcp_servers(mcp_servers.clone());
|
||||
let cli = AgentCliBackend::new(model.clone(), provider).with_env(env.clone());
|
||||
let cli = resolver
|
||||
.clone()
|
||||
.map_or_else(
|
||||
|| AgentCliBackend::new_from_env(model.clone(), provider),
|
||||
|resolver| AgentCliBackend::new(model.clone(), provider, resolver),
|
||||
)
|
||||
.with_env(env.clone());
|
||||
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
|
||||
}));
|
||||
Ok((registry, Some(client), false))
|
||||
|
|
@ -545,7 +556,14 @@ pub async fn initialize(
|
|||
// A caller-supplied registry owns execution behavior for its handlers.
|
||||
(registry, None, options.dry_run)
|
||||
} else {
|
||||
build_registry(&options.llm, Arc::clone(&options.interviewer), &env, &graph).await?
|
||||
build_registry(
|
||||
&options.llm,
|
||||
Arc::clone(&options.interviewer),
|
||||
&env,
|
||||
&graph,
|
||||
options.vault.clone(),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
if effective_dry_run {
|
||||
use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode};
|
||||
|
|
@ -838,6 +856,7 @@ mod tests {
|
|||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
worktree_mode: None,
|
||||
|
|
@ -912,6 +931,7 @@ mod tests {
|
|||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
worktree_mode: None,
|
||||
|
|
@ -980,6 +1000,7 @@ mod tests {
|
|||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
worktree_mode: None,
|
||||
|
|
@ -1042,6 +1063,7 @@ mod tests {
|
|||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
devcontainer: None,
|
||||
git: None,
|
||||
worktree_mode: None,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ use fabro_sandbox::config::WorktreeMode;
|
|||
use fabro_types::RunId;
|
||||
use fabro_types::settings::run::PullRequestSettings;
|
||||
use fabro_validate::{Diagnostic, Severity};
|
||||
use fabro_vault::Vault;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::artifact_upload::ArtifactSink;
|
||||
use crate::context::Context;
|
||||
|
|
@ -245,6 +247,7 @@ pub struct InitOptions {
|
|||
pub workflow_bundle: Option<Arc<WorkflowBundle>>,
|
||||
pub hooks: fabro_hooks::HookSettings,
|
||||
pub sandbox_env: SandboxEnvSpec,
|
||||
pub vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
pub devcontainer: Option<DevcontainerSpec>,
|
||||
pub git: Option<GitCheckpointOptions>,
|
||||
pub worktree_mode: Option<WorktreeMode>,
|
||||
|
|
|
|||
|
|
@ -1043,7 +1043,7 @@ async fn run_daytona_cli_test(provider: Provider, model: &str, install_command:
|
|||
install_result.exit_code, install_result.stdout
|
||||
);
|
||||
|
||||
let backend = AgentCliBackend::new(model.to_string(), provider);
|
||||
let backend = AgentCliBackend::new_from_env(model.to_string(), provider);
|
||||
let node = Node::new("daytona_cli_test");
|
||||
let context = Context::new();
|
||||
let emitter = Arc::new(Emitter::default());
|
||||
|
|
|
|||
|
|
@ -9360,7 +9360,7 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() {
|
|||
let claude_output = r#"{"type":"result","result":"I fixed the bug.","usage":{"input_tokens":500,"output_tokens":200}}"#;
|
||||
let test_env = Arc::new(CliTestEnv::new(claude_output));
|
||||
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
|
||||
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
|
||||
let node = Node::new("fix_code");
|
||||
|
|
@ -9432,7 +9432,7 @@ async fn cli_backend_run_detects_changed_files() {
|
|||
let claude_output = r#"{"type":"result","result":"Created new file.","usage":{"input_tokens":100,"output_tokens":50}}"#;
|
||||
let env: Arc<dyn fabro_agent::Sandbox> =
|
||||
Arc::new(CliTestEnv::new(claude_output).with_git_diff_after("src/main.rs\nsrc/lib.rs\n"));
|
||||
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
|
||||
let node = Node::new("implement");
|
||||
|
|
@ -9465,7 +9465,7 @@ async fn cli_backend_run_with_codex_provider() {
|
|||
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"Implemented the feature.\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":300,\"output_tokens\":150}}";
|
||||
let test_env = Arc::new(CliTestEnv::new(codex_output));
|
||||
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
|
||||
let backend = AgentCliBackend::new("gpt-5.3-codex".into(), Provider::OpenAi)
|
||||
let backend = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
|
||||
let node = Node::new("implement");
|
||||
|
|
@ -9622,7 +9622,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
|
|||
}
|
||||
|
||||
let failing_env: Arc<dyn fabro_agent::Sandbox> = Arc::new(FailingCliEnv);
|
||||
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
let node = Node::new("step");
|
||||
let context = Context::new();
|
||||
|
|
@ -9660,7 +9660,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
|
|||
#[tokio::test]
|
||||
async fn cli_backend_run_fails_on_unparseable_output() {
|
||||
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new("this is not json at all"));
|
||||
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
|
||||
let node = Node::new("step");
|
||||
|
|
@ -9688,7 +9688,7 @@ async fn cli_backend_run_uses_node_model_override() {
|
|||
r#"{"type":"result","result":"ok","usage":{"input_tokens":10,"output_tokens":5}}"#;
|
||||
let test_env = Arc::new(CliTestEnv::new(claude_output));
|
||||
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
|
||||
let backend = AgentCliBackend::new("default-model".into(), Provider::Anthropic)
|
||||
let backend = AgentCliBackend::new_from_env("default-model".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
|
||||
let mut node = Node::new("step");
|
||||
|
|
@ -9725,7 +9725,7 @@ async fn cli_backend_run_uses_node_provider_override() {
|
|||
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"ok\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}";
|
||||
let test_env = Arc::new(CliTestEnv::new(codex_output));
|
||||
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
|
||||
let backend = AgentCliBackend::new("default-model".into(), Provider::Anthropic)
|
||||
let backend = AgentCliBackend::new_from_env("default-model".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
|
||||
let mut node = Node::new("step");
|
||||
|
|
@ -9759,7 +9759,7 @@ async fn cli_backend_run_returns_text_and_usage() {
|
|||
let claude_output =
|
||||
r#"{"type":"result","result":"done","usage":{"input_tokens":10,"output_tokens":5}}"#;
|
||||
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(claude_output));
|
||||
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
|
||||
let node = Node::new("step");
|
||||
|
|
@ -9791,7 +9791,7 @@ async fn backend_router_delegates_to_cli_for_cli_node() {
|
|||
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(claude_output));
|
||||
|
||||
let api_backend = Box::new(MockCodergenBackend); // would return "Response for ..."
|
||||
let cli = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
let router = BackendRouter::new(api_backend, cli);
|
||||
|
||||
|
|
@ -9827,7 +9827,7 @@ async fn backend_router_delegates_to_api_for_normal_node() {
|
|||
let env = local_env();
|
||||
|
||||
let api_backend = Box::new(MockCodergenBackend);
|
||||
let cli = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
let router = BackendRouter::new(api_backend, cli);
|
||||
|
||||
|
|
@ -9862,7 +9862,7 @@ async fn backend_router_delegates_to_cli_for_backend_attr() {
|
|||
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(codex_output));
|
||||
|
||||
let api_backend = Box::new(MockCodergenBackend);
|
||||
let cli = AgentCliBackend::new("gpt-5.3-codex".into(), Provider::OpenAi)
|
||||
let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
let router = BackendRouter::new(api_backend, cli);
|
||||
|
||||
|
|
@ -9947,7 +9947,7 @@ async fn full_pipeline_with_cli_backend_node() {
|
|||
|
||||
// Build engine with BackendRouter
|
||||
let api = MockCodergenBackend;
|
||||
let cli = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
let router = BackendRouter::new(Box::new(api), cli);
|
||||
let codergen_handler = AgentHandler::new(Some(Box::new(router)));
|
||||
|
|
@ -9960,7 +9960,7 @@ async fn full_pipeline_with_cli_backend_node() {
|
|||
Box::new(AgentHandler::new(Some(Box::new({
|
||||
// Second BackendRouter for the "agent" handler
|
||||
let api2 = MockCodergenBackend;
|
||||
let cli2 = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
BackendRouter::new(Box::new(api2), cli2)
|
||||
})))),
|
||||
|
|
@ -10068,7 +10068,7 @@ async fn stylesheet_backend_property_routes_to_cli() {
|
|||
|
||||
// Run the pipeline
|
||||
let api = MockCodergenBackend;
|
||||
let cli = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
let router = BackendRouter::new(Box::new(api), cli);
|
||||
|
||||
|
|
@ -10076,7 +10076,7 @@ async fn stylesheet_backend_property_routes_to_cli() {
|
|||
registry.register("start", Box::new(StartHandler));
|
||||
registry.register("exit", Box::new(ExitHandler));
|
||||
let api2 = MockCodergenBackend;
|
||||
let cli2 = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
|
||||
.with_poll_interval(Duration::from_millis(10));
|
||||
let router2 = BackendRouter::new(Box::new(api2), cli2);
|
||||
registry.register(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
|
||||
export const SecretType = {
|
||||
ENVIRONMENT: 'environment',
|
||||
FILE: 'file'
|
||||
FILE: 'file',
|
||||
CREDENTIAL: 'credential'
|
||||
} as const;
|
||||
|
||||
export type SecretType = typeof SecretType[keyof typeof SecretType];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue