fabro/lib/crates/fabro-workflow/src/devcontainer_bridge.rs
Bryan Helmkamp ec1b3f2084
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)
2026-05-27 11:52:35 -04:00

640 lines
22 KiB
Rust

use std::time::Instant;
use fabro_agent::sandbox::Sandbox;
use fabro_devcontainer::DevcontainerSpec;
use fabro_sandbox::daytona::{DaytonaSnapshotConfig, DockerfileSource};
use futures::future::try_join_all;
use tokio_util::sync::CancellationToken;
use crate::error::Error;
use crate::event::{Emitter, Event};
/// Map a `DevcontainerSpec` to a `DaytonaSnapshotConfig`.
pub fn devcontainer_to_snapshot_config(dc: &DevcontainerSpec) -> DaytonaSnapshotConfig {
DaytonaSnapshotConfig {
dockerfile: Some(DockerfileSource::Inline(dc.dockerfile.clone())),
cpu: None,
memory: None,
disk: None,
}
}
/// Run a set of devcontainer lifecycle commands inside a sandbox.
///
/// Follows the same pattern as setup commands in `run.rs`.
pub async fn run_devcontainer_lifecycle(
sandbox: &dyn Sandbox,
emitter: &Emitter,
phase: &str,
commands: &[fabro_devcontainer::Command],
timeout_ms: u64,
cancel_token: CancellationToken,
) -> Result<(), Error> {
if commands.is_empty() {
return Ok(());
}
emitter.emit(&Event::DevcontainerLifecycleStarted {
phase: phase.to_string(),
command_count: commands.len(),
});
let phase_start = Instant::now();
for (index, cmd) in commands.iter().enumerate() {
match cmd {
fabro_devcontainer::Command::Shell(s) => {
run_single_lifecycle_command(
sandbox,
emitter,
phase,
&format!("sh -c {}", shlex::try_quote(s).unwrap_or_else(|_| s.into())),
index,
timeout_ms,
cancel_token.clone(),
)
.await?;
}
fabro_devcontainer::Command::Args(args) => {
let joined = args
.iter()
.map(|a| shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string())
.collect::<Vec<_>>()
.join(" ");
run_single_lifecycle_command(
sandbox,
emitter,
phase,
&joined,
index,
timeout_ms,
cancel_token.clone(),
)
.await?;
}
fabro_devcontainer::Command::Parallel(map) => {
let futs: Vec<_> = map
.iter()
.map(|(name, cmd_str)| {
let command = format!(
"sh -c {}",
shlex::try_quote(cmd_str).unwrap_or_else(|_| cmd_str.into())
);
let phase = phase.to_string();
let name = name.clone();
let cancel_token = cancel_token.clone();
async move {
let cmd_start = Instant::now();
emitter.emit(&Event::DevcontainerLifecycleCommandStarted {
phase: phase.clone(),
command: name.clone(),
index,
});
let child_token = cancel_token.child_token();
let result = sandbox
.exec_command(
&command,
timeout_ms,
None,
None,
Some(child_token.clone()),
)
.await
.map_err(|e| {
Error::engine(format!(
"Devcontainer {phase} parallel command '{name}' failed: {e}"
))
})?;
if cancel_token.is_cancelled() {
return Err(Error::Cancelled);
}
child_token.cancel();
let cmd_duration = crate::millis_u64(cmd_start.elapsed());
if !result.is_success() {
let exit_code = result.display_exit_code();
let exec_output_tail = result.default_redacted_output_tail();
emitter.emit(
&Event::DevcontainerLifecycleFailed {
phase: phase.clone(),
command: name.clone(),
index,
exit_code,
stderr: result.stderr.clone(),
exec_output_tail,
},
);
return Err(Error::engine(format!(
"Devcontainer {phase} parallel command '{name}' failed (exit code {}): {}",
exit_code,
result.stderr,
)));
}
let exit_code = result.exit_code.unwrap_or(0);
emitter.emit(
&Event::DevcontainerLifecycleCommandCompleted {
phase: phase.clone(),
command: name.clone(),
index,
exit_code,
duration_ms: cmd_duration,
},
);
Ok(())
}
})
.collect();
try_join_all(futs).await?;
}
}
}
let phase_duration = crate::millis_u64(phase_start.elapsed());
emitter.emit(&Event::DevcontainerLifecycleCompleted {
phase: phase.to_string(),
duration_ms: phase_duration,
});
Ok(())
}
async fn run_single_lifecycle_command(
sandbox: &dyn Sandbox,
emitter: &Emitter,
phase: &str,
command: &str,
index: usize,
timeout_ms: u64,
cancel_token: CancellationToken,
) -> Result<(), Error> {
emitter.emit(&Event::DevcontainerLifecycleCommandStarted {
phase: phase.to_string(),
command: command.to_string(),
index,
});
let cmd_start = Instant::now();
let child_token = cancel_token.child_token();
let result = sandbox
.exec_command(command, timeout_ms, None, None, Some(child_token.clone()))
.await
.map_err(|e| {
Error::engine_with_source(format!("Devcontainer {phase} command failed"), e)
})?;
if cancel_token.is_cancelled() {
return Err(Error::Cancelled);
}
child_token.cancel();
let cmd_duration = crate::millis_u64(cmd_start.elapsed());
if !result.is_success() {
let exit_code = result.display_exit_code();
let exec_output_tail = result.default_redacted_output_tail();
emitter.emit(&Event::DevcontainerLifecycleFailed {
phase: phase.to_string(),
command: command.to_string(),
index,
exit_code,
stderr: result.stderr.clone(),
exec_output_tail,
});
return Err(Error::engine(format!(
"Devcontainer {phase} command failed (exit code {}): {command}\n{}",
exit_code, result.stderr,
)));
}
let exit_code = result.exit_code.unwrap_or(0);
emitter.emit(&Event::DevcontainerLifecycleCommandCompleted {
phase: phase.to_string(),
command: command.to_string(),
index,
exit_code,
duration_ms: cmd_duration,
});
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_agent::sandbox::{ExecResult, GrepOptions, Sandbox};
use fabro_types::{CommandTermination, EventBody};
use tokio_util::sync::CancellationToken;
use super::*;
/// Simple test sandbox that records commands and returns a fixed exit code.
struct TestSandbox {
commands: Mutex<Vec<String>>,
cancel_tokens: Mutex<Vec<bool>>,
exit_code: i32,
wait_for_cancel: bool,
}
impl TestSandbox {
fn new() -> Self {
Self {
commands: Mutex::new(Vec::new()),
cancel_tokens: Mutex::new(Vec::new()),
exit_code: 0,
wait_for_cancel: false,
}
}
fn with_exit_code(exit_code: i32) -> Self {
Self {
commands: Mutex::new(Vec::new()),
cancel_tokens: Mutex::new(Vec::new()),
exit_code,
wait_for_cancel: false,
}
}
fn waiting_for_cancel() -> Self {
Self {
commands: Mutex::new(Vec::new()),
cancel_tokens: Mutex::new(Vec::new()),
exit_code: 0,
wait_for_cancel: true,
}
}
fn captured_commands(&self) -> Vec<String> {
self.commands.lock().unwrap().clone()
}
fn captured_cancel_tokens(&self) -> Vec<bool> {
self.cancel_tokens.lock().unwrap().clone()
}
}
#[async_trait]
impl Sandbox for TestSandbox {
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
Ok(Vec::new())
}
async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> fabro_sandbox::Result<Vec<fabro_agent::sandbox::DirEntry>> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> fabro_sandbox::Result<ExecResult> {
self.commands.lock().unwrap().push(command.to_string());
self.cancel_tokens
.lock()
.unwrap()
.push(cancel_token.is_some());
if self.wait_for_cancel {
let token = cancel_token
.ok_or_else(|| fabro_sandbox::Error::message("missing cancel token"))?;
token.cancelled().await;
return Ok(ExecResult {
stdout: String::new(),
stderr: "cancelled".to_string(),
exit_code: None,
termination: CommandTermination::Cancelled,
duration_ms: 10,
});
}
Ok(ExecResult {
stdout: String::new(),
stderr: if self.exit_code != 0 {
"command failed".to_string()
} else {
String::new()
},
exit_code: Some(self.exit_code),
termination: CommandTermination::Exited,
duration_ms: 10,
})
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Ok(vec![])
}
async fn download_file_to_local(
&self,
_remote_path: &str,
_local_path: &std::path::Path,
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {
"/work"
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Linux 6.1.0".into()
}
}
#[test]
fn maps_dockerfile_to_inline() {
let dc = test_devcontainer_config("FROM rust:1.85\nRUN cargo install sccache");
let snapshot = devcontainer_to_snapshot_config(&dc);
assert_eq!(
snapshot.dockerfile,
Some(DockerfileSource::Inline(dc.dockerfile.clone()))
);
}
#[test]
fn devcontainer_snapshot_uses_runtime_daytona_identity_path() {
let dc = test_devcontainer_config("FROM ubuntu:22.04");
let snapshot = devcontainer_to_snapshot_config(&dc);
assert_eq!(snapshot.cpu, None);
assert_eq!(snapshot.memory, None);
assert_eq!(snapshot.disk, None);
}
#[tokio::test]
async fn shell_command_executed() {
let sandbox = TestSandbox::new();
let emitter = Emitter::default();
let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())];
run_devcontainer_lifecycle(
&sandbox,
&emitter,
"on_create",
&commands,
300_000,
CancellationToken::new(),
)
.await
.unwrap();
let captured = sandbox.captured_commands();
assert_eq!(captured.len(), 1);
assert!(captured[0].contains("echo hi"), "command: {}", captured[0]);
}
#[tokio::test]
async fn args_command_joins() {
let sandbox = TestSandbox::new();
let emitter = Emitter::default();
let commands = vec![fabro_devcontainer::Command::Args(vec![
"echo".to_string(),
"hi".to_string(),
])];
run_devcontainer_lifecycle(
&sandbox,
&emitter,
"on_create",
&commands,
300_000,
CancellationToken::new(),
)
.await
.unwrap();
let captured = sandbox.captured_commands();
assert_eq!(captured.len(), 1);
assert!(
captured[0].contains("echo") && captured[0].contains("hi"),
"command: {}",
captured[0]
);
}
#[tokio::test]
async fn emits_started_and_completed_events() {
let emitter = Emitter::default();
let events = Arc::new(Mutex::new(Vec::<fabro_types::RunEvent>::new()));
let events_clone = Arc::clone(&events);
emitter.on_event(move |event| {
events_clone.lock().unwrap().push(event.clone());
});
let sandbox = TestSandbox::new();
let commands = vec![fabro_devcontainer::Command::Shell("echo hi".to_string())];
run_devcontainer_lifecycle(
&sandbox,
&emitter,
"on_create",
&commands,
300_000,
CancellationToken::new(),
)
.await
.unwrap();
let events = events.lock().unwrap();
let started = events[0].properties().unwrap();
assert_eq!(events[0].event_name(), "devcontainer.lifecycle.started");
assert_eq!(started["phase"], "on_create");
assert_eq!(started["command_count"], 1);
assert_eq!(
events[1].event_name(),
"devcontainer.lifecycle.command.started"
);
let command_started = events[1].properties().unwrap();
assert_eq!(command_started["phase"], "on_create");
assert_eq!(command_started["index"], 0);
assert_eq!(
events[2].event_name(),
"devcontainer.lifecycle.command.completed"
);
let command_completed = events[2].properties().unwrap();
assert_eq!(command_completed["phase"], "on_create");
assert_eq!(command_completed["index"], 0);
assert_eq!(command_completed["exit_code"], 0);
assert_eq!(events[3].event_name(), "devcontainer.lifecycle.completed");
assert_eq!(events[3].properties().unwrap()["phase"], "on_create");
}
#[tokio::test]
async fn failed_command_emits_failed_and_returns_error() {
let emitter = Emitter::default();
let events = Arc::new(Mutex::new(Vec::<fabro_types::RunEvent>::new()));
let events_clone = Arc::clone(&events);
emitter.on_event(move |event| {
events_clone.lock().unwrap().push(event.clone());
});
let sandbox = TestSandbox::with_exit_code(1);
let commands = vec![fabro_devcontainer::Command::Shell("false".to_string())];
let result = run_devcontainer_lifecycle(
&sandbox,
&emitter,
"on_create",
&commands,
300_000,
CancellationToken::new(),
)
.await;
assert!(result.is_err());
let events = events.lock().unwrap();
let failed = events
.iter()
.find(|event| event.event_name() == "devcontainer.lifecycle.failed")
.expect("devcontainer lifecycle failed event");
match &failed.body {
EventBody::DevcontainerLifecycleFailed(props) => {
assert_eq!(props.phase, "on_create");
assert_eq!(props.exit_code, 1);
assert_eq!(props.stderr, "command failed");
assert_eq!(
props
.exec_output_tail
.as_ref()
.and_then(|tail| tail.stderr.as_deref()),
Some("command failed")
);
}
other => panic!("expected devcontainer lifecycle failed body, got {other:?}"),
}
}
#[tokio::test]
async fn empty_commands_is_noop() {
let emitter = Emitter::default();
let events = Arc::new(Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
emitter.on_event(move |event| {
events_clone.lock().unwrap().push(event.clone());
});
let sandbox = TestSandbox::new();
run_devcontainer_lifecycle(
&sandbox,
&emitter,
"on_create",
&[],
300_000,
CancellationToken::new(),
)
.await
.unwrap();
assert!(events.lock().unwrap().is_empty());
}
#[tokio::test]
async fn parallel_commands_run() {
let sandbox = TestSandbox::new();
let emitter = Emitter::default();
let mut map = HashMap::new();
map.insert("install".to_string(), "npm install".to_string());
map.insert("build".to_string(), "npm run build".to_string());
let commands = vec![fabro_devcontainer::Command::Parallel(map)];
run_devcontainer_lifecycle(
&sandbox,
&emitter,
"post_create",
&commands,
300_000,
CancellationToken::new(),
)
.await
.unwrap();
let captured = sandbox.captured_commands();
assert_eq!(captured.len(), 2);
}
#[tokio::test]
async fn cancelled_shell_command_returns_cancelled() {
let sandbox = TestSandbox::waiting_for_cancel();
let emitter = Emitter::default();
let commands = vec![fabro_devcontainer::Command::Shell("sleep 5".to_string())];
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let result = run_devcontainer_lifecycle(
&sandbox,
&emitter,
"on_create",
&commands,
300_000,
cancel_token,
)
.await;
assert!(matches!(result, Err(Error::Cancelled)));
assert_eq!(sandbox.captured_cancel_tokens(), vec![true]);
}
#[tokio::test]
async fn cancelled_parallel_command_returns_cancelled() {
let sandbox = TestSandbox::waiting_for_cancel();
let emitter = Emitter::default();
let mut map = HashMap::new();
map.insert("install".to_string(), "sleep 5".to_string());
map.insert("build".to_string(), "sleep 5".to_string());
let commands = vec![fabro_devcontainer::Command::Parallel(map)];
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let result = run_devcontainer_lifecycle(
&sandbox,
&emitter,
"post_create",
&commands,
300_000,
cancel_token,
)
.await;
assert!(matches!(result, Err(Error::Cancelled)));
let captured = sandbox.captured_cancel_tokens();
assert!(!captured.is_empty());
assert!(captured.iter().all(|saw_token| *saw_token));
}
fn test_devcontainer_config(dockerfile: &str) -> DevcontainerSpec {
DevcontainerSpec {
dockerfile: dockerfile.to_string(),
build_context: std::path::PathBuf::from("."),
build_args: HashMap::new(),
build_target: None,
initialize_commands: vec![],
on_create_commands: vec![],
post_create_commands: vec![],
post_start_commands: vec![],
environment: HashMap::new(),
container_env: HashMap::new(),
remote_user: None,
workspace_folder: "/workspaces/test".to_string(),
forwarded_ports: vec![],
compose_files: vec![],
compose_service: None,
}
}
}