From 8add148580f9c9ce47575f8917d98e79f6d2750f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 11 Sep 2026 12:36:29 -0600 Subject: [PATCH] Read the Daytona scopes fabro needs from the provider's health Fabro kept its own list of the four scopes a Daytona key needs and reordered the provider's missing list against it. The provider now reports the scopes it requires, in the order it documents them, so the doctor and the install check render what the health check says and the list lives in one place. Co-Authored-By: Claude Fable 5.1 --- lib/apps/fabro-server/src/diagnostics.rs | 2 +- lib/components/fabro-sandbox/src/daytona.rs | 70 ++++++++------------- 2 files changed, 26 insertions(+), 46 deletions(-) diff --git a/lib/apps/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs index 6a6367446..c94c83769 100644 --- a/lib/apps/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -691,7 +691,7 @@ fn cloud_sandbox_probe_check(probe: anyhow::Result) -> remediation: Some(format!( "Regenerate the Daytona API key with scopes: {}, then \ `fabro secret set DAYTONA_API_KEY`.", - daytona::required_perms_display() + check.required_display() )), }, Err(err) => { diff --git a/lib/components/fabro-sandbox/src/daytona.rs b/lib/components/fabro-sandbox/src/daytona.rs index 26a2d5e35..1eabc7834 100644 --- a/lib/components/fabro-sandbox/src/daytona.rs +++ b/lib/components/fabro-sandbox/src/daytona.rs @@ -40,15 +40,6 @@ const DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT: Duration = Duration::from_mins(30); /// leaked by a dead worker. An explicit zero disables auto-stop entirely. const DEFAULT_AUTO_STOP: Duration = Duration::from_hours(2); -/// Scopes a Daytona API key needs for fabro's snapshot and sandbox flow, in -/// the order the remediation text lists them. -pub const REQUIRED_DAYTONA_SCOPES: &[&str] = &[ - "write:snapshots", - "delete:snapshots", - "write:sandboxes", - "delete:sandboxes", -]; - /// What a custom snapshot is built from: the environment's image or /// Dockerfile and its resources in whole gigabytes, the units Daytona /// sizes snapshots in and the values the snapshot's name is derived from. @@ -171,11 +162,14 @@ pub mod snapshot_identity { } /// Outcome of probing a Daytona credential through the provider's health -/// check. +/// check. The provider owns the list of scopes it needs and the order it +/// reports them in; fabro only renders them. #[derive(Debug)] pub struct DaytonaKeyCheck { /// Scopes the key lacks, in Daytona's wire names. - pub missing: Vec, + pub missing: Vec, + /// Every scope the provider requires, for the remediation text. + pub required: Vec, } #[derive(Debug, thiserror::Error)] @@ -215,11 +209,12 @@ impl DaytonaKeyCheck { self.missing_display() ) } -} -#[must_use] -pub fn required_perms_display() -> String { - REQUIRED_DAYTONA_SCOPES.join(", ") + /// Every scope the provider requires, comma separated, for remediation. + #[must_use] + pub fn required_display(&self) -> String { + self.required.join(", ") + } } /// Whether `credentials` reach Daytona, are accepted, and carry the scopes @@ -237,11 +232,13 @@ pub async fn check_daytona_api_key( .map_err(|error| anyhow::Error::new(error).context("Daytona health check failed"))?; match health.status { HealthStatus::Ok | HealthStatus::Unknown => Ok(DaytonaKeyCheck { - missing: Vec::new(), + missing: Vec::new(), + required: health.required_permissions, }), HealthStatus::Unauthorized if !health.missing_permissions.is_empty() => { Ok(DaytonaKeyCheck { - missing: ordered_scopes(&health.missing_permissions), + missing: health.missing_permissions, + required: health.required_permissions, }) } HealthStatus::Unauthorized => Err(anyhow::anyhow!( @@ -266,22 +263,6 @@ pub async fn check_daytona_api_key( } } -/// The scopes fabro requires, in fabro's documented order, followed by any -/// other scope the provider reported missing. -fn ordered_scopes(missing: &[String]) -> Vec { - let mut ordered: Vec = REQUIRED_DAYTONA_SCOPES - .iter() - .filter(|scope| missing.iter().any(|reported| reported == *scope)) - .map(|scope| (*scope).to_string()) - .collect(); - for scope in missing { - if !ordered.contains(scope) { - ordered.push(scope.clone()); - } - } - ordered -} - async fn connect(credentials: &DaytonaCredentials) -> anyhow::Result> { connect_provider( &SandboxProviderKind::DAYTONA, @@ -663,26 +644,25 @@ mod tests { } #[test] - fn missing_scopes_render_in_documented_order() { + fn missing_scopes_render_as_the_provider_reports_them() { let check = DaytonaKeyCheck { - missing: ordered_scopes(&[ - "write:sandboxes".to_string(), + missing: vec!["write:snapshots".to_string(), "write:sandboxes".to_string()], + required: vec![ "write:snapshots".to_string(), - "manage:secrets".to_string(), - ]), + "delete:snapshots".to_string(), + "write:sandboxes".to_string(), + "delete:sandboxes".to_string(), + ], }; assert!(!check.ok()); - assert_eq!( - check.missing_display(), - "write:snapshots, write:sandboxes, manage:secrets" - ); + assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes"); assert_eq!( check.missing_message(), - "Daytona API key is missing required scopes: write:snapshots, write:sandboxes, \ - manage:secrets. Regenerate the key with all snapshot and sandbox scopes." + "Daytona API key is missing required scopes: write:snapshots, write:sandboxes. \ + Regenerate the key with all snapshot and sandbox scopes." ); assert_eq!( - required_perms_display(), + check.required_display(), "write:snapshots, delete:snapshots, write:sandboxes, delete:sandboxes" ); }