Complete provider credential auth and scripted install

This commit is contained in:
Bryan Helmkamp 2026-04-13 09:15:45 -04:00
parent 43d73cb4a1
commit f3aa30d782
11 changed files with 1301 additions and 312 deletions

View file

@ -262,7 +262,9 @@ impl CredentialResolver {
fn codex_login_command(api_key: &str) -> String {
let quoted =
try_quote(api_key).map_or_else(|_| api_key.to_string(), std::borrow::Cow::into_owned);
format!("PATH=\"$HOME/.local/bin:$PATH\" echo {quoted} | codex login --with-api-key")
format!(
"export PATH=\"$HOME/.local/bin:$PATH\" && printf '%s\\n' {quoted} | codex login --with-api-key"
)
}
fn credential_ids_for(provider: Provider, usage: CredentialUsage) -> &'static [&'static str] {
@ -283,6 +285,9 @@ fn credential_ids_for(provider: Provider, usage: CredentialUsage) -> &'static [&
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use chrono::{Duration, Utc};
use httpmock::Method::POST;
use httpmock::MockServer;
@ -467,6 +472,64 @@ mod tests {
assert!(cli.login_command.is_some());
}
#[cfg(unix)]
#[tokio::test]
async fn openai_api_key_cli_login_command_executes_codex_from_local_bin() {
let dir = tempfile::tempdir().unwrap();
let local_bin = dir.path().join(".local/bin");
std::fs::create_dir_all(&local_bin).unwrap();
let codex_path = local_bin.join("codex");
std::fs::write(
&codex_path,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HOME/codex-args.txt\"\ncat > \"$HOME/codex-stdin.txt\"\n",
)
.unwrap();
let mut permissions = std::fs::metadata(&codex_path).unwrap().permissions();
permissions.set_mode(0o755);
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(Provider::OpenAi, "openai-key"),
)
.unwrap();
let resolver = test_resolver(vault, Arc::new(|_| None));
let ResolvedCredential::Cli(cli) = resolver
.resolve(
Provider::OpenAi,
CredentialUsage::CliAgent(CliAgentKind::Codex),
)
.await
.unwrap()
else {
panic!("expected cli credential");
};
let status = std::process::Command::new("/bin/sh")
.arg("-lc")
.arg(cli.login_command.unwrap())
.env("HOME", dir.path())
.env("PATH", "/usr/bin:/bin")
.status()
.unwrap();
assert!(status.success());
assert_eq!(
std::fs::read_to_string(dir.path().join("codex-args.txt")).unwrap(),
"login\n--with-api-key\n"
);
assert_eq!(
std::fs::read_to_string(dir.path().join("codex-stdin.txt"))
.unwrap()
.trim_end(),
"openai-key"
);
}
#[tokio::test]
async fn with_env_lookup_overrides_vault_settings() {
let dir = tempfile::tempdir().unwrap();

View file

@ -599,6 +599,10 @@ pub(crate) struct ProviderLoginArgs {
/// LLM provider to authenticate with
#[arg(long)]
pub(crate) provider: fabro_model::Provider,
/// Read an API key from stdin instead of prompting
#[arg(long)]
pub(crate) api_key_stdin: bool,
}
#[derive(Args)]
@ -1268,6 +1272,39 @@ pub(crate) struct DoctorArgs {
pub(crate) verbose: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum InstallGitHubStrategyArg {
GhCli,
App,
}
#[derive(Args, Debug, Clone, Default)]
pub(crate) struct InstallNonInteractiveArgs {
#[arg(long, hide = true)]
pub(crate) llm_provider: Option<fabro_model::Provider>,
#[arg(long, hide = true)]
pub(crate) llm_api_key_stdin: bool,
#[arg(long, hide = true)]
pub(crate) llm_api_key_env: Option<String>,
#[arg(long, hide = true)]
pub(crate) github_strategy: Option<InstallGitHubStrategyArg>,
#[arg(long, hide = true)]
pub(crate) github_username: Option<String>,
#[arg(long, hide = true)]
pub(crate) overwrite_settings: bool,
#[arg(long, hide = true)]
pub(crate) keep_existing_settings: bool,
#[arg(long, hide = true)]
pub(crate) run_doctor: bool,
}
#[derive(Args)]
pub(crate) struct InstallArgs {
#[command(flatten)]
@ -1276,6 +1313,13 @@ pub(crate) struct InstallArgs {
/// Base URL for the web UI (used for OAuth callback URLs)
#[arg(long, default_value = "http://localhost:3000")]
pub(crate) web_url: String,
/// Run install without prompts; use hidden scripted flags for inputs
#[arg(long)]
pub(crate) non_interactive: bool,
#[command(flatten)]
pub(crate) scripted: InstallNonInteractiveArgs,
}
#[derive(Args)]

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,17 @@ pub(super) async fn login_command(
let s = Styles::detect_stderr();
let ctx = CommandContext::for_target(&args.target, printer)?;
let server = ctx.server().await?;
let credential = provider_auth::authenticate_provider(args.provider, &s, printer).await?;
let credential = if args.api_key_stdin {
provider_auth::authenticate_provider_with_api_key_source(
args.provider,
provider_auth::ApiKeySource::Stdin,
&s,
printer,
)
.await?
} else {
provider_auth::authenticate_provider(args.provider, &s, printer).await?
};
let credential_id = credential_id_for(&credential).map_err(anyhow::Error::msg)?;
let value = serde_json::to_string(&credential)?;

View file

@ -7,10 +7,9 @@ use tokio::time;
use super::record;
pub(crate) async fn execute(storage_dir: &Path, timeout: Duration, printer: Printer) {
pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> bool {
let Some(active) = record::active_server_record_details(storage_dir) else {
fabro_util::printerr!(printer, "Server is not running");
std::process::exit(1);
return false;
};
let record = active.record;
@ -37,5 +36,14 @@ pub(crate) async fn execute(storage_dir: &Path, timeout: Duration, printer: Prin
let _ = std::fs::remove_file(path);
}
true
}
pub(crate) async fn execute(storage_dir: &Path, timeout: Duration, printer: Printer) {
if !stop_server(storage_dir, timeout).await {
fabro_util::printerr!(printer, "Server is not running");
std::process::exit(1);
}
fabro_util::printerr!(printer, "Server stopped");
}

View file

@ -356,6 +356,28 @@ mod tests {
}
}
#[test]
fn parse_provider_login_api_key_stdin() {
let cli = Cli::try_parse_from([
"fabro",
"provider",
"login",
"--provider",
"anthropic",
"--api-key-stdin",
])
.expect("should parse");
match *cli.command {
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::Provider::Anthropic);
assert!(args.api_key_stdin);
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_provider_login_missing_provider_flag() {
let result = Cli::try_parse_from(["fabro", "provider", "login"]);

View file

@ -1,6 +1,7 @@
use std::io::Read;
use std::sync::Arc;
use anyhow::Result;
use anyhow::{Context, Result, anyhow};
use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Password};
@ -56,6 +57,13 @@ pub(crate) fn prompt_password(prompt: &str) -> Result<String> {
.interact_on(&Term::stderr())?)
}
#[derive(Debug, Clone)]
pub(crate) enum ApiKeySource {
Prompt,
Stdin,
EnvVar(String),
}
// ---------------------------------------------------------------------------
// API key validation
// ---------------------------------------------------------------------------
@ -99,36 +107,66 @@ pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Resul
.map_err(|e| e.to_string())
}
pub(crate) async fn prompt_and_validate_key(
fn normalize_api_key_input(raw: &str) -> Result<String> {
let key = raw.trim_end_matches(['\r', '\n']).to_string();
anyhow::ensure!(!key.is_empty(), "API key input is empty");
Ok(key)
}
fn read_api_key_from_stdin() -> Result<String> {
let mut raw = String::new();
std::io::stdin()
.read_to_string(&mut raw)
.context("failed to read API key from stdin")?;
normalize_api_key_input(&raw)
}
fn read_api_key_from_env_var(name: &str) -> Result<String> {
let value =
std::env::var(name).with_context(|| format!("environment variable {name} is not set"))?;
normalize_api_key_input(&value)
.with_context(|| format!("environment variable {name} did not contain an API key"))
}
async fn read_api_key_from_source(source: &ApiKeySource, prompt: &str) -> Result<String> {
match source {
ApiKeySource::Prompt => {
let prompt = prompt.to_string();
let key: String = spawn_blocking(move || prompt_password(&prompt)).await??;
Ok(key)
}
ApiKeySource::Stdin => spawn_blocking(read_api_key_from_stdin).await?,
ApiKeySource::EnvVar(name) => read_api_key_from_env_var(name),
}
}
async fn read_and_validate_api_key(
provider: Provider,
source: &ApiKeySource,
env_var: &str,
s: &Styles,
printer: Printer,
) -> Result<(String, String)> {
let env_var = provider.api_key_env_vars()[0];
let url = provider_key_url(provider);
fabro_util::printerr!(
printer,
" {}",
s.dim.apply_to(format!("Get your API key at: {url}"))
);
) -> Result<String> {
loop {
let prompt = env_var.to_string();
let key: String = spawn_blocking(move || prompt_password(&prompt)).await??;
let key = read_api_key_from_source(source, env_var).await?;
fabro_util::printerr!(printer, " {}", s.dim.apply_to("Validating API key..."));
match validate_api_key(provider, &key).await {
Ok(()) => {
fabro_util::printerr!(printer, " {} API key is valid", s.green.apply_to(""));
return Ok((env_var.to_string(), key));
return Ok(key);
}
Err(e) => {
fabro_util::printerr!(printer, " [error] API key validation failed: {e}");
let retry =
spawn_blocking(|| prompt_confirm("Try again with a different key?", true))
.await??;
if !retry {
return Ok((env_var.to_string(), key));
if matches!(source, ApiKeySource::Prompt) {
let retry =
spawn_blocking(|| prompt_confirm("Try again with a different key?", true))
.await??;
if !retry {
return Ok(key);
}
} else {
return Err(anyhow!("API key validation failed: {e}"));
}
}
}
@ -159,6 +197,19 @@ pub(crate) async fn authenticate_provider(
authenticate_provider_with_method(provider, method, s, printer).await
}
pub(crate) async fn authenticate_provider_with_api_key_source(
provider: Provider,
source: ApiKeySource,
s: &Styles,
printer: Printer,
) -> Result<AuthCredential> {
let mut strategy = strategy_for(provider, AuthMethod::ApiKey);
let request = strategy.init().await?;
present_to_user(&request, s, printer);
let response = await_user_response_from_source(&request, &source, s, printer).await?;
strategy.complete(response).await
}
pub(crate) async fn authenticate_provider_with_method(
provider: Provider,
method: AuthMethod,
@ -168,7 +219,8 @@ pub(crate) async fn authenticate_provider_with_method(
let mut strategy = strategy_for(provider, method);
let request = strategy.init().await?;
present_to_user(&request, s, printer);
let response = await_user_response(&request, s, printer).await?;
let response =
await_user_response_from_source(&request, &ApiKeySource::Prompt, s, printer).await?;
strategy.complete(response).await
}
@ -210,17 +262,26 @@ pub(crate) fn present_to_user(request: &AuthContextRequest, s: &Styles, printer:
}
}
pub(crate) async fn await_user_response(
async fn await_user_response_from_source(
request: &AuthContextRequest,
source: &ApiKeySource,
s: &Styles,
printer: Printer,
) -> Result<AuthContextResponse> {
match request {
AuthContextRequest::ApiKey { provider, .. } => {
let (_, key) = prompt_and_validate_key(*provider, s, printer).await?;
AuthContextRequest::ApiKey {
provider,
env_var_names,
} => {
let env_var = env_var_names.first().map_or("API_KEY", String::as_str);
let key = read_and_validate_api_key(*provider, source, env_var, s, printer).await?;
Ok(AuthContextResponse::ApiKey { key })
}
AuthContextRequest::DeviceCode { .. } => {
anyhow::ensure!(
matches!(source, ApiKeySource::Prompt),
"device code login is not supported for scripted API key input"
);
let ready = spawn_blocking(|| {
prompt_confirm("Continue after completing sign-in in the browser?", true)
})
@ -259,4 +320,16 @@ mod tests {
let result = validate_api_key(Provider::Anthropic, "sk-invalid-key-12345").await;
assert!(result.is_err(), "expected invalid key to be rejected");
}
#[test]
fn normalize_api_key_input_trims_trailing_newlines() {
let key = normalize_api_key_input("secret-key\r\n").unwrap();
assert_eq!(key, "secret-key");
}
#[test]
fn normalize_api_key_input_rejects_empty_input() {
let err = normalize_api_key_input("\n").unwrap_err();
assert!(err.to_string().contains("API key input is empty"));
}
}

View file

@ -19,6 +19,7 @@ fn help() {
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--web-url <WEB_URL> Base URL for the web UI (used for OAuth callback URLs) [default: http://localhost:3000]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--non-interactive Run install without prompts; use hidden scripted flags for inputs
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
@ -39,3 +40,33 @@ fn install_rejects_json() {
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("--json is not supported for this command"));
}
#[test]
fn non_interactive_without_inputs_prints_scripted_usage_and_fails() {
let context = test_context!();
let output = context
.command()
.args(["install", "--non-interactive"])
.output()
.expect("command should run");
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Non-interactive install requires additional flags"));
assert!(stderr.contains("--llm-provider"));
assert!(stderr.contains("--github-strategy"));
}
#[test]
fn hidden_non_interactive_args_require_non_interactive() {
let context = test_context!();
let output = context
.command()
.args(["install", "--llm-provider", "anthropic"])
.output()
.expect("command should run");
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("requires --non-interactive"));
}

View file

@ -18,6 +18,7 @@ fn help() {
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--provider <PROVIDER> LLM provider to authenticate with
--api-key-stdin Read an API key from stdin instead of prompting
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]

View file

@ -7,6 +7,7 @@ use fabro_agent::{
AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, OpenAiProfile, Sandbox, Session,
SessionOptions, Turn,
};
use fabro_auth::{CredentialResolver, CredentialUsage, ResolveError, ResolvedCredential};
use fabro_graphviz::graph::Node;
use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request, TokenCounts};
@ -34,6 +35,65 @@ fn build_profile(model: &str, provider: Provider) -> Box<dyn AgentProfile> {
}
}
pub(crate) struct LlmClientBuildResult {
pub(crate) client: Client,
pub(crate) auth_issues: Vec<(Provider, ResolveError)>,
}
pub(crate) async fn build_llm_client(
resolver: Option<&CredentialResolver>,
) -> Result<LlmClientBuildResult, Error> {
let Some(resolver) = resolver else {
let client = Client::from_env()
.await
.map_err(|e| Error::handler(format!("Failed to create LLM client: {e}")))?;
return Ok(LlmClientBuildResult {
client,
auth_issues: Vec::new(),
});
};
let mut api_credentials = Vec::new();
let mut auth_issues = Vec::new();
for provider in Provider::ALL {
match resolver
.resolve(*provider, CredentialUsage::ApiRequest)
.await
{
Ok(ResolvedCredential::Api(credential)) => api_credentials.push(credential),
Ok(ResolvedCredential::Cli(_)) | Err(ResolveError::NotConfigured(_)) => {}
Err(err) => auth_issues.push((*provider, err)),
}
}
let client = Client::from_credentials(api_credentials)
.await
.map_err(|e| Error::handler(format!("Failed to create LLM client: {e}")))?;
Ok(LlmClientBuildResult {
client,
auth_issues,
})
}
pub(crate) fn auth_issue_message(provider: Provider, err: &ResolveError) -> String {
match err {
ResolveError::NotConfigured(_) => {
format!("{} is not configured", provider.display_name())
}
ResolveError::RefreshFailed { source, .. } => format!(
"{} requires re-authentication: {}",
provider.display_name(),
source
),
ResolveError::RefreshTokenMissing(_) => format!(
"{} requires re-authentication: refresh token missing",
provider.display_name()
),
}
}
/// Shared state for tracking file modifications from agent tool calls.
struct FileTracking {
/// Maps tool_call_id → file_path for in-flight write/edit calls.
@ -122,11 +182,17 @@ pub struct AgentApiBackend {
sessions: Mutex<HashMap<String, Session>>,
env: HashMap<String, String>,
mcp_servers: Vec<McpServerSettings>,
resolver: Option<CredentialResolver>,
}
impl AgentApiBackend {
#[must_use]
pub fn new(model: String, provider: Provider, fallback_chain: Vec<FallbackTarget>) -> Self {
pub fn new(
model: String,
provider: Provider,
fallback_chain: Vec<FallbackTarget>,
resolver: CredentialResolver,
) -> Self {
Self {
model,
provider,
@ -134,6 +200,24 @@ impl AgentApiBackend {
sessions: Mutex::new(HashMap::new()),
env: HashMap::new(),
mcp_servers: Vec::new(),
resolver: Some(resolver),
}
}
#[must_use]
pub fn new_from_env(
model: String,
provider: Provider,
fallback_chain: Vec<FallbackTarget>,
) -> Self {
Self {
model,
provider,
fallback_chain,
sessions: Mutex::new(HashMap::new()),
env: HashMap::new(),
mcp_servers: Vec::new(),
resolver: None,
}
}
@ -165,6 +249,7 @@ impl AgentApiBackend {
provider,
node,
sandbox,
self.resolver.as_ref(),
&self.env,
tool_hooks,
self.mcp_servers.clone(),
@ -177,13 +262,12 @@ impl AgentApiBackend {
provider: Provider,
node: &Node,
sandbox: &Arc<dyn Sandbox>,
resolver: Option<&CredentialResolver>,
env: &HashMap<String, String>,
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
mcp_servers: Vec<McpServerSettings>,
) -> Result<Session, Error> {
let client = Client::from_env()
.await
.map_err(|e| Error::handler(format!("Failed to create LLM client: {e}")))?;
let client = build_llm_client(resolver).await?.client;
let mut profile = build_profile(model, provider);
@ -264,9 +348,7 @@ impl CodergenBackend for AgentApiBackend {
prompt: &str,
system_prompt: Option<&str>,
) -> Result<CodergenResult, Error> {
let client = Client::from_env()
.await
.map_err(|e| Error::handler(format!("Failed to create LLM client: {e}")))?;
let client = build_llm_client(self.resolver.as_ref()).await?.client;
let model = node.model().unwrap_or(&self.model);
let provider = node
@ -507,6 +589,7 @@ impl CodergenBackend for AgentApiBackend {
target_provider,
node,
sandbox,
self.resolver.as_ref(),
&self.env,
tool_hooks.clone(),
self.mcp_servers.clone(),
@ -616,20 +699,26 @@ impl CodergenBackend for AgentApiBackend {
#[cfg(test)]
mod tests {
use fabro_agent::subagent::SessionFactory;
use fabro_auth::{AuthCredential, AuthDetails, CredentialResolver};
use fabro_vault::{SecretType, Vault};
use tokio::sync::RwLock as AsyncRwLock;
use super::*;
#[test]
fn agent_backend_stores_config() {
let backend =
AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::OpenAi, Vec::new());
let backend = AgentApiBackend::new_from_env(
"claude-opus-4-6".to_string(),
Provider::OpenAi,
Vec::new(),
);
assert_eq!(backend.model, "claude-opus-4-6");
assert_eq!(backend.provider, Provider::OpenAi);
}
#[test]
fn agent_backend_initializes_empty_sessions() {
let backend = AgentApiBackend::new(
let backend = AgentApiBackend::new_from_env(
"claude-opus-4-6".to_string(),
Provider::Anthropic,
Vec::new(),
@ -758,4 +847,33 @@ mod tests {
assert!(names.contains(&"wait".to_string()));
assert!(names.contains(&"close_agent".to_string()));
}
#[tokio::test]
async fn build_llm_client_uses_resolver_credentials() {
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault
.set(
"anthropic",
&serde_json::to_string(&AuthCredential {
provider: Provider::Anthropic,
details: AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
},
})
.unwrap(),
SecretType::Credential,
None,
)
.unwrap();
let resolver = CredentialResolver::with_env_lookup(
Arc::new(AsyncRwLock::new(vault)),
Arc::new(|_| None),
);
let result = build_llm_client(Some(&resolver)).await.unwrap();
assert_eq!(result.client.provider_names(), vec!["anthropic"]);
assert!(result.auth_issues.is_empty());
}
}

View file

@ -26,6 +26,7 @@ use crate::devcontainer_bridge::{devcontainer_to_snapshot_config, run_devcontain
use crate::error::Error;
use crate::event::{Emitter, Event, RunNoticeLevel};
use crate::git::{self, GitSyncStatus, MetadataStore};
use crate::handler::llm::api::{auth_issue_message, build_llm_client};
use crate::handler::llm::{AgentApiBackend, AgentCliBackend, BackendRouter};
use crate::handler::{HandlerRegistry, default_registry, sandbox_cancel_token};
use crate::run_options::GitCheckpointOptions;
@ -295,24 +296,56 @@ async fn build_registry(
.values()
.any(|n| graph::is_llm_handler_type(n.handler_type()));
match Client::from_env().await {
Ok(client) if client.provider_names().is_empty() => {
let resolver = vault.map(CredentialResolver::new);
match build_llm_client(resolver.as_ref()).await {
Ok(result) if result.client.provider_names().is_empty() => {
if graph_needs_llm {
return Err(Error::Precondition(
"No LLM providers configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or pass --dry-run to simulate.".to_string(),
));
let detail = (!result.auth_issues.is_empty()).then(|| {
result
.auth_issues
.iter()
.map(|(provider, issue)| auth_issue_message(*provider, issue))
.collect::<Vec<_>>()
.join("; ")
});
let prefix = detail.map_or_else(
|| "No LLM providers configured".to_string(),
|detail| format!("No usable LLM providers configured: {detail}"),
);
return Err(Error::Precondition(format!(
"{prefix}. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or pass --dry-run to simulate."
)));
}
Ok((build_no_backend(), None, false))
}
Ok(client) => {
Ok(result) => {
let env = sandbox_env.clone();
let model = spec.model.clone();
let provider = spec.provider;
let fallback_chain = spec.fallback_chain.clone();
let mcp_servers = spec.mcp_servers.clone();
let resolver = vault.map(CredentialResolver::new);
let client = result.client;
let registry = Arc::new(default_registry(interviewer, move || {
let api = AgentApiBackend::new(model.clone(), provider, fallback_chain.clone())
let api = resolver
.clone()
.map_or_else(
|| {
AgentApiBackend::new_from_env(
model.clone(),
provider,
fallback_chain.clone(),
)
},
|resolver| {
AgentApiBackend::new(
model.clone(),
provider,
fallback_chain.clone(),
resolver,
)
},
)
.with_env(env.clone())
.with_mcp_servers(mcp_servers.clone());
let cli = resolver
@ -723,13 +756,16 @@ mod tests {
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_interview::AutoApproveInterviewer;
use fabro_sandbox::SandboxSpec;
use fabro_store::Database;
use fabro_types::settings::SettingsLayer;
use fabro_types::{RunId, fixtures};
use fabro_vault::{SecretType, Vault};
use object_store::memory::InMemory;
use tokio::sync::RwLock as AsyncRwLock;
use super::*;
use crate::event::StoreProgressLogger;
@ -773,6 +809,38 @@ mod tests {
(graph, source)
}
fn llm_graph() -> (Graph, String) {
let source = r#"digraph test {
start [shape=Mdiamond];
writer [shape=box];
exit [shape=Msquare];
start -> writer;
writer -> exit;
}"#
.to_string();
let mut graph = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
let mut writer = Node::new("writer");
writer
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("start".to_string(), start);
graph.nodes.insert("writer".to_string(), writer);
graph.nodes.insert("exit".to_string(), exit);
graph.edges.push(Edge::new("start", "writer"));
graph.edges.push(Edge::new("writer", "exit"));
(graph, source)
}
fn test_settings(run_dir: &std::path::Path) -> RunOptions {
RunOptions {
settings: SettingsLayer::default(),
@ -882,6 +950,46 @@ mod tests {
assert!(initialized.llm_client.is_none());
}
#[tokio::test]
async fn build_registry_accepts_vault_only_llm_provider() {
let dir = tempfile::tempdir().unwrap();
let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap();
vault
.set(
"anthropic",
&serde_json::to_string(&AuthCredential {
provider: fabro_llm::Provider::Anthropic,
details: AuthDetails::ApiKey {
key: "anthropic-key".to_string(),
},
})
.unwrap(),
SecretType::Credential,
None,
)
.unwrap();
let (graph, _) = llm_graph();
let (_, llm_client, effective_dry_run) = build_registry(
&LlmSpec {
model: "claude-opus-4-6".to_string(),
provider: fabro_llm::Provider::Anthropic,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: false,
},
Arc::new(AutoApproveInterviewer),
&HashMap::new(),
&graph,
Some(Arc::new(AsyncRwLock::new(vault))),
)
.await
.unwrap();
assert!(!effective_dry_run);
assert!(llm_client.unwrap().provider_names().contains(&"anthropic"));
}
#[tokio::test]
async fn initialize_runs_setup_commands() {
let temp = tempfile::tempdir().unwrap();