feat(server): validate Daytona API key scopes

Probe the Daytona API at install, `fabro secret set DAYTONA_API_KEY`,
and `fabro doctor` time to confirm the configured key carries the
snapshot/sandbox scopes Fabro needs. Operators now see a precise scope
error against the control plane instead of a generic sandbox-create
failure at first run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-03 17:09:01 -04:00
parent 5c60fe8182
commit 2d7c79de1e
No known key found for this signature in database
11 changed files with 650 additions and 29 deletions

1
Cargo.lock generated
View file

@ -2117,6 +2117,7 @@ dependencies = [
"futures",
"git2",
"glob",
"httpmock",
"rand 0.9.4",
"serde",
"serde_json",

View file

@ -62,3 +62,4 @@ tempfile = "3"
uuid.workspace = true
serde_json.workspace = true
toml.workspace = true
httpmock = "0.8"

View file

@ -5,11 +5,16 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use anyhow::Context as _;
use async_trait::async_trait;
use daytona_api_client::apis::api_keys_api;
use daytona_api_client::apis::configuration::Configuration;
use daytona_api_client::models::api_key_list::Permissions;
use daytona_sdk::api_types::SignedPortPreviewUrl;
use daytona_sdk::toolbox_types::Command as SessionCommandResult;
use daytona_sdk::{DaytonaError, SessionCommandLogsResult};
use fabro_github::GitHubCredentials;
use fabro_static::EnvVars;
use fabro_types::{CommandOutputStream, CommandTermination, RunId};
use fabro_util::time::elapsed_ms;
use rand::Rng;
@ -29,33 +34,158 @@ use crate::{
const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
const DEFAULT_SNAPSHOT: &str = "daytona-medium";
pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api";
const FABRO_SANDBOX_USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION"));
const DAYTONA_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
/// Permissions a Daytona API key needs for Fabro's snapshot and sandbox flow.
pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[
Permissions::WriteColonSnapshots,
Permissions::DeleteColonSnapshots,
Permissions::WriteColonSandboxes,
Permissions::DeleteColonSandboxes,
];
pub use crate::config::{
DaytonaNetwork, DaytonaSettings as DaytonaConfig,
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource,
};
#[derive(Debug)]
pub struct DaytonaKeyCheck {
pub key_name: String,
pub missing: Vec<Permissions>,
}
impl DaytonaKeyCheck {
pub fn ok(&self) -> bool {
self.missing.is_empty()
}
pub fn missing_display(&self) -> String {
join_perms(&self.missing)
}
pub fn missing_message(&self) -> String {
format!(
"API key '{}' is missing required Daytona scopes: {}. \
Regenerate the key with all snapshot and sandbox scopes.",
self.key_name,
self.missing_display()
)
}
}
pub fn required_perms_display() -> String {
join_perms(REQUIRED_DAYTONA_PERMISSIONS)
}
fn join_perms(perms: &[Permissions]) -> String {
perms
.iter()
.copied()
.map(perm_wire_str)
.collect::<Vec<_>>()
.join(", ")
}
fn perm_wire_str(permission: Permissions) -> &'static str {
match permission {
Permissions::WriteColonSnapshots => "write:snapshots",
Permissions::DeleteColonSnapshots => "delete:snapshots",
Permissions::WriteColonSandboxes => "write:sandboxes",
Permissions::DeleteColonSandboxes => "delete:sandboxes",
_ => "unknown",
}
}
/// Build a [`daytona_sdk::Client`], forwarding an optional API key from the
/// vault so the SDK doesn't have to rely on `DAYTONA_API_KEY` being in the
/// process environment.
async fn build_daytona_client(
api_key: Option<String>,
) -> Result<daytona_sdk::Client, daytona_sdk::DaytonaError> {
build_daytona_client_with(api_key, None, None).await
}
async fn build_daytona_client_with(
api_key: Option<String>,
api_url: Option<String>,
organization_id: Option<String>,
) -> Result<daytona_sdk::Client, daytona_sdk::DaytonaError> {
let sdk_config = daytona_sdk::DaytonaConfig {
api_key,
api_url,
organization_id,
..Default::default()
};
daytona_sdk::Client::new_with_config(sdk_config).await
}
/// Validate a Daytona API key by performing a single authenticated call. Used
/// at install time to confirm the operator-provided credential is accepted by
/// the Daytona control plane before persisting it. The call requests a single
/// sandbox listing entry to keep latency and quota impact minimal.
pub async fn validate_daytona_api_key(api_key: String) -> Result<(), daytona_sdk::DaytonaError> {
let client = build_daytona_client(Some(api_key)).await?;
client.list(None, Some(1), Some(1)).await?;
Ok(())
#[expect(
clippy::disallowed_methods,
reason = "This is the production env-resolving Daytona credential probe facade."
)]
pub async fn check_daytona_api_key(api_key: String) -> anyhow::Result<DaytonaKeyCheck> {
let base_url = std::env::var(EnvVars::DAYTONA_API_URL)
.or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL))
.unwrap_or_else(|_| DEFAULT_DAYTONA_API_URL.to_string());
let org_id = std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok();
check_daytona_api_key_with(&base_url, org_id.as_deref(), api_key).await
}
pub async fn check_daytona_api_key_with(
base_url: &str,
org_id: Option<&str>,
api_key: String,
) -> anyhow::Result<DaytonaKeyCheck> {
let work = async {
let client = build_daytona_client_with(
Some(api_key.clone()),
Some(base_url.to_string()),
org_id.map(str::to_string),
)
.await
.map_err(anyhow::Error::new)
.context("failed to construct Daytona client")?;
client
.list(None, Some(1), Some(1))
.await
.map_err(anyhow::Error::new)
.context("failed to authenticate with Daytona")?;
let api_config = build_api_keys_configuration(base_url, &api_key);
let info = api_keys_api::get_current_api_key(&api_config, org_id)
.await
.map_err(anyhow::Error::new)
.context("failed to read current Daytona API key")?;
let missing = REQUIRED_DAYTONA_PERMISSIONS
.iter()
.copied()
.filter(|permission| !info.permissions.contains(permission))
.collect();
Ok::<_, anyhow::Error>(DaytonaKeyCheck {
key_name: info.name,
missing,
})
};
match time::timeout(DAYTONA_PROBE_TIMEOUT, work).await {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!(
"Daytona credential probe timed out after {}s",
DAYTONA_PROBE_TIMEOUT.as_secs()
)),
}
}
fn build_api_keys_configuration(base_url: &str, api_key: &str) -> Configuration {
let mut cfg = Configuration::new();
cfg.base_path = base_url.to_string();
cfg.bearer_access_token = Some(api_key.to_string());
cfg.user_agent = Some(FABRO_SANDBOX_USER_AGENT.to_string());
cfg
}
fn command_kind(command: &str) -> &'static str {
@ -1738,8 +1868,59 @@ fn wrap_bash_command(command: &str) -> String {
#[cfg(test)]
mod tests {
use daytona_api_client::models::api_key_list::Permissions;
use httpmock::Method::GET;
use httpmock::MockServer;
use super::*;
fn api_key_body(permissions: &[&str]) -> serde_json::Value {
serde_json::json!({
"name": "delete-only",
"value": "dtn_****",
"createdAt": "2026-05-01T00:00:00Z",
"permissions": permissions,
"lastUsedAt": null,
"expiresAt": null,
"userId": "user_123"
})
}
async fn mock_auth_probe(server: &MockServer, status: usize) -> httpmock::Mock<'_> {
server
.mock_async(move |when, then| {
when.method(GET)
.path("/sandbox/paginated")
.query_param("page", "1")
.query_param("limit", "1");
then.status(status)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"items": [],
"total": 0,
"page": 1,
"totalPages": 0
}));
})
.await
}
async fn mock_current_key<'a>(
server: &'a MockServer,
permissions: Vec<&'static str>,
) -> httpmock::Mock<'a> {
server
.mock_async(move |when, then| {
when.method(GET)
.path("/api-keys/current")
.header("authorization", "Bearer dtn_test");
then.status(200)
.header("content-type", "application/json")
.json_body(api_key_body(&permissions));
})
.await
}
#[test]
fn daytona_config_defaults() {
let config = DaytonaConfig::default();
@ -1766,6 +1947,92 @@ mod tests {
);
}
#[test]
fn missing_display_uses_daytona_wire_scope_names() {
let check = DaytonaKeyCheck {
key_name: "delete-only".to_string(),
missing: vec![
Permissions::WriteColonSnapshots,
Permissions::WriteColonSandboxes,
],
};
assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes");
assert_eq!(
check.missing_message(),
"API key 'delete-only' is missing required Daytona scopes: \
write:snapshots, write:sandboxes. Regenerate the key with all \
snapshot and sandbox scopes."
);
assert_eq!(
required_perms_display(),
"write:snapshots, delete:snapshots, write:sandboxes, delete:sandboxes"
);
}
#[tokio::test]
async fn check_daytona_api_key_with_reports_missing_scopes() {
let server = MockServer::start_async().await;
let auth = mock_auth_probe(&server, 200).await;
let current_key = mock_current_key(&server, vec![
"delete:snapshots",
"delete:sandboxes",
"delete:volumes",
])
.await;
let check = check_daytona_api_key_with(&server.base_url(), None, "dtn_test".to_string())
.await
.expect("probe should succeed");
assert!(!check.ok());
assert_eq!(check.key_name, "delete-only");
assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes");
auth.assert_async().await;
current_key.assert_async().await;
}
#[tokio::test]
async fn check_daytona_api_key_with_accepts_full_scopes() {
let server = MockServer::start_async().await;
let auth = mock_auth_probe(&server, 200).await;
let current_key = mock_current_key(&server, vec![
"write:snapshots",
"delete:snapshots",
"write:sandboxes",
"delete:sandboxes",
])
.await;
let check = check_daytona_api_key_with(&server.base_url(), None, "dtn_test".to_string())
.await
.expect("probe should succeed");
assert!(check.ok());
assert!(check.missing.is_empty());
auth.assert_async().await;
current_key.assert_async().await;
}
#[tokio::test]
async fn check_daytona_api_key_with_preserves_auth_failure_context() {
let server = MockServer::start_async().await;
let auth = mock_auth_probe(&server, 401).await;
let err = check_daytona_api_key_with(&server.base_url(), None, "dtn_test".to_string())
.await
.expect_err("auth probe should fail");
let chain = err.chain().map(ToString::to_string).collect::<Vec<_>>();
assert!(
chain
.iter()
.any(|cause| cause == "failed to authenticate with Daytona"),
"expected auth context in chain, got {chain:#?}"
);
auth.assert_async().await;
}
#[test]
fn wrap_bash_uses_base64_encoding() {
let wrapped = wrap_bash_command("echo hello");

View file

@ -1,7 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
#[cfg(any(feature = "docker", feature = "daytona"))]
#[cfg(feature = "docker")]
use anyhow::Context as _;
#[cfg(any(feature = "docker", feature = "daytona"))]
use fabro_github::GitHubCredentials;

View file

@ -6,6 +6,7 @@ use fabro_auth::auth_issue_message;
use fabro_llm::client::Client as LlmClient;
use fabro_llm::types::{Message, Request};
use fabro_model::{Catalog, Provider};
use fabro_sandbox::daytona;
use fabro_static::EnvVars;
use fabro_types::settings::server::GithubIntegrationStrategy;
use fabro_types::settings::{InterpString, ServerAuthMethod};
@ -52,12 +53,12 @@ fn validate_session_secret(value: &str) -> Result<(), String> {
}
pub async fn run_all(state: &AppState) -> DiagnosticsReport {
let (llm, github, brave) = tokio::join!(
let (llm, github, sandbox, brave) = tokio::join!(
check_llm_providers(state),
check_github_app(state),
check_sandbox(state),
check_brave_search(state),
);
let sandbox = check_sandbox(state);
let crypto = check_crypto(state);
DiagnosticsReport {
@ -381,17 +382,9 @@ async fn check_github_app(state: &AppState) -> CheckResult {
}
}
fn check_sandbox(state: &AppState) -> CheckResult {
if state.vault_or_env(EnvVars::DAYTONA_API_KEY).is_some() {
CheckResult {
name: "Sandbox".to_string(),
status: CheckStatus::Pass,
summary: "Daytona configured".to_string(),
details: Vec::new(),
remediation: None,
}
} else {
CheckResult {
async fn check_sandbox(state: &AppState) -> CheckResult {
let Some(api_key) = state.vault_or_env(EnvVars::DAYTONA_API_KEY) else {
return CheckResult {
name: "Sandbox".to_string(),
status: CheckStatus::Warning,
summary: "recommended, not configured".to_string(),
@ -400,7 +393,38 @@ fn check_sandbox(state: &AppState) -> CheckResult {
"Run `fabro secret set DAYTONA_API_KEY` to enable cloud sandbox execution"
.to_string(),
),
}
};
};
match state.check_daytona_api_key(api_key).await {
Ok(check) if check.ok() => CheckResult {
name: "Sandbox".to_string(),
status: CheckStatus::Pass,
summary: format!("Daytona configured ({})", check.key_name),
details: Vec::new(),
remediation: None,
},
Ok(check) => CheckResult {
name: "Sandbox".to_string(),
status: CheckStatus::Error,
summary: "Daytona API key is missing required scopes".to_string(),
details: vec![CheckDetail::new(format!(
"missing: {}",
check.missing_display()
))],
remediation: Some(format!(
"Regenerate the Daytona API key with scopes: {}, then \
`fabro secret set DAYTONA_API_KEY`.",
daytona::required_perms_display()
)),
},
Err(err) => CheckResult {
name: "Sandbox".to_string(),
status: CheckStatus::Error,
summary: "Daytona credential rejected".to_string(),
details: vec![CheckDetail::new(format!("{err:#}"))],
remediation: Some("Verify DAYTONA_API_KEY value and Daytona reachability".to_string()),
},
}
}

View file

@ -75,8 +75,10 @@ pub type InstallFinishHook = Arc<dyn Fn(&InstallFinishInfo) -> anyhow::Result<()
#[derive(Clone, Debug, Default)]
struct InstallUpstreamConfig {
provider_base_urls: HashMap<Provider, String>,
github_api_base_url: Option<String>,
provider_base_urls: HashMap<Provider, String>,
github_api_base_url: Option<String>,
daytona_api_base_url: Option<String>,
daytona_organization_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -203,6 +205,18 @@ impl InstallAppState {
self
}
#[must_use]
pub fn with_daytona_api_base_url(mut self, base_url: impl Into<String>) -> Self {
self.upstreams.daytona_api_base_url = Some(base_url.into());
self
}
#[must_use]
pub fn with_daytona_organization_id(mut self, organization_id: impl Into<String>) -> Self {
self.upstreams.daytona_organization_id = Some(organization_id.into());
self
}
fn set_install_bind(&self, bind: &Bind) {
*lock_unpoisoned(&self.install_listen, "install listen") = install_listen_config(bind);
}
@ -928,8 +942,15 @@ async fn post_install_sandbox_test(
}
};
match daytona::validate_daytona_api_key(api_key).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
match check_install_daytona_api_key(&state, api_key).await {
Ok(check) if check.ok() => Json(serde_json::json!({ "ok": true })).into_response(),
Ok(check) => {
warn!(
missing = %check.missing_display(),
"install sandbox scopes insufficient"
);
install_error_response(StatusCode::UNPROCESSABLE_ENTITY, check.missing_message())
}
Err(err) => {
warn!(error = %err, "install sandbox validation failed");
install_error_response(
@ -940,6 +961,19 @@ async fn post_install_sandbox_test(
}
}
async fn check_install_daytona_api_key(
state: &InstallAppState,
api_key: String,
) -> anyhow::Result<daytona::DaytonaKeyCheck> {
let base_url = state
.upstreams
.daytona_api_base_url
.as_deref()
.unwrap_or(daytona::DEFAULT_DAYTONA_API_URL);
let organization_id = state.upstreams.daytona_organization_id.as_deref();
daytona::check_daytona_api_key_with(base_url, organization_id, api_key).await
}
async fn put_install_sandbox(
State(state): State<InstallAppState>,
headers: HeaderMap,

View file

@ -56,7 +56,7 @@ use fabro_llm::types::{
};
use fabro_model::{BilledModelUsage, BilledTokenCounts, Catalog, ModelTestMode, Provider};
use fabro_redact::redact_jsonl_line;
use fabro_sandbox::daytona::DaytonaSandbox;
use fabro_sandbox::daytona::{self, DaytonaSandbox};
use fabro_sandbox::reconnect::reconnect;
use fabro_sandbox::{Sandbox, SandboxProvider};
use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient};
@ -603,6 +603,23 @@ impl AppState {
})
}
fn env_lookup_or_vault_or_env(&self, name: &str) -> Option<String> {
(self.env_lookup)(name).or_else(|| self.vault_or_env(name))
}
pub(crate) async fn check_daytona_api_key(
&self,
api_key: String,
) -> anyhow::Result<daytona::DaytonaKeyCheck> {
let base_url = self
.env_lookup_or_vault_or_env(EnvVars::DAYTONA_API_URL)
.or_else(|| self.env_lookup_or_vault_or_env(EnvVars::DAYTONA_SERVER_URL))
.unwrap_or_else(|| daytona::DEFAULT_DAYTONA_API_URL.to_string());
let org_id = self.env_lookup_or_vault_or_env(EnvVars::DAYTONA_ORGANIZATION_ID);
daytona::check_daytona_api_key_with(&base_url, org_id.as_deref(), api_key).await
}
/// Public accessor used by `run_files` — mirrors `vault_or_env` without
/// changing its visibility semantics.
pub(crate) fn vault_or_env_pub(&self, name: &str) -> Option<String> {

View file

@ -1,5 +1,7 @@
use std::sync::Arc;
use fabro_static::EnvVars;
use super::super::{
ApiError, AppState, CreateSecretRequest, DeleteSecretRequest, IntoResponse, Json, RequiredUser,
Response, Router, SecretType, State, StatusCode, VaultError, get, parse_credential_secret,
@ -34,6 +36,22 @@ async fn create_secret(
return ApiError::bad_request(err).into_response();
}
}
if secret_type == SecretType::Environment && name == EnvVars::DAYTONA_API_KEY {
match state.check_daytona_api_key(value.clone()).await {
Ok(check) if check.ok() => {}
Ok(check) => {
return ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, check.missing_message())
.into_response();
}
Err(err) => {
return ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
format!("daytona credential validation failed: {err}"),
)
.into_response();
}
}
}
let state_for_write = Arc::clone(&state);
let result = spawn_blocking(move || {
let mut vault = state_for_write.vault.blocking_write();

View file

@ -21,7 +21,8 @@ use fabro_types::{
InterviewQuestionRecord, Outcome, QuestionType, RunBlobId, RunId, RunSpec, StageOutcome,
SystemActorKind, fixtures,
};
use httpmock::Method::POST;
use fabro_util::check_report::CheckStatus;
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
use serde_json::json;
use tokio_stream::StreamExt as _;
@ -121,6 +122,49 @@ async fn body_json(body: Body) -> serde_json::Value {
serde_json::from_slice(&bytes).unwrap()
}
async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
when.method(GET)
.path("/sandbox/paginated")
.query_param("page", "1")
.query_param("limit", "1");
then.status(200)
.header("content-type", "application/json")
.json_body(json!({
"items": [],
"total": 0,
"page": 1,
"totalPages": 0
}));
})
.await
}
async fn mock_daytona_current_key<'a>(
server: &'a MockServer,
permissions: Vec<&'static str>,
) -> httpmock::Mock<'a> {
server
.mock_async(move |when, then| {
when.method(GET)
.path("/api-keys/current")
.header("authorization", "Bearer dtn_test");
then.status(200)
.header("content-type", "application/json")
.json_body(json!({
"name": "delete-only",
"value": "dtn_****",
"createdAt": "2026-05-01T00:00:00Z",
"permissions": permissions,
"lastUsedAt": null,
"expiresAt": null,
"userId": "user_123"
}));
})
.await
}
fn openai_api_key_credential(key: &str) -> AuthCredential {
AuthCredential {
provider: Provider::OpenAi,
@ -1001,6 +1045,131 @@ async fn create_secret_stores_valid_credential_entries() {
assert!(state.vault.read().await.get("openai_codex").is_some());
}
#[tokio::test]
async fn create_secret_rejects_under_scoped_daytona_api_key_and_leaves_vault_unchanged() {
let server = MockServer::start_async().await;
let auth = mock_daytona_auth_probe(&server).await;
let current_key = mock_daytona_current_key(&server, vec![
"delete:snapshots",
"delete:sandboxes",
"delete:volumes",
])
.await;
let base_url = server.base_url();
let state = test_app_state_with_env_lookup(
default_test_server_settings(),
fabro_config::RunLayer::default(),
5,
move |name| match name {
EnvVars::DAYTONA_API_URL => Some(base_url.clone()),
_ => None,
},
);
state
.vault
.write()
.await
.set(
EnvVars::DAYTONA_API_KEY,
"existing",
SecretType::Environment,
None,
)
.unwrap();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let req = Request::builder()
.method("POST")
.uri(api("/secrets"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"name": EnvVars::DAYTONA_API_KEY,
"value": "dtn_test",
"type": "environment"
}))
.unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
assert_eq!(
body["errors"][0]["detail"],
"API key 'delete-only' is missing required Daytona scopes: \
write:snapshots, write:sandboxes. Regenerate the key with all \
snapshot and sandbox scopes."
);
assert_eq!(
state.vault.read().await.get(EnvVars::DAYTONA_API_KEY),
Some("existing")
);
auth.assert_async().await;
current_key.assert_async().await;
}
#[tokio::test]
async fn diagnostics_reports_under_scoped_daytona_api_key() {
let server = MockServer::start_async().await;
let auth = mock_daytona_auth_probe(&server).await;
let current_key = mock_daytona_current_key(&server, vec![
"delete:snapshots",
"delete:sandboxes",
"delete:volumes",
])
.await;
let base_url = server.base_url();
let state = test_app_state_with_env_lookup(
default_test_server_settings(),
fabro_config::RunLayer::default(),
5,
move |name| match name {
EnvVars::DAYTONA_API_URL => Some(base_url.clone()),
_ => None,
},
);
state
.vault
.write()
.await
.set(
EnvVars::DAYTONA_API_KEY,
"dtn_test",
SecretType::Environment,
None,
)
.unwrap();
let report = crate::diagnostics::run_all(&state).await;
let sandbox = report
.sections
.iter()
.flat_map(|section| &section.checks)
.find(|check| check.name == "Sandbox")
.expect("sandbox check should be present");
assert_eq!(sandbox.status, CheckStatus::Error);
assert_eq!(
sandbox.summary,
"Daytona API key is missing required scopes"
);
assert_eq!(
sandbox.details[0].text,
"missing: write:snapshots, write:sandboxes"
);
assert_eq!(
sandbox.remediation.as_deref(),
Some(
"Regenerate the Daytona API key with scopes: write:snapshots, \
delete:snapshots, write:sandboxes, delete:sandboxes, then \
`fabro secret set DAYTONA_API_KEY`."
)
);
auth.assert_async().await;
current_key.assert_async().await;
}
#[tokio::test]
async fn resolve_llm_client_reads_openai_codex_credential_from_vault() {
let state = test_app_state_with_env_lookup(

View file

@ -19,6 +19,7 @@ use fabro_server::install::{
};
use fabro_util::{Home, dev_token};
use fabro_vault::Vault;
use httpmock::Method::GET;
use httpmock::MockServer;
use tokio::time::sleep;
use tower::ServiceExt;
@ -33,6 +34,49 @@ fn spa_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
}
async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
when.method(GET)
.path("/sandbox/paginated")
.query_param("page", "1")
.query_param("limit", "1");
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"items": [],
"total": 0,
"page": 1,
"totalPages": 0
}));
})
.await
}
async fn mock_daytona_current_key<'a>(
server: &'a MockServer,
permissions: Vec<&'static str>,
) -> httpmock::Mock<'a> {
server
.mock_async(move |when, then| {
when.method(GET)
.path("/api-keys/current")
.header("authorization", "Bearer dtn_test");
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"name": "delete-only",
"value": "dtn_****",
"createdAt": "2026-05-01T00:00:00Z",
"permissions": permissions,
"lastUsedAt": null,
"expiresAt": null,
"userId": "user_123"
}));
})
.await
}
#[derive(Default)]
struct EventCapture {
fields: Vec<(String, String)>,
@ -2434,6 +2478,50 @@ async fn daytona_install_finish_writes_settings_and_vault_secret() {
assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));
}
#[tokio::test]
async fn sandbox_daytona_test_endpoint_rejects_under_scoped_api_key() {
let server = MockServer::start_async().await;
let auth = mock_daytona_auth_probe(&server).await;
let current_key = mock_daytona_current_key(&server, vec![
"delete:snapshots",
"delete:sandboxes",
"delete:volumes",
])
.await;
let app = build_install_router(
InstallAppState::for_test("test-install-token")
.with_daytona_api_base_url(server.base_url()),
);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/install/sandbox/test")
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"provider":"daytona","api_key":"dtn_test"}"#))
.unwrap(),
)
.await
.unwrap();
let body = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /install/sandbox/test under-scoped daytona",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"API key 'delete-only' is missing required Daytona scopes: \
write:snapshots, write:sandboxes. Regenerate the key with all \
snapshot and sandbox scopes."
);
auth.assert_async().await;
current_key.assert_async().await;
}
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
async fn sandbox_daytona_test_endpoint_validates_real_api_key() {
let api_key = std::env::var(fabro_static::EnvVars::DAYTONA_API_KEY)

View file

@ -93,6 +93,7 @@ impl EnvVars {
pub const AWS_WEB_IDENTITY_TOKEN_FILE: &'static str = "AWS_WEB_IDENTITY_TOKEN_FILE";
pub const DAYTONA_API_KEY: &'static str = "DAYTONA_API_KEY";
pub const DAYTONA_API_URL: &'static str = "DAYTONA_API_URL";
pub const DAYTONA_ORGANIZATION_ID: &'static str = "DAYTONA_ORGANIZATION_ID";
pub const DAYTONA_SERVER_URL: &'static str = "DAYTONA_SERVER_URL";
pub const SESSION_SECRET: &'static str = "SESSION_SECRET";
@ -221,6 +222,7 @@ mod tests {
EnvVars::AWS_WEB_IDENTITY_TOKEN_FILE,
EnvVars::DAYTONA_API_KEY,
EnvVars::DAYTONA_API_URL,
EnvVars::DAYTONA_ORGANIZATION_ID,
EnvVars::DAYTONA_SERVER_URL,
EnvVars::SESSION_SECRET,
EnvVars::CARGO_BIN_EXE_FABRO,