mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
6a86ced77c
commit
3448a7d067
64 changed files with 2734 additions and 1048 deletions
|
|
@ -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 |
|
||||
|---|---|
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
27
docs/public/changelog/2026-05-18.mdx
Normal file
27
docs/public/changelog/2026-05-18.mdx
Normal 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>
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -451,6 +451,6 @@ mode = "keep-me"
|
|||
vault
|
||||
.get_entry("GITHUB_TOKEN")
|
||||
.map(|entry| entry.secret_type),
|
||||
Some(SecretType::Environment)
|
||||
Some(SecretType::Token)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
275
lib/crates/fabro-server/src/vault_legacy_migration.rs
Normal file
275
lib/crates/fabro-server/src/vault_legacy_migration.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue