refactor(auth): split vault credential schemas

Store API keys as raw vault token secrets and OAuth credentials as a dedicated OAuth schema. Make provider credential refs explicit about env/vault source and add a temporary best-effort startup migration for old vault files until 2026-08-18.
This commit is contained in:
Bryan Helmkamp 2026-05-18 10:22:06 -04:00
parent 6a86ced77c
commit 3448a7d067
No known key found for this signature in database
64 changed files with 2734 additions and 1048 deletions

View file

@ -328,7 +328,7 @@ Fabro no longer auto-loads `.env` files. Provider API keys are required for the
### LLM provider keys
Fabro's built-in provider access resolves these from `process env -> vault`.
Fabro's built-in provider access resolves these from the process environment first, then an exact-name vault token or OAuth entry.
| Variable | Provider |
|---|---|

View file

@ -10385,12 +10385,12 @@ components:
example: ok
SecretType:
description: The way a secret is consumed by the sandbox.
description: Schema of a stored secret.
type: string
enum:
- environment
- token
- oauth
- file
- credential
CreateSecretRequest:
description: Request to store or update a secret.

View file

@ -0,0 +1,27 @@
---
title: "Explicit vault-backed provider credentials"
date: "2026-05-18"
---
## Explicit vault-backed provider credentials
Fabro now separates process environment credentials from server-owned vault credentials. Provider auth refs use `env:<NAME>` for process environment lookup and `vault:<NAME>` for explicit vault lookup. API-key secrets are stored as raw `token` values, OAuth records are stored as typed `oauth` JSON, and file material remains `file`.
```toml
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
```
## Migration note
Use `fabro secret set NAME value` for API keys and PAT-style secrets. The default secret type is now `token`; `environment` and `credential` secret schemas are no longer part of the API.
Server startup performs a best-effort one-time migration for old vault files and writes a backup next to the original vault before rewriting. If migration or loading fails, Fabro logs a warning and continues with an empty in-memory vault. This compatibility shim is scheduled for removal after 2026-08-18.
## More
<Accordion title="Auth model">
- Removed the old `credential:` provider ref prefix in favor of explicit `vault:`
- OpenAI Codex OAuth credentials now live under `vault:OPENAI_CODEX`
- Provider catalogs now check process env first, then same-name vault entries
</Accordion>

View file

@ -37,7 +37,7 @@ No single model is best at everything. Fabro lets you assign the right model to
| `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s |
| `mercury-2` | inception | `mercury` | 131K | $0.20 / $0.80 | 1000 tok/s |
Each provider requires its own API key set via environment variable (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`). See the [Quick Start](/getting-started/quick-start) for setup.
Each provider requires its own API key set via environment variable or matching vault token (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`). See the [Quick Start](/getting-started/quick-start) for setup.
## Configuring providers and models
@ -119,7 +119,7 @@ reasoning = false
`api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same.
Provider auth is declared in `[llm.providers.<id>.auth]` with ordered `env:<NAME>` or `credential:<id>` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers.<id>.auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only typed headers and no API-key auth — go in `extra_headers` as `{ env = "NAME" }`, `{ credential = "id" }`, or `{ literal = "value" }`.
Provider auth is declared in `[llm.providers.<id>.auth]` with ordered `env:<NAME>` or `vault:<NAME>` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers.<id>.auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only typed headers and no API-key auth — go in `extra_headers` as `{ env = "NAME" }`, `{ credential = "id" }`, or `{ literal = "value" }`.
Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, and `gemini`; model-level values override provider-level values.

View file

@ -45,12 +45,12 @@ reasoning = false
## Configure credentials
The LiteLLM provider checks `credential:litellm` first, then `LITELLM_API_KEY` from the Fabro process environment.
The LiteLLM provider checks `LITELLM_API_KEY` from the Fabro process environment first, then the `vault:LITELLM_API_KEY` server secret.
For a server-owned secret:
```bash
fabro secret set litellm sk-proxy-key
fabro secret set LITELLM_API_KEY sk-proxy-key
```
For a process environment variable:
@ -115,7 +115,7 @@ Only one model for a provider should set `default = true`.
## Troubleshooting
**"No API key configured"** — Set `credential:litellm` with `fabro secret set litellm ...` or export `LITELLM_API_KEY` in the Fabro process environment.
**"No API key configured"** — Set `vault:LITELLM_API_KEY` with `fabro secret set LITELLM_API_KEY ...` or export `LITELLM_API_KEY` in the Fabro process environment.
**Connection refused** — Confirm the LiteLLM proxy is running and that `base_url` is reachable from the Fabro process. For Docker deployments, `localhost` means the Fabro container unless you point it at a host or service name.

View file

@ -1168,7 +1168,7 @@ fabro secret set [OPTIONS] <KEY> [VALUE]
| Option | Description |
| --- | --- |
| `--description <description>` | Optional human-readable description |
| `--type <type>` | Secret storage type<br />Values: `environment`, `file`<br />Default: `environment` |
| `--type <type>` | Secret storage type<br />Values: `token`, `file`<br />Default: `token` |
| `--value-stdin` | Read the secret value from stdin |
### `fabro server`

View file

@ -198,7 +198,7 @@ x-team-secret = { credential = "gateway_team_secret" }
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | derived from `adapter` | Provider-owned billing algorithm for usage estimates. Override for exceptional providers such as local no-billing runtimes. |
| `base_url` | string | built-in value or adapter runtime default | Provider API base URL. Required for most custom OpenAI-compatible providers. |
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `credential:<id>` and `env:<NAME>`. Literal secret strings are rejected. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>` and `env:<NAME>`. Literal secret strings are rejected. |
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ credential = "id" }`. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@ fn secret_metadata_reuses_canonical_type() {
fn secret_metadata_round_trips_representative_json() {
let value = json!({
"name": "ANTHROPIC_API_KEY",
"type": "environment",
"type": "token",
"description": "Anthropic API key",
"created_at": "2026-04-29T12:34:56Z",
"updated_at": "2026-04-29T12:40:00Z"
@ -21,7 +21,7 @@ fn secret_metadata_round_trips_representative_json() {
let metadata: SecretMetadata = serde_json::from_value(value.clone()).unwrap();
assert_eq!(metadata.name, "ANTHROPIC_API_KEY");
assert_eq!(metadata.secret_type, SecretType::Environment);
assert_eq!(metadata.secret_type, SecretType::Token);
assert_eq!(metadata.description, Some("Anthropic API key".to_string()));
assert_eq!(serde_json::to_value(metadata).unwrap(), value);
}

View file

@ -12,27 +12,27 @@ fn secret_type_reuses_canonical_type() {
#[test]
fn secret_type_serializes_as_snake_case_strings() {
assert_eq!(
serde_json::to_value(SecretType::Environment).unwrap(),
json!("environment")
serde_json::to_value(SecretType::Token).unwrap(),
json!("token")
);
assert_eq!(
serde_json::to_value(SecretType::Oauth).unwrap(),
json!("oauth")
);
assert_eq!(
serde_json::to_value(SecretType::File).unwrap(),
json!("file")
);
assert_eq!(
serde_json::to_value(SecretType::Credential).unwrap(),
json!("credential")
);
}
#[test]
fn secret_type_deserializes_each_variant() {
let env: SecretType = serde_json::from_value(json!("environment")).unwrap();
assert_eq!(env, SecretType::Environment);
let token: SecretType = serde_json::from_value(json!("token")).unwrap();
assert_eq!(token, SecretType::Token);
let oauth: SecretType = serde_json::from_value(json!("oauth")).unwrap();
assert_eq!(oauth, SecretType::Oauth);
let file: SecretType = serde_json::from_value(json!("file")).unwrap();
assert_eq!(file, SecretType::File);
let cred: SecretType = serde_json::from_value(json!("credential")).unwrap();
assert_eq!(cred, SecretType::Credential);
}
fn assert_same_type<T: 'static, U: 'static>() {

View file

@ -1,41 +1,25 @@
use chrono::{DateTime, Duration, Utc};
use fabro_model::ProviderId;
use fabro_redact::redact_string;
use serde::{Deserialize, Serialize};
/// JSON shape stored in the vault when `secret_type == Oauth`.
///
/// Provider context comes from the catalog and auth strategy at resolve time.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthCredential {
pub provider: ProviderId,
#[serde(flatten)]
pub details: AuthDetails,
pub struct OAuthCredential {
pub tokens: OAuthTokens,
pub config: OAuthConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
}
impl AuthCredential {
impl OAuthCredential {
#[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)
}
}
self.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,
@ -89,140 +73,47 @@ impl std::fmt::Debug for ApiKeyHeader {
}
}
pub fn credential_id_for(credential: &AuthCredential) -> Result<String, String> {
match &credential.details {
AuthDetails::ApiKey { .. } => Ok(credential.provider.to_string()),
AuthDetails::CodexOAuth { .. } if credential.provider == ProviderId::openai() => {
Ok("openai_codex".to_string())
}
AuthDetails::CodexOAuth { .. } => Err(format!(
"codex_oauth credentials are only valid for OpenAI, got {}",
credential.provider
)),
}
}
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: ProviderId::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()),
fn fixture(expires_at: DateTime<Utc>) -> OAuthCredential {
OAuthCredential {
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));
fn round_trips_through_json() {
let credential = fixture(Utc::now() + Duration::hours(1));
let json = serde_json::to_string(&credential).unwrap();
let parsed: AuthCredential = serde_json::from_str(&json).unwrap();
let parsed: OAuthCredential = 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: ProviderId::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 = ProviderId::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());
}
#[test]
fn credential_id_for_custom_api_key_uses_provider_id() {
let credential = AuthCredential {
provider: ProviderId::new("venice"),
details: AuthDetails::ApiKey {
key: "sk-test".to_string(),
},
};
assert_eq!(credential_id_for(&credential).unwrap(), "venice");
}
#[test]
fn parse_credential_secret_accepts_custom_provider_api_key() {
let credential = AuthCredential {
provider: ProviderId::new("venice"),
details: AuthDetails::ApiKey {
key: "sk-test".to_string(),
},
};
let json = serde_json::to_string(&credential).unwrap();
assert_eq!(
parse_credential_secret("venice", &json).unwrap(),
credential
);
assert!(parse_credential_secret("openai", &json).is_err());
assert!(fixture(Utc::now() + Duration::minutes(4)).needs_refresh());
assert!(!fixture(Utc::now() + Duration::minutes(6)).needs_refresh());
}
#[test]
fn api_key_header_debug_redacts_secret_values() {
let header = ApiKeyHeader::Bearer("sk-test".to_string());
let debug = format!("{header:?}");
assert!(!debug.contains("sk-test"));
assert!(debug.contains("REDACTED"));
}

View file

@ -11,10 +11,7 @@ mod vault_source;
pub mod strategies;
pub use context::{AuthContextRequest, AuthContextResponse};
pub use credential::{
ApiKeyHeader, AuthCredential, AuthDetails, OAuthConfig, OAuthTokens, credential_id_for,
parse_credential_secret,
};
pub use credential::{ApiKeyHeader, OAuthConfig, OAuthCredential, OAuthTokens};
pub use credential_source::{CredentialSource, ResolvedCredentials};
pub use env_source::EnvCredentialSource;
pub use refresh::refresh_oauth_credential;
@ -24,8 +21,10 @@ pub use resolve::{
configured_providers_from_process_env,
};
pub use strategy::{
AuthMethod, AuthStrategy, CODEX_AUTH_URL, CODEX_CLIENT_ID, CODEX_TOKEN_URL, codex_oauth_config,
strategy_for,
AuthMethod, AuthStrategy, CODEX_AUTH_URL, CODEX_CLIENT_ID, CODEX_TOKEN_URL, LoginResult,
codex_oauth_config, strategy_for,
};
pub use vault_ext::{
VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_set_token,
};
pub use vault_ext::{vault_credentials_for_provider, vault_get_credential, vault_set_credential};
pub use vault_source::VaultCredentialSource;

View file

@ -1,42 +1,31 @@
use crate::credential::{AuthCredential, AuthDetails, OAuthTokens, expires_at_from_now};
use crate::credential::{OAuthCredential, OAuthTokens, expires_at_from_now};
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
credential: &OAuthCredential,
) -> anyhow::Result<OAuthCredential> {
let refresh_token = credential
.tokens
.refresh_token
.as_deref()
.ok_or_else(|| anyhow::anyhow!("refresh token missing"))?;
let response = fabro_oauth::refresh_token(
fabro_oauth::OAuthEndpoint {
token_url: &credential.config.token_url,
client_id: &credential.config.client_id,
},
refresh_token,
)
.await
.map_err(anyhow::Error::msg)?;
Ok(OAuthCredential {
tokens: OAuthTokens {
access_token: response.access_token,
refresh_token: response
.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.clone(),
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(),
},
})
}
}
.or_else(|| credential.tokens.refresh_token.clone()),
expires_at: expires_at_from_now(response.expires_in),
},
config: credential.config.clone(),
account_id: credential.account_id.clone(),
})
}

View file

@ -4,16 +4,16 @@ use std::sync::Arc;
use fabro_model::catalog::CatalogProvider;
use fabro_model::{ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, ProviderId};
use fabro_static::EnvVars;
use fabro_vault::Vault;
use fabro_vault::{SecretType, Vault};
use shlex::try_quote;
use tokio::sync::RwLock as AsyncRwLock;
use tokio::task::spawn_blocking;
use crate::credential::{ApiKeyHeader, AuthCredential, AuthDetails, credential_id_for};
use crate::credential::{ApiKeyHeader, OAuthCredential};
use crate::credential_source::CredentialSource;
use crate::env_source::EnvCredentialSource;
use crate::refresh::refresh_oauth_credential;
use crate::vault_ext::{vault_get_credential, vault_set_credential};
use crate::vault_ext::vault_set_oauth;
pub type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
@ -30,6 +30,15 @@ pub enum CredentialUsage {
CliAgent(CliAgentKind),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResolvedSecret {
ApiKey(String),
OAuth {
credential: Box<OAuthCredential>,
vault_name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiCredential {
pub provider: ProviderId,
@ -100,6 +109,19 @@ pub enum ResolvedCredential {
pub enum ResolveError {
#[error("{0} is not configured")]
NotConfigured(ProviderId),
#[error("{provider} vault credential '{name}' has schema {actual:?}, expected Token or Oauth")]
VaultSchemaMismatch {
provider: ProviderId,
name: String,
actual: SecretType,
},
#[error("{provider} vault credential '{name}' is not valid Oauth JSON: {source}")]
VaultDecodeFailed {
provider: ProviderId,
name: String,
#[source]
source: serde_json::Error,
},
#[error("{provider} requires re-authentication: {source}")]
RefreshFailed {
provider: ProviderId,
@ -117,6 +139,14 @@ pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String {
ResolveError::NotConfigured(_) => {
format!("{provider_name} is not configured")
}
ResolveError::VaultSchemaMismatch { name, actual, .. } => {
format!(
"{provider_name} vault credential '{name}' has schema {actual:?}, expected Token or Oauth"
)
}
ResolveError::VaultDecodeFailed { name, source, .. } => {
format!("{provider_name} vault credential '{name}' is not valid OAuth JSON: {source}")
}
ResolveError::RefreshFailed { source, .. } => {
format!("{provider_name} requires re-authentication: {source}")
}
@ -163,59 +193,61 @@ impl CredentialResolver {
.api_credential_from_provider_auth(&vault, catalog_provider, catalog)
.map(ResolvedCredential::Api);
}
let initial_credential = {
let initial_secret = {
let vault = self.vault.read().await;
self.find_credential(&vault, catalog_provider, usage)?
self.find_credential(&vault, catalog_provider)?
};
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() {
let secret = if let ResolvedSecret::OAuth {
credential,
vault_name,
} = &initial_secret
{
if !credential.needs_refresh() {
initial_secret
} else if credential.tokens.refresh_token.is_none() {
return Err(ResolveError::RefreshTokenMissing(provider_id.clone()));
}
let refreshed = refresh_oauth_credential(&initial_credential)
} else {
let refreshed = refresh_oauth_credential(credential)
.await
.map_err(|source| ResolveError::RefreshFailed {
provider: provider_id.clone(),
source,
})?;
let refreshed_for_store = refreshed.clone();
let vault_name_for_store = vault_name.clone();
let vault = Arc::clone(&self.vault);
spawn_blocking(move || {
let mut vault = vault.blocking_write();
vault_set_oauth(&mut vault, &vault_name_for_store, &refreshed_for_store)
.map(|_| ())
.map_err(anyhow::Error::from)
})
.await
.map_err(|join_err| ResolveError::RefreshFailed {
provider: provider_id.clone(),
source: anyhow::Error::from(join_err),
})?
.map_err(|source| ResolveError::RefreshFailed {
provider: provider_id.clone(),
source,
})?;
let credential_id =
credential_id_for(&refreshed).map_err(|message| ResolveError::RefreshFailed {
provider: provider_id.clone(),
source: anyhow::anyhow!(message),
})?;
let refreshed_for_store = refreshed.clone();
let vault = Arc::clone(&self.vault);
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: provider_id.clone(),
source: anyhow::Error::from(join_err),
})?
.map_err(|source| ResolveError::RefreshFailed {
provider: provider_id.clone(),
source,
})?;
refreshed
ResolvedSecret::OAuth {
credential: Box::new(refreshed),
vault_name: vault_name.clone(),
}
}
} else {
initial_credential
initial_secret
};
let vault = self.vault.read().await;
match usage {
CredentialUsage::ApiRequest => self
.to_api_credential(&vault, &credential, catalog)
.to_api_credential(&vault, &provider_id, &secret, catalog)
.map(ResolvedCredential::Api),
CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli(
Self::to_cli_credential(&credential, kind, catalog),
Self::to_cli_credential(&provider_id, &secret, kind, catalog),
)),
}
}
@ -234,24 +266,14 @@ impl CredentialResolver {
&self,
vault: &Vault,
provider: &CatalogProvider,
usage: CredentialUsage,
) -> Result<AuthCredential, ResolveError> {
if provider.id == ProviderId::openai()
&& usage == CredentialUsage::CliAgent(CliAgentKind::Codex)
{
for credential_id in ["openai_codex", "openai"] {
if let Some(credential) = vault_get_credential(vault, credential_id) {
return Ok(credential);
}
}
}
) -> Result<ResolvedSecret, ResolveError> {
let Some(auth) = &provider.auth else {
return Err(ResolveError::NotConfigured(provider.id.clone()));
};
for credential_ref in &auth.credentials {
if let Some(credential) = self.credential_from_ref(vault, &provider.id, credential_ref)
if let Some(credential) =
self.credential_from_ref(vault, &provider.id, credential_ref)?
{
return Ok(credential);
}
@ -273,7 +295,7 @@ impl CredentialResolver {
};
auth.credentials.iter().any(|credential_ref| {
self.credential_from_ref(vault, &provider.id, credential_ref)
.is_some()
.is_ok_and(|credential| credential.is_some())
})
}
@ -282,21 +304,39 @@ impl CredentialResolver {
vault: &Vault,
provider: &ProviderId,
credential_ref: &CredentialRef,
) -> Option<AuthCredential> {
) -> Result<Option<ResolvedSecret>, ResolveError> {
match credential_ref {
CredentialRef::Credential(id) => vault_get_credential(vault, id),
CredentialRef::Env(name) => {
self.lookup_env_or_vault(vault, name)
.map(|key| AuthCredential {
CredentialRef::Vault(name) => {
let Some(entry) = vault.get_entry(name) else {
return Ok(None);
};
match entry.secret_type {
SecretType::Token => Ok(Some(ResolvedSecret::ApiKey(entry.value.clone()))),
SecretType::Oauth => serde_json::from_str(&entry.value)
.map(|credential| {
Some(ResolvedSecret::OAuth {
credential: Box::new(credential),
vault_name: name.clone(),
})
})
.map_err(|source| ResolveError::VaultDecodeFailed {
provider: provider.clone(),
name: name.clone(),
source,
}),
SecretType::File => Err(ResolveError::VaultSchemaMismatch {
provider: provider.clone(),
details: AuthDetails::ApiKey { key },
})
name: name.clone(),
actual: SecretType::File,
}),
}
}
CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)),
}
}
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 lookup_env(&self, name: &str) -> Option<String> {
(self.env_lookup)(name)
}
fn provider_base_url_for_catalog(provider: &ProviderId, catalog: &Catalog) -> Option<String> {
@ -320,7 +360,7 @@ impl CredentialResolver {
.map(|(name, value_ref)| {
let value = match value_ref {
HeaderValueRef::Literal(value) => Some(value.clone()),
HeaderValueRef::Env(name) => self.lookup_env_or_vault(vault, name),
HeaderValueRef::Env(name) => self.lookup_env(name),
HeaderValueRef::Credential(name) => vault.get(name).map(str::to_string),
}
.ok_or_else(|| ResolveError::NotConfigured(provider.clone()))?;
@ -332,18 +372,19 @@ impl CredentialResolver {
fn to_api_credential(
&self,
vault: &Vault,
credential: &AuthCredential,
provider_id: &ProviderId,
secret: &ResolvedSecret,
catalog: &Catalog,
) -> Result<ApiCredential, ResolveError> {
let base_url = Self::provider_base_url_for_catalog(&credential.provider, catalog);
match &credential.details {
AuthDetails::ApiKey { key } => {
let base_url = Self::provider_base_url_for_catalog(provider_id, catalog);
match secret {
ResolvedSecret::ApiKey(key) => {
let provider = catalog
.provider(&credential.provider)
.ok_or_else(|| ResolveError::NotConfigured(credential.provider.clone()))?;
.provider(provider_id)
.ok_or_else(|| ResolveError::NotConfigured(provider_id.clone()))?;
let auth_header = auth_header_for_catalog_provider(provider, key.clone())?;
let mut cred = ApiCredential {
provider: credential.provider.clone(),
provider: provider_id.clone(),
auth_header: Some(auth_header),
extra_headers: HashMap::new(),
base_url: None,
@ -353,30 +394,41 @@ impl CredentialResolver {
};
cred.base_url = base_url;
cred.extra_headers =
self.resolved_extra_headers_for_catalog(vault, &credential.provider, catalog)?;
if credential.provider == ProviderId::openai() {
cred.org_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_ORG_ID);
cred.project_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_PROJECT_ID);
self.resolved_extra_headers_for_catalog(vault, provider_id, catalog)?;
if provider_id == &ProviderId::openai() {
cred.org_id = self.lookup_env(EnvVars::OPENAI_ORG_ID);
cred.project_id = self.lookup_env(EnvVars::OPENAI_PROJECT_ID);
}
Ok(cred)
}
AuthDetails::CodexOAuth {
tokens, account_id, ..
} => {
let mut extra_headers = HashMap::new();
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());
ResolvedSecret::OAuth { credential, .. } => {
let mut extra_headers =
self.resolved_extra_headers_for_catalog(vault, provider_id, catalog)?;
let mut api_credential = ApiCredential {
provider: provider_id.clone(),
auth_header: Some(ApiKeyHeader::Bearer(credential.tokens.access_token.clone())),
extra_headers: std::mem::take(&mut extra_headers),
base_url,
codex_mode: false,
org_id: None,
project_id: None,
};
if provider_id == &ProviderId::openai() {
if let Some(account_id) = &credential.account_id {
api_credential
.extra_headers
.insert("ChatGPT-Account-Id".to_string(), account_id.clone());
}
api_credential
.extra_headers
.insert("originator".to_string(), "fabro".to_string());
api_credential.base_url =
Some("https://chatgpt.com/backend-api/codex".to_string());
api_credential.codex_mode = true;
api_credential.org_id = self.lookup_env(EnvVars::OPENAI_ORG_ID);
api_credential.project_id = self.lookup_env(EnvVars::OPENAI_PROJECT_ID);
}
Ok(ApiCredential {
provider: credential.provider.clone(),
auth_header: Some(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, EnvVars::OPENAI_ORG_ID),
project_id: self.lookup_env_or_vault(vault, EnvVars::OPENAI_PROJECT_ID),
})
Ok(api_credential)
}
}
}
@ -404,44 +456,38 @@ impl CredentialResolver {
}
fn to_cli_credential(
credential: &AuthCredential,
provider_id: &ProviderId,
secret: &ResolvedSecret,
kind: CliAgentKind,
catalog: &Catalog,
) -> CliCredential {
let mut env_vars = HashMap::new();
let is_openai = credential.provider == ProviderId::openai();
let login_command = match (is_openai, &credential.details, kind) {
(true, AuthDetails::ApiKey { key }, CliAgentKind::Codex) => {
let is_openai = provider_id == &ProviderId::openai();
let login_command = match (is_openai, secret, kind) {
(true, ResolvedSecret::ApiKey(key), CliAgentKind::Codex) => {
env_vars.insert(EnvVars::OPENAI_API_KEY.to_string(), key.clone());
Some(codex_login_command(key))
}
(
true,
AuthDetails::CodexOAuth {
tokens, account_id, ..
},
CliAgentKind::Codex,
) => {
(true, ResolvedSecret::OAuth { credential, .. }, CliAgentKind::Codex) => {
env_vars.insert(
EnvVars::OPENAI_API_KEY.to_string(),
tokens.access_token.clone(),
credential.tokens.access_token.clone(),
);
if let Some(account_id) = account_id {
if let Some(account_id) = &credential.account_id {
env_vars.insert(EnvVars::CHATGPT_ACCOUNT_ID.to_string(), account_id.clone());
}
Some(codex_login_command(&tokens.access_token))
Some(codex_login_command(&credential.tokens.access_token))
}
(_, AuthDetails::ApiKey { key }, _) => {
if let Some(name) = primary_api_key_env_var(&credential.provider, catalog) {
(_, ResolvedSecret::ApiKey(key), _) => {
if let Some(name) = primary_api_key_env_var(provider_id, catalog) {
env_vars.insert(name.to_string(), key.clone());
}
None
}
(_, AuthDetails::CodexOAuth { tokens, .. }, _) => {
env_vars.insert(
EnvVars::OPENAI_API_KEY.to_string(),
tokens.access_token.clone(),
);
(_, ResolvedSecret::OAuth { credential, .. }, _) => {
if let Some(name) = primary_api_key_env_var(provider_id, catalog) {
env_vars.insert(name.to_string(), credential.tokens.access_token.clone());
}
None
}
};
@ -470,7 +516,6 @@ pub async fn configured_providers_from_process_env(
}
}
}
fn primary_api_key_env_var<'a>(provider: &ProviderId, catalog: &'a Catalog) -> Option<&'a str> {
catalog
.provider(provider)?
@ -480,7 +525,7 @@ fn primary_api_key_env_var<'a>(provider: &ProviderId, catalog: &'a Catalog) -> O
.iter()
.find_map(|credential_ref| match credential_ref {
CredentialRef::Env(name) => Some(name.as_str()),
CredentialRef::Credential(_) => None,
CredentialRef::Vault(_) => None,
})
}
@ -503,37 +548,25 @@ mod tests {
use httpmock::MockServer;
use super::*;
use crate::credential::{OAuthConfig, OAuthTokens};
use crate::vault_ext::vault_get_credential;
use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens};
use crate::vault_ext::{vault_get_oauth, vault_set_oauth, vault_set_token};
fn api_key_credential(provider: ProviderId, key: &str) -> AuthCredential {
AuthCredential {
provider,
details: AuthDetails::ApiKey {
key: key.to_string(),
fn oauth_credential(token_url: String, expires_at: chrono::DateTime<Utc>) -> OAuthCredential {
OAuthCredential {
tokens: OAuthTokens {
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at,
},
}
}
fn oauth_credential(token_url: String, expires_at: chrono::DateTime<Utc>) -> AuthCredential {
AuthCredential {
provider: ProviderId::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()),
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()),
}
}
@ -551,16 +584,14 @@ mod tests {
}
#[tokio::test]
async fn resolve_openai_api_request_prefers_typed_credential() {
async fn resolve_openai_api_request_prefers_env_when_listed_first() {
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(ProviderId::openai(), "vault-key"),
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| Some("env-key".to_string())));
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
let resolver = test_resolver(
vault,
Arc::new(|name| (name == "OPENAI_API_KEY").then(|| "env-key".to_string())),
);
let catalog = default_catalog();
let resolved = resolver
@ -573,7 +604,7 @@ mod tests {
};
assert_eq!(
api.auth_header,
Some(ApiKeyHeader::Bearer("vault-key".to_string()))
Some(ApiKeyHeader::Bearer("env-key".to_string()))
);
}
@ -581,9 +612,9 @@ mod tests {
async fn resolve_openai_api_request_falls_back_to_codex_oauth_credential() {
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_credential(
vault_set_oauth(
&mut vault,
"openai_codex",
"OPENAI_CODEX",
&oauth_credential(
"https://auth.openai.com/oauth/token".to_string(),
Utc::now() + Duration::hours(1),
@ -638,12 +669,7 @@ mod tests {
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(ProviderId::anthropic(), "anthropic-key"),
)
.unwrap();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
@ -679,7 +705,7 @@ agent_profile = "openai"
base_url = "https://default.example.com/v1"
[providers.acme.auth]
credentials = ["credential:acme"]
credentials = ["vault:acme"]
[models."compat-model"]
provider = "acme"
@ -698,12 +724,7 @@ reasoning = false
);
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_credential(
&mut vault,
"acme",
&api_key_credential(ProviderId::new("acme"), "compat-key"),
)
.unwrap();
vault_set_token(&mut vault, "acme", "compat-key").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let resolved = resolver
.resolve(
@ -731,9 +752,9 @@ reasoning = false
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(
vault_set_oauth(
&mut vault,
"openai_codex",
"OPENAI_CODEX",
&oauth_credential(
"https://auth.openai.com/oauth/token".to_string(),
Utc::now() + Duration::hours(1),
@ -774,12 +795,7 @@ reasoning = false
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(ProviderId::openai(), "openai-key"),
)
.unwrap();
vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
@ -826,12 +842,7 @@ reasoning = false
std::fs::set_permissions(&codex_path, permissions).unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_credential(
&mut vault,
"openai",
&api_key_credential(ProviderId::openai(), "openai-key"),
)
.unwrap();
vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();
@ -876,23 +887,22 @@ reasoning = false
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(ProviderId::openai(), "vault-key"),
)
.unwrap();
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
vault
.set(
"OPENAI_ORG_ID",
"vault-org",
fabro_vault::SecretType::Environment,
fabro_vault::SecretType::Token,
None,
)
.unwrap();
let resolver = test_resolver(
vault,
Arc::new(|name| (name == "OPENAI_ORG_ID").then(|| "env-org".to_string())),
Arc::new(|name| match name {
"OPENAI_API_KEY" => Some("env-key".to_string()),
"OPENAI_ORG_ID" => Some("env-org".to_string()),
_ => None,
}),
);
let catalog = default_catalog();
@ -911,12 +921,7 @@ reasoning = false
async fn configured_providers_returns_vault_backed_provider() {
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(ProviderId::openai(), "vault-key"),
)
.unwrap();
vault_set_token(&mut vault, "OPENAI_API_KEY", "vault-key").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let vault = resolver.vault.read().await;
let catalog = default_catalog();
@ -937,7 +942,7 @@ agent_profile = "openai"
base_url = "https://api.acme.test/v1"
[providers.acme.auth]
credentials = ["credential:acme"]
credentials = ["vault:acme"]
[models."acme-large"]
provider = "acme"
@ -956,13 +961,7 @@ reasoning = false
);
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_credential(&mut vault, "acme", &AuthCredential {
provider: ProviderId::new("acme"),
details: AuthDetails::ApiKey {
key: "acme-key".to_string(),
},
})
.unwrap();
vault_set_token(&mut vault, "acme", "acme-key").unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let resolved = resolver
@ -1027,9 +1026,9 @@ reasoning = false
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_credential(
vault_set_oauth(
&mut vault,
"openai_codex",
"OPENAI_CODEX",
&oauth_credential(
server.url("/oauth/token"),
Utc::now() - Duration::minutes(1),
@ -1059,17 +1058,11 @@ reasoning = false
let stored = {
let vault = vault.read().await;
vault_get_credential(&vault, "openai_codex").unwrap()
vault_get_oauth(&vault, "OPENAI_CODEX").unwrap().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"));
assert_eq!(stored.tokens.access_token, "new-access");
assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh"));
assert_eq!(stored.account_id.as_deref(), Some("acct_123"));
refresh_mock.assert_async().await;
}
@ -1081,11 +1074,8 @@ reasoning = false
"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();
credential.tokens.refresh_token = None;
vault_set_oauth(&mut vault, "OPENAI_CODEX", &credential).unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();

View file

@ -3,8 +3,7 @@ use fabro_model::catalog::CatalogProvider;
use fabro_model::{CredentialRef, ProviderId};
use crate::context::{AuthContextRequest, AuthContextResponse};
use crate::credential::{AuthCredential, AuthDetails};
use crate::strategy::AuthStrategy;
use crate::strategy::{AuthStrategy, LoginResult};
pub struct ApiKeyStrategy {
provider_id: ProviderId,
@ -24,7 +23,7 @@ impl ApiKeyStrategy {
.iter()
.filter_map(|credential_ref| match credential_ref {
CredentialRef::Env(name) => Some(name.clone()),
CredentialRef::Credential(_) => None,
CredentialRef::Vault(_) => None,
})
.collect()
})
@ -49,11 +48,11 @@ impl AuthStrategy for ApiKeyStrategy {
})
}
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<AuthCredential> {
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<LoginResult> {
match response {
AuthContextResponse::ApiKey { key } => Ok(AuthCredential {
AuthContextResponse::ApiKey { key } => Ok(LoginResult::ApiKey {
provider: self.provider_id.clone(),
details: AuthDetails::ApiKey { key },
key,
}),
AuthContextResponse::DeviceCodeConfirmed => {
Err(anyhow::anyhow!("expected API key response"))

View file

@ -10,10 +10,8 @@ use serde_json::json;
use tokio::time::sleep;
use crate::context::{AuthContextRequest, AuthContextResponse};
use crate::credential::{
AuthCredential, AuthDetails, OAuthConfig, OAuthTokens, expires_at_from_now,
};
use crate::strategy::AuthStrategy;
use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens, expires_at_from_now};
use crate::strategy::{AuthStrategy, LoginResult};
const DEVICE_AUTH_POLL_INTERVAL: Duration = Duration::from_secs(2);
const CODEX_DEVICE_VERIFICATION_URI: &str = "https://auth.openai.com/codex/device";
@ -276,7 +274,7 @@ impl AuthStrategy for CodexDeviceStrategy {
})
}
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<AuthCredential> {
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<LoginResult> {
match response {
AuthContextResponse::ApiKey { .. } => Err(anyhow::anyhow!(
"expected device code confirmation response"
@ -299,9 +297,9 @@ impl AuthStrategy for CodexDeviceStrategy {
.await
.map_err(anyhow::Error::msg)?;
Ok(AuthCredential {
provider: fabro_model::ProviderId::openai(),
details: AuthDetails::CodexOAuth {
Ok(LoginResult::OAuth {
provider: fabro_model::ProviderId::openai(),
credential: OAuthCredential {
tokens: OAuthTokens {
access_token: token_response.access_token,
refresh_token: token_response.refresh_token,
@ -536,14 +534,14 @@ mod tests {
assert!(pending_poll_mock.calls_async().await > 0);
pending_poll_mock.delete_async().await;
let credential = complete.await.unwrap().unwrap();
let result = complete.await.unwrap().unwrap();
let AuthDetails::CodexOAuth {
tokens, account_id, ..
} = credential.details
else {
let LoginResult::OAuth { credential, .. } = result else {
panic!("expected codex oauth credential");
};
let OAuthCredential {
tokens, account_id, ..
} = credential;
assert_eq!(tokens.access_token, "new-access-token");
assert_eq!(tokens.refresh_token.as_deref(), Some("new-refresh-token"));
assert_eq!(account_id.as_deref(), Some("acct_123"));

View file

@ -2,7 +2,7 @@ use async_trait::async_trait;
use fabro_model::{Catalog, ProviderId};
use crate::context::{AuthContextRequest, AuthContextResponse};
use crate::credential::{AuthCredential, OAuthConfig};
use crate::credential::{OAuthConfig, OAuthCredential};
use crate::strategies::api_key::ApiKeyStrategy;
use crate::strategies::codex_device::CodexDeviceStrategy;
@ -10,10 +10,22 @@ 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";
#[derive(Debug, Clone)]
pub enum LoginResult {
ApiKey {
provider: ProviderId,
key: String,
},
OAuth {
provider: ProviderId,
credential: OAuthCredential,
},
}
#[async_trait]
pub trait AuthStrategy: Send {
async fn init(&mut self) -> anyhow::Result<AuthContextRequest>;
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<AuthCredential>;
async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result<LoginResult>;
}
#[derive(Debug, Clone, PartialEq, Eq)]

View file

@ -1,105 +1,148 @@
use fabro_model::ProviderId;
use fabro_types::SecretMetadata;
use fabro_vault::{SecretType, Vault};
use fabro_vault::{Error as VaultError, SecretType, Vault};
use crate::credential::AuthCredential;
use crate::credential::OAuthCredential;
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)
#[derive(Debug, thiserror::Error)]
pub enum VaultLookupError {
#[error("vault entry '{name}' has schema {actual:?}, expected {expected:?}")]
SchemaMismatch {
name: String,
expected: SecretType,
actual: SecretType,
},
#[error("vault entry '{name}' is not valid {expected:?} JSON: {source}")]
DecodeFailed {
name: String,
expected: SecretType,
#[source]
source: serde_json::Error,
},
}
#[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;
pub fn vault_get_token(vault: &Vault, name: &str) -> Result<Option<String>, VaultLookupError> {
let Some(entry) = vault.get_entry(name) else {
return Ok(None);
};
if entry.secret_type != SecretType::Token {
return Err(VaultLookupError::SchemaMismatch {
name: name.to_string(),
expected: SecretType::Token,
actual: entry.secret_type,
});
}
serde_json::from_str(&entry.value).ok()
Ok(Some(entry.value.clone()))
}
#[must_use]
pub fn vault_credentials_for_provider(
pub fn vault_get_oauth(
vault: &Vault,
provider: impl Into<ProviderId>,
) -> Vec<(String, AuthCredential)> {
let provider = provider.into();
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))
name: &str,
) -> Result<Option<OAuthCredential>, VaultLookupError> {
let Some(entry) = vault.get_entry(name) else {
return Ok(None);
};
if entry.secret_type != SecretType::Oauth {
return Err(VaultLookupError::SchemaMismatch {
name: name.to_string(),
expected: SecretType::Oauth,
actual: entry.secret_type,
});
}
serde_json::from_str(&entry.value)
.map(Some)
.map_err(|source| VaultLookupError::DecodeFailed {
name: name.to_string(),
expected: SecretType::Oauth,
source,
})
.collect()
}
pub fn vault_set_token(
vault: &mut Vault,
name: &str,
value: &str,
) -> Result<SecretMetadata, VaultError> {
vault.set(name, value, SecretType::Token, None)
}
pub fn vault_set_oauth(
vault: &mut Vault,
name: &str,
credential: &OAuthCredential,
) -> Result<SecretMetadata, VaultError> {
let json = serde_json::to_string(credential)?;
vault.set(name, &json, SecretType::Oauth, None)
}
#[cfg(test)]
mod tests {
use chrono::{Duration, Utc};
use fabro_model::ProviderId;
use super::*;
use crate::credential::{AuthDetails, OAuthConfig, OAuthTokens};
use crate::credential::{OAuthConfig, OAuthTokens};
fn oauth_credential() -> AuthCredential {
AuthCredential {
provider: ProviderId::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()),
fn temp_vault() -> Vault {
let dir = tempfile::tempdir().unwrap();
Vault::load(dir.path().join("secrets.json")).unwrap()
}
fn fixture() -> OAuthCredential {
OAuthCredential {
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: None,
use_pkce: true,
},
account_id: None,
}
}
#[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
fn vault_get_token_returns_none_when_absent() {
let vault = temp_vault();
assert!(
vault_get_token(&vault, "ANTHROPIC_API_KEY")
.unwrap()
.is_none()
);
}
#[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: ProviderId::anthropic(),
details: AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
},
})
.unwrap();
fn vault_get_token_returns_value_when_present() {
let mut vault = temp_vault();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "sk-test").unwrap();
assert_eq!(
vault_get_token(&vault, "ANTHROPIC_API_KEY")
.unwrap()
.as_deref(),
Some("sk-test"),
);
}
let credentials = vault_credentials_for_provider(&vault, ProviderId::openai());
#[test]
fn vault_get_token_errors_on_oauth_entry() {
let mut vault = temp_vault();
vault_set_oauth(&mut vault, "OPENAI_CODEX", &fixture()).unwrap();
let err = vault_get_token(&vault, "OPENAI_CODEX").unwrap_err();
assert!(matches!(err, VaultLookupError::SchemaMismatch { .. }));
}
assert_eq!(credentials.len(), 1);
assert_eq!(credentials[0].0, "openai_codex");
#[test]
fn vault_get_oauth_round_trips() {
let mut vault = temp_vault();
let credential = fixture();
vault_set_oauth(&mut vault, "OPENAI_CODEX", &credential).unwrap();
assert_eq!(
vault_get_oauth(&vault, "OPENAI_CODEX").unwrap().unwrap(),
credential,
);
}
}

View file

@ -76,41 +76,30 @@ mod tests {
use chrono::{Duration, Utc};
use fabro_model::{Catalog, ProviderId};
use fabro_vault::{SecretType, Vault};
use fabro_vault::Vault;
use tokio::sync::RwLock as AsyncRwLock;
use super::VaultCredentialSource;
use crate::credential::{AuthCredential, AuthDetails, OAuthConfig, OAuthTokens};
use crate::credential::{OAuthConfig, OAuthCredential, OAuthTokens};
use crate::vault_ext::{vault_set_oauth, vault_set_token};
use crate::{CredentialSource, ResolveError};
fn api_key_credential(provider: ProviderId, key: &str) -> AuthCredential {
AuthCredential {
provider,
details: AuthDetails::ApiKey {
key: key.to_string(),
fn expired_openai_credential() -> OAuthCredential {
OAuthCredential {
tokens: OAuthTokens {
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Utc::now() - Duration::hours(1),
},
}
}
fn expired_openai_credential() -> AuthCredential {
AuthCredential {
provider: ProviderId::openai(),
details: AuthDetails::CodexOAuth {
tokens: OAuthTokens {
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Utc::now() - Duration::hours(1),
},
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url: "http://127.0.0.1:9/oauth/token".to_string(),
client_id: "client".to_string(),
scopes: vec!["openid".to_string()],
redirect_uri: Some("https://example.com/callback".to_string()),
use_pkce: true,
},
account_id: Some("acct_123".to_string()),
config: OAuthConfig {
auth_url: "https://auth.openai.com".to_string(),
token_url: "http://127.0.0.1:9/oauth/token".to_string(),
client_id: "client".to_string(),
scopes: vec!["openid".to_string()],
redirect_uri: Some("https://example.com/callback".to_string()),
use_pkce: true,
},
account_id: Some("acct_123".to_string()),
}
}
@ -122,26 +111,8 @@ mod tests {
async fn resolve_returns_credentials_and_auth_issues() {
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault
.set(
"openai_codex",
&serde_json::to_string(&expired_openai_credential()).unwrap(),
SecretType::Credential,
None,
)
.unwrap();
vault
.set(
"anthropic",
&serde_json::to_string(&api_key_credential(
ProviderId::anthropic(),
"anthropic-key",
))
.unwrap(),
SecretType::Credential,
None,
)
.unwrap();
vault_set_oauth(&mut vault, "OPENAI_CODEX", &expired_openai_credential()).unwrap();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
let source =
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
@ -165,27 +136,8 @@ mod tests {
async fn configured_providers_reads_from_vault_without_refreshing() {
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault
.set(
"openai",
&serde_json::to_string(&api_key_credential(ProviderId::openai(), "openai-key"))
.unwrap(),
SecretType::Credential,
None,
)
.unwrap();
vault
.set(
"anthropic",
&serde_json::to_string(&api_key_credential(
ProviderId::anthropic(),
"anthropic-key",
))
.unwrap(),
SecretType::Credential,
None,
)
.unwrap();
vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
let source =
VaultCredentialSource::with_env_lookup(Arc::new(AsyncRwLock::new(vault)), |_| None);
let catalog = default_catalog();

View file

@ -654,7 +654,7 @@ pub(crate) struct SecretRmArgs {
#[derive(Clone, Copy, Debug, ValueEnum)]
pub(crate) enum SecretTypeArg {
Environment,
Token,
File,
}
@ -668,7 +668,7 @@ pub(crate) struct SecretSetArgs {
#[arg(long, conflicts_with = "value")]
pub(crate) value_stdin: bool,
/// Secret storage type
#[arg(long, value_enum, default_value = "environment")]
#[arg(long, value_enum, default_value = "token")]
pub(crate) r#type: SecretTypeArg,
/// Optional human-readable description
#[arg(long)]

View file

@ -21,7 +21,7 @@ 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};
use fabro_auth::{AuthMethod, LoginResult, codex_oauth_config};
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget};
use fabro_config::bind::Bind;
use fabro_config::daemon::ServerDaemon;
@ -98,7 +98,7 @@ fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
.iter()
.filter_map(|credential| match credential {
CredentialRef::Env(name) => Some(name.as_str()),
CredentialRef::Credential(_) => None,
CredentialRef::Vault(_) => None,
})
.collect::<Vec<_>>()
.join(" / ")
@ -107,6 +107,21 @@ fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
.unwrap_or_else(|| "API_KEY".to_string())
}
fn provider_vault_secret_name(provider: &ProviderId, catalog: &Catalog) -> String {
catalog
.provider(provider)
.and_then(|provider| provider.auth.as_ref())
.and_then(|auth| {
auth.credentials
.iter()
.find_map(|credential| match credential {
CredentialRef::Vault(name) => Some(name.clone()),
CredentialRef::Env(_) => None,
})
})
.unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase()))
}
// ---------------------------------------------------------------------------
// Auth status display
// ---------------------------------------------------------------------------
@ -307,7 +322,7 @@ struct InstallFacts {
#[derive(Debug)]
struct LlmInstallSelection {
credentials: Vec<AuthCredential>,
credentials: Vec<LoginResult>,
}
#[derive(Debug)]
@ -1212,13 +1227,21 @@ async fn persist_vault_secrets_with(
result
}
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 credential_secret_request(result: &LoginResult) -> Result<CreateSecretRequest> {
match result {
LoginResult::ApiKey { provider, key } => Ok(CreateSecretRequest {
name: provider_vault_secret_name(provider, &INSTALL_CATALOG),
value: key.clone(),
type_: ApiSecretType::Token,
description: None,
}),
LoginResult::OAuth { credential, .. } => Ok(CreateSecretRequest {
name: "OPENAI_CODEX".to_string(),
value: serde_json::to_string(credential)?,
type_: ApiSecretType::Oauth,
description: None,
}),
}
}
fn server_env_updates(secrets: &[(String, String)]) -> Vec<envfile::EnvFileUpdate> {
@ -1322,7 +1345,7 @@ fn persist_github_install_changes(
}
for (key, value) in &writes.vault_set {
vault
.set(key, value, VaultSecretType::Environment, None)
.set(key, value, VaultSecretType::Token, None)
.map_err(anyhow::Error::from)?;
}
@ -1758,7 +1781,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
vault_secrets.push(CreateSecretRequest {
name: "GITHUB_TOKEN".to_string(),
value: token,
type_: ApiSecretType::Environment,
type_: ApiSecretType::Token,
description: None,
});
Some(PendingGitHubSettings::Token)
@ -2542,14 +2565,12 @@ client_id = "client-id"
CreateSecretRequest {
name: "GITHUB_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
type_: ApiSecretType::Token,
description: None,
},
credential_secret_request(&AuthCredential {
credential_secret_request(&LoginResult::ApiKey {
provider: ProviderId::anthropic(),
details: fabro_auth::AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
},
key: "anthropic-key".to_string(),
})
.unwrap(),
];
@ -2562,7 +2583,7 @@ client_id = "client-id"
.body(
serde_json::json!({
"name": "persisted",
"type": "environment",
"type": "token",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
})
@ -2611,7 +2632,7 @@ client_id = "client-id"
let vault_secrets = [CreateSecretRequest {
name: "GITHUB_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
type_: ApiSecretType::Token,
description: None,
}];
let server = MockServer::start_async().await;
@ -2623,7 +2644,7 @@ client_id = "client-id"
.body(
serde_json::json!({
"name": "persisted",
"type": "environment",
"type": "token",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
})
@ -2822,7 +2843,7 @@ client_id = "client-id"
let vault_secrets = [CreateSecretRequest {
name: "GITHUB_CLI_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
type_: ApiSecretType::Token,
description: None,
}];
let settings_path = dir.path().join(SETTINGS_CONFIG_FILENAME);
@ -2870,7 +2891,7 @@ client_id = "client-id"
let vault_secrets = [CreateSecretRequest {
name: "GITHUB_CLI_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
type_: ApiSecretType::Token,
description: None,
}];
let settings_path = dir.path().join(SETTINGS_CONFIG_FILENAME);
@ -2955,7 +2976,7 @@ client_id = "client-id"
vault
.get_entry(GITHUB_TOKEN_SECRET_KEY)
.map(|entry| entry.secret_type),
Some(VaultSecretType::Environment)
Some(VaultSecretType::Token)
);
assert_eq!(std::fs::read_to_string(&settings_path).unwrap(), "after");
}
@ -2976,7 +2997,7 @@ client_id = "client-id"
.set(
GITHUB_TOKEN_SECRET_KEY,
"token",
VaultSecretType::Environment,
VaultSecretType::Token,
None,
)
.unwrap();

View file

@ -1,6 +1,7 @@
use anyhow::Result;
use fabro_api::types;
use fabro_auth::credential_id_for;
use fabro_auth::LoginResult;
use fabro_model::{Catalog, CredentialRef, ProviderId};
use fabro_util::terminal::Styles;
use crate::args::ProviderLoginArgs;
@ -16,7 +17,7 @@ pub(super) async fn login_command(
let s = Styles::detect_stderr();
let ctx = base_ctx.with_target(&args.target)?;
let server = ctx.server().await?;
let credential = if args.api_key_stdin {
let result = if args.api_key_stdin {
provider_auth::authenticate_provider_with_api_key_source_and_catalog(
args.provider,
provider_auth::ApiKeySource::Stdin,
@ -34,22 +35,42 @@ pub(super) async fn login_command(
)
.await?
};
let credential_id = credential_id_for(&credential).map_err(anyhow::Error::msg)?;
let value = serde_json::to_string(&credential)?;
let (name, value, type_) = match result {
LoginResult::ApiKey { provider, key } => {
let name = api_key_secret_name(&provider, ctx.catalog()?.as_ref());
(name, key, types::SecretType::Token)
}
LoginResult::OAuth { credential, .. } => (
"OPENAI_CODEX".to_string(),
serde_json::to_string(&credential)?,
types::SecretType::Oauth,
),
};
server
.create_secret(types::CreateSecretRequest {
name: credential_id.clone(),
name: name.clone(),
value,
type_: types::SecretType::Credential,
type_,
description: None,
})
.await?;
fabro_util::printerr!(
printer,
" {} Saved {}",
s.green.apply_to(""),
credential_id
);
fabro_util::printerr!(printer, " {} Saved {}", s.green.apply_to(""), name);
Ok(())
}
fn api_key_secret_name(provider: &ProviderId, catalog: &Catalog) -> String {
catalog
.provider(provider)
.and_then(|provider| provider.auth.as_ref())
.and_then(|auth| {
auth.credentials
.iter()
.find_map(|credential_ref| match credential_ref {
CredentialRef::Vault(name) => Some(name.clone()),
CredentialRef::Env(_) => None,
})
})
.unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase()))
}

View file

@ -650,10 +650,8 @@ mod tests {
use std::sync::Arc;
use chrono::Utc;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::Storage;
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question};
use fabro_model::ProviderId;
use fabro_types::run_event::{
InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps,
RunFailedProps, RunStatusTransitionProps,
@ -981,23 +979,12 @@ mod tests {
let storage = Storage::new(temp.path());
let mut vault = Vault::load(storage.secrets_path()).unwrap();
vault
.set(
"anthropic",
&serde_json::to_string(&AuthCredential {
provider: ProviderId::anthropic(),
details: AuthDetails::ApiKey {
key: "vault-key".to_string(),
},
})
.unwrap(),
SecretType::Credential,
None,
)
.set("ANTHROPIC_API_KEY", "vault-key", SecretType::Token, None)
.unwrap();
let loaded = load_worker_vault(Some(temp.path())).unwrap().unwrap();
let guard = loaded.read().await;
let credential = guard.get("anthropic").unwrap();
let credential = guard.get("ANTHROPIC_API_KEY").unwrap();
assert!(credential.contains("vault-key"));
}

View file

@ -20,7 +20,7 @@ use crate::shared::provider_auth::prompt_password;
fn api_secret_type(secret_type: SecretTypeArg) -> types::SecretType {
match secret_type {
SecretTypeArg::Environment => types::SecretType::Environment,
SecretTypeArg::Token => types::SecretType::Token,
SecretTypeArg::File => types::SecretType::File,
}
}

View file

@ -15,7 +15,7 @@ use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Password};
use fabro_auth::{
ApiCredential, AuthContextRequest, AuthContextResponse, AuthCredential, AuthMethod,
ApiCredential, AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult,
codex_oauth_config, strategy_for,
};
use fabro_llm::client::Client as LlmClient;
@ -207,7 +207,7 @@ pub(crate) async fn authenticate_provider(
provider: ProviderId,
s: &Styles,
printer: Printer,
) -> Result<AuthCredential> {
) -> Result<LoginResult> {
authenticate_provider_with_catalog(provider, s, printer, default_catalog_for_provider_auth()?)
.await
}
@ -217,7 +217,7 @@ pub(crate) async fn authenticate_provider_with_catalog(
s: &Styles,
printer: Printer,
catalog: Arc<Catalog>,
) -> Result<AuthCredential> {
) -> Result<LoginResult> {
api_key_catalog_provider(&provider, catalog.as_ref())?;
let method = pick_auth_method(&provider).await?;
authenticate_provider_with_method_and_catalog(provider, method, s, printer, catalog).await
@ -228,7 +228,7 @@ pub(crate) async fn authenticate_provider_with_api_key_source(
source: ApiKeySource,
s: &Styles,
printer: Printer,
) -> Result<AuthCredential> {
) -> Result<LoginResult> {
authenticate_provider_with_api_key_source_and_catalog(
provider,
source,
@ -245,7 +245,7 @@ pub(crate) async fn authenticate_provider_with_api_key_source_and_catalog(
s: &Styles,
printer: Printer,
catalog: Arc<Catalog>,
) -> Result<AuthCredential> {
) -> Result<LoginResult> {
api_key_catalog_provider(&provider, catalog.as_ref())?;
let mut strategy = strategy_for(&provider, AuthMethod::ApiKey, catalog.as_ref());
let request = strategy.init().await?;
@ -259,7 +259,7 @@ pub(crate) async fn authenticate_provider_with_method(
method: AuthMethod,
s: &Styles,
printer: Printer,
) -> Result<AuthCredential> {
) -> Result<LoginResult> {
authenticate_provider_with_method_and_catalog(
provider,
method,
@ -276,7 +276,7 @@ pub(crate) async fn authenticate_provider_with_method_and_catalog(
s: &Styles,
printer: Printer,
catalog: Arc<Catalog>,
) -> Result<AuthCredential> {
) -> Result<LoginResult> {
api_key_catalog_provider(&provider, catalog.as_ref())?;
let mut strategy = strategy_for(&provider, method, catalog.as_ref());
let request = strategy.init().await?;

View file

@ -5,9 +5,7 @@
use std::process::Output;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::Storage;
use fabro_model::ProviderId;
use fabro_test::{fabro_snapshot, test_context, twin_openai};
use fabro_vault::{SecretType, Vault};
@ -24,26 +22,12 @@ fn toml_path(path: &std::path::Path) -> String {
.replace('"', "\\\"")
}
fn seed_openai_vault(storage_dir: &std::path::Path, base_url: &str, api_key: &str) {
fn seed_openai_vault(storage_dir: &std::path::Path, api_key: &str) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"openai",
&serde_json::to_string(&AuthCredential {
provider: ProviderId::openai(),
details: AuthDetails::ApiKey {
key: api_key.to_string(),
},
})
.expect("OpenAI test credential should serialize"),
SecretType::Credential,
None,
)
.set("OPENAI_API_KEY", api_key, SecretType::Token, None)
.expect("OpenAI credential should store in test vault");
vault
.set("OPENAI_BASE_URL", base_url, SecretType::Environment, None)
.expect("OpenAI base URL should store in test vault");
}
#[test]
@ -116,7 +100,7 @@ strategy = "app"
toml_path(&storage_dir)
),
);
seed_openai_vault(&storage_dir, &twin.base_url, &namespace);
seed_openai_vault(&storage_dir, &namespace);
context.isolated_server();
let mut cmd = context.doctor();

View file

@ -451,6 +451,6 @@ mode = "keep-me"
vault
.get_entry("GITHUB_TOKEN")
.map(|entry| entry.secret_type),
Some(SecretType::Environment)
Some(SecretType::Token)
);
}

View file

@ -3,9 +3,7 @@
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::Storage;
use fabro_model::ProviderId;
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
use fabro_vault::{SecretType, Vault};
use httpmock::MockServer;
@ -89,15 +87,9 @@ fn seed_anthropic_vault(storage_dir: &std::path::Path) {
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"anthropic",
&serde_json::to_string(&AuthCredential {
provider: ProviderId::anthropic(),
details: AuthDetails::ApiKey {
key: "vault-anthropic-key".to_string(),
},
})
.expect("Anthropic test credential should serialize"),
SecretType::Credential,
"ANTHROPIC_API_KEY",
"vault-anthropic-key",
SecretType::Token,
None,
)
.expect("Anthropic credential should store in test vault");

View file

@ -52,7 +52,7 @@ fn test_secret_lifecycle() {
secret(&["list"])
.success()
.stdout(predicates::str::contains("FOO"))
.stdout(predicates::str::contains("environment"));
.stdout(predicates::str::contains("token"));
// 3. update FOO
secret(&["set", "FOO", "updated"]).success();
@ -96,7 +96,7 @@ fn test_secret_list_alias_ls() {
.assert()
.success()
.stdout(predicates::str::contains("X"))
.stdout(predicates::str::contains("environment"));
.stdout(predicates::str::contains("token"));
}
#[test]
@ -129,7 +129,7 @@ fn test_secret_value_with_equals() {
.assert()
.success()
.stdout(predicates::str::contains("URL"))
.stdout(predicates::str::contains("environment"))
.stdout(predicates::str::contains("token"))
.stdout(predicates::str::contains("https://x.com?a=1&b=2").not());
}

View file

@ -47,7 +47,7 @@ fn secret_list_json_returns_metadata_only() {
.iter()
.find(|entry| entry["name"] == "ANTHROPIC_API_KEY")
.expect("secret list should include the saved key");
assert_eq!(entry["type"], "environment");
assert_eq!(entry["type"], "token");
assert!(entry.get("updated_at").is_some());
assert!(entry.get("value").is_none());
}

View file

@ -21,7 +21,7 @@ fn help() {
--json Output as JSON [env: FABRO_JSON=]
--value-stdin Read the secret value from stdin
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--type <TYPE> Secret storage type [default: environment] [possible values: environment, file]
--type <TYPE> Secret storage type [default: token] [possible values: token, file]
--description <DESCRIPTION> Optional human-readable description
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]

View file

@ -4,9 +4,7 @@
)]
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::Storage;
use fabro_model::ProviderId;
use fabro_test::test_context;
use fabro_types::EventBody;
use fabro_vault::{SecretType, Vault};
@ -161,18 +159,7 @@ fn seed_openai_vault(storage_dir: &std::path::Path) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"openai",
&serde_json::to_string(&AuthCredential {
provider: ProviderId::openai(),
details: AuthDetails::ApiKey {
key: "test-openai-key".to_string(),
},
})
.expect("OpenAI test credential should serialize"),
SecretType::Credential,
None,
)
.set("OPENAI_API_KEY", "test-openai-key", SecretType::Token, None)
.expect("OpenAI credential should store in test vault");
}

View file

@ -11,9 +11,7 @@
use std::process::Output;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::Storage;
use fabro_model::ProviderId;
use fabro_test::{
TestMode, TwinOpenAi, TwinScenario, TwinScenarios, TwinToolCall, test_context, twin_openai,
};
@ -93,34 +91,20 @@ fn write_hook_settings(context: &fabro_test::TestContext, hook: &str) {
context.write_home(".fabro/settings.toml", settings);
}
fn seed_openai_vault(storage_dir: &std::path::Path, base_url: &str, api_key: &str) {
fn seed_openai_vault(storage_dir: &std::path::Path, api_key: &str) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"openai",
&serde_json::to_string(&AuthCredential {
provider: ProviderId::openai(),
details: AuthDetails::ApiKey {
key: api_key.to_string(),
},
})
.expect("OpenAI test credential should serialize"),
SecretType::Credential,
None,
)
.set("OPENAI_API_KEY", api_key, SecretType::Token, None)
.expect("OpenAI credential should store in test vault");
vault
.set("OPENAI_BASE_URL", base_url, SecretType::Environment, None)
.expect("OpenAI base URL should store in test vault");
}
fn configure_twin_server(
context: &mut fabro_test::TestContext,
twin: &TwinOpenAi,
_twin: &TwinOpenAi,
namespace: &str,
) {
seed_openai_vault(&twin_server_storage_dir(context), &twin.base_url, namespace);
seed_openai_vault(&twin_server_storage_dir(context), namespace);
context.isolated_server();
}

View file

@ -7,7 +7,7 @@
//! display_name = "Kimi"
//! adapter = "openai_compatible"
//! base_url = "https://api.moonshot.ai/v1"
//! auth = { credentials = ["credential:kimi", "env:KIMI_API_KEY"] }
//! auth = { credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] }
//! priority = 60
//! enabled = true
//! aliases = ["moonshot"]
@ -212,9 +212,9 @@ mod tests {
// ---- CredentialRef ----------------------------------------------------
#[test]
fn credential_ref_parses_credential_form() {
let r = CredentialRef::from_str("credential:openai_codex").unwrap();
assert_eq!(r, CredentialRef::Credential("openai_codex".to_string()));
fn credential_ref_parses_vault_form() {
let r = CredentialRef::from_str("vault:OPENAI_CODEX").unwrap();
assert_eq!(r, CredentialRef::Vault("OPENAI_CODEX".to_string()));
}
#[test]
@ -225,7 +225,7 @@ mod tests {
#[test]
fn credential_ref_rejects_literal_secret() {
// A literal API key contains no `credential:` or `env:` prefix.
// A literal API key contains no `vault:` or `env:` prefix.
let err = CredentialRef::from_str("sk-ant-1234").unwrap_err();
assert!(err.to_string().contains("must be"));
assert!(
@ -235,8 +235,8 @@ mod tests {
}
#[test]
fn credential_ref_rejects_empty_credential_id() {
let err = CredentialRef::from_str("credential:").unwrap_err();
fn credential_ref_rejects_empty_vault_name() {
let err = CredentialRef::from_str("vault:").unwrap_err();
assert!(err.to_string().contains("missing"));
}
@ -248,8 +248,8 @@ mod tests {
#[test]
fn credential_ref_round_trips_through_string() {
let r = CredentialRef::Credential("kimi".to_string());
assert_eq!(r.to_string(), "credential:kimi");
let r = CredentialRef::Vault("kimi".to_string());
assert_eq!(r.to_string(), "vault:kimi");
let back: CredentialRef = r.to_string().parse().unwrap();
assert_eq!(back, r);
}
@ -263,7 +263,7 @@ mod tests {
#[test]
fn credential_ref_deserializes_from_toml_string() {
let parsed: CredentialRef = toml::from_str(r#"v = "credential:foo""#)
let parsed: CredentialRef = toml::from_str(r#"v = "vault:foo""#)
.map(|v: toml::Value| {
v.as_table()
.unwrap()
@ -274,7 +274,7 @@ mod tests {
.unwrap()
})
.unwrap();
assert_eq!(parsed, CredentialRef::Credential("foo".to_string()));
assert_eq!(parsed, CredentialRef::Vault("foo".to_string()));
}
#[test]
@ -384,7 +384,7 @@ agent_profile = "gemini"
parsed,
HeaderValueRef::Credential("portkey_config".to_string())
);
assert_eq!(parsed.to_string(), "credential:portkey_config");
assert_eq!(parsed.to_string(), "vault:portkey_config");
}
#[test]
@ -479,7 +479,7 @@ enabled = true
aliases = ["moonshot"]
[providers.kimi.auth]
credentials = ["credential:kimi", "env:KIMI_API_KEY"]
credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"]
"#;
let layer: LlmLayer = toml::from_str(toml).unwrap();
let kimi = layer.providers.get("kimi").unwrap();
@ -489,8 +489,8 @@ credentials = ["credential:kimi", "env:KIMI_API_KEY"]
let auth = kimi.auth.as_ref().expect("expected api_key auth");
assert_eq!(auth.header, ApiKeyHeaderPolicy::Bearer);
assert_eq!(auth.credentials, vec![
CredentialRef::Credential("kimi".to_string()),
CredentialRef::Env("KIMI_API_KEY".to_string()),
CredentialRef::Vault("KIMI_API_KEY".to_string()),
]);
assert_eq!(kimi.base_url.as_deref(), Some("https://api.moonshot.ai/v1"));
assert_eq!(kimi.priority, Some(60));
@ -753,7 +753,7 @@ mystery = 1
let low = ProviderSettings {
auth: Some(ProviderAuthConfig {
credentials: vec![
CredentialRef::Credential("bar".to_string()),
CredentialRef::Vault("bar".to_string()),
CredentialRef::Env("BAZ".to_string()),
],
header: ApiKeyHeaderPolicy::Custom {

View file

@ -243,7 +243,7 @@ x-team-secret = { credential = "gateway_team_secret" }
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | derived from `adapter` | Provider-owned billing algorithm for usage estimates. Override for exceptional providers such as local no-billing runtimes. |
| `base_url` | string | built-in value or adapter runtime default | Provider API base URL. Required for most custom OpenAI-compatible providers. |
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `credential:<id>` and `env:<NAME>`. Literal secret strings are rejected. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>` and `env:<NAME>`. Literal secret strings are rejected. |
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ credential = "id" }`. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |

View file

@ -665,12 +665,7 @@ name = "custom"
let vault_path = storage.secrets_path();
let mut vault = Vault::load(vault_path.clone()).unwrap();
vault
.set(
"EXISTING_SECRET",
"keep",
VaultSecretType::Environment,
None,
)
.set("EXISTING_SECRET", "keep", VaultSecretType::Token, None)
.unwrap();
let result = persist_install_outputs_direct(
@ -684,7 +679,7 @@ name = "custom"
&[VaultSecretWrite {
name: "bad-secret-name".to_string(),
value: "boom".to_string(),
secret_type: VaultSecretType::Environment,
secret_type: VaultSecretType::Token,
description: None,
}],
Some(&PendingSettingsWrite {

View file

@ -154,14 +154,14 @@ pub struct CostRates {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub enum CredentialRef {
Credential(String),
Vault(String),
Env(String),
}
impl std::fmt::Display for CredentialRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Credential(id) => write!(f, "credential:{id}"),
Self::Vault(name) => write!(f, "vault:{name}"),
Self::Env(name) => write!(f, "env:{name}"),
}
}
@ -177,11 +177,11 @@ impl FromStr for CredentialRef {
type Err = CredentialRefParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if let Some(id) = value.strip_prefix("credential:") {
if id.is_empty() {
return Err(CredentialRefParseError::EmptyCredential);
if let Some(name) = value.strip_prefix("vault:") {
if name.is_empty() {
return Err(CredentialRefParseError::EmptyVault);
}
return Ok(Self::Credential(id.to_string()));
return Ok(Self::Vault(name.to_string()));
}
if let Some(name) = value.strip_prefix("env:") {
if name.is_empty() {
@ -203,10 +203,10 @@ impl TryFrom<String> for CredentialRef {
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum CredentialRefParseError {
#[error("credential reference must be `credential:<id>` or `env:<NAME>`")]
#[error("credential reference must be `vault:<name>` or `env:<NAME>`")]
Invalid,
#[error("credential reference is missing an ID after `credential:`")]
EmptyCredential,
#[error("credential reference is missing a name after `vault:`")]
EmptyVault,
#[error("credential reference is missing a name after `env:`")]
EmptyEnv,
}
@ -338,7 +338,7 @@ impl std::fmt::Display for HeaderValueRef {
match self {
Self::Literal(_) => f.write_str("literal:<redacted>"),
Self::Env(name) => write!(f, "env:{name}"),
Self::Credential(id) => write!(f, "credential:{id}"),
Self::Credential(id) => write!(f, "vault:{id}"),
}
}
}
@ -2713,7 +2713,7 @@ display_name = "Bearer"
adapter = "openai"
[providers.bearer.auth]
credentials = ["credential:bearer", "env:BEARER_API_KEY"]
credentials = ["env:BEARER_API_KEY", "vault:BEARER_API_KEY"]
[providers.custom]
display_name = "Custom"
@ -2744,8 +2744,8 @@ billing_policy = "none"
bearer.auth,
Some(ProviderAuthConfig {
credentials: vec![
CredentialRef::Credential("bearer".to_string()),
CredentialRef::Env("BEARER_API_KEY".to_string()),
CredentialRef::Vault("BEARER_API_KEY".to_string()),
],
header: ApiKeyHeaderPolicy::Bearer,
})

View file

@ -6,7 +6,7 @@ base_url = "https://api.anthropic.com/v1"
priority = 100
[providers.anthropic.auth]
credentials = ["credential:anthropic", "env:ANTHROPIC_API_KEY"]
credentials = ["env:ANTHROPIC_API_KEY", "vault:ANTHROPIC_API_KEY"]
header = { custom = "x-api-key" }
[models."claude-opus-4-7"]

View file

@ -6,7 +6,7 @@ base_url = "https://generativelanguage.googleapis.com/v1beta"
priority = 80
[providers.gemini.auth]
credentials = ["credential:gemini", "env:GEMINI_API_KEY", "env:GOOGLE_API_KEY"]
credentials = ["env:GEMINI_API_KEY", "env:GOOGLE_API_KEY", "vault:GEMINI_API_KEY"]
header = { custom = "x-goog-api-key" }
[models."gemini-3.1-pro-preview"]

View file

@ -6,7 +6,7 @@ base_url = "https://api.inceptionlabs.ai/v1"
priority = 40
[providers.inception.auth]
credentials = ["credential:inception", "env:INCEPTION_API_KEY"]
credentials = ["env:INCEPTION_API_KEY", "vault:INCEPTION_API_KEY"]
[models."mercury-2"]
provider = "inception"

View file

@ -6,7 +6,7 @@ base_url = "https://api.moonshot.ai/v1"
priority = 70
[providers.kimi.auth]
credentials = ["credential:kimi", "env:KIMI_API_KEY"]
credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"]
[models."kimi-k2.5"]
provider = "kimi"

View file

@ -6,7 +6,7 @@ priority = 50
enabled = false
[providers.litellm.auth]
credentials = ["credential:litellm", "env:LITELLM_API_KEY"]
credentials = ["env:LITELLM_API_KEY", "vault:LITELLM_API_KEY"]
# To enable LiteLLM, add entries like these to settings.toml:
#

View file

@ -6,7 +6,7 @@ base_url = "https://api.minimax.io/v1"
priority = 50
[providers.minimax.auth]
credentials = ["credential:minimax", "env:MINIMAX_API_KEY"]
credentials = ["env:MINIMAX_API_KEY", "vault:MINIMAX_API_KEY"]
[models."minimax-m2.5"]
provider = "minimax"

View file

@ -6,7 +6,7 @@ base_url = "https://api.openai.com/v1"
priority = 90
[providers.openai.auth]
credentials = ["credential:openai", "credential:openai_codex", "env:OPENAI_API_KEY"]
credentials = ["env:OPENAI_API_KEY", "vault:OPENAI_API_KEY", "vault:OPENAI_CODEX"]
[models."gpt-5.2"]
provider = "openai"

View file

@ -6,7 +6,7 @@ priority = 35
aliases = ["venice-ai"]
[providers.venice.auth]
credentials = ["credential:venice", "env:VENICE_API_KEY"]
credentials = ["env:VENICE_API_KEY", "vault:VENICE_API_KEY"]
[models."venice-uncensored-1-2"]
provider = "venice"

View file

@ -6,7 +6,7 @@ base_url = "https://api.z.ai/api/coding/paas/v4"
priority = 60
[providers.zai.auth]
credentials = ["credential:zai", "env:ZAI_API_KEY"]
credentials = ["env:ZAI_API_KEY", "vault:ZAI_API_KEY"]
[models."glm-4.7"]
provider = "zai"

View file

@ -592,13 +592,13 @@ pub(crate) async fn list_secrets(
"data": [
{
"name": "OPENAI_API_KEY",
"type": "environment",
"type": "token",
"created_at": "2026-04-05T12:00:00Z",
"updated_at": "2026-04-05T12:00:00Z"
},
{
"name": "GITHUB_APP_PRIVATE_KEY",
"type": "environment",
"type": "token",
"created_at": "2026-04-05T12:05:00Z",
"updated_at": "2026-04-05T12:05:00Z"
}

View file

@ -681,7 +681,6 @@ fn check_crypto(state: &AppState) -> CheckResult {
#[cfg(test)]
mod tests {
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::RunLayer;
use fabro_vault::SecretType;
use httpmock::Method::POST;
@ -731,20 +730,14 @@ mod tests {
.max_concurrent_runs(5)
.provider_base_url("openai", server.url("/v1"))
.build();
let credential = AuthCredential {
provider: ProviderId::openai(),
details: AuthDetails::ApiKey {
key: "vault-openai-key".to_string(),
},
};
state
.vault
.write()
.await
.set(
"openai_codex",
&serde_json::to_string(&credential).unwrap(),
SecretType::Credential,
"OPENAI_API_KEY",
"vault-openai-key",
SecretType::Token,
None,
)
.unwrap();

View file

@ -13,7 +13,6 @@ use axum::routing::{get, post, put};
use axum::{Json, Router, middleware};
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD};
use fabro_auth::{AuthCredential, AuthDetails, credential_id_for};
use fabro_config::Storage;
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::envfile::EnvFileUpdate;
@ -26,7 +25,7 @@ use fabro_install::{
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, ProviderId};
use fabro_model::{Catalog, CredentialRef, ProviderId};
use fabro_sandbox::daytona;
use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
@ -838,6 +837,22 @@ fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogPro
}
}
fn provider_secret_name(provider: &ProviderId) -> Result<String, String> {
let catalog_provider = install_catalog_provider(provider)?;
catalog_provider
.auth
.as_ref()
.and_then(|auth| {
auth.credentials
.iter()
.find_map(|credential_ref| match credential_ref {
CredentialRef::Vault(name) => Some(name.clone()),
CredentialRef::Env(_) => None,
})
})
.ok_or_else(|| format!("provider '{provider}' does not define a vault credential path"))
}
async fn put_install_server(
State(state): State<InstallAppState>,
headers: HeaderMap,
@ -1530,31 +1545,19 @@ async fn post_install_finish(
vault_secrets.push(VaultSecretWrite {
name: EnvVars::DAYTONA_API_KEY.to_string(),
value: api_key.expose_secret().to_string(),
secret_type: VaultSecretType::Environment,
secret_type: VaultSecretType::Token,
description: None,
});
}
for provider in llm.providers {
let credential = AuthCredential {
provider: provider.provider,
details: AuthDetails::ApiKey {
key: provider.api_key,
},
};
let name = match credential_id_for(&credential) {
let name = match provider_secret_name(&provider.provider) {
Ok(name) => name,
Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err),
};
let value = match serde_json::to_string(&credential) {
Ok(value) => value,
Err(err) => {
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
};
vault_secrets.push(VaultSecretWrite {
name,
value,
secret_type: VaultSecretType::Credential,
value: provider.api_key,
secret_type: VaultSecretType::Token,
description: None,
});
}
@ -1575,7 +1578,7 @@ async fn post_install_finish(
vault_secrets.push(VaultSecretWrite {
name: EnvVars::GITHUB_TOKEN.to_string(),
value: github.token,
secret_type: VaultSecretType::Environment,
secret_type: VaultSecretType::Token,
description: None,
});
let dev_token_path = Storage::new(state.storage_dir.as_ref())

View file

@ -40,6 +40,7 @@ mod startup;
pub mod static_files;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
mod vault_legacy_migration;
pub mod web_auth;
mod worker_token;

View file

@ -2331,15 +2331,9 @@ provider = "daytona"
.write()
.await
.set(
"openai",
&serde_json::to_string(&fabro_auth::AuthCredential {
provider: ProviderId::openai(),
details: fabro_auth::AuthDetails::ApiKey {
key: "test-openai-key".to_string(),
},
})
.unwrap(),
fabro_vault::SecretType::Credential,
"OPENAI_API_KEY",
"test-openai-key",
fabro_vault::SecretType::Token,
None,
)
.unwrap();

View file

@ -40,9 +40,7 @@ pub use fabro_api::types::{
SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, VncPreviewResponse,
WriteBlobResponse,
};
use fabro_auth::{
CredentialSource, VaultCredentialSource, auth_issue_message, parse_credential_secret,
};
use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message};
#[cfg(test)]
use fabro_config::RunSettingsBuilder;
use fabro_config::daemon::ServerDaemon;
@ -139,7 +137,8 @@ use crate::server_secrets::{LlmClientResult, ServerSecrets};
use crate::spawn_env::{apply_render_graph_env, apply_worker_env};
use crate::worker_token::{WorkerTokenKeys, issue_worker_token};
use crate::{
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files,
vault_legacy_migration, web_auth,
};
mod handler;
@ -1558,7 +1557,41 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
shutdown,
} = config;
let vault = Arc::new(AsyncRwLock::new(Vault::load(vault_path)?));
match vault_legacy_migration::migrate_legacy_vault_file(&vault_path) {
Ok(report) if report.changed() => {
let backup_path = report
.backup_path
.as_ref()
.map_or_else(|| "<none>".to_string(), |path| path.display().to_string());
warn!(
migrated_entries = report.migrated_entries,
skipped_entries = report.skipped_entries,
backup_path = %backup_path,
removal_deadline = vault_legacy_migration::REMOVAL_DEADLINE,
"Migrated legacy vault file"
);
}
Ok(_) => {}
Err(err) => {
warn!(
error = %err,
removal_deadline = vault_legacy_migration::REMOVAL_DEADLINE,
"Legacy vault migration failed; continuing with normal vault load"
);
}
}
let vault = match Vault::load(vault_path.clone()) {
Ok(vault) => vault,
Err(err) => {
warn!(
error = %err,
removal_deadline = vault_legacy_migration::REMOVAL_DEADLINE,
"Vault load failed after legacy migration; continuing with empty vault"
);
Vault::empty(vault_path)
}
};
let vault = Arc::new(AsyncRwLock::new(vault));
let llm_source: Arc<dyn CredentialSource> = Arc::new(VaultCredentialSource::with_env_lookup(
Arc::clone(&vault),
{

View file

@ -1,11 +1,11 @@
use std::sync::Arc;
use fabro_auth::OAuthCredential;
use fabro_static::EnvVars;
use super::super::{
ApiError, AppState, CreateSecretRequest, DeleteSecretRequest, IntoResponse, Json, RequiredUser,
Response, Router, SecretType, State, StatusCode, VaultError, get, parse_credential_secret,
spawn_blocking,
Response, Router, SecretType, State, StatusCode, VaultError, get, spawn_blocking,
};
pub(super) fn routes() -> Router<Arc<AppState>> {
@ -31,12 +31,13 @@ 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();
if secret_type == SecretType::Oauth {
if let Err(err) = serde_json::from_str::<OAuthCredential>(&value) {
return ApiError::bad_request(format!("invalid oauth credential JSON: {err}"))
.into_response();
}
}
if secret_type == SecretType::Environment && name == EnvVars::DAYTONA_API_KEY {
if secret_type == SecretType::Token && name == EnvVars::DAYTONA_API_KEY {
match state.check_daytona_api_key(value.clone()).await {
Ok(check) if check.ok() => {}
Ok(check) => {

View file

@ -11,7 +11,6 @@ use axum::body::Body;
use axum::http::{Method, Request, header};
use axum::response::sse::{Event as SseEvent, Sse};
use chrono::{Duration as ChronoDuration, Utc};
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::ServerSettingsBuilder;
use fabro_config::bind::Bind;
use fabro_interview::{
@ -223,15 +222,29 @@ async fn mock_daytona_current_key<'a>(
.await
}
fn openai_api_key_credential(key: &str) -> AuthCredential {
AuthCredential {
provider: ProviderId::openai(),
details: AuthDetails::ApiKey {
key: key.to_string(),
fn openai_oauth_credential() -> fabro_auth::OAuthCredential {
fabro_auth::OAuthCredential {
tokens: fabro_auth::OAuthTokens {
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
expires_at: Utc::now() + ChronoDuration::hours(1),
},
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()),
}
}
fn openai_oauth_credential_json() -> String {
serde_json::to_string(&openai_oauth_credential()).unwrap()
}
fn openai_responses_payload(text: &str) -> serde_json::Value {
json!({
"id": "resp_1",
@ -982,7 +995,7 @@ fn clone_sandbox_credentials_are_available_for_clone_based_providers() {
}
#[tokio::test]
async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() {
async fn create_secret_stores_file_secret_outside_token_lookups() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let req = Request::builder()
@ -1007,7 +1020,10 @@ async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() {
assert_eq!(body["description"], "Test certificate");
let vault = state.vault.read().await;
assert!(!vault.snapshot().contains_key("/tmp/test.pem"));
assert_eq!(
vault.get_entry("/tmp/test.pem").unwrap().secret_type,
SecretType::File
);
assert_eq!(vault.file_secrets(), vec![(
"/tmp/test.pem".to_string(),
"pem-data".to_string()
@ -1083,30 +1099,9 @@ async fn github_webhook_accepts_valid_signature_with_wrong_bearer_token() {
}
#[tokio::test]
async fn create_secret_stores_valid_credential_entries() {
async fn create_secret_stores_valid_oauth_entries() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let credential = fabro_auth::AuthCredential {
provider: ProviderId::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")
@ -1114,9 +1109,9 @@ async fn create_secret_stores_valid_credential_entries() {
.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"
"name": "OPENAI_CODEX",
"value": openai_oauth_credential_json(),
"type": "oauth"
}))
.unwrap(),
))
@ -1126,9 +1121,9 @@ async fn create_secret_stores_valid_credential_entries() {
assert_status!(response, StatusCode::OK).await;
let listed = state.vault.read().await.list();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "openai_codex");
assert_eq!(listed[0].secret_type, SecretType::Credential);
assert!(state.vault.read().await.get("openai_codex").is_some());
assert_eq!(listed[0].name, "OPENAI_CODEX");
assert_eq!(listed[0].secret_type, SecretType::Oauth);
assert!(state.vault.read().await.get("OPENAI_CODEX").is_some());
}
#[tokio::test]
@ -1158,7 +1153,7 @@ async fn create_secret_rejects_under_scoped_daytona_api_key_and_leaves_vault_unc
.set(
EnvVars::DAYTONA_API_KEY,
"existing",
SecretType::Environment,
SecretType::Token,
None,
)
.unwrap();
@ -1172,7 +1167,7 @@ async fn create_secret_rejects_under_scoped_daytona_api_key_and_leaves_vault_unc
serde_json::to_string(&serde_json::json!({
"name": EnvVars::DAYTONA_API_KEY,
"value": "dtn_test",
"type": "environment"
"type": "token"
}))
.unwrap(),
))
@ -1222,7 +1217,7 @@ async fn diagnostics_reports_under_scoped_daytona_api_key() {
.set(
EnvVars::DAYTONA_API_KEY,
"dtn_test",
SecretType::Environment,
SecretType::Token,
None,
)
.unwrap();
@ -1257,7 +1252,7 @@ async fn diagnostics_reports_under_scoped_daytona_api_key() {
}
#[tokio::test]
async fn resolve_llm_client_reads_openai_codex_credential_from_vault() {
async fn resolve_llm_client_reads_openai_token_from_vault() {
let state = test_app_state_with_env_lookup(
default_test_server_settings(),
RunLayer::default(),
@ -1269,9 +1264,9 @@ async fn resolve_llm_client_reads_openai_codex_credential_from_vault() {
.write()
.await
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("vault-openai-key")).unwrap(),
SecretType::Credential,
"OPENAI_API_KEY",
"vault-openai-key",
SecretType::Token,
None,
)
.unwrap();
@ -1325,7 +1320,7 @@ async fn resolve_llm_client_from_source_preserves_credential_source_chain() {
}
#[tokio::test]
async fn llm_source_configured_providers_reads_openai_codex_from_vault() {
async fn llm_source_configured_providers_reads_openai_token_from_vault() {
let state = test_app_state_with_env_lookup(
default_test_server_settings(),
RunLayer::default(),
@ -1337,9 +1332,9 @@ async fn llm_source_configured_providers_reads_openai_codex_from_vault() {
.write()
.await
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("vault-openai-key")).unwrap(),
SecretType::Credential,
"OPENAI_API_KEY",
"vault-openai-key",
SecretType::Token,
None,
)
.unwrap();
@ -1382,9 +1377,9 @@ async fn resolve_llm_client_uses_env_lookup_for_openai_settings() {
.write()
.await
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("vault-openai-key")).unwrap(),
SecretType::Credential,
"OPENAI_API_KEY",
"vault-openai-key",
SecretType::Token,
None,
)
.unwrap();
@ -1416,15 +1411,15 @@ async fn resolve_llm_client_uses_env_lookup_for_openai_settings() {
}
#[tokio::test]
async fn list_secrets_includes_credential_metadata() {
async fn list_secrets_includes_oauth_metadata() {
let state = test_app_state();
{
let mut vault = state.vault.write().await;
vault
.set(
"anthropic",
"{\"provider\":\"anthropic\"}",
SecretType::Credential,
"OPENAI_CODEX",
&openai_oauth_credential_json(),
SecretType::Oauth,
Some("saved auth"),
)
.unwrap();
@ -1446,16 +1441,16 @@ async fn list_secrets_includes_credential_metadata() {
let data = body["data"].as_array().expect("data should be an array");
let entry = data
.iter()
.find(|entry| entry["name"] == "anthropic")
.expect("credential metadata should be listed");
assert_eq!(entry["type"], "credential");
.find(|entry| entry["name"] == "OPENAI_CODEX")
.expect("oauth metadata should be listed");
assert_eq!(entry["type"], "oauth");
assert_eq!(entry["description"], "saved auth");
assert!(entry.get("updated_at").is_some());
assert!(entry.get("value").is_none());
}
#[tokio::test]
async fn create_secret_rejects_invalid_credential_json() {
async fn create_secret_rejects_invalid_oauth_json() {
let state = test_app_state();
let app = crate::test_support::build_test_router(state);
@ -1465,9 +1460,9 @@ async fn create_secret_rejects_invalid_credential_json() {
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"name": "openai_codex",
"name": "OPENAI_CODEX",
"value": "{not-json",
"type": "credential"
"type": "oauth"
}))
.unwrap(),
))
@ -1478,7 +1473,7 @@ async fn create_secret_rejects_invalid_credential_json() {
}
#[tokio::test]
async fn create_secret_rejects_wrong_credential_name() {
async fn create_secret_rejects_invalid_oauth_name() {
let state = test_app_state();
let app = crate::test_support::build_test_router(state);
@ -1488,26 +1483,9 @@ async fn create_secret_rejects_wrong_credential_name() {
.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"
"name": "1OPENAI",
"value": openai_oauth_credential_json(),
"type": "oauth"
}))
.unwrap(),
))
@ -1901,6 +1879,135 @@ methods = ["dev-token"]
));
}
#[test]
fn build_app_state_migrates_legacy_vault_file_on_boot() {
let vault_path = test_secret_store_path();
let timestamp = "2026-05-18T12:00:00Z";
let legacy_api_key = json!({
"provider": "anthropic",
"type": "api_key",
"key": "sk-ant-legacy",
});
let legacy_oauth = json!({
"provider": "openai",
"type": "codex_oauth",
"tokens": {
"access_token": "codex-access",
"refresh_token": "codex-refresh",
"expires_at": "2026-05-18T13:00:00Z",
},
"config": {
"auth_url": "https://auth.openai.com",
"token_url": "https://auth.openai.com/oauth/token",
"client_id": "client",
"scopes": ["openid", "offline_access"],
"redirect_uri": "https://auth.openai.com/deviceauth/callback",
"use_pkce": false,
},
"account_id": "acct_legacy",
});
let legacy_vault = json!({
"anthropic": {
"value": legacy_api_key.to_string(),
"type": "credential",
"created_at": timestamp,
"updated_at": timestamp,
},
"openai_codex": {
"value": legacy_oauth.to_string(),
"type": "credential",
"created_at": timestamp,
"updated_at": timestamp,
},
"GITHUB_TOKEN": {
"value": "ghp_legacy",
"type": "environment",
"created_at": timestamp,
"updated_at": timestamp,
},
"/tmp/github.pem": {
"value": "/tmp/github.pem",
"type": "file",
"created_at": timestamp,
"updated_at": timestamp,
},
});
std::fs::write(
&vault_path,
serde_json::to_vec_pretty(&legacy_vault).unwrap(),
)
.expect("legacy vault should be writable");
let state = build_test_app_state_with_vault_path(&vault_path)
.expect("legacy vault should not prevent server boot");
let vault = state
.vault
.try_read()
.expect("test vault should not be locked");
let api_key_entry = vault
.get_entry("ANTHROPIC_API_KEY")
.expect("legacy provider credential should be migrated to token name");
assert_eq!(api_key_entry.secret_type, SecretType::Token);
assert_eq!(api_key_entry.value, "sk-ant-legacy");
assert!(vault.get_entry("anthropic").is_none());
let oauth_entry = vault
.get_entry("OPENAI_CODEX")
.expect("legacy Codex credential should be migrated to canonical OAuth name");
assert_eq!(oauth_entry.secret_type, SecretType::Oauth);
let oauth: fabro_auth::OAuthCredential =
serde_json::from_str(&oauth_entry.value).expect("migrated OAuth JSON should parse");
assert_eq!(oauth.tokens.access_token, "codex-access");
assert_eq!(oauth.account_id.as_deref(), Some("acct_legacy"));
assert!(vault.get_entry("openai_codex").is_none());
assert_eq!(
vault.get_entry("GITHUB_TOKEN").unwrap().secret_type,
SecretType::Token
);
assert_eq!(
vault.get_entry("/tmp/github.pem").unwrap().secret_type,
SecretType::File
);
}
#[test]
fn build_app_state_continues_with_empty_vault_when_legacy_migration_fails() {
let vault_path = test_secret_store_path();
std::fs::write(&vault_path, "{").expect("malformed legacy vault should be writable");
let state = build_test_app_state_with_vault_path(&vault_path)
.expect("legacy migration failure should not prevent server boot");
assert!(state.vault.try_read().unwrap().list().is_empty());
}
fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result<Arc<AppState>> {
let (store, artifact_store) = test_store_bundle();
build_app_state(AppStateConfig {
resolved_settings: resolved_runtime_settings_for_tests(
default_test_server_settings(),
RunLayer::default(),
LlmCatalogSettings::default(),
),
registry_factory_override: None,
max_concurrent_runs: 5,
store,
artifact_store,
vault_path: vault_path.to_path_buf(),
server_secrets: load_test_server_secrets(
vault_path.with_file_name("server.env"),
HashMap::new(),
),
env_lookup: default_env_lookup(),
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
shutdown: tokio_util::sync::CancellationToken::new(),
})
}
fn worker_command_test_state(
storage_dir: &Path,
methods: &[&str],
@ -2593,12 +2700,7 @@ methods = ["dev-token"]
.vault
.write()
.await
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("openai-key")).unwrap(),
SecretType::Credential,
None,
)
.set("OPENAI_API_KEY", "openai-key", SecretType::Token, None)
.unwrap();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let create_response = app
@ -2762,12 +2864,7 @@ methods = ["dev-token"]
.vault
.write()
.await
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("openai-key")).unwrap(),
SecretType::Credential,
None,
)
.set("OPENAI_API_KEY", "openai-key", SecretType::Token, None)
.unwrap();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let create_response = app
@ -4523,7 +4620,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
.vault
.try_write()
.expect("test vault should not already be locked")
.set("GITHUB_TOKEN", token, SecretType::Credential, None)
.set("GITHUB_TOKEN", token, SecretType::Token, None)
.expect("test github token should be writable");
}
state
@ -6210,12 +6307,7 @@ async fn create_run_pull_request_creates_and_persists_record() {
.vault
.write()
.await
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("openai-key")).unwrap(),
SecretType::Credential,
None,
)
.set("OPENAI_API_KEY", "openai-key", SecretType::Token, None)
.unwrap();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;

View file

@ -0,0 +1,275 @@
//! Temporary compatibility shim for pre-token/oauth vault files.
//!
//! Delete this module after 2026-08-18, once supported installs have had a
//! release window to rewrite `credential` / `environment` entries to the
//! `oauth` / `token` schemas.
#![expect(
clippy::disallowed_methods,
reason = "Temporary startup migration uses synchronous vault file I/O before serving requests."
)]
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use fabro_auth::{OAuthConfig, OAuthCredential, OAuthTokens};
use serde::Deserialize;
use serde_json::{Map, Value};
pub(crate) const REMOVAL_DEADLINE: &str = "2026-08-18";
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct LegacyVaultMigrationReport {
pub(crate) migrated_entries: usize,
pub(crate) skipped_entries: usize,
pub(crate) backup_path: Option<PathBuf>,
}
impl LegacyVaultMigrationReport {
pub(crate) fn changed(&self) -> bool {
self.migrated_entries > 0 || self.skipped_entries > 0
}
}
#[derive(Debug, Deserialize)]
struct LegacyAuthCredential {
provider: String,
#[serde(flatten)]
details: LegacyAuthDetails,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum LegacyAuthDetails {
ApiKey {
key: String,
},
CodexOauth {
tokens: OAuthTokens,
config: OAuthConfig,
#[serde(default)]
account_id: Option<String>,
},
}
pub(crate) fn migrate_legacy_vault_file(path: &Path) -> anyhow::Result<LegacyVaultMigrationReport> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(LegacyVaultMigrationReport::default());
}
Err(err) => return Err(err).with_context(|| format!("read vault {}", path.display())),
};
let entries = parse_vault_entries(&contents)?;
let (next_entries, migrated_entries, skipped_entries) = rewrite_entries(entries);
let mut report = LegacyVaultMigrationReport {
migrated_entries,
skipped_entries,
backup_path: None,
};
if !report.changed() {
return Ok(report);
}
let backup_path = backup_vault_file(path)?;
write_vault_entries(path, &next_entries)?;
report.backup_path = Some(backup_path);
Ok(report)
}
fn parse_vault_entries(contents: &str) -> anyhow::Result<Map<String, Value>> {
let value: Value = serde_json::from_str(contents).context("parse vault JSON")?;
match value {
Value::Object(entries) => Ok(entries),
_ => bail!("vault JSON root must be an object"),
}
}
fn rewrite_entries(entries: Map<String, Value>) -> (Map<String, Value>, usize, usize) {
let mut next_entries = Map::new();
let mut occupied = HashSet::new();
for (name, entry) in &entries {
if matches!(
entry.get("type").and_then(Value::as_str),
Some("token" | "oauth" | "file")
) {
next_entries.insert(name.clone(), entry.clone());
occupied.insert(name.clone());
}
}
let mut migrated_entries = 0;
let mut skipped_entries = 0;
for (name, entry) in entries {
match entry.get("type").and_then(Value::as_str) {
Some("token" | "oauth" | "file") => {}
Some("environment") => {
if insert_rewritten_entry(
&mut next_entries,
&mut occupied,
name,
rewrite_entry(entry, "token", None),
) {
migrated_entries += 1;
} else {
skipped_entries += 1;
}
}
Some("credential") => match legacy_credential_entry(&name, &entry) {
Some((target_name, rewritten)) => {
if insert_rewritten_entry(
&mut next_entries,
&mut occupied,
target_name,
rewritten,
) {
migrated_entries += 1;
} else {
skipped_entries += 1;
}
}
None => skipped_entries += 1,
},
_ => skipped_entries += 1,
}
}
(next_entries, migrated_entries, skipped_entries)
}
fn insert_rewritten_entry(
entries: &mut Map<String, Value>,
occupied: &mut HashSet<String>,
name: String,
entry: Value,
) -> bool {
if !occupied.insert(name.clone()) {
return false;
}
entries.insert(name, entry);
true
}
fn legacy_credential_entry(name: &str, entry: &Value) -> Option<(String, Value)> {
let value = entry.get("value").and_then(Value::as_str)?;
let credential: LegacyAuthCredential = serde_json::from_str(value).ok()?;
match credential.details {
LegacyAuthDetails::ApiKey { key } => {
let target_name = api_key_secret_name(&credential.provider)?;
Some((
target_name,
rewrite_entry(entry.clone(), "token", Some(key)),
))
}
LegacyAuthDetails::CodexOauth {
tokens,
config,
account_id,
} if credential.provider == "openai" && name == "openai_codex" => {
let credential = OAuthCredential {
tokens,
config,
account_id,
};
let value = serde_json::to_string(&credential).ok()?;
Some((
"OPENAI_CODEX".to_string(),
rewrite_entry(entry.clone(), "oauth", Some(value)),
))
}
LegacyAuthDetails::CodexOauth { .. } => None,
}
}
fn rewrite_entry(mut entry: Value, secret_type: &str, value: Option<String>) -> Value {
if let Value::Object(fields) = &mut entry {
fields.insert("type".to_string(), Value::String(secret_type.to_string()));
if let Some(value) = value {
fields.insert("value".to_string(), Value::String(value));
}
}
entry
}
fn api_key_secret_name(provider: &str) -> Option<String> {
let mut name = String::new();
for ch in provider.chars() {
if ch.is_ascii_alphanumeric() {
name.push(ch.to_ascii_uppercase());
} else if !name.ends_with('_') {
name.push('_');
}
}
while name.ends_with('_') {
name.pop();
}
if name.is_empty() {
return None;
}
if !name.ends_with("_API_KEY") {
name.push_str("_API_KEY");
}
Some(name)
}
fn backup_vault_file(path: &Path) -> anyhow::Result<PathBuf> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("secrets.json");
let backup_path = parent.join(format!(
".{file_name}.legacy-vault-migration-{}.bak",
ulid::Ulid::new()
));
std::fs::copy(path, &backup_path).with_context(|| {
format!(
"copy vault {} to backup {}",
path.display(),
backup_path.display()
)
})?;
set_private_permissions(&backup_path)?;
Ok(backup_path)
}
fn write_vault_entries(path: &Path, entries: &Map<String, Value>) -> anyhow::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)
.with_context(|| format!("create vault directory {}", parent.display()))?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("secrets.json");
let tmp_path = parent.join(format!(
".{file_name}.legacy-vault-migration-tmp-{}",
ulid::Ulid::new()
));
let json = serde_json::to_vec_pretty(entries).context("serialize migrated vault JSON")?;
std::fs::write(&tmp_path, json)
.with_context(|| format!("write migrated vault temp file {}", tmp_path.display()))?;
set_private_permissions(&tmp_path)?;
std::fs::rename(&tmp_path, path).with_context(|| {
format!(
"rename migrated vault temp file {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
#[cfg(unix)]
fn set_private_permissions(path: &Path) -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("set private permissions on {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn set_private_permissions(_path: &Path) -> anyhow::Result<()> {
Ok(())
}

View file

@ -941,7 +941,7 @@ async fn token_install_finish_persists_settings_env_and_vault() {
assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY="));
let vault = Vault::load(fabro_config::Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert!(vault.get("anthropic").is_some());
assert!(vault.get("ANTHROPIC_API_KEY").is_some());
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
}
@ -1028,8 +1028,8 @@ async fn browser_install_finish_with_skipped_llm_persists_no_llm_credentials() {
let vault = Vault::load(fabro_config::Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert!(
vault.credential_entries().is_empty(),
"skipped LLM install should not write any credential vault entries"
vault.get("OPENAI_API_KEY").is_none() && vault.get("OPENAI_CODEX").is_none(),
"skipped LLM install should not write any OpenAI vault entries"
);
assert_eq!(
vault.get("GITHUB_TOKEN"),

View file

@ -6,10 +6,13 @@ use strum::Display;
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum SecretType {
/// Opaque API-key/PAT-style token value.
#[default]
Environment,
Token,
/// JSON-encoded OAuth credential. Refreshable; never projected into env.
Oauth,
/// Path-shaped secret materialized to the filesystem.
File,
Credential,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -22,3 +25,29 @@ pub struct SecretMetadata {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_type_serializes_to_snake_case() {
assert_eq!(
serde_json::to_string(&SecretType::Token).unwrap(),
"\"token\""
);
assert_eq!(
serde_json::to_string(&SecretType::Oauth).unwrap(),
"\"oauth\""
);
assert_eq!(
serde_json::to_string(&SecretType::File).unwrap(),
"\"file\""
);
}
#[test]
fn secret_type_default_is_token() {
assert_eq!(SecretType::default(), SecretType::Token);
}
}

View file

@ -62,6 +62,13 @@ pub struct Vault {
}
impl Vault {
pub fn empty(path: PathBuf) -> Self {
Self {
path,
entries: HashMap::new(),
}
}
pub fn load(path: PathBuf) -> Result<Self, Error> {
let entries = match std::fs::read_to_string(&path) {
Ok(contents) => serde_json::from_str(&contents)?,
@ -144,25 +151,6 @@ impl Vault {
self.entries.get(name)
}
pub fn snapshot(&self) -> HashMap<String, String> {
self.entries
.iter()
.filter(|(_, entry)| entry.secret_type == SecretType::Environment)
.map(|(name, entry)| (name.clone(), entry.value.clone()))
.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
@ -176,7 +164,7 @@ impl Vault {
pub fn validate_name(name: &str, secret_type: SecretType) -> Result<(), Error> {
match secret_type {
SecretType::Environment | SecretType::Credential => Self::validate_env_name(name),
SecretType::Token | SecretType::Oauth => Self::validate_env_name(name),
SecretType::File => Self::validate_file_name(name),
}
}
@ -282,11 +270,11 @@ mod tests {
let mut store = Vault::load(path.clone()).unwrap();
let meta = store
.set("OPENAI_API_KEY", "secret", SecretType::Environment, None)
.set("OPENAI_API_KEY", "secret", SecretType::Token, None)
.unwrap();
assert_eq!(meta.name, "OPENAI_API_KEY");
assert_eq!(meta.secret_type, SecretType::Environment);
assert_eq!(meta.secret_type, SecretType::Token);
assert_eq!(store.get("OPENAI_API_KEY"), Some("secret"));
assert!(path.exists());
}
@ -298,10 +286,10 @@ mod tests {
let mut store = Vault::load(path).unwrap();
store
.set("OPENAI_API_KEY", "first", SecretType::Environment, None)
.set("OPENAI_API_KEY", "first", SecretType::Token, None)
.unwrap();
store
.set("OPENAI_API_KEY", "second", SecretType::Environment, None)
.set("OPENAI_API_KEY", "second", SecretType::Token, None)
.unwrap();
assert_eq!(store.get("OPENAI_API_KEY"), Some("second"));
@ -313,7 +301,7 @@ mod tests {
let path = dir.path().join("secrets.json");
let mut store = Vault::load(path.clone()).unwrap();
store
.set("OPENAI_API_KEY", "secret", SecretType::Environment, None)
.set("OPENAI_API_KEY", "secret", SecretType::Token, None)
.unwrap();
store.remove("OPENAI_API_KEY").unwrap();
@ -322,19 +310,23 @@ mod tests {
}
#[test]
fn env_secret_snapshot_excludes_file_secrets() {
fn file_secrets_excludes_token_and_oauth_secrets() {
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)
.set("OPENAI_API_KEY", "token", SecretType::Token, None)
.unwrap();
store
.set("OPENAI_CODEX", "oauth-json", SecretType::Oauth, None)
.unwrap();
store
.set("/tmp/key.pem", "pem", SecretType::File, None)
.unwrap();
let snapshot = store.snapshot();
assert_eq!(snapshot.get("OPENAI_API_KEY"), Some(&"env".to_string()));
assert!(!snapshot.contains_key("/tmp/key.pem"));
assert_eq!(store.file_secrets(), vec![(
"/tmp/key.pem".to_string(),
"pem".to_string()
)]);
}
#[test]
@ -354,21 +346,21 @@ mod tests {
}
#[test]
fn list_includes_credential_entries_loaded_from_disk() {
fn list_includes_schema_typed_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",
"value": "token",
"type": "token",
"created_at": "2026-04-12T00:00:00Z",
"updated_at": "2026-04-12T00:00:00Z"
},
"openai_codex": {
"value": "{\"provider\":\"openai\"}",
"type": "credential",
"OPENAI_CODEX": {
"value": "{\"tokens\":{\"access_token\":\"access\",\"refresh_token\":\"refresh\",\"expires_at\":\"2026-04-12T01:00:00Z\"},\"config\":{\"auth_url\":\"https://auth.openai.com\",\"token_url\":\"https://auth.openai.com/oauth/token\",\"client_id\":\"client\",\"scopes\":[\"openid\"],\"redirect_uri\":null,\"use_pkce\":true}}",
"type": "oauth",
"created_at": "2026-04-12T00:00:00Z",
"updated_at": "2026-04-12T00:00:00Z"
}
@ -379,11 +371,14 @@ mod tests {
let store = Vault::load(path).unwrap();
assert_eq!(store.list().len(), 2);
assert_eq!(store.list()[0].name, "OPENAI_API_KEY");
assert_eq!(store.list()[1].name, "openai_codex");
assert_eq!(store.list()[1].secret_type, SecretType::Credential);
assert_eq!(store.get("openai_codex"), Some("{\"provider\":\"openai\"}"));
let list = store.list();
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "OPENAI_API_KEY");
assert_eq!(list[0].secret_type, SecretType::Token);
assert_eq!(list[1].name, "OPENAI_CODEX");
assert_eq!(list[1].secret_type, SecretType::Oauth);
assert_eq!(store.get("OPENAI_API_KEY"), Some("token"));
assert!(store.get("OPENAI_CODEX").is_some());
}
#[test]
@ -392,38 +387,30 @@ mod tests {
let mut store = Vault::load(dir.path().join("secrets.json")).unwrap();
store
.set(
"openai_codex",
"credential-json",
SecretType::Credential,
"OPENAI_CODEX",
"oauth-json",
SecretType::Oauth,
Some("saved auth"),
)
.unwrap();
let entry = store.get_entry("openai_codex").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.value, "oauth-json");
assert_eq!(entry.secret_type, SecretType::Oauth);
assert_eq!(entry.description.as_deref(), Some("saved auth"));
}
#[test]
fn credential_entries_only_returns_credentials() {
fn get_entry_returns_token_entries_by_name() {
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,
)
.set("OPENAI_API_KEY", "token", SecretType::Token, 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");
let entry = store.get_entry("OPENAI_API_KEY").unwrap();
assert_eq!(entry.value, "token");
assert_eq!(entry.secret_type, SecretType::Token);
}
}

View file

@ -1148,7 +1148,7 @@ impl CompletionCoordinator for SteeringCompletionCoordinator {
mod tests {
use fabro_agent::subagent::SessionFactory;
use fabro_agent::{AgentProfile, ToolRegistry};
use fabro_auth::{AuthCredential, AuthDetails, EnvCredentialSource, VaultCredentialSource};
use fabro_auth::{EnvCredentialSource, VaultCredentialSource};
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::{Error as LlmError, ProviderErrorDetail, ProviderErrorKind};
use fabro_vault::{SecretType, Vault};
@ -1534,15 +1534,9 @@ reasoning = false
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault
.set(
"anthropic",
&serde_json::to_string(&AuthCredential {
provider: ProviderId::anthropic(),
details: AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
},
})
.unwrap(),
SecretType::Credential,
"ANTHROPIC_API_KEY",
"anthropic-key",
SecretType::Token,
None,
)
.unwrap();

View file

@ -717,7 +717,6 @@ mod tests {
use std::time::Duration;
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_interview::AutoApproveInterviewer;
use fabro_sandbox::SandboxSpec;
@ -1017,15 +1016,9 @@ mod tests {
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault
.set(
"anthropic",
&serde_json::to_string(&AuthCredential {
provider: fabro_model::ProviderId::anthropic(),
details: AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
},
})
.unwrap(),
SecretType::Credential,
"ANTHROPIC_API_KEY",
"anthropic-key",
SecretType::Token,
None,
)
.unwrap();
@ -1124,18 +1117,7 @@ mod tests {
let mut vault = Vault::load(temp.path().join("secrets.json")).unwrap();
vault
.set(
"openai",
&serde_json::to_string(&AuthCredential {
provider: fabro_model::ProviderId::openai(),
details: AuthDetails::ApiKey {
key: "openai-key".to_string(),
},
})
.unwrap(),
SecretType::Credential,
None,
)
.set("OPENAI_API_KEY", "openai-key", SecretType::Token, None)
.unwrap();
let vault = Arc::new(AsyncRwLock::new(vault));

View file

@ -670,15 +670,12 @@ mod tests {
use std::time::Duration;
use chrono::Utc;
use fabro_auth::{
AuthCredential, AuthDetails, CredentialSource, EnvCredentialSource, VaultCredentialSource,
};
use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource};
use fabro_graphviz::graph::Graph;
use fabro_llm::Error as LlmError;
use fabro_llm::client::Client;
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts};
use fabro_model::ProviderId;
use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings};
use fabro_store::Database;
use fabro_types::{
@ -835,15 +832,6 @@ mod tests {
)
}
fn openai_api_key_credential(key: &str) -> AuthCredential {
AuthCredential {
provider: ProviderId::openai(),
details: AuthDetails::ApiKey {
key: key.to_string(),
},
}
}
fn openai_responses_payload(text: &str) -> serde_json::Value {
serde_json::json!({
"id": "resp_1",
@ -1344,9 +1332,9 @@ mod tests {
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("vault-openai-key")).unwrap(),
SecretType::Credential,
"OPENAI_API_KEY",
"vault-openai-key",
SecretType::Token,
None,
)
.unwrap();
@ -1840,9 +1828,9 @@ mod tests {
let mut vault = Vault::load(vault_dir.path().join("secrets.json")).unwrap();
vault
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("vault-openai-key")).unwrap(),
SecretType::Credential,
"OPENAI_API_KEY",
"vault-openai-key",
SecretType::Token,
None,
)
.unwrap();

View file

@ -6869,15 +6869,6 @@ mod real_llm {
}
}
fn openai_api_key_credential(key: &str) -> fabro_auth::AuthCredential {
fabro_auth::AuthCredential {
provider: ProviderId::openai(),
details: fabro_auth::AuthDetails::ApiKey {
key: key.to_string(),
},
}
}
fn openai_responses_payload(text: &str) -> serde_json::Value {
serde_json::json!({
"id": "resp_1",
@ -6960,9 +6951,9 @@ async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() {
let mut vault = Vault::load(vault_dir.path().join("secrets.json")).unwrap();
vault
.set(
"openai_codex",
&serde_json::to_string(&openai_api_key_credential("vault-openai-key")).unwrap(),
SecretType::Credential,
"OPENAI_API_KEY",
"vault-openai-key",
SecretType::Token,
None,
)
.unwrap();

View file

@ -15,7 +15,7 @@
/**
* Whether Fabro may expose reasoning effort levels for a model.
* Whether the model endpoint supports a native reasoning-effort parameter.
*/
export const ReasoningEffortFeature = {

View file

@ -15,13 +15,13 @@
/**
* The way a secret is consumed by the sandbox.
* Schema of a stored secret.
*/
export const SecretType = {
ENVIRONMENT: 'environment',
FILE: 'file',
CREDENTIAL: 'credential'
TOKEN: 'token',
OAUTH: 'oauth',
FILE: 'file'
} as const;
export type SecretType = typeof SecretType[keyof typeof SecretType];