mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Complete provider credential auth and scripted install
This commit is contained in:
parent
43d73cb4a1
commit
f3aa30d782
11 changed files with 1301 additions and 312 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue