checkpoint

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-05-24 14:13:19 -04:00
parent 4893d75947
commit 74e98040d9
6 changed files with 2131 additions and 20 deletions

362
run.json

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,991 @@
diff --git a/docs/public/administration/sandboxing.mdx b/docs/public/administration/sandboxing.mdx
index 749eedeed..e74dad1cc 100644
--- a/docs/public/administration/sandboxing.mdx
+++ b/docs/public/administration/sandboxing.mdx
@@ -7,6 +7,11 @@ Sandboxes isolate agent execution from the host machine. When an agent runs a sh
Fabro supports three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). See [Environments](/execution/environments) for full provider-specific configuration.
+Operators can enable or disable which providers the server may launch with
+`[server.sandbox.providers.<provider>]` in `settings.toml`. Missing entries default to
+`enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled.
+Dry-run Docker/Daytona runs execute locally, so they are governed by the `local` provider policy.
+
## Network access control
For cloud sandboxes (Daytona), you can control outbound network access with `[environments.<slug>.network]`. Three modes are available: `"allow_all"` (default), `"block"`, and `"cidr_allow_list"` with an `allow = ["..."]` CIDR list.
diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx
index 3822174ad..a6ad03475 100644
--- a/docs/public/administration/server-configuration.mdx
+++ b/docs/public/administration/server-configuration.mdx
@@ -17,7 +17,7 @@ Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.tom
| Scope | Examples |
|---|---|
-| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
+| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.sandbox]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing.
@@ -46,6 +46,15 @@ methods = ["dev-token", "github"]
[server.auth.github]
allowed_usernames = ["alice", "bob"]
+[server.sandbox.providers.local]
+enabled = true
+
+[server.sandbox.providers.docker]
+enabled = true
+
+[server.sandbox.providers.daytona]
+enabled = true
+
[server.integrations.github]
app_id = "123456"
client_id = "Iv1.abc123"
@@ -165,6 +174,24 @@ GitHub-specific auth policy.
The GitHub OAuth client ID still lives under `[server.integrations.github].client_id`.
+### `[server.sandbox.providers]` section
+
+Controls which sandbox providers the server may launch. Missing provider entries default to
+`enabled = true` for backward compatibility. Disabling a provider rejects new runs whose effective
+provider is disabled; dry-run Docker/Daytona runs use the local provider and are governed by
+`server.sandbox.providers.local.enabled`.
+
+```toml title="settings.toml"
+[server.sandbox.providers.local]
+enabled = true
+
+[server.sandbox.providers.docker]
+enabled = true
+
+[server.sandbox.providers.daytona]
+enabled = true
+```
+
### `[server.slatedb]` section
Configure the embedded SlateDB key-value store used for run event storage.
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml
index 58a1e02e9..3ec1be7f8 100644
--- a/docs/public/api-reference/fabro-api.yaml
+++ b/docs/public/api-reference/fabro-api.yaml
@@ -11192,6 +11192,7 @@ components:
- web
- auth
- ip_allowlist
+ - sandbox
- storage
- artifacts
- slatedb
@@ -11209,6 +11210,8 @@ components:
$ref: "#/components/schemas/ServerAuthSettings"
ip_allowlist:
$ref: "#/components/schemas/ServerIpAllowlistSettings"
+ sandbox:
+ $ref: "#/components/schemas/ServerSandboxSettings"
storage:
$ref: "#/components/schemas/ServerStorageSettings"
artifacts:
@@ -11325,6 +11328,31 @@ components:
type: string
enum: [GitHubMetaHooks]
+ ServerSandboxSettings:
+ type: object
+ required: [providers]
+ properties:
+ providers:
+ $ref: "#/components/schemas/ServerSandboxProvidersSettings"
+
+ ServerSandboxProvidersSettings:
+ type: object
+ required: [local, docker, daytona]
+ properties:
+ local:
+ $ref: "#/components/schemas/ServerSandboxProviderSettings"
+ docker:
+ $ref: "#/components/schemas/ServerSandboxProviderSettings"
+ daytona:
+ $ref: "#/components/schemas/ServerSandboxProviderSettings"
+
+ ServerSandboxProviderSettings:
+ type: object
+ required: [enabled]
+ properties:
+ enabled:
+ type: boolean
+
ServerStorageSettings:
type: object
required: [root]
diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs
index 4649ef19e..86e848f38 100644
--- a/lib/crates/fabro-api/build.rs
+++ b/lib/crates/fabro-api/build.rs
@@ -259,6 +259,21 @@ fn main() {
"fabro_types::settings::server::IpAllowEntry",
&[],
),
+ (
+ "ServerSandboxSettings",
+ "fabro_types::settings::server::ServerSandboxSettings",
+ &[],
+ ),
+ (
+ "ServerSandboxProvidersSettings",
+ "fabro_types::settings::server::ServerSandboxProvidersSettings",
+ &[],
+ ),
+ (
+ "ServerSandboxProviderSettings",
+ "fabro_types::settings::server::ServerSandboxProviderSettings",
+ &[],
+ ),
(
"ServerStorageSettings",
"fabro_types::settings::server::ServerStorageSettings",
diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs
index a148d9aac..b8f3a02a1 100644
--- a/lib/crates/fabro-api/src/lib.rs
+++ b/lib/crates/fabro-api/src/lib.rs
@@ -25,7 +25,8 @@ pub mod types {
IpAllowEntry, LogDestination, ObjectStoreSettings, ServerApiSettings,
ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
- ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings,
+ ServerListenSettings, ServerLoggingSettings, ServerSandboxProviderSettings,
+ ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
WebhookStrategy,
};
diff --git a/lib/crates/fabro-api/tests/server_settings_round_trip.rs b/lib/crates/fabro-api/tests/server_settings_round_trip.rs
index 45d8ef97e..a7421641b 100644
--- a/lib/crates/fabro-api/tests/server_settings_round_trip.rs
+++ b/lib/crates/fabro-api/tests/server_settings_round_trip.rs
@@ -2,12 +2,18 @@ use std::any::{TypeId, type_name};
use fabro_api::types::{
LogDestination as ApiLogDestination, ObjectStoreSettings as ApiObjectStoreSettings,
- ServerNamespace as ApiServerNamespace, ServerSettings as ApiServerSettings,
+ ServerNamespace as ApiServerNamespace,
+ ServerSandboxProviderSettings as ApiServerSandboxProviderSettings,
+ ServerSandboxProvidersSettings as ApiServerSandboxProvidersSettings,
+ ServerSandboxSettings as ApiServerSandboxSettings, ServerSettings as ApiServerSettings,
};
use fabro_config::ServerSettingsBuilder;
use fabro_types::ServerSettings;
use fabro_types::settings::ServerNamespace;
-use fabro_types::settings::server::{LogDestination, ObjectStoreSettings};
+use fabro_types::settings::server::{
+ LogDestination, ObjectStoreSettings, ServerSandboxProviderSettings,
+ ServerSandboxProvidersSettings, ServerSandboxSettings,
+};
#[test]
fn server_settings_family_reuses_domain_types() {
@@ -15,6 +21,9 @@ fn server_settings_family_reuses_domain_types() {
assert_same_type::<ApiServerNamespace, ServerNamespace>();
assert_same_type::<ApiObjectStoreSettings, ObjectStoreSettings>();
assert_same_type::<ApiLogDestination, LogDestination>();
+ assert_same_type::<ApiServerSandboxSettings, ServerSandboxSettings>();
+ assert_same_type::<ApiServerSandboxProvidersSettings, ServerSandboxProvidersSettings>();
+ assert_same_type::<ApiServerSandboxProviderSettings, ServerSandboxProviderSettings>();
}
#[test]
@@ -40,6 +49,9 @@ methods = ["dev-token", "github"]
[server.auth.github]
allowed_usernames = ["alice"]
+[server.sandbox.providers.daytona]
+enabled = false
+
[server.storage]
root = "/srv/fabro"
@@ -61,6 +73,18 @@ slug = "fabro-dev"
assert_eq!(json["server"]["listen"]["address"], "127.0.0.1:32276");
assert_eq!(json["server"]["storage"]["root"], "/srv/fabro");
assert_eq!(json["server"]["logging"]["destination"], "stdout");
+ assert_eq!(
+ json["server"]["sandbox"]["providers"]["local"]["enabled"],
+ true
+ );
+ assert_eq!(
+ json["server"]["sandbox"]["providers"]["docker"]["enabled"],
+ true
+ );
+ assert_eq!(
+ json["server"]["sandbox"]["providers"]["daytona"]["enabled"],
+ false
+ );
assert!(json.get("features").is_none());
let round_trip: ApiServerSettings =
diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs
index bf5f0a38b..6c425c33d 100644
--- a/lib/crates/fabro-config/src/layers/mod.rs
+++ b/lib/crates/fabro-config/src/layers/mod.rs
@@ -42,8 +42,9 @@ pub use server::{
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer,
ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer,
- ServerListenLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer,
- ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer,
+ ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
+ ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
+ ServerWebLayer, SlackIntegrationLayer,
};
pub use settings::SettingsLayer;
pub use workflow::WorkflowLayer;
diff --git a/lib/crates/fabro-config/src/layers/server.rs b/lib/crates/fabro-config/src/layers/server.rs
index bc03a1824..62939a0e3 100644
--- a/lib/crates/fabro-config/src/layers/server.rs
+++ b/lib/crates/fabro-config/src/layers/server.rs
@@ -23,6 +23,8 @@ pub struct ServerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<ServerIpAllowlistLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sandbox: Option<ServerSandboxLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<ServerStorageLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<ServerArtifactsLayer>,
@@ -109,6 +111,32 @@ pub struct ServerIpAllowlistOverrideLayer {
pub trusted_proxy_count: Option<u32>,
}
+/// `[server.sandbox]` — server-owned sandbox provider policy.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct ServerSandboxLayer {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub providers: Option<ServerSandboxProvidersLayer>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct ServerSandboxProvidersLayer {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub local: Option<ServerSandboxProviderLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub docker: Option<ServerSandboxProviderLayer>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub daytona: Option<ServerSandboxProviderLayer>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
+#[serde(deny_unknown_fields)]
+pub struct ServerSandboxProviderLayer {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub enabled: Option<bool>,
+}
+
/// `[server.storage]` — single managed local disk root.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs
index 7ae698007..0fb321caa 100644
--- a/lib/crates/fabro-config/src/lib.rs
+++ b/lib/crates/fabro-config/src/lib.rs
@@ -57,6 +57,7 @@ pub use layers::{
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
+ ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer,
SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
};
diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs
index f6c22c699..cfdf08c7a 100644
--- a/lib/crates/fabro-config/src/resolve/server.rs
+++ b/lib/crates/fabro-config/src/resolve/server.rs
@@ -4,7 +4,8 @@ use fabro_types::settings::server::{
IpAllowEntry, ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings,
ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
- ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings,
+ ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSandboxProviderSettings,
+ ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
WebhookStrategy,
};
@@ -15,8 +16,8 @@ use crate::user::default_storage_dir;
use crate::{
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
- ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSlateDbLayer,
- ServerStorageLayer, ServerWebLayer,
+ ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSandboxLayer,
+ ServerSandboxProviderLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
};
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerNamespace {
@@ -38,6 +39,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
web,
auth,
ip_allowlist,
+ sandbox: resolve_sandbox(layer.sandbox.as_ref()),
storage: storage.clone(),
artifacts: resolve_artifacts(layer.artifacts.as_ref(), &storage.root, errors),
slatedb: resolve_slatedb(layer.slatedb.as_ref(), &storage.root, errors),
@@ -64,6 +66,31 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
}
}
+fn resolve_sandbox(layer: Option<&ServerSandboxLayer>) -> ServerSandboxSettings {
+ let providers = layer.and_then(|sandbox| sandbox.providers.as_ref());
+ ServerSandboxSettings {
+ providers: ServerSandboxProvidersSettings {
+ local: resolve_sandbox_provider(
+ providers.and_then(|providers| providers.local.as_ref()),
+ ),
+ docker: resolve_sandbox_provider(
+ providers.and_then(|providers| providers.docker.as_ref()),
+ ),
+ daytona: resolve_sandbox_provider(
+ providers.and_then(|providers| providers.daytona.as_ref()),
+ ),
+ },
+ }
+}
+
+fn resolve_sandbox_provider(
+ layer: Option<&ServerSandboxProviderLayer>,
+) -> ServerSandboxProviderSettings {
+ ServerSandboxProviderSettings {
+ enabled: layer.and_then(|provider| provider.enabled).unwrap_or(true),
+ }
+}
+
fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings {
ServerStorageSettings {
root: layer
diff --git a/lib/crates/fabro-config/src/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs
index d3c7899d4..edd419619 100644
--- a/lib/crates/fabro-config/src/tests/resolve_server.rs
+++ b/lib/crates/fabro-config/src/tests/resolve_server.rs
@@ -135,6 +135,66 @@ fn resolved_server_integrations_are_slack_only_for_chat() {
);
}
+#[test]
+fn server_sandbox_defaults_all_providers_enabled() {
+ let settings = ServerSettingsBuilder::from_toml(
+ r#"
+_version = 1
+
+[server.auth]
+methods = ["dev-token"]
+"#,
+ )
+ .expect("server settings should resolve");
+
+ let sandbox = settings.server.sandbox;
+ assert!(sandbox.providers.local.enabled);
+ assert!(sandbox.providers.docker.enabled);
+ assert!(sandbox.providers.daytona.enabled);
+}
+
+#[test]
+fn server_sandbox_allows_partial_provider_overrides() {
+ let settings = ServerSettingsBuilder::from_toml(
+ r#"
+_version = 1
+
+[server.auth]
+methods = ["dev-token"]
+
+[server.sandbox.providers.daytona]
+enabled = false
+"#,
+ )
+ .expect("server settings should resolve");
+
+ let sandbox = settings.server.sandbox;
+ assert!(sandbox.providers.local.enabled);
+ assert!(sandbox.providers.docker.enabled);
+ assert!(!sandbox.providers.daytona.enabled);
+}
+
+#[test]
+fn parsing_rejects_unknown_server_sandbox_provider() {
+ let err = ServerSettingsBuilder::from_toml(
+ r#"
+_version = 1
+
+[server.auth]
+methods = ["dev-token"]
+
+[server.sandbox.providers.exe]
+enabled = true
+"#,
+ )
+ .expect_err("unknown sandbox provider should be rejected");
+
+ assert!(
+ err.to_string().contains("unknown field `exe`"),
+ "unexpected error: {err}"
+ );
+}
+
#[test]
fn parsing_rejects_unknown_server_integrations() {
let source = r"
diff --git a/lib/crates/fabro-install/src/lib.rs b/lib/crates/fabro-install/src/lib.rs
index f62de5414..2176b7fb2 100644
--- a/lib/crates/fabro-install/src/lib.rs
+++ b/lib/crates/fabro-install/src/lib.rs
@@ -442,6 +442,25 @@ pub fn write_object_store_settings(
}
}
+fn write_sandbox_provider_enabled(
+ providers: &mut toml::Table,
+ provider: &str,
+ enabled: bool,
+) -> Result<()> {
+ let table = ensure_table(providers, provider)?;
+ table.insert("enabled".to_string(), toml::Value::Boolean(enabled));
+ Ok(())
+}
+
+fn write_sandbox_provider_policy(server: &mut toml::Table) -> Result<()> {
+ let sandbox = ensure_table(server, "sandbox")?;
+ let providers = ensure_table(sandbox, "providers")?;
+ write_sandbox_provider_enabled(providers, "local", true)?;
+ write_sandbox_provider_enabled(providers, "docker", true)?;
+ write_sandbox_provider_enabled(providers, "daytona", true)?;
+ Ok(())
+}
+
pub fn write_sandbox_settings(
doc: &mut toml::Value,
selection: InstallSandboxSelection,
@@ -461,6 +480,8 @@ pub fn write_sandbox_settings(
"provider".to_string(),
toml::Value::String(provider.to_string()),
);
+ let server = ensure_table(root, "server")?;
+ write_sandbox_provider_policy(server)?;
Ok(())
}
@@ -1407,6 +1428,9 @@ stale = "remove-me"
.and_then(toml::Value::as_str),
Some("docker")
);
+ assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
+ assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(true));
+ assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(true));
}
#[test]
@@ -1433,6 +1457,22 @@ stale = "remove-me"
.and_then(toml::Value::as_str),
Some("daytona")
);
+ assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
+ assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(true));
+ assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(true));
+ }
+
+ fn sandbox_provider_enabled(doc: &toml::Value, provider: &str) -> Option<bool> {
+ doc.get("server")
+ .and_then(toml::Value::as_table)
+ .and_then(|server| server.get("sandbox"))
+ .and_then(toml::Value::as_table)
+ .and_then(|sandbox| sandbox.get("providers"))
+ .and_then(toml::Value::as_table)
+ .and_then(|providers| providers.get(provider))
+ .and_then(toml::Value::as_table)
+ .and_then(|provider| provider.get("enabled"))
+ .and_then(toml::Value::as_bool)
}
#[test]
diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs
index ecc61e589..e7f9884a6 100644
--- a/lib/crates/fabro-server/src/run_manifest.rs
+++ b/lib/crates/fabro-server/src/run_manifest.rs
@@ -26,7 +26,7 @@ use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, RunGoal, RunMode, RunNamespace};
-use fabro_types::{ManifestPath, RunId, WorkflowSettings};
+use fabro_types::{ManifestPath, RunId, ServerSettings, WorkflowSettings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
@@ -502,13 +502,26 @@ async fn build_preflight_report(
let resolved_run = materialized.run;
let server_settings = state.server_settings();
let github_integration = &server_settings.server.integrations.github;
- let sandbox_provider = resolve_sandbox_provider(&resolved_run);
- let sandbox_provider =
- if resolved_run.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() {
- SandboxProvider::Local
- } else {
- sandbox_provider
- };
+ let sandbox_provider = effective_sandbox_provider(&resolved_run);
+ if let Some(error) = sandbox_provider_policy_error(&server_settings, sandbox_provider) {
+ checks.push(CheckResult {
+ name: "Sandbox Provider Policy".into(),
+ status: CheckStatus::Error,
+ summary: error,
+ details: Vec::new(),
+ remediation: None,
+ });
+ return Ok((
+ CheckReport {
+ title: "Run Preflight".into(),
+ sections: vec![CheckSection {
+ title: String::new(),
+ checks,
+ }],
+ },
+ false,
+ ));
+ }
run_environment_capability_check(&mut checks, &resolved_run);
let needs_github_credentials =
sandbox_provider.is_clone_based() || resolved_run.integrations.github.is_token_requested();
@@ -621,6 +634,32 @@ fn resolve_sandbox_provider(settings: &RunNamespace) -> SandboxProvider {
SandboxProvider::from(settings.environment.provider)
}
+pub(crate) fn sandbox_provider_policy_error(
+ server_settings: &ServerSettings,
+ provider: SandboxProvider,
+) -> Option<String> {
+ let enabled = match provider {
+ SandboxProvider::Local => server_settings.server.sandbox.providers.local.enabled,
+ SandboxProvider::Docker => server_settings.server.sandbox.providers.docker.enabled,
+ SandboxProvider::Daytona => server_settings.server.sandbox.providers.daytona.enabled,
+ };
+
+ (!enabled).then(|| {
+ format!(
+ "sandbox provider \"{provider}\" is disabled by server.sandbox.providers.{provider}.enabled"
+ )
+ })
+}
+
+pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProvider {
+ let provider = resolve_sandbox_provider(settings);
+ if settings.execution.mode == RunMode::DryRun && !provider.is_local() {
+ SandboxProvider::Local
+ } else {
+ provider
+ }
+}
+
fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig {
daytona_config_from_environment(&settings.environment, !settings.clone.enabled)
}
diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs
index 3b6069115..d2f1813f9 100644
--- a/lib/crates/fabro-server/src/server.rs
+++ b/lib/crates/fabro-server/src/server.rs
@@ -3580,6 +3580,15 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
finish_cancelled_run_before_execution(&state, run_id).await;
return;
}
+ let effective_provider =
+ run_manifest::effective_sandbox_provider(&persisted.run_spec().settings.run);
+ if let Some(error) =
+ run_manifest::sandbox_provider_policy_error(&server_settings, effective_provider)
+ {
+ tracing::error!(run_id = %run_id, error = %error, "Sandbox provider disabled by server policy");
+ fail_run_before_execution(&state, run_id, FailureReason::LaunchFailed, error).await;
+ return;
+ }
let github_app_result = {
let run_spec = persisted.run_spec();
let settings = &run_spec.settings.run;
@@ -3804,6 +3813,15 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
}
};
let agent_fabro_tools_enabled = run_state.spec.settings.run.agent.fabro_tools;
+ let server_settings = state.server_settings();
+ let effective_provider = run_manifest::effective_sandbox_provider(&run_state.spec.settings.run);
+ if let Some(error) =
+ run_manifest::sandbox_provider_policy_error(&server_settings, effective_provider)
+ {
+ tracing::error!(run_id = %run_id, error = %error, "Sandbox provider disabled by server policy");
+ fail_run_before_execution(&state, run_id, FailureReason::LaunchFailed, error).await;
+ return;
+ }
let state_for_build = Arc::clone(&state);
let run_dir_for_build = run_dir.clone();
diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs
index cfc8dba88..a313d3fda 100644
--- a/lib/crates/fabro-server/src/server/handler/runs.rs
+++ b/lib/crates/fabro-server/src/server/handler/runs.rs
@@ -606,6 +606,12 @@ async fn create_run(
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let run_id = prepared.run_id.unwrap_or_else(RunId::new);
+ let provider = run_manifest::effective_sandbox_provider(&prepared.settings.run);
+ if let Some(error) =
+ run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
+ {
+ return ApiError::bad_request(error).into_response();
+ }
if let Some(parent_id) = prepared.parent_id {
if parent_id == run_id {
return ApiError::bad_request("A run cannot be its own parent.").into_response();
diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs
index 6488ab364..fe8bce65c 100644
--- a/lib/crates/fabro-server/src/server/tests.rs
+++ b/lib/crates/fabro-server/src/server/tests.rs
@@ -915,6 +915,29 @@ id = "missing"
);
}
+#[test]
+fn sandbox_provider_policy_error_reports_disabled_provider() {
+ let settings = server_settings_from_toml(
+ r#"
+_version = 1
+
+[server.auth]
+methods = ["dev-token"]
+
+[server.sandbox.providers.daytona]
+enabled = false
+"#,
+ );
+
+ assert_eq!(
+ crate::run_manifest::sandbox_provider_policy_error(&settings, SandboxProvider::Daytona)
+ .as_deref(),
+ Some(
+ "sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
+ )
+ );
+}
+
#[test]
fn clone_sandbox_credentials_are_available_for_clone_based_providers() {
use fabro_types::settings::run::EnvironmentProvider;
diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs
index 8b9bea24e..d5c431fde 100644
--- a/lib/crates/fabro-server/tests/it/api/install.rs
+++ b/lib/crates/fabro-server/tests/it/api/install.rs
@@ -34,6 +34,22 @@ fn spa_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
}
+fn assert_sandbox_provider_policy_enabled(settings: &str) {
+ assert!(settings.contains("[server.sandbox.providers.local]"));
+ assert!(settings.contains("[server.sandbox.providers.docker]"));
+ assert!(settings.contains("[server.sandbox.providers.daytona]"));
+ assert!(settings.contains("enabled = true"));
+
+ let resolved = ServerSettingsBuilder::from_toml(settings)
+ .expect("settings should resolve")
+ .server
+ .sandbox
+ .providers;
+ assert!(resolved.local.enabled);
+ assert!(resolved.docker.enabled);
+ assert!(resolved.daytona.enabled);
+}
+
async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
@@ -918,6 +934,7 @@ async fn token_install_finish_persists_settings_env_and_vault() {
settings.contains("provider = \"docker\""),
"settings.toml should record explicit docker sandbox provider"
);
+ assert_sandbox_provider_policy_enabled(&settings);
let resolved = ServerSettingsBuilder::from_toml(&settings)
.expect("settings should resolve")
.server;
@@ -2677,6 +2694,7 @@ async fn daytona_install_finish_writes_settings_and_vault_secret() {
settings.contains("provider = \"daytona\""),
"settings.toml should record daytona sandbox provider"
);
+ assert_sandbox_provider_policy_enabled(&settings);
let vault = Vault::load(Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));
diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs
index 32c205c1e..c6521ee7c 100644
--- a/lib/crates/fabro-server/tests/it/api/runs.rs
+++ b/lib/crates/fabro-server/tests/it/api/runs.rs
@@ -52,6 +52,84 @@ async fn request_json(
.await
}
+fn daytona_manifest() -> serde_json::Value {
+ let mut manifest = minimal_manifest_json(MINIMAL_DOT);
+ manifest["args"] = serde_json::json!({ "environment": "daytona" });
+ manifest
+}
+
+fn daytona_disabled_settings() -> crate::helpers::TestAppSettings {
+ settings_from_toml(
+ r"
+_version = 1
+
+[server.sandbox.providers.daytona]
+enabled = false
+",
+ )
+}
+
+#[tokio::test]
+async fn create_run_rejects_disabled_sandbox_provider() {
+ let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
+ daytona_disabled_settings(),
+ 5,
+ ));
+
+ let request = Request::builder()
+ .method("POST")
+ .uri(api("/runs"))
+ .header("content-type", "application/json")
+ .body(Body::from(daytona_manifest().to_string()))
+ .expect("create run request should build");
+ let body = response_json(
+ app.clone().oneshot(request).await.unwrap(),
+ StatusCode::BAD_REQUEST,
+ "POST /api/v1/runs",
+ )
+ .await;
+
+ assert_eq!(
+ body["errors"][0]["detail"],
+ "sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
+ );
+}
+
+#[tokio::test]
+async fn preflight_reports_disabled_sandbox_provider() {
+ let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
+ daytona_disabled_settings(),
+ 5,
+ ));
+
+ let request = Request::builder()
+ .method("POST")
+ .uri(api("/preflight"))
+ .header("content-type", "application/json")
+ .body(Body::from(daytona_manifest().to_string()))
+ .expect("preflight request should build");
+ let body = response_json(
+ app.clone().oneshot(request).await.unwrap(),
+ StatusCode::OK,
+ "POST /api/v1/preflight",
+ )
+ .await;
+
+ assert_eq!(body["ok"], false);
+ let checks = body["checks"]["sections"][0]["checks"]
+ .as_array()
+ .expect("preflight checks should be an array");
+ let policy_check = checks
+ .iter()
+ .find(|check| check["name"] == "Sandbox Provider Policy")
+ .expect("policy check should be present");
+ assert_eq!(policy_check["status"], "error");
+ assert_eq!(
+ policy_check["summary"],
+ "sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
+ );
+}
+
#[tokio::test]
async fn run_responses_include_ask_fabro_affordance() {
let settings = settings_from_toml(
diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs
index fa92d3f64..9f7daba0d 100644
--- a/lib/crates/fabro-types/src/settings/server.rs
+++ b/lib/crates/fabro-types/src/settings/server.rs
@@ -29,6 +29,7 @@ pub struct ServerNamespace {
pub web: ServerWebSettings,
pub auth: ServerAuthSettings,
pub ip_allowlist: ServerIpAllowlistSettings,
+ pub sandbox: ServerSandboxSettings,
pub storage: ServerStorageSettings,
pub artifacts: ServerArtifactsSettings,
pub slatedb: ServerSlateDbSettings,
@@ -50,6 +51,7 @@ impl ServerNamespace {
web: ServerWebSettings::default(),
auth: ServerAuthSettings::default(),
ip_allowlist: ServerIpAllowlistSettings::default(),
+ sandbox: ServerSandboxSettings::default(),
storage: ServerStorageSettings::default(),
artifacts: ServerArtifactsSettings::default(),
slatedb: ServerSlateDbSettings::default(),
@@ -133,6 +135,29 @@ pub struct ServerIpAllowlistOverrideSettings {
pub trusted_proxy_count: Option<u32>,
}
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ServerSandboxSettings {
+ pub providers: ServerSandboxProvidersSettings,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ServerSandboxProvidersSettings {
+ pub local: ServerSandboxProviderSettings,
+ pub docker: ServerSandboxProviderSettings,
+ pub daytona: ServerSandboxProviderSettings,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ServerSandboxProviderSettings {
+ pub enabled: bool,
+}
+
+impl Default for ServerSandboxProviderSettings {
+ fn default() -> Self {
+ Self { enabled: true }
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IpAllowEntry {
Literal(IpNet),
diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES
index f8d333e14..633d462aa 100644
--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES
+++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES
@@ -389,6 +389,9 @@ models/server-listen-tcp-settings.ts
models/server-listen-unix-settings.ts
models/server-logging-settings.ts
models/server-namespace.ts
+models/server-sandbox-provider-settings.ts
+models/server-sandbox-providers-settings.ts
+models/server-sandbox-settings.ts
models/server-scheduler-settings.ts
models/server-settings.ts
models/server-slate-db-settings.ts
diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts
index 78d106d70..26530b0fe 100644
--- a/lib/packages/fabro-api-client/src/models/index.ts
+++ b/lib/packages/fabro-api-client/src/models/index.ts
@@ -365,6 +365,9 @@ export * from './server-listen-tcp-settings';
export * from './server-listen-unix-settings';
export * from './server-logging-settings';
export * from './server-namespace';
+export * from './server-sandbox-provider-settings';
+export * from './server-sandbox-providers-settings';
+export * from './server-sandbox-settings';
export * from './server-scheduler-settings';
export * from './server-settings';
export * from './server-slate-db-settings';
diff --git a/lib/packages/fabro-api-client/src/models/server-namespace.ts b/lib/packages/fabro-api-client/src/models/server-namespace.ts
index f162fe6cc..315243e88 100644
--- a/lib/packages/fabro-api-client/src/models/server-namespace.ts
+++ b/lib/packages/fabro-api-client/src/models/server-namespace.ts
@@ -36,6 +36,9 @@ import type { ServerListenSettings } from './server-listen-settings';
import type { ServerLoggingSettings } from './server-logging-settings';
// May contain unused imports in some cases
// @ts-ignore
+import type { ServerSandboxSettings } from './server-sandbox-settings';
+// May contain unused imports in some cases
+// @ts-ignore
import type { ServerSchedulerSettings } from './server-scheduler-settings';
// May contain unused imports in some cases
// @ts-ignore
@@ -53,6 +56,7 @@ export interface ServerNamespace {
'web': ServerWebSettings;
'auth': ServerAuthSettings;
'ip_allowlist': ServerIpAllowlistSettings;
+ 'sandbox': ServerSandboxSettings;
'storage': ServerStorageSettings;
'artifacts': ServerArtifactsSettings;
'slatedb': ServerSlateDbSettings;
diff --git a/lib/packages/fabro-api-client/src/models/server-sandbox-provider-settings.ts b/lib/packages/fabro-api-client/src/models/server-sandbox-provider-settings.ts
new file mode 100644
index 000000000..c50d09f6d
--- /dev/null
+++ b/lib/packages/fabro-api-client/src/models/server-sandbox-provider-settings.ts
@@ -0,0 +1,19 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Fabro Run API
+ * HTTP API for managing Fabro workflow run executions.
+ *
+ * The version of the OpenAPI document: 0.1.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+
+export interface ServerSandboxProviderSettings {
+ 'enabled': boolean;
+}
diff --git a/lib/packages/fabro-api-client/src/models/server-sandbox-providers-settings.ts b/lib/packages/fabro-api-client/src/models/server-sandbox-providers-settings.ts
new file mode 100644
index 000000000..9fa9a35a1
--- /dev/null
+++ b/lib/packages/fabro-api-client/src/models/server-sandbox-providers-settings.ts
@@ -0,0 +1,24 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Fabro Run API
+ * HTTP API for managing Fabro workflow run executions.
+ *
+ * The version of the OpenAPI document: 0.1.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+// May contain unused imports in some cases
+// @ts-ignore
+import type { ServerSandboxProviderSettings } from './server-sandbox-provider-settings';
+
+export interface ServerSandboxProvidersSettings {
+ 'local': ServerSandboxProviderSettings;
+ 'docker': ServerSandboxProviderSettings;
+ 'daytona': ServerSandboxProviderSettings;
+}
diff --git a/lib/packages/fabro-api-client/src/models/server-sandbox-settings.ts b/lib/packages/fabro-api-client/src/models/server-sandbox-settings.ts
new file mode 100644
index 000000000..cb6a3b2d6
--- /dev/null
+++ b/lib/packages/fabro-api-client/src/models/server-sandbox-settings.ts
@@ -0,0 +1,22 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Fabro Run API
+ * HTTP API for managing Fabro workflow run executions.
+ *
+ * The version of the OpenAPI document: 0.1.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+// May contain unused imports in some cases
+// @ts-ignore
+import type { ServerSandboxProvidersSettings } from './server-sandbox-providers-settings';
+
+export interface ServerSandboxSettings {
+ 'providers': ServerSandboxProvidersSettings;
+}

View file

@ -0,0 +1,6 @@
{
"outcome": "succeeded",
"notes": "Stage completed: implement",
"failure_reason": null,
"timestamp": "2026-05-24T17:58:54.383898Z"
}

View file

@ -0,0 +1,752 @@
Goal: # Server Sandbox Provider Enablement Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add server-owned sandbox provider enablement policy at `[server.sandbox.providers.<provider>]`, enforce it for launched runs, and make the installer write explicit provider policy entries.
**Architecture:** Model sandbox provider policy as resolved server settings, separate from run environments. Missing config remains backward-compatible by resolving all providers to enabled, while explicit false values block the corresponding effective sandbox provider at server admission, preflight, and launch.
**Tech Stack:** Rust, `serde`, existing `fabro-config` layer/resolve patterns, OpenAPI/progenitor, generated TypeScript API client, Axum server handlers, `cargo nextest`.
---
## File Structure
- Modify `lib/crates/fabro-config/src/layers/server.rs`
- Add sparse `[server.sandbox]` layer structs with closed `providers.local`, `providers.docker`, and `providers.daytona` tables.
- Modify `lib/crates/fabro-config/src/resolve/server.rs`
- Resolve missing sandbox provider policy to all providers enabled.
- Modify `lib/crates/fabro-types/src/settings/server.rs`
- Add resolved `ServerSandboxSettings`, `ServerSandboxProvidersSettings`, and `ServerSandboxProviderSettings` types under `ServerNamespace`.
- Modify `lib/crates/fabro-install/src/lib.rs`
- Make `write_sandbox_settings` write all three provider enablement tables with `enabled = true`.
- Modify `lib/crates/fabro-server/src/run_manifest.rs` and `lib/crates/fabro-server/src/server/handler/runs.rs`
- Add policy checks for run creation and preflight.
- Modify `lib/crates/fabro-server/src/server.rs`
- Add a launch-time recheck before sandbox setup.
- Modify `docs/public/api-reference/fabro-api.yaml`
- Include `server.sandbox` in the `ServerSettings` API shape.
- Regenerate `lib/packages/fabro-api-client/src/models/*`
- Include TypeScript client models for the new settings shape.
- Modify docs:
- `docs/public/administration/server-configuration.mdx`
- `docs/public/administration/sandboxing.mdx`
## Contract
Supported TOML shape:
```toml
[server.sandbox.providers.local]
enabled = true
[server.sandbox.providers.docker]
enabled = true
[server.sandbox.providers.daytona]
enabled = true
```
Resolution rules:
- Missing `[server.sandbox]` means all providers are enabled.
- Missing `[server.sandbox.providers]` means all providers are enabled.
- Missing individual provider tables mean that provider is enabled.
- Missing individual `enabled` values mean that provider is enabled.
- Unknown keys under `[server.sandbox]`, `[server.sandbox.providers]`, or provider tables are schema errors.
Policy rule:
- The server checks the **effective** provider, after existing dry-run coercion from Docker/Daytona to Local.
- Disabled-provider failures use this message:
```text
sandbox provider "<provider>" is disabled by server.sandbox.providers.<provider>.enabled
```
Installer rule:
- Browser install and CLI/shared install persistence keep using the chosen sandbox provider as the default run environment.
- Installer-generated `settings.toml` always writes all three sandbox provider entries with `enabled = true`.
## Task 1: Add Server Config Types and Resolution
**Files:**
- Modify: `lib/crates/fabro-config/src/layers/server.rs`
- Modify: `lib/crates/fabro-config/src/resolve/server.rs`
- Modify: `lib/crates/fabro-types/src/settings/server.rs`
- Test: `lib/crates/fabro-config/src/tests/resolve_server.rs`
- [ ] **Step 1: Write failing config tests**
Add tests in `lib/crates/fabro-config/src/tests/resolve_server.rs`:
```rust
#[test]
fn server_sandbox_defaults_all_providers_enabled() {
let settings = super::server_settings_from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
"#,
);
let sandbox = settings.server.sandbox;
assert!(sandbox.providers.local.enabled);
assert!(sandbox.providers.docker.enabled);
assert!(sandbox.providers.daytona.enabled);
}
#[test]
fn server_sandbox_allows_partial_provider_overrides() {
let settings = super::server_settings_from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.daytona]
enabled = false
"#,
);
let sandbox = settings.server.sandbox;
assert!(sandbox.providers.local.enabled);
assert!(sandbox.providers.docker.enabled);
assert!(!sandbox.providers.daytona.enabled);
}
#[test]
fn parsing_rejects_unknown_server_sandbox_provider() {
let err = fabro_config::ServerSettingsBuilder::from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.exe]
enabled = true
"#,
)
.expect_err("unknown sandbox provider should be rejected");
assert!(
err.to_string().contains("unknown field `exe`"),
"unexpected error: {err}"
);
}
```
Run:
```bash
cargo test -p fabro-config server_sandbox --quiet
cargo test -p fabro-config parsing_rejects_unknown_server_sandbox_provider --quiet
```
Expected: tests fail because `server.sandbox` does not exist yet.
- [ ] **Step 2: Add sparse config layer types**
In `lib/crates/fabro-config/src/layers/server.rs`, add `sandbox` to `ServerLayer`:
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ServerSandboxLayer>,
```
Add the layer structs near the other server subdomain structs:
```rust
/// `[server.sandbox]` — server-owned sandbox provider policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub providers: Option<ServerSandboxProvidersLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerSandboxProvidersLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<ServerSandboxProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docker: Option<ServerSandboxProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona: Option<ServerSandboxProviderLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerSandboxProviderLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
```
- [ ] **Step 3: Add resolved server settings types**
In `lib/crates/fabro-types/src/settings/server.rs`, add `sandbox` to `ServerNamespace` after `ip_allowlist` or before `storage`:
```rust
pub sandbox: ServerSandboxSettings,
```
Update `ServerNamespace::test_default()` to initialize it:
```rust
sandbox: ServerSandboxSettings::default(),
```
Add resolved structs:
```rust
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSandboxSettings {
pub providers: ServerSandboxProvidersSettings,
}
impl Default for ServerSandboxSettings {
fn default() -> Self {
Self {
providers: ServerSandboxProvidersSettings::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSandboxProvidersSettings {
pub local: ServerSandboxProviderSettings,
pub docker: ServerSandboxProviderSettings,
pub daytona: ServerSandboxProviderSettings,
}
impl Default for ServerSandboxProvidersSettings {
fn default() -> Self {
Self {
local: ServerSandboxProviderSettings::default(),
docker: ServerSandboxProviderSettings::default(),
daytona: ServerSandboxProviderSettings::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSandboxProviderSettings {
pub enabled: bool,
}
impl Default for ServerSandboxProviderSettings {
fn default() -> Self {
Self { enabled: true }
}
}
```
- [ ] **Step 4: Resolve the new settings**
In `lib/crates/fabro-config/src/resolve/server.rs`, import the new layer and resolved types, then add `sandbox` to `ServerNamespace` construction:
```rust
sandbox: resolve_sandbox(layer.sandbox.as_ref()),
```
Add resolver helpers:
```rust
fn resolve_sandbox(layer: Option<&ServerSandboxLayer>) -> ServerSandboxSettings {
let providers = layer.and_then(|sandbox| sandbox.providers.as_ref());
ServerSandboxSettings {
providers: ServerSandboxProvidersSettings {
local: resolve_sandbox_provider(providers.and_then(|providers| providers.local.as_ref())),
docker: resolve_sandbox_provider(providers.and_then(|providers| providers.docker.as_ref())),
daytona: resolve_sandbox_provider(providers.and_then(|providers| providers.daytona.as_ref())),
},
}
}
fn resolve_sandbox_provider(
layer: Option<&ServerSandboxProviderLayer>,
) -> ServerSandboxProviderSettings {
ServerSandboxProviderSettings {
enabled: layer.and_then(|provider| provider.enabled).unwrap_or(true),
}
}
```
- [ ] **Step 5: Run config tests**
Run:
```bash
cargo test -p fabro-config server_sandbox --quiet
cargo test -p fabro-config parsing_rejects_unknown_server_sandbox_provider --quiet
```
Expected: all tests pass.
## Task 2: Enforce Policy in Server Run Paths
**Files:**
- Modify: `lib/crates/fabro-server/src/run_manifest.rs`
- Modify: `lib/crates/fabro-server/src/server/handler/runs.rs`
- Modify: `lib/crates/fabro-server/src/server.rs`
- Test: `lib/crates/fabro-server/src/server/tests.rs`
- Test: `lib/crates/fabro-server/tests/it/api/runs.rs`
- [ ] **Step 1: Add the shared policy helper**
In `lib/crates/fabro-server/src/run_manifest.rs`, add this helper near `resolve_sandbox_provider`:
```rust
pub(crate) fn sandbox_provider_policy_error(
server_settings: &fabro_types::ServerSettings,
provider: SandboxProvider,
) -> Option<String> {
let enabled = match provider {
SandboxProvider::Local => server_settings.server.sandbox.providers.local.enabled,
SandboxProvider::Docker => server_settings.server.sandbox.providers.docker.enabled,
SandboxProvider::Daytona => server_settings.server.sandbox.providers.daytona.enabled,
};
(!enabled).then(|| {
format!(
"sandbox provider \"{provider}\" is disabled by server.sandbox.providers.{provider}.enabled"
)
})
}
pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProvider {
let provider = resolve_sandbox_provider(settings);
if settings.execution.mode == RunMode::DryRun && !provider.is_local() {
SandboxProvider::Local
} else {
provider
}
}
```
Replace local duplicate dry-run effective-provider logic in `build_preflight_report` with `effective_sandbox_provider(&resolved_run)`.
- [ ] **Step 2: Add preflight policy failure**
In `build_preflight_report`, after `sandbox_provider` is computed and before runtime sandbox checks:
```rust
if let Some(error) = sandbox_provider_policy_error(&server_settings, sandbox_provider) {
checks.push(CheckResult {
name: "Sandbox Provider Policy".into(),
status: CheckStatus::Error,
summary: error,
details: Vec::new(),
remediation: None,
});
return Ok((
CheckReport {
title: "Run Preflight".into(),
sections: vec![CheckSection {
title: String::new(),
checks,
}],
},
false,
));
}
```
- [ ] **Step 3: Reject disabled providers at run creation**
In `lib/crates/fabro-server/src/server/handler/runs.rs`, after `prepared` is created and before parent validation:
```rust
let provider = run_manifest::effective_sandbox_provider(&prepared.settings.run);
if let Some(error) = run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
{
return ApiError::bad_request(error).into_response();
}
```
This deliberately uses the resolved run settings already produced by `prepare_manifest_with_environment_defaults`; sandbox provider selection is not graph-dependent.
- [ ] **Step 4: Recheck policy at launch**
In `lib/crates/fabro-server/src/server.rs`, after loading `persisted` and before resolving GitHub credentials:
```rust
let effective_provider = run_manifest::effective_sandbox_provider(&persisted.run_spec().settings.run);
if let Some(error) = run_manifest::sandbox_provider_policy_error(&server_settings, effective_provider)
{
tracing::error!(run_id = %run_id, error = %error, "Sandbox provider disabled by server policy");
fail_run_before_execution(&state, run_id, FailureReason::LaunchFailed, error).await;
return;
}
```
- [ ] **Step 5: Test server behavior**
Add tests covering:
```rust
#[test]
fn sandbox_provider_policy_error_reports_disabled_provider() {
let settings = server_settings_from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.daytona]
enabled = false
"#,
);
assert_eq!(
crate::run_manifest::sandbox_provider_policy_error(
&settings,
fabro_sandbox::SandboxProvider::Daytona,
)
.as_deref(),
Some(
"sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
)
);
}
```
Add an API integration test in `lib/crates/fabro-server/tests/it/api/runs.rs` that creates a test app with Daytona disabled and a manifest selecting a Daytona environment. Assert `POST /api/v1/runs` returns `400` and the policy message.
Add a preflight test that sends the same manifest to `/api/v1/runs/preflight` and asserts `ok = false` plus a `Sandbox Provider Policy` error check.
Run:
```bash
cargo nextest run -p fabro-server sandbox_provider_policy
cargo nextest run -p fabro-server --test it runs::create_run_rejects_disabled_sandbox_provider
```
Expected: all new tests pass.
## Task 3: Update Installer Persistence
**Files:**
- Modify: `lib/crates/fabro-install/src/lib.rs`
- Test: `lib/crates/fabro-install/src/lib.rs`
- Test: `lib/crates/fabro-server/tests/it/api/install.rs`
- [ ] **Step 1: Add installer unit assertions**
Extend `write_sandbox_settings_records_docker_provider` and `write_sandbox_settings_records_daytona_provider` to assert all three provider policies:
```rust
fn sandbox_provider_enabled(doc: &toml::Value, provider: &str) -> Option<bool> {
doc.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("sandbox"))
.and_then(toml::Value::as_table)
.and_then(|sandbox| sandbox.get("providers"))
.and_then(toml::Value::as_table)
.and_then(|providers| providers.get(provider))
.and_then(toml::Value::as_table)
.and_then(|provider| provider.get("enabled"))
.and_then(toml::Value::as_bool)
}
assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(true));
```
Run:
```bash
cargo test -p fabro-install write_sandbox_settings_records --quiet
```
Expected: tests fail because policy entries are not written yet.
- [ ] **Step 2: Write all provider policy entries**
Add helper functions in `lib/crates/fabro-install/src/lib.rs`:
```rust
fn write_sandbox_provider_enabled(
providers: &mut toml::Table,
provider: &str,
enabled: bool,
) -> Result<()> {
let table = ensure_table(providers, provider)?;
table.insert("enabled".to_string(), toml::Value::Boolean(enabled));
Ok(())
}
fn write_sandbox_provider_policy(server: &mut toml::Table) -> Result<()> {
let sandbox = ensure_table(server, "sandbox")?;
let providers = ensure_table(sandbox, "providers")?;
write_sandbox_provider_enabled(providers, "local", true)?;
write_sandbox_provider_enabled(providers, "docker", true)?;
write_sandbox_provider_enabled(providers, "daytona", true)?;
Ok(())
}
```
In `write_sandbox_settings`, after obtaining the root table and before returning:
```rust
let server = ensure_table(root, "server")?;
write_sandbox_provider_policy(server)?;
```
- [ ] **Step 3: Update browser install finish tests**
In `lib/crates/fabro-server/tests/it/api/install.rs`, update Docker and Daytona install finish tests to assert:
```rust
assert!(settings.contains("[server.sandbox.providers.local]"));
assert!(settings.contains("[server.sandbox.providers.docker]"));
assert!(settings.contains("[server.sandbox.providers.daytona]"));
assert!(settings.contains("enabled = true"));
```
Also parse the generated settings with `ServerSettingsBuilder::from_toml` and assert all three resolved providers are enabled.
- [ ] **Step 4: Run installer tests**
Run:
```bash
cargo test -p fabro-install write_sandbox_settings_records --quiet
cargo nextest run -p fabro-server --test it install::token_install_finish_persists_settings_env_and_vault
cargo nextest run -p fabro-server --test it install::daytona_install_finish_writes_settings_and_vault_secret
```
Expected: all tests pass.
## Task 4: Update API Schema, Generated Clients, and Docs
**Files:**
- Modify: `docs/public/api-reference/fabro-api.yaml`
- Modify: `lib/crates/fabro-api/tests/server_settings_round_trip.rs`
- Regenerate: `lib/packages/fabro-api-client/src/models/*`
- Modify: `docs/public/administration/server-configuration.mdx`
- Modify: `docs/public/administration/sandboxing.mdx`
- [ ] **Step 1: Update OpenAPI server settings schema**
In `docs/public/api-reference/fabro-api.yaml`, add `sandbox` as required on `ServerNamespace` and define:
```yaml
ServerSandboxSettings:
type: object
required: [providers]
properties:
providers:
$ref: "#/components/schemas/ServerSandboxProvidersSettings"
ServerSandboxProvidersSettings:
type: object
required: [local, docker, daytona]
properties:
local:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
docker:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
daytona:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
ServerSandboxProviderSettings:
type: object
required: [enabled]
properties:
enabled:
type: boolean
```
- [ ] **Step 2: Update API round-trip test**
In `lib/crates/fabro-api/tests/server_settings_round_trip.rs`, add TOML to the sample:
```toml
[server.sandbox.providers.daytona]
enabled = false
```
Add JSON assertions:
```rust
assert_eq!(json["server"]["sandbox"]["providers"]["local"]["enabled"], true);
assert_eq!(json["server"]["sandbox"]["providers"]["docker"]["enabled"], true);
assert_eq!(json["server"]["sandbox"]["providers"]["daytona"]["enabled"], false);
```
- [ ] **Step 3: Regenerate API artifacts**
Run:
```bash
cargo build -p fabro-api
cd lib/packages/fabro-api-client && bun run generate
```
Expected: generated Rust/API and TypeScript client types include the new sandbox settings models.
- [ ] **Step 4: Update docs**
In `docs/public/administration/server-configuration.mdx`, add `[server.sandbox]` to the server-owned sections table and full reference:
```toml
[server.sandbox.providers.local]
enabled = true
[server.sandbox.providers.docker]
enabled = true
[server.sandbox.providers.daytona]
enabled = true
```
Add a short section:
```md
### `[server.sandbox.providers]` section
Controls which sandbox providers the server may launch. Missing provider entries default to `enabled = true` for backward compatibility. Disabling a provider rejects new runs whose effective provider is disabled; dry-run Docker/Daytona runs use the local provider and are governed by `server.sandbox.providers.local.enabled`.
```
In `docs/public/administration/sandboxing.mdx`, add one paragraph pointing operators to `[server.sandbox.providers.<provider>]` for enable/disable policy.
- [ ] **Step 5: Run API/docs tests**
Run:
```bash
cargo test -p fabro-api server_settings_json_matches_openapi_shape --quiet
cd apps/fabro-web && bun run typecheck
```
Expected: tests and typecheck pass.
## Task 5: Final Verification
**Files:**
- No new files.
- [ ] **Step 1: Run focused Rust tests**
Run:
```bash
cargo test -p fabro-config server_sandbox --quiet
cargo test -p fabro-install write_sandbox_settings_records --quiet
cargo test -p fabro-api server_settings_json_matches_openapi_shape --quiet
cargo nextest run -p fabro-server sandbox_provider_policy
```
Expected: all focused tests pass.
- [ ] **Step 2: Run formatting and lint checks**
Run:
```bash
cargo +nightly-2026-04-14 fmt --check --all
cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
```
Expected: both pass.
- [ ] **Step 3: Run frontend typecheck if generated TypeScript changed**
Run:
```bash
cd apps/fabro-web && bun run typecheck
```
Expected: typecheck passes.
- [ ] **Step 4: Commit**
Run:
```bash
git add lib/crates/fabro-config lib/crates/fabro-types lib/crates/fabro-install lib/crates/fabro-server docs/public lib/crates/fabro-api lib/packages/fabro-api-client apps/fabro-web
git commit -m "feat: add server sandbox provider policy"
```
Expected: commit succeeds with only intended files staged.
## Completed stages
- **toolchain**: succeeded
- Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1`
- Output:
```
cargo 1.95.0 (f2d3ce0bd 2026-03-21)
```
- **preflight_compile**: succeeded
- Script: `cargo check -q --workspace 2>&1`
- Output: (empty)
- **preflight_lint**: succeeded
- Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1`
- Output: (empty)
- **implement**: succeeded
- Model: gpt-5.5, 5.7m tokens in / 32.2k out
- Files: /home/daytona/workspace/fabro/lib/packages/fabro-api-client/src/models/server-sandbox-provider-settings.ts, /home/daytona/workspace/fabro/lib/packages/fabro-api-client/src/models/server-sandbox-providers-settings.ts, /home/daytona/workspace/fabro/lib/packages/fabro-api-client/src/models/server-sandbox-settings.ts
# Simplify: Code Review and Cleanup
Review changes vs. origin for reuse, quality, and efficiency. Fix any issues found.
## Phase 1: Identify Changes
Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation.
## Phase 2: Launch Three Review Agents in Parallel
Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.
### Agent 1: Code Reuse Review
For each change:
1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.
2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.
3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.
Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.
### Agent 2: Code Quality Review
Review the same changes for hacky patterns:
1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls
2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones
3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction
4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries
5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase
Note: This is a greenfield app, so be aggressive in optimizing quality.
### Agent 3: Efficiency Review
Review the same changes for efficiency:
1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns
2. Missed concurrency: independent operations run sequentially when they could run in parallel
3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths
4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error
5. Memory: unbounded data structures, missing cleanup, event listener leaks
6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one
## Phase 3: Fix Issues
Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.
When done, briefly summarize what was fixed (or confirm the code was already clean).

View file

@ -0,0 +1,5 @@
{
"mode": "agent",
"provider": "anthropic",
"model": "claude-opus-4-7"
}

View file

@ -0,0 +1,35 @@
## Summary
The three review agents flagged several issues; I implemented the high-value fixes and left low-value ones alone.
### Fixes applied
1. **Dedup'd dry-run coercion** — added `SandboxProvider::effective_for(mode)` in `fabro-types/src/sandbox_provider.rs`. Replaced the duplicated `if RunMode::DryRun && !is_local()` block in both `fabro-server/src/run_manifest.rs::effective_sandbox_provider` and `fabro-workflow/src/operations/start.rs` with a single method call. Also inlined the trivial one-line `resolve_sandbox_provider` wrapper in `run_manifest.rs`.
2. **Encapsulated the stringly-typed match** — added `ServerSandboxProvidersSettings::for_provider(SandboxProvider) -> &ServerSandboxProviderSettings` in `fabro-types/src/settings/server.rs`. `sandbox_provider_policy_error` now uses that instead of hand-matching variants.
3. **Extracted launch-time policy helper**`fail_if_sandbox_provider_disabled` in `fabro-server/src/server.rs` replaces two identical 7-line blocks in `execute_run_in_process` and `execute_run_subprocess` (including the duplicated `tracing::error!` message).
4. **Looped installer writes**`write_sandbox_provider_policy` in `fabro-install/src/lib.rs` now iterates over `[SandboxProvider::Local, Docker, Daytona]` (via `Display`) instead of three hardcoded string-literal calls. Removed the now-redundant `write_sandbox_provider_enabled` helper.
### Findings deliberately skipped
- **Collapsing the 3-struct hierarchy** to a flat `{ local: bool, docker: bool, daytona: bool }` — keeping the per-provider struct preserves room for future per-provider fields and matches the TOML shape.
- **Returning `Result<(), String>` from `sandbox_provider_policy_error`** — the four call sites use the error in different ways (response body, check report, run-fail message), so `Option<String>` is the right shape.
- **Backtick-quoting the config path in the error message** — the spec mandates the exact string, and the new tests assert it verbatim.
- **Hoisting `state.server_settings()` in `create_run`**`RwLock::read + Arc::clone` is negligible.
- **Pulling `assert_sandbox_provider_policy_enabled` into a shared test-support module** — only used in two tests of one crate.
### Verification
- `cargo check --workspace` clean
- `cargo nextest run -p fabro-config` 220/220 pass (incl. new policy tests)
- `cargo nextest run -p fabro-types -p fabro-install` 326+install pass
- `cargo nextest run -p fabro-server` 631/634 pass — the 3 failures (`get_graph_returns_svg` + 2 related) are pre-existing and reproduced on the parent commit before any cleanup edits
- `cargo nextest run -p fabro-server --test it --features test-support` 143/143 pass
- `cargo nextest run -p fabro-workflow` 1162/1162 pass
- `cargo nextest run -p fabro-api` 153/153 pass (incl. round-trip)
- `cargo +nightly-2026-04-14 fmt --check --all` clean
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` clean
Net change: **+76/48 lines** removed across 6 files (further reducing duplication on top of the original feature commit).