From b6482910e517d00dfc3c4a2f2d3e417c9348f7f6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 5 Sep 2026 14:05:48 -0400 Subject: [PATCH 001/151] Remove expired startup secret migrations --- docs/internal/migrations-strategy.md | 4 +- docs/internal/server-secrets-strategy.md | 2 +- .../administration/server-configuration.mdx | 2 +- .../fabro-cli/src/commands/server/start.rs | 5 +- .../fabro-cli/tests/it/cmd/server_start.rs | 9 + .../2026051801_legacy_vault_entries.rs | 275 ------------------ ...01_optional_server_env_secrets_to_vault.rs | 172 ----------- lib/apps/fabro-server/src/lib.rs | 2 +- lib/apps/fabro-server/src/migrations.rs | 28 -- lib/apps/fabro-server/src/serve.rs | 27 +- lib/apps/fabro-server/src/server/tests.rs | 122 -------- lib/apps/fabro-server/src/startup.rs | 246 +--------------- lib/apps/fabro-server/src/test_support.rs | 3 +- 13 files changed, 19 insertions(+), 878 deletions(-) delete mode 100644 lib/apps/fabro-server/migrations/2026051801_legacy_vault_entries.rs delete mode 100644 lib/apps/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs diff --git a/docs/internal/migrations-strategy.md b/docs/internal/migrations-strategy.md index 318463a58..026abe844 100644 --- a/docs/internal/migrations-strategy.md +++ b/docs/internal/migrations-strategy.md @@ -20,7 +20,7 @@ The crate-local `src/migrations.rs` module is the registry. It imports numbered Examples: - `fabro-config` owns settings-file migrations. -- `fabro-server` owns server startup migrations for `server.env` and vault files. +- `fabro-server` owns server startup activation migrations for SQLite blob storage and run history. Keep migration APIs `pub(crate)` unless another crate genuinely orchestrates the migration. @@ -125,7 +125,7 @@ If a migration removes entries from a file after writing another store, write th Choose the error policy deliberately. -Use warn-and-continue only when the normal path may still succeed and compatibility is best-effort. The legacy vault-entry migration does this because an unreadable legacy shape should not block loading an otherwise usable vault file. +Use warn-and-continue only when the normal path may still succeed and compatibility is best-effort. Return an error when the migration found data it must move or rewrite and cannot do so safely. This gives operators a precise migration failure instead of a later, misleading startup error. diff --git a/docs/internal/server-secrets-strategy.md b/docs/internal/server-secrets-strategy.md index df4160886..a6546f751 100644 --- a/docs/internal/server-secrets-strategy.md +++ b/docs/internal/server-secrets-strategy.md @@ -119,7 +119,7 @@ Bootstrap secrets come from one of two sources: Optional integration secrets are provisioned into the vault, usually with `fabro secret set` or `fabro install`. -There is no startup-time secret generation. A temporary startup migration moves recognized legacy optional secrets from process env or `server.env` into the vault, removes matching `server.env` entries after writing a backup, and logs conflicts by key name only. Runtime lookup remains vault-only after that migration step. See [migrations-strategy.md](migrations-strategy.md) for the migration pattern. +There is no startup-time secret generation or import of optional integration secrets from process env or `server.env`. The compatibility migrations for those sources and pre-token/OAuth vault entries have been removed. The separate one-time import of current-format `secrets.json` entries into SQLite remains supported. ## Subprocess Boundaries diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index d37b7223a..6fc9be4fb 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -371,7 +371,7 @@ Fabro splits server-runtime secrets into two scopes: `server.env` is not used for Slack, Daytona, Brave Search, Venice Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`. -During startup, Fabro temporarily migrates recognized legacy optional integration secrets from process env or `server.env` into the vault. When a matching `server.env` entry can be safely removed, Fabro writes a hidden backup beside `server.env` first. Process env values cannot be cleaned up automatically, so remove those from your deployment environment after the vault contains the secret. +Startup does not import optional integration secrets from process env or `server.env`, or rewrite old `credential` / `environment` vault entries. Provision these secrets with the commands above before upgrading an installation that still uses those retired sources or formats. Fabro no longer auto-loads `.env` files. Provider API keys are required for the models you want to use; everything else is optional. diff --git a/lib/apps/fabro-cli/src/commands/server/start.rs b/lib/apps/fabro-cli/src/commands/server/start.rs index c703555a9..233465885 100644 --- a/lib/apps/fabro-cli/src/commands/server/start.rs +++ b/lib/apps/fabro-cli/src/commands/server/start.rs @@ -13,9 +13,7 @@ use fabro_config::user::default_settings_path; use fabro_config::{RuntimeDirectory, Storage}; use fabro_server::jwt_auth::auth_method_name; use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start}; -use fabro_server::{ - migrate_startup_vault, process_env_snapshot, validate_startup, validate_startup_configuration, -}; +use fabro_server::{process_env_snapshot, validate_startup, validate_startup_configuration}; use fabro_static::EnvVars; use fabro_types::settings::{LogDestination, ServerAuthMethod}; use fabro_util::printer::Printer; @@ -291,7 +289,6 @@ async fn execute_daemon( } validate_startup_configuration(&resolved_settings)?; let storage = Storage::new(storage_dir); - migrate_startup_vault(storage.secrets_path()); let startup_vault = SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path()) .await .context("loading secrets for startup validation")?; diff --git a/lib/apps/fabro-cli/tests/it/cmd/server_start.rs b/lib/apps/fabro-cli/tests/it/cmd/server_start.rs index 899405856..68c1ceb13 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/server_start.rs @@ -292,6 +292,15 @@ methods = [] server_env: &[("SESSION_SECRET", TEST_SESSION_SECRET)], expected_error: "Fabro server refuses to start: github auth is enabled but GITHUB_APP_CLIENT_SECRET is not configured in the vault.", }, + StartupFailureCase { + name: "github-client-secret-only-in-server-env", + settings: GITHUB_SETTINGS, + server_env: &[ + ("SESSION_SECRET", TEST_SESSION_SECRET), + ("GITHUB_APP_CLIENT_SECRET", "unprovisioned-client-secret"), + ], + expected_error: "Fabro server refuses to start: github auth is enabled but GITHUB_APP_CLIENT_SECRET is not configured in the vault.", + }, StartupFailureCase { name: "empty-auth-methods", settings: EMPTY_AUTH_METHODS_SETTINGS, diff --git a/lib/apps/fabro-server/migrations/2026051801_legacy_vault_entries.rs b/lib/apps/fabro-server/migrations/2026051801_legacy_vault_entries.rs deleted file mode 100644 index d37d5213d..000000000 --- a/lib/apps/fabro-server/migrations/2026051801_legacy_vault_entries.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Temporary compatibility shim for pre-token/oauth vault files. -//! -//! Delete this module after 2026-08-18, once supported installs have had a -//! release window to rewrite `credential` / `environment` entries to the -//! `oauth` / `token` schemas. - -#![expect( - clippy::disallowed_methods, - reason = "Temporary startup migration uses synchronous vault file I/O before serving requests." -)] - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, bail}; -use fabro_auth::{OAuthConfig, OAuthCredential, OAuthTokens}; -use serde::Deserialize; -use serde_json::{Map, Value}; - -pub(crate) const REMOVAL_DEADLINE: &str = "2026-08-18"; - -#[derive(Debug, Default, PartialEq, Eq)] -pub(crate) struct LegacyVaultMigrationReport { - pub(crate) migrated_entries: usize, - pub(crate) skipped_entries: usize, - pub(crate) backup_path: Option, -} - -impl LegacyVaultMigrationReport { - pub(crate) fn changed(&self) -> bool { - self.migrated_entries > 0 || self.skipped_entries > 0 - } -} - -#[derive(Debug, Deserialize)] -struct LegacyAuthCredential { - provider: String, - #[serde(flatten)] - details: LegacyAuthDetails, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum LegacyAuthDetails { - ApiKey { - key: String, - }, - CodexOauth { - tokens: OAuthTokens, - config: OAuthConfig, - #[serde(default)] - account_id: Option, - }, -} - -pub(crate) fn migrate_legacy_vault_file(path: &Path) -> anyhow::Result { - let contents = match std::fs::read_to_string(path) { - Ok(contents) => contents, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(LegacyVaultMigrationReport::default()); - } - Err(err) => return Err(err).with_context(|| format!("read vault {}", path.display())), - }; - let entries = parse_vault_entries(&contents)?; - let (next_entries, migrated_entries, skipped_entries) = rewrite_entries(entries); - let mut report = LegacyVaultMigrationReport { - migrated_entries, - skipped_entries, - backup_path: None, - }; - if !report.changed() { - return Ok(report); - } - - let backup_path = backup_vault_file(path)?; - write_vault_entries(path, &next_entries)?; - report.backup_path = Some(backup_path); - Ok(report) -} - -fn parse_vault_entries(contents: &str) -> anyhow::Result> { - let value: Value = serde_json::from_str(contents).context("parse vault JSON")?; - match value { - Value::Object(entries) => Ok(entries), - _ => bail!("vault JSON root must be an object"), - } -} - -fn rewrite_entries(entries: Map) -> (Map, usize, usize) { - let mut next_entries = Map::new(); - let mut occupied = HashSet::new(); - for (name, entry) in &entries { - if matches!( - entry.get("type").and_then(Value::as_str), - Some("token" | "oauth" | "file") - ) { - next_entries.insert(name.clone(), entry.clone()); - occupied.insert(name.clone()); - } - } - - let mut migrated_entries = 0; - let mut skipped_entries = 0; - for (name, entry) in entries { - match entry.get("type").and_then(Value::as_str) { - Some("token" | "oauth" | "file") => {} - Some("environment") => { - if insert_rewritten_entry( - &mut next_entries, - &mut occupied, - name, - rewrite_entry(entry, "token", None), - ) { - migrated_entries += 1; - } else { - skipped_entries += 1; - } - } - Some("credential") => match legacy_credential_entry(&name, &entry) { - Some((target_name, rewritten)) => { - if insert_rewritten_entry( - &mut next_entries, - &mut occupied, - target_name, - rewritten, - ) { - migrated_entries += 1; - } else { - skipped_entries += 1; - } - } - None => skipped_entries += 1, - }, - _ => skipped_entries += 1, - } - } - - (next_entries, migrated_entries, skipped_entries) -} - -fn insert_rewritten_entry( - entries: &mut Map, - occupied: &mut HashSet, - name: String, - entry: Value, -) -> bool { - if !occupied.insert(name.clone()) { - return false; - } - entries.insert(name, entry); - true -} - -fn legacy_credential_entry(name: &str, entry: &Value) -> Option<(String, Value)> { - let value = entry.get("value").and_then(Value::as_str)?; - let credential: LegacyAuthCredential = serde_json::from_str(value).ok()?; - match credential.details { - LegacyAuthDetails::ApiKey { key } => { - let target_name = api_key_secret_name(&credential.provider)?; - Some(( - target_name, - rewrite_entry(entry.clone(), "token", Some(key)), - )) - } - LegacyAuthDetails::CodexOauth { - tokens, - config, - account_id, - } if credential.provider == "openai" && name == "openai_codex" => { - let credential = OAuthCredential { - tokens, - config, - account_id, - }; - let value = serde_json::to_string(&credential).ok()?; - Some(( - "OPENAI_CODEX".to_string(), - rewrite_entry(entry.clone(), "oauth", Some(value)), - )) - } - LegacyAuthDetails::CodexOauth { .. } => None, - } -} - -fn rewrite_entry(mut entry: Value, secret_type: &str, value: Option) -> Value { - if let Value::Object(fields) = &mut entry { - fields.insert("type".to_string(), Value::String(secret_type.to_string())); - if let Some(value) = value { - fields.insert("value".to_string(), Value::String(value)); - } - } - entry -} - -fn api_key_secret_name(provider: &str) -> Option { - let mut name = String::new(); - for ch in provider.chars() { - if ch.is_ascii_alphanumeric() { - name.push(ch.to_ascii_uppercase()); - } else if !name.ends_with('_') { - name.push('_'); - } - } - while name.ends_with('_') { - name.pop(); - } - if name.is_empty() { - return None; - } - if !name.ends_with("_API_KEY") { - name.push_str("_API_KEY"); - } - Some(name) -} - -fn backup_vault_file(path: &Path) -> anyhow::Result { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("secrets.json"); - let backup_path = parent.join(format!( - ".{file_name}.legacy-vault-migration-{}.bak", - ulid::Ulid::new() - )); - std::fs::copy(path, &backup_path).with_context(|| { - format!( - "copy vault {} to backup {}", - path.display(), - backup_path.display() - ) - })?; - set_private_permissions(&backup_path)?; - Ok(backup_path) -} - -fn write_vault_entries(path: &Path, entries: &Map) -> anyhow::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - std::fs::create_dir_all(parent) - .with_context(|| format!("create vault directory {}", parent.display()))?; - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("secrets.json"); - let tmp_path = parent.join(format!( - ".{file_name}.legacy-vault-migration-tmp-{}", - ulid::Ulid::new() - )); - let json = serde_json::to_vec_pretty(entries).context("serialize migrated vault JSON")?; - std::fs::write(&tmp_path, json) - .with_context(|| format!("write migrated vault temp file {}", tmp_path.display()))?; - set_private_permissions(&tmp_path)?; - std::fs::rename(&tmp_path, path).with_context(|| { - format!( - "rename migrated vault temp file {} to {}", - tmp_path.display(), - path.display() - ) - })?; - Ok(()) -} - -#[cfg(unix)] -fn set_private_permissions(path: &Path) -> anyhow::Result<()> { - use std::os::unix::fs::PermissionsExt; - - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("set private permissions on {}", path.display()))?; - Ok(()) -} - -#[cfg(not(unix))] -fn set_private_permissions(_path: &Path) -> anyhow::Result<()> { - Ok(()) -} diff --git a/lib/apps/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs b/lib/apps/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs deleted file mode 100644 index ab2f03650..000000000 --- a/lib/apps/fabro-server/migrations/2026052501_optional_server_env_secrets_to_vault.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Temporary compatibility shim for optional secrets that used to live in -//! `server.env`. -//! -//! Delete this migration after 2026-08-18, once supported installs have had a -//! release window to move optional integration secrets into the vault. - -#![expect( - clippy::disallowed_methods, - reason = "Temporary startup migration uses synchronous file I/O before serving requests." -)] - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use anyhow::Context as _; -use fabro_config::envfile::{self, EnvFileRemoval}; -use fabro_static::{EnvVars, optional_vault_secrets}; -use fabro_vault::{SecretStore, SecretStoreWrite, SecretType}; - -pub(crate) const REMOVAL_DEADLINE: &str = "2026-08-18"; - -#[derive(Debug, Default, PartialEq, Eq)] -pub(crate) struct OptionalServerEnvSecretsMigrationReport { - pub(crate) migrated_secrets: usize, - pub(crate) removed_env_entries: usize, - pub(crate) preserved_env_entries: usize, - pub(crate) backup_path: Option, - pub(crate) warnings: Vec, -} - -impl OptionalServerEnvSecretsMigrationReport { - pub(crate) fn changed(&self) -> bool { - self.migrated_secrets > 0 || self.removed_env_entries > 0 - } -} - -pub(crate) async fn migrate_to_store( - store: &SecretStore, - server_env_path: &Path, - env_entries: &HashMap, -) -> anyhow::Result { - let server_env_entries = envfile::read_env_file(server_env_path) - .with_context(|| format!("read server env file {}", server_env_path.display()))?; - let stored = store.snapshot().await?; - let mut writes = Vec::new(); - let mut env_removals = Vec::new(); - let mut warnings = Vec::new(); - let mut preserved_env_entries = 0; - - for &name in optional_vault_secrets() { - let process_value = env_entries.get(name); - let file_value = server_env_entries.get(name); - if let Some(entry) = stored.get_entry(name) { - if let Some(file_value) = file_value { - if file_value == &entry.value { - env_removals.push(env_removal(name)); - } else { - preserved_env_entries += 1; - warnings.push(format!( - "Preserved {name} in server.env because the secret store already contains a different value" - )); - } - } - continue; - } - - let value = match (process_value, file_value) { - (Some(value), Some(file_value)) => { - if value == file_value { - env_removals.push(env_removal(name)); - } else { - preserved_env_entries += 1; - warnings.push(format!( - "Preserved {name} in server.env because process env takes precedence and the file value differs" - )); - } - Some(value) - } - (Some(value), None) => Some(value), - (None, Some(value)) => { - env_removals.push(env_removal(name)); - Some(value) - } - (None, None) => None, - }; - if let Some(value) = value { - writes.push(SecretStoreWrite { - name: name.to_string(), - value: value.clone(), - secret_type: secret_type_for(name), - description: None, - }); - } - } - - let mut report = OptionalServerEnvSecretsMigrationReport { - migrated_secrets: writes.len(), - removed_env_entries: 0, - preserved_env_entries, - backup_path: None, - warnings, - }; - if writes.is_empty() && env_removals.is_empty() { - return Ok(report); - } - - store.apply(&[], &writes).await?; - if !env_removals.is_empty() { - let backup_path = backup_server_env_file(server_env_path)?; - let update_report = - envfile::update_env_file_with_report(server_env_path, env_removals, Vec::new()) - .with_context(|| { - format!( - "remove migrated optional secrets from {}", - server_env_path.display() - ) - })?; - report.removed_env_entries = update_report.removed_keys.len(); - report.backup_path = Some(backup_path); - } - Ok(report) -} - -fn secret_type_for(name: &str) -> SecretType { - if name == EnvVars::GITHUB_APP_PRIVATE_KEY { - SecretType::File - } else { - SecretType::Token - } -} - -fn env_removal(name: &str) -> EnvFileRemoval { - EnvFileRemoval { - key: name.to_string(), - comment: None, - } -} - -fn backup_server_env_file(path: &Path) -> anyhow::Result { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("server.env"); - let backup_path = parent.join(format!( - ".{file_name}.optional-server-env-secrets-to-vault-migration-{}.bak", - ulid::Ulid::new() - )); - std::fs::copy(path, &backup_path).with_context(|| { - format!( - "copy server env {} to backup {}", - path.display(), - backup_path.display() - ) - })?; - set_private_permissions(&backup_path)?; - Ok(backup_path) -} - -#[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 permissions on {}", path.display()))?; - Ok(()) -} - -#[cfg(not(unix))] -fn set_private_permissions(_path: &Path) -> anyhow::Result<()> { - Ok(()) -} diff --git a/lib/apps/fabro-server/src/lib.rs b/lib/apps/fabro-server/src/lib.rs index d58c92f51..98f7a2c77 100644 --- a/lib/apps/fabro-server/src/lib.rs +++ b/lib/apps/fabro-server/src/lib.rs @@ -59,4 +59,4 @@ mod worker_token; pub use error::{ApiError, Error, Result}; pub use run_manifest::workflow_bundle_from_manifest; pub use server_secrets::process_env_snapshot; -pub use startup::{migrate_startup_vault, validate_startup, validate_startup_configuration}; +pub use startup::{validate_startup, validate_startup_configuration}; diff --git a/lib/apps/fabro-server/src/migrations.rs b/lib/apps/fabro-server/src/migrations.rs index 5c89015d0..68c4f9bd5 100644 --- a/lib/apps/fabro-server/src/migrations.rs +++ b/lib/apps/fabro-server/src/migrations.rs @@ -1,12 +1,3 @@ -use std::collections::HashMap; -use std::path::Path; - -use fabro_vault::SecretStore; - -#[path = "../migrations/2026051801_legacy_vault_entries.rs"] -mod legacy_vault_entries; -#[path = "../migrations/2026052501_optional_server_env_secrets_to_vault.rs"] -mod optional_server_env_secrets_to_vault; #[path = "../migrations/sqlite_activation_backup.rs"] mod sqlite_activation_backup; #[path = "../migrations/2026082301_sqlite_blob_activation.rs"] @@ -14,24 +5,5 @@ mod sqlite_blob_activation; #[path = "../migrations/2026082801_sqlite_run_history_activation.rs"] mod sqlite_run_history_activation; -pub(crate) use legacy_vault_entries::REMOVAL_DEADLINE as LEGACY_VAULT_REMOVAL_DEADLINE; -pub(crate) use optional_server_env_secrets_to_vault::REMOVAL_DEADLINE as OPTIONAL_SERVER_ENV_SECRETS_REMOVAL_DEADLINE; pub(crate) use sqlite_blob_activation::activate_blob_storage; pub(crate) use sqlite_run_history_activation::activate_run_history; - -pub(crate) type LegacyVaultMigrationReport = legacy_vault_entries::LegacyVaultMigrationReport; -pub(crate) type OptionalServerEnvSecretsMigrationReport = - optional_server_env_secrets_to_vault::OptionalServerEnvSecretsMigrationReport; - -pub(crate) fn migrate_legacy_vault_file(path: &Path) -> anyhow::Result { - legacy_vault_entries::migrate_legacy_vault_file(path) -} - -pub(crate) async fn migrate_optional_server_env_secrets_to_store( - store: &SecretStore, - server_env_path: &Path, - env_entries: &HashMap, -) -> anyhow::Result { - optional_server_env_secrets_to_vault::migrate_to_store(store, server_env_path, env_entries) - .await -} diff --git a/lib/apps/fabro-server/src/serve.rs b/lib/apps/fabro-server/src/serve.rs index 7cbe5ac7f..bcb4432b1 100644 --- a/lib/apps/fabro-server/src/serve.rs +++ b/lib/apps/fabro-server/src/serve.rs @@ -39,7 +39,7 @@ use crate::server::{ spawn_automation_scheduler, spawn_pull_request_creation_supervisor, spawn_scheduler, }; use crate::server_secrets::{ServerSecrets, process_env_snapshot}; -use crate::startup::{migrate_startup_vault, resolve_startup, validate_startup_configuration}; +use crate::startup::{resolve_startup, validate_startup_configuration}; use crate::{migrations, static_files}; pub const DEFAULT_TCP_PORT: u16 = 32276; @@ -666,7 +666,6 @@ where let resolved_server_settings = resolved_app_settings.server_settings.server.clone(); validate_startup_configuration(&resolved_server_settings)?; let env_entries = process_env_snapshot(); - migrate_startup_vault(&vault_path); let bind_request = resolve_bind_request_from_server_settings( &resolved_app_settings.server_settings, args.bind.as_deref(), @@ -680,30 +679,6 @@ where .await .with_context(|| format!("importing legacy secrets file {}", vault_path.display()))?; let secret_store = fabro_vault::SecretStore::new(database.clone_pool()); - let optional_report = migrations::migrate_optional_server_env_secrets_to_store( - &secret_store, - &server_env_path, - &env_entries, - ) - .await - .context("migrate optional server env secrets into SQLite")?; - for warning in &optional_report.warnings { - warn!( - warning = %warning, - removal_deadline = migrations::OPTIONAL_SERVER_ENV_SECRETS_REMOVAL_DEADLINE, - "Optional server env secrets migration warning" - ); - } - if optional_report.changed() { - warn!( - migrated_secrets = optional_report.migrated_secrets, - removed_env_entries = optional_report.removed_env_entries, - preserved_env_entries = optional_report.preserved_env_entries, - backup_path = ?optional_report.backup_path, - removal_deadline = migrations::OPTIONAL_SERVER_ENV_SECRETS_REMOVAL_DEADLINE, - "Migrated optional server env secrets into SQLite" - ); - } let startup_vault = secret_store .snapshot() .await diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 2e969bdff..ff8604b88 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -2622,128 +2622,6 @@ methods = ["dev-token"] )); } -#[tokio::test] -async 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.stores.vault.snapshot().await.unwrap(); - 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 - ); -} - -fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result> { - let (store, artifact_store) = test_store_bundle(); - let db_pool = test_db_pool_for_vault_path(vault_path)?; - let preloaded_vault = crate::test_support::test_secret_snapshot(db_pool.clone())?; - 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, - db_pool, - preloaded_vault, - 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")), - sandbox_provider_registry: None, - shutdown: tokio_util::sync::CancellationToken::new(), - worker_control_bus: None, - worker_runtime: None, - automation_materializer_override: None, - }) -} - fn test_worker_ref(pid: u32) -> WorkerRef { WorkerRef::Local { pid } } diff --git a/lib/apps/fabro-server/src/startup.rs b/lib/apps/fabro-server/src/startup.rs index 78fef39cb..bdf343c36 100644 --- a/lib/apps/fabro-server/src/startup.rs +++ b/lib/apps/fabro-server/src/startup.rs @@ -4,10 +4,8 @@ use std::path::Path; use fabro_static::EnvVars; use fabro_types::settings::ServerNamespace; use fabro_vault::Vault; -use tracing::warn; use crate::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup, validate_auth_configuration}; -use crate::migrations; use crate::server_secrets::ServerSecrets; pub(crate) fn resolve_startup( @@ -25,33 +23,6 @@ pub(crate) fn resolve_startup( Ok((auth_mode, server_secrets)) } -pub fn migrate_startup_vault(vault_path: impl AsRef) { - let vault_path = vault_path.as_ref(); - match migrations::migrate_legacy_vault_file(vault_path) { - Ok(report) if report.changed() => { - let backup_path = report - .backup_path - .as_ref() - .map_or_else(|| "".to_string(), |path| path.display().to_string()); - warn!( - migrated_entries = report.migrated_entries, - skipped_entries = report.skipped_entries, - backup_path = %backup_path, - removal_deadline = migrations::LEGACY_VAULT_REMOVAL_DEADLINE, - "Migrated legacy vault file" - ); - } - Ok(_) => {} - Err(err) => { - warn!( - error = %err, - removal_deadline = migrations::LEGACY_VAULT_REMOVAL_DEADLINE, - "Legacy vault migration failed; continuing with normal vault load" - ); - } - } -} - pub fn validate_startup( env_path: &Path, env_entries: HashMap, @@ -68,15 +39,13 @@ pub fn validate_startup_configuration(settings: &ServerNamespace) -> anyhow::Res #[cfg(test)] mod tests { use std::collections::HashMap; - use std::path::{Path, PathBuf}; - use fabro_config::{ServerSettingsBuilder, envfile}; + use fabro_config::ServerSettingsBuilder; use fabro_static::EnvVars; use fabro_types::settings::ServerNamespace; - use fabro_vault::{SecretStore, SecretType, Vault}; + use fabro_vault::{SecretType, Vault}; use super::validate_startup; - use crate::migrations; fn resolved_settings(auth_methods: &[&str]) -> ServerNamespace { ServerSettingsBuilder::from_toml(&format!( @@ -106,36 +75,6 @@ client_id = "Iv1.test" Vault::load(dir.path().join("secrets.json")).unwrap() } - fn env_path(dir: &tempfile::TempDir) -> PathBuf { - dir.path().join("server.env") - } - - async fn test_secret_store(dir: &tempfile::TempDir) -> SecretStore { - let database = fabro_db::Database::connect(dir.path().join("fabro.db")) - .await - .unwrap(); - database.migrate().await.unwrap(); - SecretStore::new(database.clone_pool()) - } - - #[expect( - clippy::disallowed_methods, - reason = "test helper scans a temporary directory after startup migration completes" - )] - fn migration_backups(dir: &Path) -> Vec { - std::fs::read_dir(dir) - .unwrap() - .map(|entry| entry.unwrap().path()) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| { - name.contains("optional-server-env-secrets-to-vault-migration") - }) - }) - .collect() - } - #[test] fn validate_startup_accepts_configured_secrets() { let dir = tempfile::tempdir().unwrap(); @@ -234,185 +173,4 @@ client_id = "Iv1.test" ) .expect("github client secret in vault should satisfy startup"); } - - #[tokio::test] - async fn migrate_optional_secrets_moves_server_env_secrets_to_store() { - let dir = tempfile::tempdir().unwrap(); - let server_env_path = env_path(&dir); - envfile::write_env_file( - &server_env_path, - &HashMap::from([ - ( - EnvVars::SESSION_SECRET.to_string(), - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), - ), - ( - EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(), - "legacy-client-secret".to_string(), - ), - ( - EnvVars::GITHUB_APP_PRIVATE_KEY.to_string(), - "legacy-private-key".to_string(), - ), - (EnvVars::OPENAI_API_KEY.to_string(), "sk-legacy".to_string()), - ]), - ) - .unwrap(); - let store = test_secret_store(&dir).await; - - migrations::migrate_optional_server_env_secrets_to_store( - &store, - &server_env_path, - &HashMap::new(), - ) - .await - .expect("legacy optional secrets should migrate"); - - let client_secret = store - .get(EnvVars::GITHUB_APP_CLIENT_SECRET) - .await - .unwrap() - .expect("client secret should be stored"); - assert_eq!(client_secret.value, "legacy-client-secret"); - assert_eq!(client_secret.secret_type, SecretType::Token); - let private_key = store - .get(EnvVars::GITHUB_APP_PRIVATE_KEY) - .await - .unwrap() - .expect("private key should be stored"); - assert_eq!(private_key.value, "legacy-private-key"); - assert_eq!(private_key.secret_type, SecretType::File); - let openai_key = store - .get(EnvVars::OPENAI_API_KEY) - .await - .unwrap() - .expect("openai key should be stored"); - assert_eq!(openai_key.value, "sk-legacy"); - - let server_env = envfile::read_env_file(&server_env_path).unwrap(); - assert!(server_env.contains_key(EnvVars::SESSION_SECRET)); - assert!(!server_env.contains_key(EnvVars::GITHUB_APP_CLIENT_SECRET)); - assert!(!server_env.contains_key(EnvVars::GITHUB_APP_PRIVATE_KEY)); - assert!(!server_env.contains_key(EnvVars::OPENAI_API_KEY)); - assert_eq!(migration_backups(dir.path()).len(), 1); - } - - #[tokio::test] - async fn migrate_optional_secrets_prefers_process_env_and_preserves_conflicting_server_env() { - let dir = tempfile::tempdir().unwrap(); - let server_env_path = env_path(&dir); - envfile::write_env_file( - &server_env_path, - &HashMap::from([( - EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(), - "file-client-secret".to_string(), - )]), - ) - .unwrap(); - let env_entries = HashMap::from([( - EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(), - "process-client-secret".to_string(), - )]); - let store = test_secret_store(&dir).await; - - migrations::migrate_optional_server_env_secrets_to_store( - &store, - &server_env_path, - &env_entries, - ) - .await - .expect("process env secret should migrate"); - - let client_secret = store - .get(EnvVars::GITHUB_APP_CLIENT_SECRET) - .await - .unwrap() - .expect("client secret should be stored"); - assert_eq!(client_secret.value, "process-client-secret"); - let server_env = envfile::read_env_file(&server_env_path).unwrap(); - assert_eq!( - server_env - .get(EnvVars::GITHUB_APP_CLIENT_SECRET) - .map(String::as_str), - Some("file-client-secret") - ); - assert!(migration_backups(dir.path()).is_empty()); - } - - #[tokio::test] - async fn migrate_optional_secrets_keeps_existing_stored_secret_and_removes_matching_server_env() - { - let dir = tempfile::tempdir().unwrap(); - let server_env_path = env_path(&dir); - envfile::write_env_file( - &server_env_path, - &HashMap::from([( - EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(), - "vault-client-secret".to_string(), - )]), - ) - .unwrap(); - let store = test_secret_store(&dir).await; - store - .set( - EnvVars::GITHUB_APP_CLIENT_SECRET, - "vault-client-secret", - SecretType::Token, - None, - ) - .await - .unwrap(); - - migrations::migrate_optional_server_env_secrets_to_store( - &store, - &server_env_path, - &HashMap::new(), - ) - .await - .expect("redundant server env secret should be cleaned up"); - - let client_secret = store - .get(EnvVars::GITHUB_APP_CLIENT_SECRET) - .await - .unwrap() - .expect("client secret should be stored"); - assert_eq!(client_secret.value, "vault-client-secret"); - let server_env = envfile::read_env_file(&server_env_path).unwrap(); - assert!(!server_env.contains_key(EnvVars::GITHUB_APP_CLIENT_SECRET)); - assert_eq!(migration_backups(dir.path()).len(), 1); - } - - #[tokio::test] - async fn migrate_optional_secrets_migrated_github_client_secret_satisfies_startup() { - let dir = tempfile::tempdir().unwrap(); - let server_env_path = env_path(&dir); - envfile::write_env_file( - &server_env_path, - &HashMap::from([ - ( - EnvVars::SESSION_SECRET.to_string(), - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), - ), - ( - EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(), - "legacy-client-secret".to_string(), - ), - ]), - ) - .unwrap(); - let settings = resolved_settings(&["github"]); - let store = test_secret_store(&dir).await; - - migrations::migrate_optional_server_env_secrets_to_store( - &store, - &server_env_path, - &HashMap::new(), - ) - .await - .expect("legacy github client secret should migrate"); - - let vault = store.snapshot().await.unwrap().into_vault(); - validate_startup(&server_env_path, HashMap::new(), &settings, &vault) - .expect("migrated github client secret should satisfy startup"); - } } diff --git a/lib/apps/fabro-server/src/test_support.rs b/lib/apps/fabro-server/src/test_support.rs index 8b0dd2185..3593fad6a 100644 --- a/lib/apps/fabro-server/src/test_support.rs +++ b/lib/apps/fabro-server/src/test_support.rs @@ -33,6 +33,7 @@ use tokio::runtime::Builder as TokioRuntimeBuilder; use tokio_util::sync::CancellationToken; use ulid::Ulid; +use crate::auth; pub use crate::automation_materializer::TestAutomationRunMaterializer; use crate::interp::process_env_var; use crate::jwt_auth::{AuthMode, ConfiguredAuth}; @@ -45,7 +46,6 @@ use crate::server::{ use crate::server_secrets::ServerSecrets; #[cfg(test)] use crate::worker_runtime::WorkerRuntime; -use crate::{auth, migrations}; pub const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; @@ -600,7 +600,6 @@ fn test_db_pool( default_environment_provider: Option, ) -> anyhow::Result { std::thread::spawn(move || { - migrations::migrate_legacy_vault_file(&vault_path)?; let runtime = TokioRuntimeBuilder::new_current_thread() .enable_all() .build()?; From 2f326a13c4c04e5f655d8d7c35a065653a411999 Mon Sep 17 00:00:00 2001 From: "fabro-releases[bot]" Date: Sun, 6 Sep 2026 09:29:10 +0000 Subject: [PATCH 002/151] Bump version to 0.348.0-nightly.0 --- Cargo.lock | 104 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6662e3bbb..c0d32aa79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2257,7 +2257,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -2276,7 +2276,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2323,7 +2323,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "chrono", "fabro-automation", @@ -2346,7 +2346,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2371,7 +2371,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2392,11 +2392,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -2412,7 +2412,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2514,7 +2514,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2543,7 +2543,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2573,7 +2573,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -2589,7 +2589,7 @@ dependencies = [ [[package]] name = "fabro-db" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2602,7 +2602,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2635,7 +2635,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2657,7 +2657,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2682,7 +2682,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2697,7 +2697,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -2720,7 +2720,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2730,7 +2730,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2749,7 +2749,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -2764,7 +2764,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2806,7 +2806,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2817,7 +2817,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "fabro-api", @@ -2841,7 +2841,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2861,7 +2861,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2889,7 +2889,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "chrono", "fabro-db", @@ -2907,7 +2907,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "clap", "fabro-static", @@ -2924,7 +2924,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2946,7 +2946,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2954,7 +2954,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "cc", "libc", @@ -2963,7 +2963,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2979,7 +2979,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3119,7 +3119,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -3141,18 +3141,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" [[package]] name = "fabro-store" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -3183,7 +3183,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -3209,7 +3209,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -3223,7 +3223,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3248,7 +3248,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3269,7 +3269,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3283,7 +3283,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "chrono", "clap", @@ -3306,7 +3306,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -3329,7 +3329,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -3342,7 +3342,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3359,7 +3359,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3378,7 +3378,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3448,7 +3448,7 @@ dependencies = [ [[package]] name = "fabro-workflow-version" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "fabro-config", "fabro-graphviz", @@ -8605,7 +8605,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "axum", "base64", @@ -8624,7 +8624,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index d44b7713d..c642467a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.347.0-nightly.0" +version = "0.348.0-nightly.0" license = "MIT" [workspace.dependencies] From aa8b919f1c206b7277ba5fd924e73a757ffb8c90 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 13:45:44 -0600 Subject: [PATCH 003/151] Pin daytona-sdk-rust to the merged main commit Both fabro and sandbox-driver now pin the same daytona-sdk-rust commit on main. The newer SDK adds region and sandbox class fields to snapshot creation; fabro leaves both unset and keeps its current behavior. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 28 +++++++++---------- Cargo.toml | 4 +-- .../fabro-sandbox/src/daytona/mod.rs | 2 ++ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0d32aa79..e406bb846 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,7 +1870,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -1884,7 +1884,7 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" dependencies = [ "daytona-api-client", "daytona-toolbox-client", @@ -1904,7 +1904,7 @@ dependencies = [ [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -2074,7 +2074,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2201,7 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4469,7 +4469,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -5435,7 +5435,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6432,7 +6432,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6909,7 +6909,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6968,7 +6968,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7492,7 +7492,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.2.8", + "errno 0.3.14", "libc", ] @@ -8082,7 +8082,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8128,7 +8128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9191,7 +9191,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c642467a5..c2b23454d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,8 +97,8 @@ twin-openai = { path = "test/twin/openai" } twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" } +daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "a3d267e18025151d9e61840ac7a5e6b6b63799ce", package = "daytona-sdk" } +daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "a3d267e18025151d9e61840ac7a5e6b6b63799ce", package = "daytona-api-client" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/lib/components/fabro-sandbox/src/daytona/mod.rs b/lib/components/fabro-sandbox/src/daytona/mod.rs index 66173286f..f80d2fe24 100644 --- a/lib/components/fabro-sandbox/src/daytona/mod.rs +++ b/lib/components/fabro-sandbox/src/daytona/mod.rs @@ -239,6 +239,8 @@ fn create_snapshot_params( ..Default::default() }), entrypoint: None, + region_id: None, + sandbox_class: None, }) } From 07c52b07cacac74c1a03468c60747308106f9c17 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 14:18:58 -0600 Subject: [PATCH 004/151] Depend on the sandbox-driver crates by git revision Fabro pins the sandbox-driver workspace the same way it pins the Daytona SDK: every crate at one commit on main. The bundled Host, Docker, and Daytona provider libraries link in-process, and the protocol crate reaches third-party providers over stdio. Nothing uses the crates yet; the following commits move fabro onto them one layer at a time. The driver pins tracing-subscriber exactly, so the lockfile settles on that version for the whole workspace. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 155 +++++++++++++++++++++--- Cargo.toml | 14 ++- lib/components/fabro-sandbox/Cargo.toml | 7 ++ 3 files changed, 158 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e406bb846..101226b80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,7 +1870,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -1884,7 +1884,7 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "daytona-api-client", "daytona-toolbox-client", @@ -1904,7 +1904,7 @@ dependencies = [ [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -2074,7 +2074,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2201,7 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3006,6 +3006,13 @@ dependencies = [ "rand 0.9.4", "reqwest-middleware", "rustls", + "sandbox-driver", + "sandbox-driver-daytona", + "sandbox-driver-daytona-config", + "sandbox-driver-docker", + "sandbox-driver-docker-config", + "sandbox-driver-host", + "sandbox-driver-protocol", "serde", "serde_json", "sha2 0.10.9", @@ -4469,7 +4476,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -5435,7 +5442,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6432,7 +6439,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6909,7 +6916,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6968,7 +6975,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7010,6 +7017,122 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox-driver" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "async-trait", + "globset", + "rand 0.10.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "sandbox-driver-daytona" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "daytona-api-client", + "daytona-sdk", + "rand 0.10.1", + "reqwest 0.13.2", + "sandbox-driver", + "sandbox-driver-daytona-config", + "sandbox-driver-docker", + "sandbox-driver-docker-config", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-daytona-config" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "sandbox-driver-docker-config", + "serde", + "serde_json", +] + +[[package]] +name = "sandbox-driver-docker" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "anyhow", + "async-trait", + "bollard", + "futures-util", + "sandbox-driver", + "sandbox-driver-docker-config", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tar", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-docker-config" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "sandbox-driver-host" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "anyhow", + "async-trait", + "nix 0.30.1", + "sandbox-driver", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-protocol" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "async-trait", + "base64", + "rand 0.10.1", + "sandbox-driver", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "schannel" version = "0.1.28" @@ -7492,7 +7615,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.3.14", + "errno 0.2.8", "libc", ] @@ -8082,7 +8205,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8128,7 +8251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8545,9 +8668,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", @@ -9191,7 +9314,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c2b23454d..6e27bed20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,8 +97,18 @@ twin-openai = { path = "test/twin/openai" } twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "a3d267e18025151d9e61840ac7a5e6b6b63799ce", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "a3d267e18025151d9e61840ac7a5e6b6b63799ce", package = "daytona-api-client" } +daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "5e86990418e21f4288ce537c9852dfdf78768abc", package = "daytona-sdk" } +daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "5e86990418e21f4288ce537c9852dfdf78768abc", package = "daytona-api-client" } +# sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and +# Daytona providers link in-process; third-party providers run as stdio +# plugins through sandbox-driver-protocol. Pinned by rev like the Daytona SDK. +sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/lib/components/fabro-sandbox/Cargo.toml b/lib/components/fabro-sandbox/Cargo.toml index 251e6bd77..d5672f3f4 100644 --- a/lib/components/fabro-sandbox/Cargo.toml +++ b/lib/components/fabro-sandbox/Cargo.toml @@ -20,6 +20,13 @@ doctest = false workspace = true [dependencies] +sandbox-driver.workspace = true +sandbox-driver-protocol.workspace = true +sandbox-driver-host.workspace = true +sandbox-driver-docker.workspace = true +sandbox-driver-docker-config.workspace = true +sandbox-driver-daytona.workspace = true +sandbox-driver-daytona-config.workspace = true anyhow.workspace = true async-trait.workspace = true thiserror.workspace = true From d88c6064af057e9f5b8da6c3c1437472d1384412 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 14:30:32 -0600 Subject: [PATCH 005/151] Benchmark agent tool calls through the sandbox driver Phase 2 of the sandbox-driver adoption: an ignored test that unpacks fabro's own lib tree into each provider and times file reads and content searches through fabro's current providers, the driver providers in-process, and the driver providers served over JSON-RPC on an in-process pipe. Docker reads match, Docker grep is faster through the driver, Host grep costs about 20 ms more through the derived search, and the wire hop adds about 0.1 ms per call against the plan's 100 ms per tool call budget. The plan records the full table. Co-Authored-By: Claude Fable 5.1 --- .../fabro-sandbox/tests/driver_bench.rs | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 lib/components/fabro-sandbox/tests/driver_bench.rs diff --git a/lib/components/fabro-sandbox/tests/driver_bench.rs b/lib/components/fabro-sandbox/tests/driver_bench.rs new file mode 100644 index 000000000..af6a69ae5 --- /dev/null +++ b/lib/components/fabro-sandbox/tests/driver_bench.rs @@ -0,0 +1,412 @@ +//! Phase 2 of the sandbox-driver adoption: measure agent tool-call latency +//! through the driver against fabro's current providers before any cutover. +//! +//! Three comparisons, each over the same medium repository (fabro's own +//! `lib/` tree, about 1,100 Rust files): +//! +//! - Docker file reads and content search: fabro's `DockerSandbox` (archive API +//! reads, `docker exec` grep) against the driver `DockerProvider` (archive +//! API reads, exec-derived search) in-process. +//! - Host tool calls: fabro's `LocalSandbox` against the driver `HostProvider` +//! in-process, to confirm no regression on the path every local run takes. +//! - The wire: the driver Host and Docker providers served over the JSON-RPC +//! protocol on an in-process duplex pipe, to size the budget for running a +//! provider out of process later (the plan allows 100 ms per tool call). +//! +//! Ignored: it needs a Docker daemon with `buildpack-deps:noble` present and +//! takes a minute. Run with +//! `cargo nextest run -p fabro-sandbox --features docker --test driver_bench +//! --run-ignored only --no-capture`. + +#![cfg(feature = "docker")] +#![allow( + clippy::print_stderr, + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a benchmark reports through stderr and rounds durations for display" +)] +#![expect( + clippy::disallowed_methods, + reason = "the fixture is packed and enumerated synchronously before the timed section starts" +)] + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use bollard::Docker; +use fabro_sandbox::{DockerSandbox, DockerSandboxOptions, LocalSandbox, Sandbox as FabroSandbox}; +use sandbox_driver::{ + ExecSpec, GrepOptions, Sandbox as DriverSandbox, SandboxProvider, SandboxSource, SandboxSpec, + Search, +}; +use sandbox_driver_docker::DockerProvider; +use sandbox_driver_host::HostProvider; +use sandbox_driver_protocol::{PluginProvider, serve}; +use tokio::io::{duplex, split}; + +const IMAGE: &str = "buildpack-deps:noble"; +const READS: usize = 200; +const GREPS: usize = 20; +const GREP_PATTERN: &str = "async fn "; + +/// The medium repository: fabro's `lib/` tree, packed once per run. +struct Repository { + tarball: PathBuf, + /// Repository-relative paths of the files the read benchmark samples. + files: Vec, + _dir: tempfile::TempDir, +} + +impl Repository { + fn pack() -> Self { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("workspace lib dir"); + let dir = tempfile::tempdir().expect("tempdir"); + let tarball = dir.path().join("repo.tar"); + let status = Command::new("tar") + .args(["-cf"]) + .arg(&tarball) + .args(["--exclude", "target", "--exclude", "node_modules", "-C"]) + .arg(&root) + .arg(".") + .status() + .expect("tar available"); + assert!(status.success(), "packing the repository failed"); + let mut files: Vec = walkdir(&root) + .into_iter() + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) + .filter_map(|path| { + path.strip_prefix(&root) + .ok() + .map(|rel| rel.to_string_lossy().into_owned()) + }) + .collect(); + files.sort(); + // A fixed stride samples the tree evenly and identically for every + // provider under test. + let stride = (files.len() / READS).max(1); + let files = files.into_iter().step_by(stride).take(READS).collect(); + Self { + tarball, + files, + _dir: dir, + } + } +} + +fn walkdir(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if path.file_name().is_some_and(|name| name == "target") { + continue; + } + stack.push(path); + } else { + out.push(path); + } + } + } + out +} + +#[derive(Default)] +struct Samples(Vec); + +impl Samples { + fn record(&mut self, duration: Duration) { + self.0.push(duration); + } + + fn percentile(&self, pct: f64) -> Duration { + let mut sorted = self.0.clone(); + sorted.sort(); + if sorted.is_empty() { + return Duration::ZERO; + } + let index = ((sorted.len() - 1) as f64 * pct).round() as usize; + sorted[index] + } + + fn mean(&self) -> Duration { + if self.0.is_empty() { + return Duration::ZERO; + } + self.0.iter().sum::() / self.0.len() as u32 + } +} + +struct Row { + label: &'static str, + op: &'static str, + n: usize, + stats: Samples, +} + +fn report(rows: &[Row]) { + eprintln!(); + eprintln!( + "{:<34} {:<8} {:>5} {:>9} {:>9} {:>9}", + "provider", "op", "n", "p50 ms", "p95 ms", "mean ms" + ); + for row in rows { + eprintln!( + "{:<34} {:<8} {:>5} {:>9.2} {:>9.2} {:>9.2}", + row.label, + row.op, + row.n, + row.stats.percentile(0.5).as_secs_f64() * 1000.0, + row.stats.percentile(0.95).as_secs_f64() * 1000.0, + row.stats.mean().as_secs_f64() * 1000.0, + ); + } + eprintln!(); +} + +/// The two operations an agent issues most: a file read and a content +/// search, expressed against fabro's current trait. +async fn bench_fabro( + label: &'static str, + sandbox: &dyn FabroSandbox, + repo: &Repository, +) -> Vec { + let mut reads = Samples::default(); + for file in &repo.files { + let started = Instant::now(); + let bytes = sandbox + .read_file_bytes(&format!("repo/{file}")) + .await + .expect("read"); + assert!(!bytes.is_empty()); + reads.record(started.elapsed()); + } + let mut greps = Samples::default(); + let options = fabro_sandbox::GrepOptions { + glob_filter: Some("*.rs".to_owned()), + case_insensitive: false, + max_results: Some(50), + }; + for _ in 0..GREPS { + let started = Instant::now(); + let matches = sandbox + .grep(GREP_PATTERN, "repo", &options) + .await + .expect("grep"); + assert!(!matches.is_empty()); + greps.record(started.elapsed()); + } + vec![ + Row { + label, + op: "read", + n: repo.files.len(), + stats: reads, + }, + Row { + label, + op: "grep", + n: GREPS, + stats: greps, + }, + ] +} + +/// The same two operations against the driver's facets. +async fn bench_driver( + label: &'static str, + sandbox: &dyn DriverSandbox, + repo: &Repository, +) -> Vec { + let mut reads = Samples::default(); + for file in &repo.files { + let started = Instant::now(); + let bytes = sandbox + .fs() + .read(&format!("repo/{file}")) + .await + .expect("read"); + assert!(!bytes.is_empty()); + reads.record(started.elapsed()); + } + let search = sandbox.search().expect("search facet"); + let mut options = GrepOptions::default(); + options.include = Some("*.rs".to_owned()); + options.max_matches = Some(50); + let mut greps = Samples::default(); + for _ in 0..GREPS { + let started = Instant::now(); + let matches = search + .grep(GREP_PATTERN, "repo", &options) + .await + .expect("grep"); + assert!(!matches.is_empty()); + greps.record(started.elapsed()); + } + vec![ + Row { + label, + op: "read", + n: repo.files.len(), + stats: reads, + }, + Row { + label, + op: "grep", + n: GREPS, + stats: greps, + }, + ] +} + +async fn unpack_fabro(sandbox: &dyn FabroSandbox, repo: &Repository) { + sandbox + .upload_file_from_local(&repo.tarball, "/tmp/repo.tar") + .await + .expect("upload"); + let result = sandbox + .exec_command( + "mkdir -p repo && tar -xf /tmp/repo.tar -C repo", + 120_000, + None, + None, + None, + ) + .await + .expect("unpack exec"); + assert!(result.is_success(), "unpack failed: {}", result.stderr); +} + +async fn unpack_driver(sandbox: &dyn DriverSandbox, repo: &Repository) { + sandbox + .fs() + .upload(&repo.tarball, "/tmp/repo.tar") + .await + .expect("upload"); + let result = sandbox + .exec() + .run( + &ExecSpec::bash("mkdir -p repo && tar -xf /tmp/repo.tar -C repo") + .timeout(Duration::from_secs(120)), + ) + .await + .expect("unpack exec"); + assert!(result.success(), "unpack failed: {}", result.stderr_lossy()); +} + +fn docker_spec() -> SandboxSpec { + SandboxSpec::new(SandboxSource::Image { + reference: IMAGE.to_owned(), + }) + .working_directory("/workspace") +} + +async fn serve_over_duplex(provider: Arc) -> PluginProvider { + let (host_side, plugin_side) = duplex(1024 * 1024); + let (host_read, host_write) = split(host_side); + let (plugin_read, plugin_write) = split(plugin_side); + tokio::spawn(serve(provider, plugin_read, plugin_write)); + PluginProvider::connect(host_read, host_write) + .await + .expect("handshake") +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "benchmark: needs a Docker daemon with buildpack-deps:noble and takes about a minute"] +async fn agent_tool_call_latency_through_the_driver() { + let Ok(docker) = Docker::connect_with_local_defaults() else { + eprintln!("no Docker daemon; skipping"); + return; + }; + if docker.inspect_image(IMAGE).await.is_err() { + eprintln!("{IMAGE} is not present locally; skipping"); + return; + } + let repo = Repository::pack(); + let mut rows = Vec::new(); + + // -- Host, in-process: fabro LocalSandbox vs driver HostProvider. + let host_dir = tempfile::tempdir().expect("tempdir"); + let local = LocalSandbox::new(host_dir.path().to_path_buf()); + local.initialize().await.expect("local init"); + unpack_fabro(&local, &repo).await; + rows.extend(bench_fabro("fabro LocalSandbox", &local, &repo).await); + + let host_provider = Arc::new(HostProvider::new()); + let host = host_provider + .create( + &SandboxSpec::new(SandboxSource::HostDirectory) + .working_directory(host_dir.path().to_string_lossy().into_owned()), + None, + ) + .await + .expect("host create"); + rows.extend(bench_driver("driver Host (in-process)", host.as_ref(), &repo).await); + + // -- Host over the wire (duplex pipe, no process boundary). + let remote_host = serve_over_duplex(host_provider.clone()).await; + let wire_host = remote_host.attach(host.id(), None).await.expect("attach"); + rows.extend(bench_driver("driver Host (JSON-RPC, duplex)", wire_host.as_ref(), &repo).await); + drop(wire_host); + remote_host.shutdown().await.expect("shutdown"); + host.delete().await.expect("host delete"); + + // -- Docker, in-process: fabro DockerSandbox vs driver DockerProvider. + let fabro_docker = DockerSandbox::new( + DockerSandboxOptions { + image: IMAGE.to_owned(), + auto_pull: false, + skip_clone: true, + ..DockerSandboxOptions::default() + }, + None, + None, + None, + None, + None, + None, + ) + .expect("fabro docker sandbox"); + fabro_docker.initialize().await.expect("fabro docker init"); + unpack_fabro(&fabro_docker, &repo).await; + rows.extend(bench_fabro("fabro DockerSandbox", &fabro_docker, &repo).await); + fabro_docker.cleanup().await.expect("fabro docker cleanup"); + + let docker_provider = Arc::new(DockerProvider::connect().await.expect("docker connect")); + let container = docker_provider + .create(&docker_spec(), None) + .await + .expect("driver docker create"); + unpack_driver(container.as_ref(), &repo).await; + rows.extend(bench_driver("driver Docker (in-process)", container.as_ref(), &repo).await); + + // -- Docker over the wire (duplex pipe, no process boundary). + let remote_docker = serve_over_duplex(docker_provider.clone()).await; + let wire_docker = remote_docker + .attach(container.id(), None) + .await + .expect("attach"); + rows.extend( + bench_driver( + "driver Docker (JSON-RPC, duplex)", + wire_docker.as_ref(), + &repo, + ) + .await, + ); + drop(wire_docker); + remote_docker.shutdown().await.expect("shutdown"); + container.delete().await.expect("driver docker delete"); + + report(&rows); +} From 124065ac5279a082ecd57e98076fe61e4fac6952 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 15:08:34 -0600 Subject: [PATCH 006/151] Fix duration lint in the sandbox driver benchmark Co-Authored-By: Claude Fable 5.1 --- lib/components/fabro-sandbox/tests/driver_bench.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/fabro-sandbox/tests/driver_bench.rs b/lib/components/fabro-sandbox/tests/driver_bench.rs index af6a69ae5..a3e11718c 100644 --- a/lib/components/fabro-sandbox/tests/driver_bench.rs +++ b/lib/components/fabro-sandbox/tests/driver_bench.rs @@ -297,7 +297,7 @@ async fn unpack_driver(sandbox: &dyn DriverSandbox, repo: &Repository) { .exec() .run( &ExecSpec::bash("mkdir -p repo && tar -xf /tmp/repo.tar -C repo") - .timeout(Duration::from_secs(120)), + .timeout(Duration::from_mins(2)), ) .await .expect("unpack exec"); From 80bc51c40e819b427efd406bf19453b218f4504c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 15:17:32 -0600 Subject: [PATCH 007/151] Open sandbox provider identity to plugin kinds SandboxProviderKind is now a validated string newtype instead of a closed enum. The bundled kinds (local, docker, daytona) keep their constants and a BundledProvider enum for the code paths that still dispatch on them; any other well-formed sandbox-driver kind name is accepted and names a plugin executable. EnvironmentProvider is gone: environment settings carry SandboxProviderKind directly, and is_clone_based is replaced by a workspace policy where local runs in a designated directory and every other provider clones. Server sandbox policy is keyed by kind. [server.sandbox.providers.] accepts the bundled kinds with `enabled` and any plugin kind with its launch settings (path, sha256, dev, args, env, inherit_env); bundled kinds reject the plugin keys and a kind with no entry is disabled. The OpenAPI schema, generated Rust and TypeScript clients, web settings pages, and docs follow. The environments table drops its provider CHECK enumeration in favour of the kind name rules so a plugin environment can be stored. Bundled-only code paths (run start, preflight, reconnect, terminal, details) now fail with an explicit message for a plugin kind until the driver construction function lands in the next step. Co-Authored-By: Claude Fable 5.1 --- .../app/components/environment-form.tsx | 20 +- .../app/lib/environment-providers.ts | 47 ++- .../app/routes/settings-environments.tsx | 6 +- .../app/routes/settings-sandboxes.tsx | 83 +++-- docs/public/administration/sandboxing.mdx | 9 +- .../administration/server-configuration.mdx | 35 ++- docs/public/api-reference/fabro-api.yaml | 66 ++-- docs/public/execution/environments.mdx | 2 +- lib/apps/fabro-cli/src/commands/run/create.rs | 9 +- .../src/commands/run/run_progress/mod.rs | 4 +- lib/apps/fabro-cli/src/commands/run/runner.rs | 16 +- lib/apps/fabro-cli/src/commands/runs/mod.rs | 2 +- lib/apps/fabro-server/src/demo/mod.rs | 8 +- lib/apps/fabro-server/src/diagnostics.rs | 4 +- lib/apps/fabro-server/src/install.rs | 9 +- lib/apps/fabro-server/src/run_manifest.rs | 120 ++++---- lib/apps/fabro-server/src/server.rs | 14 +- .../src/server/handler/automations.rs | 8 +- .../src/server/handler/environments.rs | 8 +- .../fabro-server/src/server/handler/runs.rs | 21 +- .../src/server/handler/sandbox.rs | 52 ++-- .../src/server/handler/sandboxes.rs | 26 +- lib/apps/fabro-server/src/server/tests.rs | 59 ++-- lib/apps/fabro-server/src/test_support.rs | 15 +- lib/apps/fabro-server/tests/it/api/install.rs | 16 +- .../fabro-server/tests/it/api/run_files.rs | 2 +- lib/apps/fabro-server/tests/it/api/runs.rs | 4 +- .../2026082801_environment_selectors.rs | 2 +- lib/components/fabro-dump/src/lib.rs | 4 +- lib/components/fabro-environment/src/store.rs | 25 +- .../fabro-environment/tests/store.rs | 27 +- lib/components/fabro-install/src/lib.rs | 19 +- .../fabro-sandbox/src/daytona/mod.rs | 2 +- lib/components/fabro-sandbox/src/details.rs | 32 +- lib/components/fabro-sandbox/src/docker.rs | 2 +- .../fabro-sandbox/src/from_environment.rs | 13 +- lib/components/fabro-sandbox/src/git_retry.rs | 14 +- lib/components/fabro-sandbox/src/provider.rs | 44 +-- .../fabro-sandbox/src/provider/daytona.rs | 2 +- .../fabro-sandbox/src/provider/docker.rs | 2 +- lib/components/fabro-sandbox/src/reconnect.rs | 18 +- .../fabro-sandbox/src/sandbox_spec.rs | 16 +- lib/components/fabro-sandbox/src/terminal.rs | 18 +- .../fabro-sandbox/src/test_support.rs | 2 +- lib/components/fabro-store/src/run_state.rs | 25 +- .../tests/serializable_projection.rs | 4 +- .../fabro-workflow/src/event/convert.rs | 2 +- .../fabro-workflow/src/operations/retry.rs | 2 +- .../fabro-workflow/src/operations/start.rs | 53 ++-- .../src/pipeline/execute/tests.rs | 2 +- .../fabro-workflow/tests/it/cp_integration.rs | 4 +- .../tests/it/daytona_integration.rs | 2 +- lib/foundation/fabro-api/build.rs | 5 + .../fabro-api/tests/run_sandbox_round_trip.rs | 4 +- .../tests/sandbox_details_round_trip.rs | 4 +- .../tests/sandbox_inventory_round_trip.rs | 4 +- lib/foundation/fabro-client/src/client.rs | 7 +- ...26050101_legacy_sandbox_to_environments.rs | 48 +-- lib/foundation/fabro-config/src/builders.rs | 7 +- .../fabro-config/src/layers/combine.rs | 11 +- .../fabro-config/src/layers/server.rs | 46 ++- .../fabro-config/src/resolve/environment.rs | 80 ++--- .../fabro-config/src/resolve/server.rs | 101 +++++-- .../fabro-config/src/tests/resolve_root.rs | 6 +- .../fabro-config/src/tests/resolve_run.rs | 15 +- .../fabro-config/src/tests/resolve_server.rs | 93 +++++- .../2026090901_environment_provider_kinds.sql | 56 ++++ lib/foundation/fabro-db/tests/sqlite.rs | 6 +- lib/foundation/fabro-types/src/lib.rs | 4 +- .../fabro-types/src/sandbox_details.rs | 4 +- .../fabro-types/src/sandbox_provider.rs | 284 ++++++++++++++++-- .../fabro-types/src/settings/mod.rs | 16 +- .../fabro-types/src/settings/run.rs | 51 +--- .../fabro-types/src/settings/server.rs | 103 +++++-- .../tests/sandbox_inventory_serde.rs | 6 +- .../fabro-types/tests/sandbox_model_serde.rs | 20 +- .../src/.openapi-generator/FILES | 4 +- .../fabro-api-client/src/api/runs-api.ts | 8 +- .../src/models/create-environment-request.ts | 8 +- .../src/models/delete-run-sandbox.ts | 8 +- .../src/models/environment-provider.ts | 27 -- .../src/models/environment-settings.ts | 8 +- .../src/models/environment.ts | 8 +- .../fabro-api-client/src/models/index.ts | 4 +- .../src/models/replace-environment-request.ts | 8 +- .../src/models/run-environment-settings.ts | 8 +- .../src/models/run-sandbox-instance.ts | 8 +- .../src/models/run-sandbox-plan.ts | 8 +- .../src/models/sandbox-info.ts | 8 +- .../src/models/sandbox-plugin-settings.ts | 36 +++ .../src/models/sandbox-provider-kind.ts | 27 -- .../models/sandbox-provider-lookup-error.ts | 8 +- .../server-sandbox-provider-settings.ts | 4 + .../server-sandbox-providers-settings.ts | 24 -- .../src/models/server-sandbox-settings.ts | 7 +- 95 files changed, 1390 insertions(+), 813 deletions(-) create mode 100644 lib/foundation/fabro-db/migrations/2026090901_environment_provider_kinds.sql delete mode 100644 lib/packages/fabro-api-client/src/models/environment-provider.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-plugin-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-sandbox-providers-settings.ts diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 688f0adbf..a78a6cc10 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -3,7 +3,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { EnvironmentApiDockerfileSourceInlineTypeEnum, EnvironmentNetworkMode, - EnvironmentProvider, } from "@qltysh/fabro-api-client"; import type { CreateEnvironmentRequest, @@ -15,6 +14,7 @@ import type { ReplaceEnvironmentRequest, } from "@qltysh/fabro-api-client"; +import { DOCKER_PROVIDER, isCloneBasedProvider } from "../lib/environment-providers"; import { Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { @@ -25,11 +25,15 @@ import { } from "./key-value-editor"; // Parse the `provider` query param used by the create flow into a creatable -// provider, defaulting to Docker for anything unexpected. -export function parseCreatableProvider(value: string | null): EnvironmentProvider { - return value === EnvironmentProvider.DAYTONA - ? EnvironmentProvider.DAYTONA - : EnvironmentProvider.DOCKER; +// provider, defaulting to Docker for anything that cannot back a managed +// environment. Kind names are validated server-side on create. +const PROVIDER_KIND_PATTERN = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/; + +export function parseCreatableProvider(value: string | null): string { + if (value && PROVIDER_KIND_PATTERN.test(value) && isCloneBasedProvider(value)) { + return value; + } + return DOCKER_PROVIDER; } // Environment ids are server-managed file names: lowercase, digits, hyphens. @@ -49,7 +53,7 @@ type ImageSource = "image" | "dockerfile"; export interface EnvironmentFormValues { id: string; - provider: EnvironmentProvider; + provider: string; imageSource: ImageSource; dockerRef: string; dockerfile: string; @@ -69,7 +73,7 @@ export interface EnvironmentFormValues { export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { id: "", - provider: EnvironmentProvider.DOCKER, + provider: DOCKER_PROVIDER, imageSource: "image", dockerRef: "", dockerfile: "", diff --git a/apps/fabro-web/app/lib/environment-providers.ts b/apps/fabro-web/app/lib/environment-providers.ts index 8002cf6a4..300fe54cb 100644 --- a/apps/fabro-web/app/lib/environment-providers.ts +++ b/apps/fabro-web/app/lib/environment-providers.ts @@ -1,17 +1,44 @@ -import { EnvironmentProvider, type Environment } from "@qltysh/fabro-api-client"; +import type { Environment, ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; -// Providers a managed environment can be created with. `local` is a reserved, -// in-memory environment, never a managed-environment provider, so it is never -// offered. The provider is fixed at creation time and cannot be changed. -export const CREATABLE_PROVIDERS = [ - EnvironmentProvider.DOCKER, - EnvironmentProvider.DAYTONA, -] as const; +// The providers linked into the server. Any other provider kind names a +// sandbox-driver plugin the operator configured under +// `server.sandbox.providers.`. +export const LOCAL_PROVIDER = "local"; +export const DOCKER_PROVIDER = "docker"; +export const DAYTONA_PROVIDER = "daytona"; + +export const BUNDLED_PROVIDERS = [LOCAL_PROVIDER, DOCKER_PROVIDER, DAYTONA_PROVIDER] as const; + +export type ProviderSettingsMap = { [kind: string]: ServerSandboxProviderSettings }; + +// `local` runs in the caller's directory and never clones. Every other +// provider owns an isolated workspace that Fabro clones into. +export function isCloneBasedProvider(provider: string): boolean { + return provider !== LOCAL_PROVIDER; +} // Whether a server-managed environment can back Git-targeted work such as -// automations: only the clone-based (creatable) providers qualify. +// automations: only clone-based providers qualify. export function isCloneBasedEnvironment(environment: Environment): boolean { - return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider); + return isCloneBasedProvider(environment.provider); +} + +// Providers a managed environment can be created with: every enabled +// clone-based provider. `local` is a reserved, in-memory environment, never a +// managed-environment provider, so it is never offered. +export function creatableProviders(providers: ProviderSettingsMap): string[] { + return Object.keys(providers) + .filter((kind) => isCloneBasedProvider(kind) && providers[kind]?.enabled) + .sort(compareProviderKinds); +} + +// Bundled kinds first, in their canonical order, then plugins alphabetically. +export function compareProviderKinds(left: string, right: string): number { + const rank = (kind: string) => { + const index = (BUNDLED_PROVIDERS as readonly string[]).indexOf(kind); + return index === -1 ? BUNDLED_PROVIDERS.length : index; + }; + return rank(left) - rank(right) || left.localeCompare(right); } export function providerLabel(provider: string): string { diff --git a/apps/fabro-web/app/routes/settings-environments.tsx b/apps/fabro-web/app/routes/settings-environments.tsx index b3059ab52..fdd13aee4 100644 --- a/apps/fabro-web/app/routes/settings-environments.tsx +++ b/apps/fabro-web/app/routes/settings-environments.tsx @@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client"; import { ApiError, apiData, environmentsApi } from "../lib/api-client"; import { useEnvironments, useServerSettings } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; -import { CREATABLE_PROVIDERS, providerLabel } from "../lib/environment-providers"; +import { creatableProviders, providerLabel } from "../lib/environment-providers"; import { Badge, Muted, @@ -67,9 +67,7 @@ const NEW_BUTTON_CLASS = // environment's lifetime. `local` is never offered (it's reserved/in-memory). function NewEnvironmentMenu() { const { data } = useServerSettings(); - const providers = data - ? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled) - : []; + const providers = data ? creatableProviders(data.server.sandbox.providers) : []; if (providers.length === 0) { return ( diff --git a/apps/fabro-web/app/routes/settings-sandboxes.tsx b/apps/fabro-web/app/routes/settings-sandboxes.tsx index d51197c73..577f81866 100644 --- a/apps/fabro-web/app/routes/settings-sandboxes.tsx +++ b/apps/fabro-web/app/routes/settings-sandboxes.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { Link } from "react-router"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ComputerDesktopIcon } from "@heroicons/react/24/outline"; -import type { ServerSandboxProvidersSettings } from "@qltysh/fabro-api-client"; +import type { ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; import { useServerSettings } from "../lib/queries"; import { Dot, @@ -12,24 +12,56 @@ import { SettingsPageIntro, } from "../components/settings-panel"; import { plural } from "../lib/plural"; +import { + DAYTONA_PROVIDER, + DOCKER_PROVIDER, + LOCAL_PROVIDER, + compareProviderKinds, + providerLabel, + type ProviderSettingsMap, +} from "../lib/environment-providers"; export function meta() { return [{ title: "Sandboxes — Fabro" }]; } -type SandboxProviderId = "local" | "docker" | "daytona"; - type SandboxProvider = { - id: SandboxProviderId; + id: string; name: string; description: string; enabled: boolean; + bundled: boolean; secretName?: string; }; const DESCRIPTION = "Runtime environments where workflow stages execute. Configured via settings.toml."; +// Display copy for the providers linked into the server. Any other kind is a +// sandbox-driver plugin configured under `server.sandbox.providers.`. +const BUNDLED_PROVIDER_COPY: Record> = { + [LOCAL_PROVIDER]: { + name: "Local", + description: "Run stages directly on the Fabro host.", + }, + [DOCKER_PROVIDER]: { + name: "Docker", + description: "Run stages in isolated Docker containers on the host daemon.", + }, + [DAYTONA_PROVIDER]: { + name: "Daytona", + description: "Run stages in cloud sandboxes managed by Daytona.", + secretName: "DAYTONA_API_KEY", + }, +}; + +function pluginDescription(settings: ServerSandboxProviderSettings): string { + const path = settings.plugin?.path; + return path + ? `Sandbox plugin executable at ${path}.` + : "Sandbox plugin executable resolved from PATH."; +} + export default function SettingsSandboxes() { const query = useServerSettings(); const settings = query.data; @@ -42,29 +74,24 @@ export default function SettingsSandboxes() { ); } -function ProvidersPanel({ settings }: { settings: ServerSandboxProvidersSettings }) { +function ProvidersPanel({ settings }: { settings: ProviderSettingsMap }) { const providers: SandboxProvider[] = useMemo( - () => [ - { - id: "local", - name: "Local", - description: "Run stages directly on the Fabro host.", - enabled: settings.local.enabled, - }, - { - id: "docker", - name: "Docker", - description: "Run stages in isolated Docker containers on the host daemon.", - enabled: settings.docker.enabled, - }, - { - id: "daytona", - name: "Daytona", - description: "Run stages in cloud sandboxes managed by Daytona.", - enabled: settings.daytona.enabled, - secretName: "DAYTONA_API_KEY", - }, - ], + () => + Object.keys(settings) + .sort(compareProviderKinds) + .map((id) => { + const entry = settings[id]; + const copy = BUNDLED_PROVIDER_COPY[id]; + return copy + ? { id, enabled: entry.enabled, bundled: true, ...copy } + : { + id, + enabled: entry.enabled, + bundled: false, + name: providerLabel(id), + description: pluginDescription(entry), + }; + }), [settings], ); @@ -138,7 +165,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) { "grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong"; const dim = provider.enabled ? "" : "opacity-60"; - if (provider.id === "local") { + if (provider.id === LOCAL_PROVIDER) { return (