feat(sandbox): secure daytona snapshot names (#429)

## Summary

Secures Daytona custom snapshot creation by removing user-controlled
snapshot/image references and replacing them with deterministic names
Fabro computes internally. Docker image selection now uses
`image.docker`, while Daytona only accepts `image.dockerfile` for custom
snapshots and continues to use `daytona-medium` when no Dockerfile is
configured.

## Changes

- Replaces public `image.ref` config/API shape with Docker-specific
`image.docker` across Rust settings, OpenAPI, generated TypeScript
client, docs, defaults, examples, and web samples.
- Adds Daytona snapshot identity generation using HMAC-SHA256 over a
canonical manifest keyed by the Daytona API key, producing
`fabro-<uuid>` snapshot names without exposing Dockerfile text or key
material.
- Routes Daytona custom Dockerfiles, including devcontainer-generated
Dockerfiles, through the same computed identity path before calling
Daytona snapshot APIs.
- Updates sandbox initialization events and store projections so
initialized run state can show the resolved image and computed Daytona
snapshot after startup.
- Updates legacy config migration behavior so Docker image refs map to
`image.docker`, while Daytona legacy snapshot names are not preserved.

## Breaking Changes

- `image.ref` is no longer accepted in new environment config.
- Docker environments should use `image.docker` for image selection.
- Daytona environments reject `image.docker`; use `image.dockerfile` to
request a custom computed snapshot.

## Verification

- `cargo build -p fabro-api`
- `cd lib/packages/fabro-api-client && bun run generate`
- `cd lib/packages/fabro-api-client && bun run typecheck`
- `cd apps/fabro-web && bun run typecheck`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings`
- `ulimit -n 4096 && cargo nextest run --no-fail-fast -p fabro-cli -p
fabro-config -p fabro-sandbox -p fabro-workflow -p fabro-store -p
fabro-server -p fabro-api`
- `cargo insta pending-snapshots`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
Bryan Helmkamp 2026-05-27 11:52:35 -04:00 committed by GitHub
parent b1bd2f522c
commit ec1b3f2084
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 564 additions and 272 deletions

View file

@ -17,7 +17,6 @@ auto_stop = "30m"
repo = "fabro-sh/fabro"
[environments.fabro-dev.image]
ref = "fabro-v12"
dockerfile = { path = "Dockerfile" }
[environments.fabro-dev.resources]

View file

@ -8,6 +8,3 @@ id = "daytona-medium"
[environments.daytona-medium]
provider = "daytona"
[environments.daytona-medium.image]
ref = "daytona-medium"

3
Cargo.lock generated
View file

@ -2238,12 +2238,15 @@ dependencies = [
"futures-util",
"git2",
"glob",
"hex",
"hmac",
"httpmock",
"rand 0.9.4",
"reqwest-middleware",
"rustls",
"serde",
"serde_json",
"sha2",
"shlex",
"strum",
"tar",

View file

@ -44,7 +44,7 @@ function sampleSettings({
const environmentId = labels.project ?? snapshot;
const environment = {
provider: "daytona",
image: { ref: snapshot, dockerfile: null },
image: { docker: null, dockerfile: null },
resources: {
cpu,
memory: `${memoryGb}GB`,

View file

@ -255,9 +255,6 @@ id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.image]
ref = "daytona-medium"
[run.artifacts]
include = ["screenshots/**"]

View file

@ -12347,9 +12347,9 @@ components:
EnvironmentImageSettings:
type: object
required: [ref, dockerfile]
required: [docker, dockerfile]
properties:
ref:
docker:
type: ["string", "null"]
dockerfile:
oneOf:

View file

@ -278,7 +278,6 @@ id = "python-dev"
provider = "daytona"
[environments.python-dev.image]
ref = "python-dev"
dockerfile = "FROM python:3.12-slim\nRUN apt-get update && apt-get install -y git libncurses-dev"
[environments.python-dev.resources]

View file

@ -15,7 +15,7 @@ id = "dev"
provider = "docker"
[environments.dev.image]
ref = "ghcr.io/acme/project-devcontainer:latest"
docker = "ghcr.io/acme/project-devcontainer:latest"
```
For Daytona snapshot creation, provide a Dockerfile path on the selected environment:
@ -28,6 +28,5 @@ id = "cloud-dev"
provider = "daytona"
[environments.cloud-dev.image]
ref = "project-dev"
dockerfile = { path = ".devcontainer/Dockerfile" }
```

View file

@ -33,7 +33,6 @@ id = "fabro-dev"
provider = "daytona" # local | docker | daytona
[environments.fabro-dev.image]
ref = "fabro-v11" # Docker image or Daytona snapshot name
dockerfile = { path = "Dockerfile" }
[environments.fabro-dev.resources]
@ -99,7 +98,7 @@ id = "default"
provider = "docker"
[environments.default.image]
ref = "buildpack-deps:noble"
docker = "buildpack-deps:noble"
[environments.default.resources]
cpu = 2
@ -114,8 +113,8 @@ stop_on_terminal = true
| Environment field | Local | Docker | Daytona |
|---|---|---|---|
| `image.ref` | Ignored | Docker image | Snapshot name |
| `image.dockerfile` | Ignored | Warning; ignored | Snapshot Dockerfile; requires `image.ref` |
| `image.docker` | Ignored | Docker image | Error |
| `image.dockerfile` | Ignored | Warning; ignored | Snapshot Dockerfile; Fabro computes the snapshot name |
| `resources.cpu` | Warning; ignored | `cpu_quota = cpu * 100000` | Snapshot CPU |
| `resources.memory` | Warning; ignored | Container memory limit | Snapshot memory |
| `resources.disk` | Warning; ignored | Warning; ignored | Snapshot disk |
@ -143,7 +142,7 @@ Fabro hard-errors if a local environment asks for blocked or CIDR-restricted net
## Docker
Docker runs tools inside a container created from `image.ref`. Docker is the built-in default provider.
Docker runs tools inside a container created from `image.docker`. Docker is the built-in default provider.
```toml
[run.environment]
@ -153,7 +152,7 @@ id = "ci"
provider = "docker"
[environments.ci.image]
ref = "buildpack-deps:noble"
docker = "buildpack-deps:noble"
[environments.ci.resources]
cpu = 2
@ -167,7 +166,7 @@ Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fa
## Daytona
Daytona runs tools in a cloud sandbox. `image.ref` is the snapshot name. If `image.dockerfile` is set and the snapshot does not exist, Fabro creates the snapshot; `image.ref` is required so the snapshot has a name.
Daytona runs tools in a cloud sandbox. Without `image.dockerfile`, Fabro uses Daytona's built-in `daytona-medium` snapshot. With `image.dockerfile`, Fabro computes a deterministic internal snapshot name from the Dockerfile, resource hints, a single-tenant scope, and the Daytona API key.
```toml
[run.environment]
@ -177,7 +176,6 @@ id = "cloud"
provider = "daytona"
[environments.cloud.image]
ref = "rust-dev"
dockerfile = { path = "Dockerfile" }
[environments.cloud.resources]

View file

@ -71,7 +71,6 @@ project = "fabro"
env = "ci"
[environments.cloud.image]
ref = "node-20"
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
[environments.cloud.resources]
@ -248,7 +247,7 @@ id = "ci"
provider = "docker" # local | docker | daytona
[environments.ci.image]
ref = "buildpack-deps:noble"
docker = "buildpack-deps:noble"
[environments.ci.resources]
cpu = 2
@ -274,8 +273,8 @@ memory = "8GB"
|---|---|
| `run.environment.id` | Environment slug to select. Defaults to `default`. |
| `environments.<slug>.provider` | Required provider: `local`, `docker`, or `daytona`. |
| `image.ref` | Docker image or Daytona snapshot name. |
| `image.dockerfile` | Inline Dockerfile or `{ path = "Dockerfile" }`; for Daytona, requires `image.ref`. |
| `image.docker` | Docker image. Daytona rejects this field. |
| `image.dockerfile` | Inline Dockerfile or `{ path = "Dockerfile" }`; Daytona uses it to create or reuse an internally named snapshot. |
| `resources.cpu` / `memory` / `disk` | Best-effort resource hints. Unsupported provider fields warn and continue. |
| `network.mode` | `allow_all`, `block`, or `cidr_allow_list`. Local cannot enforce blocked/CIDR networking; Docker cannot enforce CIDR allow-lists. |
| `network.allow` | CIDRs for `cidr_allow_list`; entries are validated as CIDRs. |
@ -558,7 +557,7 @@ id = "cloud"
provider = "daytona"
[environments.cloud.image]
ref = "my-project-snapshot"
dockerfile = { path = "Dockerfile" }
```
Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), TOML `run.inputs` tables replace wholesale, CLI input flags merge per key at highest precedence, environment `env` and `labels` merge by key, environment `volumes` replace as a whole list, and `run.prepare.steps` replaces whole-list.

View file

@ -71,7 +71,6 @@ overwritten.
</Note>
[environments.cloud.image]
ref = "rust-dev"
dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep"
# Or keep the Dockerfile next to this TOML file:
# dockerfile = { path = "./Dockerfile" }
@ -111,7 +110,6 @@ Snapshots let you pre-build an environment image so each run starts with depende
```toml title="run.toml"
[environments.cloud.image]
ref = "my-snapshot"
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
[environments.cloud.resources]
@ -120,10 +118,10 @@ memory = 8
disk = 20
```
When a run starts with a snapshot configured, Fabro looks up the snapshot by name. If it doesn't exist and a `dockerfile` is provided, Fabro creates it automatically and polls until it reaches `Active` state (up to 10 minutes). `dockerfile` can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. If the snapshot already exists, it's reused immediately.
When a run starts with `image.dockerfile`, Fabro computes an internal snapshot name and looks up that snapshot in Daytona. If it doesn't exist, Fabro creates it automatically and polls until it reaches `Active` state (up to 10 minutes). `dockerfile` can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. If the snapshot already exists, it's reused immediately.
<Note>
If a snapshot is configured by name but doesn't exist and no `dockerfile` is provided, the run fails immediately. If no snapshot is configured at all, sandboxes are created from the `daytona-medium` snapshot which includes standard dev tools (git, etc.).
If no Dockerfile is configured, sandboxes are created from the `daytona-medium` snapshot which includes standard dev tools (git, etc.). To force a new custom snapshot, change the Dockerfile text, for example by adding a comment.
</Note>
## Private repositories
@ -214,9 +212,9 @@ The `DAYTONA_API_KEY` vault secret is missing, invalid, or missing the required
If doctor reports missing scopes, regenerate the Daytona key with `write:snapshots`, `delete:snapshots`, `write:sandboxes`, and `delete:sandboxes`, then save it again with `fabro secret set DAYTONA_API_KEY`.
### "Snapshot does not exist and no dockerfile provided"
### Custom snapshot did not roll
The run config references a snapshot name that doesn't exist on Daytona, and no `dockerfile` is provided to create it. Either create the snapshot manually in the Daytona dashboard or add `image.dockerfile` under the selected `[environments.<slug>.image]`.
Custom Daytona snapshot names are computed from the Dockerfile, resource hints, tenant scope, and Daytona API key. To force a new custom snapshot, change `image.dockerfile` text under the selected `[environments.<slug>.image]`.
### "Timed out waiting for snapshot to become active"

View file

@ -46,6 +46,35 @@ fn run_event_round_trips_run_created_with_web_url() {
assert_run_event_round_trip(value);
}
#[test]
fn run_event_round_trips_sandbox_initialized_image_and_snapshot() {
assert_run_event_round_trip(json!({
"id": "evt_sandbox_initialized",
"ts": "2026-04-29T12:00:00Z",
"run_id": fixtures::RUN_1,
"event": "sandbox.initialized",
"properties": {
"provider": "daytona",
"id": "fabro-run-sandbox",
"working_directory": "/home/daytona/workspace",
"snapshot": "fabro-11111111-2222-8333-8444-555555555555"
}
}));
assert_run_event_round_trip(json!({
"id": "evt_sandbox_initialized_docker",
"ts": "2026-04-29T12:01:00Z",
"run_id": fixtures::RUN_1,
"event": "sandbox.initialized",
"properties": {
"provider": "docker",
"id": "container-id",
"working_directory": "/workspace",
"image": "ubuntu:24.04"
}
}));
}
#[test]
fn run_event_round_trips_run_interrupt() {
let value = json!({

View file

@ -40,6 +40,11 @@ approval = "auto"
assert_eq!(json["run"]["goal"]["type"], "inline");
assert_eq!(json["run"]["goal"]["value"], "Ship it");
assert_eq!(json["run"]["execution"]["approval"], "auto");
assert_eq!(
json["run"]["environment"]["image"]["docker"],
"buildpack-deps:noble"
);
assert!(json["run"]["environment"]["image"].get("ref").is_none());
let round_trip: ApiWorkflowSettings =
serde_json::from_value(json).expect("workflow settings should deserialize");

View file

@ -746,6 +746,8 @@ mod tests {
repos_root: None,
primary_repo_path: None,
primary_repo_link: None,
image: None,
snapshot: None,
},
agent_event("code", AgentEvent::ToolCallStarted {
tool_name: "read_file".into(),
@ -1128,6 +1130,8 @@ mod tests {
repos_root: None,
primary_repo_path: None,
primary_repo_link: None,
image: None,
snapshot: None,
});
emit(
&mut ui,

View file

@ -937,8 +937,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
"env": {},
"id": "local",
"image": {
"dockerfile": null,
"ref": null
"docker": null,
"dockerfile": null
},
"labels": {},
"lifecycle": {

View file

@ -165,7 +165,7 @@ fn inspect_resolves_selector_via_server_endpoint() {
"id": "default",
"provider": "local",
"image": {
"ref": null,
"docker": null,
"dockerfile": null
},
"resources": {

View file

@ -64,8 +64,14 @@ pub(crate) fn migrate_settings_path(
))
})?;
let environment_id = layer
.run
.as_ref()
.and_then(|run| run.environment.as_ref())
.and_then(|environment| environment.id.as_deref())
.unwrap_or("default");
let warning = format!(
"Migrated legacy [run.sandbox] settings in {} to [run.environment] and [environments.default]. Backup written to {}. {REMOVAL_NOTE}",
"Migrated legacy [run.sandbox] settings in {} to [run.environment] and [environments.{environment_id}]. Backup written to {}. {REMOVAL_NOTE}",
path.display(),
backup_path.display()
);
@ -88,7 +94,7 @@ fn migrate_contents(original_contents: &str, path: &Path) -> Result<Option<Strin
}
if has_new_environment_config(&doc) {
return Err(Error::other(format!(
"Legacy [run.sandbox] settings in {} could not be auto-migrated because the file already contains [run.environment] or [environments.default]. Remove one config style and retry.",
"Legacy [run.sandbox] settings in {} could not be auto-migrated because the file already contains [run.environment] or [environments]. Remove one config style and retry.",
path.display()
)));
}
@ -117,12 +123,8 @@ fn has_new_environment_config(doc: &DocumentMut) -> bool {
.and_then(Item::as_table)
.and_then(|run| run.get("environment"))
.is_some();
let has_default_environment = doc
.get("environments")
.and_then(Item::as_table)
.and_then(|envs| envs.get("default"))
.is_some();
has_run_environment || has_default_environment
let has_environment_catalog = doc.get("environments").and_then(Item::as_table).is_some();
has_run_environment || has_environment_catalog
}
fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {
@ -185,10 +187,14 @@ fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationF
ensure_table(doc.as_table_mut(), &["run", "clone"])["enabled"] =
Item::Value(Value::from(false));
}
let environment_id = match active_provider {
Some(EnvironmentProvider::Daytona) => "daytona",
_ => "default",
};
ensure_table(doc.as_table_mut(), &["run", "environment"])["id"] =
Item::Value(Value::from("default"));
Item::Value(Value::from(environment_id));
let env = ensure_table(doc.as_table_mut(), &["environments", "default"]);
let env = ensure_table(doc.as_table_mut(), &["environments", environment_id]);
if let Some(p) = provider_str {
env["provider"] = Item::Value(Value::from(p));
}
@ -271,7 +277,7 @@ fn migrate_daytona_snapshot(snapshot_item: &Item, env: &mut Table, unsupported:
for (key, item) in snapshot {
match key {
"name" => ensure_table(env, &["image"])["ref"] = item.clone(),
"name" => {}
"cpu" => ensure_table(env, &["resources"])["cpu"] = item.clone(),
"memory" => ensure_table(env, &["resources"])["memory"] = item.clone(),
"disk" => ensure_table(env, &["resources"])["disk"] = item.clone(),
@ -301,7 +307,7 @@ fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<String
unsupported.push("run.sandbox.docker.skip_clone".to_string());
}
}
"image" => ensure_table(env, &["image"])["ref"] = item.clone(),
"image" => ensure_table(env, &["image"])["docker"] = item.clone(),
"memory_limit" => ensure_table(env, &["resources"])["memory"] = item.clone(),
"cpu_quota" => {
if let Some(cpu_quota) = item.as_integer() {
@ -488,7 +494,7 @@ mod tests {
}
#[test]
fn provider_only_daytona_config_migrates_to_default_environment() {
fn provider_only_daytona_config_migrates_to_daytona_environment() {
let migrated = migrate(
r#"
_version = 1
@ -505,10 +511,10 @@ provider = "daytona"
.expect("migrated settings should resolve")
.run;
assert_eq!(resolved.environment.id, "default");
assert_eq!(resolved.environment.id, "daytona");
assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);
assert!(migrated.contains("[run.environment]"));
assert!(migrated.contains("[environments.default]"));
assert!(migrated.contains("[environments.daytona]"));
assert!(!migrated.contains("[run.sandbox]"));
}
@ -561,7 +567,9 @@ subpath = "agents"
.run
.environment;
assert_eq!(resolved.image.reference.as_deref(), Some("fabro-v11"));
assert_eq!(resolved.id, "daytona");
assert_eq!(resolved.image.docker.as_deref(), None);
assert!(resolved.image.dockerfile.is_some());
assert_eq!(resolved.resources.cpu, Some(8));
assert_eq!(
resolved.resources.memory.map(|size| size.as_bytes()),
@ -619,7 +627,7 @@ cpu_quota = 200000
assert_eq!(resolved.provider, EnvironmentProvider::Docker);
assert_eq!(
resolved.image.reference.as_deref(),
resolved.image.docker.as_deref(),
Some("buildpack-deps:noble")
);
assert_eq!(resolved.resources.cpu, Some(2));
@ -674,7 +682,8 @@ provider = "daytona"
assert_eq!(backup, original);
assert!(rewritten.contains("[run.environment]"));
assert!(rewritten.contains("[environments.default]"));
assert!(rewritten.contains("[environments.daytona]"));
assert!(report.warning.contains("[environments.daytona]"));
assert!(report.warning.contains("temporary compatibility migration"));
}

View file

@ -30,7 +30,7 @@ id = "default"
provider = "docker"
[environments.default.image]
ref = "buildpack-deps:noble"
docker = "buildpack-deps:noble"
[environments.default.resources]
cpu = 2
@ -47,7 +47,7 @@ provider = "local"
provider = "docker"
[environments.docker.image]
ref = "buildpack-deps:noble"
docker = "buildpack-deps:noble"
[environments.docker.resources]
cpu = 2

View file

@ -67,8 +67,8 @@ impl RunEnvironmentLayer {
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct EnvironmentImageLayer {
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docker: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<EnvironmentDockerfileLayer>,
}

View file

@ -66,7 +66,7 @@ fn resolve_environment_layer(
volumes: resolve_volumes(layer.volumes.as_deref()),
env: layer.env.clone().into_inner(),
};
validate_daytona_snapshot_name(&environment, path, errors);
validate_daytona_image_settings(&environment, path, errors);
environment
}
@ -87,7 +87,7 @@ fn resolve_image(layer: Option<&EnvironmentImageLayer>) -> EnvironmentImageSetti
return EnvironmentImageSettings::default();
};
EnvironmentImageSettings {
reference: layer.reference.clone(),
docker: layer.docker.clone(),
dockerfile: layer.dockerfile.as_ref().map(dockerfile_source),
}
}
@ -179,19 +179,15 @@ fn dockerfile_source(dockerfile: &EnvironmentDockerfileLayer) -> DockerfileSourc
}
}
fn validate_daytona_snapshot_name(
fn validate_daytona_image_settings(
environment: &EnvironmentSettings,
path: &str,
errors: &mut Vec<ResolveError>,
) {
if environment.provider == EnvironmentProvider::Daytona
&& environment.image.dockerfile.is_some()
&& environment.image.reference.is_none()
{
if environment.provider == EnvironmentProvider::Daytona && environment.image.docker.is_some() {
errors.push(ResolveError::Invalid {
path: format!("{path}.image"),
reason: "daytona environments with image.dockerfile must also set image.ref"
.to_string(),
reason: "daytona environments do not support image.docker; use image.dockerfile for custom snapshots".to_string(),
});
}
}

View file

@ -48,7 +48,7 @@ fn resolves_run_defaults_from_empty_settings() {
assert_eq!(settings.environment.id, "default");
assert_eq!(settings.environment.provider, EnvironmentProvider::Docker);
assert_eq!(
settings.environment.image.reference.as_deref(),
settings.environment.image.docker.as_deref(),
Some("buildpack-deps:noble")
);
assert_eq!(settings.environment.resources.cpu, Some(2));
@ -89,7 +89,7 @@ preserve = true
provider = "daytona"
[environments.fabro-dev.image]
ref = "fabro-v11"
dockerfile = "FROM ubuntu:24.04"
[environments.fabro-dev.resources]
cpu = 8
@ -123,7 +123,8 @@ NODE_ENV = "development"
assert_eq!(environment.id, "fabro-dev");
assert_eq!(environment.provider, EnvironmentProvider::Daytona);
assert_eq!(environment.image.reference.as_deref(), Some("fabro-v11"));
assert_eq!(environment.image.docker.as_deref(), None);
assert!(environment.image.dockerfile.is_some());
assert_eq!(environment.resources.cpu, Some(8));
assert_eq!(
environment.resources.memory.map(|size| size.as_bytes()),
@ -390,7 +391,7 @@ provider = "local"
assert_eq!(settings.environment.id, "host");
assert_eq!(settings.environment.provider, EnvironmentProvider::Local);
assert!(settings.environment.image.reference.is_none());
assert!(settings.environment.image.docker.is_none());
}
#[test]
@ -465,8 +466,8 @@ mode = "block"
}
#[test]
fn daytona_dockerfile_without_image_ref_errors() {
let err = WorkflowSettingsBuilder::from_toml(
fn daytona_dockerfile_without_image_ref_resolves() {
let settings = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
@ -480,12 +481,55 @@ provider = "daytona"
dockerfile = { path = "Dockerfile" }
"#,
)
.expect_err("daytona dockerfile needs a snapshot name");
.expect("daytona dockerfile should not need a user-supplied snapshot name")
.run;
assert_eq!(settings.environment.provider, EnvironmentProvider::Daytona);
assert!(settings.environment.image.docker.is_none());
assert!(settings.environment.image.dockerfile.is_some());
}
#[test]
fn daytona_image_docker_errors() {
let err = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.environment]
id = "cloud"
[environments.cloud]
provider = "daytona"
[environments.cloud.image]
docker = "ubuntu:24.04"
"#,
)
.expect_err("daytona should reject docker image selection");
let message = err.to_string();
assert!(
message.contains("image.ref"),
"expected daytona dockerfile/image.ref diagnostic, got: {message}"
message.contains("image.docker") && message.contains("daytona"),
"expected daytona image.docker diagnostic, got: {message}"
);
}
#[test]
fn image_ref_is_rejected_as_unknown_field() {
let err = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.environment.image]
ref = "ubuntu:24.04"
"#,
)
.expect_err("image.ref should not be accepted");
let message = err.to_string();
assert!(
message.contains("unknown field") && message.contains("ref"),
"expected unknown field diagnostic for image.ref, got: {message}"
);
}

View file

@ -84,7 +84,7 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer {
.then(|| RunEnvironmentLayer {
id: input.environment.map(ToOwned::to_owned),
image: input.docker_image.map(|image| EnvironmentImageLayer {
reference: Some(image.to_string()),
docker: Some(image.to_string()),
..EnvironmentImageLayer::default()
}),
lifecycle: input
@ -1215,7 +1215,6 @@ id = "daytona"
provider = "daytona"
[environments.daytona.image]
ref = "fabro-test"
dockerfile = { path = "Dockerfile" }
"#,
)

View file

@ -30,6 +30,10 @@ serde_json.workspace = true
strum.workspace = true
tracing.workspace = true
base64.workspace = true
hmac.workspace = true
sha2.workspace = true
hex.workspace = true
uuid.workspace = true
fabro-proc = { path = "../fabro-proc" }
fabro-static.workspace = true
fabro-util = { path = "../fabro-util" }
@ -66,7 +70,6 @@ rustls = { version = "0.23", default-features = false, features = ["std", "ring"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"
uuid.workspace = true
serde_json.workspace = true
toml.workspace = true
httpmock = "0.8"

View file

@ -125,7 +125,6 @@ pub enum DockerfileSource {
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct DaytonaSnapshotSettings {
pub name: String,
pub cpu: Option<i32>,
pub memory: Option<i32>,
pub disk: Option<i32>,

View file

@ -57,6 +57,74 @@ pub use crate::config::{
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DaytonaVolumeMount, DockerfileSource,
};
pub mod snapshot_identity {
use hmac::{Hmac, Mac};
use serde::Serialize;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use super::{DaytonaSnapshotConfig, DockerfileSource};
const IDENTITY_VERSION: u8 = 1;
const PROVIDER: &str = "daytona";
const TENANT: &str = "single-tenant";
type HmacSha256 = Hmac<Sha256>;
#[derive(Serialize)]
struct SnapshotManifest<'a> {
identity_version: u8,
provider: &'static str,
tenant: &'static str,
dockerfile_sha256: &'a str,
cpu: Option<i32>,
memory_gb: Option<i32>,
disk_gb: Option<i32>,
entrypoint: Option<&'static str>,
}
pub fn snapshot_name(api_key: &str, config: &DaytonaSnapshotConfig) -> crate::Result<String> {
let manifest = canonical_manifest(config)?;
let mut mac = HmacSha256::new_from_slice(api_key.as_bytes())
.expect("HMAC-SHA256 accepts keys of any length");
mac.update(&manifest);
let digest = mac.finalize().into_bytes();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
Ok(format!("fabro-{}", Uuid::new_v8(bytes)))
}
fn canonical_manifest(config: &DaytonaSnapshotConfig) -> crate::Result<Vec<u8>> {
let dockerfile = match &config.dockerfile {
Some(DockerfileSource::Inline(text)) => text.as_str(),
Some(DockerfileSource::Path { .. }) => {
return Err(crate::Error::message(
"Daytona snapshot dockerfile path should have been resolved to inline content before sandbox creation",
));
}
None => {
return Err(crate::Error::message(
"Daytona custom snapshots require image.dockerfile",
));
}
};
let dockerfile_sha256 = hex::encode(Sha256::digest(dockerfile.as_bytes()));
let manifest = SnapshotManifest {
identity_version: IDENTITY_VERSION,
provider: PROVIDER,
tenant: TENANT,
dockerfile_sha256: &dockerfile_sha256,
cpu: config.cpu,
memory_gb: config.memory,
disk_gb: config.disk,
entrypoint: None,
};
serde_json::to_vec(&manifest).map_err(|err| {
crate::Error::context("Failed to serialize Daytona snapshot identity", err)
})
}
}
#[derive(Debug)]
pub struct DaytonaKeyCheck {
pub key_name: String,
@ -128,6 +196,18 @@ async fn build_daytona_client(
build_daytona_client_with(api_key, None, None, None).await
}
#[expect(
clippy::disallowed_methods,
reason = "Standalone Daytona sandbox construction falls back to the documented process env var."
)]
fn resolve_daytona_api_key(api_key: Option<String>) -> Option<String> {
api_key.filter(|key| !key.is_empty()).or_else(|| {
std::env::var(EnvVars::DAYTONA_API_KEY)
.ok()
.filter(|key| !key.is_empty())
})
}
pub(crate) async fn build_daytona_client_with(
api_key: Option<String>,
api_url: Option<String>,
@ -242,8 +322,10 @@ fn command_kind(command: &str) -> &'static str {
pub struct DaytonaSandbox {
config: DaytonaConfig,
client: daytona_sdk::Client,
api_key: Option<String>,
github_app: Option<GitHubCredentials>,
sandbox: OnceCell<daytona_sdk::Sandbox>,
snapshot_name: OnceCell<String>,
rg_available: OnceCell<bool>,
event_callback: Option<SandboxEventCallback>,
/// HTTPS origin URL stored after clone so we can refresh push credentials
@ -271,14 +353,17 @@ impl DaytonaSandbox {
clone_branch: Option<String>,
api_key: Option<String>,
) -> crate::Result<Self> {
let client = build_daytona_client(api_key)
let api_key = resolve_daytona_api_key(api_key);
let client = build_daytona_client(api_key.clone())
.await
.map_err(|e| crate::Error::context("Failed to create Daytona client", e))?;
Ok(Self {
config,
client,
api_key,
github_app,
sandbox: OnceCell::new(),
snapshot_name: OnceCell::new(),
rg_available: OnceCell::const_new(),
event_callback: None,
origin_url: OnceCell::new(),
@ -302,7 +387,8 @@ impl DaytonaSandbox {
clone_origin_url: Option<String>,
clone_branch: Option<String>,
) -> crate::Result<Self> {
let client = build_daytona_client(api_key)
let api_key = resolve_daytona_api_key(api_key);
let client = build_daytona_client(api_key.clone())
.await
.map_err(|e| crate::Error::context("Failed to create Daytona client", e))?;
let sdk_sandbox = client.get(sandbox_name).await.map_err(|e| {
@ -326,8 +412,10 @@ impl DaytonaSandbox {
Ok(Self {
config: DaytonaConfig::default(),
client,
api_key,
github_app: None,
sandbox: sandbox_cell,
snapshot_name: OnceCell::new(),
rg_available: OnceCell::const_new(),
event_callback: None,
origin_url,
@ -477,8 +565,12 @@ impl DaytonaSandbox {
/// If the snapshot doesn't exist and a dockerfile is provided, creates it
/// and polls until it reaches `Active` state. Returns an error if the
/// snapshot is in a terminal failure state.
async fn ensure_snapshot(&self, snap_cfg: &DaytonaSnapshotConfig) -> crate::Result<()> {
match self.client.snapshot.get(&snap_cfg.name).await {
async fn ensure_snapshot(
&self,
name: &str,
snap_cfg: &DaytonaSnapshotConfig,
) -> crate::Result<()> {
match self.client.snapshot.get(name).await {
Ok(dto) => {
use daytona_api_client::models::SnapshotState;
match dto.state {
@ -486,7 +578,7 @@ impl DaytonaSandbox {
SnapshotState::Error | SnapshotState::BuildFailed => {
return Err(crate::Error::message(format!(
"Snapshot '{}' is in state '{}': {}",
snap_cfg.name,
name,
dto.state,
dto.error_reason.unwrap_or_default()
)));
@ -494,7 +586,7 @@ impl DaytonaSandbox {
_ => {
// Building/Pending/Pulling — fall through to poll
self.emit(SandboxEvent::SnapshotCreating {
name: snap_cfg.name.clone(),
name: name.to_string(),
});
}
}
@ -504,24 +596,22 @@ impl DaytonaSandbox {
Some(DockerfileSource::Inline(s)) => s.as_str(),
Some(DockerfileSource::Path { .. }) => {
return Err(crate::Error::message(format!(
"Snapshot '{}': dockerfile path should have been resolved to inline content before sandbox creation",
snap_cfg.name
"Snapshot '{name}': dockerfile path should have been resolved to inline content before sandbox creation"
)));
}
None => {
return Err(crate::Error::message(format!(
"Snapshot '{}' does not exist and no dockerfile provided to create it",
snap_cfg.name
"Snapshot '{name}' does not exist and no dockerfile provided to create it"
)));
}
};
self.emit(SandboxEvent::SnapshotCreating {
name: snap_cfg.name.clone(),
name: name.to_string(),
});
let params = daytona_sdk::CreateSnapshotParams {
name: snap_cfg.name.clone(),
name: name.to_string(),
image: daytona_sdk::ImageSource::Custom(
daytona_sdk::DockerImage::from_dockerfile(dockerfile),
),
@ -534,22 +624,19 @@ impl DaytonaSandbox {
entrypoint: None,
};
self.client.snapshot.create(&params).await.map_err(|e| {
crate::Error::context(
format!("Failed to create snapshot '{}'", snap_cfg.name),
e,
)
crate::Error::context(format!("Failed to create snapshot '{name}'"), e)
})?;
}
Err(e) => {
return Err(crate::Error::context(
format!("Failed to get snapshot '{}'", snap_cfg.name),
format!("Failed to get snapshot '{name}'"),
e,
));
}
}
// Poll until Active (or terminal failure).
self.poll_snapshot_active(&snap_cfg.name).await
self.poll_snapshot_active(name).await
}
/// Poll a snapshot until it reaches `Active` state, with exponential
@ -689,11 +776,29 @@ impl Sandbox for DaytonaSandbox {
});
let init_start = Instant::now();
let params = if let Some(ref snap_cfg) = self.config.snapshot {
let params = if let Some(snap_cfg) = self
.config
.snapshot
.as_ref()
.filter(|snapshot| snapshot.dockerfile.is_some())
{
let api_key = self.api_key.as_deref().ok_or_else(|| {
self.fail_init(
init_start,
crate::Error::message(format!(
"{} is required to compute Daytona snapshot identity",
EnvVars::DAYTONA_API_KEY
)),
)
})?;
let snapshot_name = match snapshot_identity::snapshot_name(api_key, snap_cfg) {
Ok(name) => name,
Err(err) => return Err(self.fail_init(init_start, err)),
};
let snap_start = Instant::now();
if let Err(e) = self.ensure_snapshot(snap_cfg).await {
if let Err(e) = self.ensure_snapshot(&snapshot_name, snap_cfg).await {
self.emit(SandboxEvent::SnapshotFailed {
name: snap_cfg.name.clone(),
name: snapshot_name.clone(),
error: e.to_string(),
causes: e.causes(),
});
@ -701,15 +806,17 @@ impl Sandbox for DaytonaSandbox {
}
let snap_duration = u64::try_from(snap_start.elapsed().as_millis()).unwrap_or(u64::MAX);
self.emit(SandboxEvent::SnapshotReady {
name: snap_cfg.name.clone(),
name: snapshot_name.clone(),
duration_ms: snap_duration,
});
let _ = self.snapshot_name.set(snapshot_name.clone());
daytona_sdk::CreateParams::Snapshot(daytona_sdk::SnapshotParams {
base: self.base_params(),
snapshot: snap_cfg.name.clone(),
snapshot: snapshot_name,
})
} else {
let _ = self.snapshot_name.set(DEFAULT_SNAPSHOT.to_string());
daytona_sdk::CreateParams::Snapshot(daytona_sdk::SnapshotParams {
base: self.base_params(),
snapshot: DEFAULT_SNAPSHOT.to_string(),
@ -1144,6 +1251,10 @@ impl Sandbox for DaytonaSandbox {
.unwrap_or_default()
}
fn snapshot_info(&self) -> Option<String> {
self.snapshot_name.get().cloned()
}
async fn setup_git(
&self,
intent: &crate::GitSetupIntent,
@ -2200,6 +2311,57 @@ mod tests {
.await
}
async fn mock_daytona_sandbox(
server: &MockServer,
api_key: &str,
config: DaytonaConfig,
) -> DaytonaSandbox {
let client = build_daytona_client_with(
Some(api_key.to_string()),
Some(server.base_url()),
None,
Some(fabro_test::test_http_client()),
)
.await
.expect("mock Daytona client should build");
DaytonaSandbox {
config,
client,
api_key: Some(api_key.to_string()),
github_app: None,
sandbox: OnceCell::new(),
snapshot_name: OnceCell::new(),
rg_available: OnceCell::const_new(),
event_callback: None,
origin_url: OnceCell::new(),
repo_cloned: OnceCell::new(),
working_directory: OnceCell::new(),
run_id: None,
clone_origin_url: None,
clone_branch: None,
}
}
fn snapshot_body(name: &str) -> serde_json::Value {
serde_json::json!({
"id": name,
"name": name,
"state": "active",
"general": false,
"cpu": 2.0,
"gpu": 0.0,
"mem": 4.0,
"disk": 20.0,
"size": null,
"entrypoint": null,
"errorReason": null,
"lastUsedAt": null,
"createdAt": "2026-05-01T00:00:00Z",
"updatedAt": "2026-05-01T00:00:00Z"
})
}
#[test]
fn daytona_config_defaults() {
let config = DaytonaConfig::default();
@ -2247,6 +2409,123 @@ subpath = "agents"
assert_eq!(volumes[0].subpath.as_deref(), Some("agents"));
}
#[test]
fn computed_snapshot_identity_is_deterministic_and_keyed() {
let config = DaytonaSnapshotConfig {
cpu: Some(2),
memory: Some(4),
disk: Some(10),
dockerfile: Some(DockerfileSource::Inline(
"FROM ubuntu:24.04\nRUN apt-get update".to_string(),
)),
};
let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap();
let second = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap();
let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &config).unwrap();
assert_eq!(first, second);
assert_ne!(first, rotated_key);
let uuid = first
.strip_prefix("fabro-")
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.expect("snapshot name should be fabro-<uuid>");
assert_eq!(uuid.get_version_num(), 8);
assert_eq!(uuid.get_variant(), uuid::Variant::RFC4122);
}
#[test]
fn computed_snapshot_identity_changes_for_generation_inputs() {
let base = DaytonaSnapshotConfig {
cpu: Some(2),
memory: Some(4),
disk: Some(10),
dockerfile: Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string())),
};
let base_name = snapshot_identity::snapshot_name("dtn_secret", &base).unwrap();
let cases = [
DaytonaSnapshotConfig {
dockerfile: Some(DockerfileSource::Inline(
"FROM ubuntu:24.04\n# roll cache".to_string(),
)),
..base.clone()
},
DaytonaSnapshotConfig {
cpu: Some(4),
..base.clone()
},
DaytonaSnapshotConfig {
memory: Some(8),
..base.clone()
},
DaytonaSnapshotConfig {
disk: Some(20),
..base.clone()
},
];
for changed in cases {
let changed_name = snapshot_identity::snapshot_name("dtn_secret", &changed).unwrap();
assert_ne!(base_name, changed_name);
}
}
#[test]
fn computed_snapshot_identity_excludes_raw_dockerfile_and_key_material() {
let config = DaytonaSnapshotConfig {
cpu: None,
memory: None,
disk: None,
dockerfile: Some(DockerfileSource::Inline(
"FROM private.example.com/secret-image\nRUN echo raw-secret".to_string(),
)),
};
let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &config).unwrap();
assert!(name.starts_with("fabro-"));
assert!(!name.contains("private.example.com"));
assert!(!name.contains("raw-secret"));
assert!(!name.contains("dtn_super_secret_key"));
}
#[tokio::test]
async fn ensure_snapshot_uses_computed_snapshot_name_for_daytona_api_calls() {
let api_key = "dtn_secret";
let snapshot = DaytonaSnapshotConfig {
cpu: Some(2),
memory: Some(4),
disk: Some(10),
dockerfile: Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string())),
};
let computed_name = snapshot_identity::snapshot_name(api_key, &snapshot).unwrap();
let server = MockServer::start_async().await;
let path = format!("/snapshots/{computed_name}");
let get_snapshot = server
.mock_async(|when, then| {
when.method(GET)
.path(path.as_str())
.header("authorization", "Bearer dtn_secret");
then.status(200)
.header("content-type", "application/json")
.json_body(snapshot_body(&computed_name));
})
.await;
let config = DaytonaConfig {
snapshot: Some(snapshot.clone()),
..DaytonaConfig::default()
};
let sandbox = mock_daytona_sandbox(&server, api_key, config).await;
sandbox
.ensure_snapshot(&computed_name, &snapshot)
.await
.expect("existing computed snapshot should be accepted");
get_snapshot.assert_async().await;
}
#[tokio::test]
async fn base_params_create_run_owned_non_ephemeral_sandbox() {
let sandbox = DaytonaSandbox::new(

View file

@ -40,10 +40,9 @@ pub fn daytona_config_from_environment(
.collect(),
snapshot: settings
.image
.reference
.dockerfile
.as_ref()
.map(|name| DaytonaSnapshotSettings {
name: name.clone(),
.map(|dockerfile| DaytonaSnapshotSettings {
cpu: settings.resources.cpu,
memory: settings
.resources
@ -53,18 +52,14 @@ pub fn daytona_config_from_environment(
.resources
.disk
.map(|size| size_to_gb_i32(size.as_bytes())),
dockerfile: settings
.image
.dockerfile
.as_ref()
.map(|dockerfile| match dockerfile {
ResolvedDockerfileSource::Inline(text) => {
SandboxDockerfileSource::Inline(text.clone())
}
ResolvedDockerfileSource::Path { path } => {
SandboxDockerfileSource::Path { path: path.clone() }
}
}),
dockerfile: Some(match dockerfile {
ResolvedDockerfileSource::Inline(text) => {
SandboxDockerfileSource::Inline(text.clone())
}
ResolvedDockerfileSource::Path { path } => {
SandboxDockerfileSource::Path { path: path.clone() }
}
}),
}),
network: Some(match settings.network.mode {
EnvironmentNetworkMode::Block => DaytonaNetwork::Block,
@ -94,7 +89,7 @@ pub fn docker_config_from_environment(
DockerSandboxOptions {
image: settings
.image
.reference
.docker
.clone()
.unwrap_or(default_options.image),
network_mode: match settings.network.mode {

View file

@ -192,6 +192,10 @@ macro_rules! delegate_sandbox {
self.$field.sandbox_info()
}
fn snapshot_info(&self) -> Option<String> {
self.$field.snapshot_info()
}
async fn refresh_push_credentials(&self) -> $crate::Result<()> {
self.$field.refresh_push_credentials().await
}
@ -948,6 +952,12 @@ pub trait Sandbox: Send + Sync {
String::new()
}
/// Return the provider snapshot used by an initialized sandbox, when the
/// provider has a snapshot concept.
fn snapshot_info(&self) -> Option<String> {
None
}
/// Refresh git push credentials (e.g. rotate an expiring GitHub App token).
/// Default is a no-op; Daytona overrides to update the remote URL with a
/// fresh token.

View file

@ -136,10 +136,7 @@ impl SandboxSpec {
RunSandbox {
provider: self.provider(),
image: None,
snapshot: config
.snapshot
.as_ref()
.map(|snapshot| snapshot.name.clone()),
snapshot: sandbox.snapshot_info(),
runtime: Some(RunSandboxRuntime {
id,
working_directory: working_directory.clone(),

View file

@ -1757,7 +1757,7 @@ mod runs {
let environment = EnvironmentSettings {
provider: EnvironmentProvider::Daytona,
image: EnvironmentImageSettings {
reference: Some("api-server-dev".into()),
docker: Some("api-server-dev".into()),
dockerfile: None,
},
resources: EnvironmentResourcesSettings {

View file

@ -1546,7 +1546,6 @@ id = "cloud"
provider = "daytona"
[environments.cloud.image]
ref = "fabro-test"
dockerfile = { path = "Dockerfile" }
"#
.to_string(),
@ -1606,7 +1605,6 @@ id = "cloud"
provider = "daytona"
[environments.cloud.image]
ref = "fabro-test"
dockerfile = { path = "Dockerfile" }
"#
.to_string(),

View file

@ -11624,6 +11624,8 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
provider: SandboxProviderKind::Local,
id: "sandbox-preserve-1".to_string(),
working_directory: "/tmp/fabro-preserved-sandbox".to_string(),
image: None,
snapshot: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
@ -12375,6 +12377,8 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
provider: SandboxProviderKind::Docker,
id: "missing-sandbox".to_string(),
working_directory: "/tmp/fabro-missing-sandbox".to_string(),
image: None,
snapshot: None,
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
@ -14269,6 +14273,8 @@ async fn list_runs_includes_live_metadata_from_run_state() {
provider: SandboxProviderKind::Local,
id: "sb-test".to_string(),
working_directory: "/sandbox/workdir".to_string(),
image: None,
snapshot: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
@ -14348,6 +14354,8 @@ async fn list_runs_page_limit_preserves_metadata_for_paged_items() {
provider: SandboxProviderKind::Local,
id: sandbox_id.to_string(),
working_directory: "/sandbox/workdir".to_string(),
image: None,
snapshot: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,

View file

@ -267,6 +267,8 @@ impl RunProjectionReducer for RunProjection {
runtime: None,
});
sandbox.provider = props.provider;
sandbox.image.clone_from(&props.image);
sandbox.snapshot.clone_from(&props.snapshot);
sandbox.runtime = Some(RunSandboxRuntime {
id: props.id.clone(),
working_directory: props.working_directory.clone(),
@ -800,12 +802,10 @@ fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox {
RunSandbox {
provider,
image: (settings.provider == EnvironmentProvider::Docker)
.then(|| settings.image.reference.clone())
.then(|| settings.image.docker.clone())
.flatten()
.filter(|image| !image.is_empty()),
snapshot: (settings.provider == EnvironmentProvider::Daytona)
.then(|| settings.image.reference.clone())
.flatten(),
snapshot: None,
runtime: None,
}
}
@ -1246,6 +1246,7 @@ mod tests {
RunCompletedProps, RunControlEffectProps, StageCompletedProps, StageFailedProps,
StagePromptProps, StageRetryingProps, StageStartedProps,
};
use fabro_types::settings::run::{DockerfileSource, EnvironmentProvider};
use fabro_types::{
AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint,
CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail,
@ -1371,6 +1372,50 @@ mod tests {
state
}
#[test]
fn planned_sandbox_uses_docker_image_and_hides_daytona_snapshot_until_init() {
let mut docker = WorkflowSettings::default().run.environment;
docker.provider = EnvironmentProvider::Docker;
docker.image.docker = Some("ubuntu:24.04".to_string());
let planned_docker = super::planned_sandbox(&docker);
assert_eq!(planned_docker.image.as_deref(), Some("ubuntu:24.04"));
assert_eq!(planned_docker.snapshot, None);
let mut daytona = WorkflowSettings::default().run.environment;
daytona.provider = EnvironmentProvider::Daytona;
daytona.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string()));
let planned_daytona = super::planned_sandbox(&daytona);
assert_eq!(planned_daytona.image, None);
assert_eq!(planned_daytona.snapshot, None);
}
#[test]
fn sandbox_initialized_updates_image_and_snapshot_projection_fields() {
let mut state = initialized_projection();
state
.apply_event(&test_raw_event(
1,
"sandbox.initialized",
&json!({
"provider": "daytona",
"id": "fabro-run-sandbox",
"working_directory": "/home/daytona/workspace",
"snapshot": "fabro-11111111-2222-8333-8444-555555555555"
}),
None,
))
.unwrap();
let sandbox = state.sandbox.expect("sandbox should be projected");
assert_eq!(sandbox.image, None);
assert_eq!(
sandbox.snapshot.as_deref(),
Some("fabro-11111111-2222-8333-8444-555555555555")
);
}
#[test]
fn legacy_run_created_projects_retried_from_none() {
let event = test_raw_event(

View file

@ -332,6 +332,10 @@ pub struct SandboxInitializedProps {
pub provider: SandboxProviderKind,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_origin_url: Option<String>,

View file

@ -755,8 +755,7 @@ pub enum EnvironmentNetworkMode {
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EnvironmentImageSettings {
#[serde(rename = "ref")]
pub reference: Option<String>,
pub docker: Option<String>,
pub dockerfile: Option<DockerfileSource>,
}

View file

@ -4,23 +4,14 @@ use fabro_agent::sandbox::Sandbox;
use fabro_devcontainer::DevcontainerSpec;
use fabro_sandbox::daytona::{DaytonaSnapshotConfig, DockerfileSource};
use futures::future::try_join_all;
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
use crate::error::Error;
use crate::event::{Emitter, Event};
/// Compute a deterministic snapshot name from Dockerfile content.
pub fn snapshot_name_for_dockerfile(dockerfile: &str) -> String {
let hash = Sha256::digest(dockerfile.as_bytes());
let hex = hex::encode(hash);
format!("devcontainer-{}", &hex[..12])
}
/// Map a `DevcontainerSpec` to a `DaytonaSnapshotConfig`.
pub fn devcontainer_to_snapshot_config(dc: &DevcontainerSpec) -> DaytonaSnapshotConfig {
DaytonaSnapshotConfig {
name: snapshot_name_for_dockerfile(&dc.dockerfile),
dockerfile: Some(DockerfileSource::Inline(dc.dockerfile.clone())),
cpu: None,
memory: None,
@ -379,29 +370,6 @@ mod tests {
}
}
#[test]
fn snapshot_name_is_deterministic() {
let dockerfile = "FROM ubuntu:22.04\nRUN apt-get update";
let name1 = snapshot_name_for_dockerfile(dockerfile);
let name2 = snapshot_name_for_dockerfile(dockerfile);
assert_eq!(name1, name2);
}
#[test]
fn snapshot_name_differs_for_different_dockerfiles() {
let name1 = snapshot_name_for_dockerfile("FROM ubuntu:22.04");
let name2 = snapshot_name_for_dockerfile("FROM rust:1.85");
assert_ne!(name1, name2);
}
#[test]
fn snapshot_name_has_prefix() {
let name = snapshot_name_for_dockerfile("FROM ubuntu:22.04");
assert!(name.starts_with("devcontainer-"), "name: {name}");
// prefix + 12 hex chars
assert_eq!(name.len(), "devcontainer-".len() + 12);
}
#[test]
fn maps_dockerfile_to_inline() {
let dc = test_devcontainer_config("FROM rust:1.85\nRUN cargo install sccache");
@ -413,11 +381,12 @@ mod tests {
}
#[test]
fn snapshot_name_from_dockerfile_hash() {
fn devcontainer_snapshot_uses_runtime_daytona_identity_path() {
let dc = test_devcontainer_config("FROM ubuntu:22.04");
let snapshot = devcontainer_to_snapshot_config(&dc);
let expected = snapshot_name_for_dockerfile(&dc.dockerfile);
assert_eq!(snapshot.name, expected);
assert_eq!(snapshot.cpu, None);
assert_eq!(snapshot.memory, None);
assert_eq!(snapshot.disk, None);
}
#[tokio::test]

View file

@ -1015,6 +1015,8 @@ fn event_body_from_event(event: &Event) -> EventBody {
working_directory,
provider,
id,
image,
snapshot,
repo_cloned,
clone_origin_url,
clone_branch,
@ -1026,6 +1028,8 @@ fn event_body_from_event(event: &Event) -> EventBody {
working_directory: working_directory.clone(),
provider: *provider,
id: id.clone(),
image: image.clone(),
snapshot: snapshot.clone(),
repo_cloned: *repo_cloned,
clone_origin_url: clone_origin_url.clone(),
clone_branch: clone_branch.clone(),

View file

@ -509,6 +509,10 @@ pub enum Event {
provider: SandboxProviderKind,
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
snapshot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
clone_origin_url: Option<String>,

View file

@ -304,6 +304,8 @@ mod tests {
provider: fabro_types::SandboxProviderKind::Local,
id: "sandbox-source".to_string(),
working_directory: "/tmp/source".to_string(),
image: None,
snapshot: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,

View file

@ -1302,7 +1302,7 @@ reasoning = false
let settings = settings_from_run_layer(RunLayer {
environment: Some(RunEnvironmentLayer {
image: Some(EnvironmentImageLayer {
reference: Some("ubuntu:24.04".to_string()),
docker: Some("ubuntu:24.04".to_string()),
..EnvironmentImageLayer::default()
}),
resources: Some(EnvironmentResourcesLayer {

View file

@ -519,6 +519,8 @@ pub async fn initialize(
working_directory: runtime.working_directory.clone(),
provider: run_sandbox.provider,
id: runtime.id.clone(),
image: run_sandbox.image.clone(),
snapshot: run_sandbox.snapshot.clone(),
repo_cloned: runtime.repo_cloned,
clone_origin_url: runtime.clone_origin_url.clone(),
clone_branch: runtime.clone_branch.clone(),

View file

@ -24,7 +24,7 @@ use std::sync::Arc;
use fabro_agent::Sandbox;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig};
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox};
use fabro_static::EnvVars;
use fabro_store::{ArtifactKey, ArtifactStore, Database};
use fabro_types::{RunId, StageId, WorkflowSettings};
@ -368,7 +368,6 @@ async fn daytona_snapshot_sandbox() {
let config = DaytonaConfig {
auto_stop_interval: Some(60),
snapshot: Some(DaytonaSnapshotConfig {
name: "fabro-test-snapshot".to_string(),
cpu: Some(2),
memory: Some(4),
disk: Some(10),
@ -1804,13 +1803,7 @@ async fn daytona_cp_upload_download_round_trip() {
async fn daytona_computer_use_browser_screenshot() {
use base64::Engine;
let config = DaytonaConfig {
snapshot: Some(DaytonaSnapshotConfig {
name: "daytona-medium".into(),
cpu: None,
memory: None,
disk: None,
dockerfile: None,
}),
snapshot: None,
skip_clone: true,
..DaytonaConfig::default()
};
@ -1958,13 +1951,7 @@ async fn daytona_playwright_mcp_sandbox_transport() {
// Create sandbox from daytona-medium (has Node.js + Chromium)
let config = DaytonaConfig {
snapshot: Some(DaytonaSnapshotConfig {
name: "daytona-medium".into(),
cpu: None,
memory: None,
disk: None,
dockerfile: None,
}),
snapshot: None,
skip_clone: true,
..DaytonaConfig::default()
};

View file

@ -1,28 +0,0 @@
/* 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 { DaytonaNetworkLayer } from './daytona-network-layer';
// May contain unused imports in some cases
// @ts-ignore
import type { DaytonaSnapshotSettings } from './daytona-snapshot-settings';
export interface DaytonaSettings {
'auto_stop_interval': number | null;
'labels': { [key: string]: string; };
'snapshot': DaytonaSnapshotSettings | null;
'network': DaytonaNetworkLayer | null;
}

View file

@ -1,26 +0,0 @@
/* 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 { DockerfileSource } from './dockerfile-source';
export interface DaytonaSnapshotSettings {
'name': string;
'cpu': number | null;
'memory_gb': number | null;
'disk_gb': number | null;
'dockerfile': DockerfileSource | null;
}

View file

@ -18,6 +18,6 @@
import type { DockerfileSource } from './dockerfile-source';
export interface EnvironmentImageSettings {
'ref': string | null;
'docker': string | null;
'dockerfile': DockerfileSource | null;
}

View file

@ -1,34 +0,0 @@
/* 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 { DaytonaSettings } from './daytona-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { DockerSettings } from './docker-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxProvider } from './sandbox-provider';
export interface RunSandboxSettings {
'provider': SandboxProvider;
'preserve': boolean;
'stop_on_terminal': boolean;
'devcontainer': boolean;
'env': { [key: string]: string; };
'docker': DockerSettings | null;
'daytona': DaytonaSettings | null;
}