diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 897da8e..62283d7 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -145,10 +145,21 @@ apw login https://example.com ### External password manager fallback When the native app broker cannot return a credential, APW can fall back to a -configured external password manager CLI provider. The fallback executable path -is security-sensitive and is validated before APW invokes it. +configured external password manager CLI provider. The external CLI fallback is +opt-in and only runs when explicitly enabled for the credential request. +Configure it in `~/.apw/config.json` with an absolute executable path: + +```json +{ + "fallbackProvider": "bitwarden", + "fallbackProviderPath": "/opt/homebrew/bin/bw" +} +``` + +Supported providers are `bitwarden` and `1password`. -`fallbackProviderPath` must follow these rules: +The fallback executable path is security-sensitive and is validated before APW +invokes it. `fallbackProviderPath` must follow these rules: - Use an absolute path. Relative paths and `~` expansion are rejected. - Resolve through `realpath`/canonicalization. Symlinks are followed and the @@ -157,6 +168,28 @@ is security-sensitive and is validated before APW invokes it. - Use `0755` permissions or more restrictive permissions. Group-writable, world-writable, and special-mode executables are rejected. +External provider executions are bounded by default: + +- `fallbackProviderTimeoutMs`: per-process timeout in milliseconds. Default: + `5000`. Values less than `1` fall back to the default. A timed-out provider + process is killed and the credential request fails with a clear timeout + error. +- `fallbackProviderMaxInvocations`: maximum external provider process + invocations per APW session. Default: `10`. Set `0` to block external + provider invocations for the current session. When the limit is exceeded, APW + returns a clear error instead of executing the provider again. + +Example with explicit limits: + +```json +{ + "fallbackProvider": "1password", + "fallbackProviderPath": "/opt/homebrew/bin/op", + "fallbackProviderTimeoutMs": 3000, + "fallbackProviderMaxInvocations": 6 +} +``` + ## Diagnostics ### Machine-readable status diff --git a/rust/src/client.rs b/rust/src/client.rs index b09e3d4..0d1d73e 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -618,6 +618,8 @@ impl ApplePasswordManager { bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc::now().timestamp().to_string(), }); @@ -1064,6 +1066,8 @@ impl ApplePasswordManager { bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc::now().timestamp().to_string(), }); @@ -2672,6 +2676,8 @@ mod tests { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -2764,6 +2770,8 @@ mod tests { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: (chrono::Utc::now() - chrono::Duration::days(45)).to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, diff --git a/rust/src/native_app.rs b/rust/src/native_app.rs index 309c5e2..c4f8150 100644 --- a/rust/src/native_app.rs +++ b/rust/src/native_app.rs @@ -1,6 +1,6 @@ use crate::error::{APWError, Result}; use crate::logging; -use crate::types::{ExternalFallbackProvider, Status, MAX_MESSAGE_BYTES, VERSION}; +use crate::types::{APWConfigV1, ExternalFallbackProvider, Status, MAX_MESSAGE_BYTES, VERSION}; use crate::utils::{read_config_file, read_config_file_or_empty, validate_external_provider_path}; use serde_json::{json, Value}; use std::env; @@ -11,8 +11,8 @@ use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; -use std::time::Duration; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; const NATIVE_APP_BUNDLE_NAME: &str = "APW.app"; const NATIVE_APP_EXECUTABLE_NAME: &str = "APW"; @@ -25,12 +25,11 @@ const NATIVE_APP_FILE_MODE: u32 = 0o600; const MAX_BROKER_BYTES: usize = MAX_MESSAGE_BYTES; const MAX_BROKER_LOG_BYTES: u64 = 10 * 1024 * 1024; const NATIVE_APP_SOCKET_TIMEOUT_MS: u64 = 3_000; +const EXTERNAL_PROVIDER_DEFAULT_TIMEOUT_MS: u64 = 5_000; +const EXTERNAL_PROVIDER_DEFAULT_MAX_INVOCATIONS: u32 = 10; +const EXTERNAL_PROVIDER_POLL_MS: u64 = 20; const CONNECT_RETRIES: usize = 10; const CONNECT_RETRY_DELAY_MS: u64 = 200; -#[cfg(not(test))] -const EXTERNAL_FALLBACK_CLI_TIMEOUT_MS: u64 = 10_000; -#[cfg(test)] -const EXTERNAL_FALLBACK_CLI_TIMEOUT_MS: u64 = 500; fn diagnostic( status: &str, @@ -142,6 +141,10 @@ pub fn native_app_broker_log_path() -> PathBuf { native_app_runtime_dir().join(NATIVE_APP_BROKER_LOG_NAME) } +fn native_app_fallback_provider_state_path() -> PathBuf { + native_app_runtime_dir().join("fallback-provider-session.json") +} + pub fn native_app_install_dir() -> PathBuf { native_app_runtime_dir().join("installed") } @@ -860,69 +863,114 @@ fn native_app_request(intent: &str, url: &str) -> Result { Ok(payload) } -fn external_provider_login(url: &str) -> Result> { - let config = match read_config_file() { - Ok(config) => config, - Err(error) if error.message.starts_with("No config file at ") => return Ok(None), - Err(error) => return Err(error), - }; - let Some(provider) = config.fallback_provider else { - return Ok(None); - }; - let Some(provider_path) = config.fallback_provider_path.as_deref() else { +struct ExternalProviderLimits { + session_id: String, + timeout_ms: u64, + max_invocations: u32, +} + +struct ExternalProviderCommandOutput { + success: bool, + stdout: Vec, + stderr: Vec, +} + +fn external_provider_limits(config: &APWConfigV1) -> ExternalProviderLimits { + ExternalProviderLimits { + session_id: config.created_at.clone(), + timeout_ms: config + .fallback_provider_timeout_ms + .filter(|value| *value > 0) + .unwrap_or(EXTERNAL_PROVIDER_DEFAULT_TIMEOUT_MS), + max_invocations: config + .fallback_provider_max_invocations + .unwrap_or(EXTERNAL_PROVIDER_DEFAULT_MAX_INVOCATIONS), + } +} + +fn reserve_external_provider_invocation( + provider: ExternalFallbackProvider, + limits: &ExternalProviderLimits, +) -> Result<()> { + ensure_runtime_dir()?; + let path = native_app_fallback_provider_state_path(); + let state = fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()); + let current_count = state + .as_ref() + .filter(|value| value.get("sessionId").and_then(Value::as_str) == Some(&limits.session_id)) + .and_then(|value| value.get("invocations").and_then(Value::as_u64)) + .unwrap_or(0); + + if current_count >= u64::from(limits.max_invocations) { return Err(APWError::new( - Status::InvalidConfig, + Status::GenericError, format!( - "Fallback provider `{}` requires an absolute `fallbackProviderPath`.", - provider.as_str() + "External fallback provider `{}` invocation limit exceeded for this APW session (limit: {}). Reauthenticate or increase `fallbackProviderMaxInvocations`.", + provider.as_str(), + limits.max_invocations ), )); - }; - let provider_path = validate_external_provider_path(provider, provider_path)?; - - let host = url::Url::parse(url) - .map_err(|_| APWError::new(Status::InvalidParam, "Invalid URL for external fallback."))? - .host_str() - .map(str::to_string) - .ok_or_else(|| APWError::new(Status::InvalidParam, "Invalid URL for external fallback."))?; - - let payload = match provider { - ExternalFallbackProvider::OnePassword => { - load_1password_credential(&provider_path, &host, url)? - } - ExternalFallbackProvider::Bitwarden => { - load_bitwarden_credential(&provider_path, &host, url)? - } - }; - Ok(Some(payload)) -} + } -fn read_external_provider_stream_limited( - mut stream: R, -) -> std::io::Result> { - let mut output = Vec::with_capacity(MAX_BROKER_BYTES.min(8 * 1024)); - stream - .by_ref() - .take((MAX_BROKER_BYTES + 1) as u64) - .read_to_end(&mut output)?; - Ok(output) + let updated = json!({ + "sessionId": limits.session_id, + "provider": provider.as_str(), + "invocations": current_count + 1, + }); + fs::write( + &path, + serde_json::to_vec_pretty(&updated).map_err(|error| { + APWError::new( + Status::GenericError, + format!("Failed to encode fallback provider invocation state: {error}"), + ) + })?, + ) + .map_err(|error| { + APWError::new( + Status::InvalidConfig, + format!("Failed to write fallback provider invocation state: {error}"), + ) + })?; + set_permissions(&path, NATIVE_APP_FILE_MODE)?; + Ok(()) } fn run_external_provider_command( - mut command: Command, - provider_name: &str, + provider: ExternalFallbackProvider, path: &Path, - host: &str, -) -> Result { + config: &APWConfigV1, + args: &[&str], +) -> Result { + let limits = external_provider_limits(config); + reserve_external_provider_invocation(provider, &limits)?; + + let mut command = Command::new(path); command + .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + // SAFETY: `pre_exec` runs after fork and before exec. The closure only calls + // `libc::setsid()`, which is async-signal-safe and avoids orphaning provider + // subprocesses when a timeout kills the process group. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().map_err(|error| { APWError::new( Status::ProcessNotRunning, format!( - "Failed to execute {provider_name} CLI at {}: {error}", + "Failed to execute {} CLI at {}: {error}", + provider.as_str(), path.display() ), ) @@ -931,7 +979,8 @@ fn run_external_provider_command( APWError::new( Status::ProcessNotRunning, format!( - "Failed to capture {provider_name} CLI stdout at {}.", + "Failed to capture {} CLI stdout at {}.", + provider.as_str(), path.display() ), ) @@ -940,110 +989,158 @@ fn run_external_provider_command( APWError::new( Status::ProcessNotRunning, format!( - "Failed to capture {provider_name} CLI stderr at {}.", + "Failed to capture {} CLI stderr at {}.", + provider.as_str(), path.display() ), ) })?; - let stdout_reader = std::thread::spawn(move || read_external_provider_stream_limited(stdout)); - let stderr_reader = std::thread::spawn(move || read_external_provider_stream_limited(stderr)); + let stdout_reader = std::thread::spawn(move || { + let mut output = Vec::with_capacity(MAX_BROKER_BYTES.min(8 * 1024)); + stdout + .take((MAX_BROKER_BYTES + 1) as u64) + .read_to_end(&mut output) + .map(|_| output) + }); + let stderr_reader = std::thread::spawn(move || { + let mut output = Vec::with_capacity(MAX_BROKER_BYTES.min(8 * 1024)); + stderr + .take((MAX_BROKER_BYTES + 1) as u64) + .read_to_end(&mut output) + .map(|_| output) + }); + let started = Instant::now(); + let timeout = Duration::from_millis(limits.timeout_ms); - let timeout = Duration::from_millis(EXTERNAL_FALLBACK_CLI_TIMEOUT_MS); - let started = std::time::Instant::now(); let status = loop { if let Some(status) = child.try_wait().map_err(|error| { APWError::new( Status::ProcessNotRunning, format!( - "Failed while waiting for {provider_name} CLI at {}: {error}", + "Failed to inspect {} CLI at {}: {error}", + provider.as_str(), path.display() ), ) })? { break status; } + if started.elapsed() >= timeout { + let pid = child.id() as i32; + unsafe { + libc::kill(-pid, libc::SIGKILL); + } let _ = child.kill(); let _ = child.wait(); return Err(APWError::new( Status::CommunicationTimeout, format!( - "{provider_name} CLI timed out after {} ms while looking up a credential for {host}.", - EXTERNAL_FALLBACK_CLI_TIMEOUT_MS + "External fallback provider `{}` timed out after {}ms and was killed.", + provider.as_str(), + limits.timeout_ms ), )); } - std::thread::sleep(Duration::from_millis(20)); + + std::thread::sleep(Duration::from_millis(EXTERNAL_PROVIDER_POLL_MS)); }; let stdout = stdout_reader .join() .map_err(|_| { APWError::new( - Status::ProcessNotRunning, - format!( - "Failed to read {provider_name} CLI stdout at {}.", - path.display() - ), + Status::ProtoInvalidResponse, + format!("Failed to read {} CLI stdout.", provider.as_str()), ) })? .map_err(|error| { APWError::new( - Status::ProcessNotRunning, - format!( - "Failed to read {provider_name} CLI stdout at {}: {error}", - path.display() - ), + Status::ProtoInvalidResponse, + format!("Failed to read {} CLI stdout: {error}", provider.as_str()), ) })?; let stderr = stderr_reader .join() .map_err(|_| { APWError::new( - Status::ProcessNotRunning, - format!( - "Failed to read {provider_name} CLI stderr at {}.", - path.display() - ), + Status::ProtoInvalidResponse, + format!("Failed to read {} CLI stderr.", provider.as_str()), ) })? .map_err(|error| { APWError::new( - Status::ProcessNotRunning, - format!( - "Failed to read {provider_name} CLI stderr at {}: {error}", - path.display() - ), + Status::ProtoInvalidResponse, + format!("Failed to read {} CLI stderr: {error}", provider.as_str()), ) })?; - if stdout.len() > MAX_BROKER_BYTES || stderr.len() > MAX_BROKER_BYTES { return Err(APWError::new( Status::ProtoInvalidResponse, format!( - "{provider_name} CLI returned more than {MAX_BROKER_BYTES} bytes while looking up a credential for {host}." + "{} CLI returned more than {MAX_BROKER_BYTES} bytes while looking up a credential.", + provider.as_str() ), )); } - Ok(Output { - status, + Ok(ExternalProviderCommandOutput { + success: status.success(), stdout, stderr, }) } -fn load_1password_credential(path: &Path, host: &str, raw_url: &str) -> Result { - let mut command = Command::new(path); - command - .arg("item") - .arg("list") - .arg("--categories") - .arg("LOGIN") - .arg("--format") - .arg("json"); - let list_output = run_external_provider_command(command, "1Password", path, host)?; - if !list_output.status.success() { +fn external_provider_login(url: &str) -> Result> { + let config = match read_config_file() { + Ok(config) => config, + Err(error) if error.message.starts_with("No config file at ") => return Ok(None), + Err(error) => return Err(error), + }; + let Some(provider) = config.fallback_provider else { + return Ok(None); + }; + let Some(provider_path) = config.fallback_provider_path.as_deref() else { + return Err(APWError::new( + Status::InvalidConfig, + format!( + "Fallback provider `{}` requires an absolute `fallbackProviderPath`.", + provider.as_str() + ), + )); + }; + let provider_path = validate_external_provider_path(provider, provider_path)?; + + let host = url::Url::parse(url) + .map_err(|_| APWError::new(Status::InvalidParam, "Invalid URL for external fallback."))? + .host_str() + .map(str::to_string) + .ok_or_else(|| APWError::new(Status::InvalidParam, "Invalid URL for external fallback."))?; + + let payload = match provider { + ExternalFallbackProvider::OnePassword => { + load_1password_credential(&provider_path, &config, &host, url)? + } + ExternalFallbackProvider::Bitwarden => { + load_bitwarden_credential(&provider_path, &config, &host, url)? + } + }; + Ok(Some(payload)) +} + +fn load_1password_credential( + path: &Path, + config: &APWConfigV1, + host: &str, + raw_url: &str, +) -> Result { + let list_output = run_external_provider_command( + ExternalFallbackProvider::OnePassword, + path, + config, + &["item", "list", "--categories", "LOGIN", "--format", "json"], + )?; + if !list_output.success { return Err(APWError::new( Status::NoResults, format!( @@ -1080,15 +1177,13 @@ fn load_1password_credential(path: &Path, host: &str, raw_url: &str) -> Result Result Result { - let mut command = Command::new(path); - command.arg("list").arg("items").arg("--search").arg(host); - let output = run_external_provider_command(command, "Bitwarden", path, host)?; - if !output.status.success() { +fn load_bitwarden_credential( + path: &Path, + config: &APWConfigV1, + host: &str, + raw_url: &str, +) -> Result { + let output = run_external_provider_command( + ExternalFallbackProvider::Bitwarden, + path, + config, + &["list", "items", "--search", host], + )?; + if !output.success { return Err(APWError::new( Status::NoResults, format!( @@ -1318,6 +1421,8 @@ mod tests { use std::os::unix::net::UnixListener; use tempfile::TempDir; + const TEST_EXTERNAL_FALLBACK_TIMEOUT_MS: u64 = 500; + fn with_temp_home(run: F) -> R where F: FnOnce() -> R, @@ -1595,17 +1700,22 @@ else: &provider_path, format!( "#!/bin/sh\nsleep {}\n", - (EXTERNAL_FALLBACK_CLI_TIMEOUT_MS / 1_000) + 2 + (TEST_EXTERNAL_FALLBACK_TIMEOUT_MS / 1_000) + 2 ), ) .unwrap(); let mut permissions = fs::metadata(&provider_path).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(&provider_path, permissions).unwrap(); + let config = APWConfigV1 { + fallback_provider_timeout_ms: Some(TEST_EXTERNAL_FALLBACK_TIMEOUT_MS), + ..APWConfigV1::default() + }; let started = std::time::Instant::now(); let error = load_1password_credential( &provider_path, + &config, "vault.example.com", "https://vault.example.com", ) @@ -1613,7 +1723,9 @@ else: assert_eq!(error.code, Status::CommunicationTimeout); assert!( started.elapsed() - < Duration::from_millis(EXTERNAL_FALLBACK_CLI_TIMEOUT_MS.saturating_add(2_000)) + < Duration::from_millis( + TEST_EXTERNAL_FALLBACK_TIMEOUT_MS.saturating_add(2_000) + ) ); }); } @@ -1628,17 +1740,22 @@ else: &provider_path, format!( "#!/bin/sh\nsleep {}\n", - (EXTERNAL_FALLBACK_CLI_TIMEOUT_MS / 1_000) + 2 + (TEST_EXTERNAL_FALLBACK_TIMEOUT_MS / 1_000) + 2 ), ) .unwrap(); let mut permissions = fs::metadata(&provider_path).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(&provider_path, permissions).unwrap(); + let config = APWConfigV1 { + fallback_provider_timeout_ms: Some(TEST_EXTERNAL_FALLBACK_TIMEOUT_MS), + ..APWConfigV1::default() + }; let started = std::time::Instant::now(); let error = load_bitwarden_credential( &provider_path, + &config, "vault.example.com", "https://vault.example.com", ) @@ -1646,7 +1763,9 @@ else: assert_eq!(error.code, Status::CommunicationTimeout); assert!( started.elapsed() - < Duration::from_millis(EXTERNAL_FALLBACK_CLI_TIMEOUT_MS.saturating_add(2_000)) + < Duration::from_millis( + TEST_EXTERNAL_FALLBACK_TIMEOUT_MS.saturating_add(2_000) + ) ); }); } @@ -1671,9 +1790,11 @@ sys.stdout.write('[' + ('x' * {}) + ']') let mut permissions = fs::metadata(&provider_path).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(&provider_path, permissions).unwrap(); + let config = APWConfigV1::default(); let error = load_1password_credential( &provider_path, + &config, "vault.example.com", "https://vault.example.com", ) @@ -1703,9 +1824,11 @@ sys.stdout.write('[' + ('x' * {}) + ']') let mut permissions = fs::metadata(&provider_path).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(&provider_path, permissions).unwrap(); + let config = APWConfigV1::default(); let error = load_bitwarden_credential( &provider_path, + &config, "vault.example.com", "https://vault.example.com", ) @@ -1778,6 +1901,119 @@ print(json.dumps([{"login":{"username":"alice@example.com","password":"secret"," }); } + #[test] + #[serial] + fn external_provider_timeout_kills_provider_process() { + with_temp_home(|| { + let provider_dir = TempDir::new().unwrap(); + let provider_path = provider_dir.path().join("bw"); + let pid_path = provider_dir.path().join("provider.pid"); + fs::write( + &provider_path, + format!( + r#"#!/usr/bin/env python3 +import os +import pathlib +import time + +pathlib.Path({pid_path:?}).write_text(str(os.getpid()), encoding="utf-8") +time.sleep(10) +"#, + pid_path = pid_path.display().to_string() + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&provider_path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&provider_path, permissions).unwrap(); + + let config_root = home_dir().join(".apw"); + fs::create_dir_all(&config_root).unwrap(); + let config = APWConfigV1 { + username: "demo".to_string(), + shared_key: "demo-shared-key".to_string(), + fallback_provider: Some(ExternalFallbackProvider::Bitwarden), + fallback_provider_path: Some(provider_path.display().to_string()), + fallback_provider_timeout_ms: Some(500), + fallback_provider_max_invocations: Some(5), + ..APWConfigV1::default() + }; + fs::write( + config_root.join("config.json"), + serde_json::to_vec_pretty(&config).unwrap(), + ) + .unwrap(); + + let started = Instant::now(); + let error = native_app_login("https://vault.example.com", true).unwrap_err(); + assert_eq!(error.code, Status::CommunicationTimeout); + assert!(error.message.contains("timed out after 500ms")); + assert!(error.message.contains("was killed")); + assert!(started.elapsed() < Duration::from_secs(3)); + + let pid: i32 = fs::read_to_string(pid_path).unwrap().parse().unwrap(); + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + }); + } + + #[test] + #[serial] + fn external_provider_invocation_limit_returns_clear_error() { + with_temp_home(|| { + let provider_dir = TempDir::new().unwrap(); + let provider_path = provider_dir.path().join("bw"); + fs::write( + &provider_path, + r#"#!/usr/bin/env python3 +import json +import sys + +if sys.argv[1:] == ["list", "items", "--search", "vault.example.com"]: + print(json.dumps([ + { + "name": "Work Vault", + "login": { + "username": "alice@example.com", + "password": "secret-bitwarden", + "uris": [{"uri": "https://vault.example.com/login"}] + } + } + ])) +else: + raise SystemExit(1) +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&provider_path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&provider_path, permissions).unwrap(); + + let config_root = home_dir().join(".apw"); + fs::create_dir_all(&config_root).unwrap(); + let config = APWConfigV1 { + username: "demo".to_string(), + shared_key: "demo-shared-key".to_string(), + fallback_provider: Some(ExternalFallbackProvider::Bitwarden), + fallback_provider_path: Some(provider_path.display().to_string()), + fallback_provider_max_invocations: Some(1), + ..APWConfigV1::default() + }; + fs::write( + config_root.join("config.json"), + serde_json::to_vec_pretty(&config).unwrap(), + ) + .unwrap(); + + let payload = native_app_login("https://vault.example.com", true).unwrap(); + assert_eq!(payload["source"], "bitwarden"); + + let error = native_app_login("https://vault.example.com", true).unwrap_err(); + assert_eq!(error.code, Status::GenericError); + assert!(error.message.contains("invocation limit exceeded")); + assert!(error.message.contains("fallbackProviderMaxInvocations")); + }); + } + #[test] #[serial] fn invalid_socket_permissions_fall_back_to_direct_exec() { diff --git a/rust/src/types.rs b/rust/src/types.rs index b4279f9..77bd9e2 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -179,6 +179,20 @@ pub struct APWConfigV1 { skip_serializing_if = "Option::is_none" )] pub fallback_provider_path: Option, + #[serde( + rename = "fallbackProviderTimeoutMs", + alias = "fallback_provider_timeout_ms", + default, + skip_serializing_if = "Option::is_none" + )] + pub fallback_provider_timeout_ms: Option, + #[serde( + rename = "fallbackProviderMaxInvocations", + alias = "fallback_provider_max_invocations", + default, + skip_serializing_if = "Option::is_none" + )] + pub fallback_provider_max_invocations: Option, #[serde(rename = "createdAt")] pub created_at: String, } @@ -202,6 +216,8 @@ impl Default for APWConfigV1 { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc::now().to_rfc3339(), } } @@ -267,6 +283,18 @@ pub struct APWRuntimeConfig { skip_serializing_if = "Option::is_none" )] pub fallback_provider_path: Option, + #[serde( + rename = "fallbackProviderTimeoutMs", + default, + skip_serializing_if = "Option::is_none" + )] + pub fallback_provider_timeout_ms: Option, + #[serde( + rename = "fallbackProviderMaxInvocations", + default, + skip_serializing_if = "Option::is_none" + )] + pub fallback_provider_max_invocations: Option, #[serde(rename = "createdAt")] pub created_at: String, } @@ -289,6 +317,8 @@ impl Default for APWRuntimeConfig { bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc::now().to_rfc3339(), } } diff --git a/rust/src/utils.rs b/rust/src/utils.rs index ca08803..f4327f8 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -234,6 +234,8 @@ fn normalize_legacy_config(raw: APWConfig) -> APWConfigV1 { }, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, last_launch_status: None, last_launch_error: None, last_launch_strategy: None, @@ -345,6 +347,8 @@ pub fn read_config_file_or_empty() -> APWConfigV1 { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }) } @@ -373,6 +377,8 @@ pub fn read_config(opts: Option) -> Result bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }); } @@ -402,6 +408,8 @@ pub fn read_config(opts: Option) -> Result bridge_last_error: None, fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: Utc.timestamp_nanos(0).to_rfc3339(), }); } @@ -430,6 +438,8 @@ pub fn read_config(opts: Option) -> Result bridge_last_error: raw.bridge_last_error, fallback_provider: raw.fallback_provider, fallback_provider_path: raw.fallback_provider_path, + fallback_provider_timeout_ms: raw.fallback_provider_timeout_ms, + fallback_provider_max_invocations: raw.fallback_provider_max_invocations, created_at: raw.created_at, }); } @@ -496,6 +506,8 @@ pub fn read_config(opts: Option) -> Result bridge_last_error: raw.bridge_last_error, fallback_provider: raw.fallback_provider, fallback_provider_path: raw.fallback_provider_path, + fallback_provider_timeout_ms: raw.fallback_provider_timeout_ms, + fallback_provider_max_invocations: raw.fallback_provider_max_invocations, created_at: raw.created_at, }) } @@ -720,6 +732,12 @@ pub fn write_config(input: WriteConfigInput) -> Result { fallback_provider_path: existing .as_ref() .and_then(|value| value.fallback_provider_path.clone()), + fallback_provider_timeout_ms: existing + .as_ref() + .and_then(|value| value.fallback_provider_timeout_ms), + fallback_provider_max_invocations: existing + .as_ref() + .and_then(|value| value.fallback_provider_max_invocations), }; let mut serialized = serde_json::to_string_pretty(&updated).map_err(|error| { @@ -1004,6 +1022,8 @@ mod tests { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: (chrono::Utc::now() - chrono::Duration::days(40)).to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -1061,6 +1081,8 @@ mod tests { secret_source: Some(SecretSource::Keychain), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None, @@ -1135,6 +1157,8 @@ mod tests { secret_source: Some(SecretSource::File), fallback_provider: None, fallback_provider_path: None, + fallback_provider_timeout_ms: None, + fallback_provider_max_invocations: None, created_at: chrono::Utc::now().to_rfc3339(), runtime_mode: RuntimeMode::Auto, last_launch_status: None,