diff --git a/docs/public/changelog/2026-05-18.mdx b/docs/public/changelog/2026-05-18.mdx index 00afabb87..b952ca954 100644 --- a/docs/public/changelog/2026-05-18.mdx +++ b/docs/public/changelog/2026-05-18.mdx @@ -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 diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index 044a79b48..177f8eb77 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -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..auth]` with ordered `env:` or `vault:` 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..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..auth]` with ordered `env:` or `vault:` 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..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. diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index cd1d81db5..abe17f584 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -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 | required when `auth` present | Ordered credential refs. Accepted forms are `vault:` and `env:`. 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 | `[]` | Additional provider names accepted by model routing and fallback config. | diff --git a/lib/crates/fabro-auth/src/env_source.rs b/lib/crates/fabro-auth/src/env_source.rs index af2c32cca..ca01a2b3c 100644 --- a/lib/crates/fabro-auth/src/env_source.rs +++ b/lib/crates/fabro-auth/src/env_source.rs @@ -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)) diff --git a/lib/crates/fabro-auth/src/lib.rs b/lib/crates/fabro-auth/src/lib.rs index 7517f5c2d..b04257035 100644 --- a/lib/crates/fabro-auth/src/lib.rs +++ b/lib/crates/fabro-auth/src/lib.rs @@ -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"; diff --git a/lib/crates/fabro-auth/src/resolve.rs b/lib/crates/fabro-auth/src/resolve.rs index a16ada0d0..3e08045f2 100644 --- a/lib/crates/fabro-auth/src/resolve.rs +++ b/lib/crates/fabro-auth/src/resolve.rs @@ -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 Option + 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 + 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 + 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, 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>>, 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(); diff --git a/lib/crates/fabro-auth/src/vault_ext.rs b/lib/crates/fabro-auth/src/vault_ext.rs index 8475f44f4..b08711e9e 100644 --- a/lib/crates/fabro-auth/src/vault_ext.rs +++ b/lib/crates/fabro-auth/src/vault_ext.rs @@ -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, ); } diff --git a/lib/crates/fabro-auth/src/vault_source.rs b/lib/crates/fabro-auth/src/vault_source.rs index 4cf66dcc7..16329ed0a 100644 --- a/lib/crates/fabro-auth/src/vault_source.rs +++ b/lib/crates/fabro-auth/src/vault_source.rs @@ -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 = diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 2d659f91d..5782a93f0 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -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 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, diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index 0e9a4be46..7d92e20fb 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -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())) -} diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs index a32cc34f3..94b44ab2a 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/crates/fabro-config/src/layers/llm.rs @@ -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::(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())), ); } diff --git a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs index a7fea98d6..2eca08d75 100644 --- a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs +++ b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs @@ -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 | required when `auth` present | Ordered credential refs. Accepted forms are `vault:` and `env:`. 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 | `[]` | Additional provider names accepted by model routing and fallback config. | diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 6ca0b73cd..966d9ee8a 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -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:"), 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, + literal: Option, #[serde(default)] - env: Option, + env: Option, #[serde(default)] - credential: Option, + vault: Option, } impl<'de> Deserialize<'de> for HeaderValueRef { @@ -385,7 +385,7 @@ impl TryFrom 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 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 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)?; diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index 12010f7f9..df6696732 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -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 { - 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")) } diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 917ae7a23..1a86e08a3 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 5eecf3f85..2be58dfb5 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -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 { - let backup_path = report - .backup_path - .as_ref() - .map_or_else(|| "".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 = Arc::new(VaultCredentialSource::with_env_lookup( Arc::clone(&vault), diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 6dbba15ff..60353d4b5 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -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> { - 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], diff --git a/lib/crates/fabro-server/src/vault_legacy_migration.rs b/lib/crates/fabro-server/src/vault_legacy_migration.rs deleted file mode 100644 index d37d5213d..000000000 --- a/lib/crates/fabro-server/src/vault_legacy_migration.rs +++ /dev/null @@ -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, -} - -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, - }, -} - -pub(crate) fn migrate_legacy_vault_file(path: &Path) -> anyhow::Result { - 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> { - 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) -> (Map, 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, - occupied: &mut HashSet, - 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) -> 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 { - 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 { - 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) -> 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(()) -} diff --git a/lib/crates/fabro-vault/src/lib.rs b/lib/crates/fabro-vault/src/lib.rs index b11d96ebc..d9256ea04 100644 --- a/lib/crates/fabro-vault/src/lib.rs +++ b/lib/crates/fabro-vault/src/lib.rs @@ -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 { let entries = match std::fs::read_to_string(&path) { Ok(contents) => serde_json::from_str(&contents)?,