refactor(auth): simplify vault credential cleanup

Remove the temporary legacy vault migration and fail-open empty-vault path.
Centralize vault secret naming and Codex credential shaping so env and vault
resolution share the same behavior, and make extra-header refs use the
same explicit vault vocabulary as provider auth refs.
This commit is contained in:
Bryan Helmkamp 2026-05-18 10:50:11 -04:00
parent 3448a7d067
commit 437a277692
No known key found for this signature in database
19 changed files with 174 additions and 584 deletions

View file

@ -16,7 +16,7 @@ credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
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.
Existing server-owned secrets using the removed `environment` or `credential` schemas must be re-created with the new `token`, `oauth`, or `file` schema.
## More

View file

@ -51,7 +51,7 @@ base_url = "https://llm-gateway.example.com/v1"
aliases = ["gateway"]
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY"]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
@ -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 `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 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" }`, `{ vault = "NAME" }`, 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

@ -91,7 +91,7 @@ base_url = "https://llm-gateway.example.com/v1"
aliases = ["gateway"]
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY"]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
@ -182,12 +182,12 @@ enabled = true
aliases = ["gateway"]
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY"]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-config = { literal = "@bedrock-prod" }
x-team-secret = { credential = "gateway_team_secret" }
x-team-secret = { vault = "gateway_team_secret" }
```
| Key | Type / values | Default | Description |
@ -200,7 +200,7 @@ x-team-secret = { credential = "gateway_team_secret" }
| `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 `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" }`. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ vault = "NAME" }`. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |

View file

@ -6,6 +6,7 @@ use fabro_model::{Catalog, CredentialRef, HeaderValueRef, ProviderId};
use fabro_static::EnvVars;
use crate::credential_source::{CredentialSource, ResolvedCredentials};
use crate::resolve::{apply_openai_api_env_context, apply_openai_codex_api_context};
use crate::{ApiCredential, EnvLookup, ResolveError, build_api_key_header};
#[derive(Clone)]
@ -64,15 +65,10 @@ impl EnvCredentialSource {
project_id: None,
};
if provider.id == ProviderId::openai() && cred.auth_header.is_some() {
cred.org_id = self.lookup(EnvVars::OPENAI_ORG_ID);
cred.project_id = self.lookup(EnvVars::OPENAI_PROJECT_ID);
if let Some(account_id) = self.lookup(EnvVars::CHATGPT_ACCOUNT_ID) {
cred.base_url = Some("https://chatgpt.com/backend-api/codex".to_string());
cred.codex_mode = true;
cred.extra_headers
.insert("ChatGPT-Account-Id".to_string(), account_id);
cred.extra_headers
.insert("originator".to_string(), "fabro".to_string());
apply_openai_codex_api_context(&mut cred, Some(&account_id), &*self.env_lookup);
} else {
apply_openai_api_env_context(&mut cred, &*self.env_lookup);
}
}
Ok(Some(cred))
@ -89,7 +85,7 @@ impl EnvCredentialSource {
let value = match value_ref {
HeaderValueRef::Literal(value) => Some(value.clone()),
HeaderValueRef::Env(name) => self.lookup(name),
HeaderValueRef::Credential(_) => None,
HeaderValueRef::Vault(_) => None,
}
.ok_or_else(|| ResolveError::NotConfigured(provider.id.clone()))?;
Ok((name.clone(), value))

View file

@ -28,3 +28,5 @@ pub use vault_ext::{
VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth, vault_set_token,
};
pub use vault_source::VaultCredentialSource;
pub const OPENAI_CODEX_VAULT_SECRET_NAME: &str = "OPENAI_CODEX";

View file

@ -13,7 +13,7 @@ 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_set_oauth;
use crate::vault_ext::{VaultLookupError, vault_get_oauth, vault_get_token, vault_set_oauth};
pub type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
@ -75,6 +75,38 @@ impl ApiCredential {
}
}
const OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
const CHATGPT_ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id";
const ORIGINATOR_HEADER: &str = "originator";
const FABRO_ORIGINATOR: &str = "fabro";
pub(crate) fn apply_openai_api_env_context(
credential: &mut ApiCredential,
env_lookup: &(dyn Fn(&str) -> Option<String> + Send + Sync),
) {
credential.org_id = env_lookup(EnvVars::OPENAI_ORG_ID);
credential.project_id = env_lookup(EnvVars::OPENAI_PROJECT_ID);
}
pub(crate) fn apply_openai_codex_api_context(
credential: &mut ApiCredential,
account_id: Option<&str>,
env_lookup: &(dyn Fn(&str) -> Option<String> + Send + Sync),
) {
apply_openai_api_env_context(credential, env_lookup);
if let Some(account_id) = account_id {
credential.extra_headers.insert(
CHATGPT_ACCOUNT_ID_HEADER.to_string(),
account_id.to_string(),
);
}
credential
.extra_headers
.insert(ORIGINATOR_HEADER.to_string(), FABRO_ORIGINATOR.to_string());
credential.base_url = Some(OPENAI_CODEX_BASE_URL.to_string());
credential.codex_mode = true;
}
#[must_use]
pub fn build_api_key_header(policy: ApiKeyHeaderPolicy, key: String) -> ApiKeyHeader {
match policy {
@ -306,31 +338,22 @@ impl CredentialResolver {
credential_ref: &CredentialRef,
) -> Result<Option<ResolvedSecret>, ResolveError> {
match credential_ref {
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(),
})
CredentialRef::Vault(name) => match vault_get_token(vault, name) {
Ok(Some(token)) => Ok(Some(ResolvedSecret::ApiKey(token))),
Ok(None) => Ok(None),
Err(VaultLookupError::SchemaMismatch {
actual: SecretType::Oauth,
..
}) => vault_get_oauth(vault, name)
.map(|credential| {
credential.map(|credential| 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(),
name: name.clone(),
actual: SecretType::File,
}),
}
}
})
.map_err(|err| vault_lookup_error(provider, name, err)),
Err(err) => Err(vault_lookup_error(provider, name, err)),
},
CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)),
}
}
@ -361,7 +384,7 @@ impl CredentialResolver {
let value = match value_ref {
HeaderValueRef::Literal(value) => Some(value.clone()),
HeaderValueRef::Env(name) => self.lookup_env(name),
HeaderValueRef::Credential(name) => vault.get(name).map(str::to_string),
HeaderValueRef::Vault(name) => vault.get(name).map(str::to_string),
}
.ok_or_else(|| ResolveError::NotConfigured(provider.clone()))?;
Ok((name.clone(), value))
@ -396,8 +419,7 @@ impl CredentialResolver {
cred.extra_headers =
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);
apply_openai_api_env_context(&mut cred, &*self.env_lookup);
}
Ok(cred)
}
@ -414,19 +436,11 @@ impl CredentialResolver {
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);
apply_openai_codex_api_context(
&mut api_credential,
credential.account_id.as_deref(),
&*self.env_lookup,
);
}
Ok(api_credential)
}
@ -499,6 +513,21 @@ impl CredentialResolver {
}
}
fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError {
match err {
VaultLookupError::SchemaMismatch { actual, .. } => ResolveError::VaultSchemaMismatch {
provider: provider.clone(),
name: name.to_string(),
actual,
},
VaultLookupError::DecodeFailed { source, .. } => ResolveError::VaultDecodeFailed {
provider: provider.clone(),
name: name.to_string(),
source,
},
}
}
pub async fn configured_providers_from_process_env(
vault: Option<&Arc<AsyncRwLock<Vault>>>,
catalog: &Catalog,
@ -614,7 +643,7 @@ mod tests {
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_oauth(
&mut vault,
"OPENAI_CODEX",
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
"https://auth.openai.com/oauth/token".to_string(),
Utc::now() + Duration::hours(1),
@ -754,7 +783,7 @@ reasoning = false
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_oauth(
&mut vault,
"OPENAI_CODEX",
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
"https://auth.openai.com/oauth/token".to_string(),
Utc::now() + Duration::hours(1),
@ -1028,7 +1057,7 @@ reasoning = false
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault_set_oauth(
&mut vault,
"OPENAI_CODEX",
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&oauth_credential(
server.url("/oauth/token"),
Utc::now() - Duration::minutes(1),
@ -1058,7 +1087,9 @@ reasoning = false
let stored = {
let vault = vault.read().await;
vault_get_oauth(&vault, "OPENAI_CODEX").unwrap().unwrap()
vault_get_oauth(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME)
.unwrap()
.unwrap()
};
assert_eq!(stored.tokens.access_token, "new-access");
assert_eq!(stored.tokens.refresh_token.as_deref(), Some("new-refresh"));
@ -1075,7 +1106,12 @@ reasoning = false
Utc::now() - Duration::minutes(1),
);
credential.tokens.refresh_token = None;
vault_set_oauth(&mut vault, "OPENAI_CODEX", &credential).unwrap();
vault_set_oauth(
&mut vault,
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&credential,
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = default_catalog();

View file

@ -130,8 +130,13 @@ mod tests {
#[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();
vault_set_oauth(
&mut vault,
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&fixture(),
)
.unwrap();
let err = vault_get_token(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME).unwrap_err();
assert!(matches!(err, VaultLookupError::SchemaMismatch { .. }));
}
@ -139,9 +144,16 @@ mod tests {
fn vault_get_oauth_round_trips() {
let mut vault = temp_vault();
let credential = fixture();
vault_set_oauth(&mut vault, "OPENAI_CODEX", &credential).unwrap();
vault_set_oauth(
&mut vault,
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&credential,
)
.unwrap();
assert_eq!(
vault_get_oauth(&vault, "OPENAI_CODEX").unwrap().unwrap(),
vault_get_oauth(&vault, crate::OPENAI_CODEX_VAULT_SECRET_NAME)
.unwrap()
.unwrap(),
credential,
);
}

View file

@ -111,7 +111,12 @@ 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_oauth(&mut vault, "OPENAI_CODEX", &expired_openai_credential()).unwrap();
vault_set_oauth(
&mut vault,
crate::OPENAI_CODEX_VAULT_SECRET_NAME,
&expired_openai_credential(),
)
.unwrap();
vault_set_token(&mut vault, "ANTHROPIC_API_KEY", "anthropic-key").unwrap();
let source =

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::{AuthMethod, LoginResult, codex_oauth_config};
use fabro_auth::{AuthMethod, LoginResult, OPENAI_CODEX_VAULT_SECRET_NAME, codex_oauth_config};
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget};
use fabro_config::bind::Bind;
use fabro_config::daemon::ServerDaemon;
@ -108,18 +108,10 @@ fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> 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()))
catalog.provider_vault_secret_name(provider).map_or_else(
|| format!("{}_API_KEY", provider.to_string().to_uppercase()),
str::to_string,
)
}
// ---------------------------------------------------------------------------
@ -1236,7 +1228,7 @@ fn credential_secret_request(result: &LoginResult) -> Result<CreateSecretRequest
description: None,
}),
LoginResult::OAuth { credential, .. } => Ok(CreateSecretRequest {
name: "OPENAI_CODEX".to_string(),
name: OPENAI_CODEX_VAULT_SECRET_NAME.to_string(),
value: serde_json::to_string(credential)?,
type_: ApiSecretType::Oauth,
description: None,

View file

@ -1,7 +1,6 @@
use anyhow::Result;
use anyhow::{Context, Result};
use fabro_api::types;
use fabro_auth::LoginResult;
use fabro_model::{Catalog, CredentialRef, ProviderId};
use fabro_auth::{LoginResult, OPENAI_CODEX_VAULT_SECRET_NAME};
use fabro_util::terminal::Styles;
use crate::args::ProviderLoginArgs;
@ -38,11 +37,17 @@ pub(super) async fn login_command(
let (name, value, type_) = match result {
LoginResult::ApiKey { provider, key } => {
let name = api_key_secret_name(&provider, ctx.catalog()?.as_ref());
let name = ctx
.catalog()?
.provider_vault_secret_name(&provider)
.with_context(|| {
format!("provider '{provider}' does not define a vault credential path")
})?
.to_string();
(name, key, types::SecretType::Token)
}
LoginResult::OAuth { credential, .. } => (
"OPENAI_CODEX".to_string(),
OPENAI_CODEX_VAULT_SECRET_NAME.to_string(),
serde_json::to_string(&credential)?,
types::SecretType::Oauth,
),
@ -59,18 +64,3 @@ pub(super) async fn login_command(
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

@ -367,8 +367,8 @@ agent_profile = "gemini"
}
#[test]
fn header_value_ref_parses_credential_form() {
let parsed: HeaderValueRef = toml::from_str(r#"value = { credential = "portkey_config" }"#)
fn header_value_ref_parses_vault_form() {
let parsed: HeaderValueRef = toml::from_str(r#"value = { vault = "portkey_config" }"#)
.map(|v: toml::Value| {
v.as_table()
.unwrap()
@ -380,10 +380,7 @@ agent_profile = "gemini"
})
.unwrap();
assert_eq!(
parsed,
HeaderValueRef::Credential("portkey_config".to_string())
);
assert_eq!(parsed, HeaderValueRef::Vault("portkey_config".to_string()));
assert_eq!(parsed.to_string(), "vault:portkey_config");
}
@ -457,7 +454,7 @@ agent_profile = "gemini"
for source in [
r#"value = { literal = "" }"#,
r#"value = { env = "" }"#,
r#"value = { credential = "" }"#,
r#"value = { vault = "" }"#,
] {
let err = toml::from_str::<Wrap>(source).unwrap_err();
assert!(err.to_string().contains("must not be empty"));
@ -509,7 +506,7 @@ base_url = "https://api.portkey.ai/v1"
[providers.portkey.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-provider = { literal = "@bedrock-prod" }
x-portkey-config = { credential = "portkey_config" }
x-portkey-config = { vault = "portkey_config" }
"#;
let layer: LlmLayer = toml::from_str(toml).unwrap();
@ -527,7 +524,7 @@ x-portkey-config = { credential = "portkey_config" }
);
assert_eq!(
headers.get("x-portkey-config"),
Some(&HeaderValueRef::Credential("portkey_config".to_string())),
Some(&HeaderValueRef::Vault("portkey_config".to_string())),
);
}

View file

@ -227,12 +227,12 @@ enabled = true
aliases = ["gateway"]
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY"]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = { env = "PORTKEY_API_KEY" }
x-portkey-config = { literal = "@bedrock-prod" }
x-team-secret = { credential = "gateway_team_secret" }
x-team-secret = { vault = "gateway_team_secret" }
```
| Key | Type / values | Default | Description |
@ -245,7 +245,7 @@ x-team-secret = { credential = "gateway_team_secret" }
| `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 `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" }`. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ vault = "NAME" }`. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |

View file

@ -313,7 +313,7 @@ pub enum BillingPolicy {
pub enum HeaderValueRef {
Literal(String),
Env(String),
Credential(String),
Vault(String),
}
impl Serialize for HeaderValueRef {
@ -327,7 +327,7 @@ impl Serialize for HeaderValueRef {
match self {
Self::Literal(value) => map.serialize_entry("literal", value)?,
Self::Env(value) => map.serialize_entry("env", value)?,
Self::Credential(value) => map.serialize_entry("credential", value)?,
Self::Vault(value) => map.serialize_entry("vault", value)?,
}
map.end()
}
@ -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, "vault:{id}"),
Self::Vault(id) => write!(f, "vault:{id}"),
}
}
}
@ -354,11 +354,11 @@ enum HeaderValueRefInput {
#[serde(deny_unknown_fields)]
struct HeaderValueRefSerde {
#[serde(default)]
literal: Option<String>,
literal: Option<String>,
#[serde(default)]
env: Option<String>,
env: Option<String>,
#[serde(default)]
credential: Option<String>,
vault: Option<String>,
}
impl<'de> Deserialize<'de> for HeaderValueRef {
@ -385,7 +385,7 @@ impl TryFrom<HeaderValueRefSerde> for HeaderValueRef {
let populated = [
value.literal.as_ref(),
value.env.as_ref(),
value.credential.as_ref(),
value.vault.as_ref(),
]
.into_iter()
.flatten()
@ -399,8 +399,8 @@ impl TryFrom<HeaderValueRefSerde> for HeaderValueRef {
if let Some(value) = value.env {
return non_empty_header_value(value).map(Self::Env);
}
if let Some(value) = value.credential {
return non_empty_header_value(value).map(Self::Credential);
if let Some(value) = value.vault {
return non_empty_header_value(value).map(Self::Vault);
}
unreachable!("populated field count was already checked");
}
@ -416,7 +416,7 @@ fn non_empty_header_value(value: String) -> Result<String, HeaderValueRefParseEr
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum HeaderValueRefParseError {
#[error("header value must contain exactly one of `literal`, `env`, or `credential`")]
#[error("header value must contain exactly one of `literal`, `env`, or `vault`")]
WrongFieldCount,
#[error("header value reference must not be empty")]
EmptyValue,
@ -785,6 +785,19 @@ impl Catalog {
.and_then(|idx| self.providers.get(*idx))
}
#[must_use]
pub fn provider_vault_secret_name(&self, id: &ProviderId) -> Option<&str> {
self.provider(id)?
.auth
.as_ref()?
.credentials
.iter()
.find_map(|credential_ref| match credential_ref {
CredentialRef::Vault(name) => Some(name.as_str()),
CredentialRef::Env(_) => None,
})
}
#[must_use]
pub fn model_settings(&self, id: &str) -> Option<&CatalogModelSettings> {
let model = self.get(id)?;

View file

@ -25,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, CredentialRef, ProviderId};
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::daytona;
use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
@ -838,18 +838,10 @@ 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,
})
})
install_catalog_provider(provider)?;
INSTALL_CATALOG
.provider_vault_secret_name(provider)
.map(str::to_string)
.ok_or_else(|| format!("provider '{provider}' does not define a vault credential path"))
}

View file

@ -40,7 +40,6 @@ 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

@ -137,8 +137,7 @@ 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,
vault_legacy_migration, web_auth,
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
};
mod handler;
@ -1557,40 +1556,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
shutdown,
} = config;
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 = Vault::load(vault_path.clone())
.with_context(|| format!("load vault {}", vault_path.display()))?;
let vault = Arc::new(AsyncRwLock::new(vault));
let llm_source: Arc<dyn CredentialSource> = Arc::new(VaultCredentialSource::with_env_lookup(
Arc::clone(&vault),

View file

@ -1879,135 +1879,6 @@ 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],

View file

@ -1,275 +0,0 @@
//! 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

@ -62,13 +62,6 @@ 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)?,